Files
makemd/server/src/services/UserAssetService.ts

510 lines
15 KiB
TypeScript
Raw Normal View History

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,
};
}
}