feat(admin): 用户列表新增「调余额」人工充值/扣减入口

- 新接口 POST /api/admin/users/:id/balance(requireAdmin):事务锁行、扣减不可为负、单次上限 100 万、写 finance_records 并推送站内通知

- 后台用户列表每行新增「调余额」按钮与弹窗(当前余额/方向/金额/备注)

- 新增 admin-balance-adjust 护栏;113 套件/737 用例全绿
这个提交包含在:
xcbyy
2026-10-21 17:41:54 +08:00
父节点 4e3283ff01
当前提交 14ce4b25f5
共有 4 个文件被更改,包括 238 次插入 和 1 次删除

查看文件

@@ -4204,6 +4204,76 @@ export class MysqlRepository {
return this.getUserById(id);
}
/**
* 管理后台人工调整钱包余额(运营/测试):事务内锁行、校验余额、写财务流水。
*/
async adjustUserBalance(
id: number,
amount: number,
direction: 'IN' | 'OUT',
reason: string,
operator: string
): Promise<any> {
const userId = Number(id);
const amt = Number(amount);
if (!userId || !Number.isFinite(amt) || amt <= 0) {
throw new Error('调整金额必须大于 0');
}
if (amt > 1000000) {
throw new Error('单次调整金额不能超过 ¥1,000,000');
}
const dir: 'IN' | 'OUT' = direction === 'OUT' ? 'OUT' : 'IN';
const note = String(reason || '').trim().slice(0, 80);
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [uRows]: any = await conn.query('SELECT id, nickname, balance FROM users WHERE id = ? FOR UPDATE', [userId]);
if (!uRows || uRows.length === 0) {
throw new Error('用户不存在');
}
const before = Number(uRows[0].balance) || 0;
const delta = dir === 'IN' ? amt : -amt;
const after = Number((before + delta).toFixed(2));
if (after < 0) {
throw new Error(`余额不足:当前余额 ¥${before.toFixed(2)},无法扣减 ¥${amt.toFixed(2)}`);
}
await conn.query('UPDATE users SET balance = ? WHERE id = ?', [after.toFixed(2), userId]);
const recId = `fin_admin_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`;
const recNo = `ADM${Date.now()}`;
await conn.query(`
INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())
`, [
recId,
recNo,
userId,
dir === 'IN' ? '后台余额充值' : '后台余额扣减',
amt.toFixed(2),
dir,
`${operator || '平台运营'}${note ? ` · ${note}` : ''}`,
`admin_${Date.now()}`
]);
await conn.commit();
return {
userId,
nickname: uRows[0].nickname || '',
direction: dir,
amount: amt.toFixed(2),
before: before.toFixed(2),
after: after.toFixed(2)
};
} catch (e) {
try { await conn.rollback(); } catch (_) {}
throw e;
} finally {
conn.release();
}
}
async rechargeUserBalance(id: number, amount: number, bonusAmount: number = 0): Promise<any> {
if (isNaN(amount) || amount <= 0) {
throw new Error('充值金额必须大于0');