feat: 付费体系重构 P0 - 配额独立化/简历付费下载/PDF生成

This commit is contained in:
yuzhiran
2026-06-12 09:31:11 +08:00
parent 5d407b4f79
commit 065fe7a186
23 changed files with 965 additions and 106 deletions
@@ -0,0 +1,81 @@
import * as crypto from 'crypto'
import { Injectable, Logger } from '@nestjs/common'
@Injectable()
export class ResumePdfService {
private readonly logger = new Logger(ResumePdfService.name)
async generatePdf(params: {
title: string
content: string
targetPosition?: string
userName?: string
}): Promise<Buffer> {
const { default: puppeteer } = await import('puppeteer')
const html = this.buildHtml(params)
const browser = await puppeteer.launch({
executablePath: '/root/.cache/puppeteer/chrome/linux-149.0.7827.22/chrome-linux64/chrome',
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
})
try {
const page = await browser.newPage()
await page.setContent(html, { waitUntil: 'load' })
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
})
return Buffer.from(pdf)
} finally {
await browser.close()
}
}
private buildHtml(params: {
title: string
content: string
targetPosition?: string
userName?: string
}): string {
const contentHtml = params.content
.replace(/\n/g, '<br>')
.replace(/### (.+)/g, '<h3>$1</h3>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<style>
@page { size: A4; margin: 0; }
body {
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 12pt; line-height: 1.6; color: #333; padding: 0; margin: 0;
}
.page { padding: 40px 30px; max-width: 210mm; margin: 0 auto; }
h1 { font-size: 22pt; color: #1a1a1a; margin-bottom: 4px; }
.subtitle { color: #666; font-size: 10pt; margin-bottom: 20px; }
h2 { font-size: 14pt; color: #2c6b9e; border-bottom: 2px solid #2c6b9e; padding-bottom: 4px; margin-top: 20px; }
h3 { font-size: 12pt; color: #333; margin-top: 12px; margin-bottom: 4px; }
strong { color: #1a1a1a; }
p { margin: 6px 0; }
ul { margin: 4px 0; padding-left: 20px; }
li { margin: 2px 0; }
.footer { margin-top: 30px; font-size: 9pt; color: #999; text-align: center; border-top: 1px solid #eee; padding-top: 10px; }
</style>
</head>
<body>
<div class="page">
<h1>${params.title}</h1>
<div class="subtitle">${params.targetPosition ? `目标岗位: ${params.targetPosition}` : ''}</div>
<div class="content">${contentHtml}</div>
<div class="footer">由 AI磁场·职引 生成</div>
</div>
</body>
</html>`
}
computeHash(content: string): string {
return crypto.createHash('md5').update(content).digest('hex')
}
}
@@ -1,10 +1,17 @@
import { Controller, Post, Get, Delete, Param, Body } from '@nestjs/common'
import { Controller, Post, Get, Delete, Param, Body, Res, HttpException, HttpStatus } from '@nestjs/common'
import { Response } from 'express'
import { ResumeService } from './resume.service'
import { ResumePdfService } from './resume-pdf.service'
import { QuotaService } from '../user/quota.service'
import { CurrentUser } from '../../common/decorators/current-user.decorator'
@Controller('resume')
export class ResumeController {
constructor(private resumeService: ResumeService) {}
constructor(
private resumeService: ResumeService,
private resumePdfService: ResumePdfService,
private quotaService: QuotaService,
) {}
@Post('create')
async create(
@@ -21,6 +28,31 @@ export class ResumeController {
return this.resumeService.list(userId)
}
@Post(':id/download')
async download(@Param('id') id: string, @CurrentUser('userId') userId: string, @Res() res: Response) {
const resume = await this.resumeService.getDetail(id, userId)
const canDownload = await this.quotaService.checkDownload(userId, resume)
if (!canDownload) {
throw new HttpException('请先付费下载', HttpStatus.PAYMENT_REQUIRED)
}
await this.quotaService.deductDownload(userId, resume)
if (!resume.paidDownload) {
await this.resumeService.markPaid(id, userId)
}
const pdf = await this.resumePdfService.generatePdf({
title: resume.title,
content: resume.content,
targetPosition: resume.targetPosition,
})
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(resume.title)}.pdf"`)
res.send(pdf)
}
@Get(':id')
async getDetail(@Param('id') id: string, @CurrentUser('userId') userId: string) {
return this.resumeService.getDetail(id, userId)
+8 -2
View File
@@ -2,11 +2,17 @@ import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { ResumeController } from './resume.controller'
import { ResumeService } from './resume.service'
import { ResumePdfService } from './resume-pdf.service'
import { Resume, ResumeSchema } from './resume.schema'
import { UserModule } from '../user/user.module'
@Module({
imports: [MongooseModule.forFeature([{ name: Resume.name, schema: ResumeSchema }])],
imports: [
MongooseModule.forFeature([{ name: Resume.name, schema: ResumeSchema }]),
UserModule,
],
controllers: [ResumeController],
providers: [ResumeService],
providers: [ResumeService, ResumePdfService],
exports: [ResumeService],
})
export class ResumeModule {}
@@ -17,6 +17,15 @@ export class Resume {
@Prop({ default: '' })
targetPosition: string
@Prop({ default: 1 })
version: number
@Prop({ default: '' })
contentHash: string
@Prop({ default: false })
paidDownload: boolean
readonly createdAt?: Date
readonly updatedAt?: Date
}
+30 -1
View File
@@ -1,3 +1,4 @@
import * as crypto from 'crypto'
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
@@ -8,7 +9,8 @@ export class ResumeService {
constructor(@InjectModel(Resume.name) private resumeModel: Model<ResumeDocument>) {}
async create(userId: string, title: string, content: string, targetPosition?: string) {
const resume = await this.resumeModel.create({ userId, title, content, targetPosition })
const contentHash = crypto.createHash('md5').update(content).digest('hex')
const resume = await this.resumeModel.create({ userId, title, content, targetPosition, contentHash, version: 1 })
return resume.toObject()
}
@@ -18,6 +20,9 @@ export class ResumeService {
id: r._id.toString(),
title: r.title,
targetPosition: r.targetPosition,
version: r.version,
contentHash: r.contentHash,
paidDownload: r.paidDownload,
createdAt: r.createdAt,
}))
}
@@ -28,6 +33,30 @@ export class ResumeService {
return resume.toObject()
}
/** AI 优化后更新内容,若内容变化则新建版本 */
async updateAfterOptimize(resumeId: string, userId: string, newContent: string, targetPosition?: string) {
const resume = await this.resumeModel.findOne({ _id: resumeId, userId }).exec()
if (!resume) throw new HttpException('简历不存在', HttpStatus.NOT_FOUND)
const newHash = crypto.createHash('md5').update(newContent).digest('hex')
if (newHash === resume.contentHash) return resume.toObject()
// Same user, increment version as new record
const created = await this.resumeModel.create({
userId,
title: resume.title,
content: newContent,
targetPosition: targetPosition || resume.targetPosition,
contentHash: newHash,
version: (resume.version || 1) + 1,
})
return created.toObject()
}
async markPaid(resumeId: string, userId: string) {
await this.resumeModel.updateOne({ _id: resumeId, userId }, { paidDownload: true }).exec()
}
async delete(resumeId: string, userId: string) {
const res = await this.resumeModel.deleteOne({ _id: resumeId, userId }).exec()
if (res.deletedCount === 0) throw new HttpException('简历不存在', HttpStatus.NOT_FOUND)