feat: 登录页密码+验证码双模式 / 首页岗位优化 / 法律页面 / 后端接口完善

- 前端:登录页重构,支持密码登录、验证码登录、注册三种模式
- 前端:首页热门岗位添加「参考示例」标签,去虚构数据
- 前端:面试页顶部优化,岗位名+状态标签展示
- 前端:新增用户协议、隐私政策页面及免责声明
- 后端:新增 POST /api/user/register 注册接口
- 后端:新增 POST /api/user/set-password 设置密码接口
- 后端:修复 user.schema.ts unique 索引导致 null 冲突问题
- 后端:新增 payment-order.schema、positions.schema、site-config.schema
- 后端:package.json 新增 postbuild 脚本自动复制证书
- 管理后台:新增订单管理 Tab
This commit is contained in:
yuzhiran
2026-06-09 15:39:17 +08:00
parent 511f60d0db
commit 37cfdfe93c
27 changed files with 1045 additions and 195 deletions
+114 -17
View File
@@ -6,12 +6,20 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
import { CurrentUser } from '../../common/decorators/current-user.decorator'
import { User, UserDocument } from '../user/user.schema'
import { Interview, InterviewDocument } from '../interview/interview.schema'
import { PaymentOrder, PaymentOrderDocument } from '../payment/payment-order.schema'
import { SiteConfig, SiteConfigDocument } from '../schemas/site-config.schema'
import { WechatPayService } from '../payment/wechat-pay.service'
const VIP_DURATION_DAYS = 30
@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>,
private wechatPay: WechatPayService,
) {}
@Get('check')
@@ -108,26 +116,115 @@ export class AdminController {
return { success: true, message: '已设为管理员' }
}
// ─── 订单管理 ──────────────
@UseGuards(JwtAuthGuard)
@Get('orders')
async getOrders(@Query('page') page = '1', @Query('limit') limit = '20', @Query('status') status: string, @CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
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 }
}
@UseGuards(JwtAuthGuard)
@Post('order/sync')
async syncOrder(@Body('outTradeNo') outTradeNo: string, @CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
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()
const user = await this.userModel.findById(order.userId).exec()
if (user && user.plan !== 'vip') {
const expireAt = new Date()
expireAt.setDate(expireAt.getDate() + VIP_DURATION_DAYS)
user.plan = 'vip'
user.vipExpireAt = expireAt
user.remaining = 999
await user.save()
}
}
return { order, wxResult }
}
// ─── 系统配置(支持管理后台编辑) ──────
@UseGuards(JwtAuthGuard)
@Get('config')
async getConfig(@CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
return {
interview: {
maxRoundsFree: 5,
maxRoundsVip: 10,
dailyFreeLimit: 3,
},
diagnosis: {
dailyFreeLimit: 2,
},
optimize: {
dailyFreeLimit: 2,
},
price: {
monthly: 2900, // 分
},
}
// 从数据库读,不存在则返回默认值
const cfg = await this.configModel.findOne({ key: 'site_config' }).exec()
if (cfg) return cfg.value
return DEFAULT_CONFIG
}
}
@UseGuards(JwtAuthGuard)
@Post('config/save')
async saveConfig(@Body() body: any, @CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
await this.configModel.findOneAndUpdate(
{ key: 'site_config' },
{ key: 'site_config', value: body, description: '站点配置' },
{ upsert: true },
).exec()
return { success: true }
}
// ─── 每日一题管理 ──────────────
@UseGuards(JwtAuthGuard)
@Get('questions')
async getQuestions(@CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
const cfg = await this.configModel.findOne({ key: 'daily_questions' }).exec()
return cfg?.value || DEFAULT_QUESTIONS
}
@UseGuards(JwtAuthGuard)
@Post('questions/save')
async saveQuestions(@Body() body: any, @CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
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: ['每日 3 次 AI 模拟面试', '每场最多 5 轮 AI 对话', '基础面试报告', '简历诊断', '简历优化'] },
growth: { name: '成长版', price: 1990, features: ['免费版全部权益', '无限面试次数', '每场最多 10 轮 AI 对话', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '参考回答思路', '公司真题库'] },
},
}
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。' },
]
+11 -2
View File
@@ -3,12 +3,21 @@ import { MongooseModule } from '@nestjs/mongoose'
import { AdminController } from './admin.controller'
import { User, UserSchema } from '../user/user.schema'
import { Interview, InterviewSchema } from '../interview/interview.schema'
import { PaymentOrder, PaymentOrderSchema } from '../payment/payment-order.schema'
import { WechatPayService } from '../payment/wechat-pay.service'
import { SiteConfig, SiteConfigSchema } from '../schemas/site-config.schema'
@Module({
imports: [
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
MongooseModule.forFeature([{ name: Interview.name, schema: InterviewSchema }]),
MongooseModule.forFeature([
{ name: User.name, schema: UserSchema },
{ name: Interview.name, schema: InterviewSchema },
{ name: PaymentOrder.name, schema: PaymentOrderSchema },
{ name: SiteConfig.name, schema: SiteConfigSchema },
]),
],
controllers: [AdminController],
providers: [WechatPayService],
})
export class AdminModule {}
@@ -0,0 +1,52 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document } from 'mongoose'
export type PaymentOrderDocument = PaymentOrder & Document
@Schema({ timestamps: true })
export class PaymentOrder {
@Prop({ required: true, index: true })
outTradeNo: string
@Prop({ required: true, index: true })
userId: string
@Prop({ default: '' })
userPhone?: string
@Prop({ required: true })
amount: number // 分
@Prop({ required: true })
title: string
@Prop({ default: '' })
description?: string
// pending | success | failed | refunded | partial_refund
@Prop({ default: 'pending' })
status: string
@Prop({ default: 'native' })
channel: string // native | jsapi
@Prop()
paidAt?: Date
@Prop()
wxTransactionId?: string
// 退款
@Prop({ default: 0 })
refundAmount?: number
@Prop()
refundedAt?: Date
@Prop()
refundReason?: string
}
export const PaymentOrderSchema = SchemaFactory.createForClass(PaymentOrder)
PaymentOrderSchema.index({ createdAt: -1 })
PaymentOrderSchema.index({ status: 1, createdAt: -1 })
@@ -1,19 +1,21 @@
import { Controller, Post, Body, UseGuards, HttpException, HttpStatus } from '@nestjs/common'
import { Controller, Post, Get, Query, Body, UseGuards, HttpException, HttpStatus } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { User, UserDocument } from '../user/user.schema'
import { PaymentOrder, PaymentOrderDocument } from './payment-order.schema'
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
import { CurrentUser } from '../../common/decorators/current-user.decorator'
import { WechatPayService } from './wechat-pay.service'
import { Public } from '../../common/decorators/public.decorator'
const VIP_AMOUNT = 2900 // 29 元(分)
const VIP_AMOUNT = 1990 // 19.9 元(分)
const VIP_DURATION_DAYS = 30
@Controller('payment')
export class PaymentController {
constructor(
@InjectModel(User.name) private userModel: Model<UserDocument>,
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
private wechatPay: WechatPayService,
) {}
@@ -26,20 +28,28 @@ export class PaymentController {
if (user.plan === 'vip') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST)
const outTradeNo = `VIP${Date.now()}${userId.slice(-6)}`
const result = await this.wechatPay.nativePay(
'职引月度会员',
const result = await this.wechatPay.nativePay('AI磁场月度会员', outTradeNo, VIP_AMOUNT)
// 保存订单
await this.orderModel.create({
outTradeNo,
VIP_AMOUNT,
)
return {
outTradeNo: result.outTradeNo,
codeUrl: result.codeUrl, // 二维码链接
userId,
userPhone: user.phone || '',
amount: VIP_AMOUNT,
title: '职引月度会员',
title: 'AI磁场月度会员',
status: 'pending',
channel: 'native',
})
return {
outTradeNo,
codeUrl: result.codeUrl,
amount: VIP_AMOUNT,
title: 'AI磁场月度会员',
}
}
/** JSAPI 支付(微信小程序/公众号内使用 */
/** JSAPI 支付(微信小程序) */
@UseGuards(JwtAuthGuard)
@Post('jsapi')
async jsapi(@CurrentUser('userId') userId: string, @Body('openid') openid: string) {
@@ -49,21 +59,45 @@ export class PaymentController {
if (user.plan === 'vip') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST)
const outTradeNo = `VIP${Date.now()}${userId.slice(-6)}`
const result = await this.wechatPay.jsapiPay('职引月度会员', outTradeNo, VIP_AMOUNT, openid)
const result = await this.wechatPay.jsapiPay('AI磁场月度会员', outTradeNo, VIP_AMOUNT, openid)
// 保存订单
await this.orderModel.create({
outTradeNo,
userId,
userPhone: user.phone || '',
amount: VIP_AMOUNT,
title: 'AI磁场月度会员',
status: 'pending',
channel: 'jsapi',
})
return result
}
/** 支付回调通知 */
@Public()
@Post('notify')
async notify(@Body() body: any, @Body('headers') headers: any) {
// 实际运行时从 request 读取 header
async notify(@Body() body: any) {
try {
const decrypted = this.wechatPay.verifyAndDecrypt(body, '', '', '')
if (!decrypted) return { code: 'FAIL', message: '验签失败' }
// 处理成功支付
const outTradeNo = decrypted.out_trade_no
const userId = outTradeNo.slice(-6) // 从订单号取 userId
const wxTransactionId = decrypted.transaction_id
// 更新订单状态
const order = await this.orderModel.findOne({ outTradeNo }).exec()
if (order && order.status === 'pending') {
order.status = 'success'
order.paidAt = new Date()
order.wxTransactionId = wxTransactionId
order.description = `${decrypted.trade_type || ''} 支付成功`
await order.save()
}
// 开通会员
const userId = outTradeNo.slice(-6)
const user = await this.userModel.findOne({ _id: { $regex: userId + '$' } }).exec()
if (user && user.plan !== 'vip') {
const expireAt = new Date()
@@ -79,7 +113,7 @@ export class PaymentController {
}
}
/** 查询订单 */
/** 查询订单(微信侧) */
@UseGuards(JwtAuthGuard)
@Post('query')
async query(@Body('outTradeNo') outTradeNo: string) {
@@ -4,8 +4,13 @@ import { PaymentController } from './payment.controller'
import { WechatPayService } from './wechat-pay.service'
import { User, UserSchema } from '../user/user.schema'
import { PaymentOrder, PaymentOrderSchema } from './payment-order.schema'
@Module({
imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])],
imports: [MongooseModule.forFeature([
{ name: User.name, schema: UserSchema },
{ name: PaymentOrder.name, schema: PaymentOrderSchema },
])],
controllers: [PaymentController],
providers: [WechatPayService],
exports: [WechatPayService],
@@ -135,10 +135,11 @@ export class WechatPayService {
const associatedData = resource.associated_data || ''
const nonce = resource.nonce
const key = API_V3_KEY
if (!key) throw new Error('WX_API_V3_KEY 未配置')
// AES-256-GCM 解密
const authTag = ciphertext.subarray(ciphertext.length - 16)
const data = ciphertext.subarray(0, ciphertext.length - 16)
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce)
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(key), nonce)
decipher.setAAD(Buffer.from(associatedData))
decipher.setAuthTag(authTag)
const decrypted = decipher.update(data) + decipher.final('utf8')
@@ -1,17 +1,54 @@
import { Controller, Get } from '@nestjs/common'
import { Controller, Get, Body, Post, Delete, Param, UseGuards, HttpException, HttpStatus } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { HotPosition, HotPositionDocument } from './positions.schema'
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
import { Public } from '../../common/decorators/public.decorator'
import { CurrentUser } from '../../common/decorators/current-user.decorator'
import { User, UserDocument } from '../user/user.schema'
@Controller('positions')
export class PositionsController {
constructor(
@InjectModel(HotPosition.name) private positionModel: Model<HotPositionDocument>,
@InjectModel(User.name) private userModel: Model<UserDocument>,
) {}
@Public()
@Get('hot')
hot() {
return [
{ name: '前端工程师', salary: '15-25K', company: '腾讯' },
{ name: '后端工程师', salary: '18-30K', company: '阿里巴巴' },
{ name: 'AI 算法工程师', salary: '20-35K', company: '字节跳动' },
{ name: '产品经理', salary: '12-20K', company: '美团' },
{ name: 'UI 设计师', salary: '10-18K', company: '网易' },
]
async hot() {
return this.positionModel.find({ active: true }).sort({ sort: 1 }).lean().exec()
}
// ─── 管理后台 CRUD ──────────────────────
@UseGuards(JwtAuthGuard)
@Post('admin/list')
async adminList(@CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
return this.positionModel.find().sort({ sort: 1 }).lean().exec()
}
@UseGuards(JwtAuthGuard)
@Post('admin/save')
async save(@Body() body: HotPosition & { _id?: string }, @CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
if (body._id) {
await this.positionModel.findByIdAndUpdate(body._id, body).exec()
return { success: true }
}
const created = await this.positionModel.create(body)
return { success: true, id: created._id }
}
@UseGuards(JwtAuthGuard)
@Delete('admin/:id')
async remove(@Param('id') id: string, @CurrentUser('userId') adminUserId: string) {
const admin = await this.userModel.findById(adminUserId).exec()
if (admin?.role !== 'admin') throw new HttpException('无权限', HttpStatus.FORBIDDEN)
await this.positionModel.findByIdAndDelete(id).exec()
return { success: true }
}
}
@@ -1,7 +1,16 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { PositionsController } from './positions.controller'
import { HotPosition, HotPositionSchema } from './positions.schema'
import { User, UserSchema } from '../user/user.schema'
@Module({
imports: [
MongooseModule.forFeature([
{ name: HotPosition.name, schema: HotPositionSchema },
{ name: User.name, schema: UserSchema },
]),
],
controllers: [PositionsController],
})
export class PositionsModule {}
@@ -0,0 +1,28 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document } from 'mongoose'
export type HotPositionDocument = HotPosition & Document
@Schema({ timestamps: true })
export class HotPosition {
@Prop({ required: true })
name: string
@Prop({ default: '' })
salary?: string
@Prop({ default: '' })
company?: string
@Prop({ default: '' })
icon?: string
@Prop({ default: 0 })
sort: number
@Prop({ default: true })
active: boolean
}
export const HotPositionSchema = SchemaFactory.createForClass(HotPosition)
HotPositionSchema.index({ sort: 1 })
@@ -0,0 +1,18 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document } from 'mongoose'
export type SiteConfigDocument = SiteConfig & Document
@Schema({ timestamps: true })
export class SiteConfig {
@Prop({ required: true, unique: true })
key: string
@Prop({ required: true, type: Object })
value: any
@Prop({ default: '' })
description?: string
}
export const SiteConfigSchema = SchemaFactory.createForClass(SiteConfig)
@@ -32,6 +32,20 @@ export class UserController {
return this.userService.loginByEmail(email, code)
}
// 密码登录
@Public()
@Post('password-login')
async passwordLogin(@Body('email') email: string, @Body('password') password: string) {
return this.userService.loginByPassword(email, password)
}
// 邮箱+密码注册
@Public()
@Post('register')
async register(@Body('email') email: string, @Body('password') password: string) {
return this.userService.registerWithPassword(email, password)
}
// 微信静默登录
@Public()
@Post('wx-login')
@@ -53,4 +67,9 @@ export class UserController {
async getUsage(@CurrentUser('userId') userId: string) {
return this.userService.getUsage(userId)
}
@Post('set-password')
async setPassword(@CurrentUser('userId') userId: string, @Body('password') password: string) {
return this.userService.setPassword(userId, password)
}
}
+6 -3
View File
@@ -5,10 +5,10 @@ export type UserDocument = User & Document
@Schema({ timestamps: true })
export class User {
@Prop({ unique: true, sparse: true })
@Prop({ sparse: true })
phone?: string
@Prop({ unique: true, sparse: true })
@Prop({ sparse: true })
wxOpenid?: string
@Prop({ default: '' })
@@ -35,8 +35,11 @@ export class User {
@Prop({ default: false })
isSystemAdmin: boolean
@Prop({ unique: true, sparse: true })
@Prop({ sparse: true })
email?: string
@Prop({ default: '' })
password?: string
}
export const UserSchema = SchemaFactory.createForClass(User)
+48 -1
View File
@@ -1,4 +1,5 @@
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
import * as bcrypt from 'bcrypt'
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { JwtService } from '@nestjs/jwt'
@@ -104,13 +105,59 @@ export class UserService {
// 按邮箱查找或创建用户
let user = await this.userModel.findOne({ email }).exec()
let isNew = false
if (!user) {
isNew = true
const nick = email.split('@')[0]
user = await this.userModel.create({ email, nickname: nick, remaining: 3 })
}
return { ...this.generateAuthResponse(user), isNew, hasPassword: !!user.password }
}
// 🔐 密码登录
async loginByPassword(email: string, password: string) {
const user = await this.userModel.findOne({ email }).exec()
if (!user) throw new HttpException('账号不存在', HttpStatus.NOT_FOUND)
if (!user.password) throw new HttpException('该账号未设置密码,请使用验证码登录', HttpStatus.UNAUTHORIZED)
const match = await bcrypt.compare(password, user.password)
if (!match) throw new HttpException('密码错误', HttpStatus.UNAUTHORIZED)
return this.generateAuthResponse(user)
}
// 📝 邮箱+密码注册
async registerWithPassword(email: string, password: string) {
if (!email || !email.includes('@')) {
throw new HttpException('请输入正确的邮箱地址', HttpStatus.BAD_REQUEST)
}
if (!password || password.length < 6) {
throw new HttpException('密码至少6位', HttpStatus.BAD_REQUEST)
}
const existing = await this.userModel.findOne({ email }).exec()
if (existing) {
if (existing.password) {
throw new HttpException('该邮箱已注册,请直接登录', HttpStatus.CONFLICT)
}
// 已有验证码注册的用户,补充设置密码
existing.password = await bcrypt.hash(password, 10)
await existing.save()
return this.generateAuthResponse(existing)
}
const nick = email.split('@')[0]
const hashed = await bcrypt.hash(password, 10)
const user = await this.userModel.create({ email, nickname: nick, password: hashed, remaining: 3 })
return this.generateAuthResponse(user)
}
// 🔑 已登录用户设置/修改密码
async setPassword(userId: string, password: string) {
if (!password || password.length < 6) {
throw new HttpException('密码至少6位', HttpStatus.BAD_REQUEST)
}
const hashed = await bcrypt.hash(password, 10)
await this.userModel.findByIdAndUpdate(userId, { password: hashed })
return { message: '密码设置成功' }
}
async getInfo(userId: string) {
const user = await this.userModel.findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)