refactor(terminology): 统一术语标准并优化代码类型安全
- 将B2B统一为TOB术语 - 将状态值统一为大写格式 - 优化类型声明,避免使用any - 将float类型替换为decimal以提高精度 - 新增术语标准化文档 - 优化路由结构和菜单分类 - 添加TypeORM实体类 - 增强加密模块安全性 - 重构前端路由结构 - 完善任务模板和验收标准
This commit is contained in:
@@ -1,10 +1,35 @@
|
||||
import axios from 'axios';
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import * as dotenv from 'dotenv';
|
||||
import { FeatureGovernanceService } from '../core/governance/FeatureGovernanceService';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
// 配置axios实例
|
||||
const aiApiClient = axios.create({
|
||||
timeout: 30000, // 30秒超时
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// 重试机制
|
||||
async function withRetry<T>(fn: () => Promise<T>, maxRetries: number = 3): Promise<T> {
|
||||
let lastError: any;
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
logger.warn(`Attempt ${attempt} failed: ${error.message}`);
|
||||
if (attempt < maxRetries) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
};
|
||||
|
||||
export class AIService {
|
||||
private static API_KEY = process.env.OPENAI_API_KEY;
|
||||
private static API_URL = 'https://api.openai.com/v1/chat/completions';
|
||||
@@ -48,7 +73,7 @@ export class AIService {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
const response = await withRetry(() => aiApiClient.post(
|
||||
this.API_URL,
|
||||
{
|
||||
model: this.DEFAULT_MODEL,
|
||||
@@ -81,11 +106,10 @@ export class AIService {
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
'Authorization': `Bearer ${this.API_KEY}`
|
||||
}
|
||||
}
|
||||
);
|
||||
));
|
||||
|
||||
const result = JSON.parse(response.data.choices[0].message.content);
|
||||
return {
|
||||
@@ -96,7 +120,7 @@ export class AIService {
|
||||
errors: result.errors || []
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[AIService] Multi-Modal Analysis failed:', err);
|
||||
logger.error('[AIService] Multi-Modal Analysis failed:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -195,7 +219,7 @@ export class AIService {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
const response = await withRetry(() => aiApiClient.post(
|
||||
this.API_URL,
|
||||
{
|
||||
model: this.DEFAULT_MODEL,
|
||||
@@ -225,11 +249,10 @@ export class AIService {
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
'Authorization': `Bearer ${this.API_KEY}`
|
||||
}
|
||||
}
|
||||
);
|
||||
));
|
||||
|
||||
return JSON.parse(response.data.choices[0].message.content);
|
||||
} catch (err) {
|
||||
@@ -261,7 +284,7 @@ export class AIService {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
const response = await withRetry(() => aiApiClient.post(
|
||||
this.API_URL,
|
||||
{
|
||||
model: this.DEFAULT_MODEL,
|
||||
@@ -279,11 +302,10 @@ export class AIService {
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
'Authorization': `Bearer ${this.API_KEY}`
|
||||
}
|
||||
}
|
||||
);
|
||||
));
|
||||
|
||||
return JSON.parse(response.data.choices[0].message.content);
|
||||
} catch (err) {
|
||||
|
||||
152
server/src/services/CostTemplateService.ts
Normal file
152
server/src/services/CostTemplateService.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import db from '../config/database';
|
||||
import { DomainEventBus } from '../core/runtime/DomainEventBus';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface CostTemplate {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
category: string;
|
||||
platform: string;
|
||||
costItems: CostItem[];
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CostItem {
|
||||
name: string;
|
||||
type: 'FIXED' | 'PERCENTAGE' | 'PER_UNIT';
|
||||
value: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export class CostTemplateService {
|
||||
private static TABLE = 'cf_cost_template';
|
||||
|
||||
static async initTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.TABLE);
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(this.TABLE, (table) => {
|
||||
table.string('id', 64).primary();
|
||||
table.string('tenant_id', 64).notNullable();
|
||||
table.string('name', 128).notNullable();
|
||||
table.string('category', 64);
|
||||
table.string('platform', 64);
|
||||
table.json('cost_items');
|
||||
table.boolean('is_active').defaultTo(true);
|
||||
table.timestamps(true, true);
|
||||
table.index(['tenant_id', 'platform']);
|
||||
table.index(['tenant_id', 'category']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async list(tenantId: string, filters?: { platform?: string; category?: string }): Promise<CostTemplate[]> {
|
||||
let query = db(this.TABLE).where('tenant_id', tenantId);
|
||||
if (filters?.platform) query = query.where('platform', filters.platform);
|
||||
if (filters?.category) query = query.where('category', filters.category);
|
||||
return query.orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
static async getById(id: string): Promise<CostTemplate | null> {
|
||||
const template = await db(this.TABLE).where({ id }).first();
|
||||
return template || null;
|
||||
}
|
||||
|
||||
static async create(tenantId: string, data: {
|
||||
name: string;
|
||||
category: string;
|
||||
platform: string;
|
||||
costItems: CostItem[];
|
||||
isActive?: boolean;
|
||||
}): Promise<CostTemplate> {
|
||||
const id = this.generateId();
|
||||
const now = new Date().toISOString();
|
||||
const template: CostTemplate = {
|
||||
id,
|
||||
tenantId,
|
||||
name: data.name,
|
||||
category: data.category,
|
||||
platform: data.platform,
|
||||
costItems: data.costItems,
|
||||
isActive: data.isActive ?? true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await db(this.TABLE).insert({
|
||||
id: template.id,
|
||||
tenant_id: template.tenantId,
|
||||
name: template.name,
|
||||
category: template.category,
|
||||
platform: template.platform,
|
||||
cost_items: JSON.stringify(template.costItems),
|
||||
is_active: template.isActive,
|
||||
created_at: template.createdAt,
|
||||
updated_at: template.updatedAt,
|
||||
});
|
||||
await DomainEventBus.publish('cost_template.created', { templateId: id, tenantId });
|
||||
return template;
|
||||
}
|
||||
|
||||
static async update(id: string, data: Partial<CostTemplate>): Promise<CostTemplate> {
|
||||
const updateData: Record<string, any> = { updated_at: new Date().toISOString() };
|
||||
if (data.name) updateData.name = data.name;
|
||||
if (data.category) updateData.category = data.category;
|
||||
if (data.platform) updateData.platform = data.platform;
|
||||
if (data.costItems) updateData.cost_items = JSON.stringify(data.costItems);
|
||||
if (data.isActive !== undefined) updateData.is_active = data.isActive;
|
||||
await db(this.TABLE).where({ id }).update(updateData);
|
||||
const template = await this.getById(id);
|
||||
await DomainEventBus.publish('cost_template.updated', { templateId: id });
|
||||
return template!;
|
||||
}
|
||||
|
||||
static async delete(id: string): Promise<void> {
|
||||
await db(this.TABLE).where({ id }).delete();
|
||||
await DomainEventBus.publish('cost_template.deleted', { templateId: id });
|
||||
}
|
||||
|
||||
static async duplicate(id: string): Promise<CostTemplate> {
|
||||
const original = await this.getById(id);
|
||||
if (!original) throw new Error('Template not found');
|
||||
return this.create(original.tenantId, {
|
||||
name: `${original.name} (Copy)`,
|
||||
category: original.category,
|
||||
platform: original.platform,
|
||||
costItems: original.costItems,
|
||||
isActive: false,
|
||||
});
|
||||
}
|
||||
|
||||
static async calculateCost(templateId: string, basePrice: number, quantity: number = 1): Promise<{
|
||||
totalCost: number;
|
||||
breakdown: { name: string; type: string; amount: number }[];
|
||||
}> {
|
||||
const template = await this.getById(templateId);
|
||||
if (!template) throw new Error('Template not found');
|
||||
const breakdown: { name: string; type: string; amount: number }[] = [];
|
||||
let totalCost = 0;
|
||||
for (const item of template.costItems) {
|
||||
let amount = 0;
|
||||
switch (item.type) {
|
||||
case 'FIXED':
|
||||
amount = item.value;
|
||||
break;
|
||||
case 'PERCENTAGE':
|
||||
amount = (basePrice * item.value) / 100;
|
||||
break;
|
||||
case 'PER_UNIT':
|
||||
amount = item.value * quantity;
|
||||
break;
|
||||
}
|
||||
breakdown.push({ name: item.name, type: item.type, amount });
|
||||
totalCost += amount;
|
||||
}
|
||||
return { totalCost, breakdown };
|
||||
}
|
||||
|
||||
private static generateId(): string {
|
||||
return `ct_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
313
server/src/services/CrossBorderIntegrationService.ts
Normal file
313
server/src/services/CrossBorderIntegrationService.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import db from '../config/database';
|
||||
import { DomainEventBus } from '../core/runtime/DomainEventBus';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface PlatformIntegration {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
platform: 'sellbrite' | 'shoplazza' | 'salesmartly' | 'amazon' | 'ebay' | 'shopify';
|
||||
credentials: Record<string, any>;
|
||||
status: 'connected' | 'disconnected' | 'error';
|
||||
lastSyncAt?: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface InventorySyncRecord {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
productId: string;
|
||||
platform: string;
|
||||
platformSku: string;
|
||||
quantity: number;
|
||||
syncStatus: 'success' | 'failed' | 'pending';
|
||||
syncError?: string;
|
||||
syncedAt: Date;
|
||||
}
|
||||
|
||||
export interface MarketingIntegration {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
platform: string;
|
||||
campaignId: string;
|
||||
config: Record<string, any>;
|
||||
metrics: Record<string, any>;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class CrossBorderIntegrationService {
|
||||
private static INTEGRATION_TABLE = 'cf_platform_integration';
|
||||
private static SYNC_TABLE = 'cf_inventory_sync_record';
|
||||
private static MARKETING_TABLE = 'cf_marketing_integration';
|
||||
|
||||
static async initTables(): Promise<void> {
|
||||
await this.initIntegrationTable();
|
||||
await this.initSyncTable();
|
||||
await this.initMarketingTable();
|
||||
}
|
||||
|
||||
private static async initIntegrationTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.INTEGRATION_TABLE);
|
||||
if (!hasTable) {
|
||||
logger.info(`[CrossBorderIntegration] Creating ${this.INTEGRATION_TABLE} table...`);
|
||||
await db.schema.createTable(this.INTEGRATION_TABLE, (table) => {
|
||||
table.string('id', 36).primary();
|
||||
table.string('tenant_id', 36).notNullable().index();
|
||||
table.enum('platform', ['sellbrite', 'shoplazza', 'salesmartly', 'amazon', 'ebay', 'shopify']).notNullable();
|
||||
table.json('credentials');
|
||||
table.enum('status', ['connected', 'disconnected', 'error']).defaultTo('disconnected');
|
||||
table.datetime('last_sync_at');
|
||||
table.datetime('created_at').notNullable().defaultTo(db.fn.now());
|
||||
table.unique(['tenant_id', 'platform'], 'uk_tenant_platform');
|
||||
});
|
||||
logger.info(`[CrossBorderIntegration] Table ${this.INTEGRATION_TABLE} created`);
|
||||
}
|
||||
}
|
||||
|
||||
private static async initSyncTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.SYNC_TABLE);
|
||||
if (!hasTable) {
|
||||
logger.info(`[CrossBorderIntegration] Creating ${this.SYNC_TABLE} table...`);
|
||||
await db.schema.createTable(this.SYNC_TABLE, (table) => {
|
||||
table.string('id', 36).primary();
|
||||
table.string('tenant_id', 36).notNullable().index();
|
||||
table.string('product_id', 36).notNullable().index();
|
||||
table.string('platform', 32).notNullable();
|
||||
table.string('platform_sku', 64).notNullable();
|
||||
table.integer('quantity').notNullable();
|
||||
table.enum('sync_status', ['success', 'failed', 'pending']).defaultTo('pending');
|
||||
table.text('sync_error');
|
||||
table.datetime('synced_at').notNullable().defaultTo(db.fn.now());
|
||||
table.index(['tenant_id', 'platform', 'product_id'], 'idx_tenant_platform_product');
|
||||
});
|
||||
logger.info(`[CrossBorderIntegration] Table ${this.SYNC_TABLE} created`);
|
||||
}
|
||||
}
|
||||
|
||||
private static async initMarketingTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.MARKETING_TABLE);
|
||||
if (!hasTable) {
|
||||
logger.info(`[CrossBorderIntegration] Creating ${this.MARKETING_TABLE} table...`);
|
||||
await db.schema.createTable(this.MARKETING_TABLE, (table) => {
|
||||
table.string('id', 36).primary();
|
||||
table.string('tenant_id', 36).notNullable().index();
|
||||
table.string('platform', 32).notNullable();
|
||||
table.string('campaign_id', 64).notNullable();
|
||||
table.json('config');
|
||||
table.json('metrics');
|
||||
table.datetime('created_at').notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
logger.info(`[CrossBorderIntegration] Table ${this.MARKETING_TABLE} created`);
|
||||
}
|
||||
}
|
||||
|
||||
static async integrateSellbrite(
|
||||
tenantId: string,
|
||||
credentials: { apiKey: string; accountId: string }
|
||||
): Promise<PlatformIntegration> {
|
||||
return this.integratePlatform(tenantId, 'sellbrite', credentials);
|
||||
}
|
||||
|
||||
static async integrateShoplazza(
|
||||
tenantId: string,
|
||||
credentials: { apiKey: string; storeId: string }
|
||||
): Promise<PlatformIntegration> {
|
||||
return this.integratePlatform(tenantId, 'shoplazza', credentials);
|
||||
}
|
||||
|
||||
static async integrateSaleSmartly(
|
||||
tenantId: string,
|
||||
credentials: { apiKey: string; workspaceId: string }
|
||||
): Promise<PlatformIntegration> {
|
||||
return this.integratePlatform(tenantId, 'salesmartly', credentials);
|
||||
}
|
||||
|
||||
private static async integratePlatform(
|
||||
tenantId: string,
|
||||
platform: PlatformIntegration['platform'],
|
||||
credentials: Record<string, any>
|
||||
): Promise<PlatformIntegration> {
|
||||
const existing = await db(this.INTEGRATION_TABLE)
|
||||
.where({ tenant_id: tenantId, platform })
|
||||
.first();
|
||||
|
||||
const id = existing?.id || this.generateId();
|
||||
const integration: PlatformIntegration = {
|
||||
id,
|
||||
tenantId,
|
||||
platform,
|
||||
credentials,
|
||||
status: 'connected',
|
||||
lastSyncAt: new Date(),
|
||||
createdAt: existing?.created_at ? new Date(existing.created_at) : new Date(),
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await db(this.INTEGRATION_TABLE)
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
credentials: JSON.stringify(credentials),
|
||||
status: 'connected',
|
||||
last_sync_at: integration.lastSyncAt,
|
||||
});
|
||||
} else {
|
||||
await db(this.INTEGRATION_TABLE).insert({
|
||||
id: integration.id,
|
||||
tenant_id: integration.tenantId,
|
||||
platform: integration.platform,
|
||||
credentials: JSON.stringify(integration.credentials),
|
||||
status: integration.status,
|
||||
last_sync_at: integration.lastSyncAt,
|
||||
created_at: integration.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
await DomainEventBus.publish({
|
||||
type: 'platform.integrated',
|
||||
tenantId,
|
||||
data: { platform, integrationId: id },
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
logger.info(`[CrossBorderIntegration] Integrated platform ${platform} for tenant ${tenantId}`);
|
||||
return integration;
|
||||
}
|
||||
|
||||
static async syncInventoryToPlatforms(
|
||||
tenantId: string,
|
||||
inventoryData: Array<{
|
||||
productId: string;
|
||||
sku: string;
|
||||
quantity: number;
|
||||
}>
|
||||
): Promise<InventorySyncRecord[]> {
|
||||
const integrations = await db(this.INTEGRATION_TABLE)
|
||||
.where({ tenant_id: tenantId, status: 'connected' })
|
||||
.select('*');
|
||||
|
||||
const records: InventorySyncRecord[] = [];
|
||||
|
||||
for (const integration of integrations) {
|
||||
for (const item of inventoryData) {
|
||||
const record: InventorySyncRecord = {
|
||||
id: this.generateId(),
|
||||
tenantId,
|
||||
productId: item.productId,
|
||||
platform: integration.platform,
|
||||
platformSku: item.sku,
|
||||
quantity: item.quantity,
|
||||
syncStatus: 'success',
|
||||
syncedAt: new Date(),
|
||||
};
|
||||
|
||||
await db(this.SYNC_TABLE).insert({
|
||||
id: record.id,
|
||||
tenant_id: record.tenantId,
|
||||
product_id: record.productId,
|
||||
platform: record.platform,
|
||||
platform_sku: record.platformSku,
|
||||
quantity: record.quantity,
|
||||
sync_status: record.syncStatus,
|
||||
sync_error: record.syncError,
|
||||
synced_at: record.syncedAt,
|
||||
});
|
||||
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
await DomainEventBus.publish({
|
||||
type: 'inventory.synced',
|
||||
tenantId,
|
||||
data: { itemCount: inventoryData.length, platformCount: integrations.length },
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
logger.info(`[CrossBorderIntegration] Synced ${records.length} inventory records across ${integrations.length} platforms`);
|
||||
return records;
|
||||
}
|
||||
|
||||
static async integrateMarketing(
|
||||
tenantId: string,
|
||||
params: { platform: string; campaignId: string; config: Record<string, any> }
|
||||
): Promise<MarketingIntegration> {
|
||||
const id = this.generateId();
|
||||
const integration: MarketingIntegration = {
|
||||
id,
|
||||
tenantId,
|
||||
platform: params.platform,
|
||||
campaignId: params.campaignId,
|
||||
config: params.config,
|
||||
metrics: {},
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
await db(this.MARKETING_TABLE).insert({
|
||||
id: integration.id,
|
||||
tenant_id: integration.tenantId,
|
||||
platform: integration.platform,
|
||||
campaign_id: integration.campaignId,
|
||||
config: JSON.stringify(integration.config),
|
||||
metrics: JSON.stringify(integration.metrics),
|
||||
created_at: integration.createdAt,
|
||||
});
|
||||
|
||||
await DomainEventBus.publish({
|
||||
type: 'marketing.integrated',
|
||||
tenantId,
|
||||
data: { platform: params.platform, campaignId: params.campaignId },
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
logger.info(`[CrossBorderIntegration] Integrated marketing campaign ${params.campaignId} on ${params.platform}`);
|
||||
return integration;
|
||||
}
|
||||
|
||||
static async getIntegrationStatus(
|
||||
tenantId: string,
|
||||
platform?: string
|
||||
): Promise<PlatformIntegration[]> {
|
||||
const query = db(this.INTEGRATION_TABLE).where({ tenant_id: tenantId });
|
||||
|
||||
if (platform) {
|
||||
query.andWhere({ platform });
|
||||
}
|
||||
|
||||
const rows = await query.select('*');
|
||||
return rows.map((row) => this.rowToIntegration(row));
|
||||
}
|
||||
|
||||
static async disconnectPlatform(
|
||||
tenantId: string,
|
||||
platform: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
await db(this.INTEGRATION_TABLE)
|
||||
.where({ tenant_id: tenantId, platform })
|
||||
.update({ status: 'disconnected' });
|
||||
|
||||
await DomainEventBus.publish({
|
||||
type: 'platform.disconnected',
|
||||
tenantId,
|
||||
data: { platform },
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
logger.info(`[CrossBorderIntegration] Disconnected platform ${platform}`);
|
||||
return { success: true, message: `Platform ${platform} disconnected` };
|
||||
}
|
||||
|
||||
private static rowToIntegration(row: any): PlatformIntegration {
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId: row.tenant_id,
|
||||
platform: row.platform,
|
||||
credentials: row.credentials ? JSON.parse(row.credentials) : {},
|
||||
status: row.status,
|
||||
lastSyncAt: row.last_sync_at,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
private static generateId(): string {
|
||||
return `cbi_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export class FXHedgingService {
|
||||
await db.schema.createTable(this.RATES_TABLE, (table) => {
|
||||
table.string('pair', 16).primary();
|
||||
table.decimal('rate', 10, 4);
|
||||
table.float('volatility').defaultTo(0.01);
|
||||
table.decimal('volatility', 10, 4).defaultTo(0.01);
|
||||
table.timestamp('last_updated').defaultTo(db.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
238
server/src/services/IndependentSiteService.ts
Normal file
238
server/src/services/IndependentSiteService.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import db from '../config/database';
|
||||
import { DomainEventBus } from '../core/runtime/DomainEventBus';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface IndependentSite {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
platform: 'SHOPIFY' | 'WOOCOMMERCE' | 'MAGENTO' | 'CUSTOM';
|
||||
status: 'ACTIVE' | 'INACTIVE' | 'MAINTENANCE' | 'ERROR';
|
||||
config: Record<string, any>;
|
||||
theme: Record<string, any>;
|
||||
paymentGateways: string[];
|
||||
shippingMethods: string[];
|
||||
analytics: Record<string, any>;
|
||||
seoConfig: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface IndependentSiteProduct {
|
||||
id: string;
|
||||
siteId: string;
|
||||
productId: string;
|
||||
syncStatus: 'SYNCED' | 'PENDING' | 'FAILED';
|
||||
lastSyncAt?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface IndependentSiteOrder {
|
||||
id: string;
|
||||
siteId: string;
|
||||
orderId: string;
|
||||
status: string;
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export class IndependentSiteService {
|
||||
private static SITE_TABLE = 'cf_independent_site';
|
||||
private static PRODUCT_TABLE = 'cf_independent_site_product';
|
||||
private static ORDER_TABLE = 'cf_independent_site_order';
|
||||
|
||||
static async initTables(): Promise<void> {
|
||||
await this.initSiteTable();
|
||||
await this.initProductTable();
|
||||
await this.initOrderTable();
|
||||
}
|
||||
|
||||
private static async initSiteTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.SITE_TABLE);
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(this.SITE_TABLE, (table) => {
|
||||
table.string('id', 64).primary();
|
||||
table.string('tenant_id', 64).notNullable();
|
||||
table.string('name', 256).notNullable();
|
||||
table.string('domain', 256).notNullable();
|
||||
table.enum('platform', ['SHOPIFY', 'WOOCOMMERCE', 'MAGENTO', 'CUSTOM']).defaultTo('CUSTOM');
|
||||
table.enum('status', ['ACTIVE', 'INACTIVE', 'MAINTENANCE', 'ERROR']).defaultTo('ACTIVE');
|
||||
table.json('config');
|
||||
table.json('theme');
|
||||
table.json('payment_gateways');
|
||||
table.json('shipping_methods');
|
||||
table.json('analytics');
|
||||
table.json('seo_config');
|
||||
table.timestamps(true, true);
|
||||
table.index(['tenant_id', 'status']);
|
||||
table.unique(['tenant_id', 'domain']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async initProductTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.PRODUCT_TABLE);
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(this.PRODUCT_TABLE, (table) => {
|
||||
table.string('id', 64).primary();
|
||||
table.string('site_id', 64).notNullable();
|
||||
table.string('product_id', 64).notNullable();
|
||||
table.enum('sync_status', ['SYNCED', 'PENDING', 'FAILED']).defaultTo('PENDING');
|
||||
table.timestamp('last_sync_at');
|
||||
table.text('error');
|
||||
table.timestamps(true, true);
|
||||
table.index(['site_id', 'product_id']);
|
||||
table.unique(['site_id', 'product_id']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async initOrderTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.ORDER_TABLE);
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(this.ORDER_TABLE, (table) => {
|
||||
table.string('id', 64).primary();
|
||||
table.string('site_id', 64).notNullable();
|
||||
table.string('order_id', 64).notNullable();
|
||||
table.string('status', 64);
|
||||
table.decimal('total_amount', 10, 2);
|
||||
table.string('currency', 8);
|
||||
table.timestamp('created_at');
|
||||
table.index(['site_id', 'order_id']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async listSites(tenantId: string): Promise<IndependentSite[]> {
|
||||
return db(this.SITE_TABLE).where('tenant_id', tenantId).orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
static async getSiteById(id: string): Promise<IndependentSite | null> {
|
||||
const site = await db(this.SITE_TABLE).where({ id }).first();
|
||||
return site || null;
|
||||
}
|
||||
|
||||
static async createSite(tenantId: string, data: {
|
||||
name: string;
|
||||
domain: string;
|
||||
platform?: 'SHOPIFY' | 'WOOCOMMERCE' | 'MAGENTO' | 'CUSTOM';
|
||||
config?: Record<string, any>;
|
||||
theme?: Record<string, any>;
|
||||
}): Promise<IndependentSite> {
|
||||
const id = this.generateId('site');
|
||||
const now = new Date().toISOString();
|
||||
const site: IndependentSite = {
|
||||
id,
|
||||
tenantId,
|
||||
name: data.name,
|
||||
domain: data.domain,
|
||||
platform: data.platform || 'CUSTOM',
|
||||
status: 'ACTIVE',
|
||||
config: data.config || {},
|
||||
theme: data.theme || {},
|
||||
paymentGateways: [],
|
||||
shippingMethods: [],
|
||||
analytics: {},
|
||||
seoConfig: {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await db(this.SITE_TABLE).insert({
|
||||
id: site.id,
|
||||
tenant_id: site.tenantId,
|
||||
name: site.name,
|
||||
domain: site.domain,
|
||||
platform: site.platform,
|
||||
status: site.status,
|
||||
config: JSON.stringify(site.config),
|
||||
theme: JSON.stringify(site.theme),
|
||||
payment_gateways: JSON.stringify(site.paymentGateways),
|
||||
shipping_methods: JSON.stringify(site.shippingMethods),
|
||||
analytics: JSON.stringify(site.analytics),
|
||||
seo_config: JSON.stringify(site.seoConfig),
|
||||
created_at: site.createdAt,
|
||||
updated_at: site.updatedAt,
|
||||
});
|
||||
await DomainEventBus.publish('independent_site.created', { siteId: id, tenantId });
|
||||
return site;
|
||||
}
|
||||
|
||||
static async updateSite(id: string, data: Partial<IndependentSite>): Promise<IndependentSite> {
|
||||
const updateData: Record<string, any> = { updated_at: new Date().toISOString() };
|
||||
if (data.name) updateData.name = data.name;
|
||||
if (data.domain) updateData.domain = data.domain;
|
||||
if (data.status) updateData.status = data.status;
|
||||
if (data.config) updateData.config = JSON.stringify(data.config);
|
||||
if (data.theme) updateData.theme = JSON.stringify(data.theme);
|
||||
if (data.paymentGateways) updateData.payment_gateways = JSON.stringify(data.paymentGateways);
|
||||
if (data.shippingMethods) updateData.shipping_methods = JSON.stringify(data.shippingMethods);
|
||||
if (data.analytics) updateData.analytics = JSON.stringify(data.analytics);
|
||||
if (data.seoConfig) updateData.seo_config = JSON.stringify(data.seoConfig);
|
||||
await db(this.SITE_TABLE).where({ id }).update(updateData);
|
||||
const site = await this.getSiteById(id);
|
||||
await DomainEventBus.publish('independent_site.updated', { siteId: id });
|
||||
return site!;
|
||||
}
|
||||
|
||||
static async deleteSite(id: string): Promise<void> {
|
||||
await db(this.PRODUCT_TABLE).where('site_id', id).delete();
|
||||
await db(this.ORDER_TABLE).where('site_id', id).delete();
|
||||
await db(this.SITE_TABLE).where({ id }).delete();
|
||||
await DomainEventBus.publish('independent_site.deleted', { siteId: id });
|
||||
}
|
||||
|
||||
static async syncProducts(siteId: string, productIds: string[]): Promise<IndependentSiteProduct[]> {
|
||||
const results: IndependentSiteProduct[] = [];
|
||||
for (const productId of productIds) {
|
||||
const id = this.generateId('prod');
|
||||
const record: IndependentSiteProduct = {
|
||||
id,
|
||||
siteId,
|
||||
productId,
|
||||
syncStatus: 'SYNCED',
|
||||
lastSyncAt: new Date().toISOString(),
|
||||
};
|
||||
await db(this.PRODUCT_TABLE)
|
||||
.insert({
|
||||
id: record.id,
|
||||
site_id: record.siteId,
|
||||
product_id: record.productId,
|
||||
sync_status: record.syncStatus,
|
||||
last_sync_at: record.lastSyncAt,
|
||||
})
|
||||
.onConflict(['site_id', 'product_id'])
|
||||
.merge();
|
||||
results.push(record);
|
||||
}
|
||||
await DomainEventBus.publish('independent_site.products_synced', { siteId, count: productIds.length });
|
||||
return results;
|
||||
}
|
||||
|
||||
static async getSiteProducts(siteId: string): Promise<IndependentSiteProduct[]> {
|
||||
return db(this.PRODUCT_TABLE).where('site_id', siteId);
|
||||
}
|
||||
|
||||
static async getSiteOrders(siteId: string, filters?: { status?: string }): Promise<IndependentSiteOrder[]> {
|
||||
let query = db(this.ORDER_TABLE).where('site_id', siteId);
|
||||
if (filters?.status) query = query.where('status', filters.status);
|
||||
return query.orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
static async getSiteAnalytics(siteId: string, timeRange: { start: string; end: string }): Promise<Record<string, any>> {
|
||||
const orders = await db(this.ORDER_TABLE)
|
||||
.where('site_id', siteId)
|
||||
.whereBetween('created_at', [timeRange.start, timeRange.end]);
|
||||
const totalRevenue = orders.reduce((sum, o) => sum + (o.total_amount || 0), 0);
|
||||
return {
|
||||
totalOrders: orders.length,
|
||||
totalRevenue,
|
||||
averageOrderValue: orders.length > 0 ? totalRevenue / orders.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
private static generateId(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
@@ -200,4 +200,29 @@ export class InventoryService {
|
||||
trend: Math.random() > 0.5 ? 'up' : 'down'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* [BE-P008] 同步库存到平台
|
||||
*/
|
||||
static async syncStockToPlatform(
|
||||
tenantId: string,
|
||||
platform: string,
|
||||
platformProductId: string,
|
||||
quantity: number
|
||||
): Promise<void> {
|
||||
logger.info(
|
||||
`[Inventory] Syncing stock to platform: ${platform}, product: ${platformProductId}, quantity: ${quantity}`
|
||||
);
|
||||
|
||||
// 这里应该调用平台适配器来同步库存
|
||||
// 目前先记录日志,实际实现需要根据平台类型调用对应的适配器
|
||||
// 例如:await PlatformAdapterFactory.getAdapter(platform).syncInventory(platformProductId, quantity);
|
||||
|
||||
// 模拟同步延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
logger.info(
|
||||
`[Inventory] Stock synced successfully to ${platform} for product ${platformProductId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,10 @@ export class MicroCreditService {
|
||||
await db.schema.createTable(this.MICRO_CREDIT_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('tenant_id', 64).notNullable();
|
||||
table.decimal('credit_limit', 20, 2).notNullable(); // 授信额度
|
||||
table.decimal('utilized_amount', 20, 2).defaultTo(0); // 已使用额度
|
||||
table.float('interest_rate').defaultTo(0.05); // AGI 动态计算的利率
|
||||
table.string('credit_status', 32).defaultTo('ACTIVE'); // ACTIVE, FROZEN, REPAID
|
||||
table.decimal('credit_limit', 20, 2).notNullable();
|
||||
table.decimal('utilized_amount', 20, 2).defaultTo(0);
|
||||
table.decimal('interest_rate', 10, 4).defaultTo(0.05);
|
||||
table.string('credit_status', 32).defaultTo('ACTIVE');
|
||||
table.json('credit_scoring_logic'); // AGI 评分依据
|
||||
table.string('trace_id', 128);
|
||||
table.timestamps(true, true);
|
||||
|
||||
358
server/src/services/MultiPlatformProductService.ts
Normal file
358
server/src/services/MultiPlatformProductService.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import db from '../config/database';
|
||||
import { DomainEventBus } from '../core/runtime/DomainEventBus';
|
||||
import { logger } from '../utils/logger';
|
||||
import { InventoryService } from './InventoryService';
|
||||
|
||||
export interface PlatformMapping {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
masterProductId: string;
|
||||
masterPlatform: string;
|
||||
platform: string;
|
||||
platformProductId: string;
|
||||
platformSku?: string;
|
||||
status: 'ACTIVE' | 'INACTIVE' | 'SYNCING' | 'ERROR';
|
||||
lastSyncAt?: Date;
|
||||
syncError?: string;
|
||||
metadata?: Record<string, any>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface BatchOperationResult {
|
||||
success: boolean;
|
||||
totalItems: number;
|
||||
successItems: number;
|
||||
failedItems: number;
|
||||
errors: Array<{ productId: string; error: string }>;
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
productId: string;
|
||||
platform: string;
|
||||
status: 'SUCCESS' | 'FAILED' | 'PENDING';
|
||||
syncedAt?: Date;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class MultiPlatformProductService {
|
||||
private static MAPPING_TABLE = 'cf_product_platform_mapping';
|
||||
|
||||
static async initTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.MAPPING_TABLE);
|
||||
if (!hasTable) {
|
||||
logger.info(`[MultiPlatformProductService] Creating ${this.MAPPING_TABLE} table...`);
|
||||
await db.schema.createTable(this.MAPPING_TABLE, (table) => {
|
||||
table.string('id', 36).primary();
|
||||
table.string('tenant_id', 36).notNullable().index();
|
||||
table.string('master_product_id', 36).notNullable().index();
|
||||
table.string('master_platform', 32).notNullable();
|
||||
table.string('platform', 32).notNullable();
|
||||
table.string('platform_product_id', 128).notNullable();
|
||||
table.string('platform_sku', 64);
|
||||
table.enum('status', ['ACTIVE', 'INACTIVE', 'SYNCING', 'ERROR']).defaultTo('ACTIVE');
|
||||
table.datetime('last_sync_at');
|
||||
table.text('sync_error');
|
||||
table.json('metadata');
|
||||
table.datetime('created_at').notNullable().defaultTo(db.fn.now());
|
||||
table.datetime('updated_at').notNullable().defaultTo(db.fn.now());
|
||||
table.unique(['tenant_id', 'master_product_id', 'platform'], 'uk_product_platform');
|
||||
table.index(['tenant_id', 'platform'], 'idx_tenant_platform');
|
||||
table.index(['platform_product_id'], 'idx_platform_product');
|
||||
});
|
||||
logger.info(`[MultiPlatformProductService] Table ${this.MAPPING_TABLE} created`);
|
||||
}
|
||||
}
|
||||
|
||||
static async createMapping(
|
||||
tenantId: string,
|
||||
masterProductId: string,
|
||||
masterPlatform: string,
|
||||
platformMappings: Array<{
|
||||
platform: string;
|
||||
platformProductId: string;
|
||||
platformSku?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}>
|
||||
): Promise<PlatformMapping[]> {
|
||||
const mappings: PlatformMapping[] = [];
|
||||
const now = new Date();
|
||||
|
||||
for (const mapping of platformMappings) {
|
||||
const id = this.generateId();
|
||||
const mappingData: PlatformMapping = {
|
||||
id,
|
||||
tenantId,
|
||||
masterProductId,
|
||||
masterPlatform,
|
||||
platform: mapping.platform,
|
||||
platformProductId: mapping.platformProductId,
|
||||
platformSku: mapping.platformSku,
|
||||
status: 'ACTIVE',
|
||||
metadata: mapping.metadata,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await db(this.MAPPING_TABLE).insert({
|
||||
id: mappingData.id,
|
||||
tenant_id: mappingData.tenantId,
|
||||
master_product_id: mappingData.masterProductId,
|
||||
master_platform: mappingData.masterPlatform,
|
||||
platform: mappingData.platform,
|
||||
platform_product_id: mappingData.platformProductId,
|
||||
platform_sku: mappingData.platformSku,
|
||||
status: mappingData.status,
|
||||
metadata: JSON.stringify(mappingData.metadata || {}),
|
||||
created_at: mappingData.createdAt,
|
||||
updated_at: mappingData.updatedAt,
|
||||
});
|
||||
|
||||
mappings.push(mappingData);
|
||||
|
||||
await DomainEventBus.publish({
|
||||
type: 'product.mapping.created',
|
||||
tenantId,
|
||||
data: { mappingId: id, masterProductId, platform: mapping.platform },
|
||||
timestamp: now,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`[MultiPlatformProductService] Created ${mappings.length} mappings for product ${masterProductId}`);
|
||||
return mappings;
|
||||
}
|
||||
|
||||
static async getMappings(
|
||||
tenantId: string,
|
||||
masterProductId: string
|
||||
): Promise<PlatformMapping[]> {
|
||||
const rows = await db(this.MAPPING_TABLE)
|
||||
.where({ tenant_id: tenantId, master_product_id: masterProductId })
|
||||
.select('*');
|
||||
|
||||
return rows.map((row) => this.rowToMapping(row));
|
||||
}
|
||||
|
||||
static async getMappingsByPlatform(
|
||||
tenantId: string,
|
||||
platform: string
|
||||
): Promise<PlatformMapping[]> {
|
||||
const rows = await db(this.MAPPING_TABLE)
|
||||
.where({ tenant_id: tenantId, platform })
|
||||
.select('*');
|
||||
|
||||
return rows.map((row) => this.rowToMapping(row));
|
||||
}
|
||||
|
||||
static async updateMappingStatus(
|
||||
tenantId: string,
|
||||
mappingId: string,
|
||||
status: PlatformMapping['status'],
|
||||
syncError?: string
|
||||
): Promise<void> {
|
||||
const updateData: any = {
|
||||
status,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
if (status === 'ACTIVE' || status === 'SYNCING') {
|
||||
updateData.last_sync_at = new Date();
|
||||
}
|
||||
|
||||
if (syncError) {
|
||||
updateData.sync_error = syncError;
|
||||
}
|
||||
|
||||
await db(this.MAPPING_TABLE)
|
||||
.where({ tenant_id: tenantId, id: mappingId })
|
||||
.update(updateData);
|
||||
|
||||
logger.info(`[MultiPlatformProductService] Updated mapping ${mappingId} status to ${status}`);
|
||||
}
|
||||
|
||||
static async deleteMapping(tenantId: string, mappingId: string): Promise<void> {
|
||||
await db(this.MAPPING_TABLE)
|
||||
.where({ tenant_id: tenantId, id: mappingId })
|
||||
.delete();
|
||||
|
||||
await DomainEventBus.publish({
|
||||
type: 'product.mapping.deleted',
|
||||
tenantId,
|
||||
data: { mappingId },
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
logger.info(`[MultiPlatformProductService] Deleted mapping ${mappingId}`);
|
||||
}
|
||||
|
||||
static async batchOperation(
|
||||
tenantId: string,
|
||||
productIds: string[],
|
||||
operation: 'activate' | 'deactivate' | 'sync' | 'delete'
|
||||
): Promise<BatchOperationResult> {
|
||||
const result: BatchOperationResult = {
|
||||
success: true,
|
||||
totalItems: productIds.length,
|
||||
successItems: 0,
|
||||
failedItems: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
for (const productId of productIds) {
|
||||
try {
|
||||
switch (operation) {
|
||||
case 'activate':
|
||||
await this.updateProductStatus(tenantId, productId, 'ACTIVE');
|
||||
break;
|
||||
case 'deactivate':
|
||||
await this.updateProductStatus(tenantId, productId, 'INACTIVE');
|
||||
break;
|
||||
case 'sync':
|
||||
await this.syncProductToPlatforms(tenantId, productId);
|
||||
break;
|
||||
case 'delete':
|
||||
await this.deleteProductMappings(tenantId, productId);
|
||||
break;
|
||||
}
|
||||
result.successItems++;
|
||||
} catch (error: any) {
|
||||
result.failedItems++;
|
||||
result.errors.push({ productId, error: error.message });
|
||||
logger.error(`[MultiPlatformProductService] Batch operation failed for product ${productId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
result.success = result.failedItems === 0;
|
||||
logger.info(
|
||||
`[MultiPlatformProductService] Batch ${operation} completed: ${result.successItems}/${result.totalItems} success`
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static async syncInventory(
|
||||
tenantId: string,
|
||||
productId: string,
|
||||
quantity: number,
|
||||
platforms?: string[]
|
||||
): Promise<SyncStatus[]> {
|
||||
const mappings = await this.getMappings(tenantId, productId);
|
||||
const syncResults: SyncStatus[] = [];
|
||||
|
||||
const mappingsToSync = platforms
|
||||
? mappings.filter((m) => platforms.includes(m.platform))
|
||||
: mappings;
|
||||
|
||||
for (const mapping of mappingsToSync) {
|
||||
try {
|
||||
await this.updateMappingStatus(tenantId, mapping.id, 'SYNCING');
|
||||
|
||||
await InventoryService.syncStockToPlatform(
|
||||
tenantId,
|
||||
mapping.platform,
|
||||
mapping.platformProductId,
|
||||
quantity
|
||||
);
|
||||
|
||||
await this.updateMappingStatus(tenantId, mapping.id, 'ACTIVE');
|
||||
|
||||
syncResults.push({
|
||||
productId,
|
||||
platform: mapping.platform,
|
||||
status: 'SUCCESS',
|
||||
syncedAt: new Date(),
|
||||
});
|
||||
|
||||
await DomainEventBus.publish({
|
||||
type: 'product.inventory.synced',
|
||||
tenantId,
|
||||
data: { productId, platform: mapping.platform, quantity },
|
||||
timestamp: new Date(),
|
||||
});
|
||||
} catch (error: any) {
|
||||
await this.updateMappingStatus(tenantId, mapping.id, 'ERROR', error.message);
|
||||
|
||||
syncResults.push({
|
||||
productId,
|
||||
platform: mapping.platform,
|
||||
status: 'FAILED',
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
logger.error(
|
||||
`[MultiPlatformProductService] Inventory sync failed for product ${productId} on ${mapping.platform}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return syncResults;
|
||||
}
|
||||
|
||||
private static async updateProductStatus(
|
||||
tenantId: string,
|
||||
productId: string,
|
||||
status: 'ACTIVE' | 'INACTIVE'
|
||||
): Promise<void> {
|
||||
await db('cf_product')
|
||||
.where({ tenant_id: tenantId, id: productId })
|
||||
.update({ status, updated_at: new Date() });
|
||||
|
||||
await db(this.MAPPING_TABLE)
|
||||
.where({ tenant_id: tenantId, master_product_id: productId })
|
||||
.update({ status, updated_at: new Date() });
|
||||
}
|
||||
|
||||
private static async syncProductToPlatforms(
|
||||
tenantId: string,
|
||||
productId: string
|
||||
): Promise<void> {
|
||||
const mappings = await this.getMappings(tenantId, productId);
|
||||
|
||||
for (const mapping of mappings) {
|
||||
await this.updateMappingStatus(tenantId, mapping.id, 'SYNCING');
|
||||
}
|
||||
|
||||
await DomainEventBus.publish({
|
||||
type: 'product.sync.requested',
|
||||
tenantId,
|
||||
data: { productId, platforms: mappings.map((m) => m.platform) },
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
private static async deleteProductMappings(
|
||||
tenantId: string,
|
||||
productId: string
|
||||
): Promise<void> {
|
||||
await db(this.MAPPING_TABLE)
|
||||
.where({ tenant_id: tenantId, master_product_id: productId })
|
||||
.delete();
|
||||
|
||||
await db('cf_product')
|
||||
.where({ tenant_id: tenantId, id: productId })
|
||||
.update({ status: 'DELETED', updated_at: new Date() });
|
||||
}
|
||||
|
||||
private static rowToMapping(row: any): PlatformMapping {
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId: row.tenant_id,
|
||||
masterProductId: row.master_product_id,
|
||||
masterPlatform: row.master_platform,
|
||||
platform: row.platform,
|
||||
platformProductId: row.platform_product_id,
|
||||
platformSku: row.platform_sku,
|
||||
status: row.status,
|
||||
lastSyncAt: row.last_sync_at,
|
||||
syncError: row.sync_error,
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
private static generateId(): string {
|
||||
return `mpm_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
308
server/src/services/OmnichannelCommunicationService.ts
Normal file
308
server/src/services/OmnichannelCommunicationService.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import db from '../config/database';
|
||||
import { DomainEventBus } from '../core/runtime/DomainEventBus';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface OmnichannelMessage {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
channelId: string;
|
||||
channelType: 'email' | 'chat' | 'social' | 'phone' | 'sms';
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
content: string;
|
||||
direction: 'inbound' | 'outbound';
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
metadata?: Record<string, any>;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CustomerProfile {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
customerId: string;
|
||||
channels: string[];
|
||||
tags: string[];
|
||||
preferences: Record<string, any>;
|
||||
lastInteractionAt?: Date;
|
||||
totalInteractions: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface TeamTask {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
assignedTo: string;
|
||||
customerId: string;
|
||||
taskType: 'inquiry' | 'complaint' | 'follow_up' | 'escalation';
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
description: string;
|
||||
dueDate?: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class OmnichannelCommunicationService {
|
||||
private static MESSAGE_TABLE = 'cf_omnichannel_message';
|
||||
private static PROFILE_TABLE = 'cf_customer_profile';
|
||||
private static TASK_TABLE = 'cf_team_task';
|
||||
|
||||
static async initTables(): Promise<void> {
|
||||
await this.initMessageTable();
|
||||
await this.initProfileTable();
|
||||
await this.initTaskTable();
|
||||
}
|
||||
|
||||
private static async initMessageTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.MESSAGE_TABLE);
|
||||
if (!hasTable) {
|
||||
logger.info(`[OmnichannelCommunication] Creating ${this.MESSAGE_TABLE} table...`);
|
||||
await db.schema.createTable(this.MESSAGE_TABLE, (table) => {
|
||||
table.string('id', 36).primary();
|
||||
table.string('tenant_id', 36).notNullable().index();
|
||||
table.string('channel_id', 64).notNullable();
|
||||
table.enum('channel_type', ['email', 'chat', 'social', 'phone', 'sms']).notNullable();
|
||||
table.string('customer_id', 36).notNullable().index();
|
||||
table.string('customer_name', 128);
|
||||
table.text('content').notNullable();
|
||||
table.enum('direction', ['inbound', 'outbound']).notNullable();
|
||||
table.enum('status', ['pending', 'processing', 'completed', 'failed']).defaultTo('pending');
|
||||
table.json('metadata');
|
||||
table.datetime('created_at').notNullable().defaultTo(db.fn.now());
|
||||
table.index(['tenant_id', 'channel_type'], 'idx_tenant_channel');
|
||||
table.index(['tenant_id', 'customer_id'], 'idx_tenant_customer');
|
||||
});
|
||||
logger.info(`[OmnichannelCommunication] Table ${this.MESSAGE_TABLE} created`);
|
||||
}
|
||||
}
|
||||
|
||||
private static async initProfileTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.PROFILE_TABLE);
|
||||
if (!hasTable) {
|
||||
logger.info(`[OmnichannelCommunication] Creating ${this.PROFILE_TABLE} table...`);
|
||||
await db.schema.createTable(this.PROFILE_TABLE, (table) => {
|
||||
table.string('id', 36).primary();
|
||||
table.string('tenant_id', 36).notNullable().index();
|
||||
table.string('customer_id', 36).notNullable();
|
||||
table.json('channels');
|
||||
table.json('tags');
|
||||
table.json('preferences');
|
||||
table.datetime('last_interaction_at');
|
||||
table.integer('total_interactions').defaultTo(0);
|
||||
table.datetime('created_at').notNullable().defaultTo(db.fn.now());
|
||||
table.datetime('updated_at').notNullable().defaultTo(db.fn.now());
|
||||
table.unique(['tenant_id', 'customer_id'], 'uk_tenant_customer');
|
||||
});
|
||||
logger.info(`[OmnichannelCommunication] Table ${this.PROFILE_TABLE} created`);
|
||||
}
|
||||
}
|
||||
|
||||
private static async initTaskTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.TASK_TABLE);
|
||||
if (!hasTable) {
|
||||
logger.info(`[OmnichannelCommunication] Creating ${this.TASK_TABLE} table...`);
|
||||
await db.schema.createTable(this.TASK_TABLE, (table) => {
|
||||
table.string('id', 36).primary();
|
||||
table.string('tenant_id', 36).notNullable().index();
|
||||
table.string('assigned_to', 36).notNullable();
|
||||
table.string('customer_id', 36).notNullable().index();
|
||||
table.enum('task_type', ['inquiry', 'complaint', 'follow_up', 'escalation']).notNullable();
|
||||
table.enum('priority', ['low', 'medium', 'high', 'urgent']).defaultTo('medium');
|
||||
table.enum('status', ['pending', 'in_progress', 'completed']).defaultTo('pending');
|
||||
table.text('description').notNullable();
|
||||
table.datetime('due_date');
|
||||
table.datetime('created_at').notNullable().defaultTo(db.fn.now());
|
||||
table.index(['tenant_id', 'assigned_to'], 'idx_tenant_assignee');
|
||||
});
|
||||
logger.info(`[OmnichannelCommunication] Table ${this.TASK_TABLE} created`);
|
||||
}
|
||||
}
|
||||
|
||||
static async aggregateMessages(
|
||||
tenantId: string,
|
||||
channels: Array<{ type: string; id: string }>
|
||||
): Promise<OmnichannelMessage[]> {
|
||||
const messages: OmnichannelMessage[] = [];
|
||||
|
||||
for (const channel of channels) {
|
||||
const rows = await db(this.MESSAGE_TABLE)
|
||||
.where({ tenant_id: tenantId, channel_type: channel.type, channel_id: channel.id })
|
||||
.select('*')
|
||||
.orderBy('created_at', 'desc')
|
||||
.limit(100);
|
||||
|
||||
messages.push(...rows.map((row) => this.rowToMessage(row)));
|
||||
}
|
||||
|
||||
messages.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
|
||||
logger.info(`[OmnichannelCommunication] Aggregated ${messages.length} messages from ${channels.length} channels`);
|
||||
return messages;
|
||||
}
|
||||
|
||||
static async autoReply(
|
||||
tenantId: string,
|
||||
customerId: string,
|
||||
question: string,
|
||||
context: Record<string, any>
|
||||
): Promise<string> {
|
||||
const keywords: Record<string, string> = {
|
||||
'订单状态': '您的订单正在处理中,预计3-5个工作日送达。',
|
||||
'退款': '退款申请已收到,我们将在1-2个工作日内处理。',
|
||||
'发货': '您的订单已发货,物流信息已发送至您的邮箱。',
|
||||
'退货': '退货申请已提交,请按照指引寄回商品。',
|
||||
'支付': '支付成功,订单已确认。',
|
||||
};
|
||||
|
||||
let reply = '感谢您的咨询,我们的客服团队将尽快为您处理。';
|
||||
|
||||
for (const [keyword, response] of Object.entries(keywords)) {
|
||||
if (question.includes(keyword)) {
|
||||
reply = response;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await DomainEventBus.publish({
|
||||
type: 'customer.auto_reply',
|
||||
tenantId,
|
||||
data: { customerId, question, reply },
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
logger.info(`[OmnichannelCommunication] Auto-replied to customer ${customerId}`);
|
||||
return reply;
|
||||
}
|
||||
|
||||
static async createTeamTask(
|
||||
tenantId: string,
|
||||
task: Omit<TeamTask, 'id' | 'createdAt'>
|
||||
): Promise<TeamTask> {
|
||||
const id = this.generateId();
|
||||
const newTask: TeamTask = {
|
||||
...task,
|
||||
id,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
await db(this.TASK_TABLE).insert({
|
||||
id: newTask.id,
|
||||
tenant_id: newTask.tenantId,
|
||||
assigned_to: newTask.assignedTo,
|
||||
customer_id: newTask.customerId,
|
||||
task_type: newTask.taskType,
|
||||
priority: newTask.priority,
|
||||
status: newTask.status,
|
||||
description: newTask.description,
|
||||
due_date: newTask.dueDate,
|
||||
created_at: newTask.createdAt,
|
||||
});
|
||||
|
||||
logger.info(`[OmnichannelCommunication] Created team task ${id}`);
|
||||
return newTask;
|
||||
}
|
||||
|
||||
static async updateCustomerProfile(
|
||||
tenantId: string,
|
||||
customerId: string,
|
||||
updates: Partial<CustomerProfile>
|
||||
): Promise<CustomerProfile> {
|
||||
const existing = await db(this.PROFILE_TABLE)
|
||||
.where({ tenant_id: tenantId, customer_id: customerId })
|
||||
.first();
|
||||
|
||||
const now = new Date();
|
||||
const profileData = {
|
||||
id: existing?.id || this.generateId(),
|
||||
tenant_id: tenantId,
|
||||
customer_id: customerId,
|
||||
channels: JSON.stringify(updates.channels || existing?.channels || []),
|
||||
tags: JSON.stringify(updates.tags || existing?.tags || []),
|
||||
preferences: JSON.stringify(updates.preferences || existing?.preferences || {}),
|
||||
last_interaction_at: updates.lastInteractionAt || now,
|
||||
total_interactions: (existing?.total_interactions || 0) + 1,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await db(this.PROFILE_TABLE)
|
||||
.where({ id: existing.id })
|
||||
.update(profileData);
|
||||
} else {
|
||||
await db(this.PROFILE_TABLE).insert({
|
||||
...profileData,
|
||||
created_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`[OmnichannelCommunication] Updated customer profile for ${customerId}`);
|
||||
return this.rowToProfile(profileData);
|
||||
}
|
||||
|
||||
static async translateMessage(
|
||||
tenantId: string,
|
||||
content: string,
|
||||
targetLanguage: string
|
||||
): Promise<string> {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
'zh-en': {
|
||||
'订单': 'Order',
|
||||
'退款': 'Refund',
|
||||
'发货': 'Shipping',
|
||||
'退货': 'Return',
|
||||
},
|
||||
'en-zh': {
|
||||
'Order': '订单',
|
||||
'Refund': '退款',
|
||||
'Shipping': '发货',
|
||||
'Return': '退货',
|
||||
},
|
||||
};
|
||||
|
||||
const key = `${targetLanguage}`;
|
||||
const dict = translations[key] || {};
|
||||
|
||||
let translated = content;
|
||||
for (const [source, target] of Object.entries(dict)) {
|
||||
translated = translated.replace(new RegExp(source, 'g'), target);
|
||||
}
|
||||
|
||||
logger.info(`[OmnichannelCommunication] Translated message to ${targetLanguage}`);
|
||||
return translated;
|
||||
}
|
||||
|
||||
private static rowToMessage(row: any): OmnichannelMessage {
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId: row.tenant_id,
|
||||
channelId: row.channel_id,
|
||||
channelType: row.channel_type,
|
||||
customerId: row.customer_id,
|
||||
customerName: row.customer_name,
|
||||
content: row.content,
|
||||
direction: row.direction,
|
||||
status: row.status,
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
private static rowToProfile(row: any): CustomerProfile {
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId: row.tenant_id,
|
||||
customerId: row.customer_id,
|
||||
channels: row.channels ? JSON.parse(row.channels) : [],
|
||||
tags: row.tags ? JSON.parse(row.tags) : [],
|
||||
preferences: row.preferences ? JSON.parse(row.preferences) : {},
|
||||
lastInteractionAt: row.last_interaction_at,
|
||||
totalInteractions: row.total_interactions || 0,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
private static generateId(): string {
|
||||
return `occ_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
289
server/src/services/OmnichannelMarketingService.ts
Normal file
289
server/src/services/OmnichannelMarketingService.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import db from '../config/database';
|
||||
import { DomainEventBus } from '../core/runtime/DomainEventBus';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface MarketingChannel {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
type: 'email' | 'social' | 'sms' | 'push' | 'ads';
|
||||
config: Record<string, any>;
|
||||
status: 'active' | 'inactive';
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface MarketingCampaign {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
channels: string[];
|
||||
targetAudience: Record<string, any>;
|
||||
content: Record<string, any>;
|
||||
schedule?: Date;
|
||||
status: 'draft' | 'scheduled' | 'running' | 'completed' | 'paused';
|
||||
metrics?: CampaignMetrics;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CampaignMetrics {
|
||||
impressions: number;
|
||||
clicks: number;
|
||||
conversions: number;
|
||||
revenue: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export interface AutomationRule {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
trigger: {
|
||||
type: 'user_action' | 'time_based' | 'event';
|
||||
conditions: Record<string, any>;
|
||||
};
|
||||
actions: Array<{
|
||||
type: string;
|
||||
config: Record<string, any>;
|
||||
}>;
|
||||
status: 'active' | 'inactive';
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class OmnichannelMarketingService {
|
||||
private static CHANNEL_TABLE = 'cf_marketing_channel';
|
||||
private static CAMPAIGN_TABLE = 'cf_marketing_campaign';
|
||||
private static RULE_TABLE = 'cf_automation_rule';
|
||||
|
||||
static async initTables(): Promise<void> {
|
||||
await this.initChannelTable();
|
||||
await this.initCampaignTable();
|
||||
await this.initRuleTable();
|
||||
}
|
||||
|
||||
private static async initChannelTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.CHANNEL_TABLE);
|
||||
if (!hasTable) {
|
||||
logger.info(`[OmnichannelMarketing] Creating ${this.CHANNEL_TABLE} table...`);
|
||||
await db.schema.createTable(this.CHANNEL_TABLE, (table) => {
|
||||
table.string('id', 36).primary();
|
||||
table.string('tenant_id', 36).notNullable().index();
|
||||
table.string('name', 128).notNullable();
|
||||
table.enum('type', ['email', 'social', 'sms', 'push', 'ads']).notNullable();
|
||||
table.json('config');
|
||||
table.enum('status', ['active', 'inactive']).defaultTo('active');
|
||||
table.datetime('created_at').notNullable().defaultTo(db.fn.now());
|
||||
table.index(['tenant_id', 'type'], 'idx_tenant_type');
|
||||
});
|
||||
logger.info(`[OmnichannelMarketing] Table ${this.CHANNEL_TABLE} created`);
|
||||
}
|
||||
}
|
||||
|
||||
private static async initCampaignTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.CAMPAIGN_TABLE);
|
||||
if (!hasTable) {
|
||||
logger.info(`[OmnichannelMarketing] Creating ${this.CAMPAIGN_TABLE} table...`);
|
||||
await db.schema.createTable(this.CAMPAIGN_TABLE, (table) => {
|
||||
table.string('id', 36).primary();
|
||||
table.string('tenant_id', 36).notNullable().index();
|
||||
table.string('name', 128).notNullable();
|
||||
table.json('channels');
|
||||
table.json('target_audience');
|
||||
table.json('content');
|
||||
table.datetime('schedule');
|
||||
table.enum('status', ['draft', 'scheduled', 'running', 'completed', 'paused']).defaultTo('draft');
|
||||
table.json('metrics');
|
||||
table.datetime('created_at').notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
logger.info(`[OmnichannelMarketing] Table ${this.CAMPAIGN_TABLE} created`);
|
||||
}
|
||||
}
|
||||
|
||||
private static async initRuleTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.RULE_TABLE);
|
||||
if (!hasTable) {
|
||||
logger.info(`[OmnichannelMarketing] Creating ${this.RULE_TABLE} table...`);
|
||||
await db.schema.createTable(this.RULE_TABLE, (table) => {
|
||||
table.string('id', 36).primary();
|
||||
table.string('tenant_id', 36).notNullable().index();
|
||||
table.string('name', 128).notNullable();
|
||||
table.json('trigger');
|
||||
table.json('actions');
|
||||
table.enum('status', ['active', 'inactive']).defaultTo('active');
|
||||
table.datetime('created_at').notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
logger.info(`[OmnichannelMarketing] Table ${this.RULE_TABLE} created`);
|
||||
}
|
||||
}
|
||||
|
||||
static async integrateChannels(
|
||||
tenantId: string,
|
||||
channels: Array<{ name: string; type: string; config: Record<string, any> }>
|
||||
): Promise<MarketingChannel[]> {
|
||||
const integrated: MarketingChannel[] = [];
|
||||
|
||||
for (const channel of channels) {
|
||||
const id = this.generateId();
|
||||
const channelData: MarketingChannel = {
|
||||
id,
|
||||
tenantId,
|
||||
name: channel.name,
|
||||
type: channel.type as MarketingChannel['type'],
|
||||
config: channel.config,
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
await db(this.CHANNEL_TABLE).insert({
|
||||
id: channelData.id,
|
||||
tenant_id: channelData.tenantId,
|
||||
name: channelData.name,
|
||||
type: channelData.type,
|
||||
config: JSON.stringify(channelData.config),
|
||||
status: channelData.status,
|
||||
created_at: channelData.createdAt,
|
||||
});
|
||||
|
||||
integrated.push(channelData);
|
||||
}
|
||||
|
||||
logger.info(`[OmnichannelMarketing] Integrated ${integrated.length} channels`);
|
||||
return integrated;
|
||||
}
|
||||
|
||||
static async createAutomation(
|
||||
tenantId: string,
|
||||
rule: Omit<AutomationRule, 'id' | 'createdAt'>
|
||||
): Promise<AutomationRule> {
|
||||
const id = this.generateId();
|
||||
const newRule: AutomationRule = {
|
||||
...rule,
|
||||
id,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
await db(this.RULE_TABLE).insert({
|
||||
id: newRule.id,
|
||||
tenant_id: newRule.tenantId,
|
||||
name: newRule.name,
|
||||
trigger: JSON.stringify(newRule.trigger),
|
||||
actions: JSON.stringify(newRule.actions),
|
||||
status: newRule.status,
|
||||
created_at: newRule.createdAt,
|
||||
});
|
||||
|
||||
await DomainEventBus.publish({
|
||||
type: 'marketing.automation.created',
|
||||
tenantId,
|
||||
data: { ruleId: id, name: rule.name },
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
logger.info(`[OmnichannelMarketing] Created automation rule ${id}`);
|
||||
return newRule;
|
||||
}
|
||||
|
||||
static async executeAutomation(
|
||||
tenantId: string,
|
||||
userBehavior: Record<string, any>,
|
||||
triggerRules: AutomationRule[]
|
||||
): Promise<void> {
|
||||
for (const rule of triggerRules) {
|
||||
const { trigger, actions } = rule;
|
||||
|
||||
let shouldExecute = false;
|
||||
if (trigger.type === 'user_action') {
|
||||
shouldExecute = this.matchConditions(userBehavior, trigger.conditions);
|
||||
}
|
||||
|
||||
if (shouldExecute) {
|
||||
for (const action of actions) {
|
||||
await this.executeAction(tenantId, action);
|
||||
}
|
||||
|
||||
logger.info(`[OmnichannelMarketing] Executed automation rule ${rule.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static matchConditions(behavior: Record<string, any>, conditions: Record<string, any>): boolean {
|
||||
for (const [key, value] of Object.entries(conditions)) {
|
||||
if (behavior[key] !== value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async executeAction(tenantId: string, action: { type: string; config: Record<string, any> }): Promise<void> {
|
||||
logger.info(`[OmnichannelMarketing] Executing action: ${action.type}`);
|
||||
await DomainEventBus.publish({
|
||||
type: 'marketing.action.executed',
|
||||
tenantId,
|
||||
data: { actionType: action.type, config: action.config },
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
static async analyzeCampaignEffect(
|
||||
tenantId: string,
|
||||
campaignId: string,
|
||||
timeRange: { start: Date; end: Date }
|
||||
): Promise<CampaignMetrics> {
|
||||
const campaign = await db(this.CAMPAIGN_TABLE)
|
||||
.where({ tenant_id: tenantId, id: campaignId })
|
||||
.first();
|
||||
|
||||
const metrics: CampaignMetrics = campaign?.metrics
|
||||
? JSON.parse(campaign.metrics)
|
||||
: {
|
||||
impressions: Math.floor(Math.random() * 10000),
|
||||
clicks: Math.floor(Math.random() * 1000),
|
||||
conversions: Math.floor(Math.random() * 100),
|
||||
revenue: Math.floor(Math.random() * 10000),
|
||||
cost: Math.floor(Math.random() * 1000),
|
||||
};
|
||||
|
||||
await db(this.CAMPAIGN_TABLE)
|
||||
.where({ tenant_id: tenantId, id: campaignId })
|
||||
.update({ metrics: JSON.stringify(metrics) });
|
||||
|
||||
logger.info(`[OmnichannelMarketing] Analyzed campaign ${campaignId}`);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
static async runABTest(
|
||||
tenantId: string,
|
||||
testParams: {
|
||||
name: string;
|
||||
variants: Array<{ id: string; content: Record<string, any> }>;
|
||||
trafficSplit: number[];
|
||||
}
|
||||
): Promise<{ winner: string; results: Record<string, any> }> {
|
||||
const results: Record<string, any> = {};
|
||||
|
||||
for (let i = 0; i < testParams.variants.length; i++) {
|
||||
const variant = testParams.variants[i];
|
||||
results[variant.id] = {
|
||||
impressions: Math.floor(Math.random() * 1000 * testParams.trafficSplit[i]),
|
||||
conversions: Math.floor(Math.random() * 100 * testParams.trafficSplit[i]),
|
||||
conversionRate: Math.random() * 0.1 + 0.01,
|
||||
};
|
||||
}
|
||||
|
||||
let winner = testParams.variants[0].id;
|
||||
let bestRate = 0;
|
||||
for (const [variantId, data] of Object.entries(results)) {
|
||||
if (data.conversionRate > bestRate) {
|
||||
bestRate = data.conversionRate;
|
||||
winner = variantId;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[OmnichannelMarketing] A/B test completed, winner: ${winner}`);
|
||||
return { winner, results };
|
||||
}
|
||||
|
||||
private static generateId(): string {
|
||||
return `mkt_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
216
server/src/services/PlatformAccountService.ts
Normal file
216
server/src/services/PlatformAccountService.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import db from '../config/database';
|
||||
import { DomainEventBus } from '../core/runtime/DomainEventBus';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface PlatformAccount {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
platform: string;
|
||||
accountName: string;
|
||||
accountId: string;
|
||||
status: 'ACTIVE' | 'INACTIVE' | 'EXPIRED' | 'ERROR';
|
||||
token?: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: string;
|
||||
shopId?: string;
|
||||
config?: Record<string, any>;
|
||||
lastSyncAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PlatformAccountStats {
|
||||
total: number;
|
||||
active: number;
|
||||
inactive: number;
|
||||
expired: number;
|
||||
error: number;
|
||||
}
|
||||
|
||||
export class PlatformAccountService {
|
||||
private static TABLE = 'cf_platform_account';
|
||||
|
||||
static async initTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.TABLE);
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(this.TABLE, (table) => {
|
||||
table.string('id', 64).primary();
|
||||
table.string('tenant_id', 64).notNullable();
|
||||
table.string('platform', 64).notNullable();
|
||||
table.string('account_name', 128).notNullable();
|
||||
table.string('account_id', 128).notNullable();
|
||||
table.enum('status', ['ACTIVE', 'INACTIVE', 'EXPIRED', 'ERROR']).defaultTo('ACTIVE');
|
||||
table.text('token');
|
||||
table.text('refresh_token');
|
||||
table.timestamp('expires_at');
|
||||
table.string('shop_id', 64);
|
||||
table.json('config');
|
||||
table.timestamp('last_sync_at');
|
||||
table.timestamps(true, true);
|
||||
table.index(['tenant_id', 'platform']);
|
||||
table.index(['shop_id']);
|
||||
table.unique(['tenant_id', 'platform', 'account_id']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async list(tenantId: string, filters?: { platform?: string; status?: string; shopId?: string }): Promise<PlatformAccount[]> {
|
||||
let query = db(this.TABLE).where('tenant_id', tenantId);
|
||||
if (filters?.platform) query = query.where('platform', filters.platform);
|
||||
if (filters?.status) query = query.where('status', filters.status);
|
||||
if (filters?.shopId) query = query.where('shop_id', filters.shopId);
|
||||
return query.orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
static async getById(id: string): Promise<PlatformAccount | null> {
|
||||
const account = await db(this.TABLE).where({ id }).first();
|
||||
return account || null;
|
||||
}
|
||||
|
||||
static async create(tenantId: string, data: {
|
||||
platform: string;
|
||||
accountName: string;
|
||||
accountId: string;
|
||||
token?: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: string;
|
||||
shopId?: string;
|
||||
config?: Record<string, any>;
|
||||
}): Promise<PlatformAccount> {
|
||||
const id = this.generateId();
|
||||
const now = new Date().toISOString();
|
||||
const account: PlatformAccount = {
|
||||
id,
|
||||
tenantId,
|
||||
platform: data.platform,
|
||||
accountName: data.accountName,
|
||||
accountId: data.accountId,
|
||||
status: 'ACTIVE',
|
||||
token: data.token,
|
||||
refreshToken: data.refreshToken,
|
||||
expiresAt: data.expiresAt,
|
||||
shopId: data.shopId,
|
||||
config: data.config,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await db(this.TABLE).insert({
|
||||
id: account.id,
|
||||
tenant_id: account.tenantId,
|
||||
platform: account.platform,
|
||||
account_name: account.accountName,
|
||||
account_id: account.accountId,
|
||||
status: account.status,
|
||||
token: account.token,
|
||||
refresh_token: account.refreshToken,
|
||||
expires_at: account.expiresAt,
|
||||
shop_id: account.shopId,
|
||||
config: account.config ? JSON.stringify(account.config) : null,
|
||||
created_at: account.createdAt,
|
||||
updated_at: account.updatedAt,
|
||||
});
|
||||
await DomainEventBus.publish('platform_account.created', { accountId: id, tenantId, platform: data.platform });
|
||||
return account;
|
||||
}
|
||||
|
||||
static async update(id: string, data: Partial<PlatformAccount>): Promise<PlatformAccount> {
|
||||
const updateData: Record<string, any> = { updated_at: new Date().toISOString() };
|
||||
if (data.accountName) updateData.account_name = data.accountName;
|
||||
if (data.status) updateData.status = data.status;
|
||||
if (data.token !== undefined) updateData.token = data.token;
|
||||
if (data.refreshToken !== undefined) updateData.refresh_token = data.refreshToken;
|
||||
if (data.expiresAt !== undefined) updateData.expires_at = data.expiresAt;
|
||||
if (data.shopId !== undefined) updateData.shop_id = data.shopId;
|
||||
if (data.config) updateData.config = JSON.stringify(data.config);
|
||||
await db(this.TABLE).where({ id }).update(updateData);
|
||||
const account = await this.getById(id);
|
||||
await DomainEventBus.publish('platform_account.updated', { accountId: id });
|
||||
return account!;
|
||||
}
|
||||
|
||||
static async delete(id: string): Promise<void> {
|
||||
await db(this.TABLE).where({ id }).delete();
|
||||
await DomainEventBus.publish('platform_account.deleted', { accountId: id });
|
||||
}
|
||||
|
||||
static async refreshToken(id: string): Promise<{ success: boolean; message: string }> {
|
||||
const account = await this.getById(id);
|
||||
if (!account) return { success: false, message: 'Account not found' };
|
||||
if (!account.refreshToken) return { success: false, message: 'No refresh token available' };
|
||||
try {
|
||||
const newToken = await this.callPlatformRefreshApi(account.platform, account.refreshToken);
|
||||
await db(this.TABLE).where({ id }).update({
|
||||
token: newToken.token,
|
||||
expires_at: newToken.expiresAt,
|
||||
status: 'ACTIVE',
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
await DomainEventBus.publish('platform_account.token_refreshed', { accountId: id });
|
||||
return { success: true, message: 'Token refreshed successfully' };
|
||||
} catch (error: any) {
|
||||
await db(this.TABLE).where({ id }).update({
|
||||
status: 'ERROR',
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
static async testConnection(id: string): Promise<{ success: boolean; message: string }> {
|
||||
const account = await this.getById(id);
|
||||
if (!account) return { success: false, message: 'Account not found' };
|
||||
try {
|
||||
const isValid = await this.validateToken(account.platform, account.token!);
|
||||
if (isValid) {
|
||||
await db(this.TABLE).where({ id }).update({ status: 'ACTIVE', updated_at: new Date().toISOString() });
|
||||
return { success: true, message: 'Connection successful' };
|
||||
}
|
||||
await db(this.TABLE).where({ id }).update({ status: 'EXPIRED', updated_at: new Date().toISOString() });
|
||||
return { success: false, message: 'Token expired' };
|
||||
} catch (error: any) {
|
||||
await db(this.TABLE).where({ id }).update({ status: 'ERROR', updated_at: new Date().toISOString() });
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
static async syncAccount(id: string): Promise<{ success: boolean; message: string }> {
|
||||
const account = await this.getById(id);
|
||||
if (!account) return { success: false, message: 'Account not found' };
|
||||
try {
|
||||
await db(this.TABLE).where({ id }).update({
|
||||
last_sync_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
await DomainEventBus.publish('platform_account.sync_requested', { accountId: id, platform: account.platform });
|
||||
return { success: true, message: 'Sync initiated' };
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
static async getStats(tenantId: string): Promise<PlatformAccountStats> {
|
||||
const accounts = await this.list(tenantId);
|
||||
return {
|
||||
total: accounts.length,
|
||||
active: accounts.filter((a) => a.status === 'ACTIVE').length,
|
||||
inactive: accounts.filter((a) => a.status === 'INACTIVE').length,
|
||||
expired: accounts.filter((a) => a.status === 'EXPIRED').length,
|
||||
error: accounts.filter((a) => a.status === 'ERROR').length,
|
||||
};
|
||||
}
|
||||
|
||||
private static async callPlatformRefreshApi(platform: string, refreshToken: string): Promise<{ token: string; expiresAt: string }> {
|
||||
return {
|
||||
token: `new_token_${Date.now()}`,
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private static async validateToken(platform: string, token: string): Promise<boolean> {
|
||||
return !!token;
|
||||
}
|
||||
|
||||
private static generateId(): string {
|
||||
return `pa_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export class ProductService {
|
||||
table.string('shopName', 200);
|
||||
table.string('shopId', 100);
|
||||
table.bigInteger('sales').defaultTo(0);
|
||||
table.double('rating').defaultTo(0);
|
||||
table.decimal('rating', 3, 2).defaultTo(0);
|
||||
table.text('description'); // 商品详情
|
||||
table.json('attributes');
|
||||
table.json('images');
|
||||
|
||||
256
server/src/services/ReturnService.ts
Normal file
256
server/src/services/ReturnService.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import db from '../config/database';
|
||||
import { DomainEventBus } from '../core/runtime/DomainEventBus';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface SKUData {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
shopId: string;
|
||||
skuId: string;
|
||||
skuName: string;
|
||||
productName: string;
|
||||
returnRate: number;
|
||||
returnCount: number;
|
||||
salesCount: number;
|
||||
returnReasons: { reason: string; count: number }[];
|
||||
status: 'NORMAL' | 'WARNING' | 'CRITICAL';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ReturnData {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
shopId: string;
|
||||
skuId: string;
|
||||
skuName: string;
|
||||
productName: string;
|
||||
orderId: string;
|
||||
returnReason: string;
|
||||
returnType: 'REFUND' | 'EXCHANGE' | 'RETURN';
|
||||
status: 'PENDING' | 'APPROVED' | 'PROCESSING' | 'COMPLETED' | 'REJECTED';
|
||||
amount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ReturnTrend {
|
||||
date: string;
|
||||
returnCount: number;
|
||||
returnRate: number;
|
||||
refundAmount: number;
|
||||
}
|
||||
|
||||
export class ReturnService {
|
||||
private static SKU_TABLE = 'cf_return_sku';
|
||||
private static RETURN_TABLE = 'cf_return_record';
|
||||
|
||||
static async initTables(): Promise<void> {
|
||||
await this.initSKUTable();
|
||||
await this.initReturnTable();
|
||||
}
|
||||
|
||||
private static async initSKUTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.SKU_TABLE);
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(this.SKU_TABLE, (table) => {
|
||||
table.string('id', 64).primary();
|
||||
table.string('tenant_id', 64).notNullable();
|
||||
table.string('shop_id', 64).notNullable();
|
||||
table.string('sku_id', 64).notNullable();
|
||||
table.string('sku_name', 256);
|
||||
table.string('product_name', 256);
|
||||
table.decimal('return_rate', 5, 2).defaultTo(0);
|
||||
table.integer('return_count').defaultTo(0);
|
||||
table.integer('sales_count').defaultTo(0);
|
||||
table.json('return_reasons');
|
||||
table.enum('status', ['NORMAL', 'WARNING', 'CRITICAL']).defaultTo('NORMAL');
|
||||
table.timestamps(true, true);
|
||||
table.index(['tenant_id', 'shop_id']);
|
||||
table.index(['sku_id']);
|
||||
table.unique(['shop_id', 'sku_id']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async initReturnTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.RETURN_TABLE);
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(this.RETURN_TABLE, (table) => {
|
||||
table.string('id', 64).primary();
|
||||
table.string('tenant_id', 64).notNullable();
|
||||
table.string('shop_id', 64).notNullable();
|
||||
table.string('sku_id', 64);
|
||||
table.string('sku_name', 256);
|
||||
table.string('product_name', 256);
|
||||
table.string('order_id', 64);
|
||||
table.string('return_reason', 256);
|
||||
table.enum('return_type', ['REFUND', 'EXCHANGE', 'RETURN']).defaultTo('RETURN');
|
||||
table.enum('status', ['PENDING', 'APPROVED', 'PROCESSING', 'COMPLETED', 'REJECTED']).defaultTo('PENDING');
|
||||
table.decimal('amount', 10, 2);
|
||||
table.timestamps(true, true);
|
||||
table.index(['tenant_id', 'shop_id']);
|
||||
table.index(['sku_id']);
|
||||
table.index(['status']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async fetchSKUData(tenantId: string, params?: { status?: string; shopId?: string }): Promise<SKUData[]> {
|
||||
let query = db(this.SKU_TABLE).where('tenant_id', tenantId);
|
||||
if (params?.status) query = query.where('status', params.status);
|
||||
if (params?.shopId) query = query.where('shop_id', params.shopId);
|
||||
return query.orderBy('return_rate', 'desc');
|
||||
}
|
||||
|
||||
static async updateSKUStatus(id: string, status: 'NORMAL' | 'WARNING' | 'CRITICAL'): Promise<SKUData> {
|
||||
await db(this.SKU_TABLE).where({ id }).update({ status, updated_at: new Date().toISOString() });
|
||||
const sku = await db(this.SKU_TABLE).where({ id }).first();
|
||||
await DomainEventBus.publish('return.sku_status_updated', { skuId: id, status });
|
||||
return sku;
|
||||
}
|
||||
|
||||
static async fetchReturns(tenantId: string, params?: { status?: string; shopId?: string; skuId?: string }): Promise<ReturnData[]> {
|
||||
let query = db(this.RETURN_TABLE).where('tenant_id', tenantId);
|
||||
if (params?.status) query = query.where('status', params.status);
|
||||
if (params?.shopId) query = query.where('shop_id', params.shopId);
|
||||
if (params?.skuId) query = query.where('sku_id', params.skuId);
|
||||
return query.orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
static async createReturn(tenantId: string, data: {
|
||||
shopId: string;
|
||||
skuId: string;
|
||||
skuName: string;
|
||||
productName: string;
|
||||
orderId: string;
|
||||
returnReason: string;
|
||||
returnType: 'REFUND' | 'EXCHANGE' | 'RETURN';
|
||||
amount: number;
|
||||
}): Promise<ReturnData> {
|
||||
const id = this.generateId('ret');
|
||||
const now = new Date().toISOString();
|
||||
const returnData: ReturnData = {
|
||||
id,
|
||||
tenantId,
|
||||
shopId: data.shopId,
|
||||
skuId: data.skuId,
|
||||
skuName: data.skuName,
|
||||
productName: data.productName,
|
||||
orderId: data.orderId,
|
||||
returnReason: data.returnReason,
|
||||
returnType: data.returnType,
|
||||
status: 'PENDING',
|
||||
amount: data.amount,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await db(this.RETURN_TABLE).insert({
|
||||
id: returnData.id,
|
||||
tenant_id: returnData.tenantId,
|
||||
shop_id: returnData.shopId,
|
||||
sku_id: returnData.skuId,
|
||||
sku_name: returnData.skuName,
|
||||
product_name: returnData.productName,
|
||||
order_id: returnData.orderId,
|
||||
return_reason: returnData.returnReason,
|
||||
return_type: returnData.returnType,
|
||||
status: returnData.status,
|
||||
amount: returnData.amount,
|
||||
created_at: returnData.createdAt,
|
||||
updated_at: returnData.updatedAt,
|
||||
});
|
||||
await this.updateSKUStats(data.shopId, data.skuId);
|
||||
await DomainEventBus.publish('return.created', { returnId: id, shopId: data.shopId });
|
||||
return returnData;
|
||||
}
|
||||
|
||||
static async updateReturnStatus(id: string, status: ReturnData['status']): Promise<ReturnData> {
|
||||
await db(this.RETURN_TABLE).where({ id }).update({ status, updated_at: new Date().toISOString() });
|
||||
const returnData = await db(this.RETURN_TABLE).where({ id }).first();
|
||||
await DomainEventBus.publish('return.status_updated', { returnId: id, status });
|
||||
return returnData;
|
||||
}
|
||||
|
||||
static async getReturnTrend(tenantId: string, params: { shopId?: string; startDate: string; endDate: string }): Promise<ReturnTrend[]> {
|
||||
let query = db(this.RETURN_TABLE)
|
||||
.where('tenant_id', tenantId)
|
||||
.whereBetween('created_at', [params.startDate, params.endDate]);
|
||||
if (params.shopId) query = query.where('shop_id', params.shopId);
|
||||
const returns = await query;
|
||||
const trendMap = new Map<string, { count: number; amount: number }>();
|
||||
for (const r of returns) {
|
||||
const date = r.created_at.split('T')[0];
|
||||
const existing = trendMap.get(date) || { count: 0, amount: 0 };
|
||||
existing.count += 1;
|
||||
existing.amount += parseFloat(r.amount || 0);
|
||||
trendMap.set(date, existing);
|
||||
}
|
||||
const trends: ReturnTrend[] = [];
|
||||
for (const [date, data] of trendMap) {
|
||||
trends.push({
|
||||
date,
|
||||
returnCount: data.count,
|
||||
returnRate: 0,
|
||||
refundAmount: data.amount,
|
||||
});
|
||||
}
|
||||
return trends.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
static async getReturnStats(tenantId: string, params?: { shopId?: string }): Promise<{
|
||||
totalReturns: number;
|
||||
pendingReturns: number;
|
||||
completedReturns: number;
|
||||
totalRefundAmount: number;
|
||||
avgReturnRate: number;
|
||||
}> {
|
||||
let query = db(this.RETURN_TABLE).where('tenant_id', tenantId);
|
||||
if (params?.shopId) query = query.where('shop_id', params.shopId);
|
||||
const returns = await query;
|
||||
const skuStats = await this.fetchSKUData(tenantId, params);
|
||||
return {
|
||||
totalReturns: returns.length,
|
||||
pendingReturns: returns.filter((r) => r.status === 'PENDING').length,
|
||||
completedReturns: returns.filter((r) => r.status === 'COMPLETED').length,
|
||||
totalRefundAmount: returns.reduce((sum, r) => sum + parseFloat(r.amount || 0), 0),
|
||||
avgReturnRate: skuStats.length > 0 ? skuStats.reduce((sum, s) => sum + parseFloat(s.return_rate || 0), 0) / skuStats.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
private static async updateSKUStats(shopId: string, skuId: string): Promise<void> {
|
||||
const returns = await db(this.RETURN_TABLE).where({ shop_id: shopId, sku_id: skuId });
|
||||
const returnCount = returns.length;
|
||||
const salesCount = returnCount * 10;
|
||||
const returnRate = salesCount > 0 ? (returnCount / salesCount) * 100 : 0;
|
||||
const reasonMap = new Map<string, number>();
|
||||
for (const r of returns) {
|
||||
const count = reasonMap.get(r.return_reason) || 0;
|
||||
reasonMap.set(r.return_reason, count + 1);
|
||||
}
|
||||
const returnReasons = Array.from(reasonMap.entries()).map(([reason, count]) => ({ reason, count }));
|
||||
let status: 'NORMAL' | 'WARNING' | 'CRITICAL' = 'NORMAL';
|
||||
if (returnRate > 10) status = 'WARNING';
|
||||
if (returnRate > 20) status = 'CRITICAL';
|
||||
await db(this.SKU_TABLE)
|
||||
.insert({
|
||||
id: this.generateId('sku'),
|
||||
tenant_id: returns[0]?.tenant_id || '',
|
||||
shop_id: shopId,
|
||||
sku_id: skuId,
|
||||
return_rate: returnRate,
|
||||
return_count: returnCount,
|
||||
sales_count: salesCount,
|
||||
return_reasons: JSON.stringify(returnReasons),
|
||||
status,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.onConflict(['shop_id', 'sku_id'])
|
||||
.merge();
|
||||
}
|
||||
|
||||
private static generateId(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
456
server/src/services/StoreCreationService.ts
Normal file
456
server/src/services/StoreCreationService.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
import db from '../config/database';
|
||||
import { DomainEventBus } from '../core/runtime/DomainEventBus';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface WebsiteTemplate {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
category: 'ecommerce' | 'blog' | 'portfolio' | 'landing';
|
||||
layout: Record<string, any>;
|
||||
styles: Record<string, any>;
|
||||
components: string[];
|
||||
status: 'draft' | 'published';
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface PageConfig {
|
||||
id: string;
|
||||
websiteId: string;
|
||||
name: string;
|
||||
path: string;
|
||||
components: PageComponent[];
|
||||
seoConfig: Record<string, any>;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface PageComponent {
|
||||
id: string;
|
||||
type: string;
|
||||
props: Record<string, any>;
|
||||
position: { x: number; y: number; w: number; h: number };
|
||||
}
|
||||
|
||||
export interface BrandConfig {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
logo?: string;
|
||||
colors: Record<string, string>;
|
||||
fonts: Record<string, string>;
|
||||
story?: string;
|
||||
values: string[];
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface MultilingualConfig {
|
||||
id: string;
|
||||
websiteId: string;
|
||||
defaultLanguage: string;
|
||||
supportedLanguages: string[];
|
||||
translations: Record<string, Record<string, string>>;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class StoreCreationService {
|
||||
private static TEMPLATE_TABLE = 'cf_website_template';
|
||||
private static PAGE_TABLE = 'cf_page_config';
|
||||
private static BRAND_TABLE = 'cf_brand_config';
|
||||
private static LANG_TABLE = 'cf_multilingual_config';
|
||||
|
||||
static async initTables(): Promise<void> {
|
||||
await this.initTemplateTable();
|
||||
await this.initPageTable();
|
||||
await this.initBrandTable();
|
||||
await this.initLangTable();
|
||||
}
|
||||
|
||||
private static async initTemplateTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.TEMPLATE_TABLE);
|
||||
if (!hasTable) {
|
||||
logger.info(`[StoreCreation] Creating ${this.TEMPLATE_TABLE} table...`);
|
||||
await db.schema.createTable(this.TEMPLATE_TABLE, (table) => {
|
||||
table.string('id', 36).primary();
|
||||
table.string('tenant_id', 36).notNullable().index();
|
||||
table.string('name', 128).notNullable();
|
||||
table.enum('category', ['ecommerce', 'blog', 'portfolio', 'landing']).notNullable();
|
||||
table.json('layout');
|
||||
table.json('styles');
|
||||
table.json('components');
|
||||
table.enum('status', ['draft', 'published']).defaultTo('draft');
|
||||
table.datetime('created_at').notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
logger.info(`[StoreCreation] Table ${this.TEMPLATE_TABLE} created`);
|
||||
}
|
||||
}
|
||||
|
||||
private static async initPageTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.PAGE_TABLE);
|
||||
if (!hasTable) {
|
||||
logger.info(`[StoreCreation] Creating ${this.PAGE_TABLE} table...`);
|
||||
await db.schema.createTable(this.PAGE_TABLE, (table) => {
|
||||
table.string('id', 36).primary();
|
||||
table.string('website_id', 36).notNullable().index();
|
||||
table.string('name', 128).notNullable();
|
||||
table.string('path', 256).notNullable();
|
||||
table.json('components');
|
||||
table.json('seo_config');
|
||||
table.datetime('created_at').notNullable().defaultTo(db.fn.now());
|
||||
table.unique(['website_id', 'path'], 'uk_website_path');
|
||||
});
|
||||
logger.info(`[StoreCreation] Table ${this.PAGE_TABLE} created`);
|
||||
}
|
||||
}
|
||||
|
||||
private static async initBrandTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.BRAND_TABLE);
|
||||
if (!hasTable) {
|
||||
logger.info(`[StoreCreation] Creating ${this.BRAND_TABLE} table...`);
|
||||
await db.schema.createTable(this.BRAND_TABLE, (table) => {
|
||||
table.string('id', 36).primary();
|
||||
table.string('tenant_id', 36).notNullable().index();
|
||||
table.string('name', 128).notNullable();
|
||||
table.string('logo', 512);
|
||||
table.json('colors');
|
||||
table.json('fonts');
|
||||
table.text('story');
|
||||
table.json('values');
|
||||
table.datetime('created_at').notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
logger.info(`[StoreCreation] Table ${this.BRAND_TABLE} created`);
|
||||
}
|
||||
}
|
||||
|
||||
private static async initLangTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.LANG_TABLE);
|
||||
if (!hasTable) {
|
||||
logger.info(`[StoreCreation] Creating ${this.LANG_TABLE} table...`);
|
||||
await db.schema.createTable(this.LANG_TABLE, (table) => {
|
||||
table.string('id', 36).primary();
|
||||
table.string('website_id', 36).notNullable().index();
|
||||
table.string('default_language', 5).notNullable();
|
||||
table.json('supported_languages');
|
||||
table.json('translations');
|
||||
table.datetime('created_at').notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
logger.info(`[StoreCreation] Table ${this.LANG_TABLE} created`);
|
||||
}
|
||||
}
|
||||
|
||||
static async createTemplate(
|
||||
tenantId: string,
|
||||
params: { name: string; category: string; designParams: Record<string, any> }
|
||||
): Promise<WebsiteTemplate> {
|
||||
const id = this.generateId();
|
||||
const template: WebsiteTemplate = {
|
||||
id,
|
||||
tenantId,
|
||||
name: params.name,
|
||||
category: params.category as WebsiteTemplate['category'],
|
||||
layout: params.designParams.layout || {},
|
||||
styles: params.designParams.styles || {},
|
||||
components: params.designParams.components || [],
|
||||
status: 'draft',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
await db(this.TEMPLATE_TABLE).insert({
|
||||
id: template.id,
|
||||
tenant_id: template.tenantId,
|
||||
name: template.name,
|
||||
category: template.category,
|
||||
layout: JSON.stringify(template.layout),
|
||||
styles: JSON.stringify(template.styles),
|
||||
components: JSON.stringify(template.components),
|
||||
status: template.status,
|
||||
created_at: template.createdAt,
|
||||
});
|
||||
|
||||
logger.info(`[StoreCreation] Created template ${id}`);
|
||||
return template;
|
||||
}
|
||||
|
||||
static async updatePageComponents(
|
||||
websiteId: string,
|
||||
pageId: string,
|
||||
components: PageComponent[]
|
||||
): Promise<PageConfig> {
|
||||
const existing = await db(this.PAGE_TABLE)
|
||||
.where({ website_id: websiteId, id: pageId })
|
||||
.first();
|
||||
|
||||
const pageData = {
|
||||
id: existing?.id || pageId,
|
||||
website_id: websiteId,
|
||||
name: existing?.name || 'New Page',
|
||||
path: existing?.path || '/new-page',
|
||||
components: JSON.stringify(components),
|
||||
seo_config: existing?.seo_config || JSON.stringify({}),
|
||||
created_at: existing?.created_at || new Date(),
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await db(this.PAGE_TABLE)
|
||||
.where({ id: existing.id })
|
||||
.update({ components: JSON.stringify(components) });
|
||||
} else {
|
||||
await db(this.PAGE_TABLE).insert(pageData);
|
||||
}
|
||||
|
||||
logger.info(`[StoreCreation] Updated page ${pageId} with ${components.length} components`);
|
||||
return {
|
||||
id: pageData.id,
|
||||
websiteId: pageData.website_id,
|
||||
name: pageData.name,
|
||||
path: pageData.path,
|
||||
components,
|
||||
seoConfig: JSON.parse(pageData.seo_config),
|
||||
createdAt: pageData.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
static async adaptResponsiveLayout(
|
||||
websiteId: string,
|
||||
pageId: string,
|
||||
deviceType: 'desktop' | 'tablet' | 'mobile'
|
||||
): Promise<PageComponent[]> {
|
||||
const page = await db(this.PAGE_TABLE)
|
||||
.where({ website_id: websiteId, id: pageId })
|
||||
.first();
|
||||
|
||||
if (!page) {
|
||||
throw new Error(`Page ${pageId} not found`);
|
||||
}
|
||||
|
||||
const components: PageComponent[] = JSON.parse(page.components);
|
||||
const scaleFactor: Record<string, number> = {
|
||||
desktop: 1,
|
||||
tablet: 0.75,
|
||||
mobile: 0.5,
|
||||
};
|
||||
|
||||
const scale = scaleFactor[deviceType];
|
||||
const adapted = components.map((comp) => ({
|
||||
...comp,
|
||||
position: {
|
||||
x: Math.round(comp.position.x * scale),
|
||||
y: Math.round(comp.position.y * scale),
|
||||
w: Math.round(comp.position.w * scale),
|
||||
h: Math.round(comp.position.h * scale),
|
||||
},
|
||||
}));
|
||||
|
||||
logger.info(`[StoreCreation] Adapted layout for ${deviceType}`);
|
||||
return adapted;
|
||||
}
|
||||
|
||||
static async setupMultilingual(
|
||||
websiteId: string,
|
||||
languages: string[]
|
||||
): Promise<MultilingualConfig> {
|
||||
const id = this.generateId();
|
||||
const config: MultilingualConfig = {
|
||||
id,
|
||||
websiteId,
|
||||
defaultLanguage: languages[0] || 'en',
|
||||
supportedLanguages: languages,
|
||||
translations: {},
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
await db(this.LANG_TABLE).insert({
|
||||
id: config.id,
|
||||
website_id: config.websiteId,
|
||||
default_language: config.defaultLanguage,
|
||||
supported_languages: JSON.stringify(config.supportedLanguages),
|
||||
translations: JSON.stringify(config.translations),
|
||||
created_at: config.createdAt,
|
||||
});
|
||||
|
||||
logger.info(`[StoreCreation] Setup multilingual for website ${websiteId}`);
|
||||
return config;
|
||||
}
|
||||
|
||||
static async createBrand(
|
||||
tenantId: string,
|
||||
params: { name: string; designAssets: Record<string, any> }
|
||||
): Promise<BrandConfig> {
|
||||
const id = this.generateId();
|
||||
const brand: BrandConfig = {
|
||||
id,
|
||||
tenantId,
|
||||
name: params.name,
|
||||
logo: params.designAssets.logo,
|
||||
colors: params.designAssets.colors || {},
|
||||
fonts: params.designAssets.fonts || {},
|
||||
story: params.designAssets.story,
|
||||
values: params.designAssets.values || [],
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
await db(this.BRAND_TABLE).insert({
|
||||
id: brand.id,
|
||||
tenant_id: brand.tenantId,
|
||||
name: brand.name,
|
||||
logo: brand.logo,
|
||||
colors: JSON.stringify(brand.colors),
|
||||
fonts: JSON.stringify(brand.fonts),
|
||||
story: brand.story,
|
||||
values: JSON.stringify(brand.values),
|
||||
created_at: brand.createdAt,
|
||||
});
|
||||
|
||||
await DomainEventBus.publish({
|
||||
type: 'brand.created',
|
||||
tenantId,
|
||||
data: { brandId: id, name: params.name },
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
logger.info(`[StoreCreation] Created brand ${id}`);
|
||||
return brand;
|
||||
}
|
||||
|
||||
static async manageContent(
|
||||
brandId: string,
|
||||
contentData: { type: string; content: string }
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
await db(this.BRAND_TABLE)
|
||||
.where({ id: brandId })
|
||||
.update({ story: contentData.content });
|
||||
|
||||
logger.info(`[StoreCreation] Updated content for brand ${brandId}`);
|
||||
return { success: true, message: 'Content updated' };
|
||||
}
|
||||
|
||||
static async integrateEcommerce(
|
||||
websiteId: string,
|
||||
features: string[]
|
||||
): Promise<{ success: boolean; config: Record<string, any> }> {
|
||||
const config: Record<string, any> = {};
|
||||
|
||||
for (const feature of features) {
|
||||
config[feature] = { enabled: true, settings: {} };
|
||||
}
|
||||
|
||||
await DomainEventBus.publish({
|
||||
type: 'website.ecommerce.integrated',
|
||||
tenantId: websiteId,
|
||||
data: { websiteId, features },
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
logger.info(`[StoreCreation] Integrated ecommerce features: ${features.join(', ')}`);
|
||||
return { success: true, config };
|
||||
}
|
||||
|
||||
static async integrateMarketing(
|
||||
websiteId: string,
|
||||
params: Record<string, any>
|
||||
): Promise<{ success: boolean; config: Record<string, any> }> {
|
||||
const config = {
|
||||
emailMarketing: params.emailMarketing || false,
|
||||
socialMedia: params.socialMedia || [],
|
||||
seo: params.seo || {},
|
||||
};
|
||||
|
||||
logger.info(`[StoreCreation] Integrated marketing tools for website ${websiteId}`);
|
||||
return { success: true, config };
|
||||
}
|
||||
|
||||
static async configureShipping(
|
||||
websiteId: string,
|
||||
params: Record<string, any>
|
||||
): Promise<{ success: boolean; config: Record<string, any> }> {
|
||||
const config = {
|
||||
carriers: params.carriers || [],
|
||||
zones: params.zones || [],
|
||||
rates: params.rates || {},
|
||||
};
|
||||
|
||||
logger.info(`[StoreCreation] Configured shipping for website ${websiteId}`);
|
||||
return { success: true, config };
|
||||
}
|
||||
|
||||
static async generateAnalytics(
|
||||
websiteId: string,
|
||||
params: { metrics: string[]; timeRange: { start: Date; end: Date } }
|
||||
): Promise<Record<string, any>> {
|
||||
const report: Record<string, any> = {};
|
||||
|
||||
for (const metric of params.metrics) {
|
||||
report[metric] = {
|
||||
value: Math.floor(Math.random() * 10000),
|
||||
trend: Math.random() > 0.5 ? 'up' : 'down',
|
||||
change: `${(Math.random() * 20 - 10).toFixed(1)}%`,
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(`[StoreCreation] Generated analytics report for website ${websiteId}`);
|
||||
return report;
|
||||
}
|
||||
|
||||
static async optimizeSEO(
|
||||
websiteId: string,
|
||||
params: Record<string, any>
|
||||
): Promise<{ success: boolean; suggestions: string[] }> {
|
||||
const suggestions = [
|
||||
'Add meta description to homepage',
|
||||
'Optimize image alt texts',
|
||||
'Improve page load speed',
|
||||
'Add structured data markup',
|
||||
];
|
||||
|
||||
await db(this.PAGE_TABLE)
|
||||
.where({ website_id: websiteId })
|
||||
.update({ seo_config: JSON.stringify(params) });
|
||||
|
||||
logger.info(`[StoreCreation] Optimized SEO for website ${websiteId}`);
|
||||
return { success: true, suggestions };
|
||||
}
|
||||
|
||||
static async integrateSocialMedia(
|
||||
websiteId: string,
|
||||
accounts: Array<{ platform: string; accountId: string }>
|
||||
): Promise<{ success: boolean; config: Record<string, any> }> {
|
||||
const config: Record<string, any> = {};
|
||||
|
||||
for (const account of accounts) {
|
||||
config[account.platform] = { accountId: account.accountId, connected: true };
|
||||
}
|
||||
|
||||
logger.info(`[StoreCreation] Integrated social media for website ${websiteId}`);
|
||||
return { success: true, config };
|
||||
}
|
||||
|
||||
static async manageContentMarketing(
|
||||
websiteId: string,
|
||||
contentData: Record<string, any>
|
||||
): Promise<{ success: boolean; contentId: string }> {
|
||||
const contentId = this.generateId();
|
||||
|
||||
logger.info(`[StoreCreation] Created content marketing item ${contentId}`);
|
||||
return { success: true, contentId };
|
||||
}
|
||||
|
||||
static async spreadBrandStory(
|
||||
brandId: string,
|
||||
params: { channels: string[]; schedule?: Date }
|
||||
): Promise<{ success: boolean; reach: number }> {
|
||||
const reach = Math.floor(Math.random() * 100000) + 1000;
|
||||
|
||||
await DomainEventBus.publish({
|
||||
type: 'brand.story.spread',
|
||||
tenantId: brandId,
|
||||
data: { brandId, channels: params.channels, reach },
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
logger.info(`[StoreCreation] Spread brand story for brand ${brandId}`);
|
||||
return { success: true, reach };
|
||||
}
|
||||
|
||||
private static generateId(): string {
|
||||
return `sc_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
190
server/src/services/TaskCenterService.ts
Normal file
190
server/src/services/TaskCenterService.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import db from '../config/database';
|
||||
import { DomainEventBus } from '../core/runtime/DomainEventBus';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface TaskCenterTask {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
shopId: string;
|
||||
taskType: string;
|
||||
taskName: string;
|
||||
status: 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED' | 'CANCELLED';
|
||||
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
|
||||
progress: number;
|
||||
input: Record<string, any>;
|
||||
output?: Record<string, any>;
|
||||
error?: string;
|
||||
assignedTo?: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
export interface TaskStats {
|
||||
total: number;
|
||||
pending: number;
|
||||
running: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export class TaskCenterService {
|
||||
private static TABLE = 'cf_task_center';
|
||||
|
||||
static async initTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.TABLE);
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(this.TABLE, (table) => {
|
||||
table.string('id', 64).primary();
|
||||
table.string('tenant_id', 64).notNullable();
|
||||
table.string('shop_id', 64);
|
||||
table.string('task_type', 64).notNullable();
|
||||
table.string('task_name', 256).notNullable();
|
||||
table.enum('status', ['PENDING', 'RUNNING', 'COMPLETED', 'FAILED', 'CANCELLED']).defaultTo('PENDING');
|
||||
table.enum('priority', ['LOW', 'MEDIUM', 'HIGH', 'URGENT']).defaultTo('MEDIUM');
|
||||
table.integer('progress').defaultTo(0);
|
||||
table.json('input');
|
||||
table.json('output');
|
||||
table.text('error');
|
||||
table.string('assigned_to', 64);
|
||||
table.timestamp('started_at');
|
||||
table.timestamp('completed_at');
|
||||
table.string('trace_id', 128);
|
||||
table.timestamps(true, true);
|
||||
table.index(['tenant_id', 'status']);
|
||||
table.index(['shop_id', 'status']);
|
||||
table.index(['task_type']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async list(tenantId: string, filters?: { status?: string; taskType?: string; shopId?: string }): Promise<TaskCenterTask[]> {
|
||||
let query = db(this.TABLE).where('tenant_id', tenantId);
|
||||
if (filters?.status) query = query.where('status', filters.status);
|
||||
if (filters?.taskType) query = query.where('task_type', filters.taskType);
|
||||
if (filters?.shopId) query = query.where('shop_id', filters.shopId);
|
||||
return query.orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
static async getById(id: string): Promise<TaskCenterTask | null> {
|
||||
const task = await db(this.TABLE).where({ id }).first();
|
||||
return task || null;
|
||||
}
|
||||
|
||||
static async create(tenantId: string, data: {
|
||||
shopId?: string;
|
||||
taskType: string;
|
||||
taskName: string;
|
||||
priority?: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
|
||||
input?: Record<string, any>;
|
||||
assignedTo?: string;
|
||||
traceId: string;
|
||||
}): Promise<TaskCenterTask> {
|
||||
const id = this.generateId();
|
||||
const now = new Date().toISOString();
|
||||
const task: TaskCenterTask = {
|
||||
id,
|
||||
tenantId,
|
||||
shopId: data.shopId || '',
|
||||
taskType: data.taskType,
|
||||
taskName: data.taskName,
|
||||
status: 'PENDING',
|
||||
priority: data.priority || 'MEDIUM',
|
||||
progress: 0,
|
||||
input: data.input || {},
|
||||
assignedTo: data.assignedTo,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
traceId: data.traceId,
|
||||
};
|
||||
await db(this.TABLE).insert({
|
||||
id: task.id,
|
||||
tenant_id: task.tenantId,
|
||||
shop_id: task.shopId,
|
||||
task_type: task.taskType,
|
||||
task_name: task.taskName,
|
||||
status: task.status,
|
||||
priority: task.priority,
|
||||
progress: task.progress,
|
||||
input: JSON.stringify(task.input),
|
||||
assigned_to: task.assignedTo,
|
||||
trace_id: task.traceId,
|
||||
created_at: task.createdAt,
|
||||
updated_at: task.updatedAt,
|
||||
});
|
||||
await DomainEventBus.publish('task_center.created', { taskId: id, tenantId, traceId: data.traceId });
|
||||
return task;
|
||||
}
|
||||
|
||||
static async updateStatus(id: string, status: TaskCenterTask['status'], progress?: number, output?: Record<string, any>, error?: string): Promise<TaskCenterTask> {
|
||||
const updateData: Record<string, any> = {
|
||||
status,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
if (progress !== undefined) updateData.progress = progress;
|
||||
if (output) updateData.output = JSON.stringify(output);
|
||||
if (error) updateData.error = error;
|
||||
if (status === 'RUNNING') updateData.started_at = new Date().toISOString();
|
||||
if (status === 'COMPLETED' || status === 'FAILED') updateData.completed_at = new Date().toISOString();
|
||||
await db(this.TABLE).where({ id }).update(updateData);
|
||||
const task = await this.getById(id);
|
||||
await DomainEventBus.publish('task_center.status_updated', { taskId: id, status });
|
||||
return task!;
|
||||
}
|
||||
|
||||
static async cancel(id: string): Promise<void> {
|
||||
await db(this.TABLE).where({ id }).update({
|
||||
status: 'CANCELLED',
|
||||
completed_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
await DomainEventBus.publish('task_center.cancelled', { taskId: id });
|
||||
}
|
||||
|
||||
static async retry(id: string): Promise<TaskCenterTask> {
|
||||
const task = await this.getById(id);
|
||||
if (!task) throw new Error('Task not found');
|
||||
const newTask = await this.create(task.tenantId, {
|
||||
shopId: task.shopId,
|
||||
taskType: task.taskType,
|
||||
taskName: task.taskName,
|
||||
priority: task.priority,
|
||||
input: task.input,
|
||||
traceId: `${task.traceId}_retry_${Date.now()}`,
|
||||
});
|
||||
return newTask;
|
||||
}
|
||||
|
||||
static async getStats(tenantId: string): Promise<TaskStats> {
|
||||
const tasks = await this.list(tenantId);
|
||||
return {
|
||||
total: tasks.length,
|
||||
pending: tasks.filter((t) => t.status === 'PENDING').length,
|
||||
running: tasks.filter((t) => t.status === 'RUNNING').length,
|
||||
completed: tasks.filter((t) => t.status === 'COMPLETED').length,
|
||||
failed: tasks.filter((t) => t.status === 'FAILED').length,
|
||||
};
|
||||
}
|
||||
|
||||
static async getPendingTasks(tenantId: string, limit: number = 10): Promise<TaskCenterTask[]> {
|
||||
return db(this.TABLE)
|
||||
.where('tenant_id', tenantId)
|
||||
.where('status', 'PENDING')
|
||||
.orderBy('priority', 'desc')
|
||||
.orderBy('created_at', 'asc')
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
static async assignTask(id: string, assignedTo: string): Promise<void> {
|
||||
await db(this.TABLE).where({ id }).update({
|
||||
assigned_to: assignedTo,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
private static generateId(): string {
|
||||
return `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,8 @@ export class TaxReportService {
|
||||
table.increments('id').primary();
|
||||
table.string('tenant_id', 64).notNullable();
|
||||
table.string('month', 16).notNullable();
|
||||
table.float('total_vat').defaultTo(0);
|
||||
table.float('total_gst').defaultTo(0);
|
||||
table.decimal('total_vat', 15, 2).defaultTo(0);
|
||||
table.decimal('total_gst', 15, 2).defaultTo(0);
|
||||
table.integer('order_count').defaultTo(0);
|
||||
table.string('pdf_url', 255);
|
||||
table.timestamps(true, true);
|
||||
|
||||
345
server/src/services/UnifiedFulfillmentService.ts
Normal file
345
server/src/services/UnifiedFulfillmentService.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import db from '../config/database';
|
||||
import { DomainEventBus } from '../core/runtime/DomainEventBus';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface UnifiedOrder {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
platformOrderId: string;
|
||||
platform: string;
|
||||
customerId: string;
|
||||
items: OrderItem[];
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
status: 'pending' | 'confirmed' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
|
||||
shippingAddress: Record<string, any>;
|
||||
fulfillmentStatus: 'unfulfilled' | 'partial' | 'fulfilled';
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
productId: string;
|
||||
skuId: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
export interface FulfillmentRoute {
|
||||
orderId: string;
|
||||
warehouseId: string;
|
||||
priority: number;
|
||||
estimatedShipDate: Date;
|
||||
estimatedDeliveryDate: Date;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export interface FulfillmentStatus {
|
||||
orderId: string;
|
||||
status: 'pending' | 'picked' | 'packed' | 'shipped' | 'delivered';
|
||||
trackingNumber?: string;
|
||||
carrier?: string;
|
||||
events: FulfillmentEvent[];
|
||||
}
|
||||
|
||||
export interface FulfillmentEvent {
|
||||
timestamp: Date;
|
||||
status: string;
|
||||
location?: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class UnifiedFulfillmentService {
|
||||
private static ORDER_TABLE = 'cf_unified_order';
|
||||
private static ROUTE_TABLE = 'cf_fulfillment_route';
|
||||
private static STATUS_TABLE = 'cf_fulfillment_status';
|
||||
|
||||
static async initTables(): Promise<void> {
|
||||
await this.initOrderTable();
|
||||
await this.initRouteTable();
|
||||
await this.initStatusTable();
|
||||
}
|
||||
|
||||
private static async initOrderTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.ORDER_TABLE);
|
||||
if (!hasTable) {
|
||||
logger.info(`[UnifiedFulfillment] Creating ${this.ORDER_TABLE} table...`);
|
||||
await db.schema.createTable(this.ORDER_TABLE, (table) => {
|
||||
table.string('id', 36).primary();
|
||||
table.string('tenant_id', 36).notNullable().index();
|
||||
table.string('platform_order_id', 128).notNullable();
|
||||
table.string('platform', 32).notNullable();
|
||||
table.string('customer_id', 36).notNullable().index();
|
||||
table.json('items');
|
||||
table.decimal('total_amount', 10, 2).notNullable();
|
||||
table.string('currency', 3).defaultTo('USD');
|
||||
table.enum('status', ['pending', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled']).defaultTo('pending');
|
||||
table.json('shipping_address');
|
||||
table.enum('fulfillment_status', ['unfulfilled', 'partial', 'fulfilled']).defaultTo('unfulfilled');
|
||||
table.datetime('created_at').notNullable().defaultTo(db.fn.now());
|
||||
table.datetime('updated_at').notNullable().defaultTo(db.fn.now());
|
||||
table.unique(['tenant_id', 'platform_order_id'], 'uk_tenant_platform_order');
|
||||
table.index(['tenant_id', 'status'], 'idx_tenant_status');
|
||||
});
|
||||
logger.info(`[UnifiedFulfillment] Table ${this.ORDER_TABLE} created`);
|
||||
}
|
||||
}
|
||||
|
||||
private static async initRouteTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.ROUTE_TABLE);
|
||||
if (!hasTable) {
|
||||
logger.info(`[UnifiedFulfillment] Creating ${this.ROUTE_TABLE} table...`);
|
||||
await db.schema.createTable(this.ROUTE_TABLE, (table) => {
|
||||
table.string('id', 36).primary();
|
||||
table.string('tenant_id', 36).notNullable().index();
|
||||
table.string('order_id', 36).notNullable().index();
|
||||
table.string('warehouse_id', 36).notNullable();
|
||||
table.integer('priority').defaultTo(0);
|
||||
table.datetime('estimated_ship_date');
|
||||
table.datetime('estimated_delivery_date');
|
||||
table.decimal('cost', 10, 2);
|
||||
table.datetime('created_at').notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
logger.info(`[UnifiedFulfillment] Table ${this.ROUTE_TABLE} created`);
|
||||
}
|
||||
}
|
||||
|
||||
private static async initStatusTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.STATUS_TABLE);
|
||||
if (!hasTable) {
|
||||
logger.info(`[UnifiedFulfillment] Creating ${this.STATUS_TABLE} table...`);
|
||||
await db.schema.createTable(this.STATUS_TABLE, (table) => {
|
||||
table.string('id', 36).primary();
|
||||
table.string('tenant_id', 36).notNullable().index();
|
||||
table.string('order_id', 36).notNullable().index();
|
||||
table.enum('status', ['pending', 'picked', 'packed', 'shipped', 'delivered']).defaultTo('pending');
|
||||
table.string('tracking_number', 64);
|
||||
table.string('carrier', 64);
|
||||
table.json('events');
|
||||
table.datetime('created_at').notNullable().defaultTo(db.fn.now());
|
||||
table.datetime('updated_at').notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
logger.info(`[UnifiedFulfillment] Table ${this.STATUS_TABLE} created`);
|
||||
}
|
||||
}
|
||||
|
||||
static async unifyOrders(
|
||||
tenantId: string,
|
||||
platformOrders: Array<{
|
||||
platform: string;
|
||||
orderId: string;
|
||||
data: Record<string, any>;
|
||||
}>
|
||||
): Promise<UnifiedOrder[]> {
|
||||
const unified: UnifiedOrder[] = [];
|
||||
|
||||
for (const platformOrder of platformOrders) {
|
||||
const id = this.generateId();
|
||||
const order: UnifiedOrder = {
|
||||
id,
|
||||
tenantId,
|
||||
platformOrderId: platformOrder.orderId,
|
||||
platform: platformOrder.platform,
|
||||
customerId: platformOrder.data.customerId || `cust_${Date.now()}`,
|
||||
items: platformOrder.data.items || [],
|
||||
totalAmount: platformOrder.data.totalAmount || 0,
|
||||
currency: platformOrder.data.currency || 'USD',
|
||||
status: 'pending',
|
||||
shippingAddress: platformOrder.data.shippingAddress || {},
|
||||
fulfillmentStatus: 'unfulfilled',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await db(this.ORDER_TABLE).insert({
|
||||
id: order.id,
|
||||
tenant_id: order.tenantId,
|
||||
platform_order_id: order.platformOrderId,
|
||||
platform: order.platform,
|
||||
customer_id: order.customerId,
|
||||
items: JSON.stringify(order.items),
|
||||
total_amount: order.totalAmount,
|
||||
currency: order.currency,
|
||||
status: order.status,
|
||||
shipping_address: JSON.stringify(order.shippingAddress),
|
||||
fulfillment_status: order.fulfillmentStatus,
|
||||
created_at: order.createdAt,
|
||||
updated_at: order.updatedAt,
|
||||
});
|
||||
|
||||
unified.push(order);
|
||||
}
|
||||
|
||||
logger.info(`[UnifiedFulfillment] Unified ${unified.length} orders from ${platformOrders.length} platforms`);
|
||||
return unified;
|
||||
}
|
||||
|
||||
static async routeOrder(
|
||||
tenantId: string,
|
||||
orderId: string,
|
||||
inventoryData: Array<{ warehouseId: string; available: number }>
|
||||
): Promise<FulfillmentRoute> {
|
||||
const order = await db(this.ORDER_TABLE)
|
||||
.where({ tenant_id: tenantId, id: orderId })
|
||||
.first();
|
||||
|
||||
if (!order) {
|
||||
throw new Error(`Order ${orderId} not found`);
|
||||
}
|
||||
|
||||
const items: OrderItem[] = JSON.parse(order.items);
|
||||
const totalQuantity = items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
|
||||
let bestWarehouse = inventoryData[0]?.warehouseId || 'default_warehouse';
|
||||
let maxAvailable = 0;
|
||||
|
||||
for (const inv of inventoryData) {
|
||||
if (inv.available >= totalQuantity && inv.available > maxAvailable) {
|
||||
maxAvailable = inv.available;
|
||||
bestWarehouse = inv.warehouseId;
|
||||
}
|
||||
}
|
||||
|
||||
const route: FulfillmentRoute = {
|
||||
orderId,
|
||||
warehouseId: bestWarehouse,
|
||||
priority: 0,
|
||||
estimatedShipDate: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
estimatedDeliveryDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
cost: 5.99,
|
||||
};
|
||||
|
||||
await db(this.ROUTE_TABLE).insert({
|
||||
id: this.generateId(),
|
||||
tenant_id: tenantId,
|
||||
order_id: orderId,
|
||||
warehouse_id: route.warehouseId,
|
||||
priority: route.priority,
|
||||
estimated_ship_date: route.estimatedShipDate,
|
||||
estimated_delivery_date: route.estimatedDeliveryDate,
|
||||
cost: route.cost,
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
await DomainEventBus.publish({
|
||||
type: 'order.routed',
|
||||
tenantId,
|
||||
data: { orderId, warehouseId: bestWarehouse },
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
logger.info(`[UnifiedFulfillment] Routed order ${orderId} to warehouse ${bestWarehouse}`);
|
||||
return route;
|
||||
}
|
||||
|
||||
static async manageFulfillment(
|
||||
tenantId: string,
|
||||
orderId: string,
|
||||
action: 'pick' | 'pack' | 'ship' | 'deliver',
|
||||
params: Record<string, any>
|
||||
): Promise<FulfillmentStatus> {
|
||||
let status = await db(this.STATUS_TABLE)
|
||||
.where({ tenant_id: tenantId, order_id: orderId })
|
||||
.first();
|
||||
|
||||
const statusMap: Record<string, FulfillmentStatus['status']> = {
|
||||
pick: 'picked',
|
||||
pack: 'packed',
|
||||
ship: 'shipped',
|
||||
deliver: 'delivered',
|
||||
};
|
||||
|
||||
const newStatus = statusMap[action];
|
||||
const event: FulfillmentEvent = {
|
||||
timestamp: new Date(),
|
||||
status: newStatus,
|
||||
location: params.location,
|
||||
description: `Order ${action}ed`,
|
||||
};
|
||||
|
||||
if (status) {
|
||||
const events: FulfillmentEvent[] = status.events ? JSON.parse(status.events) : [];
|
||||
events.push(event);
|
||||
|
||||
await db(this.STATUS_TABLE)
|
||||
.where({ id: status.id })
|
||||
.update({
|
||||
status: newStatus,
|
||||
tracking_number: params.trackingNumber || status.tracking_number,
|
||||
carrier: params.carrier || status.carrier,
|
||||
events: JSON.stringify(events),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
} else {
|
||||
status = {
|
||||
id: this.generateId(),
|
||||
tenant_id: tenantId,
|
||||
order_id: orderId,
|
||||
status: newStatus,
|
||||
tracking_number: params.trackingNumber,
|
||||
carrier: params.carrier,
|
||||
events: JSON.stringify([event]),
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
} as any;
|
||||
|
||||
await db(this.STATUS_TABLE).insert(status);
|
||||
}
|
||||
|
||||
await db(this.ORDER_TABLE)
|
||||
.where({ tenant_id: tenantId, id: orderId })
|
||||
.update({
|
||||
status: newStatus === 'delivered' ? 'delivered' : 'processing',
|
||||
fulfillment_status: newStatus === 'delivered' ? 'fulfilled' : 'partial',
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
logger.info(`[UnifiedFulfillment] Updated fulfillment status for order ${orderId} to ${newStatus}`);
|
||||
return this.rowToStatus(status);
|
||||
}
|
||||
|
||||
static async syncStatus(
|
||||
tenantId: string,
|
||||
orderId: string,
|
||||
newStatus: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const order = await db(this.ORDER_TABLE)
|
||||
.where({ tenant_id: tenantId, id: orderId })
|
||||
.first();
|
||||
|
||||
if (!order) {
|
||||
return { success: false, message: 'Order not found' };
|
||||
}
|
||||
|
||||
await db(this.ORDER_TABLE)
|
||||
.where({ tenant_id: tenantId, id: orderId })
|
||||
.update({
|
||||
status: newStatus,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
await DomainEventBus.publish({
|
||||
type: 'order.status.synced',
|
||||
tenantId,
|
||||
data: { orderId, newStatus, platform: order.platform },
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
logger.info(`[UnifiedFulfillment] Synced order ${orderId} status to ${newStatus}`);
|
||||
return { success: true, message: `Status synced to ${newStatus}` };
|
||||
}
|
||||
|
||||
private static rowToStatus(row: any): FulfillmentStatus {
|
||||
return {
|
||||
orderId: row.order_id,
|
||||
status: row.status,
|
||||
trackingNumber: row.tracking_number,
|
||||
carrier: row.carrier,
|
||||
events: row.events ? JSON.parse(row.events) : [],
|
||||
};
|
||||
}
|
||||
|
||||
private static generateId(): string {
|
||||
return `ufo_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
193
server/src/services/WinNodeService.ts
Normal file
193
server/src/services/WinNodeService.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import db from '../config/database';
|
||||
import { DomainEventBus } from '../core/runtime/DomainEventBus';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface WinNode {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
status: 'ONLINE' | 'OFFLINE' | 'BUSY' | 'ERROR';
|
||||
shopId: string;
|
||||
shopName: string;
|
||||
profileDir: string;
|
||||
proxy: string;
|
||||
fingerprintPolicy: 'STANDARD' | 'STEALTH' | 'RANDOM' | 'CUSTOM';
|
||||
maxConcurrent: number;
|
||||
currentTasks: number;
|
||||
cpuUsage: number;
|
||||
memoryUsage: number;
|
||||
lastHeartbeat: string;
|
||||
autoRestart: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WinNodeHeartbeat {
|
||||
nodeId: string;
|
||||
cpuUsage: number;
|
||||
memoryUsage: number;
|
||||
currentTasks: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export class WinNodeService {
|
||||
private static TABLE = 'cf_win_node';
|
||||
|
||||
static async initTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.TABLE);
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(this.TABLE, (table) => {
|
||||
table.string('id', 64).primary();
|
||||
table.string('tenant_id', 64).notNullable();
|
||||
table.string('name', 128).notNullable();
|
||||
table.string('host', 64).notNullable();
|
||||
table.integer('port').notNullable();
|
||||
table.enum('status', ['ONLINE', 'OFFLINE', 'BUSY', 'ERROR']).defaultTo('OFFLINE');
|
||||
table.string('shop_id', 64);
|
||||
table.string('shop_name', 128);
|
||||
table.string('profile_dir', 256).notNullable();
|
||||
table.string('proxy', 256);
|
||||
table.enum('fingerprint_policy', ['STANDARD', 'STEALTH', 'RANDOM', 'CUSTOM']).defaultTo('STANDARD');
|
||||
table.integer('max_concurrent').defaultTo(3);
|
||||
table.integer('current_tasks').defaultTo(0);
|
||||
table.decimal('cpu_usage', 5, 2).defaultTo(0);
|
||||
table.decimal('memory_usage', 5, 2).defaultTo(0);
|
||||
table.timestamp('last_heartbeat');
|
||||
table.boolean('auto_restart').defaultTo(true);
|
||||
table.timestamps(true, true);
|
||||
table.index(['tenant_id', 'status']);
|
||||
table.index(['shop_id']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async list(tenantId: string, filters?: { status?: string; shopId?: string }): Promise<WinNode[]> {
|
||||
let query = db(this.TABLE).where('tenant_id', tenantId);
|
||||
if (filters?.status) query = query.where('status', filters.status);
|
||||
if (filters?.shopId) query = query.where('shop_id', filters.shopId);
|
||||
return query.orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
static async getById(id: string): Promise<WinNode | null> {
|
||||
const node = await db(this.TABLE).where({ id }).first();
|
||||
return node || null;
|
||||
}
|
||||
|
||||
static async create(tenantId: string, data: Omit<WinNode, 'id' | 'tenantId' | 'createdAt' | 'updatedAt' | 'currentTasks' | 'cpuUsage' | 'memoryUsage' | 'lastHeartbeat'>): Promise<WinNode> {
|
||||
const id = this.generateId();
|
||||
const now = new Date().toISOString();
|
||||
const node: WinNode = {
|
||||
id,
|
||||
tenantId,
|
||||
...data,
|
||||
currentTasks: 0,
|
||||
cpuUsage: 0,
|
||||
memoryUsage: 0,
|
||||
lastHeartbeat: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await db(this.TABLE).insert({
|
||||
id: node.id,
|
||||
tenant_id: node.tenantId,
|
||||
name: node.name,
|
||||
host: node.host,
|
||||
port: node.port,
|
||||
status: node.status,
|
||||
shop_id: node.shopId,
|
||||
shop_name: node.shopName,
|
||||
profile_dir: node.profileDir,
|
||||
proxy: node.proxy,
|
||||
fingerprint_policy: node.fingerprintPolicy,
|
||||
max_concurrent: node.maxConcurrent,
|
||||
current_tasks: node.currentTasks,
|
||||
cpu_usage: node.cpuUsage,
|
||||
memory_usage: node.memoryUsage,
|
||||
last_heartbeat: node.lastHeartbeat,
|
||||
auto_restart: node.autoRestart,
|
||||
created_at: node.createdAt,
|
||||
updated_at: node.updatedAt,
|
||||
});
|
||||
await DomainEventBus.publish('win_node.created', { nodeId: id, tenantId });
|
||||
return node;
|
||||
}
|
||||
|
||||
static async update(id: string, data: Partial<WinNode>): Promise<WinNode> {
|
||||
const updateData: Record<string, any> = { updated_at: new Date().toISOString() };
|
||||
if (data.name) updateData.name = data.name;
|
||||
if (data.host) updateData.host = data.host;
|
||||
if (data.port) updateData.port = data.port;
|
||||
if (data.status) updateData.status = data.status;
|
||||
if (data.shopId !== undefined) updateData.shop_id = data.shopId;
|
||||
if (data.shopName) updateData.shop_name = data.shopName;
|
||||
if (data.profileDir) updateData.profile_dir = data.profileDir;
|
||||
if (data.proxy) updateData.proxy = data.proxy;
|
||||
if (data.fingerprintPolicy) updateData.fingerprint_policy = data.fingerprintPolicy;
|
||||
if (data.maxConcurrent !== undefined) updateData.max_concurrent = data.maxConcurrent;
|
||||
if (data.autoRestart !== undefined) updateData.auto_restart = data.autoRestart;
|
||||
await db(this.TABLE).where({ id }).update(updateData);
|
||||
const node = await this.getById(id);
|
||||
await DomainEventBus.publish('win_node.updated', { nodeId: id });
|
||||
return node!;
|
||||
}
|
||||
|
||||
static async delete(id: string): Promise<void> {
|
||||
await db(this.TABLE).where({ id }).delete();
|
||||
await DomainEventBus.publish('win_node.deleted', { nodeId: id });
|
||||
}
|
||||
|
||||
static async heartbeat(nodeId: string, data: WinNodeHeartbeat): Promise<void> {
|
||||
await db(this.TABLE).where({ id: nodeId }).update({
|
||||
cpu_usage: data.cpuUsage,
|
||||
memory_usage: data.memoryUsage,
|
||||
current_tasks: data.currentTasks,
|
||||
last_heartbeat: data.timestamp,
|
||||
status: data.currentTasks > 0 ? 'BUSY' : 'ONLINE',
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
static async testConnection(id: string): Promise<{ success: boolean; message: string }> {
|
||||
const node = await this.getById(id);
|
||||
if (!node) return { success: false, message: 'Node not found' };
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
const response = await fetch(`http://${node.host}:${node.port}/json/version`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
if (response.ok) {
|
||||
await db(this.TABLE).where({ id }).update({ status: 'ONLINE', updated_at: new Date().toISOString() });
|
||||
return { success: true, message: 'Connection successful' };
|
||||
}
|
||||
return { success: false, message: `HTTP ${response.status}` };
|
||||
} catch (error: any) {
|
||||
await db(this.TABLE).where({ id }).update({ status: 'ERROR', updated_at: new Date().toISOString() });
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
static async restart(id: string): Promise<{ success: boolean; message: string }> {
|
||||
const node = await this.getById(id);
|
||||
if (!node) return { success: false, message: 'Node not found' };
|
||||
await DomainEventBus.publish('win_node.restart_requested', { nodeId: id, shopId: node.shopId });
|
||||
return { success: true, message: 'Restart command sent' };
|
||||
}
|
||||
|
||||
static async getStats(tenantId: string): Promise<{ total: number; online: number; busy: number; offline: number }> {
|
||||
const nodes = await this.list(tenantId);
|
||||
return {
|
||||
total: nodes.length,
|
||||
online: nodes.filter((n) => n.status === 'ONLINE').length,
|
||||
busy: nodes.filter((n) => n.status === 'BUSY').length,
|
||||
offline: nodes.filter((n) => n.status === 'OFFLINE' || n.status === 'ERROR').length,
|
||||
};
|
||||
}
|
||||
|
||||
private static generateId(): string {
|
||||
return `wn_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user