feat: unified gravity system - VIP members consume gravity instead of unlimited; add monthly gravity top-up cron
This commit is contained in:
@@ -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<Model<UserDocument>>(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)
|
||||
@@ -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<string, number> = {
|
||||
interview: pricing.interview?.creditsPerPurchase || 1,
|
||||
optimize: pricing.resumeOptimize?.creditsPerPurchase || 1,
|
||||
download: pricing.resumeDownload?.creditsPerPurchase || 1,
|
||||
const gravityMap: Record<string, number> = {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string, any>,
|
||||
) {
|
||||
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<string, string> = {
|
||||
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<string, any>,
|
||||
) {
|
||||
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<string, number> = {
|
||||
@@ -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<string, string> = {
|
||||
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<string, number> = {
|
||||
interview: pricing.interview.creditsPerPurchase,
|
||||
optimize: pricing.resumeOptimize.creditsPerPurchase,
|
||||
download: pricing.resumeDownload.creditsPerPurchase,
|
||||
}
|
||||
const credits = creditMap[order.type]
|
||||
if (!credits) return
|
||||
|
||||
const typeMap: Record<string, 'interview' | 'optimize' | 'download'> = {
|
||||
interview: 'interview',
|
||||
optimize: 'optimize',
|
||||
download: 'download',
|
||||
}
|
||||
const mapped = typeMap[order.type]
|
||||
if (mapped) {
|
||||
await this.quotaService.grantCredits(order.userId, mapped, credits)
|
||||
const gravityMap: Record<string, number> = {
|
||||
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)
|
||||
}
|
||||
|
||||
/** 查询订单(微信侧) */
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<UserDocument>,
|
||||
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`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<UserDocument>,
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
// 主路径: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<boolean> {
|
||||
const result = await this.userModel.findByIdAndUpdate(
|
||||
userId,
|
||||
{ $inc: { shareCredits: amount } },
|
||||
).exec()
|
||||
return !!result
|
||||
}
|
||||
|
||||
async checkAndDeductDownload(userId: string, paidDownload: boolean): Promise<boolean> {
|
||||
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<string, string> = {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user