import db from '../config/database'; import { logger } from '../utils/logger'; import { UserAsset, UserAssetCreateInput, UserAssetQueryOptions, MemberLevel, } from '../models/UserAsset'; /** * UserAssetService - 用户资产计算服务 * * [BE-UA001] 用户资产计算接口 * @description 计算和管理用户资产数据,包括积分、消费、会员等级等 * * 功能定位: * - 用户资产统计与计算 * - 资产数据查询 * - 资产汇总报表 * * 安全约束: * - 所有操作必须携带五元组追踪信息 * - 租户数据隔离 * * @author AI-Backend-7 * @taskId BE-UA001 */ export class UserAssetService { private static readonly TABLE_NAME = 'cf_user_asset'; /** * 初始化数据库表 * 遵循幂等性原则:使用 hasTable 前置校验 */ static async initTable(): Promise { const hasTable = await db.schema.hasTable(this.TABLE_NAME); if (!hasTable) { logger.info(`[UserAssetService] Creating ${this.TABLE_NAME} table...`); await db.schema.createTable(this.TABLE_NAME, (table) => { table.string('id', 36).primary(); table.string('tenant_id', 64).notNullable().index(); table.string('shop_id', 64).notNullable().index(); table.string('user_id', 64).notNullable().index(); table.integer('total_points').defaultTo(0).notNullable(); table.integer('available_points').defaultTo(0).notNullable(); table.integer('frozen_points').defaultTo(0).notNullable(); table.decimal('total_spent', 12, 2).defaultTo(0).notNullable(); table.integer('total_orders').defaultTo(0).notNullable(); table.enum('member_level', ['BRONZE', 'SILVER', 'GOLD', 'PLATINUM', 'DIAMOND']).defaultTo('BRONZE').notNullable(); table.integer('member_score').defaultTo(0).notNullable(); table.decimal('cashback_balance', 10, 2).defaultTo(0).notNullable(); table.integer('coupon_count').defaultTo(0).notNullable(); table.timestamp('last_active_at'); table.timestamp('created_at').defaultTo(db.fn.now()); table.timestamp('updated_at').defaultTo(db.fn.now()); table.unique(['tenant_id', 'shop_id', 'user_id'], 'uk_asset_user'); table.index(['tenant_id', 'member_level'], 'idx_asset_level'); table.index(['available_points'], 'idx_asset_points'); }); logger.info(`[UserAssetService] Table ${this.TABLE_NAME} created`); } } /** * 获取或创建用户资产 * @param input 创建输入 * @returns 用户资产 */ static async getOrCreate(input: UserAssetCreateInput): Promise { let asset = await this.getByUserId(input.tenantId, input.shopId, input.userId); if (!asset) { const id = this.generateId(); const now = new Date(); await db(this.TABLE_NAME).insert({ id, tenant_id: input.tenantId, shop_id: input.shopId, user_id: input.userId, total_points: 0, available_points: 0, frozen_points: 0, total_spent: 0, total_orders: 0, member_level: 'BRONZE', member_score: 0, cashback_balance: 0, coupon_count: 0, last_active_at: now, created_at: now, updated_at: now, }); asset = await this.getByUserId(input.tenantId, input.shopId, input.userId); logger.info(`[UserAssetService] User asset created: userId=${input.userId}, tenantId=${input.tenantId}, traceId=${input.traceId}`); } return asset!; } /** * 根据用户ID获取资产 * @param tenantId 租户ID * @param shopId 店铺ID * @param userId 用户ID * @returns 用户资产 */ static async getByUserId(tenantId: string, shopId: string, userId: string): Promise { const row = await db(this.TABLE_NAME) .where({ tenant_id: tenantId, shop_id: shopId, user_id: userId }) .first(); if (!row) return null; return this.mapRowToAsset(row); } /** * 根据ID获取资产 * @param tenantId 租户ID * @param id 资产ID * @returns 用户资产 */ static async getById(tenantId: string, id: string): Promise { const row = await db(this.TABLE_NAME) .where({ id, tenant_id: tenantId }) .first(); if (!row) return null; return this.mapRowToAsset(row); } /** * 查询用户资产列表 * @param options 查询选项 * @returns 资产列表和总数 */ static async query( options: UserAssetQueryOptions ): Promise<{ data: UserAsset[]; total: number }> { const { page = 1, pageSize = 20 } = options; const offset = (page - 1) * pageSize; let query = db(this.TABLE_NAME) .where('tenant_id', options.tenantId); if (options.shopId) { query = query.where('shop_id', options.shopId); } if (options.userId) { query = query.where('user_id', options.userId); } if (options.memberLevel) { query = query.where('member_level', options.memberLevel); } if (options.minPoints !== undefined) { query = query.where('available_points', '>=', options.minPoints); } if (options.maxPoints !== undefined) { query = query.where('available_points', '<=', options.maxPoints); } const totalQuery = query.clone(); const [{ count }] = await totalQuery.count('* as count'); const total = Number(count); const rows = await query .orderBy('member_score', 'desc') .limit(pageSize) .offset(offset); const data = rows.map(row => this.mapRowToAsset(row)); return { data, total }; } /** * 更新用户消费统计 * @param tenantId 租户ID * @param shopId 店铺ID * @param userId 用户ID * @param amount 消费金额 * @param traceId 追踪ID */ static async updateSpending( tenantId: string, shopId: string, userId: string, amount: number, traceId: string ): Promise { await db(this.TABLE_NAME) .where({ tenant_id: tenantId, shop_id: shopId, user_id: userId }) .increment('total_spent', amount) .increment('total_orders', 1) .increment('member_score', Math.floor(amount)) .update({ last_active_at: new Date(), updated_at: new Date(), }); await this.recalculateMemberLevel(tenantId, shopId, userId); logger.info(`[UserAssetService] Spending updated: userId=${userId}, amount=${amount}, traceId=${traceId}`); } /** * 更新积分余额 * @param tenantId 租户ID * @param shopId 店铺ID * @param userId 用户ID * @param pointsDelta 积分变化(正数增加,负数减少) * @param frozenDelta 冻结积分变化 * @param traceId 追踪ID */ static async updatePoints( tenantId: string, shopId: string, userId: string, pointsDelta: number, frozenDelta: number, traceId: string ): Promise { const asset = await this.getByUserId(tenantId, shopId, userId); if (!asset) { throw new Error(`User asset not found: userId=${userId}`); } const newAvailable = asset.availablePoints + pointsDelta; const newFrozen = asset.frozenPoints + frozenDelta; const newTotal = newAvailable + newFrozen; if (newAvailable < 0) { throw new Error(`Insufficient available points: userId=${userId}, available=${asset.availablePoints}, required=${Math.abs(pointsDelta)}`); } await db(this.TABLE_NAME) .where({ tenant_id: tenantId, shop_id: shopId, user_id: userId }) .update({ total_points: newTotal, available_points: newAvailable, frozen_points: newFrozen, updated_at: new Date(), }); logger.info(`[UserAssetService] Points updated: userId=${userId}, delta=${pointsDelta}, frozenDelta=${frozenDelta}, traceId=${traceId}`); } /** * 更新返现余额 * @param tenantId 租户ID * @param shopId 店铺ID * @param userId 用户ID * @param amount 金额变化 * @param traceId 追踪ID */ static async updateCashback( tenantId: string, shopId: string, userId: string, amount: number, traceId: string ): Promise { await db(this.TABLE_NAME) .where({ tenant_id: tenantId, shop_id: shopId, user_id: userId }) .increment('cashback_balance', amount) .update({ updated_at: new Date(), }); logger.info(`[UserAssetService] Cashback updated: userId=${userId}, amount=${amount}, traceId=${traceId}`); } /** * 更新优惠券数量 * @param tenantId 租户ID * @param shopId 店铺ID * @param userId 用户ID * @param delta 数量变化 * @param traceId 追踪ID */ static async updateCouponCount( tenantId: string, shopId: string, userId: string, delta: number, traceId: string ): Promise { await db(this.TABLE_NAME) .where({ tenant_id: tenantId, shop_id: shopId, user_id: userId }) .increment('coupon_count', delta) .update({ updated_at: new Date(), }); logger.info(`[UserAssetService] Coupon count updated: userId=${userId}, delta=${delta}, traceId=${traceId}`); } /** * 重新计算会员等级 * @param tenantId 租户ID * @param shopId 店铺ID * @param userId 用户ID */ static async recalculateMemberLevel( tenantId: string, shopId: string, userId: string ): Promise { const asset = await this.getByUserId(tenantId, shopId, userId); if (!asset) return; const newLevel = this.calculateLevel(asset.memberScore); if (newLevel !== asset.memberLevel) { await db(this.TABLE_NAME) .where({ tenant_id: tenantId, shop_id: shopId, user_id: userId }) .update({ member_level: newLevel, updated_at: new Date(), }); logger.info(`[UserAssetService] Member level updated: userId=${userId}, oldLevel=${asset.memberLevel}, newLevel=${newLevel}`); } } /** * 根据积分计算会员等级 * @param score 会员积分 * @returns 会员等级 */ static calculateLevel(score: number): MemberLevel { if (score >= 10000) return 'DIAMOND'; if (score >= 5000) return 'PLATINUM'; if (score >= 2000) return 'GOLD'; if (score >= 500) return 'SILVER'; return 'BRONZE'; } /** * 获取资产统计汇总 * @param tenantId 租户ID * @param shopId 店铺ID(可选) * @returns 统计汇总 */ static async getStatistics( tenantId: string, shopId?: string ): Promise<{ totalUsers: number; totalPoints: number; totalSpent: number; totalCashback: number; levelDistribution: Record; }> { let query = db(this.TABLE_NAME).where('tenant_id', tenantId); if (shopId) { query = query.where('shop_id', shopId); } const rows = await query; const totalUsers = rows.length; const totalPoints = rows.reduce((sum, row) => sum + Number(row.total_points), 0); const totalSpent = rows.reduce((sum, row) => sum + Number(row.total_spent), 0); const totalCashback = rows.reduce((sum, row) => sum + Number(row.cashback_balance), 0); const levelDistribution: Record = { BRONZE: 0, SILVER: 0, GOLD: 0, PLATINUM: 0, DIAMOND: 0, }; for (const row of rows) { levelDistribution[row.member_level as MemberLevel]++; } return { totalUsers, totalPoints, totalSpent, totalCashback, levelDistribution, }; } /** * 批量获取用户资产 * @param tenantId 租户ID * @param shopId 店铺ID * @param userIds 用户ID列表 * @returns 用户资产映射 */ static async batchGetByUserIds( tenantId: string, shopId: string, userIds: string[] ): Promise> { const rows = await db(this.TABLE_NAME) .where('tenant_id', tenantId) .where('shop_id', shopId) .whereIn('user_id', userIds); const result: Record = {}; for (const row of rows) { result[row.user_id] = this.mapRowToAsset(row); } return result; } /** * 冻结积分 * @param tenantId 租户ID * @param shopId 店铺ID * @param userId 用户ID * @param points 冻结积分数量 * @param traceId 追踪ID */ static async freezePoints( tenantId: string, shopId: string, userId: string, points: number, traceId: string ): Promise { const asset = await this.getByUserId(tenantId, shopId, userId); if (!asset || asset.availablePoints < points) { throw new Error(`Insufficient available points to freeze: userId=${userId}, available=${asset?.availablePoints || 0}, required=${points}`); } await db(this.TABLE_NAME) .where({ tenant_id: tenantId, shop_id: shopId, user_id: userId }) .update({ available_points: asset.availablePoints - points, frozen_points: asset.frozenPoints + points, updated_at: new Date(), }); logger.info(`[UserAssetService] Points frozen: userId=${userId}, points=${points}, traceId=${traceId}`); } /** * 解冻积分 * @param tenantId 租户ID * @param shopId 店铺ID * @param userId 用户ID * @param points 解冻积分数量 * @param traceId 追踪ID */ static async unfreezePoints( tenantId: string, shopId: string, userId: string, points: number, traceId: string ): Promise { const asset = await this.getByUserId(tenantId, shopId, userId); if (!asset || asset.frozenPoints < points) { throw new Error(`Insufficient frozen points to unfreeze: userId=${userId}, frozen=${asset?.frozenPoints || 0}, required=${points}`); } await db(this.TABLE_NAME) .where({ tenant_id: tenantId, shop_id: shopId, user_id: userId }) .update({ available_points: asset.availablePoints + points, frozen_points: asset.frozenPoints - points, updated_at: new Date(), }); logger.info(`[UserAssetService] Points unfrozen: userId=${userId}, points=${points}, traceId=${traceId}`); } /** * 生成唯一ID */ private static generateId(): string { return `ASSET-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`; } /** * 将数据库行映射为资产对象 */ private static mapRowToAsset(row: Record): UserAsset { return { id: row.id, tenantId: row.tenant_id, shopId: row.shop_id, userId: row.user_id, totalPoints: Number(row.total_points), availablePoints: Number(row.available_points), frozenPoints: Number(row.frozen_points), totalSpent: Number(row.total_spent), totalOrders: Number(row.total_orders), memberLevel: row.member_level, memberScore: Number(row.member_score), cashbackBalance: Number(row.cashback_balance), couponCount: Number(row.coupon_count), lastActiveAt: row.last_active_at, createdAt: row.created_at, updatedAt: row.updated_at, }; } }