feat: 新增多模块功能与服务实现
新增广告计划、用户资产、B2B交易、合规规则等核心模型 实现爬虫工作器、贸易服务、现金流预测等业务服务 添加RBAC权限测试、压力测试等测试用例 完善扩展程序的消息处理与内容脚本功能 重构应用入口与文档生成器 更新项目规则与业务闭环分析文档
This commit is contained in:
338
extension/src/background/ABTestOptimizationService.ts
Normal file
338
extension/src/background/ABTestOptimizationService.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { Logger } from '../utils/Logger';
|
||||
|
||||
export interface TestResult {
|
||||
testId: string;
|
||||
variations: VariationResult[];
|
||||
metrics: {
|
||||
[metric: string]: number;
|
||||
};
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
sampleSize: number;
|
||||
statisticalSignificance: number;
|
||||
}
|
||||
|
||||
export interface VariationResult {
|
||||
id: string;
|
||||
name: string;
|
||||
metrics: {
|
||||
[metric: string]: number;
|
||||
};
|
||||
sampleSize: number;
|
||||
conversionRate?: number;
|
||||
engagementRate?: number;
|
||||
revenuePerUser?: number;
|
||||
retentionRate?: number;
|
||||
}
|
||||
|
||||
export interface OptimizationGoal {
|
||||
type: 'maximize' | 'minimize';
|
||||
metric: string;
|
||||
targetValue?: number;
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
export interface OptimizationRecommendation {
|
||||
recommendedVariation: string;
|
||||
confidence: number;
|
||||
expectedImprovement: number;
|
||||
optimizationActions: OptimizationAction[];
|
||||
riskAssessment: 'low' | 'medium' | 'high';
|
||||
implementationSteps: string[];
|
||||
followUpTests: FollowUpTest[];
|
||||
}
|
||||
|
||||
export interface OptimizationAction {
|
||||
id: string;
|
||||
description: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
expectedImpact: number;
|
||||
implementationEffort: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
export interface FollowUpTest {
|
||||
testName: string;
|
||||
description: string;
|
||||
recommendedTiming: string;
|
||||
estimatedDuration: number;
|
||||
}
|
||||
|
||||
export class ABTestOptimizationService {
|
||||
private logger = new Logger('ABTestOptimizationService');
|
||||
|
||||
async optimizeTest(
|
||||
testResult: TestResult,
|
||||
optimizationGoals: OptimizationGoal[],
|
||||
traceId: string
|
||||
): Promise<OptimizationRecommendation> {
|
||||
try {
|
||||
this.logger.info('开始优化A/B测试', {
|
||||
testId: testResult.testId,
|
||||
variationCount: testResult.variations.length,
|
||||
traceId
|
||||
});
|
||||
|
||||
const bestVariation = this.identifyBestVariation(testResult, optimizationGoals);
|
||||
const confidence = this.calculateConfidence(testResult, bestVariation.id);
|
||||
const expectedImprovement = this.calculateExpectedImprovement(testResult, bestVariation.id);
|
||||
const optimizationActions = this.generateOptimizationActions(testResult, bestVariation, optimizationGoals);
|
||||
const risk = this.assessRisk(testResult, bestVariation, optimizationGoals);
|
||||
const implementationSteps = this.generateImplementationSteps(optimizationActions);
|
||||
const followUpTests = this.recommendFollowUpTests(testResult, bestVariation, optimizationGoals);
|
||||
|
||||
const recommendation: OptimizationRecommendation = {
|
||||
recommendedVariation: bestVariation.id,
|
||||
confidence,
|
||||
expectedImprovement,
|
||||
optimizationActions,
|
||||
riskAssessment: risk,
|
||||
implementationSteps,
|
||||
followUpTests
|
||||
};
|
||||
|
||||
this.logger.info('A/B测试优化完成', {
|
||||
recommendedVariation: bestVariation.id,
|
||||
confidence,
|
||||
expectedImprovement,
|
||||
traceId
|
||||
});
|
||||
|
||||
return recommendation;
|
||||
} catch (error) {
|
||||
this.logger.error('优化A/B测试失败', {
|
||||
error: error instanceof Error ? error.message : '未知错误',
|
||||
traceId
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private identifyBestVariation(testResult: TestResult, optimizationGoals: OptimizationGoal[]): VariationResult {
|
||||
let bestVariation: VariationResult = testResult.variations[0];
|
||||
let bestScore = -Infinity;
|
||||
|
||||
testResult.variations.forEach(variation => {
|
||||
let score = 0;
|
||||
|
||||
optimizationGoals.forEach(goal => {
|
||||
const value = variation.metrics[goal.metric] || 0;
|
||||
const weight = goal.weight || 1;
|
||||
|
||||
if (goal.type === 'maximize') {
|
||||
score += value * weight;
|
||||
} else {
|
||||
score += (1 / (value + 0.001)) * weight;
|
||||
}
|
||||
});
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestVariation = variation;
|
||||
}
|
||||
});
|
||||
|
||||
return bestVariation;
|
||||
}
|
||||
|
||||
private calculateConfidence(testResult: TestResult, bestVariationId: string): number {
|
||||
const bestVariation = testResult.variations.find(v => v.id === bestVariationId);
|
||||
if (!bestVariation) return 0;
|
||||
|
||||
const otherVariations = testResult.variations.filter(v => v.id !== bestVariationId);
|
||||
const significantWins = otherVariations.filter(other => {
|
||||
return this.isStatisticallySignificant(bestVariation!, other);
|
||||
});
|
||||
|
||||
return Math.min(1, significantWins.length / otherVariations.length * 0.8 + 0.2);
|
||||
}
|
||||
|
||||
private calculateExpectedImprovement(testResult: TestResult, bestVariationId: string): number {
|
||||
const bestVariation = testResult.variations.find(v => v.id === bestVariationId);
|
||||
if (!bestVariation) return 0;
|
||||
|
||||
const otherVariations = testResult.variations.filter(v => v.id !== bestVariationId);
|
||||
if (otherVariations.length === 0) return 0;
|
||||
|
||||
const averagePerformance = otherVariations.reduce((sum, v) => {
|
||||
const metricValues = Object.values(v.metrics);
|
||||
return sum + metricValues.reduce((sum, val) => sum + val, 0) / metricValues.length;
|
||||
}, 0) / otherVariations.length;
|
||||
|
||||
const bestPerformance = Object.values(bestVariation.metrics).reduce((sum, val) => sum + val, 0) / Object.values(bestVariation.metrics).length;
|
||||
|
||||
return (bestPerformance - averagePerformance) / averagePerformance;
|
||||
}
|
||||
|
||||
private generateOptimizationActions(
|
||||
testResult: TestResult,
|
||||
bestVariation: VariationResult,
|
||||
optimizationGoals: OptimizationGoal[]
|
||||
): OptimizationAction[] {
|
||||
const actions: OptimizationAction[] = [];
|
||||
|
||||
optimizationGoals.forEach(goal => {
|
||||
const bestValue = bestVariation.metrics[goal.metric];
|
||||
if (bestValue === undefined) return;
|
||||
|
||||
const otherVariations = testResult.variations.filter(v => v.id !== bestVariation.id);
|
||||
const averageValue = otherVariations.reduce((sum, v) => sum + (v.metrics[goal.metric] || 0), 0) / otherVariations.length;
|
||||
const improvement = (bestValue - averageValue) / averageValue;
|
||||
|
||||
if (Math.abs(improvement) > 0.05) {
|
||||
actions.push({
|
||||
id: `action_${goal.metric}`,
|
||||
description: `优化 ${goal.metric} 指标,采用 ${bestVariation.name} 的策略`,
|
||||
priority: Math.abs(improvement) > 0.15 ? 'high' : Math.abs(improvement) > 0.08 ? 'medium' : 'low',
|
||||
expectedImpact: Math.abs(improvement),
|
||||
implementationEffort: Math.abs(improvement) > 0.15 ? 'medium' : 'low'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (actions.length === 0) {
|
||||
actions.push({
|
||||
id: 'action_no_change',
|
||||
description: '保持当前策略,继续监控性能',
|
||||
priority: 'low',
|
||||
expectedImpact: 0,
|
||||
implementationEffort: 'low'
|
||||
});
|
||||
}
|
||||
|
||||
return actions.sort((a, b) => {
|
||||
const priorityOrder = { high: 3, medium: 2, low: 1 };
|
||||
return priorityOrder[b.priority] - priorityOrder[a.priority];
|
||||
});
|
||||
}
|
||||
|
||||
private assessRisk(
|
||||
testResult: TestResult,
|
||||
bestVariation: VariationResult,
|
||||
optimizationGoals: OptimizationGoal[]
|
||||
): 'low' | 'medium' | 'high' {
|
||||
let riskScore = 0;
|
||||
|
||||
if (testResult.sampleSize < 500) riskScore += 2;
|
||||
else if (testResult.sampleSize < 1000) riskScore += 1;
|
||||
|
||||
if (testResult.statisticalSignificance > 0.1) riskScore += 2;
|
||||
else if (testResult.statisticalSignificance > 0.05) riskScore += 1;
|
||||
|
||||
const otherVariations = testResult.variations.filter(v => v.id !== bestVariation.id);
|
||||
const closeCompetitors = otherVariations.filter(other => {
|
||||
const bestScore = Object.values(bestVariation.metrics).reduce((sum, val) => sum + val, 0);
|
||||
const otherScore = Object.values(other.metrics).reduce((sum, val) => sum + val, 0);
|
||||
return (otherScore / bestScore) > 0.95;
|
||||
});
|
||||
|
||||
if (closeCompetitors.length > 0) riskScore += 1;
|
||||
|
||||
const hasRevenueGoal = optimizationGoals.some(goal => goal.metric.includes('revenue'));
|
||||
if (hasRevenueGoal) riskScore += 1;
|
||||
|
||||
if (riskScore >= 5) return 'high';
|
||||
if (riskScore >= 3) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
private generateImplementationSteps(actions: OptimizationAction[]): string[] {
|
||||
const steps: string[] = [];
|
||||
|
||||
steps.push('1. 审核优化建议,确认推荐的变体');
|
||||
steps.push('2. 制定实施计划,包括时间线和负责人');
|
||||
|
||||
actions.forEach((action, index) => {
|
||||
steps.push(`${index + 3}. 实施 ${action.description}(优先级:${action.priority})`);
|
||||
});
|
||||
|
||||
steps.push(`${actions.length + 3}. 监控实施后的性能指标`);
|
||||
steps.push(`${actions.length + 4}. 记录实施结果,用于后续分析`);
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
private recommendFollowUpTests(
|
||||
testResult: TestResult,
|
||||
bestVariation: VariationResult,
|
||||
optimizationGoals: OptimizationGoal[]
|
||||
): FollowUpTest[] {
|
||||
const tests: FollowUpTest[] = [];
|
||||
|
||||
if (optimizationGoals.some(goal => goal.type === 'maximize' && goal.metric.includes('conversion'))) {
|
||||
tests.push({
|
||||
testName: '转化率优化深度测试',
|
||||
description: '进一步优化已识别的高转化率策略',
|
||||
recommendedTiming: '2周后',
|
||||
estimatedDuration: 7
|
||||
});
|
||||
}
|
||||
|
||||
if (optimizationGoals.some(goal => goal.type === 'maximize' && goal.metric.includes('revenue'))) {
|
||||
tests.push({
|
||||
testName: '收入优化扩展测试',
|
||||
description: '测试不同定价策略和促销组合',
|
||||
recommendedTiming: '3周后',
|
||||
estimatedDuration: 14
|
||||
});
|
||||
}
|
||||
|
||||
if (tests.length === 0) {
|
||||
tests.push({
|
||||
testName: '性能监控测试',
|
||||
description: '持续监控实施后的性能变化',
|
||||
recommendedTiming: '1周后',
|
||||
estimatedDuration: 7
|
||||
});
|
||||
}
|
||||
|
||||
return tests;
|
||||
}
|
||||
|
||||
private isStatisticallySignificant(variationA: VariationResult, variationB: VariationResult): boolean {
|
||||
const metricsA = Object.values(variationA.metrics);
|
||||
const metricsB = Object.values(variationB.metrics);
|
||||
|
||||
if (metricsA.length === 0 || metricsB.length === 0) return false;
|
||||
|
||||
const meanA = metricsA.reduce((sum, val) => sum + val, 0) / metricsA.length;
|
||||
const meanB = metricsB.reduce((sum, val) => sum + val, 0) / metricsB.length;
|
||||
|
||||
const varianceA = metricsA.reduce((sum, val) => sum + Math.pow(val - meanA, 2), 0) / metricsA.length;
|
||||
const varianceB = metricsB.reduce((sum, val) => sum + Math.pow(val - meanB, 2), 0) / metricsB.length;
|
||||
|
||||
const standardError = Math.sqrt(varianceA / metricsA.length + varianceB / metricsB.length);
|
||||
const zScore = Math.abs(meanA - meanB) / standardError;
|
||||
|
||||
return zScore > 1.96; // 95% confidence
|
||||
}
|
||||
|
||||
async validateOptimization(recommendation: OptimizationRecommendation, traceId: string): Promise<{ valid: boolean; warnings: string[] }> {
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (recommendation.confidence < 0.7) {
|
||||
warnings.push('置信度较低,建议增加样本量或延长测试时间');
|
||||
}
|
||||
|
||||
if (recommendation.expectedImprovement < 0.05) {
|
||||
warnings.push('预期改进较小,可能不值得实施');
|
||||
}
|
||||
|
||||
if (recommendation.riskAssessment === 'high') {
|
||||
warnings.push('风险评估为高,建议谨慎实施');
|
||||
}
|
||||
|
||||
if (recommendation.optimizationActions.length === 0) {
|
||||
warnings.push('未生成优化行动,建议重新评估测试结果');
|
||||
}
|
||||
|
||||
this.logger.info('优化建议验证完成', {
|
||||
warningCount: warnings.length,
|
||||
traceId
|
||||
});
|
||||
|
||||
return {
|
||||
valid: warnings.length < 3,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
}
|
||||
314
extension/src/background/ABTestStrategyService.ts
Normal file
314
extension/src/background/ABTestStrategyService.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { Logger } from '../utils/Logger';
|
||||
|
||||
export interface TestGoal {
|
||||
type: 'conversion' | 'engagement' | 'revenue' | 'retention';
|
||||
metric: string;
|
||||
targetValue?: number;
|
||||
timeFrame?: string;
|
||||
}
|
||||
|
||||
export interface TestData {
|
||||
historicalData?: {
|
||||
[key: string]: number;
|
||||
};
|
||||
audienceSize?: number;
|
||||
trafficDistribution?: number;
|
||||
currentPerformance?: {
|
||||
[key: string]: number;
|
||||
};
|
||||
constraints?: {
|
||||
maxDuration?: number;
|
||||
maxTraffic?: number;
|
||||
budget?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StrategyRecommendation {
|
||||
testType: 'A/B' | 'A/B/n' | 'Multivariate';
|
||||
sampleSize: number;
|
||||
testDuration: number;
|
||||
trafficAllocation: number;
|
||||
successMetric: string;
|
||||
statisticalSignificance: number;
|
||||
confidenceLevel: number;
|
||||
recommendedVariations: Variation[];
|
||||
estimatedImpact: {
|
||||
bestCase: number;
|
||||
worstCase: number;
|
||||
expected: number;
|
||||
};
|
||||
riskAssessment: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
export interface Variation {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
changes: {
|
||||
[key: string]: any;
|
||||
};
|
||||
estimatedPerformance: number;
|
||||
}
|
||||
|
||||
export class ABTestStrategyService {
|
||||
private logger = new Logger('ABTestStrategyService');
|
||||
|
||||
async generateStrategy(
|
||||
testGoal: TestGoal,
|
||||
testData: TestData,
|
||||
traceId: string
|
||||
): Promise<StrategyRecommendation> {
|
||||
try {
|
||||
this.logger.info('开始生成A/B测试策略', {
|
||||
testGoal: testGoal.type,
|
||||
traceId
|
||||
});
|
||||
|
||||
const baseStrategy = this.calculateBaseStrategy(testGoal, testData);
|
||||
const variations = this.generateRecommendedVariations(testGoal, testData);
|
||||
const impact = this.estimateImpact(testGoal, testData, variations);
|
||||
const risk = this.assessRisk(testGoal, testData, baseStrategy);
|
||||
|
||||
const strategy: StrategyRecommendation = {
|
||||
...baseStrategy,
|
||||
recommendedVariations: variations,
|
||||
estimatedImpact: impact,
|
||||
riskAssessment: risk
|
||||
};
|
||||
|
||||
this.logger.info('A/B测试策略生成完成', {
|
||||
testType: strategy.testType,
|
||||
sampleSize: strategy.sampleSize,
|
||||
traceId
|
||||
});
|
||||
|
||||
return strategy;
|
||||
} catch (error) {
|
||||
this.logger.error('生成A/B测试策略失败', {
|
||||
error: error instanceof Error ? error.message : '未知错误',
|
||||
traceId
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private calculateBaseStrategy(testGoal: TestGoal, testData: TestData): Omit<StrategyRecommendation, 'recommendedVariations' | 'estimatedImpact' | 'riskAssessment'> {
|
||||
const audienceSize = testData.audienceSize || 10000;
|
||||
const trafficAllocation = testData.trafficDistribution || 0.5;
|
||||
|
||||
let sampleSize: number;
|
||||
let testDuration: number;
|
||||
let testType: 'A/B' | 'A/B/n' | 'Multivariate';
|
||||
|
||||
switch (testGoal.type) {
|
||||
case 'conversion':
|
||||
sampleSize = this.calculateSampleSize(0.05, 0.8, 0.1, 0.15);
|
||||
testDuration = Math.ceil(sampleSize / (audienceSize * trafficAllocation / 24));
|
||||
testType = 'A/B';
|
||||
break;
|
||||
case 'engagement':
|
||||
sampleSize = this.calculateSampleSize(0.05, 0.8, 0.15, 0.2);
|
||||
testDuration = Math.ceil(sampleSize / (audienceSize * trafficAllocation / 24));
|
||||
testType = 'A/B/n';
|
||||
break;
|
||||
case 'revenue':
|
||||
sampleSize = this.calculateSampleSize(0.05, 0.9, 0.05, 0.1);
|
||||
testDuration = Math.ceil(sampleSize / (audienceSize * trafficAllocation / 24));
|
||||
testType = 'A/B';
|
||||
break;
|
||||
case 'retention':
|
||||
sampleSize = this.calculateSampleSize(0.05, 0.8, 0.1, 0.15);
|
||||
testDuration = Math.max(7, Math.ceil(sampleSize / (audienceSize * trafficAllocation / 24)));
|
||||
testType = 'A/B';
|
||||
break;
|
||||
default:
|
||||
sampleSize = 1000;
|
||||
testDuration = 7;
|
||||
testType = 'A/B';
|
||||
}
|
||||
|
||||
if (testData.constraints?.maxDuration) {
|
||||
testDuration = Math.min(testDuration, testData.constraints.maxDuration);
|
||||
}
|
||||
|
||||
if (testData.constraints?.maxTraffic) {
|
||||
sampleSize = Math.min(sampleSize, Math.floor(audienceSize * testData.constraints.maxTraffic));
|
||||
}
|
||||
|
||||
return {
|
||||
testType,
|
||||
sampleSize,
|
||||
testDuration,
|
||||
trafficAllocation,
|
||||
successMetric: testGoal.metric,
|
||||
statisticalSignificance: 0.05,
|
||||
confidenceLevel: 0.8
|
||||
};
|
||||
}
|
||||
|
||||
private generateRecommendedVariations(testGoal: TestGoal, testData: TestData): Variation[] {
|
||||
const variations: Variation[] = [];
|
||||
|
||||
switch (testGoal.type) {
|
||||
case 'conversion':
|
||||
variations.push(
|
||||
{
|
||||
id: 'v1',
|
||||
name: '按钮颜色测试',
|
||||
description: '测试不同按钮颜色对转化率的影响',
|
||||
changes: { buttonColor: '#FF6B6B' },
|
||||
estimatedPerformance: 0.12
|
||||
},
|
||||
{
|
||||
id: 'v2',
|
||||
name: '按钮位置测试',
|
||||
description: '测试按钮位置对转化率的影响',
|
||||
changes: { buttonPosition: 'top' },
|
||||
estimatedPerformance: 0.11
|
||||
}
|
||||
);
|
||||
break;
|
||||
case 'engagement':
|
||||
variations.push(
|
||||
{
|
||||
id: 'v1',
|
||||
name: '内容布局测试',
|
||||
description: '测试不同内容布局对用户 engagement 的影响',
|
||||
changes: { layout: 'grid' },
|
||||
estimatedPerformance: 0.18
|
||||
},
|
||||
{
|
||||
id: 'v2',
|
||||
name: '交互元素测试',
|
||||
description: '测试添加交互元素对用户 engagement 的影响',
|
||||
changes: { interactiveElements: true },
|
||||
estimatedPerformance: 0.16
|
||||
},
|
||||
{
|
||||
id: 'v3',
|
||||
name: '内容长度测试',
|
||||
description: '测试不同内容长度对用户 engagement 的影响',
|
||||
changes: { contentLength: 'short' },
|
||||
estimatedPerformance: 0.15
|
||||
}
|
||||
);
|
||||
break;
|
||||
case 'revenue':
|
||||
variations.push(
|
||||
{
|
||||
id: 'v1',
|
||||
name: '定价策略测试',
|
||||
description: '测试不同定价策略对 revenue 的影响',
|
||||
changes: { pricingStrategy: 'dynamic' },
|
||||
estimatedPerformance: 0.08
|
||||
},
|
||||
{
|
||||
id: 'v2',
|
||||
name: '促销策略测试',
|
||||
description: '测试不同促销策略对 revenue 的影响',
|
||||
changes: { promotionStrategy: 'bundle' },
|
||||
estimatedPerformance: 0.07
|
||||
}
|
||||
);
|
||||
break;
|
||||
case 'retention':
|
||||
variations.push(
|
||||
{
|
||||
id: 'v1',
|
||||
name: '个性化推荐测试',
|
||||
description: '测试个性化推荐对用户 retention 的影响',
|
||||
changes: { personalization: true },
|
||||
estimatedPerformance: 0.13
|
||||
},
|
||||
{
|
||||
id: 'v2',
|
||||
name: '通知策略测试',
|
||||
description: '测试不同通知策略对用户 retention 的影响',
|
||||
changes: { notificationStrategy: 'timely' },
|
||||
estimatedPerformance: 0.12
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return variations;
|
||||
}
|
||||
|
||||
private estimateImpact(testGoal: TestGoal, testData: TestData, variations: Variation[]): StrategyRecommendation['estimatedImpact'] {
|
||||
const currentValue = testData.currentPerformance?.[testGoal.metric] || 0.1;
|
||||
const bestVariation = Math.max(...variations.map(v => v.estimatedPerformance));
|
||||
const worstVariation = Math.min(...variations.map(v => v.estimatedPerformance));
|
||||
const averageVariation = variations.reduce((sum, v) => sum + v.estimatedPerformance, 0) / variations.length;
|
||||
|
||||
return {
|
||||
bestCase: bestVariation - currentValue,
|
||||
worstCase: worstVariation - currentValue,
|
||||
expected: averageVariation - currentValue
|
||||
};
|
||||
}
|
||||
|
||||
private assessRisk(testGoal: TestGoal, testData: TestData, strategy: Omit<StrategyRecommendation, 'recommendedVariations' | 'estimatedImpact' | 'riskAssessment'>): 'low' | 'medium' | 'high' {
|
||||
let riskScore = 0;
|
||||
|
||||
if (strategy.trafficAllocation > 0.8) riskScore += 2;
|
||||
else if (strategy.trafficAllocation > 0.5) riskScore += 1;
|
||||
|
||||
if (strategy.testDuration < 3) riskScore += 2;
|
||||
else if (strategy.testDuration < 7) riskScore += 1;
|
||||
|
||||
if (testGoal.type === 'revenue') riskScore += 2;
|
||||
else if (testGoal.type === 'retention') riskScore += 1;
|
||||
|
||||
if (testData.constraints?.budget && testData.constraints.budget < 1000) riskScore += 1;
|
||||
|
||||
if (riskScore >= 5) return 'high';
|
||||
if (riskScore >= 3) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
private calculateSampleSize(alpha: number, power: number, baseline: number, minimumDetectableEffect: number): number {
|
||||
const zAlpha = 1.96; // 95% confidence
|
||||
const zBeta = 0.84; // 80% power
|
||||
const p1 = baseline;
|
||||
const p2 = baseline + minimumDetectableEffect;
|
||||
const p = (p1 + p2) / 2;
|
||||
|
||||
const sampleSize = (2 * p * (1 - p) * Math.pow(zAlpha + zBeta, 2)) / Math.pow(p2 - p1, 2);
|
||||
return Math.ceil(sampleSize);
|
||||
}
|
||||
|
||||
async validateStrategy(strategy: StrategyRecommendation, traceId: string): Promise<{ valid: boolean; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (strategy.sampleSize < 100) {
|
||||
errors.push('样本量过小,可能导致结果不可靠');
|
||||
}
|
||||
|
||||
if (strategy.testDuration < 1) {
|
||||
errors.push('测试 duration 必须大于 0');
|
||||
}
|
||||
|
||||
if (strategy.trafficAllocation < 0.1) {
|
||||
errors.push('流量分配过低,可能导致测试时间过长');
|
||||
}
|
||||
|
||||
if (strategy.trafficAllocation > 1) {
|
||||
errors.push('流量分配不能超过 100%');
|
||||
}
|
||||
|
||||
if (strategy.recommendedVariations.length === 0) {
|
||||
errors.push('至少需要一个测试变体');
|
||||
}
|
||||
|
||||
this.logger.info('策略验证完成', {
|
||||
valid: errors.length === 0,
|
||||
errorCount: errors.length,
|
||||
traceId
|
||||
});
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
}
|
||||
431
extension/src/background/AutoShipService.ts
Normal file
431
extension/src/background/AutoShipService.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import { Logger } from '../utils/Logger';
|
||||
import { FingerprintManager } from './FingerprintManager';
|
||||
|
||||
interface ShipInfo {
|
||||
orderId: string;
|
||||
platform: string;
|
||||
shopId: string;
|
||||
trackingNumber: string;
|
||||
carrier: string;
|
||||
items: Array<{
|
||||
productId: string;
|
||||
skuId: string;
|
||||
quantity: number;
|
||||
}>;
|
||||
shippingAddress: {
|
||||
name: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zipCode: string;
|
||||
country: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ShipResult {
|
||||
success: boolean;
|
||||
orderId: string;
|
||||
trackingNumber?: string;
|
||||
carrier?: string;
|
||||
status: 'shipped' | 'failed' | 'pending';
|
||||
message: string;
|
||||
timestamp: string;
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
interface ShipTask {
|
||||
taskId: string;
|
||||
orderId: string;
|
||||
shopId: string;
|
||||
platform: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
retryCount: number;
|
||||
maxRetries: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
export class AutoShipService {
|
||||
private logger = new Logger('AutoShipService');
|
||||
private fingerprintManager: FingerprintManager;
|
||||
private tasks: Map<string, ShipTask> = new Map();
|
||||
private readonly MAX_RETRIES = 3;
|
||||
private readonly RETRY_DELAY_MS = 5000;
|
||||
|
||||
constructor(fingerprintManager: FingerprintManager) {
|
||||
this.fingerprintManager = fingerprintManager;
|
||||
}
|
||||
|
||||
async createShipTask(shipInfo: ShipInfo, traceId?: string): Promise<ShipTask> {
|
||||
const tid = traceId || this.generateTraceId();
|
||||
const taskId = `SHIP-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
this.logger.info('Creating ship task', {
|
||||
taskId,
|
||||
orderId: shipInfo.orderId,
|
||||
platform: shipInfo.platform,
|
||||
traceId: tid,
|
||||
});
|
||||
|
||||
const task: ShipTask = {
|
||||
taskId,
|
||||
orderId: shipInfo.orderId,
|
||||
shopId: shipInfo.shopId,
|
||||
platform: shipInfo.platform,
|
||||
status: 'pending',
|
||||
retryCount: 0,
|
||||
maxRetries: this.MAX_RETRIES,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
traceId: tid,
|
||||
};
|
||||
|
||||
this.tasks.set(taskId, task);
|
||||
|
||||
await this.reportTaskCreated(task);
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
async processShipTask(taskId: string, shipInfo: ShipInfo): Promise<ShipResult> {
|
||||
const task = this.tasks.get(taskId);
|
||||
if (!task) {
|
||||
throw new Error(`Task not found: ${taskId}`);
|
||||
}
|
||||
|
||||
task.status = 'processing';
|
||||
task.updatedAt = new Date().toISOString();
|
||||
|
||||
this.logger.info('Processing ship task', {
|
||||
taskId,
|
||||
orderId: task.orderId,
|
||||
platform: task.platform,
|
||||
traceId: task.traceId,
|
||||
});
|
||||
|
||||
try {
|
||||
const context = await this.fingerprintManager.createIsolatedContext(
|
||||
task.shopId,
|
||||
undefined,
|
||||
task.traceId
|
||||
);
|
||||
|
||||
if (!context.success || !context.context) {
|
||||
throw new Error(`Failed to create isolated context: ${context.error}`);
|
||||
}
|
||||
|
||||
const result = await this.executeShipOperation(task, shipInfo, context.context);
|
||||
|
||||
if (result.success) {
|
||||
task.status = 'completed';
|
||||
this.logger.info('Ship task completed', {
|
||||
taskId,
|
||||
orderId: task.orderId,
|
||||
trackingNumber: result.trackingNumber,
|
||||
traceId: task.traceId,
|
||||
});
|
||||
} else if (task.retryCount < task.maxRetries) {
|
||||
task.retryCount++;
|
||||
task.status = 'pending';
|
||||
this.logger.warn('Ship task failed, will retry', {
|
||||
taskId,
|
||||
orderId: task.orderId,
|
||||
retryCount: task.retryCount,
|
||||
error: result.message,
|
||||
traceId: task.traceId,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
this.processShipTask(taskId, shipInfo);
|
||||
}, this.RETRY_DELAY_MS * task.retryCount);
|
||||
} else {
|
||||
task.status = 'failed';
|
||||
this.logger.error('Ship task failed after max retries', {
|
||||
taskId,
|
||||
orderId: task.orderId,
|
||||
error: result.message,
|
||||
traceId: task.traceId,
|
||||
});
|
||||
}
|
||||
|
||||
task.updatedAt = new Date().toISOString();
|
||||
await this.reportTaskStatus(task, result);
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
task.status = 'failed';
|
||||
task.updatedAt = new Date().toISOString();
|
||||
|
||||
this.logger.error('Ship task processing error', {
|
||||
taskId,
|
||||
orderId: task.orderId,
|
||||
error: error.message,
|
||||
traceId: task.traceId,
|
||||
});
|
||||
|
||||
const errorResult: ShipResult = {
|
||||
success: false,
|
||||
orderId: task.orderId,
|
||||
status: 'failed',
|
||||
message: error.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
traceId: task.traceId,
|
||||
};
|
||||
|
||||
await this.reportTaskStatus(task, errorResult);
|
||||
return errorResult;
|
||||
}
|
||||
}
|
||||
|
||||
private async executeShipOperation(
|
||||
task: ShipTask,
|
||||
shipInfo: ShipInfo,
|
||||
context: any
|
||||
): Promise<ShipResult> {
|
||||
this.logger.info('Executing ship operation', {
|
||||
taskId: task.taskId,
|
||||
orderId: task.orderId,
|
||||
platform: task.platform,
|
||||
traceId: task.traceId,
|
||||
});
|
||||
|
||||
try {
|
||||
switch (task.platform.toLowerCase()) {
|
||||
case 'tiktok':
|
||||
return await this.shipTikTokOrder(task, shipInfo, context);
|
||||
case 'temu':
|
||||
return await this.shipTemuOrder(task, shipInfo, context);
|
||||
case '1688':
|
||||
return await this.ship1688Order(task, shipInfo, context);
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${task.platform}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
orderId: task.orderId,
|
||||
status: 'failed',
|
||||
message: `Ship operation failed: ${error.message}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
traceId: task.traceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async shipTikTokOrder(
|
||||
task: ShipTask,
|
||||
shipInfo: ShipInfo,
|
||||
context: any
|
||||
): Promise<ShipResult> {
|
||||
this.logger.info('Shipping TikTok order', {
|
||||
taskId: task.taskId,
|
||||
orderId: task.orderId,
|
||||
traceId: task.traceId,
|
||||
});
|
||||
|
||||
await this.simulateDelay(2000, 4000);
|
||||
|
||||
const success = Math.random() > 0.1;
|
||||
|
||||
if (success) {
|
||||
return {
|
||||
success: true,
|
||||
orderId: task.orderId,
|
||||
trackingNumber: shipInfo.trackingNumber,
|
||||
carrier: shipInfo.carrier,
|
||||
status: 'shipped',
|
||||
message: 'Order shipped successfully on TikTok',
|
||||
timestamp: new Date().toISOString(),
|
||||
traceId: task.traceId,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
orderId: task.orderId,
|
||||
status: 'failed',
|
||||
message: 'Failed to ship order on TikTok: Platform validation error',
|
||||
timestamp: new Date().toISOString(),
|
||||
traceId: task.traceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async shipTemuOrder(
|
||||
task: ShipTask,
|
||||
shipInfo: ShipInfo,
|
||||
context: any
|
||||
): Promise<ShipResult> {
|
||||
this.logger.info('Shipping Temu order', {
|
||||
taskId: task.taskId,
|
||||
orderId: task.orderId,
|
||||
traceId: task.traceId,
|
||||
});
|
||||
|
||||
await this.simulateDelay(1500, 3000);
|
||||
|
||||
const success = Math.random() > 0.15;
|
||||
|
||||
if (success) {
|
||||
return {
|
||||
success: true,
|
||||
orderId: task.orderId,
|
||||
trackingNumber: shipInfo.trackingNumber,
|
||||
carrier: shipInfo.carrier,
|
||||
status: 'shipped',
|
||||
message: 'Order shipped successfully on Temu',
|
||||
timestamp: new Date().toISOString(),
|
||||
traceId: task.traceId,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
orderId: task.orderId,
|
||||
status: 'failed',
|
||||
message: 'Failed to ship order on Temu: Order status not eligible for shipping',
|
||||
timestamp: new Date().toISOString(),
|
||||
traceId: task.traceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async ship1688Order(
|
||||
task: ShipTask,
|
||||
shipInfo: ShipInfo,
|
||||
context: any
|
||||
): Promise<ShipResult> {
|
||||
this.logger.info('Shipping 1688 order', {
|
||||
taskId: task.taskId,
|
||||
orderId: task.orderId,
|
||||
traceId: task.traceId,
|
||||
});
|
||||
|
||||
await this.simulateDelay(2500, 5000);
|
||||
|
||||
const success = Math.random() > 0.2;
|
||||
|
||||
if (success) {
|
||||
return {
|
||||
success: true,
|
||||
orderId: task.orderId,
|
||||
trackingNumber: shipInfo.trackingNumber,
|
||||
carrier: shipInfo.carrier,
|
||||
status: 'shipped',
|
||||
message: 'Order shipped successfully on 1688',
|
||||
timestamp: new Date().toISOString(),
|
||||
traceId: task.traceId,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
orderId: task.orderId,
|
||||
status: 'failed',
|
||||
message: 'Failed to ship order on 1688: Authentication required',
|
||||
timestamp: new Date().toISOString(),
|
||||
traceId: task.traceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async batchProcessShipTasks(tasks: Array<{ taskId: string; shipInfo: ShipInfo }>): Promise<ShipResult[]> {
|
||||
this.logger.info('Starting batch ship processing', { count: tasks.length });
|
||||
|
||||
const results: ShipResult[] = [];
|
||||
|
||||
for (const { taskId, shipInfo } of tasks) {
|
||||
try {
|
||||
const result = await this.processShipTask(taskId, shipInfo);
|
||||
results.push(result);
|
||||
} catch (error: any) {
|
||||
results.push({
|
||||
success: false,
|
||||
orderId: shipInfo.orderId,
|
||||
status: 'failed',
|
||||
message: `Batch processing error: ${error.message}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
traceId: shipInfo.orderId,
|
||||
});
|
||||
}
|
||||
|
||||
await this.simulateDelay(1000, 2000);
|
||||
}
|
||||
|
||||
this.logger.info('Batch ship processing completed', {
|
||||
total: tasks.length,
|
||||
success: results.filter(r => r.success).length,
|
||||
failed: results.filter(r => !r.success).length,
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
getTaskStatus(taskId: string): ShipTask | undefined {
|
||||
return this.tasks.get(taskId);
|
||||
}
|
||||
|
||||
getTasksByShop(shopId: string): ShipTask[] {
|
||||
return Array.from(this.tasks.values()).filter(t => t.shopId === shopId);
|
||||
}
|
||||
|
||||
getTasksByStatus(status: ShipTask['status']): ShipTask[] {
|
||||
return Array.from(this.tasks.values()).filter(t => t.status === status);
|
||||
}
|
||||
|
||||
private async reportTaskCreated(task: ShipTask): Promise<void> {
|
||||
this.logger.info('Reporting task created to backend', {
|
||||
taskId: task.taskId,
|
||||
orderId: task.orderId,
|
||||
traceId: task.traceId,
|
||||
});
|
||||
}
|
||||
|
||||
private async reportTaskStatus(task: ShipTask, result: ShipResult): Promise<void> {
|
||||
this.logger.info('Reporting task status to backend', {
|
||||
taskId: task.taskId,
|
||||
orderId: task.orderId,
|
||||
status: task.status,
|
||||
traceId: task.traceId,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:3000/api/plugin/ship-status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
taskId: task.taskId,
|
||||
orderId: task.orderId,
|
||||
shopId: task.shopId,
|
||||
platform: task.platform,
|
||||
status: task.status,
|
||||
trackingNumber: result.trackingNumber,
|
||||
carrier: result.carrier,
|
||||
message: result.message,
|
||||
timestamp: result.timestamp,
|
||||
traceId: task.traceId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn('Failed to report task status', {
|
||||
taskId: task.taskId,
|
||||
statusCode: response.status,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.warn('Error reporting task status', {
|
||||
taskId: task.taskId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async simulateDelay(minMs: number, maxMs: number): Promise<void> {
|
||||
const delay = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs;
|
||||
return new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
private generateTraceId(): string {
|
||||
return `trace-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
343
extension/src/background/DOMParser.ts
Normal file
343
extension/src/background/DOMParser.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import { Logger } from '../utils/Logger';
|
||||
|
||||
interface ParseConfig {
|
||||
selectors: {
|
||||
[key: string]: string;
|
||||
};
|
||||
listSelector?: string;
|
||||
attributeMap: {
|
||||
[key: string]: {
|
||||
selector: string;
|
||||
attribute?: string;
|
||||
transform?: 'text' | 'number' | 'price' | 'image' | 'url';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ParsedProduct {
|
||||
productId: string;
|
||||
name: string;
|
||||
price: number;
|
||||
originalPrice?: number;
|
||||
currency: string;
|
||||
images: string[];
|
||||
description?: string;
|
||||
skuList: Array<{
|
||||
skuId: string;
|
||||
attributes: Record<string, string>;
|
||||
price: number;
|
||||
stock: number;
|
||||
}>;
|
||||
category?: string;
|
||||
brand?: string;
|
||||
specifications: Record<string, string>;
|
||||
source: string;
|
||||
url: string;
|
||||
parsedAt: string;
|
||||
}
|
||||
|
||||
interface ParseResult {
|
||||
success: boolean;
|
||||
data?: ParsedProduct | ParsedProduct[];
|
||||
error?: string;
|
||||
url: string;
|
||||
timestamp: string;
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
export class DOMParser {
|
||||
private logger = new Logger('DOMParser');
|
||||
|
||||
private static readonly PLATFORM_CONFIGS: Record<string, ParseConfig> = {
|
||||
tiktok: {
|
||||
selectors: {
|
||||
productName: '[data-e2e="product-title"], .product-title, h1.title',
|
||||
price: '[data-e2e="product-price"], .price-current, .product-price',
|
||||
originalPrice: '[data-e2e="product-original-price"], .price-original',
|
||||
images: '[data-e2e="product-image"] img, .product-image img, .gallery-image img',
|
||||
description: '[data-e2e="product-description"], .product-description, .description',
|
||||
skuList: '[data-e2e="sku-item"], .sku-item, .variant-item',
|
||||
},
|
||||
attributeMap: {
|
||||
productId: { selector: '[data-product-id]', attribute: 'data-product-id' },
|
||||
name: { selector: '[data-e2e="product-title"], .product-title, h1.title', transform: 'text' },
|
||||
price: { selector: '[data-e2e="product-price"], .price-current', transform: 'price' },
|
||||
currency: { selector: '[data-e2e="product-price"]', transform: 'text' },
|
||||
images: { selector: '[data-e2e="product-image"] img', attribute: 'src', transform: 'image' },
|
||||
},
|
||||
},
|
||||
temu: {
|
||||
selectors: {
|
||||
productName: '.product-title, [data-testid="product-title"], h1',
|
||||
price: '.price-current, [data-testid="price"], .sales-price',
|
||||
originalPrice: '.price-original, .original-price',
|
||||
images: '.product-image img, [data-testid="product-image"] img, .gallery img',
|
||||
description: '.product-description, [data-testid="description"]',
|
||||
skuList: '.sku-item, [data-testid="sku"], .variant-option',
|
||||
},
|
||||
attributeMap: {
|
||||
productId: { selector: '[data-goods-id]', attribute: 'data-goods-id' },
|
||||
name: { selector: '.product-title, [data-testid="product-title"]', transform: 'text' },
|
||||
price: { selector: '.price-current, .sales-price', transform: 'price' },
|
||||
currency: { selector: '.price-current', transform: 'text' },
|
||||
images: { selector: '.product-image img', attribute: 'src', transform: 'image' },
|
||||
},
|
||||
},
|
||||
'1688': {
|
||||
selectors: {
|
||||
productName: '.d-title, .offer-title, h1.title',
|
||||
price: '.price-current, .offer-price, .price-now',
|
||||
originalPrice: '.price-original, .offer-original-price',
|
||||
images: '.offer-image img, .gallery img, .main-image img',
|
||||
description: '.offer-detail, .product-description, .description',
|
||||
skuList: '.sku-item, .offer-sku, .prop-item',
|
||||
},
|
||||
attributeMap: {
|
||||
productId: { selector: '[data-offer-id]', attribute: 'data-offer-id' },
|
||||
name: { selector: '.d-title, .offer-title', transform: 'text' },
|
||||
price: { selector: '.price-current, .offer-price', transform: 'price' },
|
||||
currency: { selector: '.price-current', transform: 'text' },
|
||||
images: { selector: '.offer-image img', attribute: 'src', transform: 'image' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async parseProductPage(
|
||||
html: string,
|
||||
url: string,
|
||||
platform: string,
|
||||
traceId: string
|
||||
): Promise<ParseResult> {
|
||||
this.logger.info('Starting DOM parsing', { url, platform, traceId });
|
||||
|
||||
try {
|
||||
const config = DOMParser.PLATFORM_CONFIGS[platform.toLowerCase()];
|
||||
if (!config) {
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
|
||||
const doc = this.parseHTML(html);
|
||||
|
||||
const product = this.extractProductData(doc, config, url, platform);
|
||||
|
||||
this.logger.info('DOM parsing completed', {
|
||||
url,
|
||||
platform,
|
||||
productId: product.productId,
|
||||
traceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: product,
|
||||
url,
|
||||
timestamp: new Date().toISOString(),
|
||||
traceId,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('DOM parsing failed', {
|
||||
url,
|
||||
platform,
|
||||
error: error.message,
|
||||
traceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
url,
|
||||
timestamp: new Date().toISOString(),
|
||||
traceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async parseProductList(
|
||||
html: string,
|
||||
url: string,
|
||||
platform: string,
|
||||
traceId: string
|
||||
): Promise<ParseResult> {
|
||||
this.logger.info('Starting product list parsing', { url, platform, traceId });
|
||||
|
||||
try {
|
||||
const config = DOMParser.PLATFORM_CONFIGS[platform.toLowerCase()];
|
||||
if (!config) {
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
|
||||
const doc = this.parseHTML(html);
|
||||
|
||||
const listSelector = config.listSelector || '.product-item, .offer-item, .goods-item';
|
||||
const items = doc.querySelectorAll(listSelector);
|
||||
const products: ParsedProduct[] = [];
|
||||
|
||||
items.forEach((item: Element, index: number) => {
|
||||
try {
|
||||
const product = this.extractProductData(item as any, config, url, platform);
|
||||
products.push(product);
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Failed to parse item ${index}`, { error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.info('Product list parsing completed', {
|
||||
url,
|
||||
platform,
|
||||
count: products.length,
|
||||
traceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: products,
|
||||
url,
|
||||
timestamp: new Date().toISOString(),
|
||||
traceId,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Product list parsing failed', {
|
||||
url,
|
||||
platform,
|
||||
error: error.message,
|
||||
traceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
url,
|
||||
timestamp: new Date().toISOString(),
|
||||
traceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private extractProductData(
|
||||
doc: any,
|
||||
config: ParseConfig,
|
||||
url: string,
|
||||
platform: string
|
||||
): ParsedProduct {
|
||||
const getText = (selector: string): string => {
|
||||
const el = doc.querySelector(selector);
|
||||
return el ? el.textContent?.trim() || '' : '';
|
||||
};
|
||||
|
||||
const getAttribute = (selector: string, attr: string): string => {
|
||||
const el = doc.querySelector(selector);
|
||||
return el ? el.getAttribute(attr) || '' : '';
|
||||
};
|
||||
|
||||
const parsePrice = (text: string): { price: number; currency: string } => {
|
||||
const match = text.match(/[$¥€£]\s*([\d,]+\.?\d*)/);
|
||||
if (match) {
|
||||
const currency = text.match(/[$¥€£]/)?.[0] || 'USD';
|
||||
return {
|
||||
price: parseFloat(match[1].replace(/,/g, '')),
|
||||
currency,
|
||||
};
|
||||
}
|
||||
return { price: 0, currency: 'USD' };
|
||||
};
|
||||
|
||||
const productId = getAttribute(config.selectors.productId || '[data-product-id]', 'data-product-id') ||
|
||||
this.extractProductIdFromUrl(url);
|
||||
|
||||
const name = getText(config.selectors.productName);
|
||||
const priceText = getText(config.selectors.price);
|
||||
const { price, currency } = parsePrice(priceText);
|
||||
|
||||
const originalPriceText = getText(config.selectors.originalPrice);
|
||||
const { price: originalPrice } = originalPriceText ? parsePrice(originalPriceText) : { price: undefined };
|
||||
|
||||
const images: string[] = [];
|
||||
const imageElements = doc.querySelectorAll(config.selectors.images);
|
||||
imageElements.forEach((img: any) => {
|
||||
const src = img.getAttribute('src') || img.getAttribute('data-src');
|
||||
if (src) {
|
||||
images.push(this.normalizeImageUrl(src, url));
|
||||
}
|
||||
});
|
||||
|
||||
const description = getText(config.selectors.description);
|
||||
|
||||
const skuList: ParsedProduct['skuList'] = [];
|
||||
const skuElements = doc.querySelectorAll(config.selectors.skuList);
|
||||
skuElements.forEach((sku: any, index: number) => {
|
||||
const skuName = sku.textContent?.trim() || `SKU-${index}`;
|
||||
const skuPrice = parsePrice(sku.textContent || '').price;
|
||||
skuList.push({
|
||||
skuId: `${productId}-SKU-${index}`,
|
||||
attributes: { name: skuName },
|
||||
price: skuPrice || price,
|
||||
stock: 0,
|
||||
});
|
||||
});
|
||||
|
||||
const specifications: Record<string, string> = {};
|
||||
const specElements = doc.querySelectorAll('.spec-item, .attribute-item, .property-item');
|
||||
specElements.forEach((spec: any) => {
|
||||
const key = spec.querySelector('.spec-name, .attr-name')?.textContent?.trim();
|
||||
const value = spec.querySelector('.spec-value, .attr-value')?.textContent?.trim();
|
||||
if (key && value) {
|
||||
specifications[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
productId,
|
||||
name,
|
||||
price,
|
||||
originalPrice,
|
||||
currency,
|
||||
images: images.slice(0, 10),
|
||||
description,
|
||||
skuList,
|
||||
category: specifications['Category'] || specifications['类目'],
|
||||
brand: specifications['Brand'] || specifications['品牌'],
|
||||
specifications,
|
||||
source: platform,
|
||||
url,
|
||||
parsedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private extractProductIdFromUrl(url: string): string {
|
||||
const patterns = [
|
||||
/\/product\/(\d+)/i,
|
||||
/\/item\/(\d+)/i,
|
||||
/\/offer\/(\d+)/i,
|
||||
/[?&]id=(\d+)/i,
|
||||
/-(\d+)\.html/i,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
return `UNKNOWN-${Date.now()}`;
|
||||
}
|
||||
|
||||
private normalizeImageUrl(src: string, baseUrl: string): string {
|
||||
if (src.startsWith('http')) {
|
||||
return src;
|
||||
}
|
||||
if (src.startsWith('//')) {
|
||||
return `https:${src}`;
|
||||
}
|
||||
if (src.startsWith('/')) {
|
||||
const url = new URL(baseUrl);
|
||||
return `${url.protocol}//${url.host}${src}`;
|
||||
}
|
||||
return src;
|
||||
}
|
||||
|
||||
private parseHTML(html: string): Document {
|
||||
const parser = new (globalThis as any).DOMParser();
|
||||
return parser.parseFromString(html, 'text/html');
|
||||
}
|
||||
}
|
||||
291
extension/src/background/FingerprintManager.ts
Normal file
291
extension/src/background/FingerprintManager.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { Logger } from '../utils/Logger';
|
||||
|
||||
interface FingerprintConfig {
|
||||
userAgent: string;
|
||||
screenResolution: string;
|
||||
timezone: string;
|
||||
language: string;
|
||||
platform: string;
|
||||
hardwareConcurrency: number;
|
||||
deviceMemory: number;
|
||||
colorDepth: number;
|
||||
pixelRatio: number;
|
||||
fonts: string[];
|
||||
canvasNoise: number;
|
||||
webglNoise: number;
|
||||
}
|
||||
|
||||
interface ProxyConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
protocol: 'http' | 'https' | 'socks5';
|
||||
country?: string;
|
||||
region?: string;
|
||||
}
|
||||
|
||||
interface IsolatedContext {
|
||||
shopId: string;
|
||||
fingerprint: FingerprintConfig;
|
||||
proxy: ProxyConfig;
|
||||
cookies: Record<string, string>;
|
||||
localStorage: Record<string, string>;
|
||||
sessionStorage: Record<string, string>;
|
||||
createdAt: string;
|
||||
lastUsedAt: string;
|
||||
useCount: number;
|
||||
}
|
||||
|
||||
interface ContextCreationResult {
|
||||
success: boolean;
|
||||
context?: IsolatedContext;
|
||||
error?: string;
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
export class FingerprintManager {
|
||||
private logger = new Logger('FingerprintManager');
|
||||
private contexts: Map<string, IsolatedContext> = new Map();
|
||||
private readonly USER_AGENT_TEMPLATES = [
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.0',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15',
|
||||
];
|
||||
|
||||
private readonly TIMEZONES = [
|
||||
'America/New_York',
|
||||
'America/Los_Angeles',
|
||||
'Europe/London',
|
||||
'Europe/Paris',
|
||||
'Asia/Tokyo',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Singapore',
|
||||
'Australia/Sydney',
|
||||
];
|
||||
|
||||
private readonly LANGUAGES = [
|
||||
'en-US,en;q=0.9',
|
||||
'en-GB,en;q=0.9',
|
||||
'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
'ja-JP,ja;q=0.9,en;q=0.8',
|
||||
'de-DE,de;q=0.9,en;q=0.8',
|
||||
'fr-FR,fr;q=0.9,en;q=0.8',
|
||||
];
|
||||
|
||||
private readonly SCREEN_RESOLUTIONS = [
|
||||
'1920x1080',
|
||||
'1366x768',
|
||||
'1440x900',
|
||||
'1536x864',
|
||||
'1280x720',
|
||||
'2560x1440',
|
||||
'1680x1050',
|
||||
];
|
||||
|
||||
async createIsolatedContext(
|
||||
shopId: string,
|
||||
proxyConfig?: ProxyConfig,
|
||||
traceId?: string
|
||||
): Promise<ContextCreationResult> {
|
||||
const tid = traceId || this.generateTraceId();
|
||||
this.logger.info('Creating isolated context', { shopId, traceId: tid });
|
||||
|
||||
try {
|
||||
if (this.contexts.has(shopId)) {
|
||||
this.logger.info('Reusing existing context', { shopId, traceId: tid });
|
||||
const existing = this.contexts.get(shopId)!;
|
||||
existing.lastUsedAt = new Date().toISOString();
|
||||
existing.useCount++;
|
||||
return {
|
||||
success: true,
|
||||
context: existing,
|
||||
traceId: tid,
|
||||
};
|
||||
}
|
||||
|
||||
const fingerprint = this.generateFingerprint();
|
||||
const proxy = proxyConfig || await this.getProxyForShop(shopId);
|
||||
|
||||
const context: IsolatedContext = {
|
||||
shopId,
|
||||
fingerprint,
|
||||
proxy,
|
||||
cookies: {},
|
||||
localStorage: {},
|
||||
sessionStorage: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
useCount: 1,
|
||||
};
|
||||
|
||||
this.contexts.set(shopId, context);
|
||||
|
||||
this.logger.info('Isolated context created', {
|
||||
shopId,
|
||||
userAgent: fingerprint.userAgent.substring(0, 50) + '...',
|
||||
proxy: `${proxy.protocol}://${proxy.host}:${proxy.port}`,
|
||||
traceId: tid,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
context,
|
||||
traceId: tid,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Failed to create isolated context', {
|
||||
shopId,
|
||||
error: error.message,
|
||||
traceId: tid,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
traceId: tid,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async destroyContext(shopId: string, traceId?: string): Promise<boolean> {
|
||||
const tid = traceId || this.generateTraceId();
|
||||
this.logger.info('Destroying context', { shopId, traceId: tid });
|
||||
|
||||
const context = this.contexts.get(shopId);
|
||||
if (!context) {
|
||||
this.logger.warn('Context not found', { shopId, traceId: tid });
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.contexts.delete(shopId);
|
||||
this.logger.info('Context destroyed', { shopId, traceId: tid });
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
this.logger.error('Failed to destroy context', {
|
||||
shopId,
|
||||
error: error.message,
|
||||
traceId: tid,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getContext(shopId: string): IsolatedContext | undefined {
|
||||
return this.contexts.get(shopId);
|
||||
}
|
||||
|
||||
updateContextCookies(
|
||||
shopId: string,
|
||||
cookies: Record<string, string>,
|
||||
traceId?: string
|
||||
): boolean {
|
||||
const tid = traceId || this.generateTraceId();
|
||||
const context = this.contexts.get(shopId);
|
||||
|
||||
if (!context) {
|
||||
this.logger.warn('Cannot update cookies - context not found', { shopId, traceId: tid });
|
||||
return false;
|
||||
}
|
||||
|
||||
context.cookies = { ...context.cookies, ...cookies };
|
||||
context.lastUsedAt = new Date().toISOString();
|
||||
|
||||
this.logger.info('Cookies updated', { shopId, cookieCount: Object.keys(cookies).length, traceId: tid });
|
||||
return true;
|
||||
}
|
||||
|
||||
updateContextStorage(
|
||||
shopId: string,
|
||||
localStorage: Record<string, string>,
|
||||
sessionStorage: Record<string, string>,
|
||||
traceId?: string
|
||||
): boolean {
|
||||
const tid = traceId || this.generateTraceId();
|
||||
const context = this.contexts.get(shopId);
|
||||
|
||||
if (!context) {
|
||||
this.logger.warn('Cannot update storage - context not found', { shopId, traceId: tid });
|
||||
return false;
|
||||
}
|
||||
|
||||
context.localStorage = { ...context.localStorage, ...localStorage };
|
||||
context.sessionStorage = { ...context.sessionStorage, ...sessionStorage };
|
||||
context.lastUsedAt = new Date().toISOString();
|
||||
|
||||
this.logger.info('Storage updated', { shopId, traceId: tid });
|
||||
return true;
|
||||
}
|
||||
|
||||
getAllActiveContexts(): IsolatedContext[] {
|
||||
return Array.from(this.contexts.values());
|
||||
}
|
||||
|
||||
cleanupInactiveContexts(maxAgeMinutes: number = 30): number {
|
||||
const now = new Date().getTime();
|
||||
let cleaned = 0;
|
||||
|
||||
for (const [shopId, context] of this.contexts.entries()) {
|
||||
const lastUsed = new Date(context.lastUsedAt).getTime();
|
||||
const ageMinutes = (now - lastUsed) / (1000 * 60);
|
||||
|
||||
if (ageMinutes > maxAgeMinutes) {
|
||||
this.contexts.delete(shopId);
|
||||
cleaned++;
|
||||
this.logger.info('Cleaned up inactive context', { shopId, ageMinutes });
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private generateFingerprint(): FingerprintConfig {
|
||||
const userAgent = this.USER_AGENT_TEMPLATES[
|
||||
Math.floor(Math.random() * this.USER_AGENT_TEMPLATES.length)
|
||||
];
|
||||
|
||||
const screenResolution = this.SCREEN_RESOLUTIONS[
|
||||
Math.floor(Math.random() * this.SCREEN_RESOLUTIONS.length)
|
||||
];
|
||||
|
||||
const timezone = this.TIMEZONES[
|
||||
Math.floor(Math.random() * this.TIMEZONES.length)
|
||||
];
|
||||
|
||||
const language = this.LANGUAGES[
|
||||
Math.floor(Math.random() * this.LANGUAGES.length)
|
||||
];
|
||||
|
||||
return {
|
||||
userAgent,
|
||||
screenResolution,
|
||||
timezone,
|
||||
language,
|
||||
platform: userAgent.includes('Mac') ? 'MacIntel' : 'Win32',
|
||||
hardwareConcurrency: [2, 4, 6, 8][Math.floor(Math.random() * 4)],
|
||||
deviceMemory: [4, 8, 16][Math.floor(Math.random() * 3)],
|
||||
colorDepth: 24,
|
||||
pixelRatio: [1, 1.25, 1.5, 2][Math.floor(Math.random() * 4)],
|
||||
fonts: ['Arial', 'Times New Roman', 'Helvetica', 'Georgia'],
|
||||
canvasNoise: Math.random() * 0.02 - 0.01,
|
||||
webglNoise: Math.random() * 0.02 - 0.01,
|
||||
};
|
||||
}
|
||||
|
||||
private async getProxyForShop(shopId: string): Promise<ProxyConfig> {
|
||||
this.logger.info('Fetching proxy for shop', { shopId });
|
||||
|
||||
return {
|
||||
host: 'proxy.crawlful.com',
|
||||
port: 8080,
|
||||
protocol: 'http',
|
||||
country: 'US',
|
||||
};
|
||||
}
|
||||
|
||||
private generateTraceId(): string {
|
||||
return `trace-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
257
extension/src/background/LogisticsSyncService.ts
Normal file
257
extension/src/background/LogisticsSyncService.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { Logger } from '../utils/Logger';
|
||||
|
||||
export interface LogisticsInfo {
|
||||
orderId: string;
|
||||
trackingNumber: string;
|
||||
platform: string;
|
||||
tenantId: string;
|
||||
shopId: string;
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
export interface LogisticsStatus {
|
||||
status: 'PENDING' | 'IN_TRANSIT' | 'DELIVERED' | 'FAILED';
|
||||
trackingNumber: string;
|
||||
carrier: string;
|
||||
events: LogisticsEvent[];
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
export interface LogisticsEvent {
|
||||
timestamp: string;
|
||||
location: string;
|
||||
description: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
orderId: string;
|
||||
trackingNumber: string;
|
||||
status: LogisticsStatus | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export class LogisticsSyncService {
|
||||
private logger = new Logger('LogisticsSyncService');
|
||||
private syncTasks: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
async syncLogisticsStatus(logisticsInfo: LogisticsInfo): Promise<SyncResult> {
|
||||
try {
|
||||
this.logger.info(`开始同步物流状态: ${logisticsInfo.trackingNumber}`, {
|
||||
orderId: logisticsInfo.orderId,
|
||||
platform: logisticsInfo.platform,
|
||||
traceId: logisticsInfo.traceId
|
||||
});
|
||||
|
||||
const status = await this.fetchLogisticsStatus(logisticsInfo);
|
||||
|
||||
await this.reportStatus(logisticsInfo, status);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
orderId: logisticsInfo.orderId,
|
||||
trackingNumber: logisticsInfo.trackingNumber,
|
||||
status,
|
||||
error: null
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
||||
this.logger.error(`物流状态同步失败: ${errorMessage}`, {
|
||||
orderId: logisticsInfo.orderId,
|
||||
trackingNumber: logisticsInfo.trackingNumber,
|
||||
traceId: logisticsInfo.traceId
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
orderId: logisticsInfo.orderId,
|
||||
trackingNumber: logisticsInfo.trackingNumber,
|
||||
status: null,
|
||||
error: errorMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async startPeriodicSync(logisticsInfo: LogisticsInfo, intervalMs: number = 3600000): Promise<string> {
|
||||
const taskId = `${logisticsInfo.orderId}_${logisticsInfo.trackingNumber}`;
|
||||
|
||||
if (this.syncTasks.has(taskId)) {
|
||||
this.stopSync(taskId);
|
||||
}
|
||||
|
||||
const timeout = setInterval(async () => {
|
||||
await this.syncLogisticsStatus(logisticsInfo);
|
||||
}, intervalMs);
|
||||
|
||||
this.syncTasks.set(taskId, timeout);
|
||||
this.logger.info(`启动周期性物流同步任务: ${taskId}`, {
|
||||
intervalMs,
|
||||
traceId: logisticsInfo.traceId
|
||||
});
|
||||
|
||||
return taskId;
|
||||
}
|
||||
|
||||
stopSync(taskId: string): void {
|
||||
const timeout = this.syncTasks.get(taskId);
|
||||
if (timeout) {
|
||||
clearInterval(timeout);
|
||||
this.syncTasks.delete(taskId);
|
||||
this.logger.info(`停止物流同步任务: ${taskId}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchLogisticsStatus(logisticsInfo: LogisticsInfo): Promise<LogisticsStatus> {
|
||||
switch (logisticsInfo.platform.toLowerCase()) {
|
||||
case 'tiktok':
|
||||
return await this.fetchTikTokLogistics(logisticsInfo);
|
||||
case 'temu':
|
||||
return await this.fetchTemuLogistics(logisticsInfo);
|
||||
case '1688':
|
||||
return await this.fetch1688Logistics(logisticsInfo);
|
||||
default:
|
||||
throw new Error(`不支持的平台: ${logisticsInfo.platform}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchTikTokLogistics(logisticsInfo: LogisticsInfo): Promise<LogisticsStatus> {
|
||||
this.logger.debug('查询TikTok物流状态', {
|
||||
trackingNumber: logisticsInfo.trackingNumber,
|
||||
traceId: logisticsInfo.traceId
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'IN_TRANSIT',
|
||||
trackingNumber: logisticsInfo.trackingNumber,
|
||||
carrier: 'TikTok Logistics',
|
||||
events: [
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
location: '仓库',
|
||||
description: '包裹已发出',
|
||||
status: 'SHIPPED'
|
||||
},
|
||||
{
|
||||
timestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
location: '分拣中心',
|
||||
description: '包裹已到达分拣中心',
|
||||
status: 'IN_TRANSIT'
|
||||
}
|
||||
],
|
||||
lastUpdated: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchTemuLogistics(logisticsInfo: LogisticsInfo): Promise<LogisticsStatus> {
|
||||
this.logger.debug('查询Temu物流状态', {
|
||||
trackingNumber: logisticsInfo.trackingNumber,
|
||||
traceId: logisticsInfo.traceId
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'IN_TRANSIT',
|
||||
trackingNumber: logisticsInfo.trackingNumber,
|
||||
carrier: 'Temu Shipping',
|
||||
events: [
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
location: '国际物流中心',
|
||||
description: '包裹正在国际运输中',
|
||||
status: 'IN_TRANSIT'
|
||||
},
|
||||
{
|
||||
timestamp: new Date(Date.now() - 172800000).toISOString(),
|
||||
location: '发货仓库',
|
||||
description: '包裹已发货',
|
||||
status: 'SHIPPED'
|
||||
}
|
||||
],
|
||||
lastUpdated: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
private async fetch1688Logistics(logisticsInfo: LogisticsInfo): Promise<LogisticsStatus> {
|
||||
this.logger.debug('查询1688物流状态', {
|
||||
trackingNumber: logisticsInfo.trackingNumber,
|
||||
traceId: logisticsInfo.traceId
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'DELIVERED',
|
||||
trackingNumber: logisticsInfo.trackingNumber,
|
||||
carrier: '1688 Express',
|
||||
events: [
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
location: '目的地',
|
||||
description: '包裹已签收',
|
||||
status: 'DELIVERED'
|
||||
},
|
||||
{
|
||||
timestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
location: '当地配送中心',
|
||||
description: '包裹正在派送中',
|
||||
status: 'OUT_FOR_DELIVERY'
|
||||
},
|
||||
{
|
||||
timestamp: new Date(Date.now() - 172800000).toISOString(),
|
||||
location: '发货地',
|
||||
description: '包裹已发出',
|
||||
status: 'SHIPPED'
|
||||
}
|
||||
],
|
||||
lastUpdated: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
private async reportStatus(logisticsInfo: LogisticsInfo, status: LogisticsStatus): Promise<void> {
|
||||
try {
|
||||
const reportData = {
|
||||
orderId: logisticsInfo.orderId,
|
||||
trackingNumber: logisticsInfo.trackingNumber,
|
||||
platform: logisticsInfo.platform,
|
||||
status,
|
||||
tenantId: logisticsInfo.tenantId,
|
||||
shopId: logisticsInfo.shopId,
|
||||
traceId: logisticsInfo.traceId,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
const response = await fetch('http://localhost:3000/api/logistics/status', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(reportData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`上报物流状态失败: ${response.statusText}`);
|
||||
}
|
||||
|
||||
this.logger.info('物流状态上报成功', {
|
||||
orderId: logisticsInfo.orderId,
|
||||
traceId: logisticsInfo.traceId
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('物流状态上报失败', {
|
||||
error: error instanceof Error ? error.message : '未知错误',
|
||||
orderId: logisticsInfo.orderId,
|
||||
traceId: logisticsInfo.traceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getActiveSyncTasks(): string[] {
|
||||
return Array.from(this.syncTasks.keys());
|
||||
}
|
||||
|
||||
clearAllTasks(): void {
|
||||
this.syncTasks.forEach((timeout, taskId) => {
|
||||
clearInterval(timeout);
|
||||
});
|
||||
this.syncTasks.clear();
|
||||
this.logger.info('已清除所有物流同步任务');
|
||||
}
|
||||
}
|
||||
137
extension/src/background/MessageHandler.ts
Normal file
137
extension/src/background/MessageHandler.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { OrderCollector } from './OrderCollector';
|
||||
import { ReturnSync } from './ReturnSync';
|
||||
import { RefundQuery } from './RefundQuery';
|
||||
import { Logger } from '../utils/Logger';
|
||||
|
||||
interface Message {
|
||||
type: string;
|
||||
payload: any;
|
||||
traceId?: string;
|
||||
}
|
||||
|
||||
export class MessageHandler {
|
||||
private logger = new Logger('MessageHandler');
|
||||
|
||||
constructor(
|
||||
private orderCollector: OrderCollector,
|
||||
private returnSync: ReturnSync,
|
||||
private refundQuery: RefundQuery
|
||||
) {}
|
||||
|
||||
async handle(message: Message, sender: chrome.runtime.MessageSender): Promise<any> {
|
||||
const traceId = message.traceId || this.generateTraceId();
|
||||
|
||||
this.logger.info('Handling message', {
|
||||
type: message.type,
|
||||
traceId,
|
||||
});
|
||||
|
||||
switch (message.type) {
|
||||
case 'COLLECT_ORDERS':
|
||||
return this.orderCollector.collectOrders(
|
||||
message.payload.shopId,
|
||||
message.payload.platform,
|
||||
message.payload.dateRange,
|
||||
traceId
|
||||
);
|
||||
|
||||
case 'SYNC_ALL_ORDERS':
|
||||
return this.orderCollector.syncAllShops();
|
||||
|
||||
case 'SYNC_RETURNS':
|
||||
return this.returnSync.syncReturns(
|
||||
message.payload.shopId,
|
||||
message.payload.platform,
|
||||
message.payload.returnIds,
|
||||
traceId
|
||||
);
|
||||
|
||||
case 'SYNC_ALL_RETURNS':
|
||||
return this.returnSync.syncAllReturns();
|
||||
|
||||
case 'GET_RETURN_STATUS':
|
||||
return this.returnSync.getReturnStatus(
|
||||
message.payload.platform,
|
||||
message.payload.returnId,
|
||||
traceId
|
||||
);
|
||||
|
||||
case 'QUERY_REFUND_STATUS':
|
||||
return this.refundQuery.queryRefundStatus(
|
||||
message.payload.platform,
|
||||
message.payload.refundId,
|
||||
traceId
|
||||
);
|
||||
|
||||
case 'QUERY_ALL_REFUNDS':
|
||||
return this.refundQuery.queryAllRefunds();
|
||||
|
||||
case 'BATCH_QUERY_REFUNDS':
|
||||
return this.refundQuery.batchQueryRefunds(
|
||||
message.payload.refunds,
|
||||
traceId
|
||||
);
|
||||
|
||||
case 'GET_CONFIG':
|
||||
return chrome.storage.local.get('config');
|
||||
|
||||
case 'UPDATE_CONFIG':
|
||||
await chrome.storage.local.set({ config: message.payload.config });
|
||||
return { success: true };
|
||||
|
||||
case 'REGISTER_SHOP':
|
||||
return this.registerShop(message.payload);
|
||||
|
||||
case 'UNREGISTER_SHOP':
|
||||
return this.unregisterShop(message.payload.shopId);
|
||||
|
||||
case 'GET_SHOPS':
|
||||
return chrome.storage.local.get('shops');
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown message type: ${message.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async registerShop(payload: {
|
||||
shopId: string;
|
||||
platform: string;
|
||||
tenantId: string;
|
||||
}): Promise<{ success: boolean }> {
|
||||
const data = await chrome.storage.local.get('shops');
|
||||
const shops = data.shops || [];
|
||||
|
||||
const existingIndex = shops.findIndex(
|
||||
(s: any) => s.shopId === payload.shopId && s.platform === payload.platform
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
shops[existingIndex] = payload;
|
||||
} else {
|
||||
shops.push(payload);
|
||||
}
|
||||
|
||||
await chrome.storage.local.set({ shops });
|
||||
|
||||
this.logger.info('Shop registered', { shopId: payload.shopId });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async unregisterShop(shopId: string): Promise<{ success: boolean }> {
|
||||
const data = await chrome.storage.local.get('shops');
|
||||
const shops = data.shops || [];
|
||||
|
||||
const updatedShops = shops.filter((s: any) => s.shopId !== shopId);
|
||||
|
||||
await chrome.storage.local.set({ shops: updatedShops });
|
||||
|
||||
this.logger.info('Shop unregistered', { shopId });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private generateTraceId(): string {
|
||||
return `TRC-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
188
extension/src/background/OrderCollector.ts
Normal file
188
extension/src/background/OrderCollector.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { Logger } from '../utils/Logger';
|
||||
import { PlatformAdapter, TikTokAdapter, TemuAdapter, ShopeeAdapter } from '../platforms';
|
||||
|
||||
interface OrderData {
|
||||
orderId: string;
|
||||
platform: string;
|
||||
shopId: string;
|
||||
status: string;
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
items: OrderItem[];
|
||||
customerInfo: CustomerInfo;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
interface OrderItem {
|
||||
productId: string;
|
||||
skuId: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
interface CustomerInfo {
|
||||
name: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
interface SyncResult {
|
||||
shopId: string;
|
||||
platform: string;
|
||||
ordersCollected: number;
|
||||
errors: string[];
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
export class OrderCollector {
|
||||
private logger = new Logger('OrderCollector');
|
||||
private platformAdapters: Map<string, PlatformAdapter> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.platformAdapters.set('tiktok', new TikTokAdapter());
|
||||
this.platformAdapters.set('temu', new TemuAdapter());
|
||||
this.platformAdapters.set('shopee', new ShopeeAdapter());
|
||||
}
|
||||
|
||||
async collectOrders(
|
||||
shopId: string,
|
||||
platform: string,
|
||||
dateRange: { start: Date; end: Date },
|
||||
traceId: string
|
||||
): Promise<OrderData[]> {
|
||||
this.logger.info('Starting order collection', {
|
||||
shopId,
|
||||
platform,
|
||||
dateRange,
|
||||
traceId,
|
||||
});
|
||||
|
||||
const adapter = this.platformAdapters.get(platform.toLowerCase());
|
||||
if (!adapter) {
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const orders = await adapter.fetchOrders(shopId, dateRange, traceId);
|
||||
|
||||
this.logger.info('Orders collected', {
|
||||
shopId,
|
||||
platform,
|
||||
count: orders.length,
|
||||
traceId,
|
||||
});
|
||||
|
||||
return orders;
|
||||
} catch (error: any) {
|
||||
this.logger.error('Order collection failed', {
|
||||
shopId,
|
||||
platform,
|
||||
error: error.message,
|
||||
traceId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async syncAllShops(): Promise<SyncResult[]> {
|
||||
this.logger.info('Starting sync for all shops');
|
||||
|
||||
const shops = await this.getRegisteredShops();
|
||||
const results: SyncResult[] = [];
|
||||
|
||||
for (const shop of shops) {
|
||||
const traceId = this.generateTraceId();
|
||||
|
||||
try {
|
||||
const dateRange = this.getDefaultDateRange();
|
||||
const orders = await this.collectOrders(
|
||||
shop.shopId,
|
||||
shop.platform,
|
||||
dateRange,
|
||||
traceId
|
||||
);
|
||||
|
||||
await this.uploadOrders(orders, shop.tenantId, traceId);
|
||||
|
||||
results.push({
|
||||
shopId: shop.shopId,
|
||||
platform: shop.platform,
|
||||
ordersCollected: orders.length,
|
||||
errors: [],
|
||||
traceId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
results.push({
|
||||
shopId: shop.shopId,
|
||||
platform: shop.platform,
|
||||
ordersCollected: 0,
|
||||
errors: [error.message],
|
||||
traceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info('Sync completed for all shops', {
|
||||
totalShops: shops.length,
|
||||
successCount: results.filter(r => r.errors.length === 0).length,
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async getRegisteredShops(): Promise<Array<{
|
||||
shopId: string;
|
||||
platform: string;
|
||||
tenantId: string;
|
||||
}>> {
|
||||
const data = await chrome.storage.local.get('shops');
|
||||
return data.shops || [];
|
||||
}
|
||||
|
||||
private getDefaultDateRange(): { start: Date; end: Date } {
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
private async uploadOrders(
|
||||
orders: OrderData[],
|
||||
tenantId: string,
|
||||
traceId: string
|
||||
): Promise<void> {
|
||||
const config = await chrome.storage.local.get('config');
|
||||
const apiEndpoint = config.config?.apiEndpoint || 'http://localhost:3003';
|
||||
|
||||
const response = await fetch(`${apiEndpoint}/api/orders/batch`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Id': tenantId,
|
||||
'X-Trace-Id': traceId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
orders,
|
||||
tenantId,
|
||||
traceId,
|
||||
businessType: 'TOC',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed: ${response.status}`);
|
||||
}
|
||||
|
||||
this.logger.info('Orders uploaded', {
|
||||
count: orders.length,
|
||||
tenantId,
|
||||
traceId,
|
||||
});
|
||||
}
|
||||
|
||||
private generateTraceId(): string {
|
||||
return `TRC-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
244
extension/src/background/RefundQuery.ts
Normal file
244
extension/src/background/RefundQuery.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { Logger } from '../utils/Logger';
|
||||
import { PlatformAdapter, TikTokAdapter, TemuAdapter, ShopeeAdapter } from '../platforms';
|
||||
|
||||
interface RefundStatus {
|
||||
refundId: string;
|
||||
returnId: string;
|
||||
orderId: string;
|
||||
platform: string;
|
||||
shopId: string;
|
||||
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | 'CANCELLED';
|
||||
amount: number;
|
||||
currency: string;
|
||||
method: string;
|
||||
processedAt?: string;
|
||||
failureReason?: string;
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
interface QueryResult {
|
||||
refundId: string;
|
||||
platform: string;
|
||||
status: RefundStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class RefundQuery {
|
||||
private logger = new Logger('RefundQuery');
|
||||
private platformAdapters: Map<string, PlatformAdapter> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.platformAdapters.set('tiktok', new TikTokAdapter());
|
||||
this.platformAdapters.set('temu', new TemuAdapter());
|
||||
this.platformAdapters.set('shopee', new ShopeeAdapter());
|
||||
}
|
||||
|
||||
async queryRefundStatus(
|
||||
platform: string,
|
||||
refundId: string,
|
||||
traceId?: string
|
||||
): Promise<RefundStatus> {
|
||||
const actualTraceId = traceId || this.generateTraceId();
|
||||
|
||||
this.logger.info('Querying refund status', {
|
||||
platform,
|
||||
refundId,
|
||||
traceId: actualTraceId,
|
||||
});
|
||||
|
||||
const adapter = this.platformAdapters.get(platform.toLowerCase());
|
||||
if (!adapter) {
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await adapter.getRefundStatus(refundId, actualTraceId);
|
||||
|
||||
this.logger.info('Refund status retrieved', {
|
||||
platform,
|
||||
refundId,
|
||||
status: status.status,
|
||||
traceId: actualTraceId,
|
||||
});
|
||||
|
||||
return status;
|
||||
} catch (error: any) {
|
||||
this.logger.error('Refund status query failed', {
|
||||
platform,
|
||||
refundId,
|
||||
error: error.message,
|
||||
traceId: actualTraceId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async queryAllRefunds(): Promise<QueryResult[]> {
|
||||
this.logger.info('Starting refund status query for all pending refunds');
|
||||
|
||||
const pendingRefunds = await this.getPendingRefunds();
|
||||
const results: QueryResult[] = [];
|
||||
|
||||
for (const refund of pendingRefunds) {
|
||||
const traceId = this.generateTraceId();
|
||||
|
||||
try {
|
||||
const status = await this.queryRefundStatus(
|
||||
refund.platform,
|
||||
refund.refundId,
|
||||
traceId
|
||||
);
|
||||
|
||||
await this.updateRefundStatus(status, refund.tenantId, traceId);
|
||||
|
||||
results.push({
|
||||
refundId: refund.refundId,
|
||||
platform: refund.platform,
|
||||
status,
|
||||
});
|
||||
} catch (error: any) {
|
||||
results.push({
|
||||
refundId: refund.refundId,
|
||||
platform: refund.platform,
|
||||
status: {
|
||||
refundId: refund.refundId,
|
||||
returnId: refund.returnId,
|
||||
orderId: refund.orderId,
|
||||
platform: refund.platform,
|
||||
shopId: refund.shopId,
|
||||
status: 'FAILED',
|
||||
amount: refund.amount,
|
||||
currency: refund.currency,
|
||||
method: 'UNKNOWN',
|
||||
failureReason: error.message,
|
||||
traceId,
|
||||
},
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info('Refund status query completed', {
|
||||
totalRefunds: pendingRefunds.length,
|
||||
successCount: results.filter(r => !r.error).length,
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async batchQueryRefunds(
|
||||
refunds: Array<{
|
||||
platform: string;
|
||||
refundId: string;
|
||||
}>,
|
||||
traceId?: string
|
||||
): Promise<QueryResult[]> {
|
||||
const actualTraceId = traceId || this.generateTraceId();
|
||||
|
||||
this.logger.info('Batch querying refund status', {
|
||||
count: refunds.length,
|
||||
traceId: actualTraceId,
|
||||
});
|
||||
|
||||
const results: QueryResult[] = [];
|
||||
|
||||
for (const refund of refunds) {
|
||||
try {
|
||||
const status = await this.queryRefundStatus(
|
||||
refund.platform,
|
||||
refund.refundId,
|
||||
actualTraceId
|
||||
);
|
||||
|
||||
results.push({
|
||||
refundId: refund.refundId,
|
||||
platform: refund.platform,
|
||||
status,
|
||||
});
|
||||
} catch (error: any) {
|
||||
results.push({
|
||||
refundId: refund.refundId,
|
||||
platform: refund.platform,
|
||||
status: {
|
||||
refundId: refund.refundId,
|
||||
returnId: '',
|
||||
orderId: '',
|
||||
platform: refund.platform,
|
||||
shopId: '',
|
||||
status: 'FAILED',
|
||||
amount: 0,
|
||||
currency: 'USD',
|
||||
method: 'UNKNOWN',
|
||||
failureReason: error.message,
|
||||
traceId: actualTraceId,
|
||||
},
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async getPendingRefunds(): Promise<Array<{
|
||||
refundId: string;
|
||||
returnId: string;
|
||||
orderId: string;
|
||||
platform: string;
|
||||
shopId: string;
|
||||
tenantId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
}>> {
|
||||
const data = await chrome.storage.local.get('pendingRefunds');
|
||||
return data.pendingRefunds || [];
|
||||
}
|
||||
|
||||
private async updateRefundStatus(
|
||||
status: RefundStatus,
|
||||
tenantId: string,
|
||||
traceId: string
|
||||
): Promise<void> {
|
||||
const config = await chrome.storage.local.get('config');
|
||||
const apiEndpoint = config.config?.apiEndpoint || 'http://localhost:3003';
|
||||
|
||||
const response = await fetch(`${apiEndpoint}/api/refunds/${status.refundId}/status`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Id': tenantId,
|
||||
'X-Trace-Id': traceId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: status.status,
|
||||
processedAt: status.processedAt,
|
||||
failureReason: status.failureReason,
|
||||
traceId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status update failed: ${response.status}`);
|
||||
}
|
||||
|
||||
if (status.status === 'COMPLETED' || status.status === 'CANCELLED') {
|
||||
const data = await chrome.storage.local.get('pendingRefunds');
|
||||
const pendingRefunds = data.pendingRefunds || [];
|
||||
const updatedRefunds = pendingRefunds.filter(
|
||||
(r: any) => r.refundId !== status.refundId
|
||||
);
|
||||
await chrome.storage.local.set({ pendingRefunds: updatedRefunds });
|
||||
}
|
||||
|
||||
this.logger.info('Refund status updated', {
|
||||
refundId: status.refundId,
|
||||
status: status.status,
|
||||
tenantId,
|
||||
traceId,
|
||||
});
|
||||
}
|
||||
|
||||
private generateTraceId(): string {
|
||||
return `TRC-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
192
extension/src/background/ReturnSync.ts
Normal file
192
extension/src/background/ReturnSync.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Logger } from '../utils/Logger';
|
||||
import { PlatformAdapter, TikTokAdapter, TemuAdapter, ShopeeAdapter } from '../platforms';
|
||||
|
||||
interface ReturnData {
|
||||
returnId: string;
|
||||
orderId: string;
|
||||
platform: string;
|
||||
shopId: string;
|
||||
status: string;
|
||||
reason: string;
|
||||
items: ReturnItem[];
|
||||
refundAmount: number;
|
||||
currency: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
interface ReturnItem {
|
||||
productId: string;
|
||||
skuId: string;
|
||||
quantity: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface SyncResult {
|
||||
shopId: string;
|
||||
platform: string;
|
||||
returnsSynced: number;
|
||||
errors: string[];
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
export class ReturnSync {
|
||||
private logger = new Logger('ReturnSync');
|
||||
private platformAdapters: Map<string, PlatformAdapter> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.platformAdapters.set('tiktok', new TikTokAdapter());
|
||||
this.platformAdapters.set('temu', new TemuAdapter());
|
||||
this.platformAdapters.set('shopee', new ShopeeAdapter());
|
||||
}
|
||||
|
||||
async syncReturns(
|
||||
shopId: string,
|
||||
platform: string,
|
||||
returnIds?: string[],
|
||||
traceId?: string
|
||||
): Promise<ReturnData[]> {
|
||||
const actualTraceId = traceId || this.generateTraceId();
|
||||
|
||||
this.logger.info('Starting return sync', {
|
||||
shopId,
|
||||
platform,
|
||||
returnIds,
|
||||
traceId: actualTraceId,
|
||||
});
|
||||
|
||||
const adapter = this.platformAdapters.get(platform.toLowerCase());
|
||||
if (!adapter) {
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const returns = await adapter.fetchReturns(shopId, returnIds, actualTraceId);
|
||||
|
||||
this.logger.info('Returns synced', {
|
||||
shopId,
|
||||
platform,
|
||||
count: returns.length,
|
||||
traceId: actualTraceId,
|
||||
});
|
||||
|
||||
return returns;
|
||||
} catch (error: any) {
|
||||
this.logger.error('Return sync failed', {
|
||||
shopId,
|
||||
platform,
|
||||
error: error.message,
|
||||
traceId: actualTraceId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async syncAllReturns(): Promise<SyncResult[]> {
|
||||
this.logger.info('Starting return sync for all shops');
|
||||
|
||||
const shops = await this.getRegisteredShops();
|
||||
const results: SyncResult[] = [];
|
||||
|
||||
for (const shop of shops) {
|
||||
const traceId = this.generateTraceId();
|
||||
|
||||
try {
|
||||
const returns = await this.syncReturns(
|
||||
shop.shopId,
|
||||
shop.platform,
|
||||
undefined,
|
||||
traceId
|
||||
);
|
||||
|
||||
await this.uploadReturns(returns, shop.tenantId, traceId);
|
||||
|
||||
results.push({
|
||||
shopId: shop.shopId,
|
||||
platform: shop.platform,
|
||||
returnsSynced: returns.length,
|
||||
errors: [],
|
||||
traceId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
results.push({
|
||||
shopId: shop.shopId,
|
||||
platform: shop.platform,
|
||||
returnsSynced: 0,
|
||||
errors: [error.message],
|
||||
traceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info('Return sync completed for all shops', {
|
||||
totalShops: shops.length,
|
||||
successCount: results.filter(r => r.errors.length === 0).length,
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async getReturnStatus(
|
||||
platform: string,
|
||||
returnId: string,
|
||||
traceId?: string
|
||||
): Promise<ReturnData> {
|
||||
const actualTraceId = traceId || this.generateTraceId();
|
||||
|
||||
const adapter = this.platformAdapters.get(platform.toLowerCase());
|
||||
if (!adapter) {
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
|
||||
return adapter.getReturnById(returnId, actualTraceId);
|
||||
}
|
||||
|
||||
private async getRegisteredShops(): Promise<Array<{
|
||||
shopId: string;
|
||||
platform: string;
|
||||
tenantId: string;
|
||||
}>> {
|
||||
const data = await chrome.storage.local.get('shops');
|
||||
return data.shops || [];
|
||||
}
|
||||
|
||||
private async uploadReturns(
|
||||
returns: ReturnData[],
|
||||
tenantId: string,
|
||||
traceId: string
|
||||
): Promise<void> {
|
||||
const config = await chrome.storage.local.get('config');
|
||||
const apiEndpoint = config.config?.apiEndpoint || 'http://localhost:3003';
|
||||
|
||||
const response = await fetch(`${apiEndpoint}/api/returns/batch`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Id': tenantId,
|
||||
'X-Trace-Id': traceId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
returns,
|
||||
tenantId,
|
||||
traceId,
|
||||
businessType: 'TOC',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed: ${response.status}`);
|
||||
}
|
||||
|
||||
this.logger.info('Returns uploaded', {
|
||||
count: returns.length,
|
||||
tenantId,
|
||||
traceId,
|
||||
});
|
||||
}
|
||||
|
||||
private generateTraceId(): string {
|
||||
return `TRC-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
||||
71
extension/src/background/index.ts
Normal file
71
extension/src/background/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { OrderCollector } from './OrderCollector';
|
||||
import { ReturnSync } from './ReturnSync';
|
||||
import { RefundQuery } from './RefundQuery';
|
||||
import { MessageHandler } from './MessageHandler';
|
||||
import { Logger } from '../utils/Logger';
|
||||
|
||||
const logger = new Logger('Background');
|
||||
|
||||
const orderCollector = new OrderCollector();
|
||||
const returnSync = new ReturnSync();
|
||||
const refundQuery = new RefundQuery();
|
||||
const messageHandler = new MessageHandler(orderCollector, returnSync, refundQuery);
|
||||
|
||||
chrome.runtime.onInstalled.addListener((details) => {
|
||||
logger.info('Extension installed', { reason: details.reason });
|
||||
|
||||
if (details.reason === 'install') {
|
||||
chrome.storage.local.set({
|
||||
initialized: true,
|
||||
config: {
|
||||
apiEndpoint: 'http://localhost:3003',
|
||||
syncInterval: 300000,
|
||||
maxRetries: 3,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
logger.info('Message received', { type: message.type, sender: sender.tab?.id });
|
||||
|
||||
messageHandler.handle(message, sender)
|
||||
.then((result) => sendResponse({ success: true, data: result }))
|
||||
.catch((error) => {
|
||||
logger.error('Message handler error', { error: error.message });
|
||||
sendResponse({ success: false, error: error.message });
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
logger.info('Alarm triggered', { name: alarm.name });
|
||||
|
||||
switch (alarm.name) {
|
||||
case 'orderSync':
|
||||
await orderCollector.syncAllShops();
|
||||
break;
|
||||
case 'returnSync':
|
||||
await returnSync.syncAllReturns();
|
||||
break;
|
||||
case 'refundQuery':
|
||||
await refundQuery.queryAllRefunds();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
async function setupAlarms() {
|
||||
const config = await chrome.storage.local.get('config');
|
||||
const syncInterval = config.config?.syncInterval || 300000;
|
||||
|
||||
chrome.alarms.create('orderSync', { periodInMinutes: syncInterval / 60000 });
|
||||
chrome.alarms.create('returnSync', { periodInMinutes: (syncInterval * 2) / 60000 });
|
||||
chrome.alarms.create('refundQuery', { periodInMinutes: (syncInterval * 3) / 60000 });
|
||||
|
||||
logger.info('Alarms setup complete', { syncInterval });
|
||||
}
|
||||
|
||||
setupAlarms();
|
||||
|
||||
export { orderCollector, returnSync, refundQuery };
|
||||
13
extension/src/background/services.ts
Normal file
13
extension/src/background/services.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export { OrderCollector } from './OrderCollector';
|
||||
export { ReturnSync } from './ReturnSync';
|
||||
export { RefundQuery } from './RefundQuery';
|
||||
export { MessageHandler } from './MessageHandler';
|
||||
export { DOMParser } from './DOMParser';
|
||||
export { FingerprintManager } from './FingerprintManager';
|
||||
export { AutoShipService } from './AutoShipService';
|
||||
export { LogisticsSyncService } from './LogisticsSyncService';
|
||||
export type { LogisticsInfo, LogisticsStatus, SyncResult } from './LogisticsSyncService';
|
||||
export { ABTestStrategyService } from './ABTestStrategyService';
|
||||
export type { TestGoal, TestData, StrategyRecommendation, Variation } from './ABTestStrategyService';
|
||||
export { ABTestOptimizationService } from './ABTestOptimizationService';
|
||||
export type { TestResult, VariationResult, OptimizationGoal, OptimizationRecommendation, OptimizationAction, FollowUpTest } from './ABTestOptimizationService';
|
||||
146
extension/src/content/index.ts
Normal file
146
extension/src/content/index.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Logger } from '../utils/Logger';
|
||||
|
||||
const logger = new Logger('ContentScript');
|
||||
|
||||
logger.info('Content script loaded', { url: window.location.href });
|
||||
|
||||
const platform = detectPlatform();
|
||||
|
||||
function detectPlatform(): string {
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
if (hostname.includes('tiktok')) return 'tiktok';
|
||||
if (hostname.includes('temu')) return 'temu';
|
||||
if (hostname.includes('shopee')) return 'shopee';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
logger.info('Content script received message', { type: message.type, platform });
|
||||
|
||||
switch (message.type) {
|
||||
case 'EXTRACT_PAGE_DATA':
|
||||
const data = extractPageData(message.payload?.selectors);
|
||||
sendResponse({ success: true, data });
|
||||
break;
|
||||
|
||||
case 'GET_PAGE_ORDERS':
|
||||
const orders = extractOrders();
|
||||
sendResponse({ success: true, orders });
|
||||
break;
|
||||
|
||||
case 'GET_PAGE_RETURNS':
|
||||
const returns = extractReturns();
|
||||
sendResponse({ success: true, returns });
|
||||
break;
|
||||
|
||||
case 'CHECK_LOGIN_STATUS':
|
||||
const isLoggedIn = checkLoginStatus();
|
||||
sendResponse({ success: true, isLoggedIn });
|
||||
break;
|
||||
|
||||
default:
|
||||
sendResponse({ success: false, error: 'Unknown message type' });
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
function extractPageData(selectors?: Record<string, string>): Record<string, any> {
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
if (!selectors) {
|
||||
return data;
|
||||
}
|
||||
|
||||
for (const [key, selector] of Object.entries(selectors)) {
|
||||
const element = document.querySelector(selector);
|
||||
if (element) {
|
||||
data[key] = element.textContent?.trim() || '';
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function extractOrders(): any[] {
|
||||
const orders: any[] = [];
|
||||
|
||||
const orderElements = document.querySelectorAll('[data-order-id]');
|
||||
|
||||
for (const el of orderElements) {
|
||||
const orderId = el.getAttribute('data-order-id');
|
||||
const statusEl = el.querySelector('[data-order-status]');
|
||||
const amountEl = el.querySelector('[data-order-amount]');
|
||||
|
||||
if (orderId) {
|
||||
orders.push({
|
||||
orderId,
|
||||
platform,
|
||||
status: statusEl?.textContent?.trim() || 'UNKNOWN',
|
||||
totalAmount: parseFloat(amountEl?.textContent?.replace(/[^0-9.]/g, '') || '0'),
|
||||
currency: getCurrencyForPlatform(platform),
|
||||
extractedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Orders extracted', { count: orders.length, platform });
|
||||
|
||||
return orders;
|
||||
}
|
||||
|
||||
function extractReturns(): any[] {
|
||||
const returns: any[] = [];
|
||||
|
||||
const returnElements = document.querySelectorAll('[data-return-id]');
|
||||
|
||||
for (const el of returnElements) {
|
||||
const returnId = el.getAttribute('data-return-id');
|
||||
const orderId = el.getAttribute('data-order-id');
|
||||
const statusEl = el.querySelector('[data-return-status]');
|
||||
|
||||
if (returnId) {
|
||||
returns.push({
|
||||
returnId,
|
||||
orderId,
|
||||
platform,
|
||||
status: statusEl?.textContent?.trim() || 'UNKNOWN',
|
||||
extractedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Returns extracted', { count: returns.length, platform });
|
||||
|
||||
return returns;
|
||||
}
|
||||
|
||||
function checkLoginStatus(): boolean {
|
||||
const loginIndicators = {
|
||||
tiktok: () => !!document.querySelector('[data-user-avatar]'),
|
||||
temu: () => !!document.querySelector('[data-user-name]'),
|
||||
shopee: () => !!document.querySelector('[data-shop-name]'),
|
||||
};
|
||||
|
||||
const checker = loginIndicators[platform as keyof typeof loginIndicators];
|
||||
|
||||
if (checker) {
|
||||
return checker();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getCurrencyForPlatform(platform: string): string {
|
||||
const currencies: Record<string, string> = {
|
||||
tiktok: 'USD',
|
||||
temu: 'USD',
|
||||
shopee: 'MYR',
|
||||
};
|
||||
|
||||
return currencies[platform] || 'USD';
|
||||
}
|
||||
|
||||
export { extractPageData, extractOrders, extractReturns, checkLoginStatus };
|
||||
382
extension/src/platforms/index.ts
Normal file
382
extension/src/platforms/index.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
export interface PlatformAdapter {
|
||||
fetchOrders(
|
||||
shopId: string,
|
||||
dateRange: { start: Date; end: Date },
|
||||
traceId: string
|
||||
): Promise<any[]>;
|
||||
|
||||
fetchReturns(
|
||||
shopId: string,
|
||||
returnIds?: string[],
|
||||
traceId?: string
|
||||
): Promise<any[]>;
|
||||
|
||||
getReturnById(returnId: string, traceId: string): Promise<any>;
|
||||
|
||||
getRefundStatus(refundId: string, traceId: string): Promise<any>;
|
||||
}
|
||||
|
||||
import { Logger } from '../utils/Logger';
|
||||
|
||||
export class TikTokAdapter implements PlatformAdapter {
|
||||
private logger = new Logger('TikTokAdapter');
|
||||
|
||||
async fetchOrders(
|
||||
shopId: string,
|
||||
dateRange: { start: Date; end: Date },
|
||||
traceId: string
|
||||
): Promise<any[]> {
|
||||
this.logger.info('Fetching TikTok orders', { shopId, dateRange, traceId });
|
||||
|
||||
return this.executeInTab('https://seller.tiktok.com/orders', async () => {
|
||||
const orders: any[] = [];
|
||||
|
||||
const orderElements = document.querySelectorAll('[data-order-id]');
|
||||
|
||||
for (const el of orderElements) {
|
||||
const orderId = el.getAttribute('data-order-id');
|
||||
const statusEl = el.querySelector('[data-order-status]');
|
||||
const amountEl = el.querySelector('[data-order-amount]');
|
||||
|
||||
if (orderId) {
|
||||
orders.push({
|
||||
orderId,
|
||||
platform: 'tiktok',
|
||||
shopId,
|
||||
status: statusEl?.textContent?.trim() || 'UNKNOWN',
|
||||
totalAmount: parseFloat(amountEl?.textContent?.replace(/[^0-9.]/g, '') || '0'),
|
||||
currency: 'USD',
|
||||
items: [],
|
||||
customerInfo: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
traceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return orders;
|
||||
});
|
||||
}
|
||||
|
||||
async fetchReturns(
|
||||
shopId: string,
|
||||
returnIds?: string[],
|
||||
traceId?: string
|
||||
): Promise<any[]> {
|
||||
this.logger.info('Fetching TikTok returns', { shopId, returnIds, traceId });
|
||||
|
||||
return this.executeInTab('https://seller.tiktok.com/returns', async () => {
|
||||
const returns: any[] = [];
|
||||
|
||||
const returnElements = document.querySelectorAll('[data-return-id]');
|
||||
|
||||
for (const el of returnElements) {
|
||||
const returnId = el.getAttribute('data-return-id');
|
||||
const orderId = el.getAttribute('data-order-id');
|
||||
const statusEl = el.querySelector('[data-return-status]');
|
||||
|
||||
if (returnId) {
|
||||
returns.push({
|
||||
returnId,
|
||||
orderId,
|
||||
platform: 'tiktok',
|
||||
shopId,
|
||||
status: statusEl?.textContent?.trim() || 'UNKNOWN',
|
||||
reason: '',
|
||||
items: [],
|
||||
refundAmount: 0,
|
||||
currency: 'USD',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
traceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return returns;
|
||||
});
|
||||
}
|
||||
|
||||
async getReturnById(returnId: string, traceId: string): Promise<any> {
|
||||
this.logger.info('Getting TikTok return by ID', { returnId, traceId });
|
||||
|
||||
return {
|
||||
returnId,
|
||||
orderId: '',
|
||||
platform: 'tiktok',
|
||||
shopId: '',
|
||||
status: 'UNKNOWN',
|
||||
reason: '',
|
||||
items: [],
|
||||
refundAmount: 0,
|
||||
currency: 'USD',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
traceId,
|
||||
};
|
||||
}
|
||||
|
||||
async getRefundStatus(refundId: string, traceId: string): Promise<any> {
|
||||
this.logger.info('Getting TikTok refund status', { refundId, traceId });
|
||||
|
||||
return {
|
||||
refundId,
|
||||
returnId: '',
|
||||
orderId: '',
|
||||
platform: 'tiktok',
|
||||
shopId: '',
|
||||
status: 'PENDING',
|
||||
amount: 0,
|
||||
currency: 'USD',
|
||||
method: 'ORIGINAL_PAYMENT',
|
||||
traceId,
|
||||
};
|
||||
}
|
||||
|
||||
private async executeInTab<T>(url: string, extractor: () => T): Promise<T> {
|
||||
return extractor();
|
||||
}
|
||||
}
|
||||
|
||||
export class TemuAdapter implements PlatformAdapter {
|
||||
private logger = new Logger('TemuAdapter');
|
||||
|
||||
async fetchOrders(
|
||||
shopId: string,
|
||||
dateRange: { start: Date; end: Date },
|
||||
traceId: string
|
||||
): Promise<any[]> {
|
||||
this.logger.info('Fetching Temu orders', { shopId, dateRange, traceId });
|
||||
|
||||
return this.executeInTab('https://agentseller.temu.com/orders', async () => {
|
||||
const orders: any[] = [];
|
||||
|
||||
const orderElements = document.querySelectorAll('[data-order-id]');
|
||||
|
||||
for (const el of orderElements) {
|
||||
const orderId = el.getAttribute('data-order-id');
|
||||
const statusEl = el.querySelector('[data-order-status]');
|
||||
const amountEl = el.querySelector('[data-order-amount]');
|
||||
|
||||
if (orderId) {
|
||||
orders.push({
|
||||
orderId,
|
||||
platform: 'temu',
|
||||
shopId,
|
||||
status: statusEl?.textContent?.trim() || 'UNKNOWN',
|
||||
totalAmount: parseFloat(amountEl?.textContent?.replace(/[^0-9.]/g, '') || '0'),
|
||||
currency: 'USD',
|
||||
items: [],
|
||||
customerInfo: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
traceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return orders;
|
||||
});
|
||||
}
|
||||
|
||||
async fetchReturns(
|
||||
shopId: string,
|
||||
returnIds?: string[],
|
||||
traceId?: string
|
||||
): Promise<any[]> {
|
||||
this.logger.info('Fetching Temu returns', { shopId, returnIds, traceId });
|
||||
|
||||
return this.executeInTab('https://agentseller.temu.com/returns', async () => {
|
||||
const returns: any[] = [];
|
||||
|
||||
const returnElements = document.querySelectorAll('[data-return-id]');
|
||||
|
||||
for (const el of returnElements) {
|
||||
const returnId = el.getAttribute('data-return-id');
|
||||
const orderId = el.getAttribute('data-order-id');
|
||||
const statusEl = el.querySelector('[data-return-status]');
|
||||
|
||||
if (returnId) {
|
||||
returns.push({
|
||||
returnId,
|
||||
orderId,
|
||||
platform: 'temu',
|
||||
shopId,
|
||||
status: statusEl?.textContent?.trim() || 'UNKNOWN',
|
||||
reason: '',
|
||||
items: [],
|
||||
refundAmount: 0,
|
||||
currency: 'USD',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
traceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return returns;
|
||||
});
|
||||
}
|
||||
|
||||
async getReturnById(returnId: string, traceId: string): Promise<any> {
|
||||
this.logger.info('Getting Temu return by ID', { returnId, traceId });
|
||||
|
||||
return {
|
||||
returnId,
|
||||
orderId: '',
|
||||
platform: 'temu',
|
||||
shopId: '',
|
||||
status: 'UNKNOWN',
|
||||
reason: '',
|
||||
items: [],
|
||||
refundAmount: 0,
|
||||
currency: 'USD',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
traceId,
|
||||
};
|
||||
}
|
||||
|
||||
async getRefundStatus(refundId: string, traceId: string): Promise<any> {
|
||||
this.logger.info('Getting Temu refund status', { refundId, traceId });
|
||||
|
||||
return {
|
||||
refundId,
|
||||
returnId: '',
|
||||
orderId: '',
|
||||
platform: 'temu',
|
||||
shopId: '',
|
||||
status: 'PENDING',
|
||||
amount: 0,
|
||||
currency: 'USD',
|
||||
method: 'ORIGINAL_PAYMENT',
|
||||
traceId,
|
||||
};
|
||||
}
|
||||
|
||||
private async executeInTab<T>(url: string, extractor: () => T): Promise<T> {
|
||||
return extractor();
|
||||
}
|
||||
}
|
||||
|
||||
export class ShopeeAdapter implements PlatformAdapter {
|
||||
private logger = new Logger('ShopeeAdapter');
|
||||
|
||||
async fetchOrders(
|
||||
shopId: string,
|
||||
dateRange: { start: Date; end: Date },
|
||||
traceId: string
|
||||
): Promise<any[]> {
|
||||
this.logger.info('Fetching Shopee orders', { shopId, dateRange, traceId });
|
||||
|
||||
return this.executeInTab('https://shopee.com.my/seller/orders', async () => {
|
||||
const orders: any[] = [];
|
||||
|
||||
const orderElements = document.querySelectorAll('[data-order-id]');
|
||||
|
||||
for (const el of orderElements) {
|
||||
const orderId = el.getAttribute('data-order-id');
|
||||
const statusEl = el.querySelector('[data-order-status]');
|
||||
const amountEl = el.querySelector('[data-order-amount]');
|
||||
|
||||
if (orderId) {
|
||||
orders.push({
|
||||
orderId,
|
||||
platform: 'shopee',
|
||||
shopId,
|
||||
status: statusEl?.textContent?.trim() || 'UNKNOWN',
|
||||
totalAmount: parseFloat(amountEl?.textContent?.replace(/[^0-9.]/g, '') || '0'),
|
||||
currency: 'MYR',
|
||||
items: [],
|
||||
customerInfo: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
traceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return orders;
|
||||
});
|
||||
}
|
||||
|
||||
async fetchReturns(
|
||||
shopId: string,
|
||||
returnIds?: string[],
|
||||
traceId?: string
|
||||
): Promise<any[]> {
|
||||
this.logger.info('Fetching Shopee returns', { shopId, returnIds, traceId });
|
||||
|
||||
return this.executeInTab('https://shopee.com.my/seller/returns', async () => {
|
||||
const returns: any[] = [];
|
||||
|
||||
const returnElements = document.querySelectorAll('[data-return-id]');
|
||||
|
||||
for (const el of returnElements) {
|
||||
const returnId = el.getAttribute('data-return-id');
|
||||
const orderId = el.getAttribute('data-order-id');
|
||||
const statusEl = el.querySelector('[data-return-status]');
|
||||
|
||||
if (returnId) {
|
||||
returns.push({
|
||||
returnId,
|
||||
orderId,
|
||||
platform: 'shopee',
|
||||
shopId,
|
||||
status: statusEl?.textContent?.trim() || 'UNKNOWN',
|
||||
reason: '',
|
||||
items: [],
|
||||
refundAmount: 0,
|
||||
currency: 'MYR',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
traceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return returns;
|
||||
});
|
||||
}
|
||||
|
||||
async getReturnById(returnId: string, traceId: string): Promise<any> {
|
||||
this.logger.info('Getting Shopee return by ID', { returnId, traceId });
|
||||
|
||||
return {
|
||||
returnId,
|
||||
orderId: '',
|
||||
platform: 'shopee',
|
||||
shopId: '',
|
||||
status: 'UNKNOWN',
|
||||
reason: '',
|
||||
items: [],
|
||||
refundAmount: 0,
|
||||
currency: 'MYR',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
traceId,
|
||||
};
|
||||
}
|
||||
|
||||
async getRefundStatus(refundId: string, traceId: string): Promise<any> {
|
||||
this.logger.info('Getting Shopee refund status', { refundId, traceId });
|
||||
|
||||
return {
|
||||
refundId,
|
||||
returnId: '',
|
||||
orderId: '',
|
||||
platform: 'shopee',
|
||||
shopId: '',
|
||||
status: 'PENDING',
|
||||
amount: 0,
|
||||
currency: 'MYR',
|
||||
method: 'ORIGINAL_PAYMENT',
|
||||
traceId,
|
||||
};
|
||||
}
|
||||
|
||||
private async executeInTab<T>(url: string, extractor: () => T): Promise<T> {
|
||||
return extractor();
|
||||
}
|
||||
}
|
||||
29
extension/src/utils/Logger.ts
Normal file
29
extension/src/utils/Logger.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export class Logger {
|
||||
private context: string;
|
||||
|
||||
constructor(context: string) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
info(message: string, data?: any): void {
|
||||
console.log(`[${this.getTimestamp()}] [${this.context}] [INFO] ${message}`, data || '');
|
||||
}
|
||||
|
||||
warn(message: string, data?: any): void {
|
||||
console.warn(`[${this.getTimestamp()}] [${this.context}] [WARN] ${message}`, data || '');
|
||||
}
|
||||
|
||||
error(message: string, data?: any): void {
|
||||
console.error(`[${this.getTimestamp()}] [${this.context}] [ERROR] ${message}`, data || '');
|
||||
}
|
||||
|
||||
debug(message: string, data?: any): void {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.debug(`[${this.getTimestamp()}] [${this.context}] [DEBUG] ${message}`, data || '');
|
||||
}
|
||||
}
|
||||
|
||||
private getTimestamp(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
}
|
||||
1
extension/src/utils/index.ts
Normal file
1
extension/src/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Logger } from './Logger';
|
||||
Reference in New Issue
Block a user