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('调整用户钱包余额'); + }); +});