From 3bf38db8b6c82bb729f485346b06fb1eddb878b0 Mon Sep 17 00:00:00 2001 From: xcbyy <2578898740@qq.com> Date: Wed, 14 Oct 2026 15:52:56 +0800 Subject: [PATCH] feat(order): complete 7.1 order full lifecycle state transitions, 15m/30m/48h auto-timeouts, expert reject/apply-complete, and partial refund arbitration --- .../src/views/order/arbitration/index.vue | 58 ++++- .../src/views/order/list/index.vue | 5 + pwuniapp/common/api.js | 46 +++- pwuniapp/pages/expert/workspace.vue | 66 +++++- pwuniapp/pages/order/list.vue | 5 +- .../src/core/order/companion-order.service.ts | 66 +++++- server/src/db/mysql.ts | 5 +- server/src/db/repository.ts | 206 +++++++++++++++++- server/src/index.ts | 43 +++- server/tests/companion-order.spec.ts | 85 ++++++++ 10 files changed, 551 insertions(+), 34 deletions(-) diff --git a/admin/arco-design-pro-vite/src/views/order/arbitration/index.vue b/admin/arco-design-pro-vite/src/views/order/arbitration/index.vue index d49a601..05d56e3 100644 --- a/admin/arco-design-pro-vite/src/views/order/arbitration/index.vue +++ b/admin/arco-design-pro-vite/src/views/order/arbitration/index.vue @@ -62,7 +62,7 @@ - + @@ -105,6 +135,11 @@ const loading = ref(false); const rawList = ref([]); + const partialModalVisible = ref(false); + const currentArbRecord = ref(null); + const partialForm = ref({ + refundAmount: 20, + }); const fetchData = async () => { loading.value = true; @@ -120,13 +155,32 @@ } }; + const openPartialModal = (record: any) => { + currentArbRecord.value = record; + const total = Number(record.orderAmount || 60); + partialForm.value.refundAmount = Number((total * 0.5).toFixed(2)) || 20; + partialModalVisible.value = true; + }; + + const submitPartialRefund = async () => { + if (!currentArbRecord.value) return; + await handleArbitrate( + currentArbRecord.value.id, + 'refund_partial', + Number(partialForm.value.refundAmount || 20) + ); + partialModalVisible.value = false; + }; + const handleArbitrate = async ( id: string, - decision: 'refund_full' | 'reject' | 'deduct_credit' + decision: 'refund_full' | 'refund_partial' | 'reject' | 'deduct_credit', + refundAmount?: number ) => { try { const res: any = await axios.post(`/api/arbitrations/${id}/handle`, { decision, + refundAmount, }); Message.success(res.data?.message || '仲裁执行成功'); fetchData(); diff --git a/admin/arco-design-pro-vite/src/views/order/list/index.vue b/admin/arco-design-pro-vite/src/views/order/list/index.vue index 9216257..5dbb166 100644 --- a/admin/arco-design-pro-vite/src/views/order/list/index.vue +++ b/admin/arco-design-pro-vite/src/views/order/list/index.vue @@ -39,11 +39,16 @@ 全部状态 待支付 + 待接单 已接单 已到场核销 履约中 + 待确认 已完成 争议仲裁中 + 已全额退款 + 已部分退款 + 已取消 diff --git a/pwuniapp/common/api.js b/pwuniapp/common/api.js index 2c6c1b2..d8f238f 100644 --- a/pwuniapp/common/api.js +++ b/pwuniapp/common/api.js @@ -242,9 +242,22 @@ export function formatOrderData(raw) { let statusKey = raw.status || 'IN_SERVICE'; if (statusKey === '待支付' || statusKey === '待付款') statusKey = 'PENDING_PAY'; - else if (statusKey === '已到场核销' || statusKey === '履约中' || statusKey === '服务中') statusKey = 'IN_SERVICE'; + else if ( + statusKey === '待接单' || + statusKey === '已接单' || + statusKey === '已到场核销' || + statusKey === '履约中' || + statusKey === '服务中' || + statusKey === '待确认' + ) statusKey = 'IN_SERVICE'; else if (statusKey === '已完成') statusKey = 'COMPLETED'; - else if (statusKey === '争议仲裁中' || statusKey === '退款中' || statusKey === '已退款' || statusKey === '已全额退款') statusKey = 'REFUNDING'; + else if ( + statusKey === '争议仲裁中' || + statusKey === '退款中' || + statusKey === '已退款' || + statusKey === '已全额退款' || + statusKey === '已部分退款' + ) statusKey = 'REFUNDING'; else if (statusKey === '已取消') statusKey = 'CANCELLED'; const companionAvatar = raw.expertAvatar || raw.expert_avatar || raw.avatar || @@ -265,6 +278,7 @@ export function formatOrderData(raw) { serviceTitle: raw.serviceTitle || raw.service_title || `${game} · 专业陪伴服务`, quantity: Number(raw.durationHours || raw.duration_hours || raw.quantity || 2), totalAmount: Number(raw.amount || raw.totalAmount || 0), + refundAmount: Number(raw.refundAmount || raw.refund_amount || 0), status: statusKey, rawStatus: raw.status || '履约中', meetVenue: raw.location || raw.meetVenue || '北京市公开商圈', @@ -332,7 +346,33 @@ export async function payOrder(orderId) { } /** - * 确认验收订单 + * 大神拒单自动退款 (待接单 -> 已退款) + */ +export async function rejectOrder(orderId, payload = {}) { + try { + const res = await http.post(`/orders/${orderId}/reject`, payload); + return formatOrderData(res); + } catch (err) { + console.warn('[API] 拒单接口网络异常:', err.message); + throw err; + } +} + +/** + * 大神点击申请完成服务 (服务中 -> 待确认) + */ +export async function applyCompleteOrder(orderId, payload = {}) { + try { + const res = await http.post(`/orders/${orderId}/apply-complete`, payload); + return formatOrderData(res); + } catch (err) { + console.warn('[API] 申请完成服务网络异常:', err.message); + throw err; + } +} + +/** + * 买家确认验收订单 (待确认 -> 已完成) */ export async function completeOrder(orderId) { try { diff --git a/pwuniapp/pages/expert/workspace.vue b/pwuniapp/pages/expert/workspace.vue index 0f219af..814d990 100644 --- a/pwuniapp/pages/expert/workspace.vue +++ b/pwuniapp/pages/expert/workspace.vue @@ -300,7 +300,10 @@ {{ order.desc }} 倒计时: {{ order.countdown }}s - + + + + @@ -404,6 +407,8 @@ import { getOrdersPool, getExpertStats, claimOrder, + rejectOrder, + applyCompleteOrder, completeOrder as completeOrderApi, updateCompanionProfile, getExpertRevenueTrends, @@ -763,13 +768,37 @@ const grabOrder = async (order) => { }); } catch (_) {} uni.showToast({ - title: `抢单成功!已接单 ¥${order.price}`, + title: '抢单成功已接单', icon: 'success' }); poolOrders.value = poolOrders.value.filter(o => o.id !== order.id); loadWorkspaceOrders(); }; +const handleRejectOrder = (order) => { + uni.showModal({ + title: '确认拒单并退款', + content: '拒单后系统将自动将该订单款项原路全额退还给买家,确定拒单吗?', + confirmColor: '#EF4444', + success: async (res) => { + if (res.confirm) { + try { + await rejectOrder(order.id, { + expertId: expert.value.id || 101, + reason: '大神当前档期冲突拒单' + }); + } catch (_) {} + poolOrders.value = poolOrders.value.filter(o => o.id !== order.id); + uni.showToast({ + title: '已拒单并自动退款', + icon: 'none' + }); + loadWorkspaceOrders(); + } + } + }); +}; + const chatWithBuyer = () => { uni.showActionSheet({ @@ -798,15 +827,19 @@ const chatWithBuyer = () => { const completeOrder = (id) => { uni.showModal({ title: '确认提交服务验收', - content: '服务已完成?提交后将完成服务结算并自动解冻订单资金打入您的佣金钱包。', + content: '服务已完成?提交后订单将进入【待确认】状态,买家确认或48小时后自动解冻资金结算至您的佣金钱包。', confirmColor: '#10B981', success: async (res) => { if (res.confirm) { try { - await completeOrderApi(id); - } catch (_) {} + await applyCompleteOrder(id, { expertId: expert.value.id || 101 }); + } catch (_) { + try { + await completeOrderApi(id); + } catch (_) {} + } activeOrders.value = activeOrders.value.filter(o => o.id !== id); - uni.showToast({ title: '已验收结算并入账!', icon: 'success' }); + uni.showToast({ title: '已提交待买家确认', icon: 'success' }); loadWorkspaceOrders(); } } @@ -1698,6 +1731,26 @@ const switchBackToPlayer = () => { font-variant-numeric: tabular-nums; } + .pool-btn-group { + display: flex; + align-items: center; + gap: 12rpx; + } + + .btn-reject { + background: rgba(239, 68, 68, 0.14); + border: 1px solid rgba(239, 68, 68, 0.35); + color: #F87171; + font-size: 22rpx; + font-weight: 600; + border-radius: 28rpx; + height: 56rpx; + line-height: 56rpx; + padding: 0 22rpx; + margin: 0; + &::after { display: none; } + } + .btn-grab { background: linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%); color: #FFF; @@ -1709,6 +1762,7 @@ const switchBackToPlayer = () => { padding: 0 28rpx; margin: 0; box-shadow: 0 4rpx 16rpx rgba(99, 102, 241, 0.4); + &::after { display: none; } } } } diff --git a/pwuniapp/pages/order/list.vue b/pwuniapp/pages/order/list.vue index 770e021..9e62df7 100644 --- a/pwuniapp/pages/order/list.vue +++ b/pwuniapp/pages/order/list.vue @@ -466,8 +466,11 @@ const goToExplore = () => { }; const getStatusText = (status, order) => { - if (order && order.rawStatus === '已退款') return '已全额退款'; + if (order && (order.rawStatus === '已退款' || order.rawStatus === '已全额退款')) return '已全额退款'; + if (order && order.rawStatus === '已部分退款') return '已部分退款'; if (order && order.rawStatus === '争议仲裁中') return '争议仲裁中'; + if (order && order.rawStatus === '待接单') return '待接单'; + if (order && order.rawStatus === '待确认') return '待确认验收'; const map = { PENDING_PAY: '待付款', IN_SERVICE: '服务中', diff --git a/server/src/core/order/companion-order.service.ts b/server/src/core/order/companion-order.service.ts index f4af2dc..fbb3391 100644 --- a/server/src/core/order/companion-order.service.ts +++ b/server/src/core/order/companion-order.service.ts @@ -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()); } diff --git a/server/src/db/mysql.ts b/server/src/db/mysql.ts index 9ef18ff..1842b72 100644 --- a/server/src/db/mysql.ts +++ b/server/src/db/mysql.ts @@ -128,7 +128,10 @@ export async function initDatabase(): Promise { "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 { diff --git a/server/src/db/repository.ts b/server/src/db/repository.ts index 64b3e0e..3f4157c 100644 --- a/server/src/db/repository.ts +++ b/server/src/db/repository.ts @@ -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 { + 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 { + 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 { + 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 { 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 { 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 { + async handleArbitration(id: string, decision: string, refundAmountInput?: number): Promise { 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' ? '已部分退款' : '已完成') + }; } // 买家发起退款/售后申诉:订单流转为争议仲裁中,同时写入仲裁工单 (含理由与凭证截图) diff --git a/server/src/index.ts b/server/src/index.ts index 11d792f..9669db2 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 }); diff --git a/server/tests/companion-order.spec.ts b/server/tests/companion-order.spec.ts index 2be8abe..dd36554 100644 --- a/server/tests/companion-order.spec.ts +++ b/server/tests/companion-order.spec.ts @@ -248,4 +248,89 @@ describe('CompanionOrderService (TDD 测试套件: 线上游戏与同城陪玩· }).toThrow('无权核销该订单'); }); }); + + describe('3. 7.1 陪玩服务全生命周期超时自动流转与部分退款仲裁闭环', () => { + it('待付款订单超过 15 分钟未支付自动流转为已取消 (CANCELLED)', () => { + const order = orderService.createOrder({ + buyerId: 1001, + expertId: 2001, + track: CompanionTrack.ONLINE_GAME, + serviceTitle: '王者双排', + hours: 1, + totalAmountCents: 3500, + }); + + const future16Min = new Date(order.createdAt.getTime() + 16 * 60 * 1000); + const res = orderService.processOrderTimeouts(future16Min); + expect(res.cancelledIds).toContain(order.id); + expect(order.status).toBe(OrderStatus.CANCELLED); + }); + + it('待接单订单超过 30 分钟大神未接单自动流转为已退款 (REFUNDED)', () => { + const order = orderService.createOrder({ + buyerId: 1001, + expertId: 2001, + track: CompanionTrack.ONLINE_GAME, + serviceTitle: '王者双排', + hours: 1, + totalAmountCents: 3500, + }); + orderService.payOrder(order.id, 1001); + expect(order.status).toBe(OrderStatus.PENDING_ACCEPT); + + const future31Min = new Date((order.paidAt || order.createdAt).getTime() + 31 * 60 * 1000); + const res = orderService.processOrderTimeouts(future31Min); + expect(res.refundedIds).toContain(order.id); + expect(order.status).toBe(OrderStatus.REFUNDED); + expect(order.refundAmountCents).toBe(3500); + }); + + it('待确认订单超过 48 小时买家未确认自动流转为已完成 (COMPLETED)', () => { + const order = orderService.createOrder({ + buyerId: 1001, + expertId: 2001, + track: CompanionTrack.ONLINE_GAME, + serviceTitle: '王者双排', + hours: 2, + totalAmountCents: 7000, + }); + orderService.payOrder(order.id, 1001); + orderService.expertAccept(order.id, 2001); + orderService.expertApplyFinish(order.id, 2001); + expect(order.status).toBe(OrderStatus.PENDING_CONFIRM); + + const future49Hours = new Date((order.finishAppliedAt || order.createdAt).getTime() + 49 * 60 * 60 * 1000); + const res = orderService.processOrderTimeouts(future49Hours); + expect(res.autoCompletedIds).toContain(order.id); + expect(order.status).toBe(OrderStatus.COMPLETED); + }); + + it('争议仲裁支持客服判定部分退款 (PARTIAL_REFUND) 并流转为已退款', () => { + const order = orderService.createOrder({ + buyerId: 1001, + expertId: 2001, + track: CompanionTrack.ONLINE_GAME, + serviceTitle: '王者三排', + hours: 2, + totalAmountCents: 10000, + }); + orderService.payOrder(order.id, 1001); + orderService.expertAccept(order.id, 2001); + orderService.expertApplyFinish(order.id, 2001); + orderService.buyerDispute(order.id, 1001, '只打了一半时长申请部分退款'); + + const arbOrder = orderService.arbitrate({ + orderId: order.id, + operatorAdminId: 9001, + decision: 'PARTIAL_REFUND', + refundAmountCents: 5000, + reason: '核实已完成1小时对局,判定退还买家一半费用', + }); + + expect(arbOrder.status).toBe(OrderStatus.REFUNDED); + expect(arbOrder.arbitrationDecision).toBe('PARTIAL_REFUND'); + expect(arbOrder.refundAmountCents).toBe(5000); + }); + }); }); +