From 2fbab1072f3e80a1ddf35f11a464f5a04b5ed3ec Mon Sep 17 00:00:00 2001 From: yuzhiran Date: Fri, 19 Jun 2026 22:43:52 +0800 Subject: [PATCH] feat: unified gravity system - VIP members consume gravity instead of unlimited; add monthly gravity top-up cron --- .../ses_15492f54bffepl01q9FkaRyN28.json | 4 +- AGENTS.md | 14 +- backend/scripts/migrate-gravity.ts | 54 ++++ backend/src/modules/admin/admin.controller.ts | 20 +- .../src/modules/member/member.controller.ts | 9 +- .../payment/payment.controller.spec.ts | 11 +- .../src/modules/payment/payment.controller.ts | 84 +++--- .../src/modules/payment/wechat-pay.service.ts | 14 +- .../schedule/gravity-top-up.service.ts | 49 ++++ .../src/modules/schedule/schedule.module.ts | 4 +- .../src/modules/schemas/pricing.service.ts | 17 +- backend/src/modules/share/share.service.ts | 9 +- backend/src/modules/user/quota.service.ts | 167 ++++++----- backend/src/modules/user/user.controller.ts | 11 +- backend/src/modules/user/user.schema.ts | 5 +- backend/src/modules/user/user.service.ts | 56 +++- zhiyin-app/src/pages/admin/admin.vue | 173 +++++++++--- zhiyin-app/src/pages/index/index.vue | 2 +- zhiyin-app/src/pages/interview/interview.vue | 116 +++++++- zhiyin-app/src/pages/member/member.vue | 262 ++++++++++++++++-- zhiyin-app/src/pages/share/share.vue | 47 +++- zhiyin-app/src/pages/user/user.vue | 44 ++- 22 files changed, 956 insertions(+), 216 deletions(-) create mode 100644 backend/scripts/migrate-gravity.ts create mode 100644 backend/src/modules/schedule/gravity-top-up.service.ts diff --git a/.omo/run-continuation/ses_15492f54bffepl01q9FkaRyN28.json b/.omo/run-continuation/ses_15492f54bffepl01q9FkaRyN28.json index e576056..2664573 100644 --- a/.omo/run-continuation/ses_15492f54bffepl01q9FkaRyN28.json +++ b/.omo/run-continuation/ses_15492f54bffepl01q9FkaRyN28.json @@ -1,10 +1,10 @@ { "sessionID": "ses_15492f54bffepl01q9FkaRyN28", - "updatedAt": "2026-06-16T01:20:56.855Z", + "updatedAt": "2026-06-19T07:14:26.627Z", "sources": { "background-task": { "state": "idle", - "updatedAt": "2026-06-16T01:20:56.855Z" + "updatedAt": "2026-06-19T07:14:26.627Z" } } } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 34b163f..a4d159e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,6 +105,10 @@ zhiyin/ ## 三、开发命令 +> ⚠️ **构建铁律:必须始终使用 `npm run build:*` 命令,禁止直接调用 `npx uni build` 或 `npx nest build`。** +> 前端 `npm run build:mp-weixin` 和 `npm run build:h5` 脚本包含头像文件(`avatar-*.png`)复制步骤, +> 后端 `npm run build` 是 `nest build` 的别名。直接使用 `npx` 会遗漏这些关键步骤,导致线上数字人头像不显示等问题。 + ### 后端 ```bash # 路径: backend/ @@ -130,12 +134,12 @@ npm test # 前端单元测试(vitest,7 个) ### 构建检查 ```bash # 后端构建(注意 OOM:需 NODE_OPTIONS="--max-old-space-size=2048") -cd backend && NODE_OPTIONS="--max-old-space-size=2048" npx nest build +cd backend && npm run build ``` ### 部署后端 ```bash -cd backend && npx nest build +cd backend && npm run build cp -rf dist/* /www/wwwroot/server/zhiyin/backend/dist/ cp -r certs /www/wwwroot/server/zhiyin/backend/dist/src/certs pm2 restart yhl-backend @@ -144,7 +148,7 @@ sleep 3 && curl -s http://localhost:3006/api/user/wx-login -X POST -H "Content-T ### 部署前端 H5 ```bash -cd zhiyin-app && npx uni build +cd zhiyin-app && npm run build:h5 rm -rf /www/wwwroot/zhiyin.yzrcloud.cn/assets cp -r dist/build/h5/index.html /www/wwwroot/zhiyin.yzrcloud.cn/ cp -r dist/build/h5/assets /www/wwwroot/zhiyin.yzrcloud.cn/ @@ -153,9 +157,11 @@ chown -R www:www /www/wwwroot/zhiyin.yzrcloud.cn/index.html /www/wwwroot/zhiyin. grep -oP '["'"'"']([a-zA-Z0-9_-]+\.[a-z]+(\.js|\.css|\.png|\.svg))["'"'"']' /www/wwwroot/zhiyin.yzrcloud.cn/assets/index-*.js | sort -u ``` -### 小程序上传 +### 小程序上传(先 build 后 upload,两步分开更安全) ```bash cd zhiyin-app && npm run build:mp-weixin && node scripts/upload-mp.js +# 注意:build:mp-weixin 已自动复制 avatar-*.png 到 dist/build/mp-weixin/static/ +# 如遇数字人头像不显示,检查是否漏了 cp 步骤,重新用 npm run build:mp-weixin 构建 ``` --- diff --git a/backend/scripts/migrate-gravity.ts b/backend/scripts/migrate-gravity.ts new file mode 100644 index 0000000..4d2e3bc --- /dev/null +++ b/backend/scripts/migrate-gravity.ts @@ -0,0 +1,54 @@ +/** + * 引力值迁移脚本 + * 将现有用户的多维额度合并到 gravity 字段 + * 公式: gravity = interviewCredits×5 + resumeOptimizeCredits×3 + resumeDownloadCredits×2 + shareCredits×1 + remaining×5 + * 用法: npx ts-node --project tsconfig.json scripts/migrate-gravity.ts + */ +import { NestFactory } from '@nestjs/core' +import { AppModule } from '../src/app.module' +import { getModelToken } from '@nestjs/mongoose' +import { User, UserDocument } from '../src/modules/user/user.schema' +import { Model } from 'mongoose' + +async function bootstrap() { + const app = await NestFactory.createApplicationContext(AppModule) + const userModel = app.get>(getModelToken(User.name)) + + const total = await userModel.countDocuments().exec() + console.log(`Total users: ${total}`) + + let migrated = 0 + let skipped = 0 + const cursor = userModel.find().cursor() + + for await (const user of cursor) { + const interviewVal = (user.interviewCredits ?? 0) * 5 + const optimizeVal = (user.resumeOptimizeCredits ?? 0) * 3 + const downloadVal = (user.resumeDownloadCredits ?? 0) * 2 + const oldRemainVal = (user.remaining ?? 0) * 5 + const shareVal = (user.shareCredits ?? 0) * 1 + const totalGravity = interviewVal + optimizeVal + downloadVal + oldRemainVal + shareVal + + if (totalGravity <= 0 && (user.gravity ?? 0) === 0) { + skipped++ + continue + } + + await userModel.findByIdAndUpdate(user._id, { + $set: { + gravity: Math.max(user.gravity ?? 0, totalGravity), + interviewCredits: 0, + resumeOptimizeCredits: 0, + resumeDownloadCredits: 0, + remaining: 0, + shareCredits: 0, + }, + }).exec() + migrated++ + } + + console.log(`Migrated: ${migrated}, Skipped (no credits): ${skipped}`) + await app.close() +} + +bootstrap().catch(console.error) diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index 6737350..d0678ce 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -113,7 +113,7 @@ export class AdminController { expireAt.setDate(expireAt.getDate() + (pricing.plans?.growth?.durationDays || VIP_DURATION_DAYS)) user.plan = 'growth' user.vipExpireAt = expireAt - await this.quotaService.setPlanQuota(targetUserId, 'growth', credits) + await this.quotaService.setPlanQuota(targetUserId, pricing.plans?.growth?.gravityPerMonth || 250) return { success: true, plan: 'growth', expireAt } } @@ -122,7 +122,7 @@ export class AdminController { if (!userId || !type || amount === undefined) { throw new HttpException('参数不完整', HttpStatus.BAD_REQUEST) } - const validTypes = ['interviewCredits', 'resumeOptimizeCredits', 'resumeDownloadCredits', 'shareCredits'] + const validTypes = ['interviewCredits', 'resumeOptimizeCredits', 'resumeDownloadCredits', 'shareCredits', 'gravity'] if (!validTypes.includes(type)) { throw new HttpException('无效的额度类型', HttpStatus.BAD_REQUEST) } @@ -291,18 +291,18 @@ export class AdminController { } else { user.vipExpireAt = expireAt } - await this.quotaService.setPlanQuota(order.userId, planId, credits) + await this.quotaService.setPlanQuota(order.userId, planCfg.gravityPerMonth) } } 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 gravityMap: Record = { + interview: pricing.gravityRates?.interviewPerUse || 5, + optimize: pricing.gravityRates?.optimizePerUse || 3, + download: pricing.gravityRates?.downloadPerUse || 2, } - const credits = creditMap[order.type] - if (credits) { - await this.quotaService.grantCredits(order.userId, order.type as any, credits) + const g = gravityMap[order.type] + if (g) { + await this.quotaService.grantGravity(order.userId, g) } } } diff --git a/backend/src/modules/member/member.controller.ts b/backend/src/modules/member/member.controller.ts index 71b0641..dd06e03 100644 --- a/backend/src/modules/member/member.controller.ts +++ b/backend/src/modules/member/member.controller.ts @@ -54,6 +54,12 @@ export class MemberController { return { interview: { dailyFreeLimit: FREE_DAILY_LIMIT, maxRoundsFree: 5, maxRoundsVip: 10 }, + gravityRates: pricing.gravityRates, + products: { + interview: { price: pricing.interview.pricePerSession, title: 'AI 模拟面试单次', gravity: pricing.gravityRates.interviewPerUse }, + optimize: { price: pricing.resumeOptimize.pricePerOptimize, title: '简历优化单次', gravity: pricing.gravityRates.optimizePerUse }, + download: { price: pricing.resumeDownload.pricePerDownload, title: '简历下载', gravity: pricing.gravityRates.downloadPerUse }, + }, plans, } } @@ -66,6 +72,7 @@ export class MemberController { plan: user.plan, planName: user.plan === 'growth' ? '成长版' : user.plan === 'sprint' ? '冲刺版' : '免费版', remaining: user.remaining, + gravity: user.gravity ?? 0, dailyLimit: user.plan !== 'free' ? 999 : FREE_DAILY_LIMIT, vipExpireAt: user.vipExpireAt, sprintExpireAt: user.sprintExpireAt, @@ -101,7 +108,7 @@ export class MemberController { user.plan = 'growth' user.vipExpireAt = expireAt } - await this.quotaService.setPlanQuota(userId, order.plan, planCfg.credits) + await this.quotaService.setPlanQuota(userId, planCfg.gravityPerMonth) return { success: true, plan: user.plan, planName: user.plan === 'growth' ? '成长版' : '冲刺版', expireAt } } diff --git a/backend/src/modules/payment/payment.controller.spec.ts b/backend/src/modules/payment/payment.controller.spec.ts index d0124de..ddf5efc 100644 --- a/backend/src/modules/payment/payment.controller.spec.ts +++ b/backend/src/modules/payment/payment.controller.spec.ts @@ -40,8 +40,8 @@ describe('PaymentController', () => { 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: [] }, - sprint: { price: 4990, durationDays: 30, credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, features: [] }, + growth: { price: 1990, durationDays: 30, gravityPerMonth: 250, credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, features: [] }, + sprint: { price: 4990, durationDays: 30, gravityPerMonth: 600, credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, features: [] }, }, }), } @@ -150,7 +150,7 @@ describe('PaymentController', () => { it('should activate growth plan', async () => { mockOrderModel.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ outTradeNo: 'ORD123', userId: mockUserId, status: 'success', plan: 'growth', type: 'membership' }) }) - const mockUser = { plan: 'free', vipExpireAt: null, sprintExpireAt: null, sprintRemaining: 0, remaining: 0, interviewCredits: 1, resumeOptimizeCredits: 0, resumeDownloadCredits: 0, freeOptimizeUsed: 0, save: jest.fn().mockResolvedValue(true) } + const mockUser = { plan: 'free', vipExpireAt: null, sprintExpireAt: null, sprintRemaining: 0, remaining: 0, gravity: 0, freeOptimizeUsed: 0, save: jest.fn().mockResolvedValue(true) } mockUserModel.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(mockUser) }) const result = await controller.activate(mockUserId, 'ORD123') @@ -158,9 +158,8 @@ describe('PaymentController', () => { expect(result.plan).toBe('growth') expect(mockUser.save).toHaveBeenCalled() expect(mockUser.plan).toBe('growth') - expect(mockUser.interviewCredits).toBe(999) - expect(mockUser.resumeOptimizeCredits).toBe(20) - expect(mockUser.resumeDownloadCredits).toBe(10) + expect(mockUser.gravity).toBe(250) + expect(mockUser.freeOptimizeUsed).toBe(3) }) it('should activate sprint plan', async () => { diff --git a/backend/src/modules/payment/payment.controller.ts b/backend/src/modules/payment/payment.controller.ts index 5ee6f25..7937833 100644 --- a/backend/src/modules/payment/payment.controller.ts +++ b/backend/src/modules/payment/payment.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Post, Get, Param, Body, UseGuards, HttpException, HttpStatus, Logger, Req } from '@nestjs/common' +import { Controller, Post, Get, Param, Body, UseGuards, HttpException, HttpStatus, Logger, Req, HttpCode } from '@nestjs/common' import { InjectModel } from '@nestjs/mongoose' import { Model } from 'mongoose' import { User, UserDocument } from '../user/user.schema' @@ -25,6 +25,7 @@ export class PaymentController { /** 创建套餐订单(H5:Native 扫码支付) */ @UseGuards(JwtAuthGuard) @Post('create') + @HttpCode(200) async create(@CurrentUser('userId') userId: string, @Body('plan') plan: string = 'growth') { if (!['growth', 'sprint'].includes(plan)) throw new HttpException('无效套餐', HttpStatus.BAD_REQUEST) const user = await this.userModel.findById(userId).exec() @@ -49,11 +50,13 @@ export class PaymentController { async createProduct( @CurrentUser('userId') userId: string, @Body('type') type: string, + @Body('quantity') quantity: number = 1, @Body('metadata') metadata?: Record, ) { if (!['interview', 'optimize', 'download'].includes(type)) { throw new HttpException('无效产品类型', HttpStatus.BAD_REQUEST) } + const qty = Math.max(1, Math.min(99, quantity || 1)) const user = await this.userModel.findById(userId).exec() if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) @@ -63,40 +66,55 @@ export class PaymentController { optimize: pricing.resumeOptimize.pricePerOptimize, download: pricing.resumeDownload.pricePerDownload, } - const price = priceMap[type] - if (!price) throw new HttpException('价格未配置', HttpStatus.INTERNAL_SERVER_ERROR) + const price = priceMap[type] * qty + if (!priceMap[type]) throw new HttpException('价格未配置', HttpStatus.INTERNAL_SERVER_ERROR) const titles: Record = { interview: 'AI 模拟面试单次', optimize: '简历优化单次', download: '简历下载', } - const title = titles[type] || type + const title = qty > 1 ? `${titles[type]}×${qty}` : titles[type] const outTradeNo = `${type.slice(0, 3).toUpperCase()}${Date.now()}${userId.slice(-6)}` const result = await this.wechatPay.nativePay(title, outTradeNo, price) - await this.orderModel.create({ outTradeNo, userId, userPhone: user.phone || '', amount: price, title, status: 'pending', channel: 'native', type, plan: 'growth', metadata }) + await this.orderModel.create({ outTradeNo, userId, userPhone: user.phone || '', amount: price, title, status: 'pending', channel: 'native', type, plan: 'growth', metadata: { ...metadata, quantity: qty } }) - return { outTradeNo, codeUrl: result.codeUrl, amount: price, title } + return { outTradeNo, codeUrl: result.codeUrl, amount: price, title, quantity: qty } } /** JSAPI 支付(微信小程序) */ @UseGuards(JwtAuthGuard) @Post('jsapi') + @HttpCode(200) async jsapi(@CurrentUser('userId') userId: string, @Body('plan') plan: string = 'growth') { + this.logger.log(`[jsapi] userId=${userId}, plan=${plan}`) if (!['growth', 'sprint'].includes(plan)) throw new HttpException('无效套餐', HttpStatus.BAD_REQUEST) const user = await this.userModel.findById(userId).exec() - if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) - if (user.plan !== 'free') throw new HttpException('已是会员', HttpStatus.BAD_REQUEST) + if (!user) { this.logger.warn(`[jsapi] 用户不存在 userId=${userId}`); throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) } + this.logger.log(`[jsapi] 用户查询结果: plan=${user.plan}, wxOpenid=${user.wxOpenid ? '已设置' : '空'}, phone=${user.phone || '无'}`) + if (user.plan !== 'free') { this.logger.warn(`[jsapi] 已是会员 plan=${user.plan}`); throw new HttpException('已是会员', HttpStatus.BAD_REQUEST) } const openid = user.wxOpenid - if (!openid) throw new HttpException('未绑定微信', HttpStatus.BAD_REQUEST) + if (!openid) { + this.logger.warn(`[jsapi] 未绑定微信openid userId=${userId}`) + throw new HttpException({ message: '未绑定微信openid', needBindWx: true }, HttpStatus.BAD_REQUEST) + } const pricing = await this.pricingService.getConfig() + this.logger.log(`[jsapi] pricing获取成功`) const planCfg = pricing.plans[plan === 'sprint' ? 'sprint' : 'growth'] const amount = planCfg.price const title = plan === 'sprint' ? '职引冲刺版月度会员' : '职引成长版月度会员' const outTradeNo = `${plan === 'sprint' ? 'SPR' : 'VIP'}${Date.now()}${userId.slice(-6)}` - const result = await this.wechatPay.jsapiPay(title, outTradeNo, amount, openid) + this.logger.log(`[jsapi] 准备调用微信: outTradeNo=${outTradeNo}, amount=${amount}, openid=${openid}`) + let result: any + try { + result = await this.wechatPay.jsapiPay(title, outTradeNo, amount, openid) + this.logger.log(`[jsapi] 微信下单成功 prepayId=${result?.prepayId}`) + } catch (e: any) { + this.logger.error(`[jsapi] 微信下单失败: ${e.message}`, e.response?.data ? JSON.stringify(e.response.data) : '') + throw new HttpException(e.response?.data?.message || '微信支付下单失败', HttpStatus.INTERNAL_SERVER_ERROR) + } await this.orderModel.create({ outTradeNo, userId, userPhone: user.phone || '', amount, title, status: 'pending', channel: 'jsapi', type: 'membership', plan }) @@ -109,15 +127,19 @@ export class PaymentController { async jsapiProduct( @CurrentUser('userId') userId: string, @Body('type') type: string, + @Body('quantity') quantity: number = 1, @Body('metadata') metadata?: Record, ) { if (!['interview', 'optimize', 'download'].includes(type)) { throw new HttpException('无效产品类型', HttpStatus.BAD_REQUEST) } + const qty = Math.max(1, Math.min(99, quantity || 1)) const user = await this.userModel.findById(userId).exec() if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) const openid = user.wxOpenid - if (!openid) throw new HttpException('未绑定微信', HttpStatus.BAD_REQUEST) + if (!openid) { + throw new HttpException({ message: '未绑定微信openid', needBindWx: true }, HttpStatus.BAD_REQUEST) + } const pricing = await this.pricingService.getConfig() const priceMap: Record = { @@ -125,21 +147,21 @@ export class PaymentController { optimize: pricing.resumeOptimize.pricePerOptimize, download: pricing.resumeDownload.pricePerDownload, } - const price = priceMap[type] - if (!price) throw new HttpException('价格未配置', HttpStatus.INTERNAL_SERVER_ERROR) + const price = priceMap[type] * qty + if (!priceMap[type]) throw new HttpException('价格未配置', HttpStatus.INTERNAL_SERVER_ERROR) const titles: Record = { interview: 'AI 模拟面试单次', optimize: '简历优化单次', download: '简历下载', } - const title = titles[type] || type + const title = qty > 1 ? `${titles[type]}×${qty}` : titles[type] const outTradeNo = `${type.slice(0, 3).toUpperCase()}${Date.now()}${userId.slice(-6)}` const result = await this.wechatPay.jsapiPay(title, outTradeNo, price, openid) - await this.orderModel.create({ outTradeNo, userId, userPhone: user.phone || '', amount: price, title, status: 'pending', channel: 'jsapi', type, plan: 'growth', metadata }) + await this.orderModel.create({ outTradeNo, userId, userPhone: user.phone || '', amount: price, title, status: 'pending', channel: 'jsapi', type, plan: 'growth', metadata: { ...metadata, quantity: qty } }) - return { ...result, outTradeNo } + return { ...result, outTradeNo, quantity: qty } } /** 支付回调通知 */ @@ -201,34 +223,22 @@ export class PaymentController { user.plan = 'growth' user.vipExpireAt = expireAt } - const credits = planCfg.credits - user.remaining = 999 - user.interviewCredits = credits.interview - user.resumeOptimizeCredits = credits.resumeOptimize - user.resumeDownloadCredits = credits.resumeDownload + user.gravity = planCfg.gravityPerMonth user.freeOptimizeUsed = 3 await user.save() } private async activateProduct(order: PaymentOrderDocument) { const pricing = await this.pricingService.getConfig() - const creditMap: Record = { - interview: pricing.interview.creditsPerPurchase, - optimize: pricing.resumeOptimize.creditsPerPurchase, - download: pricing.resumeDownload.creditsPerPurchase, - } - const credits = creditMap[order.type] - if (!credits) return - - const typeMap: Record = { - interview: 'interview', - optimize: 'optimize', - download: 'download', - } - const mapped = typeMap[order.type] - if (mapped) { - await this.quotaService.grantCredits(order.userId, mapped, credits) + const gravityMap: Record = { + interview: pricing.gravityRates.interviewPerUse, + optimize: pricing.gravityRates.optimizePerUse, + download: pricing.gravityRates.downloadPerUse, } + const g = gravityMap[order.type] + if (!g) return + const quantity = order.metadata?.quantity || 1 + await this.quotaService.grantGravity(order.userId, g * quantity) } /** 查询订单(微信侧) */ diff --git a/backend/src/modules/payment/wechat-pay.service.ts b/backend/src/modules/payment/wechat-pay.service.ts index f6a90a4..153ac01 100644 --- a/backend/src/modules/payment/wechat-pay.service.ts +++ b/backend/src/modules/payment/wechat-pay.service.ts @@ -51,6 +51,8 @@ export class WechatPayService { /** 发起 API v3 请求 */ private async request(method: string, apiPath: string, body?: any) { const url = `${WX_API_BASE}${apiPath}` + const bodyStr = body ? JSON.stringify(body) : '' + this.logger.log(`[wxpay-request] ${method} ${apiPath} 请求体: ${bodyStr}`) try { const res = await axios({ method, @@ -63,9 +65,12 @@ export class WechatPayService { }, data: body, }) + this.logger.log(`[wxpay-request] ${method} ${apiPath} 成功: ${JSON.stringify(res.data)}`) return res.data } catch (e: any) { - this.logger.error(`微信支付请求失败: ${method} ${apiPath}`, e.response?.data || e.message) + const errDetail = e.response?.data ? JSON.stringify(e.response.data) : e.message + const errStatus = e.response?.status || '无状态码' + this.logger.error(`[wxpay-request] ${method} ${apiPath} 失败 status=${errStatus}: ${errDetail}`) throw e } } @@ -99,8 +104,15 @@ export class WechatPayService { amount: { total: amount, currency: 'CNY' }, payer: { openid }, } + this.logger.log(`[jsapiPay] 下单参数: description=${description}, outTradeNo=${outTradeNo}, amount=${amount}, openid=${openid}`) + this.logger.log(`[jsapiPay] 完整请求体: ${JSON.stringify(body)}`) const result = await this.request('POST', '/v3/pay/transactions/jsapi', body) + this.logger.log(`[jsapiPay] 微信返回: ${JSON.stringify(result)}`) const prepayId = result.prepay_id + if (!prepayId) { + this.logger.error(`[jsapiPay] 微信返回缺少prepay_id: ${JSON.stringify(result)}`) + throw new Error('微信下单失败: 缺少prepay_id') + } // 生成小程序/JSAPI 调起支付参数 const nonce = crypto.randomBytes(16).toString('hex') const timestamp = Math.floor(Date.now() / 1000).toString() diff --git a/backend/src/modules/schedule/gravity-top-up.service.ts b/backend/src/modules/schedule/gravity-top-up.service.ts new file mode 100644 index 0000000..671abf5 --- /dev/null +++ b/backend/src/modules/schedule/gravity-top-up.service.ts @@ -0,0 +1,49 @@ +import { Injectable, Logger } from '@nestjs/common' +import { Cron, CronExpression } from '@nestjs/schedule' +import { InjectModel } from '@nestjs/mongoose' +import { Model } from 'mongoose' +import { User, UserDocument } from '../user/user.schema' +import { PricingService } from '../schemas/pricing.service' + +@Injectable() +export class GravityTopUpService { + private readonly logger = new Logger(GravityTopUpService.name) + + constructor( + @InjectModel(User.name) private userModel: Model, + private pricingService: PricingService, + ) {} + + @Cron(CronExpression.EVERY_DAY_AT_2AM) + async topUpVipGravity() { + this.logger.log('Topping up gravity for active VIP members...') + const pricing = await this.pricingService.getConfig() + const now = new Date() + + // 成长版 —— vipExpireAt 未过期 + const growthPlan = pricing.plans.growth + const growthResult = await this.userModel.updateMany( + { + plan: 'growth', + vipExpireAt: { $gt: now }, + }, + { $inc: { gravity: growthPlan.gravityPerMonth } }, + ).exec() + if (growthResult.modifiedCount > 0) { + this.logger.log(`Growth plan: topped up ${growthResult.modifiedCount} users with ${growthPlan.gravityPerMonth} gravity each`) + } + + // 冲刺版 —— sprintExpireAt 未过期 + const sprintPlan = pricing.plans.sprint + const sprintResult = await this.userModel.updateMany( + { + plan: 'sprint', + sprintExpireAt: { $gt: now }, + }, + { $inc: { gravity: sprintPlan.gravityPerMonth } }, + ).exec() + if (sprintResult.modifiedCount > 0) { + this.logger.log(`Sprint plan: topped up ${sprintResult.modifiedCount} users with ${sprintPlan.gravityPerMonth} gravity each`) + } + } +} diff --git a/backend/src/modules/schedule/schedule.module.ts b/backend/src/modules/schedule/schedule.module.ts index 8e80384..6c69a1e 100644 --- a/backend/src/modules/schedule/schedule.module.ts +++ b/backend/src/modules/schedule/schedule.module.ts @@ -3,8 +3,10 @@ import { MongooseModule } from '@nestjs/mongoose' import { DailyQuestionPushService } from './daily-question-push.service' import { WechatTokenService } from './wechat-token.service' import { VipExpiryService } from './vip-expiry.service' +import { GravityTopUpService } from './gravity-top-up.service' import { DailyQuestion, DailyQuestionSchema } from '../schemas/daily-question.schema' import { User, UserSchema } from '../user/user.schema' +import { PricingService } from '../schemas/pricing.service' @Module({ imports: [ @@ -13,6 +15,6 @@ import { User, UserSchema } from '../user/user.schema' { name: User.name, schema: UserSchema }, ]), ], - providers: [WechatTokenService, DailyQuestionPushService, VipExpiryService], + providers: [WechatTokenService, DailyQuestionPushService, VipExpiryService, GravityTopUpService, PricingService], }) export class ScheduleModule {} diff --git a/backend/src/modules/schemas/pricing.service.ts b/backend/src/modules/schemas/pricing.service.ts index 2eb17aa..f913185 100644 --- a/backend/src/modules/schemas/pricing.service.ts +++ b/backend/src/modules/schemas/pricing.service.ts @@ -3,13 +3,20 @@ import { InjectModel } from '@nestjs/mongoose' import { Model } from 'mongoose' import { SiteConfig, SiteConfigDocument } from './site-config.schema' +export interface GravityRates { + interviewPerUse: number // 每次面试消耗引力值 + optimizePerUse: number // 每次优化消耗引力值 + downloadPerUse: number // 每次下载消耗引力值 +} + interface PricingConfig { interview: { pricePerSession: number; creditsPerPurchase: number } resumeOptimize: { freeLimit: number; pricePerOptimize: number; creditsPerPurchase: number } resumeDownload: { pricePerDownload: number; creditsPerPurchase: number } + gravityRates: GravityRates plans: { - growth: { price: number; durationDays: number; credits: { interview: number; resumeOptimize: number; resumeDownload: number }; features: string[] } - sprint: { price: number; durationDays: number; credits: { interview: number; resumeOptimize: number; resumeDownload: number }; features: string[] } + growth: { price: number; durationDays: number; gravityPerMonth: number; credits: { interview: number; resumeOptimize: number; resumeDownload: number }; features: string[] } + sprint: { price: number; durationDays: number; gravityPerMonth: number; credits: { interview: number; resumeOptimize: number; resumeDownload: number }; features: string[] } } } @@ -17,9 +24,10 @@ const DEFAULT_PRICING: PricingConfig = { interview: { pricePerSession: 500, creditsPerPurchase: 1 }, resumeOptimize: { freeLimit: 3, pricePerOptimize: 300, creditsPerPurchase: 1 }, resumeDownload: { pricePerDownload: 200, creditsPerPurchase: 1 }, + gravityRates: { interviewPerUse: 5, optimizePerUse: 3, downloadPerUse: 2 }, 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 语音分析(语气词/语速检测)', '技能缺口分析报告', '学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先', '简历优化 50 次/月', '简历下载 30 次/月'] }, + growth: { price: 1990, durationDays: 30, gravityPerMonth: 250, credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, features: ['免费版全部权益', 'AI 数字人面试每次 3 引力值(折扣价)', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库', '简历优化 20 次/月', '简历下载 10 次/月'] }, + sprint: { price: 4990, durationDays: 30, gravityPerMonth: 600, credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, features: ['成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', '学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先', '简历优化 50 次/月', '简历下载 30 次/月'] }, }, } @@ -57,6 +65,7 @@ export class PricingService { interview: { ...DEFAULT_PRICING.interview, ...value?.interview }, resumeOptimize: { ...DEFAULT_PRICING.resumeOptimize, ...value?.resumeOptimize }, resumeDownload: { ...DEFAULT_PRICING.resumeDownload, ...value?.resumeDownload }, + gravityRates: { ...DEFAULT_PRICING.gravityRates, ...value?.gravityRates }, plans: { growth: { ...DEFAULT_PRICING.plans.growth, ...value?.plans?.growth, credits: { ...DEFAULT_PRICING.plans.growth.credits, ...value?.plans?.growth?.credits } }, sprint: { ...DEFAULT_PRICING.plans.sprint, ...value?.plans?.sprint, credits: { ...DEFAULT_PRICING.plans.sprint.credits, ...value?.plans?.sprint?.credits } }, diff --git a/backend/src/modules/share/share.service.ts b/backend/src/modules/share/share.service.ts index 87256c7..762df1c 100644 --- a/backend/src/modules/share/share.service.ts +++ b/backend/src/modules/share/share.service.ts @@ -83,8 +83,11 @@ export class ShareService { if (todayCredited >= DAILY_LIMIT) return { dailyLimitReached: true, visitorUserId } - const shareCreditsResult = await this.quotaService.grantShareCredits(sharerIdStr) - if (!shareCreditsResult) return { creditFailed: true, visitorUserId } + try { + await this.quotaService.grantGravity(sharerIdStr, 1) + } catch (e) { + return { creditFailed: true, visitorUserId } + } await this.visitModel.updateOne( { shareId: share._id, visitorId }, @@ -125,7 +128,7 @@ export class ShareService { totalVisits: visitAgg[0]?.totalVisits ?? 0, creditedCount: visitAgg[0]?.creditedCount ?? 0, todayCredited: todayAgg, - shareCredits: user?.shareCredits ?? 0, + gravity: user?.gravity ?? 0, } } diff --git a/backend/src/modules/user/quota.service.ts b/backend/src/modules/user/quota.service.ts index fb42abd..6f1d4ff 100644 --- a/backend/src/modules/user/quota.service.ts +++ b/backend/src/modules/user/quota.service.ts @@ -2,6 +2,7 @@ import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common' import { InjectModel } from '@nestjs/mongoose' import { Model } from 'mongoose' import { User, UserDocument } from './user.schema' +import { PricingService } from '../schemas/pricing.service' const FREE_OPTIMIZE_LIMIT = 3 @@ -11,119 +12,143 @@ export class QuotaService { constructor( @InjectModel(User.name) private userModel: Model, + private pricingService: PricingService, ) {} + /** 检查并扣除面试引力值(所有计划统一走引力值) */ async checkAndDeductInterview(userId: string) { const user = await this.userModel.findById(userId).exec() if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) - if (user.plan !== 'free') return - // 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() + // 迁移旧字段到 gravity + if ((user.gravity ?? 0) <= 0 && this.hasOldCredits(user)) { + await this.migrateOldCredits(userId) } - const result = await this.userModel.findOneAndUpdate( - { _id: userId, interviewCredits: { $gt: 0 } }, - { $inc: { interviewCredits: -1, interviewCount: 1 } }, - { new: true }, - ).exec() + const rates = (await this.pricingService.getConfig()).gravityRates + const cost = rates.interviewPerUse + + // 用 gravity 支付,后备 shareCredits + const result = await this.deductGravityOrFallback(userId, cost) 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) + throw new HttpException('引力值不足,请充值或分享获取', HttpStatus.FORBIDDEN) } + /** 检查并扣除优化引力值(所有计划统一走引力值) */ async checkAndDeductOptimize(userId: string) { const user = await this.userModel.findById(userId).exec() if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) - if (user.plan !== 'free') return - // Try paid credits first - const paid = await this.userModel.findOneAndUpdate( - { _id: userId, resumeOptimizeCredits: { $gt: 0 } }, - { $inc: { resumeOptimizeCredits: -1 } }, - ).exec() - if (paid) return + // 迁移旧字段 + if ((user.gravity ?? 0) <= 0 && this.hasOldCredits(user)) { + await this.migrateOldCredits(userId) + } - // Try old remaining credits (backward compat) - const oldRemaining = await this.userModel.findOneAndUpdate( - { _id: userId, remaining: { $gt: 0 } }, - { $inc: { remaining: -1 } }, - ).exec() - if (oldRemaining) 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 rates = (await this.pricingService.getConfig()).gravityRates + const cost = rates.optimizePerUse + + const result = await this.deductGravityOrFallback(userId, cost) + if (result) return + + throw new HttpException('引力值不足,请充值或分享获取', HttpStatus.FORBIDDEN) + } + + /** 检查并扣除下载引力值 */ + async checkAndDeductDownload(userId: string, paidDownload: boolean): Promise { + if (paidDownload) return true + + const rates = (await this.pricingService.getConfig()).gravityRates + const cost = rates.downloadPerUse + + const result = await this.deductGravityOrFallback(userId, cost) + if (result) return true + + return false + } + + /** 从 gravity 扣除,后备从 shareCredits 扣除(兼容旧数据) */ + private async deductGravityOrFallback(userId: string, cost: number): Promise { + // 主路径:gravity + const gravResult = await this.userModel.findOneAndUpdate( + { _id: userId, gravity: { $gte: cost } }, + { $inc: { gravity: -cost } }, + ).exec() + if (gravResult) return true + + // 后备:旧 shareCredits const shareResult = await this.userModel.findOneAndUpdate( { _id: userId, shareCredits: { $gt: 0 } }, { $inc: { shareCredits: -1 } }, ).exec() - if (shareResult) return + if (shareResult) return true - throw new HttpException('简历优化次数已用完,请购买优化次数或开通会员', HttpStatus.FORBIDDEN) + return false } - async grantShareCredits(userId: string, amount = 1): Promise { - const result = await this.userModel.findByIdAndUpdate( - userId, - { $inc: { shareCredits: amount } }, - ).exec() - return !!result - } - - async checkAndDeductDownload(userId: string, paidDownload: boolean): Promise { - if (paidDownload) return true - - 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) { + /** 增加引力值 */ + async grantGravity(userId: string, amount: number) { if (amount <= 0) throw new HttpException('无效数量', HttpStatus.BAD_REQUEST) - - const fieldMap: Record = { - interview: 'interviewCredits', - optimize: 'resumeOptimizeCredits', - download: 'resumeDownloadCredits', - } - const field = fieldMap[type] - if (!field) throw new HttpException('无效类型', HttpStatus.BAD_REQUEST) - const result = await this.userModel.findByIdAndUpdate( userId, - { $inc: { [field]: amount } }, + { $inc: { gravity: amount } }, ).exec() if (!result) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) } - async setPlanQuota(userId: string, _plan: string, credits: { interview: number; resumeOptimize: number; resumeDownload: number }) { + /** 设置 VIP 套餐引力值额度 */ + async setPlanQuota(userId: string, gravityAmount: number) { const result = await this.userModel.findByIdAndUpdate(userId, { $set: { - remaining: 999, - interviewCredits: credits.interview, - resumeOptimizeCredits: credits.resumeOptimize, - resumeDownloadCredits: credits.resumeDownload, + gravity: gravityAmount, freeOptimizeUsed: FREE_OPTIMIZE_LIMIT, }, }).exec() if (!result) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) } + + /** 是否为非会员用户授予初始引力值 */ + async grantFreeGravity(userId: string) { + await this.userModel.findByIdAndUpdate(userId, { + $set: { interviewCredits: 1, gravity: 5 }, + }).exec() + } + + /** 判断是否有旧额度需要迁移 */ + private hasOldCredits(user: UserDocument): boolean { + return (user.interviewCredits ?? 0) > 0 + || (user.resumeOptimizeCredits ?? 0) > 0 + || (user.resumeDownloadCredits ?? 0) > 0 + || (user.remaining ?? 0) > 0 + } + + /** 迁移旧额度到 gravity */ + private async migrateOldCredits(userId: string) { + const user = await this.userModel.findById(userId).exec() + if (!user) return + const interviewVal = (user.interviewCredits ?? 0) * 5 + const optimizeVal = (user.resumeOptimizeCredits ?? 0) * 3 + const downloadVal = (user.resumeDownloadCredits ?? 0) * 2 + const oldRemainVal = (user.remaining ?? 0) * 5 + const shareVal = (user.shareCredits ?? 0) * 1 + const total = interviewVal + optimizeVal + downloadVal + oldRemainVal + shareVal + if (total <= 0) return + await this.userModel.findByIdAndUpdate(userId, { + $inc: { gravity: total }, + $set: { + interviewCredits: 0, + resumeOptimizeCredits: 0, + resumeDownloadCredits: 0, + remaining: 0, + shareCredits: 0, + }, + }).exec() + } } diff --git a/backend/src/modules/user/user.controller.ts b/backend/src/modules/user/user.controller.ts index 7988d27..80d885e 100644 --- a/backend/src/modules/user/user.controller.ts +++ b/backend/src/modules/user/user.controller.ts @@ -1,7 +1,8 @@ -import { Controller, Post, Get, Put, Body, Req, HttpCode, HttpStatus } from '@nestjs/common' +import { Controller, Post, Get, Put, Body, Req, HttpCode, HttpStatus, UseGuards } from '@nestjs/common' import { UserService } from './user.service' import { Public } from '../../common/decorators/public.decorator' import { CurrentUser } from '../../common/decorators/current-user.decorator' +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard' @Controller('user') export class UserController { @@ -29,6 +30,14 @@ export class UserController { return this.userService.sendEmailCode(email) } + /** 绑定微信 openid 到当前登录用户 */ + @UseGuards(JwtAuthGuard) + @Post('bind-wx') + @HttpCode(HttpStatus.OK) + async bindWx(@CurrentUser('userId') userId: string, @Body('code') code: string) { + return this.userService.bindWxOpenid(userId, code) + } + @Public() @Post('email-login') @HttpCode(HttpStatus.OK) diff --git a/backend/src/modules/user/user.schema.ts b/backend/src/modules/user/user.schema.ts index dff0052..2ed440e 100644 --- a/backend/src/modules/user/user.schema.ts +++ b/backend/src/modules/user/user.schema.ts @@ -49,7 +49,10 @@ export class User { freeOptimizeUsed: number // 已使用免费优化次数(上限 3) @Prop({ default: 0 }) - shareCredits: number // 分享积分,每 3 次有效访问获 1 积分 + gravity: number // 引力值(统一额度),面试 5、优化 3、下载 2 + + @Prop({ default: 0 }) + shareCredits: number // 已合并到 gravity,保留字段防报错 @Prop({ default: 'user' }) role: string // 'user' | 'admin' diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index a86973e..2e9dff3 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -46,14 +46,13 @@ export class UserService { let user = await this.userModel.findOne({ phone }).exec() if (!user) { - user = await this.userModel.create({ phone, nickname: `用户${phone.slice(-4)}` }) + user = await this.userModel.create({ phone, nickname: `用户${phone.slice(-4)}`, gravity: 5 }) } return this.generateAuthResponse(user) } - async loginByWx(code: string) { - // WeChat silent login - exchange code for openid + async loginByWx(code: string, userId?: string) { const appid = process.env.WX_APPID const secret = process.env.WX_SECRET if (!appid || !secret) { @@ -70,15 +69,59 @@ export class UserService { } const openid = wxData.openid + + if (userId) { + const user = await this.userModel.findById(userId).exec() + if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) + if (user.wxOpenid) throw new HttpException('该账号已绑定微信', HttpStatus.CONFLICT) + user.wxOpenid = openid + await user.save() + return this.generateAuthResponse(user) + } + let user = await this.userModel.findOne({ wxOpenid: openid }).exec() if (!user) { - user = await this.userModel.create({ wxOpenid: openid, nickname: '微信用户' }) + user = await this.userModel.create({ wxOpenid: openid, nickname: '微信用户', gravity: 5 }) } return this.generateAuthResponse(user) } // 📧 邮箱验证码 + async bindWxOpenid(userId: string, code: string) { + this.logger.log(`[bindWx] userId=${userId}, code=${code ? '已提供' : '空'}`) + const appid = process.env.WX_APPID + const secret = process.env.WX_SECRET + if (!appid || !secret) { + this.logger.error(`[bindWx] 微信配置不完整`) + throw new HttpException('微信登录未配置', HttpStatus.SERVICE_UNAVAILABLE) + } + + const wxRes = await fetch( + `https://api.weixin.qq.com/sns/jscode2session?appid=${appid}&secret=${secret}&js_code=${code}&grant_type=authorization_code`, + ) + const wxData: any = await wxRes.json() + this.logger.log(`[bindWx] 微信接口返回: ${JSON.stringify(wxData)}`) + + if (wxData.errcode) { + this.logger.error(`[bindWx] 微信登录失败: ${wxData.errmsg}, rid: ${wxData.rid || '无'}`) + throw new HttpException(`微信登录失败: ${wxData.errmsg}`, HttpStatus.UNAUTHORIZED) + } + + const openid = wxData.openid + this.logger.log(`[bindWx] 获取到openid=${openid}`) + const existing = await this.userModel.findOne({ wxOpenid: openid }).exec() + if (existing) { + this.logger.warn(`[bindWx] openid=${openid} 已绑定到其他用户 ${existing._id}`) + throw new HttpException('该微信号已绑定其他账号', HttpStatus.CONFLICT) + } + + const user = await this.userModel.findByIdAndUpdate(userId, { wxOpenid: openid }, { new: true }).exec() + if (!user) { this.logger.error(`[bindWx] 用户不存在 userId=${userId}`); throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) } + this.logger.log(`[bindWx] openid=${openid} 绑定到用户 ${userId} 成功`) + return { message: '微信绑定成功', wxOpenid: openid } + } + async sendEmailCode(email: string) { if (!email || !email.includes('@')) { throw new HttpException('请输入正确的邮箱地址', HttpStatus.BAD_REQUEST) @@ -113,7 +156,7 @@ export class UserService { if (!user) { isNew = true const nick = email.split('@')[0] - user = await this.userModel.create({ email, nickname: nick, remaining: 3 }) + user = await this.userModel.create({ email, nickname: nick, remaining: 0, gravity: 5 }) } return { ...this.generateAuthResponse(user), isNew, hasPassword: !!user.password } } @@ -148,7 +191,7 @@ export class UserService { } 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 }) + const user = await this.userModel.create({ email, nickname: nick, password: hashed, remaining: 0, gravity: 5 }) return this.generateAuthResponse(user) } @@ -216,6 +259,7 @@ export class UserService { resumeDownloadCredits: user.resumeDownloadCredits ?? 0, freeOptimizeUsed: user.freeOptimizeUsed ?? 0, shareCredits: user.shareCredits ?? 0, + gravity: user.gravity ?? 0, } } } diff --git a/zhiyin-app/src/pages/admin/admin.vue b/zhiyin-app/src/pages/admin/admin.vue index ad2419b..cc0f687 100644 --- a/zhiyin-app/src/pages/admin/admin.vue +++ b/zhiyin-app/src/pages/admin/admin.vue @@ -22,7 +22,8 @@ 定价 分享 岗位 - 管理 + 诊断 + 管理员 @@ -66,10 +67,8 @@ {{ u.plan === 'growth' || u.plan === 'sprint' ? '会员' : '免费' }} - 面试:{{ u.interviewCredits ?? 0 }} - 优化:{{ u.resumeOptimizeCredits ?? 0 }} - 下载:{{ u.resumeDownloadCredits ?? 0 }} - + 引力值:{{ u.gravity ?? 0 }} + + + 引力值消耗 + + 面试消耗引力值/次 + + + + 优化消耗引力值/次 + + + + 下载消耗引力值/次 + + + + 成长版 ¥{{ growthPriceDisplay }} 价格(元/月) + + 每月引力值 + + 面试额度/月 @@ -207,7 +242,9 @@ - 面试额度/月 + 每月引力值 + + @@ -300,13 +337,27 @@ 加载中... + + + + 简历诊断 + 总诊断次数{{ analysisStats.totalDiagnoses ?? 0 }} + 今日诊断{{ analysisStats.todayDiagnoses ?? 0 }} + + + 技能缺口分析 + 总分析次数{{ analysisStats.totalGapAnalysis ?? 0 }} + + 加载中... + + 调整 {{ creditModal.user?.nickname || '用户' }} 的额度 - - {{ t.label }} - + + 引力值 + @@ -333,6 +384,10 @@ {{ posForm.active ? '启用' : '停用' }} + 岗位描述 +