Files
makemd/server/src/services/UserAssetService.ts
wurenzhi 037e412aad feat: 新增多模块功能与服务实现
新增广告计划、用户资产、B2B交易、合规规则等核心模型
实现爬虫工作器、贸易服务、现金流预测等业务服务
添加RBAC权限测试、压力测试等测试用例
完善扩展程序的消息处理与内容脚本功能
重构应用入口与文档生成器
更新项目规则与业务闭环分析文档
2026-03-18 09:38:09 +08:00

510 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> {
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<UserAsset> {
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<UserAsset | null> {
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<UserAsset | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<MemberLevel, number>;
}> {
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<MemberLevel, number> = {
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<Record<string, UserAsset>> {
const rows = await db(this.TABLE_NAME)
.where('tenant_id', tenantId)
.where('shop_id', shopId)
.whereIn('user_id', userIds);
const result: Record<string, UserAsset> = {};
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<void> {
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<void> {
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<string, any>): 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,
};
}
}