feat: 付费体系重构 P0 - 配额独立化/简历付费下载/PDF生成
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user