Просмотр исходного кода

test(interface): 业务级重测 — 契约感知断言 + direct-register多角色token + 错误路径修正

- ops_interface_smoke.py: _classify 新增 AUTH_FAIL(HTTP401) 分类;修正 /api/user/switch-* 404 为真实路由(/api/auth/switch-role、/api/family/user/switch-{back-to-parent,mode,to-child});新增 direct_register/acquire_tokens 多角色 token 前置与 --multi-role/--only-extra 开关
- run_interface_tests_cron.sh: 报告生成同步 AUTH_FAIL 分类
- 新增 INTERFACE_CONTRACT_REVIEW.md(柯砚接口契约评审)、INTERFACE_TEST_BUSINESS_REPORT.md(业务级重测报告)
- 业务级重测结果 interface_test_biz_retest.tsv: 23 条用例, BIZ_OK 7, 404 4(疑似部署版本与源码分支错位, 待开发团队确认)
- 聚焦用例集 interface_test_focus_biz.tsv
E2E Test Bot 2 недель назад
Родитель
Сommit
9ad0f95727

+ 277 - 0
INTERFACE_CONTRACT_REVIEW.md

@@ -0,0 +1,277 @@
+# CFC 小程序后端「接口契约」评审报告
+
+> 评审角色:集成·接口级评审专家(柯砚)
+> 代码仓库:`D:/cfc` · 后端:`D:/cfc/cfc-backend`(Spring Boot 2.7,端口 **9082**,统一 `@PostMapping`,JWT Bearer 鉴权)
+> 评审方式:全程只读,仅新增本文件,未修改/删除/提交任何源码
+> 目标:为把空体冒烟测试升级为业务级测试(带真实参数、多角色 token)提供契约基线,逐步达到 100% 通过
+
+---
+
+## 0. 前置结论(先读这段)
+
+1. **统一响应结构**:`Result<T>{code,message,data}`,位于 `common/Result.java`。`code=200` 成功;其余为非 0 失败码。
+2. **鉴权**:`JwtInterceptor` 拦截 `/api/**`,白名单见 §2。**token 通过 `Authorization: Bearer <token>` 头传递**(也可走 `?token=` 参数)。token 字段名是 `token`(在 `LoginResultDTO` 里,不是 `accessToken`)。
+3. **既有冒烟 0% 的根因**:`INTERFACE_TEST_REPORT.md` 显示全部 `NON_JSON`,目标域填的是 `cfc.iwintrue.com`(外网不可达,返回 HTML 而非 JSON)。**测试应改为指向 `http://localhost:9082`**。同时基线里大量路径写错(如 `/api/user/switch-*`、实际是 `/api/family/user/switch-role`),导致 404/错路由。
+4. **没有 `mock-login` 接口**。`auto-login` 已废弃(返回 410)。多角色 token 的获取姿势见 §3、§4。
+
+---
+
+## 1. 统一响应结构 `Result<T>` 与 code 取值约定
+
+**定义位置**:`src/main/java/com/etotem/cfc/common/Result.java:8`
+
+字段:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `code` | `Integer` | 业务码,200=成功 |
+| `message` | `String` | 提示文案 |
+| `data` | `T` | 业务数据,可 null |
+
+**code 取值约定(证据均在 `Result.java`)**:
+
+| 取值 | 含义 | 出处 / 触发位置 |
+|------|------|----------------|
+| `200` | 成功 | `Result.java:16,23,31`(`success()` 系列统一写 200) |
+| `500` | 通用失败(参数错误、业务异常、未登录等混用) | `Result.java:39`(`error(String)` 写死 500) |
+| `5001` | 无家庭(请先创建或加入家庭) | `Result.java:45-50`(`noFamily()`) |
+| `401` | 未登录 / token 失效 | 见 §2 `JwtInterceptor.writeUnauthorized`;业务层 `AuthController.verifyToken` `AuthController.java:226,238,246`、`sso-ticket` `:353` 用 `Result.error(401,...)` |
+| `403` | 无权限(越权访问家庭数据 / 组织权限) | `FamilyAccessInterceptor.java:139`(`{"code":403,...}`);`common/AdminOrgAccess.java:40,43,46,52,61` |
+| `410` | 接口已废弃(Gone) | `AuthController.java:139`(auto-login)、`FamilyController.java:42,52`、`FamilyUserController.java:97,124,131`(switch-mode/switch-to-child/switch-back-to-parent) |
+| `4001` | 业务自定义失败(如“请先购买健康启航计划体验包”) | `HealthReportController.java:216` |
+| 其他任意非 0 | 各业务自定义错误码 | 全代码库散落大量 `Result.error(NNNN, msg)`,测试需**按接口逐个核对实际码**,不能假设只有上面几种 |
+
+**对编写用例的建议**:
+- 断言不要只判 `200`。把「业务成功」定义为 `code==200 && data!=null`(或 data 符合契约);「失败」定义为 `code!=200` 且断言 `message` 非空。
+- 必须记录每个接口返回的**实际错误码**(尤其是自定义码如 4001),避免把“预期失败”误判为“通过”或“崩溃”。
+- 注意:**未带 token 访问非白名单接口,不一定返回 401**。`JwtInterceptor` 对“无 token 且非强制登录路径”会注入匿名用户(`userId=-1, role=anonymous`)并放行,下游 Controller 通常返回 `code=500` 的 `Result.error("请先登录")`/`"未登录"`,HTTP 状态仍是 200。**只有 token 格式非法/过期、或命中 `LOGIN_REQUIRED_PATHS` 才会真正返回 HTTP 401**。用例要区分这两种“未登录”表现。
+
+---
+
+## 2. 鉴权拦截器 `JwtInterceptor` 与公开路径白名单
+
+**实现位置**:`src/main/java/com/etotem/cfc/config/JwtInterceptor.java:21`
+**注册位置**:`src/main/java/com/etotem/cfc/config/WebConfig.java:95-137`(拦截 `/api/**`,并在 `excludePathPatterns` 中再排除一批)
+
+### 2.1 白名单 `PUBLIC_PATHS`(`JwtInterceptor.java:32-77`,精确匹配 + `prefix/` 前缀匹配)
+
+以下路径**完全跳过 token 校验**(匿名即可访问):
+
+- 认证类:`/api/auth/send-code`、`/api/auth/phone-login`、`/api/auth/wechat-phone-login`、`/api/auth/silent-login`、`/api/auth/sso-login`、`/api/auth/auto-login`(已废弃)、`/api/auth/register-with-idcard`、`/api/auth/wechat-login`、`/api/auth/verify`、`/api/auth/check-phone`、`/api/auth/direct-register`、`/api/auth/register-with-invite`、`/api/auth/wechat-web/qr`、`/api/auth/wechat-web/poll`、`/api/auth/internal/`
+- 商品/商城:`/api/product/list`、`/api/product/detail`、`/api/special-price/info`、`/api/product/type-list`、`/api/product/related-products`、`/api/mini-game/list`
+- 文章:`/api/articles/list`、`/api/articles/detail`、`/api/articles/categories`、`/api/articles/featured`、`/api/articles/record-read`、`/api/articles/related-products`、`/api/articles/related`、`/api/articles/related-by-product`、`/api/content/related-items`
+- 成长/活动:`/api/growth/**`、`/api/activity/list`、`/api/activity/detail`、`/api/activity/order/notify`、`/api/content-sections/visible`
+- 心智:`/api/mind/alert/active`、`/api/mind/alert/checkin-alerts`、`/api/mind/traditional/mirror`、`/api/mind/traditional/compatibility`
+- 其他:`/api/admin-auth/send-code`、`/api/admin-auth/login`、`/api/admin-auth/login-by-password`、`/api/admin-auth/account-roles`、`/api/mcp/**`
+
+> 匹配规则见 `JwtInterceptor.java:93-99`:`path.equals(publicPath)` 或 `path.startsWith(boundaryPath + "/")`(`/**` 仅取前缀)。
+
+### 2.2 强制登录路径 `LOGIN_REQUIRED_PATHS`(`JwtInterceptor.java:80-83`)
+
+无 token 直接 **HTTP 401**:`/api/share/qrcode`、`/api/auth/verify-password`。
+
+### 2.3 token 解析与请求属性注入(`JwtInterceptor.java:102-191`)
+
+- 取 token:`Authorization` 头,去 `Bearer ` 前缀(`:130`);空则取 `?token=` 参数(`:106`)。
+- 解析成功后注入请求属性(下游 `@RequestAttribute` 取值):
+  - `userId`(`Long`,`:148`)
+  - `role`(`String`,`:149`)
+  - `roles`(`List<String>`,`:150`)
+  - `familyId`(`Long`,`:167`,user.family_id 为空时从 `family_members` 反查)
+  - `currentMemberId` / `familyMemberId`(`Long`,`:184-185`,当 familyId 非空时查家庭成员)
+- 无 token 且非强制登录:注入**匿名用户** `userId=-1, role="anonymous"`,放行(`:211-217`)。
+
+### 2.4 第二个拦截器:家庭越权 `FamilyAccessInterceptor`(`config/FamilyAccessInterceptor.java:33`)
+
+- 作用:校验请求里出现的 `familyId`(query/path/body 递归)是否属于当前用户自己或 `teacherFamilyIds`。
+- `admin/**` 跳过(`:51`)。
+- 默认 **logging 模式**(只打 warn,不拦截);**enforcement 模式**(`-Dfamily.access.mode=enforcement`)越权返回 **HTTP 403 + code 403**(`:135-140`)。
+- ⚠️ 测试环境若开了 enforcement,跨家庭访问会被 403;业务级用例需确保每个角色的 token 只访问自己家庭的数据。
+
+### 2.5 其它拦截器(顺序见 `WebConfig.java:94-179`)
+
+`operationLogInterceptor`、`rateLimitInterceptor` 也拦截 `/api/**`,并对白名单批量 exclude。限流可能让高频测试偶发失败,必要时在测试侧限速或识别限流返回。
+
+**对编写用例的建议**:
+- 把白名单接口标记为「免登录可测」,优先拿它们做“空体/最小参数”探活。
+- 非白名单接口必须先拿 token;**拿 token 的最小成本路径是 `/api/auth/direct-register`**(见 §3),无需验证码。
+- 注意公开接口(白名单)内部大多用 `@RequestAttribute(value="userId", required=false)`(如 `ActivityController.java:29`),匿名时 `userId` 为 null,不应断言“未登录 401”,而应断言业务正常返回(活动列表/详情本就是公开读)。
+
+---
+
+## 3. 登录相关接口(请求参数 + 返回 token 字段)
+
+**控制器**:`src/main/java/com/etotem/cfc/controller/auth/AuthController.java`
+
+> 所有登录/注册接口均返回 `Result<LoginResultDTO>`。token 字段名 = **`token`**(`LoginResultDTO`,见 `src/main/java/com/etotem/cfc/dto/LoginResultDTO.java`),响应体取值 `data.token`。前端/测试用 `Authorization: Bearer <data.token>` 携带。`LoginResultDTO` 还含 `userId, role, roles, familyId, nickname, phone, currentChildId, isNewUser, needsRoleSelect, needsFamily, teacherStatus, hasTeacherRole, vendorType, activityProviderStatus` 等,可用来驱动后续断言。
+
+| 接口 | 行号 | 请求体(必填项) | 说明 / 返回 |
+|------|------|------------------|------------|
+| `POST /api/auth/wechat-login` | `AuthController.java:175` | `{code}`(`WechatLoginDTO.code`) | 微信 code 登录;测试环境若无真实微信 code 不易用 |
+| `POST /api/auth/phone-login` | `:67` | `{phone, code}` | 需先 `send-code` 拿验证码并落库,流程重 |
+| `POST /api/auth/direct-register` | `:278` | `{phone, nickname?, avatar?, role?, birthday?, gender?, inviteCode?}`(`DirectRegisterDTO`) | **测试首选**:跳过验证码直接注册/登录;`phone` 必填且需 `^1[3-9]\d{9}$`;已注册则返回“该手机号已注册” | 
+| `POST /api/auth/register-with-idcard` | `:146` | `{phone, code, idCard, realName}` | 需验证码,身份证格式校验 |
+| `POST /api/auth/sso-login` | `:376` | `{ticket}`(公开) | 跨小程序 SSO:先 `/api/auth/sso-ticket` 签发,再消费换登录态 |
+| `POST /api/auth/sso-ticket` | `:349` | `{target:"train"}`(需登录) | 返回 `{ticket,target}` |
+| `POST /api/auth/auto-login` | `:136` | — | **已废弃 → `Result.error(410,...)`**,不要再用 |
+| `POST /api/auth/verify` | `:222` | 仅 `Authorization` 头 | 校验 token,返回 `{userId,role}`;token 非法/用户不存在 → `code=401` |
+| `POST /api/auth/wechat-phone-login` | `:90` | `{code, phoneCode}` | 需微信能力 |
+| `POST /api/auth/silent-login` | `:107` | `{code}` | 已废弃的 openid 自动登录替代方案(仍需微信 code) |
+
+**关于 mock-login**:**全代码库无 `mock-login` 接口**(已 grep 确认)。多角色 token 的推荐获取方式:
+1. `direct-register` 时直接带 `role`(值 `parent`/`teacher`)拿对应角色 token;
+2. 登录后用 `switch-role` 切换(见 §4)。
+
+**对编写用例的建议**:
+- 业务级用例的“登录前置”统一封装为:`POST /api/auth/direct-register` → 提取 `data.token` → 后续请求带 `Authorization: Bearer <token>`。注意 `direct-register` 会校验手机号格式,使用测试专用号段即可,但**同一手机号二次调用会报“已注册”**,用例需做号段/清理或复用已注册 token。
+- `needsFamily=true`(familyId 为空/0)时,要先建/加入家庭再调家庭/健康/任务类接口,否则易触发 `code=5001` 或 downstream 的 `familyId不能为空`。
+- 校验未登录判定:带**非法/过期 token** 才稳定得到 HTTP 401;不带 token 访问非白名单接口是匿名放行 + 业务 `code=500`,不要混为一谈。
+
+---
+
+## 4. 角色模型与切换角色接口现状
+
+### 4.1 角色如何区分
+
+**实体**:`src/main/java/com/etotem/cfc/entity/User.java`
+- `role`(`User.java:30`):当前活跃角色,取值 `parent` / `child` / `teacher` / `admin`。
+- `roles`(`User.java:34`):拥有的全部角色列表,JSON。
+- `familyRole`(`User.java:99`):家长身份(爸爸/妈妈/爷爷…),区别于 `role`。
+- `vendorType`(`User.java`):服务商类型 `planner` / `activity_provider` / `product_supplier`(配合 `vendorStatus`)。
+
+**角色语义(基于 `UserService` 与控制器)**:
+- **parent(家长)**:常规登录用户,可建/加入家庭,给孩子派任务、看健康报告。
+- **child(孩子)**:**通常不作为独立登录账号**——孩子以 `family_members` 表的成员记录存在,由家长以 `currentMemberId` 切换“成员视图”来代操作。`User.role="child"` 在代码里也存在(`UserService.java` 有 `setRole("child")` 分支),但小程序端孩子一般通过家长切换成员视图访问,而非自己登录拿 token。
+- **teacher(成长规划师)**:可登录,受 `teacherStatus`(pending/approved/rejected)审核;`teacherFamilyIds` 决定可访问哪些家庭。部分接口要求 `teacher` 角色(如 `FamilyController.guideBindFamily` `:68` 校验 `"teacher".equals(guide.getRole())`)。
+- **admin**:**禁止登录小程序**(`UserService.ssoLogin` `:701` 抛“该账号不允许登录小程序”;`switchRoleForLogin` `:635` 抛“管理员角色不允许切换”)。admin 走独立的 `/api/admin-auth/*` 体系。
+
+### 4.2 切换角色接口现状(重点:此前冒烟 `/api/user/switch-*` 返回 404 的原因)
+
+经核对,**不存在 `/api/user/switch-*` 路由**,真实路由如下(`grep` 证据):
+
+| 真实路由 | 行号 | 说明 |
+|----------|------|------|
+| `POST /api/auth/switch-role` | `AuthController.java:330` | 切换**活跃登录角色** `parent/teacher`,重新签发 token(返回 `LoginResultDTO`)。需登录。底层 `UserService.switchRoleForLogin` `:637`:非 teacher 不能切到 teacher |
+| `POST /api/family/user/switch-role` | `FamilyUserController.java:101-105` | 切 `parent/child/teacher`,body `{role}`,返回 `Result<String>` |
+| `POST /api/family/member/switch` | `FamilyMembersController.java:84` | **切换成员视图**(家长切到某个孩子 `memberId`),body `{memberId}`,返回 `SwitchMemberVO`。这是替代一堆废弃接口的新路由 |
+| `POST /api/family/user/switch-mode` | `FamilyUserController.java:94` | **已废弃 → `code=410`** |
+| `POST /api/family/user/switch-to-child` | `FamilyUserController.java:121` | **已废弃 → `code=410`** |
+| `POST /api/family/user/switch-back-to-parent` | `FamilyUserController.java:129` | **已废弃 → `code=410`** |
+
+**结论**:此前冒烟 `/api/user/switch-*` 的 404,是因为路径写错(少了 `/family/` 前缀,或混淆了废弃路由)。正确做法是:切换登录角色用 `/api/auth/switch-role`,切换成员视图用 `/api/family/member/switch`。不要再用任何 `switch-mode/switch-to-child/switch-back-to-parent`(都返回 410)。
+
+**对编写用例的建议**:
+- 多角色 token 准备:用 `direct-register` 带 `role=parent` 注册家长;另注册一个 `role=teacher` 且 `teacherStatus=approved` 的账号作规划师(注意 `switchRoleForLogin` 要求该用户 `roles` 里真含 teacher,否则切不过去)。
+- 验证 teacher 越权场景:用家长 token 调 `POST /api/family/guide-bind`(要求 teacher 角色)应得“只有成长规划师才能使用此功能”。
+- 孩子视角用例:家长登录 → `POST /api/family/member/switch {memberId}` 拿到成员视图 → 用返回的 `currentMemberId` 调健康/任务接口。
+
+---
+
+## 5. 代表性业务接口契约(优先此前冒烟大量 500 的 health / family / activity)
+
+> 通用约定:全部 `@PostMapping`;请求体多为 `Map<String,Object>` 或实体;缺参通常 `Result.error("xxx不能为空")` → `code=500`。以下“常见错误码”含业务自定义码。
+
+### 5.1 活动 Activity —— `controller/ActivityController.java`(`@RequestMapping("/api/activity")`)
+此前冒烟 500 多因未登录 / 缺 `id` / 缺 `memberId`。注意 `/api/activity/*` 中仅 `list`、`detail`、`order/notify` 在白名单,其余需登录。
+
+| 接口 | 行号 | 必填参数 | 正常返回 / 常见错误 |
+|------|------|----------|--------------------|
+| `POST /api/activity/list` | `:28` | 无(公开)`dimensionCode? page? size?` | `Map`(列表+分页);匿名可访问 |
+| `POST /api/activity/detail` | `:36` | `id`(必填) | `ActivityDTO`;缺 `id`→`"id不能为空"` |
+| `POST /api/activity/register` | `:123` | `id`(活动ID);单报需 `memberId`(缺→`"孩子ID不能为空"`) | `Map`;批量用 `registrants[]` |
+| `POST /api/activity/create` | `:44` | 活动实体;普通用户需登录(未登录且非管理员→`"普通用户需登录后创建活动"`) | `ActivityDTO` |
+| `POST /api/activity/publish` | `:71` | `id`;需 `activity_manager/admin` 角色 | 字符串;无权限→`"只有管理员或活动管理员可以发布活动"` |
+| `POST /api/activity/checkin` | `:167` | `id`, `memberId` | `Map`;含 `error` 键时 `Result.error(...)` |
+| `POST /api/activity/submit-review` | `:106` | `id`;须创建人或管理员 | `"已提交审核"` |
+
+活动管理角色判定:`hasActivityManageRole` `:23-26`:`role=="admin"||"activity_admin"` 或 `roles` 含 `activity_manager`。
+
+### 5.2 家庭 Family —— `controller/family/FamilyController.java`(`/api/family`)+ 成员
+此前 500 多因 `familyId` 缺失/无家庭。
+
+| 接口 | 行号 | 必填参数 | 正常返回 / 常见错误 |
+|------|------|----------|--------------------|
+| `POST /api/family/guide-bind` | `:59` | `inviteCode`;需 `teacher` 角色 | `"申请已提交,请等待家长审批"`;非 teacher→`"只有成长规划师才能使用此功能"`;无效码→`"邀请码无效"` |
+| `POST /api/family/member/switch` | `:84` | `memberId` | `SwitchMemberVO`;缺 `memberId`→`"缺少成员ID"` |
+| `POST /api/family/user/switch-role` | `FamilyUserController.java:101` | `{role}` | `String`;无该角色→`Result.error("切换失败...")` |
+| `POST /api/family/invite-code` / `generate` | `:40 / :50` | — | **已废弃 → `code=410`**,应改用 `/api/family/invite/generate`(邀请令牌) |
+
+### 5.3 健康现状 HealthStatus —— `controller/HealthStatusController.java`(`/api/health-status`)
+需登录(`userId` 必填,非白名单)。
+
+| 接口 | 行号 | 必填参数 | 正常返回 / 常见错误 |
+|------|------|----------|--------------------|
+| `POST /api/health-status/get` | `:28` | 无(`userId` 由拦截器注入);`memberId?` | `HealthStatus`(账号级或成员级) |
+| `POST /api/health-status/save` | `:48` | 至少填一项(全空→`"请至少填写一项"`);`heightCm/weightKg/bloodPressure/bloodGlucose` 有范围校验 | `HealthStatus`;身高 30-250、体重 3-300、血压 `\d{2,3}/\d{2,3}`、血糖 1-30,越界→对应中文错误 |
+| `POST /api/health-status/signature` | `:79` | `signature`(base64 PNG data URL) | `Void`;空→`"请完成手签名"` |
+
+### 5.4 健康报告 HealthReport —— `controller/HealthReportController.java`(`/api/health-report`)
+**此前 500 的高发区**。根因:报告创建依赖“已购买健康启航计划体验包”且需 `memberId`/`reportType`。
+
+| 接口 | 行号 | 必填参数 | 正常返回 / 常见错误 |
+|------|------|----------|--------------------|
+| `POST /api/health-report/report/create` | `:206` | `userId`(注入)、`memberId`、`reportType`(`physical_exam`/`gut_flora`)、且**需先购计划** | `HealthReport`;未购→`code=4001 "请先购买健康启航计划体验包"`;缺 `memberId`→`"memberId不能为空"`;缺 `reportType`→`"reportType不能为空: ..."` |
+| `POST /api/health-report/report/latest` | `:295` | `memberId` | `HealthReport`;缺→`"memberId不能为空"` |
+| `POST /api/health-report/report/list` | `:316` | `userId` | `List<HealthReport>` |
+| `POST /api/health-report/indicators/current` | `:541` | `memberId` | `List`;缺→`"memberId不能为空"` |
+| `POST /api/health-report/indicators/rebuild` | `:516` | `memberId`;**需 `role==admin`** | 否则→`"权限不足"` |
+
+> 推论:此前 health 模块 500,很可能是测试未建家庭/未购计划/未传 `memberId`。业务级用例需先完成“建家庭→(必要时购计划)→拿 `memberId`”的前置链。
+
+### 5.5 任务 Task —— `controller/task/TaskController.java`(`/api/task`)
+需登录;`/today` 等需要 `familyId`。
+
+| 接口 | 行号 | 必填参数 | 正常返回 / 常见错误 |
+|------|------|----------|--------------------|
+| `POST /api/task/today` | `:81` | `familyId`(缺→`"familyId不能为空"`) | `List<Task>` |
+| `POST /api/task/create` | `:73` | `userId`(注入) | `Long`(任务ID) |
+| `POST /api/task/{id}/complete` | `:109` | 路径 `id`;`currentMemberId` | `Map`;无权/不存在→`"任务不存在、无权完成或状态已变更"` |
+| `POST /api/task/pending-review` | `:189` | `userId` | `List<Task>` |
+| `POST /api/task/batch-complete` | `:255` | `taskIds`(缺→`"taskIds不能为空"`) | `Boolean` |
+
+### 5.6 商城/商品 Product(白名单只读)
+- `POST /api/product/list`(`WebConfig` 白名单、`JwtInterceptor` 白名单):公开,无必填。
+- `POST /api/product/detail`:公开,需 `id`。
+- `POST /api/special-price/info`:公开。
+
+---
+
+## 6. 业务级用例编写总建议(落地清单)
+
+1. **测试目标改 localhost**:`http://localhost:9082`(端口 `application.yml:2`),不要打外网域名,否则全 `NON_JSON`。
+2. **统一登录封装**:用 `POST /api/auth/direct-register`(带 `phone`+`role`)拿 `data.token`;每个角色(parent/teacher)+ 每个成员视图各准备一个 token 池,避免“已注册”冲突(可维护一批测试手机号)。
+3. **前置链路**:parent 注册 →(建/加家庭拿到 `familyId`)→(如测健康报告需购计划)→ 通过 `/api/family/member/switch` 拿 `memberId`。把这条链路做成用例 fixture。
+4. **断言策略**:成功 = `code==200`;失败 = `code!=200` 且记录真实 `message`/自定义码(如 4001/5001/410/403)。不要假设失败码只有 500。
+5. **未登录断言双形态**:非法 token → 真 HTTP 401(`/api/auth/verify` 等);不带头访问非白名单 → 匿名放行 + 业务 `code=500`,HTTP 仍 200。
+6. **角色/权限用例**:
+   - 家长调 `teacher`-only 接口(如 `/api/family/guide-bind`)→ 期望“只有成长规划师才能使用此功能”。
+   - 非 admin 调 `indicators/rebuild` → 期望“权限不足”。
+   - 跨家庭访问(若开 enforcement)→ 期望 403。
+7. **废弃接口不要测通过**:`auto-login`、`switch-mode`、`switch-to-child`、`switch-back-to-parent`、`/api/family/invite-code` 等均返回 410,用例应预期 410 而非 200/404。
+8. **修正错误路径**:把旧基线里的 `/api/user/switch-*` 改成 `/api/family/user/switch-role` 或 `/api/auth/switch-role`;`/api/activities/hot` 等需核对真实路由(活动列表是 `/api/activity/list`)。
+9. **限流**:`rateLimitInterceptor` 可能让高频批量测试偶发失败,建议在测试客户端加节流或识别限流返回后退避重试。
+10. **参数校验覆盖**:对 `health-status/save` 等做边界用例(身高 <30、体重 >300、血压格式错误、血糖越界)验证中文错误码;对 `activity/detail`、`report/latest` 等做缺 `id`/`memberId` 的负向用例。
+
+---
+
+## 附:核心证据文件索引
+
+| 主题 | 文件:行号 |
+|------|-----------|
+| 响应结构 / code 约定 | `common/Result.java:8,16,39,45,52` |
+| 401 未登录写出 | `config/JwtInterceptor.java:205-209` |
+| 403 越权写出 | `config/FamilyAccessInterceptor.java:135-140`;`common/AdminOrgAccess.java` |
+| 拦截注册 / 白名单 exclude | `config/WebConfig.java:94-179` |
+| 公开白名单 PUBLIC_PATHS | `config/JwtInterceptor.java:32-77` |
+| token 取用与属性注入 | `config/JwtInterceptor.java:102-191` |
+| JWT 签发(claims=userId/role/roles) | `config/JwtConfig.java:46-68` |
+| 登录/注册/切换接口 | `controller/auth/AuthController.java:67,136,175,222,278,330,349,376` |
+| 登录返回 DTO(token 字段) | `dto/LoginResultDTO.java` |
+| 角色字段 | `entity/User.java:30,34,99` |
+| 切换角色(真实路由) | `AuthController.java:330`;`FamilyUserController.java:101`;`FamilyMembersController.java:84`;废弃 `FamilyUserController.java:94,121,129` |
+| 活动接口 | `controller/ActivityController.java:28,36,44,71,123,167` |
+| 家庭/成员切换 | `controller/family/FamilyController.java:59`;`FamilyMembersController.java:84` |
+| 健康现状 | `controller/HealthStatusController.java:28,48,79` |
+| 健康报告(4001) | `controller/HealthReportController.java:206,216,295,541` |
+| 任务 | `controller/task/TaskController.java:73,81,109,189,255` |
+| 端口 | `src/main/resources/application.yml:2` |

