From 8191cf4b416c08cc9ffc008db83b0daf13e1a535 Mon Sep 17 00:00:00 2001 From: yuzhiran Date: Fri, 12 Jun 2026 15:32:04 +0800 Subject: [PATCH] feat: realistic face avatar + voice input + ASR endpoint --- AGENTS.md | 56 ++ backend/src/app.module.ts | 4 +- backend/src/modules/admin/admin.controller.ts | 173 ++++++- backend/src/modules/admin/admin.module.ts | 5 + .../src/modules/payment/payment.controller.ts | 11 + .../src/modules/resume/resume-pdf.service.ts | 11 +- .../src/modules/resume/resume.controller.ts | 7 +- backend/src/modules/share/share.controller.ts | 69 +++ backend/src/modules/share/share.module.ts | 26 + backend/src/modules/share/share.schema.ts | 72 +++ backend/src/modules/share/share.service.ts | 186 +++++++ backend/src/modules/tts/tts.controller.ts | 40 +- backend/src/modules/tts/tts.service.ts | 13 + backend/src/modules/user/quota.service.ts | 137 +++-- backend/src/modules/user/user.schema.ts | 3 + backend/src/modules/user/user.service.ts | 1 + docs/SHARE-FEATURE.md | 253 +++++++++ zhiyin-app/src/components/digital-human.vue | 481 +++++++++++++----- zhiyin-app/src/config.ts | 16 +- zhiyin-app/src/manifest.json | 4 +- zhiyin-app/src/pages.json | 3 +- zhiyin-app/src/pages/admin/admin.vue | 246 ++++++++- zhiyin-app/src/pages/interview/interview.vue | 58 ++- zhiyin-app/src/pages/share/share.vue | 273 ++++++++++ zhiyin-app/src/pages/user/user.vue | 7 + zhiyin-app/src/services/api.ts | 7 + 26 files changed, 1934 insertions(+), 228 deletions(-) create mode 100644 AGENTS.md create mode 100644 backend/src/modules/share/share.controller.ts create mode 100644 backend/src/modules/share/share.module.ts create mode 100644 backend/src/modules/share/share.schema.ts create mode 100644 backend/src/modules/share/share.service.ts create mode 100644 docs/SHARE-FEATURE.md create mode 100644 zhiyin-app/src/pages/share/share.vue diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c68263b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +## 开发交付流程 + +每次实施/开发完功能后,必须按以下步骤执行: + +### Step 1: 代码评审 +- 检查变更是否符合现有代码模式(schema、service、controller、module 的结构一致性) +- 检查是否有未使用的变量、import、参数 +- 检查命名规范是否与项目一致 +- 检查是否有重复代码或可复用逻辑 + +### Step 2: 安全评审 +- **注入防护**:检查所有外部输入(请求 body、query、params)是否有注入风险(HTML、Shell、NoSQL) +- **认证/授权**:检查端点是否正确地加了 JWT guard 或 `@Public()`;管理端接口是否有 AdminGuard +- **并发安全**:检查所有修改用户额度/积分的操作是否使用原子操作(`findOneAndUpdate + $inc`),而非 read-modify-write +- **敏感信息**:检查是否误将密钥、凭据、token 暴露到响应或日志中 +- **XSS/CSRF**:检查用户内容输出到 HTML/PDF 时是否做了转义 + +### Step 3: 性能优化 +- 检查是否有 N+1 查询(循环内查数据库),应使用批量查询或聚合 +- 检查大表查询是否有索引覆盖 +- 检查是否有不必要的 `.lean()` 缺失(读操作用 `exec()` 但不需要 mongoose document 方法时应加 `.lean()`) +- 检查是否有内存泄漏风险(如 puppeteer browser 未在 finally 中 close) +- Throttler/限流是否合理 + +### Step 4: 完整测试 +```bash +# 构建检查 +cd /root/opencode-workspace/zhiyin/backend && npx nest build 2>&1 + +# 单元测试 +npm test -- --forceExit --detectOpenHandles 2>&1 + +# 如果有变更的模块,验证关键 endpoint curl 可访问 +``` + +### Step 5: 同步修复 +- 上述步骤发现的问题必须修复后再发布 +- 修复后重新执行 Step 4 验证构建通过 + +### Step 6: 部署到生产 +```bash +# 构建 +cd /root/opencode-workspace/zhiyin/backend && npx nest build + +# 同步到生产目录 +cp -rf dist/* /www/wwwroot/server/yzr-yhl/backend/dist/ + +# 复制证书(postbuild 替代) +cp -r certs /www/wwwroot/server/yzr-yhl/backend/dist/src/certs + +# 重启 +pm2 restart yhl-backend + +# 验证 +sleep 3 && curl -s http://localhost:3002/api/share/visit/test?visitorId=v +``` diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index be63a0d..b6e4afd 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -25,6 +25,7 @@ import { DailyQuestionModule } from './modules/daily-question/daily-question.mod import { ScheduleModule } from './modules/schedule/schedule.module' import { TtsModule } from './modules/tts/tts.module' import { PricingModule } from './modules/schemas/pricing.module' +import { ShareModule } from './modules/share/share.module' const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin' @@ -38,7 +39,7 @@ const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin }), ThrottlerModule.forRoot([{ ttl: 60000, - limit: 10, + limit: 100, }]), NestScheduleModule.forRoot(), UserModule, @@ -58,6 +59,7 @@ const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin ScheduleModule, TtsModule, PricingModule, + ShareModule, ], providers: [ JwtStrategy, diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index 0f9885c..031baac 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -6,9 +6,12 @@ import { AdminGuard } from '../../common/guards/admin.guard' import { CurrentUser } from '../../common/decorators/current-user.decorator' import { User, UserDocument } from '../user/user.schema' import { Interview, InterviewDocument } from '../interview/interview.schema' +import { Resume, ResumeDocument } from '../resume/resume.schema' import { PaymentOrder, PaymentOrderDocument } from '../payment/payment-order.schema' import { SiteConfig, SiteConfigDocument } from '../schemas/site-config.schema' +import { ShareRecord, ShareRecordDocument, ShareVisit, ShareVisitDocument } from '../share/share.schema' import { QuotaService } from '../user/quota.service' +import { PricingService } from '../schemas/pricing.service' import { WechatPayService } from '../payment/wechat-pay.service' const VIP_DURATION_DAYS = 30 @@ -21,7 +24,11 @@ export class AdminController { @InjectModel(Interview.name) private interviewModel: Model, @InjectModel(PaymentOrder.name) private orderModel: Model, @InjectModel(SiteConfig.name) private configModel: Model, + @InjectModel(ShareRecord.name) private shareModel: Model, + @InjectModel(ShareVisit.name) private shareVisitModel: Model, + @InjectModel(Resume.name) private resumeModel: Model, private quotaService: QuotaService, + private pricingService: PricingService, private wechatPay: WechatPayService, ) {} @@ -38,13 +45,28 @@ export class AdminController { @Get('overview') async overview() { - const [userCount, interviewCount, todayUsers, todayInterviews] = await Promise.all([ + const [ + userCount, interviewCount, todayUsers, todayInterviews, + resumeCount, paidDownloadCount, + planStats, + ] = await Promise.all([ this.userModel.countDocuments().exec(), this.interviewModel.countDocuments().exec(), this.userModel.countDocuments({ createdAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } }).exec(), this.interviewModel.countDocuments({ createdAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } }).exec(), + this.resumeModel.countDocuments().exec(), + this.resumeModel.countDocuments({ paidDownload: true }).exec(), + this.userModel.aggregate([ + { $group: { _id: '$plan', count: { $sum: 1 } } }, + ]).exec(), ]) - return { userCount, interviewCount, todayUsers, todayInterviews } + const planBreakdown: Record = {} + planStats.forEach(p => { planBreakdown[p._id || 'free'] = p.count }) + return { + userCount, interviewCount, todayUsers, todayInterviews, + resumeCount, paidDownloadCount, + planBreakdown, + } } @Get('users') @@ -70,7 +92,12 @@ export class AdminController { async getInterviews(@Query('page') page = '1', @Query('limit') limit = '20') { const skip = (Math.max(1, +page) - 1) * +limit const [interviews, total] = await Promise.all([ - this.interviewModel.find().sort({ createdAt: -1 }).skip(skip).limit(+limit).populate('userId', 'phone nickname').lean().exec(), + this.interviewModel.find() + .sort({ createdAt: -1 }) + .skip(skip).limit(+limit) + .populate('userId', 'phone nickname') + .select('position status totalScore questionCount fillerScore fillerDensity summary createdAt') + .lean().exec(), this.interviewModel.countDocuments().exec(), ]) return { interviews, total, page: +page } @@ -80,14 +107,135 @@ export class AdminController { async setVip(@Body('userId') targetUserId: string) { const user = await this.userModel.findById(targetUserId).exec() if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) + const pricing = await this.pricingService.getConfig() + const credits = pricing.plans?.growth?.credits || { interview: 999, resumeOptimize: 20, resumeDownload: 10 } const expireAt = new Date() - expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS) + expireAt.setDate(expireAt.getDate() + (pricing.plans?.growth?.durationDays || VIP_DURATION_DAYS)) user.plan = 'growth' user.vipExpireAt = expireAt - await this.quotaService.setPlanQuota(targetUserId, 'growth', { interview: 999, resumeOptimize: 20, resumeDownload: 10 }) + await this.quotaService.setPlanQuota(targetUserId, 'growth', credits) return { success: true, plan: 'growth', expireAt } } + @Post('user/credits') + async adjustCredits(@Body('userId') userId: string, @Body('type') type: string, @Body('amount') amount: number) { + if (!userId || !type || amount === undefined) { + throw new HttpException('参数不完整', HttpStatus.BAD_REQUEST) + } + const validTypes = ['interviewCredits', 'resumeOptimizeCredits', 'resumeDownloadCredits', 'shareCredits'] + if (!validTypes.includes(type)) { + throw new HttpException('无效的额度类型', HttpStatus.BAD_REQUEST) + } + const result = await this.userModel.findByIdAndUpdate( + userId, + { $set: { [type]: Math.max(0, Math.round(amount)) } }, + ).exec() + if (!result) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) + return { success: true } + } + + @Get('share-records') + async getShareRecords(@Query('page') page = '1', @Query('limit') limit = '20') { + const skip = (Math.max(1, +page) - 1) * +limit + const [list, total] = await Promise.all([ + this.shareModel.aggregate([ + { $sort: { createdAt: -1 } }, + { $skip: skip }, + { $limit: +limit }, + { + $lookup: { + from: 'users', + localField: 'userId', + foreignField: '_id', + as: 'sharer', + }, + }, + { $unwind: { path: '$sharer', preserveNullAndEmptyArrays: true } }, + { + $project: { + shareCode: 1, + type: 1, + title: 1, + visitCount: 1, + creditedCount: 1, + isActive: 1, + createdAt: 1, + sharer: { nickname: '$sharer.nickname', phone: '$sharer.phone', _id: '$sharer._id' }, + }, + }, + ]).exec(), + this.shareModel.countDocuments().exec(), + ]) + return { list, total, page: +page } + } + + @Get('share-visitors') + async getShareVisitors(@Query('page') page = '1', @Query('limit') limit = '20') { + const skip = (Math.max(1, +page) - 1) * +limit + const [list, total] = await Promise.all([ + this.shareVisitModel.aggregate([ + { $sort: { createdAt: -1 } }, + { $skip: skip }, + { $limit: +limit }, + { + $lookup: { + from: 'users', + localField: 'sharerId', + foreignField: '_id', + as: 'sharer', + }, + }, + { $unwind: { path: '$sharer', preserveNullAndEmptyArrays: true } }, + { + $lookup: { + from: 'users', + localField: 'visitorUserId', + foreignField: '_id', + as: 'visitorUser', + }, + }, + { $unwind: { path: '$visitorUser', preserveNullAndEmptyArrays: true } }, + { + $project: { + credited: 1, + creditedAt: 1, + createdAt: 1, + sharer: { nickname: '$sharer.nickname', phone: '$sharer.phone' }, + visitor: { nickname: { $ifNull: ['$visitorUser.nickname', '匿名'] }, phone: { $ifNull: ['$visitorUser.phone', ''] } }, + }, + }, + ]).exec(), + this.shareVisitModel.countDocuments().exec(), + ]) + return { list, total, page: +page } + } + + @Get('resumes') + async getResumes(@Query('page') page = '1', @Query('limit') limit = '20') { + const skip = (Math.max(1, +page) - 1) * +limit + const [list, total] = await Promise.all([ + this.resumeModel.find() + .sort({ createdAt: -1 }) + .skip(skip).limit(+limit) + .populate('userId', 'phone nickname') + .select('title targetPosition version paidDownload createdAt') + .lean().exec(), + this.resumeModel.countDocuments().exec(), + ]) + return { list, total, page: +page } + } + + @Get('user/:id') + async getUserDetail(@Query('id') id: string) { + const user = await this.userModel.findById(id).select('-password -openid').lean().exec() + if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) + const [interviews, resumes] = await Promise.all([ + this.interviewModel.find({ userId: id }).sort({ createdAt: -1 }).limit(10).select('position status totalScore questionCount createdAt').lean().exec(), + this.resumeModel.find({ userId: id }).sort({ createdAt: -1 }).limit(10).select('title targetPosition version paidDownload createdAt').lean().exec(), + ]) + return { user, interviews, resumes } + } + @Get('admins') async getAdmins() { const admins = await this.userModel.find({ role: 'admin' }).select('phone nickname email createdAt isSystemAdmin').lean().exec() @@ -130,14 +278,22 @@ export class AdminController { if (order.type === 'membership') { const user = await this.userModel.findById(order.userId).exec() if (user && user.plan === 'free') { + const pricing = await this.pricingService.getConfig() + const credits = pricing.plans?.growth?.credits || { interview: 999, resumeOptimize: 20, resumeDownload: 10 } const expireAt = new Date() - expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS) + expireAt.setDate(expireAt.getDate() + (pricing.plans?.growth?.durationDays || VIP_DURATION_DAYS)) user.plan = 'growth' user.vipExpireAt = expireAt - await this.quotaService.setPlanQuota(order.userId, 'growth', { interview: 999, resumeOptimize: 20, resumeDownload: 10 }) + await this.quotaService.setPlanQuota(order.userId, 'growth', credits) } } else { - const credits = { interview: 1, optimize: 1, download: 1 }[order.type] + const pricing = await this.pricingService.getConfig() + const creditMap: Record = { + interview: pricing.interview?.creditsPerPurchase || 1, + optimize: pricing.resumeOptimize?.creditsPerPurchase || 1, + download: pricing.resumeDownload?.creditsPerPurchase || 1, + } + const credits = creditMap[order.type] if (credits) { await this.quotaService.grantCredits(order.userId, order.type as any, credits) } @@ -179,6 +335,7 @@ export class AdminController { { key: 'pricing', value: body, description: '定价配置' }, { upsert: true }, ).exec() + this.pricingService.invalidateCache() return { success: true } } diff --git a/backend/src/modules/admin/admin.module.ts b/backend/src/modules/admin/admin.module.ts index 62fd687..7fafc2a 100644 --- a/backend/src/modules/admin/admin.module.ts +++ b/backend/src/modules/admin/admin.module.ts @@ -8,6 +8,8 @@ import { PaymentOrder, PaymentOrderSchema } from '../payment/payment-order.schem import { WechatPayService } from '../payment/wechat-pay.service' import { AdminGuard } from '../../common/guards/admin.guard' import { SiteConfig, SiteConfigSchema } from '../schemas/site-config.schema' +import { ShareRecord, ShareRecordSchema, ShareVisit, ShareVisitSchema } from '../share/share.schema' +import { Resume, ResumeSchema } from '../resume/resume.schema' @Module({ imports: [ @@ -16,6 +18,9 @@ import { SiteConfig, SiteConfigSchema } from '../schemas/site-config.schema' { name: Interview.name, schema: InterviewSchema }, { name: PaymentOrder.name, schema: PaymentOrderSchema }, { name: SiteConfig.name, schema: SiteConfigSchema }, + { name: ShareRecord.name, schema: ShareRecordSchema }, + { name: ShareVisit.name, schema: ShareVisitSchema }, + { name: Resume.name, schema: ResumeSchema }, ]), UserModule, ], diff --git a/backend/src/modules/payment/payment.controller.ts b/backend/src/modules/payment/payment.controller.ts index 0be6abc..58f5668 100644 --- a/backend/src/modules/payment/payment.controller.ts +++ b/backend/src/modules/payment/payment.controller.ts @@ -168,6 +168,8 @@ export class PaymentController { order.wxTransactionId = wxTransactionId order.description = `${decrypted.trade_type || ''} 支付成功` await order.save() + } else { + return { code: 'SUCCESS', message: '已处理' } } if (order.type === 'membership') { @@ -260,6 +262,15 @@ export class PaymentController { return { success: true, plan: order.plan } } + // For product orders, check if already activated via callback + const user = await this.userModel.findById(userId).exec() + if (user && order.type === 'interview' && (user.interviewCredits || 0) > 0) { + return { success: true, type: order.type, alreadyActivated: true } + } + if (user && order.type === 'optimize' && (user.resumeOptimizeCredits || 0) > 0) { + return { success: true, type: order.type, alreadyActivated: true } + } + await this.activateProduct(order) return { success: true, type: order.type } } diff --git a/backend/src/modules/resume/resume-pdf.service.ts b/backend/src/modules/resume/resume-pdf.service.ts index d7ce976..a2cc836 100644 --- a/backend/src/modules/resume/resume-pdf.service.ts +++ b/backend/src/modules/resume/resume-pdf.service.ts @@ -31,13 +31,18 @@ export class ResumePdfService { } } + private escapeHtml(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, ''') + } + private buildHtml(params: { title: string content: string targetPosition?: string userName?: string }): string { - const contentHtml = params.content + const contentHtml = this.escapeHtml(params.content) .replace(/\n/g, '
') .replace(/### (.+)/g, '

