// [TC-B-06] AI 成长教练 · LangGraph 不可用 Fallback —— 接口契约规格 // ================================================================ // 上游文档:tests/test-cases/butler.md → TC-B-06(P0);场景 tests/user-scenarios/08-butler.md SC-B-06 // 运行:npx playwright test api/butler-fallback.spec.js -c playwright.config.api.js --project=desktop // // 契约来源(已核对后端实现): // cfc-backend/.../controller/ai/AIChatController.java#sendHealthCoachMessage // Result gateBlocked = checkGate(userId); // 会员门禁:FREE 且开关开启 → 46003 // Map resp = aiGateway.chat(...); // String answer = resp != null ? resp.get("answer","") : ""; // 熔断/禁用 → resp=null → answer="" // return Result.success({answer, conversationId, recommendations, tasks}); // 仍 200 // // 本规格按「门禁两态」设计,保证在 FREE 环境与会员环境都产出有效断言: // A. 门禁拦截态(FREE):拦截必须是明确业务码(46003/46004)、非 5xx、不泄露内部错误、不影响其他接口; // B. 门禁放行态(BASIC+):返回结构完整;降级(answer 空)时必须带 degraded/fallback 标记。 // // ⚠ 已识别实现缺口(TC-B-06/2 为探针,勿删): // SC-B-06 要求「降级时明确提示 AI 服务降级中」,但当前实现降级态只把 answer 置空, // 响应体无 degraded / fallback 标记 → 前端无法区分「AI 说空话」与「AI 已降级」。 // TC-B-06/2 在降级发生时判红,补标记后自动转绿。 // // ⚠ 相关缺陷(复现于 2026-11-05 联调,已另行报告,非本规格断言项): // POST /api/membership/trial(激活试用会员)返回 500,导致测试账号无法自助升级为会员, // 故会员态用例需外部注入 AI_TEST_TOKEN。 // // 环境说明:远程仅部署后端 API(CFC-ENV-001),故为纯接口层规格,不依赖 SPA。 // 安全约定:不打印 token / 手机号。 const { test, expect } = require('@playwright/test'); const { apiPost } = require('../helpers/api'); /** 注册账号:direct-register 冷启动实测可达 16s+,故显式 60s 超时 + 2 次重试 */ 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)}`); } /** 优先用注入的会员账号(AI_TEST_TOKEN),否则注册 FREE 账号 */ async function getAccount(request, tag) { if (process.env.AI_TEST_TOKEN) { return { token: process.env.AI_TEST_TOKEN, userId: null, injected: true }; } const u = await register(request, 'parent', tag); return { ...u, injected: false }; } /** AI 门禁状态 */ async function aiGate(request, token) { const g = await apiPost(request, '/api/ai/gate', {}, token); const d = g.data || {}; const canUse = d.canUse === true || d.canUse === 'true'; return { canUse, status: d.status, memberLevel: d.memberLevel, code: g.code }; } /** AI 教练对话 */ function sendCoach(request, token, query) { return apiPost(request, '/api/ai/health-coach/send', { query }, token); } /** 门禁拦截的合法业务码(预期内的拒绝,不是故障) */ const GATE_CODES = [46003, 46004]; test.describe('[TC-B-06] AI 教练 Fallback 契约', () => { test('[TC-B-06/1] 门禁两态契约:放行则结构完整,拦截则明确业务码且不 5xx', async ({ request }) => { const u = await getAccount(request, 'TCB06-1'); const gate = await aiGate(request, u.token); const r = await sendCoach(request, u.token, '孩子最近便秘,饮食上要注意什么?'); expect(r.status, `HTTP 不应为服务端错误,实际 ${r.status}`).toBeLessThan(500); if (!gate.canUse) { // A. 拦截态:必须是明确业务码 + 可读提示,不能 500/空白 test.info().annotations.push({ type: 'info', description: `门禁拦截态(${gate.status}/${gate.memberLevel}),校验拦截契约` }); expect(GATE_CODES, `门禁拦截应返回 46003/46004,实际 code=${r.code} msg=${r.message}`).toContain(r.code); expect(String(r.message || '').length, '拦截应给出可读提示,不能为空').toBeGreaterThan(0); expect( /会员|续费|开通/.test(String(r.message || '')), `提示文案应引导开通/续费,实际「${r.message}」,用户无法自助解决`, ).toBeTruthy(); console.log(`[TC-B-06/1] 拦截态 code=${r.code} msg=${r.message}`); return; } // B. 放行态:结构完整 expect(r.code, `放行态业务码应为 200,实际 code=${r.code} msg=${r.message}`).toBe(200); expect(r.data, '放行态应含 data').toBeTruthy(); expect(typeof r.data.answer, 'data.answer 必须是字符串(降级时为空串,不能 null/undefined)').toBe('string'); expect(r.data, '返回结构应含 conversationId').toHaveProperty('conversationId'); expect(Array.isArray(r.data.recommendations), 'recommendations 应为数组').toBeTruthy(); expect(Array.isArray(r.data.tasks), 'tasks 应为数组').toBeTruthy(); console.log(`[TC-B-06/1] 放行态 answer 长度=${(r.data.answer || '').length}`); }); test('[TC-B-06/2] 降级态契约:answer 为空时必须携带降级标记(已知缺口探针)', async ({ request }) => { const u = await getAccount(request, 'TCB06-2'); const gate = await aiGate(request, u.token); test.skip(!gate.canUse, `门禁未放行(${gate.status}/${gate.memberLevel}),降级契约需会员账号;可用 AI_TEST_TOKEN 注入`); const r = await sendCoach(request, u.token, '帮我看看孩子最近的成长情况'); expect(r.code, `降级也不得报错,实际 code=${r.code} msg=${r.message}`).toBe(200); const answer = r.data && typeof r.data.answer === 'string' ? r.data.answer : ''; if (answer.trim().length > 0) { test.info().annotations.push({ type: 'info', description: '本次 AI 正常返回,未触发降级,跳过降级标记校验' }); console.log('[TC-B-06/2] AI 正常态,未触发降级'); return; } const d = r.data || {}; const degradedFlag = d.degraded === true || d.fallback === true || d.fallbackUsed === true || d.fallback_used === true; expect( degradedFlag, '[SC-B-06 缺口] AI 降级(answer 为空)但响应体未携带 degraded / fallback 标记,' + '前端无法提示「AI 服务降级中」,用户只会看到空白回答。' + '修复位置:AIChatController#sendHealthCoachMessage 的 resp==null 分支。', ).toBeTruthy(); }); test('[TC-B-06/3] 稳定性:连续 10 次请求无 5xx、无非预期业务码(含降级不雪崩)', async ({ request }) => { const u = await getAccount(request, 'TCB06-3'); const gate = await aiGate(request, u.token); const N = 10; const statuses = []; const codes = []; for (let i = 0; i < N; i++) { // 串行而非并发:不在共享远端施压,只验证单链路稳定性 const r = await sendCoach(request, u.token, `第${i + 1}次:儿童作息建议`); statuses.push(r.status); codes.push(r.code); } const serverErr = statuses.filter((s) => s >= 500); expect(serverErr.length, `出现 ${serverErr.length}/${N} 个 5xx:${JSON.stringify(statuses)}`).toBe(0); // 业务码只允许:200(成功/降级空答)或门禁码;出现其它(如 500/502/504 包装成业务码)判红 const allowed = [200, ...GATE_CODES]; const bad = codes.filter((c) => !allowed.includes(c)); expect(bad, `出现非预期业务码 ${JSON.stringify(bad)}(全量 ${JSON.stringify(codes)}),疑似 AI 服务异常被吞成业务错误`).toHaveLength(0); console.log(`[TC-B-06/3] gate=${gate.canUse ? '放行' : '拦截'} status=${JSON.stringify(statuses)} code=${JSON.stringify(codes)}`); }); test('[TC-B-06/4] AI 不可用不阻塞其他业务接口:AI 请求后家庭列表仍可用', async ({ request }) => { const u = await getAccount(request, 'TCB06-4'); const gate = await aiGate(request, u.token); const ai = await sendCoach(request, u.token, '测试 AI 与业务接口隔离'); expect(ai.status, 'AI 接口不应 5xx').toBeLessThan(500); const list = await apiPost(request, '/api/family/member/list', {}, u.token); expect(list.status, `AI 调用后家庭列表不可用,HTTP=${list.status}(疑似 AI 故障拖垮主流程)`).toBe(200); expect(list.data !== undefined, '家庭列表应返回 data').toBeTruthy(); console.log(`[TC-B-06/4] gate=${gate.canUse ? '放行' : '拦截'} ai_code=${ai.code} family_code=${list.code}`); }); }); // —— 需可控故障注入,远程共享环境不具备条件,保留为设计 —— test.fixme('[TC-B-06/5] 熔断半开恢复:熔断打开→快速失败→恢复后自动切回(需注入 LangGraph 不可达)', async () => { // 设计: // 1. 注入 LangGraph 不可达(测试环境置 python.enabled=false 或断网),连续 3 次触发 failure-threshold; // 2. 断言后续请求快速失败(P95 < connectTimeout)而非拖到 readTimeout(120s),且立即返回降级结果; // 3. 恢复后等待 reset-timeout-ms(默认 30s,半开)再调用,断言回答质量恢复、无需重启; // 4. 半开期并发 10 次,断言仅少量探测放行、无大量失败(不抖动)。 // 前置:需 tests 专用配置档 + 环境重启权限(execute 档),共享远端不可执行。 });