+ 115 - 0
INTERFACE_TEST_BUSINESS_REPORT.md

@@ -0,0 +1,115 @@
+# CFC 接口业务级重测报告(Business-level Retest)
+
+> 生成时间:2026-10-27 · 测试目标:`cfc.iwintrue.com`(经 `192.168.16.251` + `Host` 头访问)
+> 测试脚本:`ops_interface_smoke.py`(本轮已升级:契约感知断言 + `direct-register` 多角色 token 前置 + 错误路径修正)
+> 用例来源:`interface_test_focus_biz.tsv`(19 条聚焦业务用例)+ 多角色验证 4 条
+> 契约基线:`INTERFACE_CONTRACT_REVIEW.md`
+
+---
+
+## 一、结论摘要
+
+- **断言已升级为契约感知**:成功 = `Result.code == 200`;`HTTP 200 + code≠200`(匿名放行/业务失败)归 `BIZ_FAIL`;真 `HTTP 401` 归 `AUTH_FAIL`;`404` 归 `NOT_FOUND`。
+- **多角色 token 前置可用**:`wechat-login`(父)+ `direct-register(role=teacher)`(师)均成功获取;角色访问控制验证通过(见第三节)。
+- **错误路径已修正**:原 `/api/user/switch-*` 404 全部修正为真实路由,废弃接口现正确返回 `410`。
+- **发现 1 个需开发团队处理的真问题**:`/api/health-report/report/latest`、`/api/health-report/indicators/current`、`/api/task/today`、`/api/health-report/indicators/rebuild` 在**已部署服务器返回 404**,但源码中确有这些路由(见 `INTERFACE_CONTRACT_REVIEW.md` 证据)。疑似**部署版本与源码分支不一致(版本错位)**。
+
+---
+
+## 二、分类汇总(共 23 条)
+
+| 分类 | 数量 | 说明 |
+|------|------|------|
+| `BIZ_OK` | 7 | 契约成功(code=200) |
+| `BIZ_FAIL` | 12 | HTTP 200 但 code≠200(含 3×`410` 废弃接口 + 9×空体预期失败) |
+| `NOT_FOUND` | 4 | 已部署服务器 404(见第四节,疑似版本错位) |
+| `AUTH_FAIL` | 0 | 本轮无真 401(匿名放行场景归 BIZ_FAIL,符合契约) |
+| `SERVER_ERR` | 0 | 无 5xx |
+| `CONN_ERR` | 0 | 连通正常 |
+
+> 业务成功率(BIZ_OK)7/23 = 30%。该值偏低属**预期**:本批用例多为空体请求,9 条 BIZ_FAIL 是"缺必填参数"的结构化失败,证明接口可达且返回规范 `Result`,并非崩溃。
+
+---
+
+## 三、多角色验证(本轮核心升级,全部通过)
+
+| 验证项 | 请求身份 | 结果 | 结论 |
+|--------|----------|------|------|
+| `/api/auth/verify` | teacher token | `BIZ_OK` (code=200) | teacher token 有效 |
+| `/api/family/guide-bind` | parent token | `BIZ_FAIL` msg="只有成长规划师才能使用此功能" | parent 被正确拒绝(角色校验生效) |
+| `/api/family/guide-bind` | teacher token | `BIZ_FAIL` msg="邀请码无效" | teacher 角色被接受,进入业务校验(非角色拒绝) |
+| `/api/health-report/indicators/rebuild` | parent token | `NOT_FOUND` (404) | 见第四节(路由在部署端缺失) |
+
+**结论**:多角色 token 获取与 RBAC 行为验证链路打通,测试脚本已具备业务级多角色能力。
+
+---
+
+## 四、需开发团队处理的契约缺口(优先级高)
+
+以下路由在源码中存在,但**已部署的 `cfc.iwintrue.com` 返回 404**:
+
+| 接口 | 源码证据(INTERFACE_CONTRACT_REVIEW.md) | 部署端表现 |
+|------|------------------------------------------|------------|
+| `POST /api/health-report/report/latest` | `HealthReportController.java:295` | 404 |
+| `POST /api/health-report/indicators/current` | `HealthReportController.java:541` | 404 |
+| `POST /api/health-report/indicators/rebuild` | `HealthReportController.java:516` | 404 |
+| `POST /api/task/today` | `TaskController.java:81` | 404 |
+
+**初步判断**:同服务器上 `activity/list`、`product/list`、`health-status/get` 等均正常 200,说明是**特定 handler 未部署**而非网络/网关问题。最大嫌疑是**部署版本落后源码分支**(如部署跑 `master`、而源码在 `feature/ui-token-u5-color-tokens` 或反之)。
+
+**建议动作**:
+1. 确认 `cfc.iwintrue.com` 实际部署的分支/构建号;
+2. 重新部署最新构建,或对齐测试目标到正确环境;
+3. 对齐后重跑本脚本,404 应转为 BIZ_FAIL(空体预期失败)或 BIZ_OK。
+
+---
+
+## 五、废弃接口(应迁移,非缺陷)
+
+| 接口 | 部署端表现 | 建议 |
+|------|------------|------|
+| `/api/family/user/switch-back-to-parent` | `410 Gone` | 前端仍引用,应迁移到 `/api/family/member/switch` |
+| `/api/family/user/switch-mode` | `410 Gone` | 同上 |
+| `/api/family/user/switch-to-child` | `410 Gone` | 同上 |
+
+前端 `request()` 调用中仍出现上述废弃路径,属技术债,建议统一切换到 `/api/family/member/switch`(切换成员视图)。
+
+---
+
+## 六、附:本轮修正清单(脚本侧)
+
+1. `_classify()` 新增 `AUTH_FAIL`(HTTP 401)分类,区分"真未登录"与"匿名放行伪 200"。
+2. 修复 4 类错误路径:`/api/user/switch-*` → `/api/auth/switch-role`、`/api/family/user/switch-{back-to-parent,mode,to-child}`。
+3. 新增 `direct_register()` + `acquire_tokens()`:父 token 优先 `wechat-login`,师 token 用 `direct-register(role=teacher)`;`--multi-role` 按需获取(避免定时任务每轮新建教师账号)。
+4. 新增 `--only-extra`:支持只跑 `--extra` 聚焦用例,便于快速业务级重测。
+5. 定时闭环 `run_interface_tests_cron.sh` 报告生成已同步 `AUTH_FAIL` 分类。
+
+---
+
+## 七、全量结果明细(`interface_test_biz_retest.tsv`)
+
+| page | fn | method | path | http | biz | cat |
+|------|----|--------|------|------|-----|-----|
+| auth | directRegister | POST | /api/auth/direct-register | 200 | 500 | BIZ_FAIL |
+| auth | ssoLogin | POST | /api/auth/sso-login | 200 | 500 | BIZ_FAIL |
+| auth | verify | POST | /api/auth/verify | 200 | 200 | BIZ_OK |
+| auth | switchRole | POST | /api/auth/switch-role | 200 | 200 | BIZ_OK |
+| family | inviteCheck | POST | /api/family/invite/check-family | 200 | 200 | BIZ_OK |
+| family | guideBind | POST | /api/family/guide-bind | 200 | 500 | BIZ_FAIL |
+| family | memberSwitch | POST | /api/family/member/switch | 200 | 500 | BIZ_FAIL |
+| family | switchBackToParent | POST | /api/family/user/switch-back-to-parent | 200 | 410 | BIZ_FAIL |
+| family | switchMode | POST | /api/family/user/switch-mode | 200 | 410 | BIZ_FAIL |
+| family | switchToChild | POST | /api/family/user/switch-to-child | 200 | 410 | BIZ_FAIL |
+| healthStatus | get | POST | /api/health-status/get | 200 | 200 | BIZ_OK |
+| healthStatus | save | POST | /api/health-status/save | 200 | 500 | BIZ_FAIL |
+| healthReport | latest | POST | /api/health-report/report/latest | 404 | — | NOT_FOUND |
+| healthReport | indicatorsCurrent | POST | /api/health-report/indicators/current | 404 | — | NOT_FOUND |
+| activity | list | POST | /api/activity/list | 200 | 200 | BIZ_OK |
+| activity | detail | POST | /api/activity/detail | 200 | 500 | BIZ_FAIL |
+| task | today | POST | /api/task/today | 404 | — | NOT_FOUND |
+| product | list | POST | /api/product/list | 200 | 200 | BIZ_OK |
+| product | detail | POST | /api/product/detail | 200 | 500 | BIZ_FAIL |
+| __multi_role__ | teacherVerify | POST | /api/auth/verify | 200 | 200 | BIZ_OK |
+| __multi_role__ | guideBindAsParent | POST | /api/family/guide-bind | 200 | 500 | BIZ_FAIL |
+| __multi_role__ | guideBindAsTeacher | POST | /api/family/guide-bind | 200 | 500 | BIZ_FAIL |
+| __multi_role__ | indicatorsRebuildParent | POST | /api/health-report/indicators/rebuild | 404 | — | NOT_FOUND |

