fix(business-loop): complete arbitration balance refund, withdrawal rejection compensation, admin activity takedown, and companion violation alerts
这个提交包含在:
@@ -119,7 +119,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</a-table-column>
|
</a-table-column>
|
||||||
<a-table-column title="操作" :width="190" fixed="right">
|
<a-table-column title="操作" :width="260" fixed="right">
|
||||||
<template #cell="{ record }">
|
<template #cell="{ record }">
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-button
|
<a-button
|
||||||
@@ -136,6 +136,17 @@
|
|||||||
>
|
>
|
||||||
追加赏金
|
追加赏金
|
||||||
</a-button>
|
</a-button>
|
||||||
|
<a-popconfirm
|
||||||
|
v-if="record.status !== 'CANCELLED' && record.status !== 'FINISHED'"
|
||||||
|
content="确定要违规下架该组局吗?下架后将自动原路退款已报名成员。"
|
||||||
|
type="warning"
|
||||||
|
@ok="handleCancelActivity(record)"
|
||||||
|
>
|
||||||
|
<a-button type="text" status="danger" size="small">
|
||||||
|
违规下架
|
||||||
|
</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
<span v-else style="color: var(--color-text-4); font-size: 12px">已结案/已下架</span>
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
</a-table-column>
|
</a-table-column>
|
||||||
@@ -351,6 +362,19 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCancelActivity = async (record: any) => {
|
||||||
|
try {
|
||||||
|
await axios.post(`/api/activities/${record.id}/cancel`, {
|
||||||
|
hostId: record.creatorId || 1001,
|
||||||
|
reason: '平台风控巡查违规下架退款',
|
||||||
|
});
|
||||||
|
Message.success('组局已违规下架,已报名搭子款项已全额原路退还');
|
||||||
|
fetchData();
|
||||||
|
} catch (err: any) {
|
||||||
|
Message.error(err.response?.data?.message || err.message || '下架失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -109,11 +109,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</a-table-column>
|
</a-table-column>
|
||||||
<a-table-column title="履约声誉" :width="140">
|
<a-table-column title="履约声誉" :width="150">
|
||||||
<template #cell="{ record }">
|
<template #cell="{ record }">
|
||||||
<div style="font-weight: 600; color: #ff7d00">
|
<div style="font-weight: 600; color: #ff7d00">
|
||||||
<icon-star-fill style="margin-right: 4px; color: #ff7d00" />
|
<icon-star-fill style="margin-right: 4px; color: #ff7d00" />
|
||||||
{{ record.rating }} 分
|
{{ Number(record.rating).toFixed(1) }} 分
|
||||||
|
</div>
|
||||||
|
<div v-if="Number(record.rating) < 4.5" style="margin-top: 2px">
|
||||||
|
<a-tag size="small" color="red">违规扣分警示</a-tag>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size: 12px; color: var(--color-text-3)">
|
<div style="font-size: 12px; color: var(--color-text-3)">
|
||||||
已接 {{ record.orderCount }} 单
|
已接 {{ record.orderCount }} 单
|
||||||
|
|||||||
@@ -309,16 +309,16 @@ export class MysqlRepository {
|
|||||||
return this.getActivityById(activityId);
|
return this.getActivityById(activityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancelActivity(id: string, hostId: number, reason?: string): Promise<any> {
|
async cancelActivity(id: string, hostId: number, reason?: string, isAdmin?: boolean): Promise<any> {
|
||||||
const act = await this.getActivityById(id);
|
const act = await this.getActivityById(id);
|
||||||
if (!act) {
|
if (!act) {
|
||||||
throw new Error('活动不存在');
|
throw new Error('活动不存在');
|
||||||
}
|
}
|
||||||
if (Number(act.creatorId) !== Number(hostId)) {
|
if (!isAdmin && Number(act.creatorId) !== Number(hostId)) {
|
||||||
throw new Error('只有发起人有权解散此组局');
|
throw new Error('只有发起人或平台管理员有权解散此组局');
|
||||||
}
|
}
|
||||||
const hasCheckedIn = act.participants.some((p: any) => p.checkinStatus);
|
const hasCheckedIn = act.participants.some((p: any) => p.checkinStatus);
|
||||||
if (hasCheckedIn) {
|
if (!isAdmin && hasCheckedIn) {
|
||||||
throw new Error('聚会已有成员到场核销,不可随意解散');
|
throw new Error('聚会已有成员到场核销,不可随意解散');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1585,18 +1585,62 @@ export class MysqlRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleArbitration(id: string, decision: string): Promise<any> {
|
async handleArbitration(id: string, decision: string): Promise<any> {
|
||||||
let statusText = '申诉已驳回';
|
const conn = await pool.getConnection();
|
||||||
if (decision === 'refund_full') statusText = '已全额退款用户';
|
try {
|
||||||
if (decision === 'deduct_credit') statusText = '已扣减大神信用分';
|
await conn.beginTransaction();
|
||||||
|
|
||||||
await pool.query('UPDATE arbitrations SET status = ? WHERE id = ?', [statusText, id]);
|
let statusText = '申诉已驳回';
|
||||||
if (decision === 'refund_full') {
|
if (decision === 'refund_full') statusText = '已全额退款用户';
|
||||||
const [arbRows]: any = await pool.query('SELECT order_id FROM arbitrations WHERE id = ?', [id]);
|
if (decision === 'deduct_credit') statusText = '已扣减大神信用分';
|
||||||
|
|
||||||
|
await conn.query('UPDATE arbitrations SET status = ? WHERE id = ?', [statusText, id]);
|
||||||
|
|
||||||
|
const [arbRows]: any = await conn.query('SELECT order_id FROM arbitrations WHERE id = ? FOR UPDATE', [id]);
|
||||||
if (arbRows.length > 0) {
|
if (arbRows.length > 0) {
|
||||||
await pool.query('UPDATE orders SET status = "已全额退款" WHERE id = ?', [arbRows[0].order_id]);
|
const orderId = arbRows[0].order_id;
|
||||||
|
const [ordRows]: any = await conn.query('SELECT * FROM orders WHERE id = ? FOR UPDATE', [orderId]);
|
||||||
|
const order = ordRows && ordRows[0];
|
||||||
|
|
||||||
|
if (decision === 'refund_full' && order) {
|
||||||
|
// 1. 更新订单为已全额退款
|
||||||
|
await conn.query('UPDATE orders SET status = "已全额退款" WHERE id = ?', [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]);
|
||||||
|
|
||||||
|
// 3. 记录财务退款入账流水
|
||||||
|
const recId = `fin_ref_${Date.now()}`;
|
||||||
|
const recNo = `TK${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, refundAmount, `平台仲裁退款 (${order.order_no})`, String(order.id)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 联动扣减违规大神评分
|
||||||
|
const expertId = Number(order.expert_id);
|
||||||
|
if (expertId > 0) {
|
||||||
|
await conn.query('UPDATE companions SET rating = GREATEST(1.0, rating - 0.2) WHERE id = ?', [expertId]);
|
||||||
|
}
|
||||||
|
} else if (decision === 'deduct_credit' && order) {
|
||||||
|
const expertId = Number(order.expert_id);
|
||||||
|
if (expertId > 0) {
|
||||||
|
await conn.query('UPDATE companions SET rating = GREATEST(1.0, rating - 0.3) WHERE id = ?', [expertId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
return { id, status: statusText };
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
}
|
}
|
||||||
return { id, status: statusText };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 买家发起退款/售后申诉:订单流转为争议仲裁中,同时写入仲裁工单 (含理由与凭证截图)
|
// 买家发起退款/售后申诉:订单流转为争议仲裁中,同时写入仲裁工单 (含理由与凭证截图)
|
||||||
@@ -1755,9 +1799,53 @@ export class MysqlRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async reviewWithdrawal(id: string, action: 'approve' | 'reject', rejectReason?: string): Promise<any> {
|
async reviewWithdrawal(id: string, action: 'approve' | 'reject', rejectReason?: string): Promise<any> {
|
||||||
const statusText = action === 'approve' ? '已打款' : '已驳回';
|
const conn = await pool.getConnection();
|
||||||
await pool.query('UPDATE withdrawals SET status = ? WHERE id = ?', [statusText, id]);
|
try {
|
||||||
return { id, status: statusText, reason: rejectReason };
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
const [rows]: any = await conn.query('SELECT * FROM withdrawals WHERE id = ? FOR UPDATE', [id]);
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
throw new Error('提现工单不存在');
|
||||||
|
}
|
||||||
|
const wd = rows[0];
|
||||||
|
if (wd.status !== '待审核') {
|
||||||
|
throw new Error(`当前提现工单已处理,状态为【${wd.status}】`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusText = action === 'approve' ? '已打款' : '已驳回';
|
||||||
|
const reason = rejectReason || (action === 'reject' ? '银行/微信账号信息不匹配或未通过财务初审' : '');
|
||||||
|
|
||||||
|
await conn.query(`
|
||||||
|
UPDATE withdrawals
|
||||||
|
SET status = ?, reason = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, [statusText, reason, id]);
|
||||||
|
|
||||||
|
// 若被驳回,将预扣的提现佣金全额原路退还至用户 commission 佣金账户
|
||||||
|
if (action === 'reject') {
|
||||||
|
const userId = Number(wd.companion_id);
|
||||||
|
const refundAmt = Number(wd.amount);
|
||||||
|
if (userId > 0 && refundAmt > 0) {
|
||||||
|
await conn.query('UPDATE users SET commission = commission + ? WHERE id = ?', [refundAmt, userId]);
|
||||||
|
|
||||||
|
// 记录一条冲正入账流水
|
||||||
|
const recId = `fin_wdr_${Date.now()}`;
|
||||||
|
const recNo = `TH${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, userId, refundAmt, `财务审核驳回退还 (${reason})`, id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
return { id, status: statusText, reason };
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFinanceConfig(): Promise<any> {
|
async getFinanceConfig(): Promise<any> {
|
||||||
|
|||||||
@@ -479,16 +479,18 @@ app.post('/api/activities/:id/adjust-price', async (req: Request, res: Response)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 房主取消/解散组局并触发全额原路退款
|
// 房主或平台管理员取消/违规下架组局并触发全额原路退款
|
||||||
app.post('/api/activities/:id/cancel', async (req: Request, res: Response) => {
|
app.post('/api/activities/:id/cancel', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const actId = String(req.params.id);
|
const actId = String(req.params.id);
|
||||||
const { hostId, reason } = req.body;
|
const { hostId, reason, operatorRole } = req.body || {};
|
||||||
const result = await repo.cancelActivity(actId, Number(hostId || 1001), reason);
|
const headerRole = req.headers['x-user-role'];
|
||||||
|
const isAdmin = operatorRole === 'admin' || headerRole === 'admin';
|
||||||
|
const result = await repo.cancelActivity(actId, Number(hostId || 1001), reason, isAdmin);
|
||||||
res.json({
|
res.json({
|
||||||
code: 20000,
|
code: 20000,
|
||||||
data: result,
|
data: result,
|
||||||
message: `组局已成功解散${result.refundedCount > 0 ? `,已原路全额退款 ${result.refundedCount} 位搭子` : ''}`
|
message: `组局已成功解散下架${result.refundedCount > 0 ? `,已原路全额退款 ${result.refundedCount} 位搭子` : ''}`
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(400).json({ code: 40000, message: err.message });
|
res.status(400).json({ code: 40000, message: err.message });
|
||||||
|
|||||||
@@ -101,6 +101,35 @@ describe('ClientFeatures (TDD 测试套件: 用户端功能闭环)', () => {
|
|||||||
expect(content).toContain('arbitrations');
|
expect(content).toContain('arbitrations');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('仲裁全额退款用户时,必须原子将订单金额退还买家钱包余额并记录入账流水', () => {
|
||||||
|
const repoPath = path.join(serverSrcDir, 'db', 'repository.ts');
|
||||||
|
const content = fs.readFileSync(repoPath, 'utf-8');
|
||||||
|
|
||||||
|
expect(content).toContain('UPDATE users SET balance = balance + ?');
|
||||||
|
expect(content).toContain('仲裁退款');
|
||||||
|
expect(content).toContain('finance_records');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('提现申请被管理员驳回时,必须原子将预扣佣金原路退还用户佣金账户并记录冲正流水', () => {
|
||||||
|
const repoPath = path.join(serverSrcDir, 'db', 'repository.ts');
|
||||||
|
const content = fs.readFileSync(repoPath, 'utf-8');
|
||||||
|
|
||||||
|
expect(content).toContain('UPDATE users SET commission = commission + ?');
|
||||||
|
expect(content).toContain('提现驳回退还');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('后端 repository 与管理后台必须支持平台管理员强制违规下架组局并原路退款搭子', () => {
|
||||||
|
const repoPath = path.join(serverSrcDir, 'db', 'repository.ts');
|
||||||
|
const content = fs.readFileSync(repoPath, 'utf-8');
|
||||||
|
expect(content).toContain('cancelActivity');
|
||||||
|
expect(content).toContain('isAdmin');
|
||||||
|
|
||||||
|
const adminActListPath = path.join(rootDir, 'admin', 'arco-design-pro-vite', 'src', 'views', 'activity', 'list', 'index.vue');
|
||||||
|
const adminActContent = fs.readFileSync(adminActListPath, 'utf-8');
|
||||||
|
expect(adminActContent).toContain('handleCancelActivity');
|
||||||
|
expect(adminActContent).toContain('违规下架');
|
||||||
|
});
|
||||||
|
|
||||||
it('后端 index.ts POST /api/orders/:id/refund 必须接收理由与说明并执行风控拦截', () => {
|
it('后端 index.ts POST /api/orders/:id/refund 必须接收理由与说明并执行风控拦截', () => {
|
||||||
const indexPath = path.join(serverSrcDir, 'index.ts');
|
const indexPath = path.join(serverSrcDir, 'index.ts');
|
||||||
const content = fs.readFileSync(indexPath, 'utf-8');
|
const content = fs.readFileSync(indexPath, 'utf-8');
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户