feat: gravity transaction logging + user-facing history popup
- New GravityTransaction schema tracks all gravity changes (registration, interview/optimize/download deduction, purchase, monthly topup, migration) - GravityTopUpService: bulk log for monthly VIP topup - PaymentController.activateMembership: log plan_set transactions - QuotaService: add logTransaction, wire into all gravity-modifying methods - UserService: log registration grants (phone/wx/email/password) - GET /user/gravity-transactions?page=&limit= API - Frontend: user.vue '明细' button + paginated popup - Docs: update PROJECT-STATUS v4.10, FEATURE-LIST, DEPLOYMENT
This commit is contained in:
@@ -25,6 +25,7 @@ import { DailyQuestionModule } from './modules/daily-question/daily-question.mod
|
||||
import { ScheduleModule } from './modules/schedule/schedule.module'
|
||||
import { TtsModule } from './modules/tts/tts.module'
|
||||
import { PricingModule } from './modules/schemas/pricing.module'
|
||||
import { GravityTransactionModule } from './modules/schemas/gravity-transaction.module'
|
||||
import { ShareModule } from './modules/share/share.module'
|
||||
import { InterviewReviewModule } from './modules/interview-review/interview-review.module'
|
||||
import { CareerAdviceModule } from './modules/career-advice/career-advice.module'
|
||||
@@ -66,6 +67,7 @@ const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/zhiyin
|
||||
InterviewReviewModule,
|
||||
CareerAdviceModule,
|
||||
VirtualPaymentModule,
|
||||
GravityTransactionModule,
|
||||
],
|
||||
providers: [
|
||||
JwtStrategy,
|
||||
|
||||
@@ -51,6 +51,7 @@ describe('PaymentController', () => {
|
||||
providers: [
|
||||
{ provide: getModelToken('User'), useValue: mockUserModel },
|
||||
{ provide: getModelToken('PaymentOrder'), useValue: mockOrderModel },
|
||||
{ provide: getModelToken('GravityTransaction'), useValue: { create: jest.fn() } },
|
||||
{ provide: WechatPayService, useValue: mockWechatPay },
|
||||
{ provide: QuotaService, useValue: mockQuotaService },
|
||||
{ provide: PricingService, useValue: mockPricingService },
|
||||
|
||||
@@ -9,6 +9,7 @@ import { WechatPayService } from './wechat-pay.service'
|
||||
import { QuotaService } from '../user/quota.service'
|
||||
import { PricingService } from '../schemas/pricing.service'
|
||||
import { Public } from '../../common/decorators/public.decorator'
|
||||
import { GravityTransaction } from '../schemas/gravity-transaction.schema'
|
||||
|
||||
@Controller('payment')
|
||||
export class PaymentController {
|
||||
@@ -17,6 +18,7 @@ export class PaymentController {
|
||||
constructor(
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
|
||||
@InjectModel(GravityTransaction.name) private gravityTxModel: Model<GravityTransaction>,
|
||||
private wechatPay: WechatPayService,
|
||||
private quotaService: QuotaService,
|
||||
private pricingService: PricingService,
|
||||
@@ -226,6 +228,14 @@ export class PaymentController {
|
||||
user.gravity = planCfg.gravityPerMonth
|
||||
user.freeOptimizeUsed = 3
|
||||
await user.save()
|
||||
await this.gravityTxModel.create({
|
||||
userId: order.userId,
|
||||
amount: planCfg.gravityPerMonth,
|
||||
balance: planCfg.gravityPerMonth,
|
||||
type: 'plan_set',
|
||||
description: `开通${isSprint ? '冲刺版' : '成长版'}会员,获得 ${planCfg.gravityPerMonth} 引力值`,
|
||||
refId: order.outTradeNo,
|
||||
})
|
||||
}
|
||||
|
||||
private async activateProduct(order: PaymentOrderDocument) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { User, UserDocument } from '../user/user.schema'
|
||||
import { PricingService } from '../schemas/pricing.service'
|
||||
import { GravityTransaction } from '../schemas/gravity-transaction.schema'
|
||||
|
||||
@Injectable()
|
||||
export class GravityTopUpService {
|
||||
@@ -11,9 +12,24 @@ export class GravityTopUpService {
|
||||
|
||||
constructor(
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
@InjectModel(GravityTransaction.name) private gravityTxModel: Model<GravityTransaction>,
|
||||
private pricingService: PricingService,
|
||||
) {}
|
||||
|
||||
private async logBulkTopUp(userIds: string[], amount: number, type: string) {
|
||||
const users = await this.userModel.find({ _id: { $in: userIds } }).select('gravity').exec()
|
||||
const docs = users.map(u => ({
|
||||
userId: u._id.toString(),
|
||||
amount,
|
||||
balance: u.gravity,
|
||||
type,
|
||||
description: `月度补给 ${amount} 引力值`,
|
||||
}))
|
||||
if (docs.length > 0) {
|
||||
await this.gravityTxModel.insertMany(docs)
|
||||
}
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_2AM)
|
||||
async topUpVipGravity() {
|
||||
this.logger.log('Topping up gravity for active VIP members...')
|
||||
@@ -22,28 +38,32 @@ export class GravityTopUpService {
|
||||
|
||||
// 成长版 —— 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`)
|
||||
const growthUsers = await this.userModel.find(
|
||||
{ plan: 'growth', vipExpireAt: { $gt: now } },
|
||||
).select('_id').exec()
|
||||
const growthIds = growthUsers.map(u => u._id.toString())
|
||||
if (growthIds.length > 0) {
|
||||
await this.userModel.updateMany(
|
||||
{ _id: { $in: growthIds } },
|
||||
{ $inc: { gravity: growthPlan.gravityPerMonth } },
|
||||
).exec()
|
||||
await this.logBulkTopUp(growthIds, growthPlan.gravityPerMonth, 'monthly_topup')
|
||||
this.logger.log(`Growth plan: topped up ${growthIds.length} 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`)
|
||||
const sprintUsers = await this.userModel.find(
|
||||
{ plan: 'sprint', sprintExpireAt: { $gt: now } },
|
||||
).select('_id').exec()
|
||||
const sprintIds = sprintUsers.map(u => u._id.toString())
|
||||
if (sprintIds.length > 0) {
|
||||
await this.userModel.updateMany(
|
||||
{ _id: { $in: sprintIds } },
|
||||
{ $inc: { gravity: sprintPlan.gravityPerMonth } },
|
||||
).exec()
|
||||
await this.logBulkTopUp(sprintIds, sprintPlan.gravityPerMonth, 'monthly_topup')
|
||||
this.logger.log(`Sprint plan: topped up ${sprintIds.length} users with ${sprintPlan.gravityPerMonth} gravity each`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module, Global } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { GravityTransaction, GravityTransactionSchema } from './gravity-transaction.schema'
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [MongooseModule.forFeature([{ name: GravityTransaction.name, schema: GravityTransactionSchema }])],
|
||||
exports: [MongooseModule],
|
||||
})
|
||||
export class GravityTransactionModule {}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { Document } from 'mongoose'
|
||||
|
||||
export type GravityTransactionDocument = GravityTransaction & Document
|
||||
|
||||
export type GravityTransactionType =
|
||||
| 'registration' // 注册赠送
|
||||
| 'interview_deduct' // AI 面试消耗
|
||||
| 'optimize_deduct' // 简历优化消耗
|
||||
| 'download_deduct' // 简历下载消耗
|
||||
| 'purchase' // 充值购买
|
||||
| 'monthly_topup' // 月度补给
|
||||
| 'migration' // 旧额度迁移
|
||||
| 'plan_set' // 套餐设置
|
||||
| 'share' // 分享所得
|
||||
| 'contribution' // 面经贡献奖励
|
||||
| 'share_credits_fallback' // 分享币后备抵扣
|
||||
| 'admin_adjust' // 管理员调整
|
||||
|
||||
@Schema({ timestamps: true })
|
||||
export class GravityTransaction {
|
||||
@Prop({ required: true, index: true })
|
||||
userId: string
|
||||
|
||||
@Prop({ required: true })
|
||||
amount: number // 变动数量(正=增加,负=减少)
|
||||
|
||||
@Prop({ required: true })
|
||||
balance: number // 变动后余额
|
||||
|
||||
@Prop({ required: true })
|
||||
type: GravityTransactionType
|
||||
|
||||
@Prop({ default: '' })
|
||||
description: string // 描述(如 "AI 模拟面试消耗")
|
||||
|
||||
@Prop()
|
||||
refId?: string // 关联 ID(订单号、面试ID 等)
|
||||
}
|
||||
|
||||
export const GravityTransactionSchema = SchemaFactory.createForClass(GravityTransaction)
|
||||
|
||||
GravityTransactionSchema.index({ userId: 1, createdAt: -1 })
|
||||
@@ -3,6 +3,7 @@ import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { User, UserDocument } from './user.schema'
|
||||
import { PricingService } from '../schemas/pricing.service'
|
||||
import { GravityTransaction, GravityTransactionType } from '../schemas/gravity-transaction.schema'
|
||||
|
||||
const FREE_OPTIMIZE_LIMIT = 3
|
||||
|
||||
@@ -12,9 +13,16 @@ export class QuotaService {
|
||||
|
||||
constructor(
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
@InjectModel(GravityTransaction.name) private gravityTxModel: Model<GravityTransaction>,
|
||||
private pricingService: PricingService,
|
||||
) {}
|
||||
|
||||
private async logTransaction(userId: string, amount: number, type: GravityTransactionType, description: string, refId?: string) {
|
||||
const u = await this.userModel.findById(userId).select('gravity').exec()
|
||||
const balance = u?.gravity ?? 0
|
||||
await this.gravityTxModel.create({ userId, amount, balance, type, description, refId })
|
||||
}
|
||||
|
||||
/** 仅检查面试引力值是否充足,不扣除(用于先 AI 后扣款模式) */
|
||||
async checkInterview(userId: string): Promise<number> {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
@@ -106,19 +114,23 @@ export class QuotaService {
|
||||
|
||||
/** 从 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
|
||||
if (gravResult) {
|
||||
await this.logTransaction(userId, -cost, 'interview_deduct', `AI 模拟面试消耗 ${cost} 引力值`)
|
||||
return true
|
||||
}
|
||||
|
||||
// 后备:旧 shareCredits
|
||||
const shareResult = await this.userModel.findOneAndUpdate(
|
||||
{ _id: userId, shareCredits: { $gt: 0 } },
|
||||
{ $inc: { shareCredits: -1 } },
|
||||
).exec()
|
||||
if (shareResult) return true
|
||||
if (shareResult) {
|
||||
await this.logTransaction(userId, 0, 'share_credits_fallback', '分享币后备抵扣 1 次')
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -131,6 +143,7 @@ export class QuotaService {
|
||||
{ $inc: { gravity: amount } },
|
||||
).exec()
|
||||
if (!result) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
await this.logTransaction(userId, amount, 'purchase', `充值获得 ${amount} 引力值`)
|
||||
}
|
||||
|
||||
/** 设置 VIP 套餐引力值额度 */
|
||||
@@ -142,6 +155,7 @@ export class QuotaService {
|
||||
},
|
||||
}).exec()
|
||||
if (!result) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
await this.logTransaction(userId, gravityAmount, 'plan_set', `套餐开通,获得 ${gravityAmount} 引力值`)
|
||||
}
|
||||
|
||||
/** 是否为非会员用户授予初始引力值 */
|
||||
@@ -149,6 +163,7 @@ export class QuotaService {
|
||||
await this.userModel.findByIdAndUpdate(userId, {
|
||||
$set: { interviewCredits: 1, gravity: 5 },
|
||||
}).exec()
|
||||
await this.logTransaction(userId, 5, 'registration', '注册赠送 5 引力值')
|
||||
}
|
||||
|
||||
/** 判断是否有旧额度需要迁移 */
|
||||
@@ -180,5 +195,6 @@ export class QuotaService {
|
||||
shareCredits: 0,
|
||||
},
|
||||
}).exec()
|
||||
await this.logTransaction(userId, total, 'migration', `旧额度迁移,获得 ${total} 引力值`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Post, Get, Put, Body, Req, HttpCode, HttpStatus, UseGuards } from '@nestjs/common'
|
||||
import { Controller, Post, Get, Put, Query, 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'
|
||||
@@ -88,4 +88,13 @@ export class UserController {
|
||||
async setPassword(@CurrentUser('userId') userId: string, @Body('password') password: string) {
|
||||
return this.userService.setPassword(userId, password)
|
||||
}
|
||||
|
||||
@Get('gravity-transactions')
|
||||
async getGravityTransactions(
|
||||
@CurrentUser('userId') userId: string,
|
||||
@Query('page') page = '1',
|
||||
@Query('limit') limit = '20',
|
||||
) {
|
||||
return this.userService.getGravityTransactions(userId, parseInt(page), parseInt(limit))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { JwtService } from '@nestjs/jwt'
|
||||
import { HttpException } from '@nestjs/common'
|
||||
import { UserService } from './user.service'
|
||||
import { EmailService } from '../email/email.service'
|
||||
import { PricingService } from '../schemas/pricing.service'
|
||||
|
||||
describe('UserService', () => {
|
||||
let service: UserService
|
||||
@@ -44,8 +45,10 @@ describe('UserService', () => {
|
||||
providers: [
|
||||
UserService,
|
||||
{ provide: getModelToken('User'), useValue: mockUserModel },
|
||||
{ provide: getModelToken('GravityTransaction'), useValue: { create: jest.fn() } },
|
||||
{ provide: JwtService, useValue: mockJwtService },
|
||||
{ provide: EmailService, useValue: mockEmailService },
|
||||
{ provide: PricingService, useValue: { getConfig: jest.fn().mockResolvedValue({ registrationGravity: 50, gravityRates: { interviewPerUse: 5, optimizePerUse: 3, downloadPerUse: 2 }, plans: { growth: { gravityPerMonth: 80 }, sprint: { gravityPerMonth: 200 } } }) } },
|
||||
],
|
||||
}).compile()
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
import { User, UserDocument } from './user.schema'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
import { EmailService } from '../email/email.service'
|
||||
import { PricingService } from '../schemas/pricing.service'
|
||||
import { GravityTransaction, GravityTransactionType } from '../schemas/gravity-transaction.schema'
|
||||
|
||||
/** 通过 IP 查询粗略地理位置(ip-api.com 免费接口) */
|
||||
async function lookupIpLocation(ip: string): Promise<string> {
|
||||
@@ -29,11 +30,18 @@ export class UserService {
|
||||
|
||||
constructor(
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
@InjectModel(GravityTransaction.name) private gravityTxModel: Model<GravityTransaction>,
|
||||
private jwtService: JwtService,
|
||||
private emailService: EmailService,
|
||||
private pricingService: PricingService,
|
||||
) {}
|
||||
|
||||
private async logTransaction(userId: string, amount: number, type: GravityTransactionType, description: string) {
|
||||
const u = await this.userModel.findById(userId).select('gravity').exec()
|
||||
const balance = u?.gravity ?? 0
|
||||
await this.gravityTxModel.create({ userId, amount, balance, type, description })
|
||||
}
|
||||
|
||||
async sendCode(phone: string) {
|
||||
const code = process.env.NODE_ENV === 'production'
|
||||
? String(Math.floor(100000 + Math.random() * 900000))
|
||||
@@ -59,11 +67,17 @@ export class UserService {
|
||||
codeStore.delete(phone)
|
||||
|
||||
let user = await this.userModel.findOne({ phone }).exec()
|
||||
let isNew = false
|
||||
if (!user) {
|
||||
isNew = true
|
||||
user = await this.userModel.create({ phone, nickname: `用户${phone.slice(-4)}`, gravity: (await this.pricingService.getConfig()).registrationGravity })
|
||||
}
|
||||
|
||||
await this.recordLogin(user._id.toString(), ip)
|
||||
if (isNew) {
|
||||
const amount = (await this.pricingService.getConfig()).registrationGravity
|
||||
await this.logTransaction(user._id.toString(), amount, 'registration', '注册赠送引力值')
|
||||
}
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
|
||||
@@ -95,11 +109,17 @@ export class UserService {
|
||||
}
|
||||
|
||||
let user = await this.userModel.findOne({ wxOpenid: openid }).exec()
|
||||
let isNew = false
|
||||
if (!user) {
|
||||
isNew = true
|
||||
user = await this.userModel.create({ wxOpenid: openid, nickname: '微信用户', gravity: (await this.pricingService.getConfig()).registrationGravity })
|
||||
}
|
||||
|
||||
await this.recordLogin(user._id.toString(), ip)
|
||||
if (isNew) {
|
||||
const amount = (await this.pricingService.getConfig()).registrationGravity
|
||||
await this.logTransaction(user._id.toString(), amount, 'registration', '注册赠送引力值')
|
||||
}
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
|
||||
@@ -175,6 +195,10 @@ export class UserService {
|
||||
user = await this.userModel.create({ email, nickname: nick, remaining: 0, gravity: (await this.pricingService.getConfig()).registrationGravity })
|
||||
}
|
||||
await this.recordLogin(user._id.toString(), ip)
|
||||
if (isNew) {
|
||||
const amount = (await this.pricingService.getConfig()).registrationGravity
|
||||
await this.logTransaction(user._id.toString(), amount, 'registration', '注册赠送引力值')
|
||||
}
|
||||
return { ...this.generateAuthResponse(user), isNew, hasPassword: !!user.password }
|
||||
}
|
||||
|
||||
@@ -211,6 +235,8 @@ 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: 0, gravity: (await this.pricingService.getConfig()).registrationGravity })
|
||||
const amount = (await this.pricingService.getConfig()).registrationGravity
|
||||
await this.logTransaction(user._id.toString(), amount, 'registration', '注册赠送引力值')
|
||||
await this.recordLogin(user._id.toString(), ip)
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
@@ -265,6 +291,15 @@ export class UserService {
|
||||
await user.save()
|
||||
}
|
||||
|
||||
async getGravityTransactions(userId: string, page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit
|
||||
const [items, total] = await Promise.all([
|
||||
this.gravityTxModel.find({ userId }).sort({ createdAt: -1 }).skip(skip).limit(limit).exec(),
|
||||
this.gravityTxModel.countDocuments({ userId }),
|
||||
])
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) }
|
||||
}
|
||||
|
||||
private generateAuthResponse(user: UserDocument) {
|
||||
const payload = { userId: user._id.toString(), phone: user.phone || '', role: user.role || 'user' }
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user