feat: 新增多模块功能与服务实现
新增广告计划、用户资产、B2B交易、合规规则等核心模型 实现爬虫工作器、贸易服务、现金流预测等业务服务 添加RBAC权限测试、压力测试等测试用例 完善扩展程序的消息处理与内容脚本功能 重构应用入口与文档生成器 更新项目规则与业务闭环分析文档
This commit is contained in:
509
server/src/services/UserAssetService.ts
Normal file
509
server/src/services/UserAssetService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user