INTERFACE_CONTRACT_REVIEW.md 24 KB

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