// 已读取AGENTS.md import * as fs from 'fs'; import * as path from 'path'; /** * 第二批高危项回归闸门:报名超卖 / 抢单竞态 / 评价绕过结算 * 以「源码契约断言」方式锁定关键并发与校验模式,防止后续重构悄悄回退。 */ describe('订单与活动资金一致性护栏 (报名超卖 / 抢单 CAS / 评价结算绕过)', () => { const rootDir = path.resolve(__dirname, '../../'); const serverRepoPath = path.join(rootDir, 'server/src/db/repository.ts'); const serverMysqlPath = path.join(rootDir, 'server/src/db/mysql.ts'); const pwuniappOrderListPath = path.join(rootDir, 'pwuniapp/pages/order/list.vue'); describe('1. 活动报名必须使用事务 + 行锁,杜绝超卖', () => { it('joinActivity 必须在事务内对活动行加锁,并以当前读统计名额', () => { const code = fs.readFileSync(serverRepoPath, 'utf-8'); expect(code).toContain('async joinActivity('); expect(code).toContain('FROM activities WHERE id = ? FOR UPDATE'); expect(code).toContain('SELECT id, user_id, verify_code FROM participants WHERE activity_id = ? FOR UPDATE'); expect(code).toContain('活动报名人数已满'); }); it('joinActivity 必须防重复报名并生成活动内唯一核销码', () => { const code = fs.readFileSync(serverRepoPath, 'utf-8'); expect(code).toContain('用户已报名该活动'); expect(code).toContain('const usedCodes = new Set(rowsList.map((r: any) => String(r.verify_code)));'); }); it('数据库层必须为 participants 增加 (activity_id,user_id) 与 (activity_id,verify_code) 唯一约束', () => { const mysql = fs.readFileSync(serverMysqlPath, 'utf-8'); expect(mysql).toContain('UNIQUE KEY uq_activity_user (activity_id, user_id)'); expect(mysql).toContain('UNIQUE KEY uq_activity_verify (activity_id, verify_code)'); expect(mysql).toContain('ALTER TABLE participants ADD UNIQUE KEY uq_activity_user (activity_id, user_id)'); expect(mysql).toContain('ALTER TABLE participants ADD UNIQUE KEY uq_activity_verify (activity_id, verify_code)'); }); }); describe('2. 抢单必须以 claimed_by 原子 CAS 保证唯一赢家', () => { it('claimOrder 必须使用 claimed_by 条件更新并校验受影响行数', () => { const code = fs.readFileSync(serverRepoPath, 'utf-8'); expect(code).toContain('async claimOrder('); expect(code).toContain('claimed_by = ?, claimed_at = NOW()'); expect(code).toContain('AND (claimed_by IS NULL OR claimed_by = ?)'); expect(code).toContain('res.affectedRows !== 1'); expect(code).toContain('该订单已被其他大神抢先接单'); }); it('claimOrder 必须仅允许待接单抢单', () => { const code = fs.readFileSync(serverRepoPath, 'utf-8'); const start = code.indexOf('async claimOrder('); expect(start).toBeGreaterThan(-1); const block = code.slice(start, start + 2400); expect(block).toContain("String(cur.status) !== '待接单'"); expect(block).toContain("AND status = '待接单'"); }); it('数据库层必须为 orders 增加 claimed_by / claimed_at 认领标记列', () => { const mysql = fs.readFileSync(serverMysqlPath, 'utf-8'); expect(mysql).toContain('ALTER TABLE orders ADD COLUMN claimed_by INT NULL AFTER expert_id'); expect(mysql).toContain('ALTER TABLE orders ADD COLUMN claimed_at DATETIME NULL AFTER claimed_by'); }); }); describe('3. 完单结算必须原子且幂等,杜绝重复结算', () => { it('completeOrderWithSettlement 必须在事务内锁定订单并再次校验幂等键', () => { const code = fs.readFileSync(serverRepoPath, 'utf-8'); expect(code).toContain('async completeOrderWithSettlement('); expect(code).toContain('FROM orders WHERE id = ? OR order_no = ? FOR UPDATE'); expect(code).toContain("related_id = ? AND type = '大神完单收益结算'"); expect(code).toContain('await conn.commit();'); expect(code).toContain('await conn.rollback();'); }); }); describe('4. 订单评价必须服务端校验身份/状态/防重,杜绝绕过结算', () => { it('createOrderReview 必须校验买家身份、可评价状态与防重复评价', () => { const code = fs.readFileSync(serverRepoPath, 'utf-8'); expect(code).toContain('async createOrderReview('); expect(code).toContain('仅下单买家本人可以评价该订单'); expect(code).toContain('不可评价'); expect(code).toContain('该订单已评价,请勿重复提交'); expect(code).toContain("const reviewable = ['履约中', '服务中', '已到场核销', '待确认', '已完成']"); }); it('同一订单仅允许一条评价的数据库唯一约束必须存在', () => { const mysql = fs.readFileSync(serverMysqlPath, 'utf-8'); expect(mysql).toContain('UNIQUE KEY uq_order_review (order_id)'); expect(mysql).toContain('ALTER TABLE order_reviews ADD UNIQUE KEY uq_order_review (order_id)'); }); it('小程序评价必须提交真实登录用户ID,严禁硬编码 1001 绕过买家校验', () => { const vue = fs.readFileSync(pwuniappOrderListPath, 'utf-8'); expect(vue).toContain("userId: Number(uni.getStorageSync('userId') || 1001),"); }); }); });