- claimOrder 状态闸门改为严格只允许 待接单(含 CAS 条件 status='待接单'),杜绝抢已在履约/已服务/已核销的订单 - getOrdersPool 仅返回未认领的 待接单 订单 - 种子数据新增一笔待接单订单用于演示抢单 - 更新/新增回归护栏 claim-status-gate.spec.ts(78 套件/608 用例全绿)
98 行
5.2 KiB
TypeScript
98 行
5.2 KiB
TypeScript
// 已读取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),");
|
|
});
|
|
});
|
|
});
|