新增广告计划、用户资产、B2B交易、合规规则等核心模型 实现爬虫工作器、贸易服务、现金流预测等业务服务 添加RBAC权限测试、压力测试等测试用例 完善扩展程序的消息处理与内容脚本功能 重构应用入口与文档生成器 更新项目规则与业务闭环分析文档
538 lines
15 KiB
TypeScript
538 lines
15 KiB
TypeScript
import db from '../config/database';
|
||
import { logger } from '../utils/logger';
|
||
import { UserAssetService } from './UserAssetService';
|
||
import {
|
||
MemberLevel,
|
||
MemberLevelRule,
|
||
MemberLevelRuleCreateInput,
|
||
} from '../models/UserAsset';
|
||
|
||
/**
|
||
* MemberLevelService - 会员等级系统
|
||
*
|
||
* [BE-UA003] 会员等级系统
|
||
* @description 管理会员等级规则、等级升降、权益配置
|
||
*
|
||
* 功能定位:
|
||
* - 会员等级规则管理
|
||
* - 等级自动升降
|
||
* - 会员权益配置
|
||
* - 等级统计报表
|
||
*
|
||
* 安全约束:
|
||
* - 所有操作必须携带五元组追踪信息
|
||
* - 等级变更必须记录日志
|
||
*
|
||
* @author AI-Backend-7
|
||
* @taskId BE-UA003
|
||
*/
|
||
export class MemberLevelService {
|
||
private static readonly RULE_TABLE = 'cf_member_level_rule';
|
||
private static readonly LOG_TABLE = 'cf_member_level_log';
|
||
|
||
/**
|
||
* 初始化数据库表
|
||
* 遵循幂等性原则:使用 hasTable 前置校验
|
||
*/
|
||
static async initTable(): Promise<void> {
|
||
const hasRuleTable = await db.schema.hasTable(this.RULE_TABLE);
|
||
if (!hasRuleTable) {
|
||
logger.info(`[MemberLevelService] Creating ${this.RULE_TABLE} table...`);
|
||
await db.schema.createTable(this.RULE_TABLE, (table) => {
|
||
table.string('id', 36).primary();
|
||
table.string('tenant_id', 64).index();
|
||
table.enum('level', ['BRONZE', 'SILVER', 'GOLD', 'PLATINUM', 'DIAMOND']).notNullable();
|
||
table.integer('min_score').notNullable();
|
||
table.integer('max_score').notNullable();
|
||
table.decimal('discount', 5, 4).defaultTo(1.0).notNullable();
|
||
table.decimal('points_multiplier', 5, 4).defaultTo(1.0).notNullable();
|
||
table.json('benefits').notNullable();
|
||
table.boolean('is_active').defaultTo(true).notNullable();
|
||
table.timestamp('created_at').defaultTo(db.fn.now());
|
||
table.timestamp('updated_at').defaultTo(db.fn.now());
|
||
|
||
table.index(['tenant_id', 'level'], 'idx_rule_tenant_level');
|
||
});
|
||
logger.info(`[MemberLevelService] Table ${this.RULE_TABLE} created`);
|
||
}
|
||
|
||
const hasLogTable = await db.schema.hasTable(this.LOG_TABLE);
|
||
if (!hasLogTable) {
|
||
logger.info(`[MemberLevelService] Creating ${this.LOG_TABLE} table...`);
|
||
await db.schema.createTable(this.LOG_TABLE, (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.string('trace_id', 64).notNullable();
|
||
table.enum('old_level', ['BRONZE', 'SILVER', 'GOLD', 'PLATINUM', 'DIAMOND']);
|
||
table.enum('new_level', ['BRONZE', 'SILVER', 'GOLD', 'PLATINUM', 'DIAMOND']).notNullable();
|
||
table.integer('old_score');
|
||
table.integer('new_score').notNullable();
|
||
table.string('change_type', 32).notNullable();
|
||
table.text('reason');
|
||
table.timestamp('created_at').defaultTo(db.fn.now());
|
||
|
||
table.index(['tenant_id', 'user_id'], 'idx_log_user');
|
||
table.index(['created_at'], 'idx_log_time');
|
||
});
|
||
logger.info(`[MemberLevelService] Table ${this.LOG_TABLE} created`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建或更新等级规则
|
||
* @param input 规则创建输入
|
||
* @returns 规则ID
|
||
*/
|
||
static async createOrUpdateRule(input: MemberLevelRuleCreateInput): Promise<string> {
|
||
const existing = await db(this.RULE_TABLE)
|
||
.where({
|
||
tenant_id: input.tenantId || null,
|
||
level: input.level,
|
||
})
|
||
.first();
|
||
|
||
if (existing) {
|
||
await db(this.RULE_TABLE)
|
||
.where({ id: existing.id })
|
||
.update({
|
||
min_score: input.minScore,
|
||
max_score: input.maxScore,
|
||
discount: input.discount,
|
||
points_multiplier: input.pointsMultiplier,
|
||
benefits: JSON.stringify(input.benefits),
|
||
is_active: input.isActive !== undefined ? input.isActive : true,
|
||
updated_at: new Date(),
|
||
});
|
||
|
||
logger.info(`[MemberLevelService] Rule updated: level=${input.level}, tenantId=${input.tenantId || 'global'}`);
|
||
return existing.id;
|
||
}
|
||
|
||
const id = this.generateId();
|
||
const now = new Date();
|
||
|
||
await db(this.RULE_TABLE).insert({
|
||
id,
|
||
tenant_id: input.tenantId || null,
|
||
level: input.level,
|
||
min_score: input.minScore,
|
||
max_score: input.maxScore,
|
||
discount: input.discount,
|
||
points_multiplier: input.pointsMultiplier,
|
||
benefits: JSON.stringify(input.benefits),
|
||
is_active: input.isActive !== undefined ? input.isActive : true,
|
||
created_at: now,
|
||
updated_at: now,
|
||
});
|
||
|
||
logger.info(`[MemberLevelService] Rule created: id=${id}, level=${input.level}, tenantId=${input.tenantId || 'global'}`);
|
||
|
||
return id;
|
||
}
|
||
|
||
/**
|
||
* 获取等级规则
|
||
* @param tenantId 租户ID(可选,为空则获取全局规则)
|
||
* @param level 等级
|
||
* @returns 等级规则
|
||
*/
|
||
static async getRule(tenantId: string | undefined, level: MemberLevel): Promise<MemberLevelRule | null> {
|
||
let query = db(this.RULE_TABLE)
|
||
.where('level', level)
|
||
.where('is_active', true);
|
||
|
||
if (tenantId) {
|
||
query = query.where(function(this: any) {
|
||
this.whereNull('tenant_id').orWhere('tenant_id', tenantId);
|
||
});
|
||
} else {
|
||
query = query.whereNull('tenant_id');
|
||
}
|
||
|
||
const row = await query.orderBy('tenant_id', 'desc').first();
|
||
|
||
if (!row) return null;
|
||
|
||
return this.mapRowToRule(row);
|
||
}
|
||
|
||
/**
|
||
* 获取所有等级规则
|
||
* @param tenantId 租户ID(可选)
|
||
* @returns 等级规则列表
|
||
*/
|
||
static async getAllRules(tenantId?: string): Promise<MemberLevelRule[]> {
|
||
let query = db(this.RULE_TABLE)
|
||
.where('is_active', true);
|
||
|
||
if (tenantId) {
|
||
query = query.where(function(this: any) {
|
||
this.whereNull('tenant_id').orWhere('tenant_id', tenantId);
|
||
});
|
||
} else {
|
||
query = query.whereNull('tenant_id');
|
||
}
|
||
|
||
const rows = await query.orderBy('min_score', 'asc');
|
||
|
||
const rules: MemberLevelRule[] = [];
|
||
const seenLevels = new Set<string>();
|
||
|
||
for (const row of rows) {
|
||
if (!seenLevels.has(row.level)) {
|
||
rules.push(this.mapRowToRule(row));
|
||
seenLevels.add(row.level);
|
||
}
|
||
}
|
||
|
||
return rules;
|
||
}
|
||
|
||
/**
|
||
* 根据积分计算等级
|
||
* @param tenantId 租户ID
|
||
* @param score 积分
|
||
* @returns 等级
|
||
*/
|
||
static async calculateLevel(tenantId: string, score: number): Promise<MemberLevel> {
|
||
const rules = await this.getAllRules(tenantId);
|
||
|
||
if (rules.length === 0) {
|
||
return UserAssetService.calculateLevel(score);
|
||
}
|
||
|
||
for (const rule of rules) {
|
||
if (score >= rule.minScore && score <= rule.maxScore) {
|
||
return rule.level;
|
||
}
|
||
}
|
||
|
||
const sortedRules = [...rules].sort((a, b) => b.minScore - a.minScore);
|
||
for (const rule of sortedRules) {
|
||
if (score >= rule.minScore) {
|
||
return rule.level;
|
||
}
|
||
}
|
||
|
||
return 'BRONZE';
|
||
}
|
||
|
||
/**
|
||
* 获取等级权益
|
||
* @param tenantId 租户ID
|
||
* @param level 等级
|
||
* @returns 权益列表
|
||
*/
|
||
static async getLevelBenefits(tenantId: string, level: MemberLevel): Promise<string[]> {
|
||
const rule = await this.getRule(tenantId, level);
|
||
return rule?.benefits || [];
|
||
}
|
||
|
||
/**
|
||
* 获取等级折扣
|
||
* @param tenantId 租户ID
|
||
* @param level 等级
|
||
* @returns 折扣率(0-1)
|
||
*/
|
||
static async getLevelDiscount(tenantId: string, level: MemberLevel): Promise<number> {
|
||
const rule = await this.getRule(tenantId, level);
|
||
return rule?.discount || 1.0;
|
||
}
|
||
|
||
/**
|
||
* 获取等级积分倍率
|
||
* @param tenantId 租户ID
|
||
* @param level 等级
|
||
* @returns 积分倍率
|
||
*/
|
||
static async getLevelPointsMultiplier(tenantId: string, level: MemberLevel): Promise<number> {
|
||
const rule = await this.getRule(tenantId, level);
|
||
return rule?.pointsMultiplier || 1.0;
|
||
}
|
||
|
||
/**
|
||
* 升级用户等级
|
||
* @param tenantId 租户ID
|
||
* @param shopId 店铺ID
|
||
* @param userId 用户ID
|
||
* @param newLevel 新等级
|
||
* @param reason 原因
|
||
* @param traceId 追踪ID
|
||
*/
|
||
static async upgradeLevel(
|
||
tenantId: string,
|
||
shopId: string,
|
||
userId: string,
|
||
newLevel: MemberLevel,
|
||
reason: string,
|
||
traceId: string
|
||
): Promise<void> {
|
||
const asset = await UserAssetService.getByUserId(tenantId, shopId, userId);
|
||
if (!asset) {
|
||
throw new Error(`User asset not found: userId=${userId}`);
|
||
}
|
||
|
||
const oldLevel = asset.memberLevel;
|
||
|
||
if (oldLevel === newLevel) {
|
||
return;
|
||
}
|
||
|
||
await db('cf_user_asset')
|
||
.where({ tenant_id: tenantId, shop_id: shopId, user_id: userId })
|
||
.update({
|
||
member_level: newLevel,
|
||
updated_at: new Date(),
|
||
});
|
||
|
||
await this.logLevelChange({
|
||
tenantId,
|
||
shopId,
|
||
userId,
|
||
oldLevel,
|
||
newLevel,
|
||
oldScore: asset.memberScore,
|
||
newScore: asset.memberScore,
|
||
changeType: this.getLevelOrder(newLevel) > this.getLevelOrder(oldLevel) ? 'UPGRADE' : 'DOWNGRADE',
|
||
reason,
|
||
traceId,
|
||
});
|
||
|
||
logger.info(`[MemberLevelService] Level changed: userId=${userId}, oldLevel=${oldLevel}, newLevel=${newLevel}, traceId=${traceId}`);
|
||
}
|
||
|
||
/**
|
||
* 批量重新计算用户等级
|
||
* @param tenantId 租户ID
|
||
* @param shopId 店铺ID
|
||
* @returns 更新的用户数
|
||
*/
|
||
static async batchRecalculateLevels(
|
||
tenantId: string,
|
||
shopId?: string
|
||
): Promise<number> {
|
||
let query = db('cf_user_asset').where('tenant_id', tenantId);
|
||
if (shopId) {
|
||
query = query.where('shop_id', shopId);
|
||
}
|
||
|
||
const users = await query;
|
||
let updatedCount = 0;
|
||
|
||
for (const user of users) {
|
||
try {
|
||
const newLevel = await this.calculateLevel(tenantId, user.member_score);
|
||
|
||
if (newLevel !== user.member_level) {
|
||
await this.upgradeLevel(
|
||
tenantId,
|
||
user.shop_id,
|
||
user.user_id,
|
||
newLevel,
|
||
'系统自动计算',
|
||
`AUTO-RECALC-${Date.now()}`
|
||
);
|
||
updatedCount++;
|
||
}
|
||
} catch (error: any) {
|
||
logger.error(`[MemberLevelService] Failed to recalculate level: userId=${user.user_id}, error=${error.message}`);
|
||
}
|
||
}
|
||
|
||
logger.info(`[MemberLevelService] Batch recalculate completed: updated=${updatedCount}`);
|
||
|
||
return updatedCount;
|
||
}
|
||
|
||
/**
|
||
* 获取等级变更历史
|
||
* @param tenantId 租户ID
|
||
* @param userId 用户ID
|
||
* @param limit 限制条数
|
||
* @returns 变更历史
|
||
*/
|
||
static async getLevelHistory(
|
||
tenantId: string,
|
||
userId: string,
|
||
limit: number = 20
|
||
): Promise<Array<{
|
||
oldLevel: MemberLevel | null;
|
||
newLevel: MemberLevel;
|
||
changeType: string;
|
||
reason: string;
|
||
createdAt: Date;
|
||
}>> {
|
||
const rows = await db(this.LOG_TABLE)
|
||
.where({ tenant_id: tenantId, user_id: userId })
|
||
.orderBy('created_at', 'desc')
|
||
.limit(limit);
|
||
|
||
return rows.map(row => ({
|
||
oldLevel: row.old_level,
|
||
newLevel: row.new_level,
|
||
changeType: row.change_type,
|
||
reason: row.reason,
|
||
createdAt: row.created_at,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* 获取等级分布统计
|
||
* @param tenantId 租户ID
|
||
* @param shopId 店铺ID(可选)
|
||
* @returns 等级分布
|
||
*/
|
||
static async getLevelDistribution(
|
||
tenantId: string,
|
||
shopId?: string
|
||
): Promise<Record<MemberLevel, number>> {
|
||
let query = db('cf_user_asset').where('tenant_id', tenantId);
|
||
if (shopId) {
|
||
query = query.where('shop_id', shopId);
|
||
}
|
||
|
||
const rows = await query;
|
||
|
||
const distribution: Record<MemberLevel, number> = {
|
||
BRONZE: 0,
|
||
SILVER: 0,
|
||
GOLD: 0,
|
||
PLATINUM: 0,
|
||
DIAMOND: 0,
|
||
};
|
||
|
||
for (const row of rows) {
|
||
distribution[row.member_level as MemberLevel]++;
|
||
}
|
||
|
||
return distribution;
|
||
}
|
||
|
||
/**
|
||
* 初始化默认等级规则
|
||
*/
|
||
static async initDefaultRules(): Promise<void> {
|
||
const defaultRules: MemberLevelRuleCreateInput[] = [
|
||
{
|
||
level: 'BRONZE',
|
||
minScore: 0,
|
||
maxScore: 499,
|
||
discount: 1.0,
|
||
pointsMultiplier: 1.0,
|
||
benefits: ['基础积分获取', '生日优惠'],
|
||
},
|
||
{
|
||
level: 'SILVER',
|
||
minScore: 500,
|
||
maxScore: 1999,
|
||
discount: 0.98,
|
||
pointsMultiplier: 1.2,
|
||
benefits: ['基础积分获取', '生日优惠', '专属客服', '优先发货'],
|
||
},
|
||
{
|
||
level: 'GOLD',
|
||
minScore: 2000,
|
||
maxScore: 4999,
|
||
discount: 0.95,
|
||
pointsMultiplier: 1.5,
|
||
benefits: ['基础积分获取', '生日优惠', '专属客服', '优先发货', '会员专享价', '免费退换货'],
|
||
},
|
||
{
|
||
level: 'PLATINUM',
|
||
minScore: 5000,
|
||
maxScore: 9999,
|
||
discount: 0.92,
|
||
pointsMultiplier: 2.0,
|
||
benefits: ['基础积分获取', '生日优惠', '专属客服', '优先发货', '会员专享价', '免费退换货', '专属活动', '积分双倍日'],
|
||
},
|
||
{
|
||
level: 'DIAMOND',
|
||
minScore: 10000,
|
||
maxScore: 999999,
|
||
discount: 0.88,
|
||
pointsMultiplier: 3.0,
|
||
benefits: ['基础积分获取', '生日优惠', '专属客服', '优先发货', '会员专享价', '免费退换货', '专属活动', '积分双倍日', '一对一顾问', '新品优先体验', '年度礼包'],
|
||
},
|
||
];
|
||
|
||
for (const rule of defaultRules) {
|
||
await this.createOrUpdateRule(rule);
|
||
}
|
||
|
||
logger.info(`[MemberLevelService] Default rules initialized: count=${defaultRules.length}`);
|
||
}
|
||
|
||
/**
|
||
* 记录等级变更日志
|
||
*/
|
||
private static async logLevelChange(params: {
|
||
tenantId: string;
|
||
shopId: string;
|
||
userId: string;
|
||
oldLevel: MemberLevel;
|
||
newLevel: MemberLevel;
|
||
oldScore: number;
|
||
newScore: number;
|
||
changeType: string;
|
||
reason: string;
|
||
traceId: string;
|
||
}): Promise<void> {
|
||
await db(this.LOG_TABLE).insert({
|
||
id: this.generateId(),
|
||
tenant_id: params.tenantId,
|
||
shop_id: params.shopId,
|
||
user_id: params.userId,
|
||
trace_id: params.traceId,
|
||
old_level: params.oldLevel,
|
||
new_level: params.newLevel,
|
||
old_score: params.oldScore,
|
||
new_score: params.newScore,
|
||
change_type: params.changeType,
|
||
reason: params.reason,
|
||
created_at: new Date(),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 获取等级顺序
|
||
*/
|
||
private static getLevelOrder(level: MemberLevel): number {
|
||
const order: Record<MemberLevel, number> = {
|
||
BRONZE: 1,
|
||
SILVER: 2,
|
||
GOLD: 3,
|
||
PLATINUM: 4,
|
||
DIAMOND: 5,
|
||
};
|
||
return order[level] || 0;
|
||
}
|
||
|
||
/**
|
||
* 生成唯一ID
|
||
*/
|
||
private static generateId(): string {
|
||
return `MLR-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
|
||
}
|
||
|
||
/**
|
||
* 将数据库行映射为规则对象
|
||
*/
|
||
private static mapRowToRule(row: Record<string, any>): MemberLevelRule {
|
||
return {
|
||
id: row.id,
|
||
tenantId: row.tenant_id,
|
||
level: row.level,
|
||
minScore: Number(row.min_score),
|
||
maxScore: Number(row.max_score),
|
||
discount: Number(row.discount),
|
||
pointsMultiplier: Number(row.points_multiplier),
|
||
benefits: JSON.parse(row.benefits),
|
||
isActive: Boolean(row.is_active),
|
||
createdAt: row.created_at,
|
||
updatedAt: row.updated_at,
|
||
};
|
||
}
|
||
}
|