70c4f28eb5
- Backend getUsers: add wxOpenid to regex search - Backend getAdmins: return gravity, plan, wxOpenid, email fields - Frontend admin tab: show full user info (ID copy, email, gravity, plan) - Frontend search result: show complete user details like user list rows
454 lines
19 KiB
TypeScript
454 lines
19 KiB
TypeScript
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<UserDocument>,
|
||
@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,
|
||
) {}
|
||
|
||
@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, orderCount, todayOrders, totalRevenue,
|
||
] = 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(),
|
||
this.orderModel.countDocuments().exec(),
|
||
this.orderModel.countDocuments({ status: 'success', createdAt: { $gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } }).exec(),
|
||
this.orderModel.aggregate([
|
||
{ $match: { status: 'success' } },
|
||
{ $group: { _id: null, total: { $sum: '$amount' } } },
|
||
]).exec(),
|
||
])
|
||
const planBreakdown: Record<string, number> = {}
|
||
planStats.forEach(p => { planBreakdown[p._id || 'free'] = p.count })
|
||
return {
|
||
userCount, interviewCount, todayUsers, todayInterviews,
|
||
resumeCount, paidDownloadCount, orderCount, todayOrders,
|
||
totalRevenue: totalRevenue[0]?.total || 0,
|
||
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' } },
|
||
{ email: { $regex: escaped, $options: 'i' } },
|
||
{ wxOpenid: { $regex: escaped, $options: 'i' } },
|
||
]
|
||
// 支持按 MongoDB _id 搜索(24位 hex)
|
||
if (/^[0-9a-f]{24}$/i.test(keyword)) {
|
||
filter.$or.push({ _id: keyword })
|
||
}
|
||
}
|
||
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 email wxOpenid')
|
||
.select('position status totalScore questionCount fillerScore fillerDensity summary createdAt updatedAt')
|
||
.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, pricing.plans?.growth?.gravityPerMonth || 250)
|
||
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', 'gravity']
|
||
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 email')
|
||
.select('title targetPosition version paidDownload createdAt updatedAt contentHash')
|
||
.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 wxOpenid gravity plan role 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, planCfg.gravityPerMonth)
|
||
}
|
||
} else {
|
||
const pricing = await this.pricingService.getConfig()
|
||
const gravityMap: Record<string, number> = {
|
||
interview: pricing.gravityRates?.interviewPerUse || 5,
|
||
optimize: pricing.gravityRates?.optimizePerUse || 3,
|
||
download: pricing.gravityRates?.downloadPerUse || 2,
|
||
}
|
||
const g = gravityMap[order.type]
|
||
if (g) {
|
||
await this.quotaService.grantGravity(order.userId, g)
|
||
}
|
||
}
|
||
}
|
||
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。' },
|
||
]
|