feat: realistic face avatar + voice input + ASR endpoint
This commit is contained in:
@@ -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,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user