diff --git a/admin/arco-design-pro-vite/src/views/user/list/index.vue b/admin/arco-design-pro-vite/src/views/user/list/index.vue
index 5a01c6c..fdfc798 100644
--- a/admin/arco-design-pro-vite/src/views/user/list/index.vue
+++ b/admin/arco-design-pro-vite/src/views/user/list/index.vue
@@ -226,6 +226,11 @@
详情
+
+
+ 调余额
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 充值(增加余额)
+ 扣减(减少余额)
+
+
+
+
+
+
+
+
+
+ 该操作会真实修改用户余额并写入财务流水;扣减时不能超过当前余额,请谨慎操作。
+
+
+
@@ -622,6 +674,52 @@
const currentUser = ref(null);
const userDetail = ref(null);
+ // 人工调整余额
+ const balanceModalVisible = ref(false);
+ const balanceTarget = ref({});
+ 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 {
diff --git a/server/src/db/repository.ts b/server/src/db/repository.ts
index 3ece12e..d27df89 100644
--- a/server/src/db/repository.ts
+++ b/server/src/db/repository.ts
@@ -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 {
+ 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 {
if (isNaN(amount) || amount <= 0) {
throw new Error('充值金额必须大于0');
diff --git a/server/src/index.ts b/server/src/index.ts
index 7169759..d94434e 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -2961,7 +2961,7 @@ async function loadDemoAccounts(): Promise 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);
diff --git a/server/tests/admin-balance-adjust.spec.ts b/server/tests/admin-balance-adjust.spec.ts
new file mode 100644
index 0000000..ce64098
--- /dev/null
+++ b/server/tests/admin-balance-adjust.spec.ts
@@ -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('调整用户钱包余额');
+ });
+});