+ 24 - 0
interface_test_biz_retest.tsv

@@ -0,0 +1,24 @@
+page	fn	method	path	http	biz	cat
+auth	directRegister	POST	/api/auth/direct-register	200	500	BIZ_FAIL
+auth	ssoLogin	POST	/api/auth/sso-login	200	500	BIZ_FAIL
+auth	verify	POST	/api/auth/verify	200	200	BIZ_OK
+auth	switchRole	POST	/api/auth/switch-role	200	200	BIZ_OK
+family	inviteCheck	POST	/api/family/invite/check-family	200	200	BIZ_OK
+family	guideBind	POST	/api/family/guide-bind	200	500	BIZ_FAIL
+family	memberSwitch	POST	/api/family/member/switch	200	500	BIZ_FAIL
+family	switchBackToParent	POST	/api/family/user/switch-back-to-parent	200	410	BIZ_FAIL
+family	switchMode	POST	/api/family/user/switch-mode	200	410	BIZ_FAIL
+family	switchToChild	POST	/api/family/user/switch-to-child	200	410	BIZ_FAIL
+healthStatus	get	POST	/api/health-status/get	200	200	BIZ_OK
+healthStatus	save	POST	/api/health-status/save	200	500	BIZ_FAIL
+healthReport	latest	POST	/api/health-report/report/latest	404	None	NOT_FOUND
+healthReport	indicatorsCurrent	POST	/api/health-report/indicators/current	404	None	NOT_FOUND
+activity	list	POST	/api/activity/list	200	200	BIZ_OK
+activity	detail	POST	/api/activity/detail	200	500	BIZ_FAIL
+task	today	POST	/api/task/today	404	None	NOT_FOUND
+product	list	POST	/api/product/list	200	200	BIZ_OK
+product	detail	POST	/api/product/detail	200	500	BIZ_FAIL
+__multi_role__	teacherVerify	POST	/api/auth/verify	200	200	BIZ_OK
+__multi_role__	guideBindAsParent	POST	/api/family/guide-bind	200	500	BIZ_FAIL
+__multi_role__	guideBindAsTeacher	POST	/api/family/guide-bind	200	500	BIZ_FAIL
+__multi_role__	indicatorsRebuildParent	POST	/api/health-report/indicators/rebuild	404	None	NOT_FOUND

