feat: 添加MSW模拟服务和数据源集成

refactor: 重构页面组件移除冗余Layout组件

feat: 实现WebSocket和事件总线系统

feat: 添加队列和调度系统

docs: 更新架构文档和服务映射

style: 清理重复接口定义使用数据源

chore: 更新依赖项配置

feat: 添加运行时系统和领域引导

ci: 配置ESLint边界检查规则

build: 添加Redis和WebSocket依赖

test: 添加MSW浏览器环境入口

perf: 优化数据获取逻辑使用统一数据源

fix: 修复类型定义和状态管理问题
This commit is contained in:
2026-03-19 01:39:34 +08:00
parent cd55097dbf
commit 0dac26d781
176 changed files with 47075 additions and 8404 deletions

View File

@@ -0,0 +1,516 @@
/**
* [BE-AILOG-001] AI决策日志服务
* 负责记录、存储、查询AI决策全链路日志
* AI注意: 所有AI决策操作必须通过此服务记录日志
*/
import db from '../config/database';
import { logger } from '../utils/logger';
import { EventBusService } from './EventBusService';
import { RedisService } from './RedisService';
// 决策类型
export type DecisionType =
| 'PRICING' // 定价决策
| 'INVENTORY' // 库存决策
| 'AD_OPTIMIZE' // 广告优化
| 'PRODUCT_SELECT' // 选品决策
| 'LOGISTICS' // 物流决策
| 'RISK_CONTROL' // 风控决策
| 'CUSTOMER_SERVICE' // 客服决策
| 'SETTLEMENT' // 结算决策
| 'OTHER'; // 其他
// 决策状态
export type DecisionStatus =
| 'PENDING' // 待执行
| 'EXECUTING' // 执行中
| 'SUCCESS' // 成功
| 'FAILED' // 失败
| 'REJECTED' // 被拒绝
| 'ROLLED_BACK'; // 已回滚
// 决策日志实体
export interface AIDecisionLog {
id: string;
tenant_id: string;
shop_id: string;
task_id: string;
trace_id: string;
decision_type: DecisionType;
business_type: 'TOC' | 'TOB';
status: DecisionStatus;
// 决策输入
input_data: {
context: Record<string, any>;
parameters: Record<string, any>;
constraints: Record<string, any>;
};
// 决策输出
output_data: {
decision: string;
confidence: number;
reasoning: string;
alternatives: Array<{
option: string;
score: number;
}>;
expected_result: Record<string, any>;
};
// 执行结果
execution_result?: {
success: boolean;
actual_result: Record<string, any>;
deviation: number;
execution_time: number;
error_message?: string;
};
// 人工干预
human_intervention?: {
operator_id: string;
operator_name: string;
action: 'APPROVE' | 'REJECT' | 'MODIFY' | 'ROLLBACK';
comment: string;
timestamp: Date;
};
// 时间戳
created_at: Date;
updated_at: Date;
executed_at?: Date;
completed_at?: Date;
}
// 查询参数
export interface DecisionLogQueryParams {
tenant_id?: string;
shop_id?: string;
decision_type?: DecisionType;
status?: DecisionStatus;
business_type?: 'TOC' | 'TOB';
start_time?: Date;
end_time?: Date;
trace_id?: string;
task_id?: string;
page?: number;
pageSize?: number;
}
// 统计结果
export interface DecisionStatistics {
total_count: number;
success_count: number;
failed_count: number;
rejected_count: number;
success_rate: number;
avg_execution_time: number;
by_type: Record<DecisionType, {
count: number;
success_rate: number;
}>;
by_day: Array<{
date: string;
count: number;
success_count: number;
}>;
}
export class AIDecisionLogService {
private static readonly TABLE_NAME = 'cf_ai_decision_logs';
private static readonly CACHE_PREFIX = 'ai_decision_log:';
private static readonly CACHE_TTL = 3600; // 1小时
/**
* [BE-AILOG-002] 初始化数据库表
*/
static async initTables() {
const hasTable = await db.schema.hasTable(this.TABLE_NAME);
if (!hasTable) {
logger.info(`Creating ${this.TABLE_NAME} table...`);
await db.schema.createTable(this.TABLE_NAME, (table) => {
table.string('id', 50).primary();
table.string('tenant_id', 50).notNullable().index();
table.string('shop_id', 50).notNullable().index();
table.string('task_id', 50).notNullable().index();
table.string('trace_id', 100).notNullable().index();
table.enum('decision_type', [
'PRICING', 'INVENTORY', 'AD_OPTIMIZE', 'PRODUCT_SELECT',
'LOGISTICS', 'RISK_CONTROL', 'CUSTOMER_SERVICE', 'SETTLEMENT', 'OTHER'
]).notNullable().index();
table.enum('business_type', ['TOC', 'TOB']).notNullable();
table.enum('status', [
'PENDING', 'EXECUTING', 'SUCCESS', 'FAILED', 'REJECTED', 'ROLLED_BACK'
]).notNullable().index();
// JSON字段存储复杂数据
table.json('input_data').notNullable();
table.json('output_data').notNullable();
table.json('execution_result').nullable();
table.json('human_intervention').nullable();
// 时间戳
table.timestamp('created_at').notNullable().defaultTo(db.fn.now());
table.timestamp('updated_at').notNullable().defaultTo(db.fn.now());
table.timestamp('executed_at').nullable();
table.timestamp('completed_at').nullable();
// 复合索引
table.index(['tenant_id', 'created_at']);
table.index(['decision_type', 'status']);
table.index(['trace_id', 'created_at']);
});
logger.info(`${this.TABLE_NAME} table created successfully`);
}
}
/**
* [BE-AILOG-003] 创建决策日志
*/
static async createLog(
tenantId: string,
shopId: string,
taskId: string,
traceId: string,
decisionType: DecisionType,
businessType: 'TOC' | 'TOB',
inputData: AIDecisionLog['input_data'],
outputData: AIDecisionLog['output_data']
): Promise<AIDecisionLog> {
const id = `ADL-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const log: AIDecisionLog = {
id,
tenant_id: tenantId,
shop_id: shopId,
task_id: taskId,
trace_id: traceId,
decision_type: decisionType,
business_type: businessType,
status: 'PENDING',
input_data: inputData,
output_data: outputData,
created_at: new Date(),
updated_at: new Date(),
};
await db(this.TABLE_NAME).insert({
...log,
input_data: JSON.stringify(inputData),
output_data: JSON.stringify(outputData),
});
// 发布事件
await EventBusService.publish('ai.decision.created', {
logId: id,
tenantId,
shopId,
decisionType,
status: 'PENDING',
timestamp: new Date(),
});
logger.info(`[AIDecisionLog] Created log ${id} for ${decisionType}`);
return log;
}
/**
* [BE-AILOG-004] 更新决策执行状态
*/
static async updateExecutionStatus(
logId: string,
status: DecisionStatus,
executionResult?: AIDecisionLog['execution_result']
): Promise<void> {
const updateData: any = {
status,
updated_at: new Date(),
};
if (status === 'EXECUTING') {
updateData.executed_at = new Date();
}
if (status === 'SUCCESS' || status === 'FAILED' || status === 'REJECTED') {
updateData.completed_at = new Date();
}
if (executionResult) {
updateData.execution_result = JSON.stringify(executionResult);
}
await db(this.TABLE_NAME)
.where('id', logId)
.update(updateData);
// 清除缓存
await RedisService.del(`${this.CACHE_PREFIX}${logId}`);
// 发布状态变更事件
await EventBusService.publish('ai.decision.status_changed', {
logId,
status,
timestamp: new Date(),
});
logger.info(`[AIDecisionLog] Updated log ${logId} status to ${status}`);
}
/**
* [BE-AILOG-005] 记录人工干预
*/
static async recordHumanIntervention(
logId: string,
operatorId: string,
operatorName: string,
action: 'APPROVE' | 'REJECT' | 'MODIFY' | 'ROLLBACK',
comment: string
): Promise<void> {
const intervention = {
operator_id: operatorId,
operator_name: operatorName,
action,
comment,
timestamp: new Date(),
};
await db(this.TABLE_NAME)
.where('id', logId)
.update({
human_intervention: JSON.stringify(intervention),
updated_at: new Date(),
});
// 清除缓存
await RedisService.del(`${this.CACHE_PREFIX}${logId}`);
logger.info(`[AIDecisionLog] Recorded human intervention for ${logId}: ${action}`);
}
/**
* [BE-AILOG-006] 查询决策日志列表
*/
static async queryLogs(params: DecisionLogQueryParams): Promise<{
list: AIDecisionLog[];
total: number;
page: number;
pageSize: number;
}> {
const {
tenant_id,
shop_id,
decision_type,
status,
business_type,
start_time,
end_time,
trace_id,
task_id,
page = 1,
pageSize = 20,
} = params;
let query = db(this.TABLE_NAME);
if (tenant_id) {
query = query.where('tenant_id', tenant_id);
}
if (shop_id) {
query = query.where('shop_id', shop_id);
}
if (decision_type) {
query = query.where('decision_type', decision_type);
}
if (status) {
query = query.where('status', status);
}
if (business_type) {
query = query.where('business_type', business_type);
}
if (trace_id) {
query = query.where('trace_id', trace_id);
}
if (task_id) {
query = query.where('task_id', task_id);
}
if (start_time) {
query = query.where('created_at', '>=', start_time);
}
if (end_time) {
query = query.where('created_at', '<=', end_time);
}
// 获取总数
const countResult = await query.clone().count('id as count').first();
const total = parseInt(countResult?.count as string, 10) || 0;
// 获取列表
const list = await query
.orderBy('created_at', 'desc')
.offset((page - 1) * pageSize)
.limit(pageSize);
return {
list: list.map(this.parseLogRecord),
total,
page,
pageSize,
};
}
/**
* [BE-AILOG-007] 获取决策日志详情
*/
static async getLogById(logId: string): Promise<AIDecisionLog | null> {
// 尝试从缓存获取
const cached = await RedisService.get(`${this.CACHE_PREFIX}${logId}`);
if (cached) {
return JSON.parse(cached);
}
const record = await db(this.TABLE_NAME)
.where('id', logId)
.first();
if (!record) {
return null;
}
const log = this.parseLogRecord(record);
// 缓存结果
await RedisService.setex(
`${this.CACHE_PREFIX}${logId}`,
this.CACHE_TTL,
JSON.stringify(log)
);
return log;
}
/**
* [BE-AILOG-008] 获取决策统计
*/
static async getStatistics(
tenantId: string,
shopId?: string,
startTime?: Date,
endTime?: Date
): Promise<DecisionStatistics> {
let query = db(this.TABLE_NAME)
.where('tenant_id', tenantId);
if (shopId) {
query = query.where('shop_id', shopId);
}
if (startTime) {
query = query.where('created_at', '>=', startTime);
}
if (endTime) {
query = query.where('created_at', '<=', endTime);
}
const records = await query;
const total_count = records.length;
const success_count = records.filter(r => r.status === 'SUCCESS').length;
const failed_count = records.filter(r => r.status === 'FAILED').length;
const rejected_count = records.filter(r => r.status === 'REJECTED').length;
// 按类型统计
const by_type: DecisionStatistics['by_type'] = {} as any;
const typeGroups = this.groupBy(records, 'decision_type');
for (const [type, items] of Object.entries(typeGroups)) {
const typeRecords = items as any[];
const typeSuccess = typeRecords.filter(r => r.status === 'SUCCESS').length;
by_type[type as DecisionType] = {
count: typeRecords.length,
success_rate: typeRecords.length > 0
? Math.round((typeSuccess / typeRecords.length) * 100)
: 0,
};
}
// 按天统计
const byDayGroups = this.groupBy(records, (r: any) =>
new Date(r.created_at).toISOString().split('T')[0]
);
const by_day = Object.entries(byDayGroups)
.map(([date, items]) => ({
date,
count: (items as any[]).length,
success_count: (items as any[]).filter((r: any) => r.status === 'SUCCESS').length,
}))
.sort((a, b) => a.date.localeCompare(b.date));
// 计算平均执行时间
const executionTimes = records
.filter(r => r.execution_result)
.map(r => JSON.parse(r.execution_result).execution_time || 0);
const avg_execution_time = executionTimes.length > 0
? executionTimes.reduce((a, b) => a + b, 0) / executionTimes.length
: 0;
return {
total_count,
success_count,
failed_count,
rejected_count,
success_rate: total_count > 0 ? Math.round((success_count / total_count) * 100) : 0,
avg_execution_time: Math.round(avg_execution_time * 100) / 100,
by_type,
by_day,
};
}
/**
* [BE-AILOG-009] 获取决策链路通过trace_id
*/
static async getDecisionChain(traceId: string): Promise<AIDecisionLog[]> {
const records = await db(this.TABLE_NAME)
.where('trace_id', traceId)
.orderBy('created_at', 'asc');
return records.map(this.parseLogRecord);
}
/**
* 解析数据库记录
*/
private static parseLogRecord(record: any): AIDecisionLog {
return {
...record,
input_data: typeof record.input_data === 'string'
? JSON.parse(record.input_data)
: record.input_data,
output_data: typeof record.output_data === 'string'
? JSON.parse(record.output_data)
: record.output_data,
execution_result: record.execution_result
? (typeof record.execution_result === 'string'
? JSON.parse(record.execution_result)
: record.execution_result)
: undefined,
human_intervention: record.human_intervention
? (typeof record.human_intervention === 'string'
? JSON.parse(record.human_intervention)
: record.human_intervention)
: undefined,
};
}
/**
* 分组辅助函数
*/
private static groupBy<T>(array: T[], key: keyof T | ((item: T) => string)): Record<string, T[]> {
return array.reduce((result, item) => {
const groupKey = typeof key === 'function' ? key(item) : String(item[key]);
result[groupKey] = result[groupKey] || [];
result[groupKey].push(item);
return result;
}, {} as Record<string, T[]>);
}
}

View File

@@ -1,92 +1,361 @@
import db from '../config/database';
import { logger } from '../utils/logger';
import { InventoryService } from './InventoryService';
import { v4 as uuid } from 'uuid';
import db from '../core/database';
import { eventBus, emitEvent } from '../runtime/eventBus';
import { addJob } from '../runtime/queue-core';
export interface AdCampaign {
interface ProductMetrics {
id: string;
tenantId: string;
shopId: string;
productId: string;
platform: 'FB' | 'TIKTOK';
dailyBudget: number;
cpaLimit: number;
status: 'ACTIVE' | 'PAUSED_STOCK' | 'PAUSED_ROI';
clicks: number;
orders: number;
cost: number;
revenue: number;
roi: number;
updatedAt: Date;
}
interface AdCampaign {
id: string;
merchantId: string;
productId: string;
name: string;
budget: number;
status: string;
startDate: Date;
endDate: Date;
createdAt: Date;
updatedAt: Date;
}
interface AdPerformance {
campaignId: string;
date: Date;
impressions: number;
clicks: number;
cost: number;
conversions: number;
revenue: number;
roi: number;
}
/**
* [BIZ_MKT_01] 投放-库存联动与 ROI 自动止损服务
*/
export class AdAutoService {
private static readonly TABLE_NAME = 'cf_ad_campaigns';
/**
* 注册广告组
* 自动创建广告活动
*/
static async registerCampaign(campaign: AdCampaign): Promise<void> {
logger.info(`[AdAuto] Registering campaign: ${campaign.id} for Product: ${campaign.productId}`);
await db(this.TABLE_NAME).insert({
id: campaign.id,
tenant_id: campaign.tenantId,
shop_id: campaign.shopId,
product_id: campaign.productId,
platform: campaign.platform,
daily_budget: campaign.dailyBudget,
cpa_limit: campaign.cpaLimit,
status: campaign.status,
created_at: new Date(),
updated_at: new Date()
});
}
/**
* 执行自动化巡检 (由定时任务调用)
* 1. 检查库存是否充足
* 2. 检查 CPA 是否超标
*/
static async performAudit(): Promise<void> {
const activeCampaigns = await db(this.TABLE_NAME).where({ status: 'ACTIVE' });
for (const campaign of activeCampaigns) {
await this.auditCampaign(campaign);
}
}
private static async auditCampaign(campaign: any): Promise<void> {
async createAutoCampaign(merchantId: string, productId: string, budget: number): Promise<AdCampaign | null> {
try {
// 1. 库存联动检查 (BIZ_MKT_01)
const stock = await InventoryService.getSKUStock(campaign.product_id, campaign.tenant_id);
if (stock.available_stock <= 5) {
logger.warn(`[AdAuto] Low stock detected for campaign ${campaign.id}. Pausing ad.`);
await this.updateStatus(campaign.id, 'PAUSED_STOCK', 'Low inventory (<= 5 units)');
return;
// 分析产品数据
const productMetrics = await this.getProductMetrics(productId);
// 计算ROI预测
const predictedROI = this.predictROI(productMetrics);
// 只有ROI大于0.5的产品才创建广告活动
if (predictedROI < 0.5) {
console.log(`[AdAutoService] Skipping campaign creation for product ${productId} - predicted ROI too low: ${predictedROI}`);
return null;
}
// 2. ROI 止损检查 (BIZ_MKT_01)
// 假设从 cf_pixel_logs 获取最近 24 小时的 CPA
const recentStats = await this.getRecentStats(campaign.product_id, campaign.tenant_id);
if (recentStats.cpa > campaign.cpa_limit) {
logger.warn(`[AdAuto] High CPA detected for campaign ${campaign.id}: ${recentStats.cpa} > ${campaign.cpa_limit}. Pausing ad.`);
await this.updateStatus(campaign.id, 'PAUSED_ROI', `CPA limit exceeded: ${recentStats.cpa.toFixed(2)}`);
return;
}
} catch (err: any) {
logger.error(`[AdAuto] Audit failed for campaign ${campaign.id}: ${err.message}`);
// 创建广告活动
const campaign: AdCampaign = {
id: uuid(),
merchantId,
productId,
name: `Auto Campaign for ${productId}`,
budget,
status: 'active',
startDate: new Date(),
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30天后结束
createdAt: new Date(),
updatedAt: new Date()
};
await db('ad_campaigns').insert({
id: campaign.id,
merchant_id: campaign.merchantId,
product_id: campaign.productId,
name: campaign.name,
budget: campaign.budget,
status: campaign.status,
start_date: campaign.startDate,
end_date: campaign.endDate,
created_at: campaign.createdAt,
updated_at: campaign.updatedAt
});
// 发送事件
emitEvent('AD_CAMPAIGN_CREATED', {
campaign,
predictedROI
}, 'AdAutoService', merchantId);
// 添加任务到队列
await addJob('RUN_ADS', {
campaignId: campaign.id,
merchantId,
productId,
budget,
roi: predictedROI
});
console.log(`[AdAutoService] Created auto campaign ${campaign.id} for product ${productId} with predicted ROI: ${predictedROI}`);
return campaign;
} catch (error) {
console.error('[AdAutoService] Error creating auto campaign:', error);
throw error;
}
}
private static async updateStatus(campaignId: string, status: string, reason: string): Promise<void> {
await db(this.TABLE_NAME).where({ id: campaignId }).update({
status,
updated_at: new Date()
});
// 在实际业务中,应调用外部广告平台 API (FB/TikTok) 真正暂停广告组
logger.info(`[AdAuto] [API_CALL] Paused campaign ${campaignId} on platform. Reason: ${reason}`);
/**
* 获取产品指标
*/
async getProductMetrics(productId: string): Promise<ProductMetrics> {
try {
const metrics = await db('product_metrics').where({ product_id: productId }).first();
if (metrics) {
return {
id: metrics.id,
productId: metrics.product_id,
clicks: metrics.clicks,
orders: metrics.orders,
cost: metrics.cost,
revenue: metrics.revenue,
roi: metrics.roi,
updatedAt: metrics.updated_at
};
}
// 如果没有指标记录,创建一个新的
const newMetrics: ProductMetrics = {
id: uuid(),
productId,
clicks: 0,
orders: 0,
cost: 0,
revenue: 0,
roi: 0,
updatedAt: new Date()
};
await db('product_metrics').insert({
id: newMetrics.id,
product_id: newMetrics.productId,
clicks: newMetrics.clicks,
orders: newMetrics.orders,
cost: newMetrics.cost,
revenue: newMetrics.revenue,
roi: newMetrics.roi,
updated_at: newMetrics.updatedAt
});
return newMetrics;
} catch (error) {
console.error('[AdAutoService] Error getting product metrics:', error);
throw error;
}
}
private static async getRecentStats(productId: string, tenantId: string): Promise<{ cpa: number }> {
// 模拟从 Pixel 数据中计算 CPA
// 实际逻辑应为: 总支出 / 转化数
return { cpa: Math.random() * 20 }; // 随机返回一个 CPA 用于测试
/**
* 更新产品指标
*/
async updateProductMetrics(productId: string, metrics: Partial<ProductMetrics>): Promise<ProductMetrics> {
try {
const updatedMetrics = {
...metrics,
updatedAt: new Date()
};
// 计算ROI
if (updatedMetrics.cost > 0) {
updatedMetrics.roi = (updatedMetrics.revenue - updatedMetrics.cost) / updatedMetrics.cost;
}
await db('product_metrics')
.where({ product_id: productId })
.update({
clicks: updatedMetrics.clicks,
orders: updatedMetrics.orders,
cost: updatedMetrics.cost,
revenue: updatedMetrics.revenue,
roi: updatedMetrics.roi,
updated_at: updatedMetrics.updatedAt
});
return {
id: '', // 实际应该从数据库获取
productId,
clicks: updatedMetrics.clicks || 0,
orders: updatedMetrics.orders || 0,
cost: updatedMetrics.cost || 0,
revenue: updatedMetrics.revenue || 0,
roi: updatedMetrics.roi || 0,
updatedAt: updatedMetrics.updatedAt
};
} catch (error) {
console.error('[AdAutoService] Error updating product metrics:', error);
throw error;
}
}
/**
* 预测ROI
*/
predictROI(metrics: ProductMetrics): number {
// 简单的ROI预测模型
// 基于历史ROI和趋势
if (metrics.cost === 0) {
return 0.5; // 默认值
}
// 基础ROI
const baseROI = metrics.roi;
// 考虑点击率和转化率
const clickRate = metrics.clicks > 0 ? metrics.orders / metrics.clicks : 0;
const conversionFactor = clickRate > 0.01 ? 1.2 : 0.8;
// 考虑趋势(这里简化处理,实际应该使用时间序列分析)
const trendFactor = baseROI > 0.5 ? 1.1 : 0.9;
// 计算预测ROI
const predictedROI = baseROI * conversionFactor * trendFactor;
return Math.max(0, predictedROI);
}
/**
* 优化广告预算
*/
async optimizeAdBudget(campaignId: string): Promise<number> {
try {
// 获取广告活动信息
const campaign = await db('ad_campaigns').where({ id: campaignId }).first();
if (!campaign) {
throw new Error(`Campaign ${campaignId} not found`);
}
// 获取产品指标
const metrics = await this.getProductMetrics(campaign.product_id);
// 计算最优预算
// 基于ROI和历史数据
const optimalBudget = this.calculateOptimalBudget(metrics, campaign.budget);
// 更新广告活动预算
await db('ad_campaigns')
.where({ id: campaignId })
.update({
budget: optimalBudget,
updated_at: new Date()
});
// 发送事件
emitEvent('AD_BUDGET_UPDATED', {
campaignId,
oldBudget: campaign.budget,
newBudget: optimalBudget,
roi: metrics.roi
}, 'AdAutoService', campaign.merchant_id);
console.log(`[AdAutoService] Optimized budget for campaign ${campaignId}: ${optimalBudget}`);
return optimalBudget;
} catch (error) {
console.error('[AdAutoService] Error optimizing ad budget:', error);
throw error;
}
}
/**
* 计算最优预算
*/
calculateOptimalBudget(metrics: ProductMetrics, currentBudget: number): number {
// 简单的预算优化模型
// 基于ROI和历史花费
if (metrics.roi > 1.0) {
// ROI大于1增加预算
return currentBudget * 1.2;
} else if (metrics.roi > 0.5) {
// ROI大于0.5,保持预算
return currentBudget;
} else {
// ROI小于0.5,减少预算
return currentBudget * 0.8;
}
}
/**
* 停止表现不佳的广告活动
*/
async stopUnderperformingCampaigns(merchantId: string): Promise<number> {
try {
// 获取商户的所有广告活动
const campaigns = await db('ad_campaigns').where({ merchant_id: merchantId, status: 'active' });
let stoppedCount = 0;
for (const campaign of campaigns) {
// 获取产品指标
const metrics = await this.getProductMetrics(campaign.product_id);
// 如果ROI小于0.3,停止广告活动
if (metrics.roi < 0.3) {
await db('ad_campaigns')
.where({ id: campaign.id })
.update({
status: 'stopped',
updated_at: new Date()
});
// 发送事件
emitEvent('AD_CAMPAIGN_STOPPED', {
campaignId: campaign.id,
reason: 'Low ROI',
roi: metrics.roi
}, 'AdAutoService', merchantId);
// 添加任务到队列
await addJob('STOP_AD', {
campaignId: campaign.id,
merchantId
});
stoppedCount++;
}
}
console.log(`[AdAutoService] Stopped ${stoppedCount} underperforming campaigns for merchant ${merchantId}`);
return stoppedCount;
} catch (error) {
console.error('[AdAutoService] Error stopping underperforming campaigns:', error);
throw error;
}
}
/**
* 获取广告活动列表
*/
async getCampaignsByMerchant(merchantId: string): Promise<AdCampaign[]> {
try {
const campaigns = await db('ad_campaigns').where({ merchant_id: merchantId });
return campaigns.map(campaign => ({
id: campaign.id,
merchantId: campaign.merchant_id,
productId: campaign.product_id,
name: campaign.name,
budget: campaign.budget,
status: campaign.status,
startDate: campaign.start_date,
endDate: campaign.end_date,
createdAt: campaign.created_at,
updatedAt: campaign.updated_at
}));
} catch (error) {
console.error('[AdAutoService] Error getting campaigns:', error);
throw error;
}
}
}
export default new AdAutoService();

View File

@@ -0,0 +1,653 @@
import db from '../config/database';
import { logger } from '../utils/logger';
import { ProductSelectionService } from './ProductSelectionService';
import { ProductService } from './ProductService';
import { PricingService } from './PricingService';
import { PublishService } from './PublishService';
import { AIService } from './AIService';
import { EventBusService } from './EventBusService';
import { BullMQService } from './BullMQService';
interface ListingTask {
id: string;
tenant_id: string;
shop_id: string;
product_id: string;
product_name: string;
target_platforms: string[];
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
progress: number;
error_message?: string;
created_at: Date;
completed_at?: Date;
updated_at: Date;
}
interface AutoListingConfig {
id: string;
tenant_id: string;
shop_id: string;
enabled: boolean;
batch_size: number;
interval_hours: number;
target_platforms: string[];
auto_pricing: boolean;
auto_inventory: boolean;
auto_description: boolean;
auto_images: boolean;
created_at: Date;
updated_at: Date;
}
interface ListingResult {
task_id: string;
product_id: string;
platform: string;
success: boolean;
error?: string;
platform_product_id?: string;
listing_url?: string;
}
export class AutoListingService {
private static readonly TABLE_NAME_TASKS = 'cf_listing_tasks';
private static readonly TABLE_NAME_CONFIG = 'cf_auto_listing_config';
private static readonly TABLE_NAME_RESULTS = 'cf_listing_results';
/**
* 初始化数据库表
*/
static async initTables() {
await this.initTasksTable();
await this.initConfigTable();
await this.initResultsTable();
}
private static async initTasksTable() {
const hasTable = await db.schema.hasTable(this.TABLE_NAME_TASKS);
if (!hasTable) {
logger.info(`Creating ${this.TABLE_NAME_TASKS} table...`);
await db.schema.createTable(this.TABLE_NAME_TASKS, (table) => {
table.increments('id').primary();
table.string('tenant_id', 50).notNullable().index();
table.string('shop_id', 50).notNullable().index();
table.string('product_id', 100).notNullable();
table.string('product_name', 500).notNullable();
table.json('target_platforms').notNullable();
table.enum('status', ['PENDING', 'PROCESSING', 'COMPLETED', 'FAILED']).defaultTo('PENDING');
table.integer('progress').defaultTo(0);
table.text('error_message');
table.timestamps(true, true);
table.timestamp('completed_at');
table.index(['tenant_id', 'shop_id', 'status'], 'idx_tasks_status');
table.index(['tenant_id', 'shop_id', 'created_at'], 'idx_tasks_created');
});
logger.info(`✅ Table ${this.TABLE_NAME_TASKS} created`);
}
}
private static async initConfigTable() {
const hasTable = await db.schema.hasTable(this.TABLE_NAME_CONFIG);
if (!hasTable) {
logger.info(`Creating ${this.TABLE_NAME_CONFIG} table...`);
await db.schema.createTable(this.TABLE_NAME_CONFIG, (table) => {
table.increments('id').primary();
table.string('tenant_id', 50).notNullable().unique();
table.string('shop_id', 50).notNullable();
table.boolean('enabled').defaultTo(false);
table.integer('batch_size').defaultTo(10);
table.integer('interval_hours').defaultTo(6);
table.json('target_platforms').notNullable();
table.boolean('auto_pricing').defaultTo(true);
table.boolean('auto_inventory').defaultTo(true);
table.boolean('auto_description').defaultTo(true);
table.boolean('auto_images').defaultTo(true);
table.timestamps(true, true);
table.index(['tenant_id', 'shop_id', 'enabled'], 'idx_config_enabled');
});
logger.info(`✅ Table ${this.TABLE_NAME_CONFIG} created`);
}
}
private static async initResultsTable() {
const hasTable = await db.schema.hasTable(this.TABLE_NAME_RESULTS);
if (!hasTable) {
logger.info(`Creating ${this.TABLE_NAME_RESULTS} table...`);
await db.schema.createTable(this.TABLE_NAME_RESULTS, (table) => {
table.increments('id').primary();
table.string('task_id').notNullable().index();
table.string('product_id', 100).notNullable();
table.string('platform', 50).notNullable();
table.boolean('success').notNullable();
table.text('error');
table.string('platform_product_id', 100);
table.string('listing_url', 500);
table.timestamps(true, true);
table.index(['task_id', 'platform'], 'idx_results_task_platform');
table.index(['product_id', 'platform'], 'idx_results_product_platform');
});
logger.info(`✅ Table ${this.TABLE_NAME_RESULTS} created`);
}
}
/**
* 获取或创建自动上架配置
*/
static async getConfig(tenantId: string, shopId: string): Promise<AutoListingConfig | null> {
const config = await db(this.TABLE_NAME_CONFIG)
.where({ tenant_id: tenantId, shop_id: shopId })
.first();
if (!config) return null;
return {
...config,
target_platforms: JSON.parse(config.target_platforms),
};
}
/**
* 创建自动上架配置
*/
static async createConfig(config: Omit<AutoListingConfig, 'id' | 'created_at' | 'updated_at'>): Promise<AutoListingConfig> {
const [id] = await db(this.TABLE_NAME_CONFIG).insert({
...config,
target_platforms: JSON.stringify(config.target_platforms),
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
return {
...config,
id: id.toString(),
created_at: new Date(),
updated_at: new Date(),
};
}
/**
* 更新自动上架配置
*/
static async updateConfig(
tenantId: string,
shopId: string,
updates: Partial<AutoListingConfig>
): Promise<void> {
const updateData: any = { ...updates, updated_at: new Date() };
if (updates.target_platforms) {
updateData.target_platforms = JSON.stringify(updates.target_platforms);
}
await db(this.TABLE_NAME_CONFIG)
.where({ tenant_id: tenantId, shop_id: shopId })
.update(updateData);
}
/**
* 切换自动上架启用状态
*/
static async toggleAutoListing(tenantId: string, shopId: string): Promise<boolean> {
const config = await this.getConfig(tenantId, shopId);
if (!config) {
throw new Error('Auto listing config not found');
}
const newEnabled = !config.enabled;
await this.updateConfig(tenantId, shopId, { enabled: newEnabled });
if (newEnabled) {
await this.scheduleNextRun(tenantId, shopId);
} else {
await this.cancelScheduledRun(tenantId, shopId);
}
return newEnabled;
}
/**
* 创建上架任务
*/
static async createListingTask(
tenantId: string,
shopId: string,
productId: string,
productName: string,
targetPlatforms: string[]
): Promise<ListingTask> {
const [id] = await db(this.TABLE_NAME_TASKS).insert({
tenant_id: tenantId,
shop_id: shopId,
product_id: productId,
product_name: productName,
target_platforms: JSON.stringify(targetPlatforms),
status: 'PENDING',
progress: 0,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
return {
id: id.toString(),
tenant_id: tenantId,
shop_id: shopId,
product_id: productId,
product_name: productName,
target_platforms: targetPlatforms,
status: 'PENDING',
progress: 0,
created_at: new Date(),
updated_at: new Date(),
};
}
/**
* 批量创建上架任务
*/
static async batchCreateListingTasks(
tenantId: string,
shopId: string,
products: Array<{ product_id: string; product_name: string }>,
targetPlatforms: string[]
): Promise<ListingTask[]> {
const now = new Date();
const tasks = products.map(product => ({
tenant_id: tenantId,
shop_id: shopId,
product_id: product.product_id,
product_name: product.product_name,
target_platforms: JSON.stringify(targetPlatforms),
status: 'PENDING',
progress: 0,
created_at: now,
updated_at: now,
}));
const ids = await db(this.TABLE_NAME_TASKS).insert(tasks).returning('id');
return tasks.map((task, index) => ({
...task,
id: ids[index].toString(),
target_platforms: targetPlatforms,
}));
}
/**
* 获取上架任务
*/
static async getListingTask(taskId: string): Promise<ListingTask | null> {
const task = await db(this.TABLE_NAME_TASKS).where({ id: taskId }).first();
if (!task) return null;
return {
...task,
target_platforms: JSON.parse(task.target_platforms),
};
}
/**
* 获取租户的上架任务列表
*/
static async getListingTasks(
tenantId: string,
shopId: string,
status?: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED',
limit: number = 50,
offset: number = 0
): Promise<ListingTask[]> {
let query = db(this.TABLE_NAME_TASKS)
.where({ tenant_id: tenantId, shop_id: shopId });
if (status) {
query = query.where({ status });
}
const tasks = await query
.orderBy('created_at', 'desc')
.limit(limit)
.offset(offset);
return tasks.map(task => ({
...task,
target_platforms: JSON.parse(task.target_platforms),
}));
}
/**
* 执行上架任务
*/
static async executeListingTask(taskId: string): Promise<void> {
const task = await this.getListingTask(taskId);
if (!task) {
throw new Error(`Task ${taskId} not found`);
}
if (task.status !== 'PENDING') {
throw new Error(`Task ${taskId} is not in PENDING status`);
}
await db(this.TABLE_NAME_TASKS).where({ id: taskId }).update({
status: 'PROCESSING',
progress: 0,
updated_at: new Date(),
});
try {
const config = await this.getConfig(task.tenant_id, task.shop_id);
if (!config || !config.enabled) {
throw new Error('Auto listing is not enabled');
}
const totalPlatforms = task.target_platforms.length;
const results: ListingResult[] = [];
for (let i = 0; i < task.target_platforms.length; i++) {
const platform = task.target_platforms[i];
const progress = Math.round(((i + 1) / totalPlatforms) * 100);
try {
const result = await this.listToPlatform(
task.tenant_id,
task.shop_id,
task.product_id,
platform,
config
);
results.push(result);
await db(this.TABLE_NAME_TASKS).where({ id: taskId }).update({
progress,
updated_at: new Date(),
});
} catch (error) {
logger.error(`Failed to list product ${task.product_id} to ${platform}:`, error);
results.push({
task_id: taskId,
product_id: task.product_id,
platform,
success: false,
error: error.message,
});
}
}
const allSuccess = results.every(r => r.success);
const status = allSuccess ? 'COMPLETED' : 'FAILED';
await db(this.TABLE_NAME_TASKS).where({ id: taskId }).update({
status,
progress: 100,
completed_at: new Date(),
updated_at: new Date(),
});
for (const result of results) {
await db(this.TABLE_NAME_RESULTS).insert({
task_id: taskId,
product_id: result.product_id,
platform: result.platform,
success: result.success,
error: result.error,
platform_product_id: result.platform_product_id,
listing_url: result.listing_url,
created_at: new Date(),
updated_at: new Date(),
});
}
await EventBusService.emit('listing.task.completed', {
task_id: taskId,
status,
results,
});
} catch (error) {
logger.error(`Failed to execute listing task ${taskId}:`, error);
await db(this.TABLE_NAME_TASKS).where({ id: taskId }).update({
status: 'FAILED',
progress: 0,
error_message: error.message,
updated_at: new Date(),
});
throw error;
}
}
/**
* 将商品上架到指定平台
*/
private static async listToPlatform(
tenantId: string,
shopId: string,
productId: string,
platform: string,
config: AutoListingConfig
): Promise<ListingResult> {
const product = await ProductService.getProductById(productId);
if (!product) {
throw new Error(`Product ${productId} not found`);
}
let listingData: any = {
title: product.title,
description: product.description,
price: parseFloat(product.price),
currency: product.currency,
images: product.images ? JSON.parse(product.images) : [],
attributes: product.attributes ? JSON.parse(product.attributes) : {},
};
if (config.auto_pricing) {
const pricing = await PricingService.calculateOptimalPrice(productId, platform);
listingData.price = pricing.price;
listingData.currency = pricing.currency;
}
if (config.auto_description) {
const enhancedDescription = await AIService.enhanceProductDescription(
product.title,
product.description || '',
platform
);
listingData.description = enhancedDescription;
}
if (config.auto_images) {
const optimizedImages = await AIService.optimizeProductImages(
product.images ? JSON.parse(product.images) : [],
platform
);
listingData.images = optimizedImages;
}
const result = await PublishService.publishToPlatform(
tenantId,
shopId,
productId,
platform,
listingData
);
return {
task_id: '',
product_id: productId,
platform,
success: result.success,
error: result.error,
platform_product_id: result.platform_product_id,
listing_url: result.listing_url,
};
}
/**
* 重试失败的上架任务
*/
static async retryListingTask(taskId: string): Promise<void> {
const task = await this.getListingTask(taskId);
if (!task) {
throw new Error(`Task ${taskId} not found`);
}
if (task.status !== 'FAILED') {
throw new Error(`Task ${taskId} is not in FAILED status`);
}
await db(this.TABLE_NAME_TASKS).where({ id: taskId }).update({
status: 'PENDING',
progress: 0,
error_message: null,
updated_at: new Date(),
});
await this.executeListingTask(taskId);
}
/**
* 删除上架任务
*/
static async deleteListingTask(taskId: string): Promise<void> {
await db(this.TABLE_NAME_TASKS).where({ id: taskId }).delete();
await db(this.TABLE_NAME_RESULTS).where({ task_id: taskId }).delete();
}
/**
* 执行自动上架(批量)
*/
static async executeAutoListing(tenantId: string, shopId: string): Promise<number> {
const config = await this.getConfig(tenantId, shopId);
if (!config || !config.enabled) {
throw new Error('Auto listing is not enabled');
}
const selectedProducts = await ProductSelectionService.getFromPool(
tenantId,
shopId,
undefined,
config.batch_size,
0
);
if (selectedProducts.length === 0) {
logger.info(`No products to list for tenant ${tenantId}, shop ${shopId}`);
return 0;
}
const tasks = await this.batchCreateListingTasks(
tenantId,
shopId,
selectedProducts.map(p => ({
product_id: p.product_id,
product_name: p.name,
})),
config.target_platforms
);
for (const task of tasks) {
await BullMQService.addJob('listing.execute', {
task_id: task.id,
});
}
logger.info(`Created ${tasks.length} listing tasks for tenant ${tenantId}, shop ${shopId}`);
return tasks.length;
}
/**
* 调度下次自动上架运行
*/
private static async scheduleNextRun(tenantId: string, shopId: string): Promise<void> {
const config = await this.getConfig(tenantId, shopId);
if (!config) return;
const delay = config.interval_hours * 60 * 60 * 1000;
await BullMQService.addJob('auto-listing.run', {
tenant_id: tenantId,
shop_id: shopId,
}, {
delay,
jobId: `auto-listing-${tenantId}-${shopId}`,
});
logger.info(`Scheduled next auto listing run for tenant ${tenantId}, shop ${shopId} in ${config.interval_hours} hours`);
}
/**
* 取消计划的自动上架运行
*/
private static async cancelScheduledRun(tenantId: string, shopId: string): Promise<void> {
const jobId = `auto-listing-${tenantId}-${shopId}`;
await BullMQService.removeJob(jobId);
logger.info(`Cancelled scheduled auto listing run for tenant ${tenantId}, shop ${shopId}`);
}
/**
* 获取上架统计
*/
static async getListingStats(tenantId: string, shopId: string): Promise<{
totalTasks: number;
pendingTasks: number;
processingTasks: number;
completedTasks: number;
failedTasks: number;
totalListings: number;
successListings: number;
failedListings: number;
}> {
const [taskStats] = await db(this.TABLE_NAME_TASKS)
.where({ tenant_id: tenantId, shop_id: shopId })
.select(
db.raw('COUNT(*) as total'),
db.raw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as pending', ['PENDING']),
db.raw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as processing', ['PROCESSING']),
db.raw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as completed', ['COMPLETED']),
db.raw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as failed', ['FAILED'])
);
const [listingStats] = await db(this.TABLE_NAME_RESULTS)
.join(this.TABLE_NAME_TASKS, `${this.TABLE_NAME_TASKS}.id`, `${this.TABLE_NAME_RESULTS}.task_id`)
.where(`${this.TABLE_NAME_TASKS}.tenant_id`, tenantId)
.where(`${this.TABLE_NAME_TASKS}.shop_id`, shopId)
.select(
db.raw('COUNT(*) as total'),
db.raw('SUM(CASE WHEN success = ? THEN 1 ELSE 0 END) as success', [true]),
db.raw('SUM(CASE WHEN success = ? THEN 1 ELSE 0 END) as failed', [false])
);
return {
totalTasks: taskStats.total || 0,
pendingTasks: taskStats.pending || 0,
processingTasks: taskStats.processing || 0,
completedTasks: taskStats.completed || 0,
failedTasks: taskStats.failed || 0,
totalListings: listingStats.total || 0,
successListings: listingStats.success || 0,
failedListings: listingStats.failed || 0,
};
}
/**
* 获取上架结果
*/
static async getListingResults(taskId: string): Promise<ListingResult[]> {
const results = await db(this.TABLE_NAME_RESULTS).where({ task_id: taskId });
return results;
}
/**
* 清理旧的上架任务
*/
static async cleanupOldTasks(days: number = 30): Promise<number> {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - days);
const deleted = await db(this.TABLE_NAME_TASKS)
.where('created_at', '<', cutoffDate)
.where('status', 'COMPLETED')
.delete();
logger.info(`Cleaned up ${deleted} old completed listing tasks`);
return deleted;
}
}

View File

@@ -1,154 +1,293 @@
import { logger } from '../utils/logger';
import { v4 as uuidv4 } from 'uuid';
import db from '../config/database';
import UsageService from './UsageService';
export interface Bill {
interface BillingRecord {
id: string;
tenantId: string;
shopId: string;
type: 'FEATURE' | 'TRANSACTION' | 'SERVICE';
amount: number;
currency: string;
status: 'PENDING' | 'PAID' | 'OVERDUE' | 'REFUNDED';
dueDate: Date;
paymentDate?: Date;
relatedId: string; // 关联的功能激活ID或订单ID
traceId: string;
taskId: string;
businessType: 'TOC' | 'TOB';
merchantId: string;
totalAmount: number;
status: string;
createdAt: Date;
updatedAt: Date;
paidAt?: Date;
}
export interface CreateBillParams {
tenantId: string;
shopId: string;
type: 'FEATURE' | 'TRANSACTION' | 'SERVICE';
interface BillingItem {
id: string;
billingId: string;
feature: string;
amount: number;
currency: string;
relatedId: string;
traceId: string;
taskId: string;
businessType: 'TOC' | 'TOB';
quantity: number;
unitPrice: number;
}
export interface BillResult {
success: boolean;
bill: Bill;
message: string;
interface FeaturePrice {
[key: string]: number;
}
export class BillingService {
// 功能价格映射
private featurePrices: FeaturePrice = {
'AI_OPTIMIZE': 0.1,
'ADS_AUTO': 0.2,
'SYNC_INVENTORY': 0.05,
'CALCULATE_PROFIT': 0.02
};
/**
* 生成商户账单
*/
async generateBill(merchantId: string): Promise<BillingRecord | null> {
try {
// 获取商户的使用量记录
const usageRecords = await UsageService.getUsageByMerchant(merchantId);
if (usageRecords.length === 0) {
console.log(`[BillingService] No usage records for merchant ${merchantId}`);
return null;
}
// 计算总金额和明细
let totalAmount = 0;
const billingItems: BillingItem[] = [];
// 按功能分组计算
const featureUsageMap = new Map<string, number>();
for (const record of usageRecords) {
const currentUsage = featureUsageMap.get(record.feature) || 0;
featureUsageMap.set(record.feature, currentUsage + record.usage);
}
// 生成账单明细
for (const [feature, usage] of featureUsageMap.entries()) {
const unitPrice = this.featurePrices[feature] || 0;
const amount = usage * unitPrice;
totalAmount += amount;
billingItems.push({
id: uuidv4(),
billingId: '', // 稍后填充
feature,
amount,
quantity: usage,
unitPrice
});
}
// 创建账单记录
const billingRecord: BillingRecord = {
id: uuidv4(),
merchantId,
totalAmount,
status: 'pending',
createdAt: new Date()
};
// 开始事务
await db.transaction(async (trx: any) => {
// 插入账单记录
await trx('billing_records').insert({
id: billingRecord.id,
merchant_id: billingRecord.merchantId,
total_amount: billingRecord.totalAmount,
status: billingRecord.status,
created_at: billingRecord.createdAt
});
// 插入账单明细
for (const item of billingItems) {
item.billingId = billingRecord.id;
await trx('billing_items').insert({
id: item.id,
billing_id: item.billingId,
feature: item.feature,
amount: item.amount,
quantity: item.quantity,
unit_price: item.unitPrice
});
}
});
console.log(`[BillingService] Generated bill for merchant ${merchantId}: $${totalAmount}`);
return billingRecord;
} catch (error) {
console.error('[BillingService] Error generating bill:', error);
throw error;
}
}
/**
* 获取商户的账单记录
*/
async getBillsByMerchant(merchantId: string): Promise<BillingRecord[]> {
try {
const records = await db('billing_records')
.where({ merchant_id: merchantId })
.orderBy('created_at', 'desc');
return records.map((record: any) => ({
id: record.id,
merchantId: record.merchant_id,
totalAmount: record.total_amount,
status: record.status,
createdAt: record.created_at,
paidAt: record.paid_at
}));
} catch (error) {
console.error('[BillingService] Error getting bills:', error);
throw error;
}
}
/**
* 获取账单明细
*/
async getBillItems(billingId: string): Promise<BillingItem[]> {
try {
const items = await db('billing_items').where({ billing_id: billingId });
return items.map((item: any) => ({
id: item.id,
billingId: item.billing_id,
feature: item.feature,
amount: item.amount,
quantity: item.quantity,
unitPrice: item.unit_price
}));
} catch (error) {
console.error('[BillingService] Error getting bill items:', error);
throw error;
}
}
/**
* 标记账单为已支付
*/
async markBillAsPaid(billingId: string): Promise<BillingRecord> {
try {
const now = new Date();
await db('billing_records')
.where({ id: billingId })
.update({
status: 'paid',
paid_at: now
});
const updatedBill = await db('billing_records').where({ id: billingId }).first();
console.log(`[BillingService] Marked bill ${billingId} as paid`);
return {
id: updatedBill.id,
merchantId: updatedBill.merchant_id,
totalAmount: updatedBill.total_amount,
status: updatedBill.status,
createdAt: updatedBill.created_at,
paidAt: updatedBill.paid_at
};
} catch (error) {
console.error('[BillingService] Error marking bill as paid:', error);
throw error;
}
}
/**
* 获取功能的价格
*/
getFeaturePrice(feature: string): number {
return this.featurePrices[feature] || 0;
}
/**
* 设置功能价格
*/
setFeaturePrice(feature: string, price: number): void {
this.featurePrices[feature] = price;
console.log(`[BillingService] Updated price for feature ${feature}: $${price}`);
}
/**
* 初始化数据库表
*/
static async initTable() {
logger.info('🚀 BillingService table initialized');
// 这里可以添加数据库表初始化逻辑
static async initTable(): Promise<void> {
try {
// 检查billing_records表是否存在
const hasBillingTable = await db.schema.hasTable('billing_records');
if (!hasBillingTable) {
await db.schema.createTable('billing_records', (table: any) => {
table.string('id').primary();
table.string('merchant_id').notNullable();
table.decimal('total_amount', 10, 2).notNullable();
table.string('status').notNullable();
table.timestamp('created_at').notNullable();
table.timestamp('paid_at');
table.index('merchant_id');
table.index('status');
});
console.log('[BillingService] Created billing_records table');
}
// 检查billing_items表是否存在
const hasBillingItemsTable = await db.schema.hasTable('billing_items');
if (!hasBillingItemsTable) {
await db.schema.createTable('billing_items', (table: any) => {
table.string('id').primary();
table.string('billing_id').notNullable();
table.string('feature').notNullable();
table.decimal('amount', 10, 2).notNullable();
table.integer('quantity').notNullable();
table.decimal('unit_price', 10, 2).notNullable();
table.foreign('billing_id').references('id').inTable('billing_records').onDelete('cascade');
table.index('billing_id');
table.index('feature');
});
console.log('[BillingService] Created billing_items table');
}
} catch (error) {
console.error('[BillingService] Error initializing tables:', error);
throw error;
}
}
/**
* 创建账单
*/
static async createBill(params: CreateBillParams): Promise<BillResult> {
logger.info(`[BillingService] Creating bill for ${params.type}`, { traceId: params.traceId });
// 计算到期日期默认30天后
const dueDate = new Date();
dueDate.setDate(dueDate.getDate() + 30);
const bill: Bill = {
id: 'bill_' + Date.now(),
tenantId: params.tenantId,
shopId: params.shopId,
type: params.type,
amount: params.amount,
currency: params.currency,
status: 'PENDING',
dueDate,
relatedId: params.relatedId,
traceId: params.traceId,
taskId: params.taskId,
businessType: params.businessType,
createdAt: new Date(),
updatedAt: new Date()
};
// 这里可以添加创建账单的逻辑
return {
success: true,
bill,
message: 'Bill created successfully'
};
}
/**
* 标记账单为已支付
*/
static async markAsPaid(billId: string, traceId: string): Promise<Bill> {
logger.info(`[BillingService] Marking bill as paid: ${billId}`, { traceId });
// 这里可以添加标记账单为已支付的逻辑
return {
id: billId,
tenantId: 'tenant_1',
shopId: 'shop_1',
type: 'FEATURE',
amount: 99,
currency: 'USD',
status: 'PAID',
dueDate: new Date(),
paymentDate: new Date(),
relatedId: 'activation_1',
traceId,
taskId: 'task_1',
businessType: 'TOC',
createdAt: new Date(),
updatedAt: new Date()
};
}
/**
* 获取账单列表
*/
static async getBills(tenantId: string, shopId: string, traceId: string): Promise<Bill[]> {
logger.info(`[BillingService] Getting bills for tenant: ${tenantId}`, { traceId });
// 这里可以添加获取账单列表的逻辑
return [];
}
/**
* 获取账单详情
*/
static async getBill(billId: string, traceId: string): Promise<Bill | null> {
logger.info(`[BillingService] Getting bill: ${billId}`, { traceId });
// 这里可以添加获取账单详情的逻辑
return null;
}
/**
* 生成账单报表
*/
static async generateBillReport(tenantId: string, startDate: Date, endDate: Date, traceId: string): Promise<any> {
logger.info(`[BillingService] Generating bill report for tenant: ${tenantId}`, { traceId });
// 这里可以添加生成账单报表的逻辑
return {
tenantId,
startDate,
endDate,
totalBills: 5,
totalAmount: 500,
paidAmount: 400,
overdueAmount: 100,
reportDate: new Date()
};
}
/**
* 处理逾期账单
*/
static async processOverdueBills(traceId: string): Promise<number> {
logger.info('[BillingService] Processing overdue bills', { traceId });
// 这里可以添加处理逾期账单的逻辑
return 0; // 返回处理的逾期账单数量
static async createBill(data: any): Promise<any> {
try {
const billId = `bill_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// 创建账单记录
await db('billing_records').insert({
id: billId,
merchant_id: data.merchantId || data.shopId || 'anonymous',
total_amount: data.totalAmount || data.amount || 0,
status: 'pending',
created_at: new Date(),
paid_at: null
});
// 创建账单项目
const items = data.items || [{ feature: data.type || 'FEATURE', amount: data.amount || 0, quantity: 1, unit_price: data.amount || 0 }];
for (const item of items) {
await db('billing_items').insert({
id: `bill_item_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
billing_id: billId,
feature: item.feature,
amount: item.amount || 0,
quantity: item.quantity || 1,
unit_price: item.unitPrice || 0
});
}
return {
success: true,
bill: { id: billId }
};
} catch (error) {
console.error('[BillingService] Error creating bill:', error);
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
}
export default new BillingService();

View File

@@ -0,0 +1,316 @@
import { v4 as uuidv4 } from 'uuid';
import db from '../config/database';
import { logger } from '../utils/logger';
import MerchantMetricsService, { LeaderboardEntry } from './MerchantMetricsService';
export interface LeaderboardSnapshot {
id: string;
leaderboard_type: 'REVENUE' | 'ROI' | 'GROWTH';
period: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'ALL_TIME';
rankings: LeaderboardEntry[];
total_merchants: number;
total_revenue: number;
snapshot_date: Date;
created_at: Date;
}
export interface LeaderboardConfig {
refreshIntervalMinutes: number;
topN: number;
enableAntiCheat: boolean;
minOrdersForRanking: number;
}
const DEFAULT_CONFIG: LeaderboardConfig = {
refreshIntervalMinutes: 10,
topN: 10,
enableAntiCheat: true,
minOrdersForRanking: 5
};
export class LeaderboardService {
private static instance: LeaderboardService;
private config: LeaderboardConfig;
private refreshTimer: NodeJS.Timeout | null = null;
private constructor(config: Partial<LeaderboardConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
static getInstance(config?: Partial<LeaderboardConfig>): LeaderboardService {
if (!LeaderboardService.instance) {
LeaderboardService.instance = new LeaderboardService(config);
}
return LeaderboardService.instance;
}
async getLeaderboard(
type: 'REVENUE' | 'ROI' | 'GROWTH',
period: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'ALL_TIME' = 'MONTHLY'
): Promise<LeaderboardEntry[]> {
const cachedSnapshot = await this.getLatestSnapshot(type, period);
if (cachedSnapshot) {
const snapshotAge = Date.now() - new Date(cachedSnapshot.snapshot_date).getTime();
const maxAge = this.config.refreshIntervalMinutes * 60 * 1000;
if (snapshotAge < maxAge) {
logger.info(`[LeaderboardService] Returning cached ${type} leaderboard for ${period}`);
return cachedSnapshot.rankings;
}
}
return this.refreshLeaderboard(type, period);
}
async refreshLeaderboard(
type: 'REVENUE' | 'ROI' | 'GROWTH',
period: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'ALL_TIME'
): Promise<LeaderboardEntry[]> {
logger.info(`[LeaderboardService] Refreshing ${type} leaderboard for ${period}`);
let rankings = await MerchantMetricsService.getTopMerchants(
type,
this.config.topN,
period
);
if (this.config.enableAntiCheat) {
rankings = await this.applyAntiCheatFilters(rankings);
}
rankings = rankings.filter(entry => entry.value > 0);
const totalRevenue = rankings.reduce((sum, r) => sum + r.value, 0);
await this.saveSnapshot({
id: uuidv4(),
leaderboard_type: type,
period,
rankings,
total_merchants: rankings.length,
total_revenue: totalRevenue,
snapshot_date: new Date(),
created_at: new Date()
});
logger.info(`[LeaderboardService] Refreshed ${type} leaderboard with ${rankings.length} entries`);
return rankings;
}
async getLatestSnapshot(
type: 'REVENUE' | 'ROI' | 'GROWTH',
period: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'ALL_TIME'
): Promise<LeaderboardSnapshot | null> {
const snapshot = await db('cf_leaderboard_snapshot')
.where('leaderboard_type', type)
.where('period', period)
.orderBy('snapshot_date', 'desc')
.first();
if (!snapshot) return null;
return {
...snapshot,
rankings: typeof snapshot.rankings === 'string'
? JSON.parse(snapshot.rankings)
: snapshot.rankings
};
}
async getSnapshotHistory(
type: 'REVENUE' | 'ROI' | 'GROWTH',
period: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'ALL_TIME',
limit: number = 30
): Promise<LeaderboardSnapshot[]> {
const snapshots = await db('cf_leaderboard_snapshot')
.where('leaderboard_type', type)
.where('period', period)
.orderBy('snapshot_date', 'desc')
.limit(limit);
return snapshots.map(s => ({
...s,
rankings: typeof s.rankings === 'string' ? JSON.parse(s.rankings) : s.rankings
}));
}
async saveSnapshot(snapshot: LeaderboardSnapshot): Promise<void> {
await db('cf_leaderboard_snapshot').insert({
id: snapshot.id,
leaderboard_type: snapshot.leaderboard_type,
period: snapshot.period,
rankings: JSON.stringify(snapshot.rankings),
total_merchants: snapshot.total_merchants,
total_revenue: snapshot.total_revenue,
snapshot_date: snapshot.snapshot_date,
created_at: snapshot.created_at
});
logger.info(`[LeaderboardService] Saved snapshot for ${snapshot.leaderboard_type} - ${snapshot.period}`);
}
startAutoRefresh(): void {
if (this.refreshTimer) {
logger.warn('[LeaderboardService] Auto refresh already running');
return;
}
logger.info(`[LeaderboardService] Starting auto refresh every ${this.config.refreshIntervalMinutes} minutes`);
this.refreshTimer = setInterval(async () => {
try {
await this.refreshAllLeaderboards();
} catch (error) {
logger.error('[LeaderboardService] Auto refresh failed:', error);
}
}, this.config.refreshIntervalMinutes * 60 * 1000);
this.refreshAllLeaderboards().catch(err => {
logger.error('[LeaderboardService] Initial refresh failed:', err);
});
}
stopAutoRefresh(): void {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
logger.info('[LeaderboardService] Stopped auto refresh');
}
}
async refreshAllLeaderboards(): Promise<void> {
const types: Array<'REVENUE' | 'ROI' | 'GROWTH'> = ['REVENUE', 'ROI', 'GROWTH'];
const periods: Array<'DAILY' | 'WEEKLY' | 'MONTHLY' | 'ALL_TIME'> = ['DAILY', 'WEEKLY', 'MONTHLY', 'ALL_TIME'];
for (const type of types) {
for (const period of periods) {
try {
await this.refreshLeaderboard(type, period);
} catch (error) {
logger.error(`[LeaderboardService] Failed to refresh ${type} - ${period}:`, error);
}
}
}
}
async getMerchantRank(
tenantId: string,
type: 'REVENUE' | 'ROI' | 'GROWTH',
period: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'ALL_TIME' = 'MONTHLY'
): Promise<{ rank: number; percentile: number } | null> {
const leaderboard = await this.getLeaderboard(type, period);
const entry = leaderboard.find(e => e.tenant_id === tenantId);
if (!entry) return null;
const percentile = ((leaderboard.length - entry.rank) / leaderboard.length) * 100;
return {
rank: entry.rank,
percentile: Math.round(percentile * 100) / 100
};
}
async getLeaderboardStats(): Promise<{
totalSnapshots: number;
lastRefreshTime: Date | null;
activeMerchants: number;
}> {
const totalSnapshots = await db('cf_leaderboard_snapshot').count('* as count').first();
const lastSnapshot = await db('cf_leaderboard_snapshot')
.orderBy('snapshot_date', 'desc')
.first();
const activeMerchants = await db('cf_merchant_metrics')
.where('metrics_date', '>=', new Date(Date.now() - 30 * 24 * 60 * 60 * 1000))
.countDistinct('tenant_id as count')
.first();
return {
totalSnapshots: totalSnapshots?.count || 0,
lastRefreshTime: lastSnapshot ? new Date(lastSnapshot.snapshot_date) : null,
activeMerchants: activeMerchants?.count || 0
};
}
private async applyAntiCheatFilters(rankings: LeaderboardEntry[]): Promise<LeaderboardEntry[]> {
const filtered: LeaderboardEntry[] = [];
for (const entry of rankings) {
const orderCount = await this.getMerchantOrderCount(entry.tenant_id);
if (orderCount < this.config.minOrdersForRanking) {
logger.info(`[LeaderboardService] Filtering out ${entry.tenant_name} - only ${orderCount} orders`);
continue;
}
if (entry.growth_rate > 10) {
const isLegitimate = await this.verifyHighGrowth(entry.tenant_id);
if (!isLegitimate) {
logger.warn(`[LeaderboardService] Filtering suspicious high growth from ${entry.tenant_name}`);
continue;
}
}
filtered.push(entry);
}
return filtered.map((entry, index) => ({
...entry,
rank: index + 1
}));
}
private async getMerchantOrderCount(tenantId: string): Promise<number> {
const result = await db('cf_order')
.where('tenant_id', tenantId)
.count('* as count')
.first();
return result?.count || 0;
}
private async verifyHighGrowth(tenantId: string): Promise<boolean> {
const recentOrders = await db('cf_order')
.where('tenant_id', tenantId)
.where('created_at', '>=', new Date(Date.now() - 7 * 24 * 60 * 60 * 1000))
.count('* as count')
.first();
const olderOrders = await db('cf_order')
.where('tenant_id', tenantId)
.whereBetween('created_at', [
new Date(Date.now() - 14 * 24 * 60 * 60 * 1000),
new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
])
.count('* as count')
.first();
const recent = recentOrders?.count || 0;
const older = olderOrders?.count || 0;
if (recent > 0 && older === 0) {
return false;
}
return true;
}
updateConfig(newConfig: Partial<LeaderboardConfig>): void {
this.config = { ...this.config, ...newConfig };
logger.info('[LeaderboardService] Config updated:', this.config);
if (this.refreshTimer && newConfig.refreshIntervalMinutes) {
this.stopAutoRefresh();
this.startAutoRefresh();
}
}
getConfig(): LeaderboardConfig {
return { ...this.config };
}
}
export default LeaderboardService.getInstance();

View File

@@ -0,0 +1,371 @@
import { v4 as uuidv4 } from 'uuid';
import db from '../config/database';
import { logger } from '../utils/logger';
export interface MerchantMetrics {
id: string;
tenant_id: string;
shop_id: string;
total_revenue: number;
total_cost: number;
total_profit: number;
roi: number;
growth_rate: number;
order_count: number;
product_count: number;
avg_order_value: number;
return_rate: number;
top_strategy: string | null;
top_platform: string | null;
tier: 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINUM' | 'DIAMOND';
is_verified: boolean;
metrics_date: Date;
created_at: Date;
updated_at: Date;
}
export interface MetricsCalculationInput {
tenantId: string;
shopId: string;
period: 'DAILY' | 'WEEKLY' | 'MONTHLY';
}
export interface LeaderboardEntry {
rank: number;
tenant_id: string;
tenant_name: string;
shop_id: string;
shop_name: string;
value: number;
tier: string;
is_verified: boolean;
growth_rate?: number;
}
export class MerchantMetricsService {
private static instance: MerchantMetricsService;
private constructor() {}
static getInstance(): MerchantMetricsService {
if (!MerchantMetricsService.instance) {
MerchantMetricsService.instance = new MerchantMetricsService();
}
return MerchantMetricsService.instance;
}
async calculateAndStoreMetrics(input: MetricsCalculationInput): Promise<MerchantMetrics> {
const { tenantId, shopId, period } = input;
const metricsDate = this.getMetricsDate(period);
const existingMetrics = await this.getMetricsByTenantAndDate(tenantId, metricsDate);
if (existingMetrics) {
logger.info(`[MerchantMetricsService] Metrics already exist for tenant ${tenantId} on ${metricsDate}`);
return existingMetrics;
}
const metricsData = await this.aggregateMetrics(tenantId, shopId, period);
const metrics: MerchantMetrics = {
id: uuidv4(),
tenant_id: tenantId,
shop_id: shopId,
total_revenue: metricsData.totalRevenue,
total_cost: metricsData.totalCost,
total_profit: metricsData.totalProfit,
roi: metricsData.roi,
growth_rate: metricsData.growthRate,
order_count: metricsData.orderCount,
product_count: metricsData.productCount,
avg_order_value: metricsData.avgOrderValue,
return_rate: metricsData.returnRate,
top_strategy: metricsData.topStrategy,
top_platform: metricsData.topPlatform,
tier: this.calculateTier(metricsData.totalProfit),
is_verified: false,
metrics_date: metricsDate,
created_at: new Date(),
updated_at: new Date()
};
await db('cf_merchant_metrics').insert(metrics);
logger.info(`[MerchantMetricsService] Created metrics for tenant ${tenantId}`);
return metrics;
}
async getMetricsByTenantAndDate(tenantId: string, date: Date): Promise<MerchantMetrics | null> {
const metrics = await db('cf_merchant_metrics')
.where('tenant_id', tenantId)
.where('metrics_date', date)
.first();
return metrics || null;
}
async getLatestMetrics(tenantId: string): Promise<MerchantMetrics | null> {
const metrics = await db('cf_merchant_metrics')
.where('tenant_id', tenantId)
.orderBy('metrics_date', 'desc')
.first();
return metrics || null;
}
async getMetricsHistory(
tenantId: string,
startDate: Date,
endDate: Date
): Promise<MerchantMetrics[]> {
return db('cf_merchant_metrics')
.where('tenant_id', tenantId)
.whereBetween('metrics_date', [startDate, endDate])
.orderBy('metrics_date', 'asc');
}
async getTopMerchants(
type: 'REVENUE' | 'ROI' | 'GROWTH',
limit: number = 10,
period: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'ALL_TIME' = 'MONTHLY'
): Promise<LeaderboardEntry[]> {
const metricsDate = this.getMetricsDate(period === 'ALL_TIME' ? 'MONTHLY' : period);
let query = db('cf_merchant_metrics as mm')
.join('cf_tenant as t', 'mm.tenant_id', 't.id')
.join('cf_shop as s', 'mm.shop_id', 's.id')
.select(
'mm.tenant_id',
't.name as tenant_name',
'mm.shop_id',
's.name as shop_name',
'mm.tier',
'mm.is_verified',
'mm.growth_rate'
);
switch (type) {
case 'REVENUE':
query = query.select('mm.total_profit as value').orderBy('mm.total_profit', 'desc');
break;
case 'ROI':
query = query.select('mm.roi as value').orderBy('mm.roi', 'desc');
break;
case 'GROWTH':
query = query.select('mm.growth_rate as value').orderBy('mm.growth_rate', 'desc');
break;
}
if (period !== 'ALL_TIME') {
query = query.where('mm.metrics_date', metricsDate);
}
const results = await query.limit(limit);
return results.map((row, index) => ({
rank: index + 1,
tenant_id: row.tenant_id,
tenant_name: row.tenant_name,
shop_id: row.shop_id,
shop_name: row.shop_name,
value: parseFloat(row.value) || 0,
tier: row.tier,
is_verified: row.is_verified,
growth_rate: parseFloat(row.growth_rate) || 0
}));
}
async verifyMetrics(metricsId: string): Promise<void> {
await db('cf_merchant_metrics')
.where('id', metricsId)
.update({ is_verified: true, updated_at: new Date() });
logger.info(`[MerchantMetricsService] Verified metrics ${metricsId}`);
}
async flagSuspiciousMetrics(): Promise<MerchantMetrics[]> {
const suspicious = await db('cf_merchant_metrics')
.where('roi', '>', 10)
.orWhere('growth_rate', '>', 5)
.orWhere('return_rate', '<', 0.01);
logger.warn(`[MerchantMetricsService] Found ${suspicious.length} potentially suspicious metrics`);
return suspicious;
}
private async aggregateMetrics(
tenantId: string,
shopId: string,
period: 'DAILY' | 'WEEKLY' | 'MONTHLY'
): Promise<{
totalRevenue: number;
totalCost: number;
totalProfit: number;
roi: number;
growthRate: number;
orderCount: number;
productCount: number;
avgOrderValue: number;
returnRate: number;
topStrategy: string | null;
topPlatform: string | null;
}> {
const { startDate, endDate } = this.getPeriodRange(period);
const orders = await db('cf_order')
.where('tenant_id', tenantId)
.where('shop_id', shopId)
.whereBetween('created_at', [startDate, endDate]);
const products = await db('cf_product')
.where('tenant_id', tenantId)
.where('shop_id', shopId)
.whereBetween('created_at', [startDate, endDate]);
const totalRevenue = orders.reduce((sum, o) => sum + (parseFloat(o.total_amount) || 0), 0);
const totalCost = orders.reduce((sum, o) => sum + (parseFloat(o.cost_amount) || 0), 0);
const totalProfit = totalRevenue - totalCost;
const roi = totalCost > 0 ? totalProfit / totalCost : 0;
const previousPeriodMetrics = await this.getPreviousPeriodMetrics(tenantId, period);
const growthRate = previousPeriodMetrics.totalProfit > 0
? (totalProfit - previousPeriodMetrics.totalProfit) / previousPeriodMetrics.totalProfit
: 0;
const orderCount = orders.length;
const productCount = products.length;
const avgOrderValue = orderCount > 0 ? totalRevenue / orderCount : 0;
const returnOrders = orders.filter(o => o.status === 'RETURNED' || o.status === 'REFUNDED');
const returnRate = orderCount > 0 ? returnOrders.length / orderCount : 0;
const topStrategy = await this.getTopStrategy(tenantId, startDate, endDate);
const topPlatform = await this.getTopPlatform(tenantId, startDate, endDate);
return {
totalRevenue,
totalCost,
totalProfit,
roi,
growthRate,
orderCount,
productCount,
avgOrderValue,
returnRate,
topStrategy,
topPlatform
};
}
private async getPreviousPeriodMetrics(
tenantId: string,
period: 'DAILY' | 'WEEKLY' | 'MONTHLY'
): Promise<{ totalProfit: number }> {
const previousDate = this.getPreviousPeriodDate(period);
const metrics = await db('cf_merchant_metrics')
.where('tenant_id', tenantId)
.where('metrics_date', previousDate)
.first();
return {
totalProfit: metrics ? parseFloat(metrics.total_profit) || 0 : 0
};
}
private async getTopStrategy(tenantId: string, startDate: Date, endDate: Date): Promise<string | null> {
const strategies = await db('cf_order')
.where('tenant_id', tenantId)
.whereBetween('created_at', [startDate, endDate])
.whereNotNull('strategy')
.groupBy('strategy')
.select('strategy')
.count('* as count')
.orderBy('count', 'desc')
.first();
return strategies?.strategy || null;
}
private async getTopPlatform(tenantId: string, startDate: Date, endDate: Date): Promise<string | null> {
const platforms = await db('cf_order')
.where('tenant_id', tenantId)
.whereBetween('created_at', [startDate, endDate])
.whereNotNull('platform')
.groupBy('platform')
.select('platform')
.count('* as count')
.orderBy('count', 'desc')
.first();
return platforms?.platform || null;
}
private calculateTier(totalProfit: number): 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINUM' | 'DIAMOND' {
if (totalProfit >= 100000) return 'DIAMOND';
if (totalProfit >= 50000) return 'PLATINUM';
if (totalProfit >= 20000) return 'GOLD';
if (totalProfit >= 5000) return 'SILVER';
return 'BRONZE';
}
private getMetricsDate(period: 'DAILY' | 'WEEKLY' | 'MONTHLY'): Date {
const now = new Date();
switch (period) {
case 'DAILY':
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
case 'WEEKLY':
const weekStart = new Date(now);
weekStart.setDate(now.getDate() - now.getDay());
return new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate());
case 'MONTHLY':
return new Date(now.getFullYear(), now.getMonth(), 1);
default:
return new Date(now.getFullYear(), now.getMonth(), 1);
}
}
private getPeriodRange(period: 'DAILY' | 'WEEKLY' | 'MONTHLY'): { startDate: Date; endDate: Date } {
const now = new Date();
let startDate: Date;
const endDate = new Date(now);
switch (period) {
case 'DAILY':
startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
break;
case 'WEEKLY':
startDate = new Date(now);
startDate.setDate(now.getDate() - 7);
break;
case 'MONTHLY':
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
break;
default:
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
}
return { startDate, endDate };
}
private getPreviousPeriodDate(period: 'DAILY' | 'WEEKLY' | 'MONTHLY'): Date {
const now = new Date();
switch (period) {
case 'DAILY':
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
return new Date(yesterday.getFullYear(), yesterday.getMonth(), yesterday.getDate());
case 'WEEKLY':
const lastWeek = new Date(now);
lastWeek.setDate(now.getDate() - 14);
const weekStart = new Date(lastWeek);
weekStart.setDate(lastWeek.getDate() - lastWeek.getDay());
return new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate());
case 'MONTHLY':
return new Date(now.getFullYear(), now.getMonth() - 1, 1);
default:
return new Date(now.getFullYear(), now.getMonth() - 1, 1);
}
}
}
export default MerchantMetricsService.getInstance();

View File

@@ -458,6 +458,100 @@ export class OrderService {
}
}
/**
* 将各平台原始 Payload 映射为系统统一模型
*/
static mapPlatformPayloadToOrder(platform: string, payload: any, tenantId: string, shopId: string): Partial<ConsumerOrder> | null {
const upperPlatform = platform.toUpperCase();
switch (upperPlatform) {
case 'SHOPIFY':
return {
tenant_id: tenantId,
shop_id: shopId,
platform: 'SHOPIFY',
platform_order_id: String(payload.id || payload.name),
customer_name: payload.customer?.first_name ? `${payload.customer.first_name} ${payload.customer.last_name}` : 'Unknown',
customer_email: payload.customer?.email,
shipping_address: payload.shipping_address,
items: payload.line_items?.map((item: any) => ({
skuId: item.sku,
title: item.title,
price: Number(item.price),
quantity: item.quantity,
costPrice: item.cost_price || 0
})),
total_amount: Number(payload.total_price),
currency: payload.currency || 'USD',
status: this.mapShopifyStatus(payload.financial_status, payload.fulfillment_status),
payment_status: payload.financial_status === 'paid' ? 'COMPLETED' : 'PENDING',
fulfillment_status: payload.fulfillment_status === 'fulfilled' ? 'SHIPPED' : 'PENDING',
trace_id: `webhook-${Date.now()}`
};
case 'AMAZON':
// 模拟 Amazon SP-API 结构
return {
tenant_id: tenantId,
shop_id: shopId,
platform: 'AMAZON',
platform_order_id: payload.AmazonOrderId,
customer_name: payload.BuyerInfo?.BuyerName || 'Amazon Customer',
customer_email: payload.BuyerInfo?.BuyerEmail,
shipping_address: payload.ShippingAddress,
items: payload.OrderItems?.map((item: any) => ({
skuId: item.SellerSKU,
title: item.Title,
price: Number(item.ItemPrice?.Amount || 0),
quantity: item.QuantityOrdered,
costPrice: 0
})),
total_amount: Number(payload.OrderTotal?.Amount || 0),
currency: payload.OrderTotal?.CurrencyCode || 'USD',
status: payload.OrderStatus === 'Shipped' ? 'SHIPPED' : 'PAID',
payment_status: 'COMPLETED',
fulfillment_status: payload.OrderStatus === 'Shipped' ? 'SHIPPED' : 'PENDING',
trace_id: `webhook-${Date.now()}`
};
case 'TIKTOK':
// 模拟 TikTok Shop API 结构
return {
tenant_id: tenantId,
shop_id: shopId,
platform: 'TIKTOK',
platform_order_id: payload.order_id,
customer_name: payload.recipient_address?.name || 'TikTok Customer',
shipping_address: payload.recipient_address,
items: payload.item_list?.map((item: any) => ({
skuId: item.sku_id,
title: item.product_name,
price: Number(item.sku_sale_price),
quantity: 1,
costPrice: 0
})),
total_amount: Number(payload.total_amount),
currency: payload.currency || 'USD',
status: payload.order_status === 100 ? 'PAID' : 'UNPAID',
payment_status: payload.order_status >= 100 ? 'COMPLETED' : 'PENDING',
fulfillment_status: 'PENDING',
trace_id: `webhook-${Date.now()}`
};
default:
return null;
}
}
/**
* 映射Shopify订单状态
*/
private static mapShopifyStatus(financial: string, fulfillment: string): 'UNPAID' | 'PAID' | 'SHIPPED' | 'DELIVERED' {
if (fulfillment === 'fulfilled') return 'SHIPPED';
if (financial === 'paid') return 'PAID';
return 'UNPAID';
}
/**
* 初始化订单相关表
*/

View File

@@ -0,0 +1,585 @@
import db from '../config/database';
import { logger } from '../utils/logger';
import { AIService } from './AIService';
import { ProductService } from './ProductService';
import { PricingService } from './PricingService';
import { CompetitorService } from './CompetitorService';
import { EventBusService } from './EventBusService';
interface SelectionRule {
id: string;
tenant_id: string;
shop_id: string;
name: string;
category: string[];
min_roi: number;
max_roi: number;
min_profit: number;
max_price: number;
min_sales_volume: number;
max_competition_level: 'LOW' | 'MEDIUM' | 'HIGH';
min_rating: number;
trend_filter: 'ALL' | 'UP' | 'STABLE';
enabled: boolean;
last_run_at?: Date;
selected_count: number;
created_at: Date;
updated_at: Date;
}
interface ProductPool {
id: string;
tenant_id: string;
shop_id: string;
product_id: string;
name: string;
category: string;
price: number;
cost_price: number;
profit: number;
roi: number;
sales_volume: number;
rating: number;
review_count: number;
competition_level: 'LOW' | 'MEDIUM' | 'HIGH';
trend: 'UP' | 'DOWN' | 'STABLE';
source_platform: string;
image_url: string;
selection_score: number;
tags: string[];
selected_at?: Date;
created_at: Date;
updated_at: Date;
}
interface SelectionResult {
rule_id: string;
rule_name: string;
selected_products: ProductPool[];
total_count: number;
execution_time: number;
timestamp: Date;
}
export class ProductSelectionService {
private static readonly TABLE_NAME_RULES = 'cf_selection_rules';
private static readonly TABLE_NAME_POOL = 'cf_product_pool';
private static readonly TABLE_NAME_SELECTED = 'cf_selected_products';
/**
* 初始化数据库表
*/
static async initTables() {
await this.initRulesTable();
await this.initPoolTable();
await this.initSelectedTable();
}
private static async initRulesTable() {
const hasTable = await db.schema.hasTable(this.TABLE_NAME_RULES);
if (!hasTable) {
logger.info(`Creating ${this.TABLE_NAME_RULES} table...`);
await db.schema.createTable(this.TABLE_NAME_RULES, (table) => {
table.increments('id').primary();
table.string('tenant_id', 50).notNullable().index();
table.string('shop_id', 50).notNullable().index();
table.string('name', 200).notNullable();
table.json('category').notNullable();
table.decimal('min_roi', 5, 2).notNullable();
table.decimal('max_roi', 5, 2).notNullable();
table.decimal('min_profit', 10, 2).notNullable();
table.decimal('max_price', 10, 2).notNullable();
table.integer('min_sales_volume').notNullable();
table.enum('max_competition_level', ['LOW', 'MEDIUM', 'HIGH']).notNullable();
table.decimal('min_rating', 3, 2).notNullable();
table.enum('trend_filter', ['ALL', 'UP', 'STABLE']).notNullable();
table.boolean('enabled').defaultTo(true);
table.timestamp('last_run_at');
table.integer('selected_count').defaultTo(0);
table.timestamps(true, true);
table.index(['tenant_id', 'shop_id', 'enabled'], 'idx_rule_tenant_shop_enabled');
});
logger.info(`✅ Table ${this.TABLE_NAME_RULES} created`);
}
}
private static async initPoolTable() {
const hasTable = await db.schema.hasTable(this.TABLE_NAME_POOL);
if (!hasTable) {
logger.info(`Creating ${this.TABLE_NAME_POOL} table...`);
await db.schema.createTable(this.TABLE_NAME_POOL, (table) => {
table.increments('id').primary();
table.string('tenant_id', 50).notNullable().index();
table.string('shop_id', 50).notNullable().index();
table.string('product_id', 100).notNullable();
table.string('name', 500).notNullable();
table.string('category', 100).notNullable();
table.decimal('price', 10, 2).notNullable();
table.decimal('cost_price', 10, 2).notNullable();
table.decimal('profit', 10, 2).notNullable();
table.decimal('roi', 5, 2).notNullable();
table.integer('sales_volume').notNullable();
table.decimal('rating', 3, 2).notNullable();
table.integer('review_count').notNullable();
table.enum('competition_level', ['LOW', 'MEDIUM', 'HIGH']).notNullable();
table.enum('trend', ['UP', 'DOWN', 'STABLE']).notNullable();
table.string('source_platform', 50).notNullable();
table.string('image_url', 500);
table.decimal('selection_score', 5, 2).notNullable();
table.json('tags');
table.timestamp('selected_at');
table.timestamps(true, true);
table.index(['tenant_id', 'shop_id', 'selection_score'], 'idx_pool_score');
table.index(['tenant_id', 'shop_id', 'category'], 'idx_pool_category');
table.index(['tenant_id', 'shop_id', 'competition_level'], 'idx_pool_competition');
});
logger.info(`✅ Table ${this.TABLE_NAME_POOL} created`);
}
}
private static async initSelectedTable() {
const hasTable = await db.schema.hasTable(this.TABLE_NAME_SELECTED);
if (!hasTable) {
logger.info(`Creating ${this.TABLE_NAME_SELECTED} table...`);
await db.schema.createTable(this.TABLE_NAME_SELECTED, (table) => {
table.increments('id').primary();
table.string('tenant_id', 50).notNullable().index();
table.string('shop_id', 50).notNullable().index();
table.string('product_id', 100).notNullable();
table.string('rule_id').notNullable();
table.decimal('selection_score', 5, 2).notNullable();
table.enum('status', ['PENDING', 'APPROVED', 'REJECTED']).defaultTo('PENDING');
table.json('selection_reason');
table.timestamps(true, true);
table.index(['tenant_id', 'shop_id', 'status'], 'idx_selected_status');
table.index(['tenant_id', 'shop_id', 'rule_id'], 'idx_selected_rule');
});
logger.info(`✅ Table ${this.TABLE_NAME_SELECTED} created`);
}
}
/**
* 创建选品规则
*/
static async createRule(rule: Omit<SelectionRule, 'id' | 'selected_count' | 'created_at' | 'updated_at'>): Promise<SelectionRule> {
const [id] = await db(this.TABLE_NAME_RULES).insert({
...rule,
category: JSON.stringify(rule.category),
selected_count: 0,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
return {
...rule,
id: id.toString(),
selected_count: 0,
created_at: new Date(),
updated_at: new Date(),
};
}
/**
* 更新选品规则
*/
static async updateRule(ruleId: string, updates: Partial<SelectionRule>): Promise<void> {
const updateData: any = { ...updates, updated_at: new Date() };
if (updates.category) {
updateData.category = JSON.stringify(updates.category);
}
await db(this.TABLE_NAME_RULES).where({ id: ruleId }).update(updateData);
}
/**
* 删除选品规则
*/
static async deleteRule(ruleId: string): Promise<void> {
await db(this.TABLE_NAME_RULES).where({ id: ruleId }).delete();
}
/**
* 切换规则启用状态
*/
static async toggleRule(ruleId: string): Promise<SelectionRule | null> {
const rule = await db(this.TABLE_NAME_RULES).where({ id: ruleId }).first();
if (!rule) return null;
const updated = await db(this.TABLE_NAME_RULES).where({ id: ruleId }).update({
enabled: !rule.enabled,
updated_at: new Date(),
}).returning('*');
return {
...updated[0],
category: JSON.parse(updated[0].category),
};
}
/**
* 获取租户的所有选品规则
*/
static async getRules(tenantId: string, shopId?: string): Promise<SelectionRule[]> {
const query = db(this.TABLE_NAME_RULES).where({ tenant_id: tenantId });
if (shopId) {
query.where({ shop_id: shopId });
}
const rules = await query.orderBy('created_at', 'desc');
return rules.map(rule => ({
...rule,
category: JSON.parse(rule.category),
}));
}
/**
* 添加商品到选品池
*/
static async addToPool(product: Omit<ProductPool, 'id' | 'created_at' | 'updated_at'>): Promise<ProductPool> {
const [id] = await db(this.TABLE_NAME_POOL).insert({
...product,
tags: JSON.stringify(product.tags || []),
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
return {
...product,
id: id.toString(),
created_at: new Date(),
updated_at: new Date(),
};
}
/**
* 批量添加商品到选品池
*/
static async batchAddToPool(products: Omit<ProductPool, 'id' | 'created_at' | 'updated_at'>[]): Promise<void> {
const now = new Date();
const records = products.map(product => ({
...product,
tags: JSON.stringify(product.tags || []),
created_at: now,
updated_at: now,
}));
await db(this.TABLE_NAME_POOL).insert(records);
}
/**
* 从选品池获取商品
*/
static async getFromPool(
tenantId: string,
shopId: string,
filters?: {
category?: string;
min_roi?: number;
max_roi?: number;
competition_level?: 'LOW' | 'MEDIUM' | 'HIGH';
trend?: 'UP' | 'DOWN' | 'STABLE';
min_score?: number;
},
limit: number = 50,
offset: number = 0
): Promise<ProductPool[]> {
let query = db(this.TABLE_NAME_POOL)
.where({ tenant_id: tenantId, shop_id: shopId });
if (filters?.category) {
query = query.where({ category: filters.category });
}
if (filters?.min_roi) {
query = query.where('roi', '>=', filters.min_roi);
}
if (filters?.max_roi) {
query = query.where('roi', '<=', filters.max_roi);
}
if (filters?.competition_level) {
query = query.where({ competition_level: filters.competition_level });
}
if (filters?.trend) {
query = query.where({ trend: filters.trend });
}
if (filters?.min_score) {
query = query.where('selection_score', '>=', filters.min_score);
}
const products = await query
.orderBy('selection_score', 'desc')
.limit(limit)
.offset(offset);
return products.map(product => ({
...product,
tags: product.tags ? JSON.parse(product.tags) : [],
}));
}
/**
* 执行选品规则
*/
static async executeRule(ruleId: string): Promise<SelectionResult> {
const startTime = Date.now();
const rule = await db(this.TABLE_NAME_RULES).where({ id: ruleId }).first();
if (!rule) {
throw new Error(`Rule ${ruleId} not found`);
}
const ruleData = {
...rule,
category: JSON.parse(rule.category),
};
const selectedProducts = await this.getFromPool(
ruleData.tenant_id,
ruleData.shop_id,
{
category: ruleData.category.length > 0 ? undefined : undefined,
min_roi: ruleData.min_roi,
max_roi: ruleData.max_roi,
competition_level: ruleData.max_competition_level,
trend: ruleData.trend_filter === 'ALL' ? undefined : ruleData.trend_filter,
},
100
);
const filteredProducts = selectedProducts.filter(product => {
if (ruleData.category.length > 0 && !ruleData.category.includes(product.category)) {
return false;
}
if (product.profit < ruleData.min_profit) return false;
if (product.price > ruleData.max_price) return false;
if (product.sales_volume < ruleData.min_sales_volume) return false;
if (product.rating < ruleData.min_rating) return false;
return true;
});
const now = new Date();
for (const product of filteredProducts) {
await db(this.TABLE_NAME_SELECTED).insert({
tenant_id: ruleData.tenant_id,
shop_id: ruleData.shop_id,
product_id: product.product_id,
rule_id: ruleId,
selection_score: product.selection_score,
status: 'PENDING',
selection_reason: JSON.stringify({
rule_name: ruleData.name,
roi: product.roi,
profit: product.profit,
sales_volume: product.sales_volume,
rating: product.rating,
}),
created_at: now,
updated_at: now,
});
}
await db(this.TABLE_NAME_RULES).where({ id: ruleId }).update({
last_run_at: now,
selected_count: ruleData.selected_count + filteredProducts.length,
updated_at: now,
});
const executionTime = Date.now() - startTime;
const result: SelectionResult = {
rule_id: ruleId,
rule_name: ruleData.name,
selected_products: filteredProducts,
total_count: filteredProducts.length,
execution_time,
timestamp: now,
};
logger.info(`Rule ${ruleId} executed: ${filteredProducts.length} products selected in ${executionTime}ms`);
return result;
}
/**
* 执行所有启用的规则
*/
static async executeAllRules(tenantId: string, shopId?: string): Promise<SelectionResult[]> {
const query = db(this.TABLE_NAME_RULES).where({ tenant_id: tenantId, enabled: true });
if (shopId) {
query.where({ shop_id: shopId });
}
const rules = await query;
const results: SelectionResult[] = [];
for (const rule of rules) {
try {
const result = await this.executeRule(rule.id);
results.push(result);
} catch (error) {
logger.error(`Failed to execute rule ${rule.id}:`, error);
}
}
return results;
}
/**
* 计算商品选品评分
*/
static async calculateSelectionScore(product: any): Promise<number> {
let score = 0;
const roiScore = Math.min(product.roi / 100, 1) * 30;
score += roiScore;
const profitScore = Math.min(product.profit / 100, 1) * 20;
score += profitScore;
const salesScore = Math.min(product.sales_volume / 1000, 1) * 15;
score += salesScore;
const ratingScore = (product.rating / 5) * 15;
score += ratingScore;
const competitionScore = {
LOW: 10,
MEDIUM: 5,
HIGH: 0,
}[product.competition_level] || 0;
score += competitionScore;
const trendScore = {
UP: 10,
STABLE: 5,
DOWN: 0,
}[product.trend] || 0;
score += trendScore;
return Math.min(score, 100);
}
/**
* 分析竞争水平
*/
static async analyzeCompetitionLevel(productId: string, platform: string): Promise<'LOW' | 'MEDIUM' | 'HIGH'> {
const competitors = await CompetitorService.getCompetitors(productId, platform);
const count = competitors.length;
if (count < 10) return 'LOW';
if (count < 30) return 'MEDIUM';
return 'HIGH';
}
/**
* 分析销售趋势
*/
static async analyzeSalesTrend(productId: string, platform: string): Promise<'UP' | 'DOWN' | 'STABLE'> {
const salesHistory = await CompetitorService.getSalesHistory(productId, platform);
if (salesHistory.length < 2) return 'STABLE';
const recent = salesHistory.slice(-7).reduce((sum, h) => sum + h.sales, 0);
const previous = salesHistory.slice(-14, -7).reduce((sum, h) => sum + h.sales, 0);
if (previous === 0) return 'STABLE';
const growthRate = ((recent - previous) / previous) * 100;
if (growthRate > 10) return 'UP';
if (growthRate < -10) return 'DOWN';
return 'STABLE';
}
/**
* 从外部平台同步商品到选品池
*/
static async syncProductsFromPlatform(
tenantId: string,
shopId: string,
platform: string,
category?: string
): Promise<number> {
const products = await ProductService.getProductsByPlatform(platform, category);
let syncedCount = 0;
for (const product of products) {
const competitionLevel = await this.analyzeCompetitionLevel(product.productId, platform);
const trend = await this.analyzeSalesTrend(product.productId, platform);
const selectionScore = await this.calculateSelectionScore(product);
const poolProduct: Omit<ProductPool, 'id' | 'created_at' | 'updated_at'> = {
tenant_id: tenantId,
shop_id: shopId,
product_id: product.productId,
name: product.title,
category: category || '未分类',
price: parseFloat(product.price),
cost_price: parseFloat(product.price) * 0.5,
profit: parseFloat(product.price) * 0.5,
roi: 100,
sales_volume: product.sales || 0,
rating: product.rating || 0,
review_count: 0,
competition_level: competitionLevel,
trend: trend,
source_platform: platform,
image_url: product.mainImage,
selection_score: selectionScore,
tags: [],
};
await this.addToPool(poolProduct);
syncedCount++;
}
logger.info(`Synced ${syncedCount} products from ${platform} to pool`);
return syncedCount;
}
/**
* 获取选品统计
*/
static async getSelectionStats(tenantId: string, shopId: string): Promise<{
totalPool: number;
totalSelected: number;
avgROI: number;
avgScore: number;
lowCompetition: number;
upTrend: number;
}> {
const [poolStats] = await db(this.TABLE_NAME_POOL)
.where({ tenant_id: tenantId, shop_id: shopId })
.select(
db.raw('COUNT(*) as total'),
db.raw('AVG(roi) as avg_roi'),
db.raw('AVG(selection_score) as avg_score'),
db.raw('SUM(CASE WHEN competition_level = ? THEN 1 ELSE 0 END) as low_competition', ['LOW']),
db.raw('SUM(CASE WHEN trend = ? THEN 1 ELSE 0 END) as up_trend', ['UP'])
);
const [selectedStats] = await db(this.TABLE_NAME_SELECTED)
.where({ tenant_id: tenantId, shop_id: shopId })
.select(db.raw('COUNT(*) as total'));
return {
totalPool: poolStats.total || 0,
totalSelected: selectedStats.total || 0,
avgROI: poolStats.avg_roi || 0,
avgScore: poolStats.avg_score || 0,
lowCompetition: poolStats.low_competition || 0,
upTrend: poolStats.up_trend || 0,
};
}
/**
* 清理过期的选品池商品
*/
static async cleanupOldPoolProducts(days: number = 30): Promise<number> {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - days);
const deleted = await db(this.TABLE_NAME_POOL)
.where('created_at', '<', cutoffDate)
.delete();
logger.info(`Cleaned up ${deleted} old products from pool`);
return deleted;
}
}

View File

@@ -0,0 +1,359 @@
import db from '../config/database';
import { logger } from '../utils/logger';
import StrategyService, { Strategy } from './StrategyService';
import MerchantMetricsService from './MerchantMetricsService';
export interface RecommendationContext {
tenantId: string;
shopId?: string;
currentMetrics?: {
revenue: number;
roi: number;
orderCount: number;
productCount: number;
};
activeStrategies?: string[];
preferences?: {
riskTolerance: 'LOW' | 'MEDIUM' | 'HIGH';
budget: number;
categories?: string[];
};
}
export interface StrategyRecommendation {
strategy: Strategy;
score: number;
reasons: string[];
expectedRoi: number;
confidence: number;
priority: 'HIGH' | 'MEDIUM' | 'LOW';
}
export class StrategyRecommendationService {
private static instance: StrategyRecommendationService;
private constructor() {}
static getInstance(): StrategyRecommendationService {
if (!StrategyRecommendationService.instance) {
StrategyRecommendationService.instance = new StrategyRecommendationService();
}
return StrategyRecommendationService.instance;
}
async getRecommendations(context: RecommendationContext): Promise<StrategyRecommendation[]> {
const { tenantId, preferences } = context;
const metrics = context.currentMetrics || await this.fetchCurrentMetrics(tenantId);
const activeStrategies = context.activeStrategies || await this.fetchActiveStrategies(tenantId);
const allStrategies = await StrategyService.getAllStrategies({ isActive: true });
const availableStrategies = allStrategies.strategies.filter(
s => !activeStrategies.includes(s.id)
);
const recommendations: StrategyRecommendation[] = [];
for (const strategy of availableStrategies) {
const recommendation = await this.evaluateStrategy(strategy, {
...context,
currentMetrics: metrics,
activeStrategies
});
if (recommendation) {
recommendations.push(recommendation);
}
}
recommendations.sort((a, b) => b.score - a.score);
const filtered = this.applyPreferences(recommendations, preferences);
logger.info(`[StrategyRecommendationService] Generated ${filtered.length} recommendations for tenant ${tenantId}`);
return filtered.slice(0, 10);
}
async getPersonalizedRecommendations(tenantId: string): Promise<StrategyRecommendation[]> {
const context = await this.buildRecommendationContext(tenantId);
return this.getRecommendations(context);
}
async getCategoryRecommendations(
category: string,
tenantId: string
): Promise<StrategyRecommendation[]> {
const context = await this.buildRecommendationContext(tenantId);
const strategies = await StrategyService.getStrategiesByCategory(category);
const recommendations: StrategyRecommendation[] = [];
for (const strategy of strategies) {
if (context.activeStrategies?.includes(strategy.id)) continue;
const recommendation = await this.evaluateStrategy(strategy, context);
if (recommendation) {
recommendations.push(recommendation);
}
}
recommendations.sort((a, b) => b.score - a.score);
return recommendations;
}
async getSimilarStrategies(strategyId: string): Promise<Strategy[]> {
const strategy = await StrategyService.getStrategyById(strategyId);
if (!strategy) return [];
const allStrategies = await StrategyService.getAllStrategies({ isActive: true });
return allStrategies.strategies
.filter(s => s.id !== strategyId)
.filter(s => s.category === strategy.category || s.tags.some(t => strategy.tags.includes(t)))
.sort((a, b) => {
const aScore = this.calculateSimilarity(strategy, a);
const bScore = this.calculateSimilarity(strategy, b);
return bScore - aScore;
})
.slice(0, 5);
}
async getTrendingStrategies(limit: number = 5): Promise<Strategy[]> {
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const trending = await db('cf_merchant_strategies as ms')
.join('cf_strategies as s', 'ms.strategy_id', 's.id')
.where('ms.activated_at', '>=', thirtyDaysAgo)
.where('s.is_active', true)
.groupBy('s.id')
.select('s.*')
.count('ms.id as activation_count')
.orderBy('activation_count', 'desc')
.limit(limit);
return trending.map(row => ({
id: row.id,
name: row.name,
description: row.description,
category: row.category,
risk_level: row.risk_level,
price: parseFloat(row.price) || 0,
billing_type: row.billing_type,
parameters: typeof row.parameters === 'string' ? JSON.parse(row.parameters) : row.parameters,
default_config: typeof row.default_config === 'string' ? JSON.parse(row.default_config) : row.default_config,
avg_roi: parseFloat(row.avg_roi) || 0,
usage_count: row.usage_count || 0,
success_rate: row.success_rate || 0,
tags: typeof row.tags === 'string' ? JSON.parse(row.tags) : row.tags,
is_active: row.is_active,
is_featured: row.is_featured,
created_by: row.created_by,
created_at: row.created_at,
updated_at: row.updated_at
}));
}
private async evaluateStrategy(
strategy: Strategy,
context: RecommendationContext
): Promise<StrategyRecommendation | null> {
const score = this.calculateStrategyScore(strategy, context);
const reasons = this.generateReasons(strategy, context);
const expectedRoi = this.estimateRoi(strategy, context);
const confidence = this.calculateConfidence(strategy, context);
const priority = this.determinePriority(score, strategy.risk_level);
return {
strategy,
score,
reasons,
expectedRoi,
confidence,
priority
};
}
private calculateStrategyScore(
strategy: Strategy,
context: RecommendationContext
): number {
let score = 0;
score += strategy.avg_roi * 30;
score += strategy.success_rate * 0.2;
score += Math.min(strategy.usage_count / 100, 10);
if (context.currentMetrics) {
if (strategy.category === 'PRICING' && context.currentMetrics.roi < 0.5) {
score += 20;
}
if (strategy.category === 'ADVERTISING' && context.currentMetrics.orderCount < 100) {
score += 15;
}
if (strategy.category === 'PRODUCT_SELECTION' && context.currentMetrics.productCount < 50) {
score += 15;
}
}
if (context.preferences?.riskTolerance) {
if (context.preferences.riskTolerance === 'LOW' && strategy.risk_level === 'LOW') {
score += 10;
} else if (context.preferences.riskTolerance === 'HIGH' && strategy.risk_level === 'HIGH') {
score += 10;
}
}
if (strategy.is_featured) {
score += 5;
}
return Math.round(score * 100) / 100;
}
private generateReasons(strategy: Strategy, context: RecommendationContext): string[] {
const reasons: string[] = [];
if (strategy.avg_roi > 0.5) {
reasons.push(`High average ROI of ${(strategy.avg_roi * 100).toFixed(1)}%`);
}
if (strategy.success_rate > 80) {
reasons.push(`Proven success rate of ${strategy.success_rate}%`);
}
if (strategy.usage_count > 100) {
reasons.push(`Used by ${strategy.usage_count}+ merchants`);
}
if (context.currentMetrics) {
if (strategy.category === 'PRICING' && context.currentMetrics.roi < 0.5) {
reasons.push('Recommended for improving your current ROI');
}
if (strategy.category === 'ADVERTISING' && context.currentMetrics.orderCount < 100) {
reasons.push('Can help increase your order volume');
}
}
if (strategy.price === 0) {
reasons.push('Free to use');
}
return reasons.slice(0, 4);
}
private estimateRoi(strategy: Strategy, context: RecommendationContext): number {
let estimatedRoi = strategy.avg_roi;
if (context.currentMetrics) {
const roiDiff = strategy.avg_roi - context.currentMetrics.roi;
if (roiDiff > 0) {
estimatedRoi = context.currentMetrics.roi + roiDiff * 0.7;
}
}
return Math.round(estimatedRoi * 100) / 100;
}
private calculateConfidence(strategy: Strategy, context: RecommendationContext): number {
let confidence = 0.5;
if (strategy.usage_count > 50) confidence += 0.1;
if (strategy.usage_count > 200) confidence += 0.1;
if (strategy.success_rate > 70) confidence += 0.1;
if (strategy.success_rate > 90) confidence += 0.1;
return Math.min(confidence, 0.95);
}
private determinePriority(score: number, riskLevel: string): 'HIGH' | 'MEDIUM' | 'LOW' {
if (score > 50 && riskLevel === 'LOW') return 'HIGH';
if (score > 30) return 'MEDIUM';
return 'LOW';
}
private applyPreferences(
recommendations: StrategyRecommendation[],
preferences?: RecommendationContext['preferences']
): StrategyRecommendation[] {
if (!preferences) return recommendations;
let filtered = recommendations;
if (preferences.budget !== undefined) {
filtered = filtered.filter(r => r.strategy.price <= preferences.budget);
}
if (preferences.riskTolerance === 'LOW') {
filtered = filtered.sort((a, b) => {
const riskOrder = { LOW: 0, MEDIUM: 1, HIGH: 2 };
return riskOrder[a.strategy.risk_level] - riskOrder[b.strategy.risk_level];
});
}
if (preferences.categories && preferences.categories.length > 0) {
filtered = filtered.filter(r => preferences.categories!.includes(r.strategy.category));
}
return filtered;
}
private async buildRecommendationContext(tenantId: string): Promise<RecommendationContext> {
const metrics = await this.fetchCurrentMetrics(tenantId);
const activeStrategies = await this.fetchActiveStrategies(tenantId);
return {
tenantId,
currentMetrics: metrics,
activeStrategies
};
}
private async fetchCurrentMetrics(tenantId: string): Promise<RecommendationContext['currentMetrics']> {
try {
const latestMetrics = await MerchantMetricsService.getLatestMetrics(tenantId);
if (latestMetrics) {
return {
revenue: parseFloat(latestMetrics.total_revenue as any) || 0,
roi: parseFloat(latestMetrics.roi as any) || 0,
orderCount: latestMetrics.order_count || 0,
productCount: latestMetrics.product_count || 0
};
}
} catch (error) {
logger.warn('[StrategyRecommendationService] Could not fetch metrics:', error);
}
return {
revenue: 0,
roi: 0,
orderCount: 0,
productCount: 0
};
}
private async fetchActiveStrategies(tenantId: string): Promise<string[]> {
const active = await db('cf_merchant_strategies')
.where('tenant_id', tenantId)
.where('status', 'ACTIVE')
.select('strategy_id');
return active.map(s => s.strategy_id);
}
private calculateSimilarity(strategy1: Strategy, strategy2: Strategy): number {
let score = 0;
if (strategy1.category === strategy2.category) score += 0.5;
const commonTags = strategy1.tags.filter(t => strategy2.tags.includes(t));
score += commonTags.length * 0.1;
const roiDiff = Math.abs(strategy1.avg_roi - strategy2.avg_roi);
score -= roiDiff * 0.1;
return Math.max(0, score);
}
}
export default StrategyRecommendationService.getInstance();

View File

@@ -0,0 +1,374 @@
import { v4 as uuidv4 } from 'uuid';
import db from '../config/database';
import { logger } from '../utils/logger';
export interface Strategy {
id: string;
name: string;
description: string;
category: 'PRICING' | 'ADVERTISING' | 'PRODUCT_SELECTION' | 'INVENTORY' | 'LOGISTICS' | 'MARKETING';
risk_level: 'LOW' | 'MEDIUM' | 'HIGH';
price: number;
billing_type: 'FREE' | 'ONE_TIME' | 'SUBSCRIPTION' | 'USAGE_BASED';
parameters: Record<string, any>;
default_config: Record<string, any>;
avg_roi: number;
usage_count: number;
success_rate: number;
tags: string[];
is_active: boolean;
is_featured: boolean;
created_by: string;
created_at: Date;
updated_at: Date;
}
export interface MerchantStrategy {
id: string;
tenant_id: string;
strategy_id: string;
status: 'ACTIVE' | 'PAUSED' | 'COMPLETED' | 'FAILED';
config: Record<string, any>;
results: Record<string, any>;
roi_achieved: number;
revenue_generated: number;
activated_at: Date;
completed_at: Date | null;
created_at: Date;
updated_at: Date;
}
export interface StrategyActivationInput {
tenantId: string;
strategyId: string;
config?: Record<string, any>;
}
export class StrategyService {
private static instance: StrategyService;
private constructor() {}
static getInstance(): StrategyService {
if (!StrategyService.instance) {
StrategyService.instance = new StrategyService();
}
return StrategyService.instance;
}
async createStrategy(strategy: Omit<Strategy, 'id' | 'created_at' | 'updated_at'>): Promise<Strategy> {
const newStrategy: Strategy = {
...strategy,
id: uuidv4(),
created_at: new Date(),
updated_at: new Date()
};
await db('cf_strategies').insert({
...newStrategy,
parameters: JSON.stringify(newStrategy.parameters),
default_config: JSON.stringify(newStrategy.default_config),
tags: JSON.stringify(newStrategy.tags)
});
logger.info(`[StrategyService] Created strategy: ${newStrategy.name}`);
return newStrategy;
}
async getStrategyById(strategyId: string): Promise<Strategy | null> {
const strategy = await db('cf_strategies')
.where('id', strategyId)
.first();
if (!strategy) return null;
return this.mapStrategyFromDb(strategy);
}
async getStrategiesByCategory(category: string): Promise<Strategy[]> {
const strategies = await db('cf_strategies')
.where('category', category)
.where('is_active', true)
.orderBy('avg_roi', 'desc');
return strategies.map(this.mapStrategyFromDb);
}
async getAllStrategies(options?: {
category?: string;
isActive?: boolean;
isFeatured?: boolean;
limit?: number;
offset?: number;
}): Promise<{ strategies: Strategy[]; total: number }> {
let query = db('cf_strategies');
if (options?.category) {
query = query.where('category', options.category);
}
if (options?.isActive !== undefined) {
query = query.where('is_active', options.isActive);
}
if (options?.isFeatured !== undefined) {
query = query.where('is_featured', options.isFeatured);
}
const total = await query.clone().count('* as count').first();
if (options?.limit) {
query = query.limit(options.limit);
}
if (options?.offset) {
query = query.offset(options.offset);
}
const strategies = await query.orderBy('avg_roi', 'desc');
return {
strategies: strategies.map(this.mapStrategyFromDb),
total: total?.count || 0
};
}
async getFeaturedStrategies(limit: number = 5): Promise<Strategy[]> {
const strategies = await db('cf_strategies')
.where('is_active', true)
.where('is_featured', true)
.orderBy('usage_count', 'desc')
.limit(limit);
return strategies.map(this.mapStrategyFromDb);
}
async activateStrategy(input: StrategyActivationInput): Promise<MerchantStrategy> {
const { tenantId, strategyId, config } = input;
const strategy = await this.getStrategyById(strategyId);
if (!strategy) {
throw new Error('Strategy not found');
}
const existingActivation = await db('cf_merchant_strategies')
.where('tenant_id', tenantId)
.where('strategy_id', strategyId)
.where('status', 'ACTIVE')
.first();
if (existingActivation) {
throw new Error('Strategy already activated for this tenant');
}
const merchantStrategy: MerchantStrategy = {
id: uuidv4(),
tenant_id: tenantId,
strategy_id: strategyId,
status: 'ACTIVE',
config: config || strategy.default_config,
results: {},
roi_achieved: 0,
revenue_generated: 0,
activated_at: new Date(),
completed_at: null,
created_at: new Date(),
updated_at: new Date()
};
await db('cf_merchant_strategies').insert({
...merchantStrategy,
config: JSON.stringify(merchantStrategy.config),
results: JSON.stringify(merchantStrategy.results)
});
await db('cf_strategies')
.where('id', strategyId)
.increment('usage_count', 1);
logger.info(`[StrategyService] Strategy ${strategy.name} activated for tenant ${tenantId}`);
return merchantStrategy;
}
async pauseStrategy(merchantStrategyId: string, tenantId: string): Promise<void> {
const result = await db('cf_merchant_strategies')
.where('id', merchantStrategyId)
.where('tenant_id', tenantId)
.update({
status: 'PAUSED',
updated_at: new Date()
});
if (result === 0) {
throw new Error('Merchant strategy not found or not owned by tenant');
}
logger.info(`[StrategyService] Strategy ${merchantStrategyId} paused`);
}
async resumeStrategy(merchantStrategyId: string, tenantId: string): Promise<void> {
const result = await db('cf_merchant_strategies')
.where('id', merchantStrategyId)
.where('tenant_id', tenantId)
.where('status', 'PAUSED')
.update({
status: 'ACTIVE',
updated_at: new Date()
});
if (result === 0) {
throw new Error('Paused merchant strategy not found or not owned by tenant');
}
logger.info(`[StrategyService] Strategy ${merchantStrategyId} resumed`);
}
async completeStrategy(
merchantStrategyId: string,
tenantId: string,
results: { roi: number; revenue: number }
): Promise<void> {
const merchantStrategy = await db('cf_merchant_strategies')
.where('id', merchantStrategyId)
.where('tenant_id', tenantId)
.first();
if (!merchantStrategy) {
throw new Error('Merchant strategy not found');
}
await db('cf_merchant_strategies')
.where('id', merchantStrategyId)
.update({
status: 'COMPLETED',
results: JSON.stringify(results),
roi_achieved: results.roi,
revenue_generated: results.revenue,
completed_at: new Date(),
updated_at: new Date()
});
await this.updateStrategyStats(merchantStrategy.strategy_id);
logger.info(`[StrategyService] Strategy ${merchantStrategyId} completed with ROI: ${results.roi}`);
}
async getMerchantStrategies(
tenantId: string,
status?: 'ACTIVE' | 'PAUSED' | 'COMPLETED' | 'FAILED'
): Promise<(MerchantStrategy & { strategy: Strategy })[]> {
let query = db('cf_merchant_strategies as ms')
.join('cf_strategies as s', 'ms.strategy_id', 's.id')
.where('ms.tenant_id', tenantId)
.select('ms.*', 's.*');
if (status) {
query = query.where('ms.status', status);
}
const results = await query.orderBy('ms.created_at', 'desc');
return results.map(row => ({
id: row.id,
tenant_id: row.tenant_id,
strategy_id: row.strategy_id,
status: row.status,
config: typeof row.config === 'string' ? JSON.parse(row.config) : row.config,
results: typeof row.results === 'string' ? JSON.parse(row.results) : row.results,
roi_achieved: parseFloat(row.roi_achieved) || 0,
revenue_generated: parseFloat(row.revenue_generated) || 0,
activated_at: row.activated_at,
completed_at: row.completed_at,
created_at: row.created_at,
updated_at: row.updated_at,
strategy: this.mapStrategyFromDb({
id: row.strategy_id,
name: row.name,
description: row.description,
category: row.category,
risk_level: row.risk_level,
price: row.price,
billing_type: row.billing_type,
parameters: row.parameters,
default_config: row.default_config,
avg_roi: row.avg_roi,
usage_count: row.usage_count,
success_rate: row.success_rate,
tags: row.tags,
is_active: row.is_active,
is_featured: row.is_featured,
created_by: row.created_by,
created_at: row.s_created_at || row.created_at,
updated_at: row.s_updated_at || row.updated_at
})
}));
}
async updateStrategyStats(strategyId: string): Promise<void> {
const stats = await db('cf_merchant_strategies')
.where('strategy_id', strategyId)
.where('status', 'COMPLETED')
.select('roi_achieved');
const completedCount = stats.length;
const successCount = stats.filter(s => s.roi_achieved > 0).length;
const avgRoi = completedCount > 0
? stats.reduce((sum, s) => sum + parseFloat(s.roi_achieved as any) || 0, 0) / completedCount
: 0;
const successRate = completedCount > 0 ? Math.round((successCount / completedCount) * 100) : 0;
await db('cf_strategies')
.where('id', strategyId)
.update({
avg_roi: avgRoi,
success_rate: successRate,
updated_at: new Date()
});
logger.info(`[StrategyService] Updated stats for strategy ${strategyId}: avg_roi=${avgRoi}, success_rate=${successRate}%`);
}
async searchStrategies(query: string): Promise<Strategy[]> {
const strategies = await db('cf_strategies')
.where('is_active', true)
.where(function() {
this.where('name', 'like', `%${query}%`)
.orWhere('description', 'like', `%${query}%`);
})
.orderBy('avg_roi', 'desc')
.limit(20);
return strategies.map(this.mapStrategyFromDb);
}
async getStrategiesByTags(tags: string[]): Promise<Strategy[]> {
const strategies = await db('cf_strategies')
.where('is_active', true);
return strategies
.map(this.mapStrategyFromDb)
.filter(s => s.tags.some(t => tags.includes(t)));
}
private mapStrategyFromDb(row: any): Strategy {
return {
id: row.id,
name: row.name,
description: row.description,
category: row.category,
risk_level: row.risk_level,
price: parseFloat(row.price) || 0,
billing_type: row.billing_type,
parameters: typeof row.parameters === 'string' ? JSON.parse(row.parameters) : row.parameters,
default_config: typeof row.default_config === 'string' ? JSON.parse(row.default_config) : row.default_config,
avg_roi: parseFloat(row.avg_roi) || 0,
usage_count: row.usage_count || 0,
success_rate: row.success_rate || 0,
tags: typeof row.tags === 'string' ? JSON.parse(row.tags) : row.tags,
is_active: row.is_active,
is_featured: row.is_featured,
created_by: row.created_by,
created_at: row.created_at,
updated_at: row.updated_at
};
}
}
export default StrategyService.getInstance();

View File

@@ -0,0 +1,126 @@
import { v4 as uuidv4 } from 'uuid';
import db from '../config/database';
interface UsageRecord {
id: string;
merchantId: string;
feature: string;
usage: number;
source: string;
createdAt: Date;
}
interface TrackOptions {
merchantId: string;
feature: string;
usage: number;
source: string;
}
export class UsageService {
/**
* 记录使用量
*/
async track(options: TrackOptions): Promise<UsageRecord> {
const { merchantId, feature, usage, source } = options;
const record: UsageRecord = {
id: uuidv4(),
merchantId,
feature,
usage,
source,
createdAt: new Date()
};
try {
await db('usage_logs').insert({
id: record.id,
merchant_id: record.merchantId,
feature: record.feature,
usage: record.usage,
source: record.source,
created_at: record.createdAt
});
console.log(`[UsageService] Tracked usage: ${feature} x ${usage} for merchant ${merchantId}`);
return record;
} catch (error) {
console.error('[UsageService] Error tracking usage:', error);
throw error;
}
}
/**
* 获取商户的使用量记录
*/
async getUsageByMerchant(merchantId: string, feature?: string): Promise<UsageRecord[]> {
try {
let query = db('usage_logs').where({ merchant_id: merchantId });
if (feature) {
query = query.where({ feature });
}
const records = await query.orderBy('created_at', 'desc');
return records.map((record: any) => ({
id: record.id,
merchantId: record.merchant_id,
feature: record.feature,
usage: record.usage,
source: record.source,
createdAt: record.created_at
}));
} catch (error) {
console.error('[UsageService] Error getting usage:', error);
throw error;
}
}
/**
* 获取指定时间范围内的使用量
*/
async getUsageByTimeRange(merchantId: string, startDate: Date, endDate: Date): Promise<UsageRecord[]> {
try {
const records = await db('usage_logs')
.where({ merchant_id: merchantId })
.where('created_at', '>=', startDate)
.where('created_at', '<=', endDate)
.orderBy('created_at', 'desc');
return records.map((record: any) => ({
id: record.id,
merchantId: record.merchant_id,
feature: record.feature,
usage: record.usage,
source: record.source,
createdAt: record.created_at
}));
} catch (error) {
console.error('[UsageService] Error getting usage by time range:', error);
throw error;
}
}
/**
* 计算商户的总使用量
*/
async calculateTotalUsage(merchantId: string, feature?: string): Promise<number> {
try {
let query = db('usage_logs').where({ merchant_id: merchantId });
if (feature) {
query = query.where({ feature });
}
const result = await query.sum('usage as total');
return result[0].total || 0;
} catch (error) {
console.error('[UsageService] Error calculating total usage:', error);
throw error;
}
}
}
export default new UsageService();