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:
516
server/src/services/AIDecisionLogService.ts
Normal file
516
server/src/services/AIDecisionLogService.ts
Normal 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[]>);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
653
server/src/services/AutoListingService.ts
Normal file
653
server/src/services/AutoListingService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
316
server/src/services/LeaderboardService.ts
Normal file
316
server/src/services/LeaderboardService.ts
Normal 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();
|
||||
371
server/src/services/MerchantMetricsService.ts
Normal file
371
server/src/services/MerchantMetricsService.ts
Normal 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();
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化订单相关表
|
||||
*/
|
||||
|
||||
585
server/src/services/ProductSelectionService.ts
Normal file
585
server/src/services/ProductSelectionService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
359
server/src/services/StrategyRecommendationService.ts
Normal file
359
server/src/services/StrategyRecommendationService.ts
Normal 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();
|
||||
374
server/src/services/StrategyService.ts
Normal file
374
server/src/services/StrategyService.ts
Normal 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();
|
||||
126
server/src/services/UsageService.ts
Normal file
126
server/src/services/UsageService.ts
Normal 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();
|
||||
Reference in New Issue
Block a user