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

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