|
|
@@ -0,0 +1,213 @@
|
|
|
+// [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. 高风险工单不排在普通工单之后。
|
|
|
+ // 前置:需后端补齐工单实体与接口(当前仅有管家认证审核与成员分配)。
|
|
|
+});
|