human-butler-workorder.spec.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. // [TC-H-01] 真人管家 · AI 升级工单承接与乐观锁 —— 接口契约规格
  2. // ================================================================
  3. // 上游文档:tests/test-cases/human-butler.md → TC-H-01(P0);场景 tests/user-scenarios/09-human-butler.md SC-H-01
  4. // 运行:npx playwright test api/human-butler-workorder.spec.js -c playwright.config.api.js --project=desktop
  5. //
  6. // 契约来源(已核对后端实现):
  7. // cfc-backend/.../controller/admin/AdminButlerController.java
  8. // POST /api/admin/butler/review {userId, action:'approve'|'reject', reason}
  9. // POST /api/admin/butler/assign-member {profileId, memberId} ← profileId 实为 butler 的 userId
  10. // cfc-backend/.../service/ButlerService.java
  11. // approve(): if (!"pending".equals(user.getButlerStatus())) return error("该用户当前无待审核的管家申请");
  12. // assignMemberToButler(): 校验 approved → 统计成员数(LIKE referrerId)→ 判上限 → setReferrerId + updateById
  13. //
  14. // ⚠ 已识别实现风险(本规格 /2 为探针):
  15. // approve() 的状态守卫只在「串行」下有效。并发两个 approve 会同时通过 pending 校验,
  16. // 各自 addRole + 各自 butler_profiles.insert(selectOne 均返回 null)→ 产生重复管家档案。
  17. // → TC-H-01/2 复现时判红;若后端补乐观锁(版本号/唯一索引/条件更新)后自动转绿。
  18. //
  19. // ⚠ 另一处代码级问题(API 层无法构造,见交付说明):
  20. // assignMemberToButler 用 `.like(User::getReferrerId, profileId)` 统计已分配成员,
  21. // 对 Long 型 referrerId 用 LIKE 子串匹配(profileId=10 会命中 1/10/100/1010…),
  22. // 既可能虚高误判「已达最大成员数」,也可能跨管家串数。应改为 eq。
  23. //
  24. // ⚠ 场景缺口:SC-H-01 描述的「AI 升级工单」(工单队列/认领/上下文/超时升级)
  25. // 后端目前无对应实体与接口(仅 /api/admin/butler/* 的管家认证审核与成员分配)。
  26. // → TC-H-01/5 以 fixme 记录期望契约,待后端实现后解除。
  27. //
  28. // 安全约定:不打印 token / 手机号;所有测试数据由 direct-register 自建,不改动存量业务数据。
  29. const { test, expect } = require('@playwright/test');
  30. const { apiPost } = require('../helpers/api');
  31. const ADMIN_PHONE = process.env.ADMIN_PHONE || '13800138000';
  32. const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123';
  33. /** 注册账号:direct-register 冷启动实测可达 16s+,helpers 默认 15s 会超时,故显式 60s + 重试 */
  34. async function register(request, role, tag) {
  35. const phone = '13' + String(Math.floor(Math.random() * 900000000) + 100000000);
  36. let lastErr;
  37. for (let i = 0; i < 3; i++) {
  38. try {
  39. const r = await request.post('/api/auth/direct-register', {
  40. data: { phone, role, nickname: `QA-${tag}` },
  41. timeout: 60000,
  42. });
  43. const body = await r.json().catch(() => ({}));
  44. if (body.code === 200 && body.data && body.data.token) {
  45. return { token: body.data.token, userId: body.data.userId };
  46. }
  47. lastErr = body;
  48. } catch (e) {
  49. lastErr = e.message;
  50. }
  51. }
  52. throw new Error(`register(${role}) 失败: ${JSON.stringify(lastErr)}`);
  53. }
  54. /** 管理端登录,拿不到 token 时返回 null(由调用方 skip,避免账号变更导致误报) */
  55. async function adminLogin(request) {
  56. const r = await request.post('/api/admin-auth/login-by-password', {
  57. data: { phone: ADMIN_PHONE, password: ADMIN_PASSWORD },
  58. });
  59. const body = await r.json().catch(() => ({}));
  60. if (body.code !== 200 || !body.data || !body.data.token) return null;
  61. return body.data.token;
  62. }
  63. /** 造一个待审核的管家申请:普通用户注册 → /api/butler/apply,返回 {token,userId} */
  64. async function createPendingButler(request, tag) {
  65. const u = await register(request, 'parent', tag);
  66. const apply = await apiPost(
  67. request,
  68. '/api/butler/apply',
  69. { tier: 'bronze', description: `自动化用例申请-${tag}`, realName: 'QA测试' },
  70. u.token,
  71. );
  72. expect(apply.code, `管家申请应成功,实际 code=${apply.code} msg=${apply.message}`).toBe(200);
  73. return u;
  74. }
  75. /** 查某 userId 的管家档案条数(approved 态),用于验证是否重复建档 */
  76. async function countApprovedProfile(request, adminToken, userId) {
  77. const r = await apiPost(request, '/api/admin/butler/list', { status: 'approved', page: 1, size: 100 }, adminToken);
  78. expect(r.code, `管家列表查询失败 code=${r.code} msg=${r.message}`).toBe(200);
  79. const records = (r.data && r.data.records) || [];
  80. return records.filter((x) => String(x.userId) === String(userId)).length;
  81. }
  82. test.describe('[TC-H-01] 真人管家工单承接与并发守卫', () => {
  83. test('[TC-H-01/1] 串行重放审核:第二次必须被状态守卫拒绝(不重复建档)', async ({ request }) => {
  84. const admin = await adminLogin(request);
  85. test.skip(!admin, '管理端登录失败(账号/密码变更?),跳过');
  86. const butler = await createPendingButler(request, 'TCH01-1');
  87. const first = await apiPost(request, '/api/admin/butler/review', { userId: butler.userId, action: 'approve' }, admin);
  88. expect(first.code, `首次审核应通过,实际 code=${first.code} msg=${first.message}`).toBe(200);
  89. const second = await apiPost(request, '/api/admin/butler/review', { userId: butler.userId, action: 'approve' }, admin);
  90. expect(second.code, `重复审核应被状态守卫拒绝,实际 code=${second.code}(若为 200 说明缺幂等守卫)`).not.toBe(200);
  91. expect(String(second.message || ''), '拒绝原因应明确指向「无待审核申请」,便于排查').toContain('待审核');
  92. const n = await countApprovedProfile(request, admin, butler.userId);
  93. expect(n, `串行重复审核后管家档案应恰好 1 条,实际 ${n} 条`).toBe(1);
  94. console.log(`[TC-H-01/1] 二次审核 code=${second.code} msg=${second.message},档案数=${n}`);
  95. });
  96. test('[TC-H-01/2] 并发审核:同一申请并发 approve 不得产生重复管家档案(乐观锁探针)', async ({ request }) => {
  97. const admin = await adminLogin(request);
  98. test.skip(!admin, '管理端登录失败(账号/密码变更?),跳过');
  99. const butler = await createPendingButler(request, 'TCH01-2');
  100. // 两个请求同时打:均可能在 selectOne 时读到 profile=null,从而各自 insert
  101. const [a, b] = await Promise.all([
  102. apiPost(request, '/api/admin/butler/review', { userId: butler.userId, action: 'approve' }, admin),
  103. apiPost(request, '/api/admin/butler/review', { userId: butler.userId, action: 'approve' }, admin),
  104. ]);
  105. const okCount = [a, b].filter((x) => x.code === 200).length;
  106. const n = await countApprovedProfile(request, admin, butler.userId);
  107. console.log(`[TC-H-01/2] 并发结果 code=${a.code}/${b.code} 成功数=${okCount} 档案数=${n}`);
  108. // 底线:并发不得打出 5xx
  109. expect(a.status, '并发审核出现 5xx').toBeLessThan(500);
  110. expect(b.status, '并发审核出现 5xx').toBeLessThan(500);
  111. if (okCount <= 1 && n === 1) {
  112. test.info().annotations.push({ type: 'info', description: '本次未复现并发重复建档(可能已被串行化或时序未撞入)' });
  113. return;
  114. }
  115. // 复现:两个都成功 + 档案重复 = 缺乐观锁
  116. expect(
  117. n,
  118. `[SC-H-01 缺口] 并发审核产生 ${n} 条重复管家档案(成功 ${okCount} 次)。` +
  119. 'approve() 的 pending 守卫仅对串行有效,并发时两个请求均通过校验并各自 butler_profiles.insert。' +
  120. '建议:butler_profiles.user_id 加唯一索引 + 条件更新(UPDATE ... WHERE status=\'pending\')或版本号乐观锁。',
  121. ).toBe(1);
  122. });
  123. test('[TC-H-01/3] 状态守卫:未通过认证的管家不可被分配成员', async ({ request }) => {
  124. const admin = await adminLogin(request);
  125. test.skip(!admin, '管理端登录失败(账号/密码变更?),跳过');
  126. // 只申请、不审核 → 状态 pending
  127. const pending = await createPendingButler(request, 'TCH01-3');
  128. const member = await register(request, 'parent', 'TCH01-3M');
  129. const r = await apiPost(
  130. request,
  131. '/api/admin/butler/assign-member',
  132. { profileId: pending.userId, memberId: member.userId },
  133. admin,
  134. );
  135. expect(r.code, `未认证管家被分配成员应被拒,实际 code=${r.code}`).not.toBe(200);
  136. expect(String(r.message || ''), '拒绝原因应指明「未通过认证」').toContain('认证');
  137. console.log(`[TC-H-01/3] code=${r.code} msg=${r.message}`);
  138. });
  139. test('[TC-H-01/4] 分配生效:成员被正确归属到已认证管家(重复分配不报错、不重复计数)', async ({ request }) => {
  140. const admin = await adminLogin(request);
  141. test.skip(!admin, '管理端登录失败(账号/密码变更?),跳过');
  142. const butler = await createPendingButler(request, 'TCH01-4');
  143. const ap = await apiPost(request, '/api/admin/butler/review', { userId: butler.userId, action: 'approve' }, admin);
  144. expect(ap.code, `管家审核应通过 code=${ap.code} msg=${ap.message}`).toBe(200);
  145. const member = await register(request, 'parent', 'TCH01-4M');
  146. // 注意:assign-member 的 memberId 实为 users.id(后端 userMapper.selectById(memberId)),
  147. // 不是 family_members.id —— 传成员记录 id 会报「家庭成员不存在」。
  148. expect(member.userId, 'direct-register 应返回 userId,否则无法定位分配对象').toBeTruthy();
  149. const first = await apiPost(
  150. request,
  151. '/api/admin/butler/assign-member',
  152. { profileId: butler.userId, memberId: member.userId },
  153. admin,
  154. );
  155. expect(first.code, `首次分配应成功 code=${first.code} msg=${first.message}`).toBe(200);
  156. // 重复分配同一成员:应幂等(200 或明确已分配),不得 5xx
  157. const again = await apiPost(
  158. request,
  159. '/api/admin/butler/assign-member',
  160. { profileId: butler.userId, memberId: member.userId },
  161. admin,
  162. );
  163. expect(again.status, '重复分配不应 5xx').toBeLessThan(500);
  164. // 管家侧应能看到该成员
  165. const mine = await apiPost(request, '/api/butler/members', { page: 1, size: 50 }, butler.token);
  166. expect(mine.code, `管家成员列表查询失败 code=${mine.code} msg=${mine.message}`).toBe(200);
  167. const mineArr = (mine.data && (mine.data.list || mine.data.records)) || [];
  168. const hit = mineArr.filter((m) => String(m.id) === String(member.userId)).length;
  169. expect(hit, `管家侧应恰好看到该成员 1 次,实际 ${hit} 次(重复分配导致重复归属?)`).toBe(1);
  170. console.log(`[TC-H-01/4] 首次=${first.code} 重复=${again.code} 管家侧可见=${hit}`);
  171. });
  172. });
  173. // —— 后端尚无工单实体,保留期望契约 ——
  174. test.fixme('[TC-H-01/5] AI 升级工单:认领乐观锁(需后端先实现 /api/butler/workorder/*)', async () => {
  175. // 期望契约(待实现):
  176. // POST /api/butler/workorder/list {status:'pending', page, size} → 按风险等级排序的工单列表
  177. // POST /api/butler/workorder/claim {workorderId} → 认领;并发认领仅 1 人成功,另一人返回「已被认领」
  178. // POST /api/butler/workorder/close {workorderId, conclusion, duration} → 闭环并留痕
  179. // 断言:
  180. // 1. 两名管家并发 claim 同一工单 → 成功数 = 1,另一人 code != 200 且 message 含「已被认领」;
  181. // 2. 工单携带上下文(家长诉求 / AI 已给结论 / 风险等级)三项非空;
  182. // 3. close 后状态为已闭环,操作人与时间留痕;
  183. // 4. 高风险工单不排在普通工单之后。
  184. // 前置:需后端补齐工单实体与接口(当前仅有管家认证审核与成员分配)。
  185. });