feat: realistic face avatar + voice input + ASR endpoint

This commit is contained in:
yuzhiran
2026-06-12 15:32:04 +08:00
parent 6fe84b6ef8
commit 8191cf4b41
26 changed files with 1934 additions and 228 deletions
+165 -8
View File
@@ -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<InterviewDocument>,
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
@InjectModel(SiteConfig.name) private configModel: Model<SiteConfigDocument>,
@InjectModel(ShareRecord.name) private shareModel: Model<ShareRecordDocument>,
@InjectModel(ShareVisit.name) private shareVisitModel: Model<ShareVisitDocument>,
@InjectModel(Resume.name) private resumeModel: Model<ResumeDocument>,
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<string, number> = {}
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<string, number> = {
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 }
}
@@ -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,
],
@@ -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 }
}
@@ -31,13 +31,18 @@ export class ResumePdfService {
}
}
private escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#039;')
}
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, '<br>')
.replace(/### (.+)/g, '<h3>$1</h3>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
@@ -66,8 +71,8 @@ export class ResumePdfService {
</head>
<body>
<div class="page">
<h1>${params.title}</h1>
<div class="subtitle">${params.targetPosition ? `目标岗位: ${params.targetPosition}` : ''}</div>
<h1>${this.escapeHtml(params.title)}</h1>
<div class="subtitle">${params.targetPosition ? `目标岗位: ${this.escapeHtml(params.targetPosition)}` : ''}</div>
<div class="content">${contentHtml}</div>
<div class="footer">由 AI磁场·职引 生成</div>
</div>
@@ -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)
}
@@ -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)
}
}
+26
View File
@@ -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 {}
+72
View File
@@ -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 })
+186
View File
@@ -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<ShareRecordDocument>,
@InjectModel(ShareVisit.name) private visitModel: Model<ShareVisitDocument>,
@InjectModel(User.name) private userModel: Model<UserDocument>,
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 }
}
}
+38 -2
View File
@@ -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: '' }
}
}
+13
View File
@@ -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<TtsResult> {
validateVoice(voice)
const hash = crypto.createHash('md5').update(text + voice).digest('hex')
const filePath = path.join(CACHE_DIR, `${hash}.mp3`)
+88 -49
View File
@@ -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<UserDocument>,
) {}
@@ -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<boolean> {
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<boolean> {
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<boolean> {
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<string, string> = {
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)
}
}
+3
View File
@@ -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'
+1
View File
@@ -215,6 +215,7 @@ export class UserService {
resumeOptimizeCredits: user.resumeOptimizeCredits ?? 0,
resumeDownloadCredits: user.resumeDownloadCredits ?? 0,
freeOptimizeUsed: user.freeOptimizeUsed ?? 0,
shareCredits: user.shareCredits ?? 0,
}
}
}