feat(admin): 用户列表新增「调余额」人工充值/扣减入口
- 新接口 POST /api/admin/users/:id/balance(requireAdmin):事务锁行、扣减不可为负、单次上限 100 万、写 finance_records 并推送站内通知 - 后台用户列表每行新增「调余额」按钮与弹窗(当前余额/方向/金额/备注) - 新增 admin-balance-adjust 护栏;113 套件/737 用例全绿
这个提交包含在:
@@ -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');
|
||||
|
||||
@@ -2961,7 +2961,7 @@ async function loadDemoAccounts(): Promise<Array<{ userId: number; roleTag: stri
|
||||
roleDesc: String(a.roleDesc || ''),
|
||||
desc: String(a.desc || ''),
|
||||
}))
|
||||
.filter((a) => a.userId > 0)
|
||||
.filter((a: any) => a.userId > 0)
|
||||
.slice(0, 10);
|
||||
} catch (e) {
|
||||
logServerError('loadDemoAccounts', e);
|
||||
@@ -3793,6 +3793,36 @@ app.get('/api/users', requireAdmin, async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 管理后台:人工调整用户钱包余额(运营/测试用,写入财务流水)
|
||||
app.post('/api/admin/users/:id/balance', requireAdmin, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
const amount = Number(req.body?.amount);
|
||||
const direction = String(req.body?.direction || 'IN') === 'OUT' ? 'OUT' : 'IN';
|
||||
const reason = String(req.body?.reason || '').trim().slice(0, 80);
|
||||
|
||||
if (!userId || !Number.isFinite(amount) || amount <= 0) {
|
||||
return res.status(400).json({ code: 40000, message: '请填写有效的调整金额' });
|
||||
}
|
||||
|
||||
const result = await repo.adjustUserBalance(userId, amount, direction as 'IN' | 'OUT', reason, '平台运营');
|
||||
pushUserNotification(
|
||||
userId,
|
||||
'SYSTEM',
|
||||
direction === 'IN' ? '余额已充值' : '余额已扣减',
|
||||
`平台${direction === 'IN' ? '充值' : '扣减'} ¥${result.amount}${reason ? `(${reason})` : ''},当前余额 ¥${result.after}。`,
|
||||
'/pages/user/recharge'
|
||||
);
|
||||
res.json({
|
||||
code: 20000,
|
||||
data: result,
|
||||
message: direction === 'IN' ? `充值成功,当前余额 ¥${result.after}` : `扣减成功,当前余额 ¥${result.after}`
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ code: 40000, message: err.message || '余额调整失败' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/users/:id/status', requireAdmin, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
|
||||
在新工单中引用
屏蔽一个用户