feat: 新增多模块功能与服务实现

新增广告计划、用户资产、B2B交易、合规规则等核心模型
实现爬虫工作器、贸易服务、现金流预测等业务服务
添加RBAC权限测试、压力测试等测试用例
完善扩展程序的消息处理与内容脚本功能
重构应用入口与文档生成器
更新项目规则与业务闭环分析文档
This commit is contained in:
2026-03-18 09:38:09 +08:00
parent 72cd7f6f45
commit 037e412aad
158 changed files with 50217 additions and 1313 deletions

View File

@@ -0,0 +1,509 @@
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,
};
}
}