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

查看文件

@@ -62,7 +62,7 @@
</a-tag>
</template>
</a-table-column>
<a-table-column title="仲裁决议" :width="230" fixed="right">
<a-table-column title="仲裁决议" :width="280" fixed="right">
<template #cell="{ record }">
<a-space v-if="record.status === '待平台仲裁'">
<a-button
@@ -73,6 +73,14 @@
>
全额退款
</a-button>
<a-button
type="outline"
status="danger"
size="small"
@click="openPartialModal(record)"
>
部分退款
</a-button>
<a-button
type="outline"
status="warning"
@@ -95,6 +103,28 @@
</template>
</a-table>
</a-card>
<a-modal
v-model:visible="partialModalVisible"
title="客服判定部分退款"
@ok="submitPartialRefund"
@cancel="partialModalVisible = false"
>
<a-form :model="partialForm" layout="vertical">
<a-form-item label="关联订单号">
<a-input :model-value="currentArbRecord?.orderNo || ''" disabled />
</a-form-item>
<a-form-item label="部分退款金额 (元) — 剩余款项将自动解冻划入大神账户">
<a-input-number
v-model="partialForm.refundAmount"
:min="1"
:max="Number(currentArbRecord?.orderAmount || 9999)"
:precision="2"
placeholder="请输入退还给买家的部分金额"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
@@ -105,6 +135,11 @@
const loading = ref(false);
const rawList = ref<any[]>([]);
const partialModalVisible = ref(false);
const currentArbRecord = ref<any>(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();

查看文件

@@ -39,11 +39,16 @@
<a-select v-model="statusFilter" placeholder="订单流转状态" allow-clear>
<a-option value="ALL">全部状态</a-option>
<a-option value="待支付">待支付</a-option>
<a-option value="待接单">待接单</a-option>
<a-option value="已接单">已接单</a-option>
<a-option value="已到场核销">已到场核销</a-option>
<a-option value="履约中">履约中</a-option>
<a-option value="待确认">待确认</a-option>
<a-option value="已完成">已完成</a-option>
<a-option value="争议仲裁中">争议仲裁中</a-option>
<a-option value="已全额退款">已全额退款</a-option>
<a-option value="已部分退款">已部分退款</a-option>
<a-option value="已取消">已取消</a-option>
</a-select>
</a-col>
</a-row>

查看文件

@@ -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 {

查看文件

@@ -300,7 +300,10 @@
<text class="pool-desc">{{ order.desc }}</text>
<view class="pool-foot">
<text class="pool-time">倒计时: {{ order.countdown }}s</text>
<button class="btn-grab btn-active" @click="grabOrder(order)">立即抢单</button>
<view class="pool-btn-group">
<button class="btn-reject btn-active" @click="handleRejectOrder(order)">拒单</button>
<button class="btn-grab btn-active" @click="grabOrder(order)">立即抢单</button>
</view>
</view>
</view>
</view>
@@ -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; }
}
}
}

查看文件

@@ -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: '服务中',

查看文件

@@ -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 });

查看文件

@@ -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);
});
});
});