feat(order): complete 7.1 order full lifecycle state transitions, 15m/30m/48h auto-timeouts, expert reject/apply-complete, and partial refund arbitration

这个提交包含在:
xcbyy
2026-10-14 15:52:56 +08:00
父节点 a14f27d641
当前提交 3bf38db8b6
共有 10 个文件被更改,包括 551 次插入 和 34 次删除

查看文件

@@ -38,13 +38,16 @@ export interface CompanionOrder {
gameDetails?: GameDetails;
offlineDetails?: OfflineDetails;
offlineVerifyCode?: string;
paidAt?: Date;
checkedInAt?: Date;
finishAppliedAt?: Date;
completedAt?: Date;
disputeReason?: string;
disputedAt?: Date;
arbitratedAt?: Date;
arbitratedBy?: number;
arbitrationDecision?: 'REFUND' | 'RELEASE';
arbitrationDecision?: 'REFUND' | 'PARTIAL_REFUND' | 'RELEASE';
refundAmountCents?: number;
arbitrationReason?: string;
createdAt: Date;
}
@@ -93,6 +96,7 @@ export class CompanionOrderService {
throw new Error(`订单状态 [${order.status}] 不允许支付`);
}
order.paidAt = new Date();
order.status = OrderStatus.PENDING_ACCEPT;
return order;
}
@@ -148,6 +152,7 @@ export class CompanionOrderService {
throw new Error(`订单当前状态 [${order.status}] 不可拒单`);
}
order.refundAmountCents = order.totalAmountCents;
order.status = OrderStatus.REFUNDED;
return order;
}
@@ -188,6 +193,7 @@ export class CompanionOrderService {
throw new Error(`订单当前状态 [${order.status}] 无法申请完工结算`);
}
order.finishAppliedAt = new Date();
order.status = OrderStatus.PENDING_CONFIRM;
return order;
}
@@ -226,7 +232,8 @@ export class CompanionOrderService {
arbitrate(params: {
orderId: string;
operatorAdminId: number;
decision: 'REFUND' | 'RELEASE';
decision: 'REFUND' | 'PARTIAL_REFUND' | 'RELEASE';
refundAmountCents?: number;
reason?: string;
roleManager?: AuthRoleManager;
}): CompanionOrder {
@@ -250,20 +257,73 @@ export class CompanionOrderService {
throw new Error(`订单当前状态 [${order.status}] 非争议仲裁中,不可执行仲裁`);
}
if (params.decision === 'PARTIAL_REFUND') {
const partial = Number(params.refundAmountCents || 0);
if (partial <= 0 || partial > order.totalAmountCents) {
throw new Error('部分退款金额必须大于0且不超过订单总金额');
}
order.refundAmountCents = partial;
} else if (params.decision === 'REFUND') {
order.refundAmountCents = order.totalAmountCents;
}
order.arbitratedAt = new Date();
order.arbitratedBy = params.operatorAdminId;
order.arbitrationDecision = params.decision;
order.arbitrationReason = params.reason;
if (params.decision === 'REFUND') {
if (params.decision === 'REFUND' || params.decision === 'PARTIAL_REFUND') {
order.status = OrderStatus.REFUNDED;
} else {
order.completedAt = new Date();
order.status = OrderStatus.COMPLETED;
}
return order;
}
/**
* 扫描并执行订单生命周期超时流转:
* 1. 待付款超过 15 分钟未支付 -> 已取消 (CANCELLED)
* 2. 待接单超过 30 分钟未接单 -> 已退款 (REFUNDED)
* 3. 待确认超过 48 小时未确认 -> 已完成 (COMPLETED)
*/
processOrderTimeouts(now: Date = new Date()): {
cancelledIds: string[];
refundedIds: string[];
autoCompletedIds: string[];
} {
const nowMs = now.getTime();
const cancelledIds: string[] = [];
const refundedIds: string[] = [];
const autoCompletedIds: string[] = [];
for (const order of this.orders.values()) {
if (order.status === OrderStatus.PENDING_PAYMENT) {
if (nowMs - order.createdAt.getTime() >= 15 * 60 * 1000) {
order.status = OrderStatus.CANCELLED;
cancelledIds.push(order.id);
}
} else if (order.status === OrderStatus.PENDING_ACCEPT) {
const baseTime = (order.paidAt || order.createdAt).getTime();
if (nowMs - baseTime >= 30 * 60 * 1000) {
order.status = OrderStatus.REFUNDED;
order.refundAmountCents = order.totalAmountCents;
refundedIds.push(order.id);
}
} else if (order.status === OrderStatus.PENDING_CONFIRM && !order.offlineVerifyCode) {
const baseTime = (order.finishAppliedAt || order.createdAt).getTime();
if (nowMs - baseTime >= 48 * 60 * 60 * 1000) {
order.status = OrderStatus.COMPLETED;
order.completedAt = now;
autoCompletedIds.push(order.id);
}
}
}
return { cancelledIds, refundedIds, autoCompletedIds };
}
getAllOrders(): CompanionOrder[] {
return Array.from(this.orders.values());
}

查看文件

@@ -128,7 +128,10 @@ export async function initDatabase(): Promise<void> {
"ALTER TABLE orders ADD COLUMN verify_code VARCHAR(8) AFTER location",
"ALTER TABLE orders ADD COLUMN remark VARCHAR(255) AFTER verify_code",
"ALTER TABLE orders ADD COLUMN pay_type VARCHAR(32) DEFAULT 'WECHAT' AFTER status",
"ALTER TABLE orders ADD COLUMN coupon_discount DECIMAL(10,2) DEFAULT 0.00 AFTER amount"
"ALTER TABLE orders ADD COLUMN coupon_discount DECIMAL(10,2) DEFAULT 0.00 AFTER amount",
"ALTER TABLE orders ADD COLUMN paid_at DATETIME NULL AFTER created_at",
"ALTER TABLE orders ADD COLUMN finish_applied_at DATETIME NULL AFTER paid_at",
"ALTER TABLE orders ADD COLUMN refund_amount DECIMAL(10,2) DEFAULT 0.00 AFTER coupon_discount"
];
for (const colSql of extraOrderCols) {
try {

查看文件

@@ -820,8 +820,72 @@ export class MysqlRepository {
};
}
// 扫描并执行订单超时自动流转 (15分钟待付款自动取消 / 30分钟待接单自动退款 / 48小时待确认自动完成结算)
async processOrderTimeouts(): Promise<{ cancelledCount: number; refundedCount: number; completedCount: number }> {
let cancelledCount = 0;
let refundedCount = 0;
let completedCount = 0;
try {
// 1. 15分钟超时未支付 -> 自动流转为已取消
const [cancelRes]: any = await pool.query(`
UPDATE orders
SET status = '已取消'
WHERE status IN ('待支付', '待付款')
AND created_at <= DATE_SUB(NOW(), INTERVAL 15 MINUTE)
`);
cancelledCount = Number(cancelRes?.affectedRows || 0);
} catch (_) {}
try {
// 2. 30分钟超时未接单 -> 自动流转为已全额退款并退还买家余额
const [expiredAcceptRows]: any = await pool.query(`
SELECT * FROM orders
WHERE status = '待接单'
AND COALESCE(paid_at, created_at) <= DATE_SUB(NOW(), INTERVAL 30 MINUTE)
`);
for (const ord of expiredAcceptRows || []) {
const [upd]: any = await pool.query(
"UPDATE orders SET status = '已全额退款', refund_amount = amount WHERE id = ? AND status = '待接单'",
[ord.id]
);
if (upd && upd.affectedRows === 1) {
refundedCount += 1;
const buyerId = Number(ord.buyer_id || 1001);
const refundAmt = Number(ord.amount || 0);
if (buyerId > 0 && refundAmt > 0) {
await pool.query('UPDATE users SET balance = COALESCE(balance, 0) + ? WHERE id = ?', [refundAmt, buyerId]);
const ts = Date.now();
const rand = Math.floor(100 + Math.random() * 900);
await pool.query(
`INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at)
VALUES (?, ?, ?, '超时未接单自动退款', ?, 'IN', ?, ?, NOW())`,
[`fin_tmo_${ts}_${rand}`, `TO${ts}${rand}`, buyerId, refundAmt, `超时未接单退款 (${ord.order_no || ord.id})`, String(ord.id)]
);
}
}
}
} catch (_) {}
try {
// 3. 48小时待确认超时 -> 自动确认完成并解冻结算至大神钱包
const [expiredConfirmRows]: any = await pool.query(`
SELECT id FROM orders
WHERE status = '待确认'
AND COALESCE(finish_applied_at, created_at) <= DATE_SUB(NOW(), INTERVAL 48 HOUR)
`);
for (const ord of expiredConfirmRows || []) {
await this.completeOrderWithSettlement(String(ord.id));
completedCount += 1;
}
} catch (_) {}
return { cancelledCount, refundedCount, completedCount };
}
// 订单相关
async getOrders(): Promise<any[]> {
await this.processOrderTimeouts().catch(() => {});
const [rows]: any = await pool.query(`
SELECT
id,
@@ -838,11 +902,14 @@ export class MysqlRepository {
duration_hours as durationHours,
amount,
COALESCE(coupon_discount, 0.00) as couponDiscount,
COALESCE(refund_amount, 0.00) as refundAmount,
COALESCE(pay_type, 'WECHAT') as payType,
location,
verify_code as verifyCode,
remark,
status,
paid_at as paidAt,
finish_applied_at as finishAppliedAt,
created_at as createdAt
FROM orders
ORDER BY created_at DESC
@@ -920,8 +987,8 @@ export class MysqlRepository {
companion_name, expert_avatar, track, service_title,
expense_burden, hourly_rate, duration_hours, amount,
coupon_discount, pay_type,
location, verify_code, remark, status, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
location, verify_code, remark, status, paid_at, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${status === '待支付' || status === '待付款' ? 'NULL' : 'NOW()'}, NOW())
`, [
id, buyerId, expertId, orderNo, clientName,
companionName, expertAvatar, track, serviceTitle,
@@ -974,11 +1041,78 @@ export class MysqlRepository {
if (newStatus === '已完成') {
return this.completeOrderWithSettlement(id);
}
await pool.query('UPDATE orders SET status = ? WHERE id = ? OR order_no = ?', [newStatus, id, id]);
if (newStatus === '待接单' || newStatus === '履约中' || newStatus === '服务中') {
await pool.query('UPDATE orders SET status = ?, paid_at = COALESCE(paid_at, NOW()) WHERE id = ? OR order_no = ?', [newStatus, id, id]);
} else if (newStatus === '待确认') {
await pool.query('UPDATE orders SET status = ?, finish_applied_at = NOW() WHERE id = ? OR order_no = ?', [newStatus, id, id]);
} else {
await pool.query('UPDATE orders SET status = ? WHERE id = ? OR order_no = ?', [newStatus, id, id]);
}
const [rows]: any = await pool.query('SELECT * FROM orders WHERE id = ? OR order_no = ?', [id, id]);
return rows[0] || null;
}
// 大神点击申请完成服务 (服务中 -> 待确认,进入 48 小时买家确认窗口)
async applyCompleteOrder(orderId: string): Promise<any> {
await pool.query(
"UPDATE orders SET status = '待确认', finish_applied_at = NOW() WHERE id = ? OR order_no = ?",
[orderId, orderId]
);
const [rows]: any = await pool.query('SELECT * FROM orders WHERE id = ? OR order_no = ?', [orderId, orderId]);
return rows[0] || null;
}
// 大神拒单自动全额退款 (待接单 -> 已退款,资金退回买家钱包余额)
async rejectOrder(orderId: string, data?: { expertId?: number; reason?: string }): Promise<any> {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [rows]: any = await conn.query('SELECT * FROM orders WHERE id = ? OR order_no = ? FOR UPDATE', [orderId, orderId]);
if (!rows || rows.length === 0) {
throw new Error('订单不存在');
}
const order = rows[0];
if (['已完成', '已取消', '已退款', '已全额退款'].includes(order.status)) {
throw new Error(`当前订单状态【${order.status}】不可拒单`);
}
const rejectNote = data?.reason ? ` [大神拒单: ${data.reason}]` : ' [大神拒单自动退款]';
await conn.query(
"UPDATE orders SET status = '已全额退款', refund_amount = amount, remark = CONCAT(COALESCE(remark, ''), ?) WHERE id = ?",
[rejectNote.slice(0, 60), order.id]
);
const buyerId = Number(order.buyer_id || 1001);
const refundAmount = Number(order.amount || 0);
if (buyerId > 0 && refundAmount > 0) {
await conn.query('UPDATE users SET balance = COALESCE(balance, 0) + ? WHERE id = ?', [refundAmount, buyerId]);
const ts = Date.now();
const rand = Math.floor(100 + Math.random() * 900);
await conn.query(
`INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at)
VALUES (?, ?, ?, '大神拒单自动退款', ?, 'IN', ?, ?, NOW())`,
[
`fin_rej_${ts}_${rand}`,
`RJ${ts}${rand}`,
buyerId,
refundAmount,
`大神拒单退款 (${order.order_no || order.id})`,
String(order.id)
]
);
}
await conn.commit();
const [updated]: any = await pool.query('SELECT * FROM orders WHERE id = ?', [order.id]);
return updated[0] || order;
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
}
async completeOrderWithSettlement(id: string): Promise<any> {
await pool.query("UPDATE orders SET status = '已完成' WHERE id = ? OR order_no = ?", [id, id]);
const [rows]: any = await pool.query('SELECT * FROM orders WHERE id = ? OR order_no = ?', [id, id]);
@@ -1948,23 +2082,28 @@ export class MysqlRepository {
async getArbitrations(): Promise<any[]> {
const [rows]: any = await pool.query(`
SELECT
id, order_id as orderId, order_no as orderNo,
client_name as clientName, companion_name as companionName,
reason, user_evidence as userEvidence, status, created_at as createdAt
FROM arbitrations
ORDER BY created_at DESC
a.id, a.order_id as orderId, a.order_no as orderNo,
a.client_name as clientName, a.companion_name as companionName,
a.reason, a.user_evidence as userEvidence, a.status, a.created_at as createdAt,
COALESCE(o.amount, 0.00) as orderAmount,
COALESCE(o.refund_amount, 0.00) as refundAmount
FROM arbitrations a
LEFT JOIN orders o ON a.order_id = o.id
ORDER BY a.created_at DESC
`);
return rows;
}
async handleArbitration(id: string, decision: string): Promise<any> {
async handleArbitration(id: string, decision: string, refundAmountInput?: number): Promise<any> {
const conn = await pool.getConnection();
let shouldSettleOrderId = '';
let finalRefundAmount = 0;
try {
await conn.beginTransaction();
let statusText = '申诉已驳回';
if (decision === 'refund_full') statusText = '已全额退款用户';
if (decision === 'refund_partial') statusText = '已部分退款用户';
if (decision === 'deduct_credit') statusText = '已扣减大神信用分';
await conn.query('UPDATE arbitrations SET status = ? WHERE id = ?', [statusText, id]);
@@ -1976,12 +2115,13 @@ export class MysqlRepository {
const order = ordRows && ordRows[0];
if (decision === 'refund_full' && order) {
const refundAmount = Number(order.amount || 0);
finalRefundAmount = refundAmount;
// 1. 更新订单为已全额退款
await conn.query('UPDATE orders SET status = "已全额退款" WHERE id = ?', [order.id]);
await conn.query('UPDATE orders SET status = "已全额退款", refund_amount = ? WHERE id = ?', [refundAmount, order.id]);
// 2. 将订单金额真实退回买家钱包余额
const buyerId = Number(order.buyer_id || 1001);
const refundAmount = Number(order.amount || 0);
if (buyerId > 0 && refundAmount > 0) {
await conn.query('UPDATE users SET balance = balance + ? WHERE id = ?', [refundAmount, buyerId]);
@@ -2000,6 +2140,42 @@ export class MysqlRepository {
const altId = expertId > 100 && expertId < 200 ? expertId - 100 : expertId;
await conn.query('UPDATE companions SET rating = GREATEST(1.0, rating - 0.2) WHERE id IN (?, ?) OR user_id = ?', [expertId, altId, expertId]);
}
} else if (decision === 'refund_partial' && order) {
const totalOrderAmt = Number(order.amount || 0);
const partialAmt = refundAmountInput !== undefined && Number(refundAmountInput) > 0
? Math.min(totalOrderAmt, Number(Number(refundAmountInput).toFixed(2)))
: Number((totalOrderAmt * 0.5).toFixed(2));
finalRefundAmount = partialAmt;
await conn.query('UPDATE orders SET status = "已部分退款", refund_amount = ? WHERE id = ?', [partialAmt, order.id]);
const buyerId = Number(order.buyer_id || 1001);
if (buyerId > 0 && partialAmt > 0) {
await conn.query('UPDATE users SET balance = balance + ? WHERE id = ?', [partialAmt, buyerId]);
const recId = `fin_pref_${Date.now()}`;
const recNo = `PTK${Date.now()}`;
await conn.query(`
INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at)
VALUES (?, ?, ?, '仲裁部分退款', ?, 'IN', ?, ?, NOW())
`, [recId, recNo, buyerId, partialAmt, `平台仲裁部分退款 (${order.order_no})`, String(order.id)]);
}
// 剩余未退部分解冻结算给大神
const remainAmt = Number(Math.max(0, totalOrderAmt - partialAmt).toFixed(2));
if (remainAmt > 0) {
const expertUserId = Number(order.expert_id || 101);
const expertIncome = Number((remainAmt * 0.9).toFixed(2));
await conn.query(
'UPDATE users SET commission = COALESCE(commission, 0) + ? WHERE id = ? OR nickname = ?',
[expertIncome, expertUserId, order.companion_name || '']
);
const setId = `fin_pset_${Date.now()}`;
const setNo = `PJS${Date.now()}`;
await conn.query(`
INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at)
VALUES (?, ?, ?, '仲裁部分结算', ?, 'IN', ?, ?, NOW())
`, [setId, setNo, expertUserId, expertIncome, order.client_name || '买家客户', String(order.id)]);
}
} else if (order) {
// 驳回退款申诉或仅扣减大神信用分:订单恢复为已完成并进入完单结算闭环
await conn.query('UPDATE orders SET status = "已完成" WHERE id = ?', [order.id]);
@@ -2030,8 +2206,14 @@ export class MysqlRepository {
let statusText = '申诉已驳回';
if (decision === 'refund_full') statusText = '已全额退款用户';
if (decision === 'refund_partial') statusText = '已部分退款用户';
if (decision === 'deduct_credit') statusText = '已扣减大神信用分';
return { id, status: statusText, orderStatus: decision === 'refund_full' ? '已全额退款' : '已完成' };
return {
id,
status: statusText,
refundAmount: finalRefundAmount,
orderStatus: decision === 'refund_full' ? '已全额退款' : (decision === 'refund_partial' ? '已部分退款' : '已完成')
};
}
// 买家发起退款/售后申诉:订单流转为争议仲裁中,同时写入仲裁工单 (含理由与凭证截图)

查看文件

@@ -1265,17 +1265,38 @@ app.post('/api/orders/:id/cancel', async (req: Request, res: Response) => {
}
});
// 支付订单
// 支付订单 (支持流转为待接单或履约中)
app.post('/api/orders/:id/pay', async (req: Request, res: Response) => {
try {
const order = await repo.updateOrderStatus(String(req.params.id), '履约中');
res.json({ code: 20000, data: order, message: '支付成功,服务已进入履约中' });
const targetStatus = req.body?.targetStatus || '履约中';
const order = await repo.updateOrderStatus(String(req.params.id), targetStatus);
res.json({ code: 20000, data: order, message: `支付成功,订单已进入${targetStatus}` });
} catch (err: any) {
res.status(400).json({ code: 40000, message: err.message });
}
});
// 确认完成验收
// 大神拒单自动全额退款 (待接单 -> 已退款)
app.post('/api/orders/:id/reject', async (req: Request, res: Response) => {
try {
const order = await repo.rejectOrder(String(req.params.id), req.body);
res.json({ code: 20000, data: order, message: '已拒单,订单款项已自动原路退还买家' });
} catch (err: any) {
res.status(400).json({ code: 40000, message: err.message });
}
});
// 大神点击申请完成服务 (服务中 -> 待确认,进入 48 小时买家确认窗口)
app.post('/api/orders/:id/apply-complete', async (req: Request, res: Response) => {
try {
const order = await repo.applyCompleteOrder(String(req.params.id));
res.json({ code: 20000, data: order, message: '已发起完成服务申请,等待买家确认验收(48小时自动确认)' });
} catch (err: any) {
res.status(400).json({ code: 40000, message: err.message });
}
});
// 买家确认完成验收 (待确认 -> 已完成,触发资金解冻结算)
app.post('/api/orders/:id/complete', async (req: Request, res: Response) => {
try {
const order = await repo.updateOrderStatus(String(req.params.id), '已完成');
@@ -1285,6 +1306,16 @@ app.post('/api/orders/:id/complete', async (req: Request, res: Response) => {
}
});
// 扫描处理超时订单 (15m未支付自动取消 / 30m未接单自动退款 / 48h待确认自动完成)
app.post('/api/orders/process-timeouts', async (_req: Request, res: Response) => {
try {
const result = await repo.processOrderTimeouts();
res.json({ code: 20000, data: result, message: '订单超时自动流转扫描完毕' });
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
// 申请退款/售后 (写入仲裁工单:退款理由 + 详细说明 + 凭证截图)
app.post('/api/orders/:id/refund', async (req: Request, res: Response) => {
try {
@@ -1374,13 +1405,13 @@ app.get('/api/arbitrations', async (_req: Request, res: Response) => {
app.post('/api/arbitrations/:id/handle', async (req: Request, res: Response) => {
try {
const arbId = String(req.params.id);
const { decision, operatorRole } = req.body; // 'refund_full' | 'reject' | 'deduct_credit'
const { decision, refundAmount, operatorRole } = req.body; // 'refund_full' | 'refund_partial' | 'reject' | 'deduct_credit'
const headerRole = req.headers['x-user-role'];
const callerRole = operatorRole || headerRole || 'admin';
if (callerRole === 'user' || callerRole === 'expert' || callerRole === 'PLAYER' || callerRole === 'EXPERT') {
return res.status(403).json({ code: 40300, message: '权限不足:买家与大神不可越权执行管理员仲裁' });
}
const result = await repo.handleArbitration(arbId, decision);
const result = await repo.handleArbitration(arbId, decision, refundAmount !== undefined ? Number(refundAmount) : undefined);
res.json({ code: 20000, data: result, message: `仲裁处理完毕: ${result.status}` });
} catch (err: any) {
res.status(400).json({ code: 40000, message: err.message });