+ 20 - 0
interface_test_focus_biz.tsv

@@ -0,0 +1,20 @@
+page	fn	method	path	auth	param
+auth	directRegister	POST	/api/auth/direct-register	false	false
+auth	ssoLogin	POST	/api/auth/sso-login	false	false
+auth	verify	POST	/api/auth/verify	true	false
+auth	switchRole	POST	/api/auth/switch-role	true	false
+family	inviteCheck	POST	/api/family/invite/check-family	true	false
+family	guideBind	POST	/api/family/guide-bind	true	false
+family	memberSwitch	POST	/api/family/member/switch	true	false
+family	switchBackToParent	POST	/api/family/user/switch-back-to-parent	true	false
+family	switchMode	POST	/api/family/user/switch-mode	true	false
+family	switchToChild	POST	/api/family/user/switch-to-child	true	false
+healthStatus	get	POST	/api/health-status/get	true	false
+healthStatus	save	POST	/api/health-status/save	true	false
+healthReport	latest	POST	/api/health-report/report/latest	true	false
+healthReport	indicatorsCurrent	POST	/api/health-report/indicators/current	true	false
+activity	list	POST	/api/activity/list	false	false
+activity	detail	POST	/api/activity/detail	true	false
+task	today	POST	/api/task/today	true	false
+product	list	POST	/api/product/list	false	false
+product	detail	POST	/api/product/detail	false	false

+ 1 - 1
run_interface_tests_cron.sh

@@ -153,7 +153,7 @@ lines.append(f"# CFC 接口冒烟测试报告 ({ts})\n")
 lines.append(f"- 目标: {os.environ.get('CFC_HOST', 'cfc.iwintrue.com')}  用例总数: **{total}**")
 lines.append(f"- 业务成功率(BIZ_OK): **{ok}/{total} = {rate}%**\n")
 lines.append("## 分类汇总")
-for k in ('BIZ_OK','BIZ_FAIL','NOT_FOUND','SERVER_ERR','NON_JSON','CONN_ERR'):
+for k in ('BIZ_OK','BIZ_FAIL','AUTH_FAIL','NOT_FOUND','SERVER_ERR','NON_JSON','CONN_ERR'):
     if cats.get(k): lines.append(f"- {k}: {cats[k]}")
 lines.append("")
 if fixed: