评审角色:集成·接口级评审专家(柯砚) 代码仓库:
D:/cfc· 后端:D:/cfc/cfc-backend(Spring Boot 2.7,端口 9082,统一@PostMapping,JWT Bearer 鉴权) 评审方式:全程只读,仅新增本文件,未修改/删除/提交任何源码 目标:为把空体冒烟测试升级为业务级测试(带真实参数、多角色 token)提供契约基线,逐步达到 100% 通过
Result<T>{code,message,data},位于 common/Result.java。code=200 成功;其余为非 0 失败码。JwtInterceptor 拦截 /api/**,白名单见 §2。token 通过 Authorization: Bearer <token> 头传递(也可走 ?token= 参数)。token 字段名是 token(在 LoginResultDTO 里,不是 accessToken)。INTERFACE_TEST_REPORT.md 显示全部 NON_JSON,目标域填的是 cfc.iwintrue.com(外网不可达,返回 HTML 而非 JSON)。测试应改为指向 http://localhost:9082。同时基线里大量路径写错(如 /api/user/switch-*、实际是 /api/family/user/switch-role),导致 404/错路由。mock-login 接口。auto-login 已废弃(返回 410)。多角色 token 的获取姿势见 §3、§4。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 非空。JwtInterceptor 对“无 token 且非强制登录路径”会注入匿名用户(userId=-1, role=anonymous)并放行,下游 Controller 通常返回 code=500 的 Result.error("请先登录")/"未登录",HTTP 状态仍是 200。只有 token 格式非法/过期、或命中 LOGIN_REQUIRED_PATHS 才会真正返回 HTTP 401。用例要区分这两种“未登录”表现。JwtInterceptor 与公开路径白名单实现位置:src/main/java/com/etotem/cfc/config/JwtInterceptor.java:21
注册位置:src/main/java/com/etotem/cfc/config/WebConfig.java:95-137(拦截 /api/**,并在 excludePathPatterns 中再排除一批)
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 + "/")(/**仅取前缀)。
LOGIN_REQUIRED_PATHS(JwtInterceptor.java:80-83)无 token 直接 HTTP 401:/api/share/qrcode、/api/auth/verify-password。
JwtInterceptor.java:102-191)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 非空时查家庭成员)userId=-1, role="anonymous",放行(:211-217)。FamilyAccessInterceptor(config/FamilyAccessInterceptor.java:33)familyId(query/path/body 递归)是否属于当前用户自己或 teacherFamilyIds。admin/** 跳过(:51)。-Dfamily.access.mode=enforcement)越权返回 HTTP 403 + code 403(:135-140)。WebConfig.java:94-179)operationLogInterceptor、rateLimitInterceptor 也拦截 /api/**,并对白名单批量 exclude。限流可能让高频测试偶发失败,必要时在测试侧限速或识别限流返回。
对编写用例的建议:
/api/auth/direct-register(见 §3),无需验证码。@RequestAttribute(value="userId", required=false)(如 ActivityController.java:29),匿名时 userId 为 null,不应断言“未登录 401”,而应断言业务正常返回(活动列表/详情本就是公开读)。控制器: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 的推荐获取方式:
direct-register 时直接带 role(值 parent/teacher)拿对应角色 token;switch-role 切换(见 §4)。对编写用例的建议:
POST /api/auth/direct-register → 提取 data.token → 后续请求带 Authorization: Bearer <token>。注意 direct-register 会校验手机号格式,使用测试专用号段即可,但同一手机号二次调用会报“已注册”,用例需做号段/清理或复用已注册 token。needsFamily=true(familyId 为空/0)时,要先建/加入家庭再调家庭/健康/任务类接口,否则易触发 code=5001 或 downstream 的 familyId不能为空。code=500,不要混为一谈。实体: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 与控制器):
family_members 表的成员记录存在,由家长以 currentMemberId 切换“成员视图”来代操作。User.role="child" 在代码里也存在(UserService.java 有 setRole("child") 分支),但小程序端孩子一般通过家长切换成员视图访问,而非自己登录拿 token。teacherStatus(pending/approved/rejected)审核;teacherFamilyIds 决定可访问哪些家庭。部分接口要求 teacher 角色(如 FamilyController.guideBindFamily :68 校验 "teacher".equals(guide.getRole()))。UserService.ssoLogin :701 抛“该账号不允许登录小程序”;switchRoleForLogin :635 抛“管理员角色不允许切换”)。admin 走独立的 /api/admin-auth/* 体系。/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)。
对编写用例的建议:
direct-register 带 role=parent 注册家长;另注册一个 role=teacher 且 teacherStatus=approved 的账号作规划师(注意 switchRoleForLogin 要求该用户 roles 里真含 teacher,否则切不过去)。POST /api/family/guide-bind(要求 teacher 角色)应得“只有成长规划师才能使用此功能”。POST /api/family/member/switch {memberId} 拿到成员视图 → 用返回的 currentMemberId 调健康/任务接口。通用约定:全部
@PostMapping;请求体多为Map<String,Object>或实体;缺参通常Result.error("xxx不能为空")→code=500。以下“常见错误码”含业务自定义码。
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。
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(邀请令牌) |
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;空→"请完成手签名" |
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”的前置链。
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 |
POST /api/product/list(WebConfig 白名单、JwtInterceptor 白名单):公开,无必填。POST /api/product/detail:公开,需 id。POST /api/special-price/info:公开。http://localhost:9082(端口 application.yml:2),不要打外网域名,否则全 NON_JSON。POST /api/auth/direct-register(带 phone+role)拿 data.token;每个角色(parent/teacher)+ 每个成员视图各准备一个 token 池,避免“已注册”冲突(可维护一批测试手机号)。familyId)→(如测健康报告需购计划)→ 通过 /api/family/member/switch 拿 memberId。把这条链路做成用例 fixture。code==200;失败 = code!=200 且记录真实 message/自定义码(如 4001/5001/410/403)。不要假设失败码只有 500。/api/auth/verify 等);不带头访问非白名单 → 匿名放行 + 业务 code=500,HTTP 仍 200。teacher-only 接口(如 /api/family/guide-bind)→ 期望“只有成长规划师才能使用此功能”。indicators/rebuild → 期望“权限不足”。auto-login、switch-mode、switch-to-child、switch-back-to-parent、/api/family/invite-code 等均返回 410,用例应预期 410 而非 200/404。/api/user/switch-* 改成 /api/family/user/switch-role 或 /api/auth/switch-role;/api/activities/hot 等需核对真实路由(活动列表是 /api/activity/list)。rateLimitInterceptor 可能让高频批量测试偶发失败,建议在测试客户端加节流或识别限流返回后退避重试。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 |