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

查看文件

@@ -226,6 +226,11 @@
详情
</a-button>
<a-button type="outline" size="small" @click.stop="openBalanceModal(record)">
<template #icon><icon-plus /></template>
调余额
</a-button>
<a-popconfirm
:content="record.status === '正常' ? `确定要封禁用户 [${record.nickname}] 吗?封禁后将限制发单与接单。` : `确定要解除用户 [${record.nickname}] 的封禁状态吗?`"
ok-text="确认"
@@ -601,6 +606,53 @@
</a-tabs>
</div>
</a-drawer>
<!-- 人工调整钱包余额(运营/测试) -->
<a-modal
v-model:visible="balanceModalVisible"
title="调整用户钱包余额"
:width="480"
:ok-loading="submittingBalance"
ok-text="确认调整"
cancel-text="取消"
@ok="submitBalanceAdjust"
>
<a-form :model="balanceForm" layout="vertical">
<a-form-item label="用户">
<a-input :model-value="`${balanceTarget.nickname || ''}(UID: ${balanceTarget.id || ''})`" disabled />
</a-form-item>
<a-form-item label="当前余额">
<a-input :model-value="`¥${Number(balanceTarget.balance || 0).toFixed(2)}`" disabled />
</a-form-item>
<a-form-item label="调整方向">
<a-radio-group v-model="balanceForm.direction">
<a-radio value="IN">充值(增加余额)</a-radio>
<a-radio value="OUT">扣减(减少余额)</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="金额(元)" required>
<a-input-number
v-model="balanceForm.amount"
:min="0.01"
:max="100000"
:precision="2"
style="width: 100%"
placeholder="请输入调整金额"
/>
</a-form-item>
<a-form-item label="备注(写入用户财务流水)">
<a-input
v-model="balanceForm.reason"
placeholder="如:测试充值 / 活动赠送 / 人工补偿"
:max-length="80"
show-word-limit
/>
</a-form-item>
<a-alert type="warning">
该操作会真实修改用户余额并写入财务流水;扣减时不能超过当前余额,请谨慎操作。
</a-alert>
</a-form>
</a-modal>
</div>
</template>
@@ -622,6 +674,52 @@
const currentUser = ref<any>(null);
const userDetail = ref<any>(null);
// 人工调整余额
const balanceModalVisible = ref(false);
const balanceTarget = ref<any>({});
const balanceForm = ref({ direction: 'IN', amount: 100, reason: '' });
const submittingBalance = ref(false);
const openBalanceModal = (record: any) => {
balanceTarget.value = { ...(record || {}) };
balanceForm.value = { direction: 'IN', amount: 100, reason: '' };
balanceModalVisible.value = true;
};
const submitBalanceAdjust = async () => {
const amount = Number(balanceForm.value.amount);
if (!amount || amount <= 0) {
Message.warning('请输入有效的调整金额');
return;
}
if (!balanceTarget.value.id) {
Message.error('用户信息缺失,请刷新后重试');
return;
}
submittingBalance.value = true;
try {
const res: any = await axios.post(`/api/admin/users/${balanceTarget.value.id}/balance`, {
amount,
direction: balanceForm.value.direction,
reason: balanceForm.value.reason,
});
if (res && res.data && res.data.code === 20000) {
Message.success(res.data.message || '余额调整成功');
balanceModalVisible.value = false;
await fetchUsers();
if (detailDrawerVisible.value && currentUser.value) {
fetchUserDetail(currentUser.value.id);
}
} else {
Message.error((res && res.data && res.data.message) || '余额调整失败');
}
} catch (err: any) {
Message.error((err && err.message) || '余额调整失败');
} finally {
submittingBalance.value = false;
}
};
const fetchUsers = async () => {
loading.value = true;
try {

查看文件

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

查看文件

@@ -0,0 +1,39 @@
// 已读取AGENTS.md
import * as fs from 'fs';
import * as path from 'path';
/**
* 第九十八批:后台人工调整余额。
* 背景:测试与运营需要给用户钱包充值/扣减,但后台没有入口,只能改库。
* 要求:必须管理员权限、事务锁行、禁止扣成负数、写财务流水。
*/
describe('第九十八批后台余额调整闸门', () => {
const root = path.resolve(__dirname, '../../');
const read = (rel: string) => fs.readFileSync(path.join(root, rel), 'utf-8');
const indexCode = read('server/src/index.ts');
const repo = read('server/src/db/repository.ts');
const adminPage = read('admin/arco-design-pro-vite/src/views/user/list/index.vue');
it('接口必须要求管理员权限', () => {
expect(indexCode).toContain("app.post('/api/admin/users/:id/balance', requireAdmin");
expect(indexCode).toContain('adjustUserBalance');
});
it('仓储层必须锁行、校验负数并写财务流水', () => {
const start = repo.indexOf('async adjustUserBalance(');
expect(start).toBeGreaterThan(-1);
const block = repo.slice(start, start + 3200);
expect(block).toContain('FOR UPDATE');
expect(block).toContain('余额不足');
expect(block).toContain('后台余额充值');
expect(block).toContain('后台余额扣减');
expect(block).toContain('INSERT INTO finance_records');
});
it('后台用户列表必须提供「调余额」入口与弹窗', () => {
expect(adminPage).toContain('openBalanceModal');
expect(adminPage).toContain('balanceModalVisible');
expect(adminPage).toContain('/api/admin/users/${balanceTarget.value.id}/balance');
expect(adminPage).toContain('调整用户钱包余额');
});
});