import { Controller, Get, Post, Body, Query, Param, HttpException, HttpStatus, UseGuards } from '@nestjs/common' import { InjectModel } from '@nestjs/mongoose' import { Model } from 'mongoose' import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard' 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 @UseGuards(JwtAuthGuard, AdminGuard) @Controller('admin') export class AdminController { constructor( @InjectModel(User.name) private userModel: Model, @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, ) {} @Get('check') async checkAdmin(@CurrentUser('role') role: string) { return { isAdmin: role === 'admin' } } @Post('verify') async verify(@CurrentUser('userId') userId: string) { const user = await this.userModel.findById(userId).select('nickname').exec() return { ok: true, nickname: user?.nickname || '管理员' } } @Get('overview') async overview() { 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(), ]) const planBreakdown: Record = {} planStats.forEach(p => { planBreakdown[p._id || 'free'] = p.count }) return { userCount, interviewCount, todayUsers, todayInterviews, resumeCount, paidDownloadCount, planBreakdown, } } @Get('users') async getUsers(@Query('keyword') keyword: string, @Query('page') page = '1', @Query('limit') limit = '20') { const filter: any = {} if (keyword) { if (keyword.length > 50) throw new HttpException('关键词过长', HttpStatus.BAD_REQUEST) const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') filter.$or = [ { phone: { $regex: escaped, $options: 'i' } }, { nickname: { $regex: escaped, $options: 'i' } }, ] } const skip = (Math.max(1, +page) - 1) * +limit const [users, total] = await Promise.all([ this.userModel.find(filter).sort({ createdAt: -1 }).skip(skip).limit(+limit).lean().exec(), this.userModel.countDocuments(filter).exec(), ]) return { users, total, page: +page } } @Get('interviews') 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') .select('position status totalScore questionCount fillerScore fillerDensity summary createdAt') .lean().exec(), this.interviewModel.countDocuments().exec(), ]) return { interviews, total, page: +page } } @Post('set-vip') 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() + (pricing.plans?.growth?.durationDays || VIP_DURATION_DAYS)) user.plan = 'growth' user.vipExpireAt = expireAt 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(@Param('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() return { admins } } @Post('set-admin') async setAdmin(@Body('userId') targetUserId: string) { const user = await this.userModel.findById(targetUserId).exec() if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) if (user.role === 'admin') throw new HttpException('该用户已是管理员', HttpStatus.BAD_REQUEST) user.role = 'admin' await user.save() return { success: true, message: '已设为管理员' } } @Get('orders') async getOrders(@Query('page') page = '1', @Query('limit') limit = '20', @Query('status') status: string) { const filter: any = {} if (status) filter.status = status const skip = (Math.max(1, +page) - 1) * +limit const [orders, total] = await Promise.all([ this.orderModel.find(filter).sort({ createdAt: -1 }).skip(skip).limit(+limit).lean().exec(), this.orderModel.countDocuments(filter).exec(), ]) return { orders, total, page: +page } } @Post('order/sync') async syncOrder(@Body('outTradeNo') outTradeNo: string) { const wxResult = await this.wechatPay.queryOrder(outTradeNo) const tradeState = wxResult?.trade_state const order = await this.orderModel.findOne({ outTradeNo }).exec() if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND) if (tradeState === 'SUCCESS' && order.status === 'pending') { order.status = 'success' order.wxTransactionId = wxResult?.transaction_id || '' order.paidAt = new Date() await order.save() 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 planId = order.plan === 'sprint' ? 'sprint' : 'growth' const planCfg = pricing.plans?.[planId] const credits = planCfg?.credits || { interview: 999, resumeOptimize: 20, resumeDownload: 10 } const expireAt = new Date() expireAt.setDate(expireAt.getDate() + (planCfg?.durationDays || VIP_DURATION_DAYS)) user.plan = planId if (planId === 'sprint') { user.sprintExpireAt = expireAt user.sprintRemaining = 10 } else { user.vipExpireAt = expireAt } await this.quotaService.setPlanQuota(order.userId, planId, credits) } } else { 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) } } } return { order, wxResult } } /** 订单详情(含用户信息) */ @Get('order/:outTradeNo') async getOrderDetail(@Param('outTradeNo') outTradeNo: string) { const order = await this.orderModel.findOne({ outTradeNo }).lean().exec() if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND) const user = await this.userModel.findById(order.userId).select('phone nickname plan').lean().exec() return { order, user } } /** 发起退款 */ @Post('order/refund') async refundOrder(@Body('outTradeNo') outTradeNo: string, @Body('amount') amount?: number, @Body('reason') reason?: string) { const order = await this.orderModel.findOne({ outTradeNo }).exec() if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND) if (order.status !== 'success') throw new HttpException('仅支付成功的订单可退款', HttpStatus.BAD_REQUEST) if (order.refundAmount && order.refundAmount > 0) throw new HttpException('该订单已退款', HttpStatus.BAD_REQUEST) const result = await this.wechatPay.refund(outTradeNo, order.amount, amount || order.amount, reason) const refundId = result?.refund_id || '' order.status = 'refunded' order.refundAmount = amount || order.amount order.refundedAt = new Date() order.refundReason = reason || '' order.refundId = refundId await order.save() return { success: true, refundId } } /** 查询微信侧退款状态 */ @Get('order/refund/:outTradeNo') async queryRefund(@Param('outTradeNo') outTradeNo: string) { const order = await this.orderModel.findOne({ outTradeNo }).lean().exec() if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND) if (!order.refundId) return { localStatus: order.status, message: '无微信退款单号' } const wxResult = await this.wechatPay.queryRefund(order.refundId) return { localStatus: order.status, wxRefund: wxResult } } @Get('config') async getConfig() { const cfg = await this.configModel.findOne({ key: 'site_config' }).exec() if (cfg) return cfg.value return DEFAULT_CONFIG } @Post('config/save') async saveConfig(@Body() body: any) { await this.configModel.findOneAndUpdate( { key: 'site_config' }, { key: 'site_config', value: body, description: '站点配置' }, { upsert: true }, ).exec() return { success: true } } // --- Pricing Management --- @Get('pricing') async getPricing() { const cfg = await this.configModel.findOne({ key: 'pricing' }).exec() if (cfg) return cfg.value return DEFAULT_PRICING } @Post('pricing/save') async savePricing(@Body() body: any) { await this.configModel.findOneAndUpdate( { key: 'pricing' }, { key: 'pricing', value: body, description: '定价配置' }, { upsert: true }, ).exec() this.pricingService.invalidateCache() return { success: true } } @Get('questions') async getQuestions() { const cfg = await this.configModel.findOne({ key: 'daily_questions' }).exec() return cfg?.value || DEFAULT_QUESTIONS } @Post('questions/save') async saveQuestions(@Body() body: any) { await this.configModel.findOneAndUpdate( { key: 'daily_questions' }, { key: 'daily_questions', value: body, description: '每日一题题库' }, { upsert: true }, ).exec() return { success: true } } } const DEFAULT_CONFIG = { interview: { maxRoundsFree: 5, maxRoundsVip: 10, dailyFreeLimit: 3 }, diagnosis: { dailyFreeLimit: 2 }, optimize: { dailyFreeLimit: 2 }, price: { monthly: 1990 }, plans: { free: { name: '免费版', price: 0, features: ['AI 模拟面试 1 次(体验)', '基础面试报告', '通用题库随机出题', '简历优化(限 3 次免费)'] }, growth: { name: '成长版', price: 1990, features: ['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题', '参考回答思路', '公司真题库', '简历优化 20 次/月', '简历下载 10 次/月'] }, }, } const DEFAULT_PRICING = { interview: { pricePerSession: 500, creditsPerPurchase: 1 }, resumeOptimize: { freeLimit: 3, pricePerOptimize: 300, creditsPerPurchase: 1 }, resumeDownload: { pricePerDownload: 200, creditsPerPurchase: 1 }, plans: { growth: { price: 1990, durationDays: 30, credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, features: ['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题', '参考回答思路', '公司真题库', '简历优化 20 次/月', '简历下载 10 次/月'], }, sprint: { price: 4990, durationDays: 30, credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, features: ['成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', '公司真题库精选', '简历优化 50 次/月', '简历下载 30 次/月'], }, }, } const DEFAULT_QUESTIONS = [ { position: '通用', category: 'behavioral', question: '请做一个简单的自我介绍,突出你的核心优势和职业目标。', referenceAnswer: '建议结构:1) 基本信息 2) 教育背景与专业方向 3) 实习/项目经历 4) 核心优势 5) 职业目标' }, { position: '前端工程师', category: 'technical', question: '请用 JavaScript 实现一个深拷贝函数,并说明可能存在的问题。', referenceAnswer: '可使用递归遍历,注意循环引用用 WeakMap 处理,特殊类型如 Date/RegExp/Map/Set 需单独处理。' }, { position: '后端工程师', category: 'technical', question: '请说说你对 RESTful API 设计的理解,以及和 GraphQL 的区别。', referenceAnswer: 'RESTful 以资源为核心,使用 HTTP 方法操作;GraphQL 客户端可指定返回字段,减少过度获取。' }, { position: 'AI 算法工程师', category: 'technical', question: '解释一下 Transformer 架构中的 Self-Attention 机制是如何工作的。', referenceAnswer: 'Self-Attention 通过 QKV 计算注意力权重,公式为 Attention(Q,K,V)=softmax(QK^T/√d)V。' }, ]