$1

') .replace(/\*\*(.+?)\*\*/g, '$1') @@ -66,8 +71,8 @@ export class ResumePdfService {
-

${params.title}

-
${params.targetPosition ? `目标岗位: ${params.targetPosition}` : ''}
+

${this.escapeHtml(params.title)}

+
${params.targetPosition ? `目标岗位: ${this.escapeHtml(params.targetPosition)}` : ''}
${contentHtml}
diff --git a/backend/src/modules/resume/resume.controller.ts b/backend/src/modules/resume/resume.controller.ts index cfd3e92..e00335a 100644 --- a/backend/src/modules/resume/resume.controller.ts +++ b/backend/src/modules/resume/resume.controller.ts @@ -31,13 +31,10 @@ export class ResumeController { @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) { + const canDownload = await this.quotaService.checkAndDeductDownload(userId, resume.paidDownload) + if (!canDownload && !resume.paidDownload) { throw new HttpException('请先付费下载', HttpStatus.PAYMENT_REQUIRED) } - - await this.quotaService.deductDownload(userId, resume) if (!resume.paidDownload) { await this.resumeService.markPaid(id, userId) } diff --git a/backend/src/modules/share/share.controller.ts b/backend/src/modules/share/share.controller.ts new file mode 100644 index 0000000..45f9ff4 --- /dev/null +++ b/backend/src/modules/share/share.controller.ts @@ -0,0 +1,69 @@ +import { Controller, Get, Post, Body, Param, Query, HttpException, HttpStatus, UseGuards } from '@nestjs/common' +import { JwtService } from '@nestjs/jwt' +import { ShareService } from './share.service' +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard' +import { CurrentUser } from '../../common/decorators/current-user.decorator' +import { Public } from '../../common/decorators/public.decorator' + +@Controller('share') +export class ShareController { + constructor( + private shareService: ShareService, + private jwtService: JwtService, + ) {} + + @UseGuards(JwtAuthGuard) + @Post('create') + async create( + @CurrentUser('userId') userId: string, + @Body() body: { type: string; refId?: string; title?: string; description?: string }, + ) { + return this.shareService.create(userId, body) + } + + @Public() + @Get('visit/:shareCode') + async visit( + @Param('shareCode') shareCode: string, + @Query('visitorId') visitorId?: string, + @Query('token') token?: string, + ) { + if (!visitorId) { + throw new HttpException('缺少访问者标识', HttpStatus.BAD_REQUEST) + } + let visitorUserId: string | undefined + if (token) { + try { + const payload = this.jwtService.verify(token) as any + visitorUserId = payload.userId + } catch {} + } + return this.shareService.visit(shareCode, visitorId, visitorUserId) + } + + @UseGuards(JwtAuthGuard) + @Get('stats') + async stats(@CurrentUser('userId') userId: string) { + return this.shareService.stats(userId) + } + + @UseGuards(JwtAuthGuard) + @Get('records') + async records( + @CurrentUser('userId') userId: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.shareService.records(userId, Number(page) || 1, Number(pageSize) || 20) + } + + @UseGuards(JwtAuthGuard) + @Get('visitors') + async visitors( + @CurrentUser('userId') userId: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.shareService.visitors(userId, Number(page) || 1, Number(pageSize) || 20) + } +} diff --git a/backend/src/modules/share/share.module.ts b/backend/src/modules/share/share.module.ts new file mode 100644 index 0000000..01f544c --- /dev/null +++ b/backend/src/modules/share/share.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common' +import { JwtModule } from '@nestjs/jwt' +import { MongooseModule } from '@nestjs/mongoose' +import { ShareController } from './share.controller' +import { ShareService } from './share.service' +import { ShareRecord, ShareRecordSchema, ShareVisit, ShareVisitSchema } from './share.schema' +import { UserModule } from '../user/user.module' +import { User, UserSchema } from '../user/user.schema' + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: ShareRecord.name, schema: ShareRecordSchema }, + { name: ShareVisit.name, schema: ShareVisitSchema }, + { name: User.name, schema: UserSchema }, + ]), + UserModule, + JwtModule.register({ + secret: process.env.JWT_SECRET, + signOptions: { expiresIn: '7d' }, + }), + ], + controllers: [ShareController], + providers: [ShareService], +}) +export class ShareModule {} diff --git a/backend/src/modules/share/share.schema.ts b/backend/src/modules/share/share.schema.ts new file mode 100644 index 0000000..0cd7127 --- /dev/null +++ b/backend/src/modules/share/share.schema.ts @@ -0,0 +1,72 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { Document, Types } from 'mongoose' + +export type ShareRecordDocument = ShareRecord & Document +export type ShareVisitDocument = ShareVisit & Document + +@Schema({ timestamps: true }) +export class ShareRecord { + @Prop({ type: Types.ObjectId, ref: 'User', required: true }) + userId: Types.ObjectId + + @Prop({ required: true, unique: true }) + shareCode: string + + @Prop({ default: 'app' }) + type: string + + @Prop({ default: '' }) + refId: string + + @Prop({ default: '' }) + title: string + + @Prop({ default: '' }) + description: string + + @Prop({ default: 'link' }) + channel: string + + @Prop({ default: 0 }) + visitCount: number + + @Prop({ default: 0 }) + creditedCount: number + + @Prop({ default: true }) + isActive: boolean + + readonly createdAt?: Date + readonly updatedAt?: Date +} + +@Schema({ timestamps: true }) +export class ShareVisit { + @Prop({ type: Types.ObjectId, ref: 'ShareRecord', required: true }) + shareId: Types.ObjectId + + @Prop({ type: Types.ObjectId, ref: 'User', required: true }) + sharerId: Types.ObjectId + + @Prop({ required: true }) + visitorId: string + + @Prop({ type: Types.ObjectId, ref: 'User' }) + visitorUserId: Types.ObjectId + + @Prop({ default: false }) + credited: boolean + + @Prop() + creditedAt?: Date + + readonly createdAt?: Date + readonly updatedAt?: Date +} + +export const ShareRecordSchema = SchemaFactory.createForClass(ShareRecord) +export const ShareVisitSchema = SchemaFactory.createForClass(ShareVisit) + +ShareRecordSchema.index({ userId: 1, createdAt: -1 }) +ShareVisitSchema.index({ shareId: 1, visitorId: 1 }, { unique: true }) +ShareVisitSchema.index({ sharerId: 1, createdAt: -1 }) diff --git a/backend/src/modules/share/share.service.ts b/backend/src/modules/share/share.service.ts new file mode 100644 index 0000000..87256c7 --- /dev/null +++ b/backend/src/modules/share/share.service.ts @@ -0,0 +1,186 @@ +import * as crypto from 'crypto' +import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { Model, Types } from 'mongoose' +import { ShareRecord, ShareRecordDocument, ShareVisit, ShareVisitDocument } from './share.schema' +import { QuotaService } from '../user/quota.service' +import { User, UserDocument } from '../user/user.schema' + +const DAILY_LIMIT = 3 +const LINK_TTL_DAYS = 30 + +@Injectable() +export class ShareService { + private readonly logger = new Logger(ShareService.name) + + constructor( + @InjectModel(ShareRecord.name) private shareModel: Model, + @InjectModel(ShareVisit.name) private visitModel: Model, + @InjectModel(User.name) private userModel: Model, + private quotaService: QuotaService, + ) {} + + async create(userId: string, body: { type: string; refId?: string; title?: string; description?: string }) { + const shareCode = crypto.randomBytes(4).toString('hex') + const record = await this.shareModel.create({ + userId: new Types.ObjectId(userId), + shareCode, + type: body.type || 'app', + refId: body.refId || '', + title: body.title || '我在职引发现了好东西', + description: body.description || '快来一起体验吧', + }) + return { + shareCode: record.shareCode, + shareUrl: `/share/${record.shareCode}`, + wechatShareInfo: { + title: record.title, + description: record.description, + path: `/pages/share/share?code=${record.shareCode}`, + }, + } + } + + async visit(shareCode: string, visitorId: string, visitorUserId?: string) { + const share = await this.shareModel.findOne({ shareCode, isActive: true }).exec() + if (!share) throw new HttpException('分享链接不存在或已失效', HttpStatus.NOT_FOUND) + + await this.shareModel.findByIdAndUpdate(share._id, { $inc: { visitCount: 1 } }).exec() + + const sharerIdStr = share.userId.toString() + if (!visitorUserId || visitorUserId === sharerIdStr) { + return { sharer: true, visitorUserId } + } + + const existing = await this.visitModel.findOne({ + shareId: share._id, + visitorId, + }).exec() + if (existing) return { alreadyVisited: true, visitorUserId } + + await this.visitModel.create({ + shareId: share._id, + sharerId: share.userId, + visitorId, + visitorUserId: new Types.ObjectId(visitorUserId), + }).catch(() => {}) + + const alreadyCredited = await this.visitModel.findOne({ + shareId: share._id, + visitorId, + credited: true, + }).exec() + if (alreadyCredited) return { credited: true, visitorUserId } + + const todayStart = new Date() + todayStart.setHours(0, 0, 0, 0) + + const todayCredited = await this.visitModel.countDocuments({ + sharerId: share.userId, + credited: true, + creditedAt: { $gte: todayStart }, + }).exec() + + if (todayCredited >= DAILY_LIMIT) return { dailyLimitReached: true, visitorUserId } + + const shareCreditsResult = await this.quotaService.grantShareCredits(sharerIdStr) + if (!shareCreditsResult) return { creditFailed: true, visitorUserId } + + await this.visitModel.updateOne( + { shareId: share._id, visitorId }, + { $set: { credited: true, creditedAt: new Date() } }, + ).exec() + + await this.shareModel.findByIdAndUpdate(share._id, { $inc: { creditedCount: 1 } }).exec() + + return { credited: true, visitorUserId } + } + + async stats(userId: string) { + const todayStart = new Date() + todayStart.setHours(0, 0, 0, 0) + + const [totalShares, visitAgg, todayAgg, user] = await Promise.all([ + this.shareModel.countDocuments({ userId: new Types.ObjectId(userId) }).exec(), + this.visitModel.aggregate([ + { $match: { sharerId: new Types.ObjectId(userId) } }, + { + $group: { + _id: null, + totalVisits: { $sum: 1 }, + creditedCount: { $sum: { $cond: ['$credited', 1, 0] } }, + }, + }, + ]).exec(), + this.visitModel.countDocuments({ + sharerId: new Types.ObjectId(userId), + credited: true, + creditedAt: { $gte: todayStart }, + }).exec(), + this.userModel.findById(userId).exec(), + ]) + + return { + totalShares, + totalVisits: visitAgg[0]?.totalVisits ?? 0, + creditedCount: visitAgg[0]?.creditedCount ?? 0, + todayCredited: todayAgg, + shareCredits: user?.shareCredits ?? 0, + } + } + + async records(userId: string, page = 1, pageSize = 20) { + const list = await this.shareModel.find({ userId: new Types.ObjectId(userId) }) + .sort({ createdAt: -1 }) + .skip((page - 1) * pageSize) + .limit(pageSize) + .exec() + const total = await this.shareModel.countDocuments({ userId: new Types.ObjectId(userId) }).exec() + return { + list: list.map(r => ({ + shareCode: r.shareCode, + type: r.type, + title: r.title, + visitCount: r.visitCount, + creditedCount: r.creditedCount, + createdAt: r.createdAt, + })), + total, + page, + pageSize, + } + } + + async visitors(userId: string, page = 1, pageSize = 20) { + const [list, total] = await Promise.all([ + this.visitModel.aggregate([ + { $match: { sharerId: new Types.ObjectId(userId) } }, + { $sort: { createdAt: -1 } }, + { $skip: (page - 1) * pageSize }, + { $limit: pageSize }, + { + $lookup: { + from: 'users', + localField: 'visitorUserId', + foreignField: '_id', + as: 'visitorUser', + }, + }, + { $unwind: { path: '$visitorUser', preserveNullAndEmptyArrays: true } }, + { + $project: { + _id: 0, + visitorId: 1, + credited: 1, + creditedAt: 1, + createdAt: 1, + nickname: { $ifNull: ['$visitorUser.nickname', '匿名用户'] }, + avatar: { $ifNull: ['$visitorUser.avatar', ''] }, + }, + }, + ]).exec(), + this.visitModel.countDocuments({ sharerId: new Types.ObjectId(userId) }).exec(), + ]) + return { list, total, page, pageSize } + } +} diff --git a/backend/src/modules/tts/tts.controller.ts b/backend/src/modules/tts/tts.controller.ts index 57cbe01..cebcd67 100644 --- a/backend/src/modules/tts/tts.controller.ts +++ b/backend/src/modules/tts/tts.controller.ts @@ -1,14 +1,18 @@ -import { Controller, Get, Post, Body, Param, Res, HttpException, HttpStatus } from '@nestjs/common' +import { Controller, Get, Post, Body, Param, Res, HttpException, HttpStatus, UseGuards, UploadedFile, UseInterceptors } from '@nestjs/common' +import { FileInterceptor } from '@nestjs/platform-express' import { Response } from 'express' import * as fs from 'fs' +import * as path from 'path' +import { execSync } from 'child_process' import { TtsService } from './tts.service' +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard' import { Public } from '../../common/decorators/public.decorator' @Controller('tts') export class TtsController { constructor(private ttsService: TtsService) {} - @Public() + @UseGuards(JwtAuthGuard) @Post('synthesize') async synthesize(@Body('text') text: string, @Body('voice') voice?: string) { if (!text || text.length > 500) { @@ -30,4 +34,36 @@ export class TtsController { res.setHeader('Cache-Control', 'public, max-age=31536000') stream.pipe(res) } + + @UseGuards(JwtAuthGuard) + @Post('asr') + @UseInterceptors(FileInterceptor('audio', { dest: '/tmp/asr_uploads' })) + async recognize(@UploadedFile() file: any) { + if (!file) throw new HttpException('请上传音频文件', HttpStatus.BAD_REQUEST) + const uploadDir = '/tmp/asr_uploads' + if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }) + const ext = path.extname(file.originalname) || '.mp3' + const dest = path.join(uploadDir, file.filename + ext) + fs.renameSync(file.path, dest) + try { + if (process.env.OPENAI_API_KEY) { + const result = execSync( + `curl -s -X POST https://api.openai.com/v1/audio/transcriptions \ + -H "Authorization: Bearer ${process.env.OPENAI_API_KEY}" \ + -H "Content-Type: multipart/form-data" \ + -F "file=@${dest}" \ + -F "model=whisper-1" \ + -F "language=zh"`, + { encoding: 'utf8', timeout: 30000 }, + ) + const parsed = JSON.parse(result) + if (parsed.text) return { text: parsed.text.trim() } + } + const whisperResult = execSync(`whisper "${dest}" --language zh --output_format txt 2>/dev/null`, { encoding: 'utf8', timeout: 60000 }) + if (whisperResult && whisperResult.trim()) { + return { text: whisperResult.trim() } + } + } catch {} + return { text: '' } + } } diff --git a/backend/src/modules/tts/tts.service.ts b/backend/src/modules/tts/tts.service.ts index 545c649..c1c9571 100644 --- a/backend/src/modules/tts/tts.service.ts +++ b/backend/src/modules/tts/tts.service.ts @@ -12,6 +12,18 @@ interface TtsResult { durationMs: number } +const VALID_VOICES = new Set([ + 'zh-CN-XiaoxiaoNeural', 'zh-CN-XiaoyiNeural', 'zh-CN-YunjianNeural', + 'zh-CN-YunxiNeural', 'zh-CN-YunxiaNeural', 'zh-CN-YunyangNeural', + 'zh-CN-liaoning-XiaobeiNeural', 'zh-CN-shaanxi-XiaoniNeural', +]) + +function validateVoice(voice: string): void { + if (!VALID_VOICES.has(voice)) { + throw new Error(`不支持的语音: ${voice}`) + } +} + @Injectable() export class TtsService { private readonly logger = new Logger(TtsService.name) @@ -23,6 +35,7 @@ export class TtsService { } async synthesize(text: string, voice: string = 'zh-CN-XiaoxiaoNeural'): Promise { + validateVoice(voice) const hash = crypto.createHash('md5').update(text + voice).digest('hex') const filePath = path.join(CACHE_DIR, `${hash}.mp3`) diff --git a/backend/src/modules/user/quota.service.ts b/backend/src/modules/user/quota.service.ts index b7d8902..82bbc99 100644 --- a/backend/src/modules/user/quota.service.ts +++ b/backend/src/modules/user/quota.service.ts @@ -1,4 +1,4 @@ -import { Injectable, HttpException, HttpStatus } from '@nestjs/common' +import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common' import { InjectModel } from '@nestjs/mongoose' import { Model } from 'mongoose' import { User, UserDocument } from './user.schema' @@ -7,6 +7,8 @@ const FREE_OPTIMIZE_LIMIT = 3 @Injectable() export class QuotaService { + private readonly logger = new Logger(QuotaService.name) + constructor( @InjectModel(User.name) private userModel: Model, ) {} @@ -16,12 +18,28 @@ export class QuotaService { if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) if (user.plan !== 'free') return - if ((user.interviewCredits || 0) <= 0) { - throw new HttpException('面试次数已用完,请购买面试次数或开通会员', HttpStatus.FORBIDDEN) + // Backward compat: migrate remaining → interviewCredits + if ((user.interviewCredits ?? 0) <= 0 && (user.remaining ?? 0) > 0) { + await this.userModel.findByIdAndUpdate(userId, { + $set: { interviewCredits: user.remaining, remaining: 0 }, + }).exec() } - user.interviewCredits = (user.interviewCredits || 0) - 1 - user.interviewCount = (user.interviewCount || 0) + 1 - await user.save() + + const result = await this.userModel.findOneAndUpdate( + { _id: userId, interviewCredits: { $gt: 0 } }, + { $inc: { interviewCredits: -1, interviewCount: 1 } }, + { new: true }, + ).exec() + if (result) return + + // Fallback to share credits + const shareResult = await this.userModel.findOneAndUpdate( + { _id: userId, shareCredits: { $gt: 0 } }, + { $inc: { shareCredits: -1, interviewCount: 1 } }, + ).exec() + if (shareResult) return + + throw new HttpException('面试次数已用完,请购买面试次数或开通会员', HttpStatus.FORBIDDEN) } async checkAndDeductOptimize(userId: string) { @@ -29,64 +47,85 @@ export class QuotaService { if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) if (user.plan !== 'free') return - // 优先扣付费额度 - if ((user.resumeOptimizeCredits || 0) > 0) { - user.resumeOptimizeCredits = (user.resumeOptimizeCredits || 0) - 1 - await user.save() - return + // Backward compat: migrate remaining → freeOptimizeUsed + if ((user.freeOptimizeUsed ?? 0) <= 0 && (user.remaining ?? 0) > 0 && (user.resumeOptimizeCredits ?? 0) <= 0) { + const migrateCount = Math.min(user.remaining, FREE_OPTIMIZE_LIMIT) + await this.userModel.findByIdAndUpdate(userId, { + $set: { freeOptimizeUsed: migrateCount, remaining: Math.max(0, user.remaining - migrateCount) }, + }).exec() + this.logger.log(`Migrated remaining=${user.remaining} → freeOptimizeUsed=${migrateCount} for user ${userId}`) } - // 免费额度 - if ((user.freeOptimizeUsed || 0) < FREE_OPTIMIZE_LIMIT) { - user.freeOptimizeUsed = (user.freeOptimizeUsed || 0) + 1 - await user.save() - return - } + // Try paid credits first + const paid = await this.userModel.findOneAndUpdate( + { _id: userId, resumeOptimizeCredits: { $gt: 0 } }, + { $inc: { resumeOptimizeCredits: -1 } }, + ).exec() + if (paid) return + + // Then free limit + const freeResult = await this.userModel.findOneAndUpdate( + { _id: userId, freeOptimizeUsed: { $lt: FREE_OPTIMIZE_LIMIT } }, + { $inc: { freeOptimizeUsed: 1 } }, + ).exec() + if (freeResult) return + + // Fallback to share credits + const shareResult = await this.userModel.findOneAndUpdate( + { _id: userId, shareCredits: { $gt: 0 } }, + { $inc: { shareCredits: -1 } }, + ).exec() + if (shareResult) return throw new HttpException('简历优化次数已用完,请购买优化次数或开通会员', HttpStatus.FORBIDDEN) } - async checkDownload(userId: string, resume: { paidDownload?: boolean }): Promise { - const user = await this.userModel.findById(userId).exec() - if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) - - if (resume.paidDownload) return true - if ((user.resumeDownloadCredits || 0) > 0) return true - return false + async grantShareCredits(userId: string, amount = 1): Promise { + const result = await this.userModel.findByIdAndUpdate( + userId, + { $inc: { shareCredits: amount } }, + ).exec() + return !!result } - async deductDownload(userId: string, resume: { paidDownload?: boolean; _id?: any }) { - const user = await this.userModel.findById(userId).exec() - if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) + async checkAndDeductDownload(userId: string, paidDownload: boolean): Promise { + if (paidDownload) return true - if (resume.paidDownload) return - if ((user.resumeDownloadCredits || 0) > 0) { - user.resumeDownloadCredits = (user.resumeDownloadCredits || 0) - 1 - await user.save() - } + const result = await this.userModel.findOneAndUpdate( + { _id: userId, resumeDownloadCredits: { $gt: 0 } }, + { $inc: { resumeDownloadCredits: -1 } }, + ).exec() + return !!result } async grantCredits(userId: string, type: 'interview' | 'optimize' | 'download', amount: number) { - const user = await this.userModel.findById(userId).exec() - if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) + if (amount <= 0) throw new HttpException('无效数量', HttpStatus.BAD_REQUEST) - if (type === 'interview') user.interviewCredits = (user.interviewCredits || 0) + amount - else if (type === 'optimize') user.resumeOptimizeCredits = (user.resumeOptimizeCredits || 0) + amount - else if (type === 'download') user.resumeDownloadCredits = (user.resumeDownloadCredits || 0) + amount + const fieldMap: Record = { + interview: 'interviewCredits', + optimize: 'resumeOptimizeCredits', + download: 'resumeDownloadCredits', + } + const field = fieldMap[type] + if (!field) throw new HttpException('无效类型', HttpStatus.BAD_REQUEST) - await user.save() + const result = await this.userModel.findByIdAndUpdate( + userId, + { $inc: { [field]: amount } }, + ).exec() + if (!result) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) } - async setPlanQuota(userId: string, plan: string, credits: { interview: number; resumeOptimize: number; resumeDownload: number }) { - const user = await this.userModel.findById(userId).exec() - if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) - - user.remaining = 999 - user.interviewCredits = credits.interview - user.resumeOptimizeCredits = credits.resumeOptimize - user.resumeDownloadCredits = credits.resumeDownload - user.freeOptimizeUsed = FREE_OPTIMIZE_LIMIT // 会员不再消耗免费次数 - - await user.save() + async setPlanQuota(userId: string, _plan: string, credits: { interview: number; resumeOptimize: number; resumeDownload: number }) { + const result = await this.userModel.findByIdAndUpdate(userId, { + $set: { + remaining: 999, + interviewCredits: credits.interview, + resumeOptimizeCredits: credits.resumeOptimize, + resumeDownloadCredits: credits.resumeDownload, + freeOptimizeUsed: FREE_OPTIMIZE_LIMIT, + }, + }).exec() + if (!result) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) } } diff --git a/backend/src/modules/user/user.schema.ts b/backend/src/modules/user/user.schema.ts index c1f493d..dff0052 100644 --- a/backend/src/modules/user/user.schema.ts +++ b/backend/src/modules/user/user.schema.ts @@ -48,6 +48,9 @@ export class User { @Prop({ default: 0 }) freeOptimizeUsed: number // 已使用免费优化次数(上限 3) + @Prop({ default: 0 }) + shareCredits: number // 分享积分,每 3 次有效访问获 1 积分 + @Prop({ default: 'user' }) role: string // 'user' | 'admin' diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index 252a659..a86973e 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -215,6 +215,7 @@ export class UserService { resumeOptimizeCredits: user.resumeOptimizeCredits ?? 0, resumeDownloadCredits: user.resumeDownloadCredits ?? 0, freeOptimizeUsed: user.freeOptimizeUsed ?? 0, + shareCredits: user.shareCredits ?? 0, } } } diff --git a/docs/SHARE-FEATURE.md b/docs/SHARE-FEATURE.md new file mode 100644 index 0000000..4c40da9 --- /dev/null +++ b/docs/SHARE-FEATURE.md @@ -0,0 +1,253 @@ +# 分享功能设计方案 + +## 1. 概述 + +**目标**:用户可通过分享面试/简历等给好友或微信群,每次有效分享获得积分奖励,积分可抵扣面试/简历优化次数,形成用户增长闭环。 + +**核心逻辑**:分享者 A → 生成分享链接 → 非 A 的其他用户点击 → 计入有效分享一次 → A 获得 1 个「分享积分」,每 3 次有效分享可兑换 1 次面试或简历优化次数。 + +--- + +## 2. 数据模型 + +### User 新增字段 + +``` +shareCredits: number // 分享积分,默认 0 +``` + +- 1 个 shareCredit 可兑换 1 次 面试 或 1 次 简历优化 +- 在 QuotaService 中作为兜底:当 interviewCredits / resumeOptimizeCredits 为 0 时尝试消耗 shareCredits + +### ShareRecord 集合 + +``` +@Schema({ timestamps: true }) +ShareRecord { + _id: ObjectId + userId: ObjectId // 分享者 + shareCode: string // 8位唯一短码 (hex),用于分享链接 + type: 'interview' | 'resume' | 'app' + refId: string // 关联的面试/简历ID(可选) + title: string // 分享标题 + description: string // 分享描述 + visitCount: number // 总访问次数 + creditedCount: number // 已计为有效的访问次数 + isActive: boolean // 链接是否有效 + createdAt: Date // timestamps + updatedAt: Date +} + +index: { shareCode: 1 } unique +index: { userId: 1, createdAt: -1 } +``` + +### ShareVisit 集合(访问记录) + +``` +@Schema({ timestamps: true }) +ShareVisit { + _id: ObjectId + shareId: ObjectId // 关联的 ShareRecord + sharerId: ObjectId // 分享者 userId(冗余,便于查询) + visitorId: string // 访问者标识(openId 或 匿名ID) + visitorUserId: ObjectId // 访问者 userId(如果已注册/登录) + credited: boolean // 是否已为此访问发积分 + creditedAt: Date + createdAt: Date // timestamps +} + +index: { shareId: 1, visitorId: 1 } unique // 同一人只计一次 +index: { sharerId: 1, createdAt: -1 } +``` + +--- + +## 3. API 设计 + +| 方法 | 路径 | 权限 | 说明 | +|------|------|------|------| +| POST | /api/share/create | JWT | 生成分享链接,返回 shareCode + URL | +| GET | /api/share/visit/:shareCode | Public | 访问分享链接,记录访问+判定发放积分 | +| GET | /api/share/stats | JWT | 获取我的分享统计(总分享次数、总积分、今日积分) | +| GET | /api/share/records | JWT | 获取我的分享记录列表(分页) | +| GET | /api/share/visitors | JWT | 获取访问过我分享的用户列表(分页) | + +### POST /api/share/create + +Request: +```json +{ + "type": "interview" | "resume" | "app", + "refId": "xxx", + "title": "我在职引完成了AI模拟面试", + "description": "快来和我一起练习面试吧" +} +``` + +Response: +```json +{ + "shareCode": "a1b2c3d4", + "shareUrl": "https://zhiyin.app/share/a1b2c3d4", + "wechatShareInfo": { + "title": "...", + "description": "...", + "path": "/pages/share/share?code=a1b2c3d4" + } +} +``` + +### GET /api/share/visit/:shareCode + +- 如果来自微信小程序:记录 `visitorId = openId` +- 如果来自 H5 链接:记录 `visitorId = 匿名设备ID`(可选) +- 判定条件: + - `visitorId !== sharer.openId`(自己点自己不算) + - 该 `visitorId` 之前未在该 shareId 下领过积分 + - 当日该分享者已获得积分 < 3(日上限) +- 满足条件 → `credited = true`,shareCredits + 1 +- 返回重定向或展示落地页 + +### GET /api/share/stats + +```json +{ + "totalShares": 15, + "totalVisits": 43, + "creditedCount": 12, + "todayCredited": 2, + "shareCredits": 4 +} +``` + +### GET /api/share/records + +```json +[ + { + "shareCode": "a1b2c3d4", + "type": "interview", + "title": "我在职引完成了AI模拟面试", + "visitCount": 5, + "creditedCount": 3, + "createdAt": "2026-06-12T10:00:00Z" + } +] +``` + +### GET /api/share/visitors + +```json +[ + { + "visitor": { "nickname": "张三", "avatar": "..." }, + "credited": true, + "creditedAt": "2026-06-12T10:30:00Z" + } +] +``` + +--- + +## 4. 积分兜底机制 + +在 `QuotaService.checkAndDeductInterview()` 和 `checkAndDeductOptimize()` 中增加兜底: + +``` +if (interviewCredits <= 0 && shareCredits > 0) { + shareCredits -= 1 + return // 用分享积分抵扣 +} +``` + +同时新增 `useShareCredit(userId, type)` 方法由前端显式调用,或自动在抵扣链中完成。 + +--- + +## 5. 前端实现 + +### 5.1 分享入口 + +**进入点 1:面试报告页**(`pages/report/report.vue`) +- 在"生成分享卡片"按钮旁增加"分享给好友"按钮 +- 点击后调用 `POST /api/share/create` 生成 shareCode +- 微信小程序内直接调 `wx.shareAppMessage` +- H5 环境复制分享链接到剪贴板 + +**进入点 2:简历优化结果页**(`pages/result/result.vue`) +- 同上 + +**进入点 3:应用首页**(`pages/index/index.vue`) +- "邀请好友"入口,固定分享 app 类型 + +### 5.2 我的分享页 + +**页面位置**:`/pages/share/share.vue` +**进入方式**:用户「我的」页面 → "我的分享" 菜单项 + +页面内容: +- 顶部统计卡片:总收益积分、今日收益、总分享次数 +- "分享给好友"主按钮 +- Tab 切换:「分享记录」/ 「访问记录」 +- 分享记录列表:分享类型、标题、访问数、有效数、时间 +- 访问记录列表:访客昵称、访问时间、是否已积分 + +### 5.3 微信分享 + +使用 uni-app `onShareAppMessage` 生命周期: +```javascript +// 在页面中定义 +onShareAppMessage() { + return { + title: '我在职引完成了AI模拟面试', + path: `/pages/share/share?code=${this.shareCode}`, + imageUrl: '...' + } +} +``` + +### 5.4 H5 分享链接 + +分享链接格式:`https://zhiyin.app/share/{shareCode}` +H5 落地页展示: +- 分享者信息 +- 分享内容预览 +- "打开小程序" / "下载 App" 引导按钮 + +--- + +## 6. 风控规则 + +| 规则 | 说明 | +|------|------| +| 日上限 | 同一用户每日最多获 3 个分享积分 | +| 自分享过滤 | 自己点自己的链接不计数 | +| 同人去重 | 同一访客对同一链接只计一次 | +| 链接有效期 | 分享链接 30 天有效期 | +| 频率限制 | 每分钟最多创建 5 次分享(前端控制) | + +--- + +## 7. 存储方案 + +- **ShareRecord**:MongoDB 集合 `sharerecords` +- **ShareVisit**:MongoDB 集合 `sharevisits` +- 积分字段 `shareCredits` 存储在 User 文档上,`$inc` 原子操作确保并发安全 + +--- + +## 8. 依赖模块 + +- `QuotaService`(来自 UserModule)- 积分发放与抵扣 +- `UserModule`(获取 User 信息、校验身份) +- `PricingModule`(@Global 已有,无需额外导入) + +--- + +## 9. 未纳入 MVP 的内容 + +- 分享链接落地页(H5 路由 `/share/:code`)— 第一期直接返回 JSON +- 分享数据可视化图表 +- 积分兑换商城 +- 排行榜 / 邀请竞赛 diff --git a/zhiyin-app/src/components/digital-human.vue b/zhiyin-app/src/components/digital-human.vue index 53f65f5..68b9c34 100644 --- a/zhiyin-app/src/components/digital-human.vue +++ b/zhiyin-app/src/components/digital-human.vue @@ -1,21 +1,17 @@ diff --git a/zhiyin-app/src/pages/interview/interview.vue b/zhiyin-app/src/pages/interview/interview.vue index b3a50a2..d80a48c 100644 --- a/zhiyin-app/src/pages/interview/interview.vue +++ b/zhiyin-app/src/pages/interview/interview.vue @@ -53,11 +53,14 @@ + + 🎤 +