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 次删除

查看文件

@@ -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' ? '已部分退款' : '已完成')
};
}
// 买家发起退款/售后申诉:订单流转为争议仲裁中,同时写入仲裁工单 (含理由与凭证截图)