| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213 |
- // [TC-H-01] 真人管家 · AI 升级工单承接与乐观锁 —— 接口契约规格
- // ================================================================
- // 上游文档:tests/test-cases/human-butler.md → TC-H-01(P0);场景 tests/user-scenarios/09-human-butler.md SC-H-01
- // 运行:npx playwright test api/human-butler-workorder.spec.js -c playwright.config.api.js --project=desktop
- //
- // 契约来源(已核对后端实现):
- // cfc-backend/.../controller/admin/AdminButlerController.java
- // POST /api/admin/butler/review {userId, action:'approve'|'reject', reason}
- // POST /api/admin/butler/assign-member {profileId, memberId} ← profileId 实为 butler 的 userId
- // cfc-backend/.../service/ButlerService.java
- // approve(): if (!"pending".equals(user.getButlerStatus())) return error("该用户当前无待审核的管家申请");
- // assignMemberToButler(): 校验 approved → 统计成员数(LIKE referrerId)→ 判上限 → setReferrerId + updateById
- //
- // ⚠ 已识别实现风险(本规格 /2 为探针):
- // approve() 的状态守卫只在「串行」下有效。并发两个 approve 会同时通过 pending 校验,
- // 各自 addRole + 各自 butler_profiles.insert(selectOne 均返回 null)→ 产生重复管家档案。
- // → TC-H-01/2 复现时判红;若后端补乐观锁(版本号/唯一索引/条件更新)后自动转绿。
- //
- // ⚠ 另一处代码级问题(API 层无法构造,见交付说明):
- // assignMemberToButler 用 `.like(User::getReferrerId, profileId)` 统计已分配成员,
- // 对 Long 型 referrerId 用 LIKE 子串匹配(profileId=10 会命中 1/10/100/1010…),
- // 既可能虚高误判「已达最大成员数」,也可能跨管家串数。应改为 eq。
- //
- // ⚠ 场景缺口:SC-H-01 描述的「AI 升级工单」(工单队列/认领/上下文/超时升级)
- // 后端目前无对应实体与接口(仅 /api/admin/butler/* 的管家认证审核与成员分配)。
- // → TC-H-01/5 以 fixme 记录期望契约,待后端实现后解除。
- //
- // 安全约定:不打印 token / 手机号;所有测试数据由 direct-register 自建,不改动存量业务数据。
- const { test, expect } = require('@playwright/test');
- const { apiPost } = require('../helpers/api');
- const ADMIN_PHONE = process.env.ADMIN_PHONE || '13800138000';
- const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123';
- /** 注册账号:direct-register 冷启动实测可达 16s+,helpers 默认 15s 会超时,故显式 60s + 重试 */
- async function register(request, role, tag) {
- const phone = '13' + String(Math.floor(Math.random() * 900000000) + 100000000);
- let lastErr;
- for (let i = 0; i < 3; i++) {
- try {
- const r = await request.post('/api/auth/direct-register', {
- data: { phone, role, nickname: `QA-${tag}` },
- timeout: 60000,
- });
- const body = await r.json().catch(() => ({}));
- if (body.code === 200 && body.data && body.data.token) {
- return { token: body.data.token, userId: body.data.userId };
- }
- lastErr = body;
- } catch (e) {
- lastErr = e.message;
- }
- }
- throw new Error(`register(${role}) 失败: ${JSON.stringify(lastErr)}`);
- }
- /** 管理端登录,拿不到 token 时返回 null(由调用方 skip,避免账号变更导致误报) */
- async function adminLogin(request) {
- const r = await request.post('/api/admin-auth/login-by-password', {
- data: { phone: ADMIN_PHONE, password: ADMIN_PASSWORD },
- });
- const body = await r.json().catch(() => ({}));
- if (body.code !== 200 || !body.data || !body.data.token) return null;
- return body.data.token;
- }
- /** 造一个待审核的管家申请:普通用户注册 → /api/butler/apply,返回 {token,userId} */
- async function createPendingButler(request, tag) {
- const u = await register(request, 'parent', tag);
- const apply = await apiPost(
- request,
- '/api/butler/apply',
- { tier: 'bronze', description: `自动化用例申请-${tag}`, realName: 'QA测试' },
- u.token,
- );
- expect(apply.code, `管家申请应成功,实际 code=${apply.code} msg=${apply.message}`).toBe(200);
- return u;
- }
- /** 查某 userId 的管家档案条数(approved 态),用于验证是否重复建档 */
- async function countApprovedProfile(request, adminToken, userId) {
- const r = await apiPost(request, '/api/admin/butler/list', { status: 'approved', page: 1, size: 100 }, adminToken);
- expect(r.code, `管家列表查询失败 code=${r.code} msg=${r.message}`).toBe(200);
- const records = (r.data && r.data.records) || [];
- return records.filter((x) => String(x.userId) === String(userId)).length;
- }
- test.describe('[TC-H-01] 真人管家工单承接与并发守卫', () => {
- test('[TC-H-01/1] 串行重放审核:第二次必须被状态守卫拒绝(不重复建档)', async ({ request }) => {
- const admin = await adminLogin(request);
- test.skip(!admin, '管理端登录失败(账号/密码变更?),跳过');
- const butler = await createPendingButler(request, 'TCH01-1');
- const first = await apiPost(request, '/api/admin/butler/review', { userId: butler.userId, action: 'approve' }, admin);
- expect(first.code, `首次审核应通过,实际 code=${first.code} msg=${first.message}`).toBe(200);
- const second = await apiPost(request, '/api/admin/butler/review', { userId: butler.userId, action: 'approve' }, admin);
- expect(second.code, `重复审核应被状态守卫拒绝,实际 code=${second.code}(若为 200 说明缺幂等守卫)`).not.toBe(200);
- expect(String(second.message || ''), '拒绝原因应明确指向「无待审核申请」,便于排查').toContain('待审核');
- const n = await countApprovedProfile(request, admin, butler.userId);
- expect(n, `串行重复审核后管家档案应恰好 1 条,实际 ${n} 条`).toBe(1);
- console.log(`[TC-H-01/1] 二次审核 code=${second.code} msg=${second.message},档案数=${n}`);
- });
- test('[TC-H-01/2] 并发审核:同一申请并发 approve 不得产生重复管家档案(乐观锁探针)', async ({ request }) => {
- const admin = await adminLogin(request);
- test.skip(!admin, '管理端登录失败(账号/密码变更?),跳过');
- const butler = await createPendingButler(request, 'TCH01-2');
- // 两个请求同时打:均可能在 selectOne 时读到 profile=null,从而各自 insert
- const [a, b] = await Promise.all([
- apiPost(request, '/api/admin/butler/review', { userId: butler.userId, action: 'approve' }, admin),
- apiPost(request, '/api/admin/butler/review', { userId: butler.userId, action: 'approve' }, admin),
- ]);
- const okCount = [a, b].filter((x) => x.code === 200).length;
- const n = await countApprovedProfile(request, admin, butler.userId);
- console.log(`[TC-H-01/2] 并发结果 code=${a.code}/${b.code} 成功数=${okCount} 档案数=${n}`);
- // 底线:并发不得打出 5xx
- expect(a.status, '并发审核出现 5xx').toBeLessThan(500);
- expect(b.status, '并发审核出现 5xx').toBeLessThan(500);
- if (okCount <= 1 && n === 1) {
- test.info().annotations.push({ type: 'info', description: '本次未复现并发重复建档(可能已被串行化或时序未撞入)' });
- return;
- }
- // 复现:两个都成功 + 档案重复 = 缺乐观锁
- expect(
- n,
- `[SC-H-01 缺口] 并发审核产生 ${n} 条重复管家档案(成功 ${okCount} 次)。` +
- 'approve() 的 pending 守卫仅对串行有效,并发时两个请求均通过校验并各自 butler_profiles.insert。' +
- '建议:butler_profiles.user_id 加唯一索引 + 条件更新(UPDATE ... WHERE status=\'pending\')或版本号乐观锁。',
- ).toBe(1);
- });
- test('[TC-H-01/3] 状态守卫:未通过认证的管家不可被分配成员', async ({ request }) => {
- const admin = await adminLogin(request);
- test.skip(!admin, '管理端登录失败(账号/密码变更?),跳过');
- // 只申请、不审核 → 状态 pending
- const pending = await createPendingButler(request, 'TCH01-3');
- const member = await register(request, 'parent', 'TCH01-3M');
- const r = await apiPost(
- request,
- '/api/admin/butler/assign-member',
- { profileId: pending.userId, memberId: member.userId },
- admin,
- );
- expect(r.code, `未认证管家被分配成员应被拒,实际 code=${r.code}`).not.toBe(200);
- expect(String(r.message || ''), '拒绝原因应指明「未通过认证」').toContain('认证');
- console.log(`[TC-H-01/3] code=${r.code} msg=${r.message}`);
- });
- test('[TC-H-01/4] 分配生效:成员被正确归属到已认证管家(重复分配不报错、不重复计数)', async ({ request }) => {
- const admin = await adminLogin(request);
- test.skip(!admin, '管理端登录失败(账号/密码变更?),跳过');
- const butler = await createPendingButler(request, 'TCH01-4');
- const ap = await apiPost(request, '/api/admin/butler/review', { userId: butler.userId, action: 'approve' }, admin);
- expect(ap.code, `管家审核应通过 code=${ap.code} msg=${ap.message}`).toBe(200);
- const member = await register(request, 'parent', 'TCH01-4M');
- // 注意:assign-member 的 memberId 实为 users.id(后端 userMapper.selectById(memberId)),
- // 不是 family_members.id —— 传成员记录 id 会报「家庭成员不存在」。
- expect(member.userId, 'direct-register 应返回 userId,否则无法定位分配对象').toBeTruthy();
- const first = await apiPost(
- request,
- '/api/admin/butler/assign-member',
- { profileId: butler.userId, memberId: member.userId },
- admin,
- );
- expect(first.code, `首次分配应成功 code=${first.code} msg=${first.message}`).toBe(200);
- // 重复分配同一成员:应幂等(200 或明确已分配),不得 5xx
- const again = await apiPost(
- request,
- '/api/admin/butler/assign-member',
- { profileId: butler.userId, memberId: member.userId },
- admin,
- );
- expect(again.status, '重复分配不应 5xx').toBeLessThan(500);
- // 管家侧应能看到该成员
- const mine = await apiPost(request, '/api/butler/members', { page: 1, size: 50 }, butler.token);
- expect(mine.code, `管家成员列表查询失败 code=${mine.code} msg=${mine.message}`).toBe(200);
- const mineArr = (mine.data && (mine.data.list || mine.data.records)) || [];
- const hit = mineArr.filter((m) => String(m.id) === String(member.userId)).length;
- expect(hit, `管家侧应恰好看到该成员 1 次,实际 ${hit} 次(重复分配导致重复归属?)`).toBe(1);
- console.log(`[TC-H-01/4] 首次=${first.code} 重复=${again.code} 管家侧可见=${hit}`);
- });
- });
- // —— 后端尚无工单实体,保留期望契约 ——
- test.fixme('[TC-H-01/5] AI 升级工单:认领乐观锁(需后端先实现 /api/butler/workorder/*)', async () => {
- // 期望契约(待实现):
- // POST /api/butler/workorder/list {status:'pending', page, size} → 按风险等级排序的工单列表
- // POST /api/butler/workorder/claim {workorderId} → 认领;并发认领仅 1 人成功,另一人返回「已被认领」
- // POST /api/butler/workorder/close {workorderId, conclusion, duration} → 闭环并留痕
- // 断言:
- // 1. 两名管家并发 claim 同一工单 → 成功数 = 1,另一人 code != 200 且 message 含「已被认领」;
- // 2. 工单携带上下文(家长诉求 / AI 已给结论 / 风险等级)三项非空;
- // 3. close 后状态为已闭环,操作人与时间留痕;
- // 4. 高风险工单不排在普通工单之后。
- // 前置:需后端补齐工单实体与接口(当前仅有管家认证审核与成员分配)。
- });
|