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

674 lines
19 KiB
TypeScript

import db from '../config/database';
import { logger } from '../utils/logger';
export interface ReturnRequest {
orderId: string;
reason: string;
items: ReturnItem[];
tenantId: string;
shopId: string;
taskId: string;
traceId: string;
businessType: 'TOC' | 'TOB';
}
export interface ReturnItem {
productId: string;
skuId: string;
quantity: number;
reason?: string;
}
export interface ReturnRecord {
id: string;
orderId: string;
status: ReturnStatus;
reason: string;
items: ReturnItem[];
totalRefundAmount: number;
tenantId: string;
shopId: string;
taskId: string;
traceId: string;
businessType: 'TOC' | 'TOB';
createdAt: Date;
updatedAt: Date;
}
export type ReturnStatus =
| 'PENDING'
| 'APPROVED'
| 'REJECTED'
| 'RETURNED'
| 'REFUNDED'
| 'CLOSED';
export interface RefundRequest {
returnId: string;
approvalResult: 'APPROVED' | 'REJECTED';
approvedAmount?: number;
reason?: string;
tenantId: string;
shopId: string;
taskId: string;
traceId: string;
businessType: 'TOC' | 'TOB';
}
export interface RefundRecord {
id: string;
returnId: string;
orderId: string;
amount: number;
status: RefundStatus;
method: string;
tenantId: string;
shopId: string;
taskId: string;
traceId: string;
businessType: 'TOC' | 'TOB';
createdAt: Date;
updatedAt: Date;
}
export type RefundStatus =
| 'PENDING_REVIEW'
| 'APPROVED'
| 'PROCESSING'
| 'COMPLETED'
| 'FAILED'
| 'CANCELLED';
export interface ServiceTicket {
id: string;
returnId: string;
type: TicketType;
priority: TicketPriority;
status: TicketStatus;
subject: string;
description: string;
messages: TicketMessage[];
tenantId: string;
shopId: string;
taskId: string;
traceId: string;
businessType: 'TOC' | 'TOB';
createdAt: Date;
updatedAt: Date;
}
export type TicketType =
| 'RETURN'
| 'REFUND'
| 'EXCHANGE'
| 'COMPLAINT'
| 'INQUIRY';
export type TicketPriority =
| 'LOW'
| 'MEDIUM'
| 'HIGH'
| 'URGENT';
export type TicketStatus =
| 'OPEN'
| 'IN_PROGRESS'
| 'WAITING_CUSTOMER'
| 'WAITING_INTERNAL'
| 'RESOLVED'
| 'CLOSED';
export interface TicketMessage {
id: string;
sender: 'CUSTOMER' | 'AGENT' | 'SYSTEM';
content: string;
attachments?: string[];
createdAt: Date;
}
export class AfterSalesService {
static async createReturnRequest(input: ReturnRequest): Promise<ReturnRecord> {
const { orderId, reason, items, tenantId, shopId, taskId, traceId, businessType } = input;
logger.info('[AfterSalesService] Creating return request', {
orderId,
tenantId,
shopId,
taskId,
traceId,
businessType,
itemCount: items.length,
});
try {
const order = await this.getOrderById(orderId, tenantId, traceId);
if (!order) {
throw new Error(`Order not found: ${orderId}`);
}
if (order.status !== 'DELIVERED' && order.status !== 'COMPLETED') {
throw new Error(`Order status ${order.status} does not allow return`);
}
const returnDaysLimit = 30;
const deliveredAt = order.deliveredAt || order.updatedAt;
const daysSinceDelivery = Math.floor(
(Date.now() - deliveredAt.getTime()) / (1000 * 60 * 60 * 24)
);
if (daysSinceDelivery > returnDaysLimit) {
throw new Error(`Return period expired (${daysSinceDelivery} days)`);
}
const returnId = `RET-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const totalRefundAmount = await this.calculateRefundAmount(
order,
items,
tenantId,
traceId
);
const returnRecord: ReturnRecord = {
id: returnId,
orderId,
status: 'PENDING',
reason,
items,
totalRefundAmount,
tenantId,
shopId,
taskId,
traceId,
businessType,
createdAt: new Date(),
updatedAt: new Date(),
};
await db('cf_return_requests').insert({
id: returnRecord.id,
order_id: returnRecord.orderId,
status: returnRecord.status,
reason: returnRecord.reason,
items: JSON.stringify(returnRecord.items),
total_refund_amount: returnRecord.totalRefundAmount,
tenant_id: returnRecord.tenantId,
shop_id: returnRecord.shopId,
task_id: returnRecord.taskId,
trace_id: returnRecord.traceId,
business_type: returnRecord.businessType,
created_at: returnRecord.createdAt,
updated_at: returnRecord.updatedAt,
});
await this.createServiceTicket({
returnId,
type: 'RETURN',
priority: this.determinePriority(reason),
subject: `Return Request - Order ${orderId}`,
description: reason,
tenantId,
shopId,
taskId,
traceId,
businessType,
});
logger.info('[AfterSalesService] Return request created', {
returnId,
orderId,
tenantId,
traceId,
status: returnRecord.status,
});
return returnRecord;
} catch (error: any) {
logger.error('[AfterSalesService] Failed to create return request', {
orderId,
tenantId,
traceId,
error: error.message,
});
throw error;
}
}
static async processRefund(input: RefundRequest): Promise<RefundRecord> {
const { returnId, approvalResult, approvedAmount, reason, tenantId, shopId, taskId, traceId, businessType } = input;
logger.info('[AfterSalesService] Processing refund', {
returnId,
approvalResult,
tenantId,
shopId,
taskId,
traceId,
businessType,
});
try {
const returnRecord = await this.getReturnById(returnId, tenantId, traceId);
if (!returnRecord) {
throw new Error(`Return request not found: ${returnId}`);
}
if (returnRecord.status !== 'PENDING' && returnRecord.status !== 'APPROVED') {
throw new Error(`Return status ${returnRecord.status} cannot be refunded`);
}
const refundId = `REF-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const refundAmount = approvedAmount || returnRecord.totalRefundAmount;
let refundStatus: RefundStatus;
if (approvalResult === 'REJECTED') {
refundStatus = 'CANCELLED';
} else {
refundStatus = 'PENDING_REVIEW';
}
const refundRecord: RefundRecord = {
id: refundId,
returnId,
orderId: returnRecord.orderId,
amount: refundAmount,
status: refundStatus,
method: 'ORIGINAL_PAYMENT',
tenantId,
shopId,
taskId,
traceId,
businessType,
createdAt: new Date(),
updatedAt: new Date(),
};
await db('cf_refunds').insert({
id: refundRecord.id,
return_id: refundRecord.returnId,
order_id: refundRecord.orderId,
amount: refundRecord.amount,
status: refundRecord.status,
method: refundRecord.method,
tenant_id: refundRecord.tenantId,
shop_id: refundRecord.shopId,
task_id: refundRecord.taskId,
trace_id: refundRecord.traceId,
business_type: refundRecord.businessType,
created_at: refundRecord.createdAt,
updated_at: refundRecord.updatedAt,
});
if (approvalResult === 'APPROVED') {
await db('cf_return_requests')
.where({ id: returnId, tenant_id: tenantId })
.update({ status: 'APPROVED', updated_at: new Date() });
await this.executeRefundWorkflow(refundRecord, tenantId, traceId);
} else {
await db('cf_return_requests')
.where({ id: returnId, tenant_id: tenantId })
.update({ status: 'REJECTED', updated_at: new Date() });
}
logger.info('[AfterSalesService] Refund processed', {
refundId,
returnId,
status: refundRecord.status,
tenantId,
traceId,
});
return refundRecord;
} catch (error: any) {
logger.error('[AfterSalesService] Failed to process refund', {
returnId,
tenantId,
traceId,
error: error.message,
});
throw error;
}
}
static async createServiceTicket(input: {
returnId: string;
type: TicketType;
priority: TicketPriority;
subject: string;
description: string;
tenantId: string;
shopId: string;
taskId: string;
traceId: string;
businessType: 'TOC' | 'TOB';
}): Promise<ServiceTicket> {
const { returnId, type, priority, subject, description, tenantId, shopId, taskId, traceId, businessType } = input;
logger.info('[AfterSalesService] Creating service ticket', {
returnId,
type,
priority,
tenantId,
shopId,
taskId,
traceId,
businessType,
});
try {
const ticketId = `TKT-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const initialMessage: TicketMessage = {
id: `MSG-${Date.now()}`,
sender: 'SYSTEM',
content: `Ticket created: ${subject}`,
createdAt: new Date(),
};
const ticket: ServiceTicket = {
id: ticketId,
returnId,
type,
priority,
status: 'OPEN',
subject,
description,
messages: [initialMessage],
tenantId,
shopId,
taskId,
traceId,
businessType,
createdAt: new Date(),
updatedAt: new Date(),
};
await db('cf_service_tickets').insert({
id: ticket.id,
return_id: ticket.returnId,
type: ticket.type,
priority: ticket.priority,
status: ticket.status,
subject: ticket.subject,
description: ticket.description,
messages: JSON.stringify(ticket.messages),
tenant_id: ticket.tenantId,
shop_id: ticket.shopId,
task_id: ticket.taskId,
trace_id: ticket.traceId,
business_type: ticket.businessType,
created_at: ticket.createdAt,
updated_at: ticket.updatedAt,
});
logger.info('[AfterSalesService] Service ticket created', {
ticketId,
returnId,
type,
priority,
tenantId,
traceId,
});
return ticket;
} catch (error: any) {
logger.error('[AfterSalesService] Failed to create service ticket', {
returnId,
tenantId,
traceId,
error: error.message,
});
throw error;
}
}
static async addTicketMessage(
ticketId: string,
sender: 'CUSTOMER' | 'AGENT' | 'SYSTEM',
content: string,
options: {
tenantId: string;
traceId: string;
attachments?: string[];
}
): Promise<ServiceTicket> {
const { tenantId, traceId, attachments } = options;
logger.info('[AfterSalesService] Adding ticket message', {
ticketId,
sender,
tenantId,
traceId,
});
try {
const ticket = await db('cf_service_tickets')
.where({ id: ticketId, tenant_id: tenantId })
.first();
if (!ticket) {
throw new Error(`Ticket not found: ${ticketId}`);
}
const messages: TicketMessage[] = JSON.parse(ticket.messages || '[]');
const newMessage: TicketMessage = {
id: `MSG-${Date.now()}`,
sender,
content,
attachments,
createdAt: new Date(),
};
messages.push(newMessage);
await db('cf_service_tickets')
.where({ id: ticketId, tenant_id: tenantId })
.update({
messages: JSON.stringify(messages),
status: sender === 'CUSTOMER' ? 'IN_PROGRESS' : 'WAITING_CUSTOMER',
updated_at: new Date(),
});
logger.info('[AfterSalesService] Ticket message added', {
ticketId,
sender,
tenantId,
traceId,
});
return {
...ticket,
messages,
} as ServiceTicket;
} catch (error: any) {
logger.error('[AfterSalesService] Failed to add ticket message', {
ticketId,
tenantId,
traceId,
error: error.message,
});
throw error;
}
}
static async getReturnById(returnId: string, tenantId: string, traceId: string): Promise<ReturnRecord | null> {
const record = await db('cf_return_requests')
.where({ id: returnId, tenant_id: tenantId })
.first();
if (!record) return null;
return {
id: record.id,
orderId: record.order_id,
status: record.status,
reason: record.reason,
items: JSON.parse(record.items || '[]'),
totalRefundAmount: Number(record.total_refund_amount),
tenantId: record.tenant_id,
shopId: record.shop_id,
taskId: record.task_id,
traceId: record.trace_id,
businessType: record.business_type,
createdAt: record.created_at,
updatedAt: record.updated_at,
};
}
private static async getOrderById(orderId: string, tenantId: string, traceId: string): Promise<any> {
const order = await db('cf_orders')
.where({ id: orderId, tenant_id: tenantId })
.first();
return order;
}
private static async calculateRefundAmount(
order: any,
items: ReturnItem[],
tenantId: string,
traceId: string
): Promise<number> {
const orderItems = JSON.parse(order.items || '[]');
let totalAmount = 0;
for (const returnItem of items) {
const orderItem = orderItems.find((oi: any) =>
oi.productId === returnItem.productId && oi.skuId === returnItem.skuId
);
if (orderItem) {
totalAmount += orderItem.unitPrice * returnItem.quantity;
}
}
return Number(totalAmount.toFixed(2));
}
private static determinePriority(reason: string): TicketPriority {
const highPriorityKeywords = ['损坏', '缺失', '错误', 'damaged', 'missing', 'wrong', 'defective'];
const urgentKeywords = ['欺诈', '投诉', 'fraud', 'complaint', 'legal'];
const lowerReason = reason.toLowerCase();
if (urgentKeywords.some(kw => lowerReason.includes(kw))) {
return 'URGENT';
}
if (highPriorityKeywords.some(kw => lowerReason.includes(kw))) {
return 'HIGH';
}
return 'MEDIUM';
}
private static async executeRefundWorkflow(
refund: RefundRecord,
tenantId: string,
traceId: string
): Promise<void> {
logger.info('[AfterSalesService] Executing refund workflow', {
refundId: refund.id,
tenantId,
traceId,
});
await db('cf_refunds')
.where({ id: refund.id, tenant_id: tenantId })
.update({ status: 'PROCESSING', updated_at: new Date() });
await new Promise(resolve => setTimeout(resolve, 100));
await db('cf_refunds')
.where({ id: refund.id, tenant_id: tenantId })
.update({ status: 'COMPLETED', updated_at: new Date() });
await db('cf_return_requests')
.where({ id: refund.returnId, tenant_id: tenantId })
.update({ status: 'REFUNDED', updated_at: new Date() });
logger.info('[AfterSalesService] Refund workflow completed', {
refundId: refund.id,
tenantId,
traceId,
});
}
static async initializeTables(): Promise<void> {
logger.info('[AfterSalesService] Initializing after-sales tables');
try {
if (!(await db.schema.hasTable('cf_return_requests'))) {
await db.schema.createTable('cf_return_requests', table => {
table.string('id').primary();
table.string('order_id').notNullable();
table.enum('status', ['PENDING', 'APPROVED', 'REJECTED', 'RETURNED', 'REFUNDED', 'CLOSED']).defaultTo('PENDING');
table.text('reason');
table.json('items');
table.decimal('total_refund_amount', 12, 2).defaultTo(0);
table.string('tenant_id').notNullable();
table.string('shop_id');
table.string('task_id');
table.string('trace_id');
table.enum('business_type', ['TOC', 'TOB']).defaultTo('TOC');
table.timestamp('created_at').defaultTo(db.fn.now());
table.timestamp('updated_at').defaultTo(db.fn.now());
table.index(['order_id', 'tenant_id']);
table.index(['status', 'tenant_id']);
});
logger.info('[AfterSalesService] Table cf_return_requests created');
}
if (!(await db.schema.hasTable('cf_refunds'))) {
await db.schema.createTable('cf_refunds', table => {
table.string('id').primary();
table.string('return_id').notNullable();
table.string('order_id').notNullable();
table.decimal('amount', 12, 2).notNullable();
table.enum('status', ['PENDING_REVIEW', 'APPROVED', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED']).defaultTo('PENDING_REVIEW');
table.string('method').defaultTo('ORIGINAL_PAYMENT');
table.string('tenant_id').notNullable();
table.string('shop_id');
table.string('task_id');
table.string('trace_id');
table.enum('business_type', ['TOC', 'TOB']).defaultTo('TOC');
table.timestamp('created_at').defaultTo(db.fn.now());
table.timestamp('updated_at').defaultTo(db.fn.now());
table.index(['return_id', 'tenant_id']);
table.index(['order_id', 'tenant_id']);
table.index(['status', 'tenant_id']);
});
logger.info('[AfterSalesService] Table cf_refunds created');
}
if (!(await db.schema.hasTable('cf_service_tickets'))) {
await db.schema.createTable('cf_service_tickets', table => {
table.string('id').primary();
table.string('return_id');
table.enum('type', ['RETURN', 'REFUND', 'EXCHANGE', 'COMPLAINT', 'INQUIRY']).defaultTo('INQUIRY');
table.enum('priority', ['LOW', 'MEDIUM', 'HIGH', 'URGENT']).defaultTo('MEDIUM');
table.enum('status', ['OPEN', 'IN_PROGRESS', 'WAITING_CUSTOMER', 'WAITING_INTERNAL', 'RESOLVED', 'CLOSED']).defaultTo('OPEN');
table.string('subject');
table.text('description');
table.json('messages');
table.string('tenant_id').notNullable();
table.string('shop_id');
table.string('task_id');
table.string('trace_id');
table.enum('business_type', ['TOC', 'TOB']).defaultTo('TOC');
table.timestamp('created_at').defaultTo(db.fn.now());
table.timestamp('updated_at').defaultTo(db.fn.now());
table.index(['return_id', 'tenant_id']);
table.index(['status', 'tenant_id']);
table.index(['priority', 'tenant_id']);
});
logger.info('[AfterSalesService] Table cf_service_tickets created');
}
logger.info('[AfterSalesService] After-sales tables initialized successfully');
} catch (error: any) {
logger.error(`[AfterSalesService] Failed to initialize after-sales tables - error: ${error.message}`);
throw error;
}
}
}