文件
shanghaipeiwan/server/tests/order-activity-integrity.spec.ts

94 行
5.1 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');
expect(code).toContain("const blocked = ['待付款', '待支付', '已完成', '已取消', '已关闭', '已退款', '已全额退款', '争议仲裁中']");
});
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),");
});
});
});