Commit Graph
75 Commits
Author SHA1 Message Date
WorkBuddy f73a116a10 feat(matchmaker): 受控联系审批分层落地(OQ-MATCHMAKER-009 裁决:组长主审+管理员兜底)+ enabled fail-safe 行为基线
- ContactAuthorizationService approve/reject 分层语义:matchmaker.assign 平台管理员=兜底岗(全量审批、不做组织校验);approver-authorities 首段(默认 matchmaker.team.assign)=主审岗(assertTeamScope 组织范围,hasGlobalAssign 固定 false 防权限面双通道,NULL=NULL 平台直属可审);两者皆无 403 deny 不变
- 真实缺口修复(OQ-010 引出):request 入口原未查 matchmaker.contact.enabled,补 denyAsNotFound fail-safe(与 disclose 口径一致:最先、404 化、防探测)
- 测试:MatchmakerContactAuthorizationTest 10→13(管理员跨组织兜底/组长他组织 403/组长审平台直属 NULL=NULL/双岗兜底全量,均 HTTP+审计双断言);新增 MatchmakerContactDisabledTest(enabled=false 时 disclose/request 404 化、零 SensitiveAccessLog、零 contact 成功审计、fail-safe 先于 purpose 校验)
- 负向注入:移除兜底分支 → 恰好 2 例分层用例判红(主审路径保持绿,判别力精准)→ 还原复绿
- 验证:主理人 Docker 独立实跑 20/20(授权 13+disabled 2+守卫 5);全量 mvn verify 900/900(3 skip);后端契约 234/CURATED 20;UI 24/24
- 裁决依据:OQ-MATCHMAKER-009(组长主审+管理员兜底)、LEGAL_VERIFY③ 7 天/④ 365 天/⑤ NULL=NULL 维持(登记于 governance)
2026-10-11 15:57:56 +08:00
WorkBuddy d3735e3afa feat(matchmaker,contactauth): 受控联系授权机制立项落地(OQ-MATCHMAKER-003 / DEC-MATCHMAKER-003 B1 例外通道)
后端:
- V056 matchmaker_contact_authorizations 表:channel CHECK(phone/offline_meeting/full_contact)、六态状态机(pending_approval→pending_consent→active→expired/revoked, →rejected)、部分唯一索引 uq_mm_contact_auth_active 防同红娘-同用户-同渠道多 ACTIVE、双态惰性收敛(active 过期 + pending_consent 超同意窗口)
- ContactAuthorizationConfig 五 canonical key(matchmaker.contact.*):enabled 代码缺省 false fail-safe(yml 显式 true)/approver-authorities 双岗并列/consent-deadline-hours 72/default-validity-days 7/retention-days 365,全部 DEFAULT+CONFIG 待产品+法务终审
- ContactAuthorizationPolicy.assertContactAllowed(404 化 deny 先审计后 404) + canContact 徽标查询
- ContactAuthorizationService:request(须有效分配+purpose 必填)/approve(权限码动态判定∈配置+组长组织范围)/consent(用户本人,仅 pending_consent)/decline/revoke(reason 必填)/disclose(唯一披露出口:enabled fail-safe→assertActiveAssignment→assertContactAllowed→仅返回 phone/disclosedAt/authorizationId 三键,每次落 SensitiveAccessLog user_phone_disclosure,phone 不落应用日志)
- 双控制器:运营端 /operator/matchmaker/contact-authorizations + C 端 /contact-authorizations(UserPrincipal 归属校验,独立端点无预同意参数),不碰 SecurityConfig

守卫翻转(机械锁强制复审机制按设计生效):
- MatchmakerContactGuardTest 由「锁不存在」翻转为「锁新契约」五条:回显白名单类收敛/端点前缀收敛(受治理三包)/到期语义/consent 独立性三端扫描(后端+miniapp+admin-web,修复原只扫后端的预同意参数逃逸缺口)/ReferralService 白名单与无 @Scheduled 保留
- run-backend-contracts CURATED 18→20

前端:
- miniapp 新页 pages/contact-consent(目的/渠道/窗口明示+独立同意拒绝按钮无预勾选,app.json 第 64 页) + 红娘工作台申请/列表/撤销
- admin-web「联系授权审批」tab(审批岗队列+本人授权撤销,枚举码中文映射)

验证:mvn verify 895/895(3 skip)、UI 契约 24/24(64 页)、后端契约门禁 228/CURATED 20、小程序真编译 87 WXML 0 失败;负向注入 5(工程师)+2(QA 不同落点)判红→还原复绿;QA 抓 disclose 键数收敛断言缺口已补并回归;QA fresh eyes PASS 0 P0/P1
2026-10-11 13:08:02 +08:00
WorkBuddy 4248e9574e chore(release): 版本号 1.1.0 -> 1.2.0(红娘角色权限专项 ZC-MM-001~007 落地固化)
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
2026-10-11 10:48:33 +08:00
WorkBuddy 1be0d98ebd ZC-MM-006 审计与权限回收(阶段4)
- T01: V055 audit_logs 扩展 4 字段(purpose_code/service_assignment_id/actor_role_snapshot/request_trace_id, 全可空幂等) + AuditLog/DataScopeGuard/AuthorizationAuditListener/MatchmakerServiceService 写入链路改造 + schema-h2 同步 + LATEST_MIGRATION 54→55
- T02: JwtAuthFilter 运营分支改实时查库权限回收(每请求查 operators.status + 权限, 停用即401/撤权即403, 零缓存, claims 权限降级只写不读), 不碰 SecurityConfig/JwtTokenService/用户端
- T03: getServiceDetail 持有人分支接入 SensitiveAccessLog(成功查看落行 dataType=matchmaker_service_detail, 拒绝路径只落 audit_logs DATA_SCOPE_DENIED, 双记录结构性互斥)
- T04: 写接口 404 统一防探测(transfer/revoke/end/recommend/followup/complete/转介范围违规 404 化 deny, 先审计后 404, create/assign 无资源寻址维持 403, ReferralService 消灭裸 IllegalArgumentException)
- T05: OperatorEndpointPermissionCoverageTest +3 守卫(类级 RequestMapping 前缀合成/缺类级则逐方法 PreAuthorize/matchmaker+referral 禁 PatchMapping) + run-backend-contracts CURATED 14→18
- 新增测试 4 类 20 用例: MatchmakerAuditFieldsTest / OperatorPermissionRevocationTest / MatchmakerSensitiveAccessAuditTest / MatchmakerWriteDenyUniformityTest
- 验证: mvn verify 884/884(3 skip) + UI 契约 24/24 + 后端契约门禁 198 通过; 负向注入 5(工程师)+2(QA) 判红→还原复绿; QA fresh eyes 独立验收 PASS(0 P0/P1, 2 P2 观察项移交 ZC-MM-007)
- ContactAuthorizationPolicy 维持 DEFERRED; C 端死代码退役评估 3 项留待 007/专项治理
2026-10-11 09:19:01 +08:00
WorkBuddy 5e484d0e74 test(matchmaker,referral): ZC-MM-003/004 纯护栏——B1 联系规则锁定 + 禁止代用户决定固化
依据《善缘丹阳 V1.1 红娘角色权限正式决策与开发规则》DEC-MATCHMAKER-003(B1
平台内优先、授权例外)与 DEC-MATCHMAKER-004(红娘不得代替用户执行交友决策)。
本阶段生产代码零改动,交付物为护栏测试(MM-CONTACT-001~004、MM-ACTION-001~003
全部有自动化用例)。

MatchmakerContactGuardTest(4 用例,standalone 源码扫描,FieldPrivacyGuardTest 范式):
- MM-CONTACT-002:ReferralService 全部响应 put 键 ⊆ 16 键白名单(逐键对账吻合),
  禁词 {emergencycontact,phone,mobile,wechat,weixin,address,idnumber,id_card}——
  锁定红娘侧零联系方式回显(紧急联系人信息纯入口 write-only,连红娘本人都无读回面);
  含防清空自检(三关键键必须在场)
- MM-CONTACT-003:红娘/转介 Controller 无 contact/call/phone/channel/offline 端点;
  全仓无 ContactAuthorization 类、无 matchmaker_contact_authorizations 迁移表——
  锁定「无授权机制」;未来引入联系功能必须连同本守卫+治理同步更新
- MM-CONTACT-004:机制不存在 ⇒ 无授权到期场景(与 003 同源,未来引入同时变红)
- 004 佐证:matchmaker/referral 包 @Scheduled=0(无批处理代决策面)

MatchmakerActionGuardTest(6 用例,@SpringBootTest 行为锁定):
- MM-ACTION-001(3 例负向):operator token 调用户决策端点
  (/matchmaker/recommendations/{id}/accept、/pass、/referrals/{id}/confirm)→ 403
  且业务状态零变化(403 来源于 SecurityConfig hasRole("USER") 身份类型防线,
  与 PermissionService mock 无关,变异验证锚定真实)
- MM-ACTION-002:operator token 开启会话端点 → 403
- MM-ACTION-003 must-confirm 语义锁定:红娘代建转介 → needsUserConfirmation=true/
  userConfirmedAt=null/status=pending,后续 recommend/followup/complete 不改确认态
  (「代填草稿、用户确认」灰色点永久锁定)
- MM-CONTACT-001(正向):红娘对本人分配对象创建推荐/转介 → 平台内服务通知落库,
  title/content 无手机号/微信模式,红娘 operatorId 不在通知收件面

负向注入自证:
- 注入 A:ReferralService 临时加 emergencyContactPhoneEnc 回显 → 守卫三重命中判红 → 还原
- 注入 B:SecurityConfig hasRole("USER")→authenticated() → 4/4 负向用例判红
  (403→401/500)→ 还原复绿

验证:定向 42/42、全量 861/861(3 预存 skip)、UI 契约 24/24。
003 方案 A(纯测试锁定)决策记录与五项 LEGAL_VERIFY open questions 随 ZC-MM-007 登记;
ContactAuthorizationPolicy 桩 + matchmaker_contact_authorizations 表推迟至 ZC-MM-006
(仅当产品确认开放受控联系时立项)。
2026-10-10 14:46:53 +08:00
WorkBuddy 3caf5f8816 feat(matchmaker,referral): ZC-MM-005 统一数据范围与字段隐私(DEC-MATCHMAKER-005/§5.2 落地)
依据《善缘丹阳 V1.1 红娘角色权限正式决策与开发规则》DEC-MATCHMAKER-005 最小必要原则
与 §5.2 字段级隐私,ZC-MM-005 范围=「读接口行级数据范围统一接入判定器 + 字段白名单 +
组长团队脱敏统计」。承接 ZC-MM-002 落地的 matchmaker_assignments 事实源与
ServiceAssignmentPolicy 判定器。

E1 双轨切纯判定器(不留回退):
- assertServiceAccess 纯化(删 assigned_operator_id 回退分支),判定器成唯一事实源
- getServiceDetail 删 viewerIsOwner 双态与「非负责人脱敏详情」整段

E2 非负责人防探测 404:
- ServiceAssignmentPolicy.assertActiveAssignmentOrNotFound:无生效分配→先落
  DATA_SCOPE_DENIED 审计再抛 NOT_FOUND(响应 404),存在性不可枚举,
  与「查无此单」响应体逐字段一致(MM-PRIVACY-001)

E3 字段白名单显式化 + 用户隐私开关:
- 抽取 assembleMatchmakerUserProfile:仅 7 键(displayName/gender/age 派生/regionName/
  educationCode/occupationCode/maritalStatus),无 birthDate 原值/详细地址/手机号/微信/
  身份证;接 UserPrivacySettings.showOccupation(缺省全放行)

E4 死端点清理:
- 删除 GET /operator/matchmaker/my-services(operatorId 误当 user_id、恒空、前后端零调用),
  替代路径 /operator/matchmaker/assignments/my;C 端 /matchmaker/services 与
  counseling myServices 保留

P1 残留收口(读/写路径仍信任废弃 assigned_operator_id 残留列,与 E1 同类洞):
- P1-1 运营端推荐列表:MatchmakerRecommendationMapper.findByOperatorId 改 join
  生效分配(active+expires),撤/结后不再可见
- P1-2 dashboard 三计数:count*ByOperator 改 join 生效分配 + COUNT(DISTINCT) 防重,
  end/revoke 后不再计入(DEC-002 规则7)
- P1-3 转介授权:ReferralService.assertMatchmakerServesUser 切
  serviceAssignmentPolicy.assertActiveAssignment(DATA_SCOPE_DENIED 审计 + 403),
  删残留列查询 findActiveByOperatorAndUser

新端点(组长团队脱敏统计):
- GET /operator/matchmaker/team/statistics(@PreAuthorize matchmaker.team.assign):
  本组织 active 分配数/按红娘分组/在岗红娘数,纯计数无用户 PII;org 边界
  IS NOT DISTINCT FROM(NULL=NULL 平台直属)

P2-4 operatorName 脱敏决策:保留不脱敏(§5.2 字段分类:红娘姓名=团队范围内
工作人员标识非用户 PII;组长统计本团队工作量属管理职责),依据登记
docs/governance/open-questions.md OQ-MATCHMAKER-002(RESOLVED)

死代码清理:getOperatorServices(零调用)+ MatchmakerServiceMapper.findByOperatorId
(残留列查询,调用清零)删除;C 端 MatchmakerService 遗留信任残留列按豁免未动(006/007 退役)

测试:
- MatchmakerDataScope005Test(13 用例):MM-PRIVACY-001/002、MM-AUTH-001/003 读侧、
  MM-LIFECYCLE-001 读侧、P1-1/2/3 残留收口负向(撤/结后推荐不可见/计数归零/转介 403)、
  防探测逐字段一致、showOccupation 三态
- MatchmakerFieldPrivacyGuardTest(3 用例):字段白名单源码扫描 + 无导出端点锁定
- MatchmakerReferralPhase13Test 转介口径收紧(IllegalStateException→AccessDeniedException)
- 负向注入自证(P1-3 残留列变异→判红→恢复复绿)

验证:定向 73/73;全量 851/851(3 预存 skip);UI 契约 24/24。
2026-10-10 11:51:15 +08:00
WorkBuddy 861c78efb5 feat(matchmaker): ZC-MM-002 A1 服务对象分配建模(DEC-MATCHMAKER-002 LOCKED 落地)
数据库(V053/V054):
- 新表 matchmaker_assignments:独立分配关系(assigned_by/reason/scope/
  expires_at/ended_at/end_reason),状态机 active/ended/revoked/transferred/
  expired(CHECK 白名单),部分唯一索引防同用户多 ACTIVE 主责分配,
  存量进行中服务单幂等回填
- 新角色 matchmaker_leader(014) + 新权限 matchmaker.team.assign(10000000-…-023);
  组长授 view/service/team.assign(不给平台级 matchmaker.assign)

后端:
- ServiceAssignmentPolicy 有效分配判定器(读前惰性过期,失效分配即时拒绝)
- 分配/转分配/撤销/结束四操作:状态机非法转换拒绝、理由必填、
  同用户主责冲突拒绝;组织范围校验(team.assign 同组织,NULL=NULL 视为
  平台直属,技术默认安全值待治理登记);transfer 同时校验原持有人与新目标范围
- 全操作审计 MATCHMAKER_ASSIGNMENT_CREATE/TRANSFER/REVOKE/END,
  operatorId=当前操作者,afterData 仅记摘要(无敏感内容)
- 删除认领端点 POST /services/{id}/claim(A1 禁止普通红娘认领,MM-AUTH-004=404)
- GET /pending 收敛为分配权持有者(assign or team.assign);
  dashboard today/completed 改本人口径,inProgress 口径修正 'processing'→'in_service'
- recommend/followup/complete/detail 归属判定接入判定器,拒绝走
  DataScopeGuard(自动 DATA_SCOPE_DENIED 审计),不再裸 IllegalStateException;
  assignService 与创建分配同事务联动;删除死查询 findAssignedTo

前端:
- admin-web 分配弹窗去硬编码假数据(operator_wang/chen)→ assignable-matchmakers
  真实接口(仅 id/name/organizationId,无联系方式)+ 理由必填;
  队列 tab 按 assign/team.assign 显隐;新增「服务对象分配」tab(转分配/撤销/结束)
- 小程序红娘工作台:删除接单入口,数据源切 GET /operator/matchmaker/assignments/my,
  枚举码不上屏(中文映射),空 serviceId 不跳转

测试:
- MatchmakerAssignmentA1Test 14 用例(MM-AUTH-001~004、MM-AUDIT-001、
  MM-LIFECYCLE-001、组长跨组织拒绝、NULL=NULL 平台直属边界、transfer 原持有人
  范围负向、/pending 403+审计),负向注入自证有效
- MatchmakerAssignmentPermissionTest 3 用例(端点权限契约 + claim 残留扫描)
- MatchmakerServiceTest 补 assignment 状态/scope 白名单守卫;迁移基线 LATEST=54
- 验证:定向 38/38 全绿;UI 契约 24/24;小程序 wcc/wcsc 真编译 0 失败
2026-10-09 22:46:32 +08:00
WorkBuddy 4edb7619bc fix(admin-web,db): 红娘菜单权限码对齐后端真实码 + 红娘角色授权
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
前端菜单/路由守卫用 referral.manage、matchmaker.service.view,但后端 permissions
表不存在这两个码(referral 控制器要 matchmaker.service、红娘控制器要
matchmaker.view/service/assign)。visibleNavigation 据此把「公益红娘」「红娘服务」
两个菜单对**所有角色(含 super_admin)隐藏**。

- admin-web: MainLayout.vue(菜单 + 角色名判定)、router/index.ts(路由 meta)、
  stores/auth.ts(DEV 预览角色)三处对齐为后端真实 authority 码。
- db: 新增 V052 迁移,将 matchmaker.view/service/assign 授予 matchmaker 角色
  (005),修复「角色定义!=可用」同族缺陷;幂等 ON CONFLICT DO NOTHING。
- 重建 danyang-admin-web 镜像并重新部署;运行时验证 wanghongniang 登录返回
  [matchmaker.assign, matchmaker.service, matchmaker.view],两菜单可见。
2026-10-09 19:33:40 +08:00
WorkBuddy dfb74f3386 fix(backend): 修复审核决策请求体契约不一致导致的 400
审核操作统一发送 {comment, reasonCodes[]}(数组),但 ReviewController 的
approve / need-evidence / approve-local-eligibility / reject-local-eligibility /
evidences/{id}/access 将 @RequestBody 声明为 Map<String,String>,数组值无法反序列
化成 String,Jackson 抛 HttpMessageNotReadableException,经 GlobalExceptionHandler
映射为 400「请求体格式错误」。

- 5 处 @RequestBody Map<String,String> 改为 Map<String,Object>,comment/purpose
  读取加 (String) 强转(与原本正确的 needEdit/reject 一致)。
- 新增回归守卫 ReviewControllerRequestBodyContractTest(standalone MockMvc +
  GlobalExceptionHandler + 自定义 @AuthenticationPrincipal 解析器),注入前端真实
  载荷 {comment, reasonCodes:[]} 断言 200;直接 new 控制器测不到 HTTP/绑定层缺陷。
- 接入后端契约门禁 scripts/run-backend-contracts.mjs(CURATED 13 -> 14)。
- 负向注入已自证判别力:临时改回 Map<String,String> 精确判红 400,恢复后 5/5 绿。
2026-10-09 19:33:16 +08:00
WorkBuddy c1c75edc55 fix(backend): 修复 v1.1.0 引入的 4 项后端测试失败
上一轮 v1.1.0 修复从未真正运行过后端测试,本次接入后端契约门禁前
全量 mvn verify 暴露 4 处缺陷:

1. PhotoService.confirmDirectUpload:countActiveProfilePhotos 返回 long,
   窄化赋值给 int sortOrder 导致整个后端编译失败(全量测试 0 跑)。
2. OperatorAuthController 直接引用 JwtTokenService 计算 Cookie max-age 并在
   logout 解析 JWT,违反 PrincipalInjectionContractTest 契约(控制器不得
   解析/引用 JWT 工具)。改为由 OperatorCookieWriter 持有 TTL、logout 从
   SecurityContext 的 OperatorPrincipal 取 operatorId。
3-4. AuthServiceRefreshTtlTest 与 JwtAuthFilterTest 的 UnnecessaryStubbing:
   移除未被调用的 getExpirationMs() / validateToken("bad-cookie") 桩。

全量 mvn verify:809 测试 0 失败 0 错误(3 跳过)。
2026-09-30 16:21:16 +08:00
WorkBuddy 47cccc9ca5 test: 补 v1.1.0 修复的回归测试
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
- RealIdentityVerificationProvider: 伪造证件校验位拦截、配置缺失 fail-fast、第三方 fail-closed
- AuthService: 钉死刷新 Token Redis TTL 用 getRefreshExpirationMs(14 天)而非 access*2(4 小时)
- PhotoService: 直传落库 + 归属/存在性护栏(修复「传了白传」)
- JwtAuthFilter: 管理端 HttpOnly Cookie(dy_op_at)兜底鉴权 + 头优先于 Cookie
- miniapp profileLabels: 枚举码→中文映射不退化(serviceTypeLabel / labelOf)
2026-09-30 14:05:48 +08:00
WorkBuddy ca0db57f24 chore(release): 冻结 v1.1.0 版本
- 同步版本号:VERSION / backend pom / admin-web / miniapp 均升至 1.1.0
- 合并本轮代码审查修复(详见 docs/CODE-REVIEW-2026-09-24.md):
  - 后端:微信登录真实实现 RealWechatLoginProvider、实名核验真实实现 RealIdentityVerificationProvider(本地强校验 + 可配置第三方 + fail-closed/fail-fast)
  - 照片直传落库端点 POST /me/photos/confirm(fileKey 归属校验 + 对象存在性校验后 savePhoto)
  - 刷新 Token Redis TTL 修正(getExpirationMs()*2 的 4h → refreshExpirationMs 的 14 天)
  - 管理端 token 改为 HttpOnly Cookie + silent-refresh,移除 localStorage 明文存储与 30+ 处手工注入
  - 小程序:红娘接单鉴权头错位修复、serviceType 中文映射、生产域名可配置
- 隔离本地产物(.workbuddy/、*.bundle、*.dump、secrets、application-dev.yml 等已被 .gitignore 覆盖)

未验证项(打标签 ≠ 生产发布):
- 后端编译 / 单测未在本环境执行(Maven 慢、Docker DNS 受限)
- 管理端 登录→刷新→登出 端到端、小程序真机走查待人工验收
- 生产部署需注入 WECHAT_APP_ID / WECHAT_APP_SECRET、VERIFICATION_API_URL / VERIFICATION_API_KEY 与小程序生产域名
2026-09-30 12:10:00 +08:00
WorkBuddy 44f75acebe fix(uat): 闭环 UAT 4 项硬阻塞(推荐恒空/保存即失败/越权端点/文档不一致)+ 小程序字段契约
UAT 走查(docs/UAT-WALKTHROUGH-2026-09-23.md)发现 4 项硬阻塞,本次闭环其中 3 项
(#2 微信登录、#5 照片直传不在本轮范围),并顺带修掉走查中新发现的跨端字段契约缺陷。

后端
- #6 推荐恒空:新增 MatchScoringService 作为唯一评分事实源(复用 UserPreference 既有
  10 维权重,零新造常量);RecommendationEngineService.calculateScore/generateReasons 由
  桩(return 50.0)改为委托;MatchEligibilityService 3 处桩全部去雷。此前 50 < 阈值 60
  → GET /recommendations 恒返回空数组。
- #4 保存即失败:updateProfile / updateBasicProfile / savePreference 改为
  「无则插入、有则更新」。注意 UserPreference 实体 @TableId=id 与库主键 user_id 不一致
  (V037 后补的可空列),故按 user_id 定位更新而非 updateById(后者会静默不更新)。
  gender / birth_date 为 NOT NULL 且向导不采集,缺失时从已通过实名认证的 user_identities
  补齐,仍缺则抛 400 业务拒绝,不再让 DataIntegrityViolationException 渲染成 500。
  GET /me/profile 的 preferences 补回 5 个此前未返回字段(regionPriority / maritalPriority /
  minEducationCode / educationPriority / childrenPriority)。
- #8 越权端点:POST /activities/{id}/interests/confirm 不再直调「建 Match 原语」
  RelationshipService.confirmActivityInterest,改为与 /activities/{id}/mutual/* 同一条
  二阶段同意链路(createMutualConsent + confirmConnection),单方调用不再可能建 Match
  (INV-014 / INV-015)。
- #10 文档不一致:MatchmakerController 类注释更正(operator 端点实际在 /operator/matchmaker)。
- 存量 500 家族:RuntimeExceptionThrowGuardTest 的正则只认 throw new RuntimeException(,
  而 17 处裸抛写作 orElseThrow(() -> new RuntimeException(...)),整体绕过守卫(CI 全绿却
  仍有 500 家族缺陷)→ 17 处全量改为 GlobalExceptionHandler.BusinessException;
  守卫扩为「整段代码 + 该形态」,并加自检断言防止守卫退化成恒真空断言。

小程序(跨端字段名 / 取值契约)
- 字段名对齐后端规范名:hasChildren→childrenStatus、occupationCategory→occupationCode、
  workRegion→workRegionCode、ageMin/ageImportance→minAge/agePriority、
  educationAccepted/maritalStatusAccepted→minEducationCode/maritalStatuses。
  此前字段名对不上,后端读不到 → 字段被静默丢弃。
- 取值修 4 类违反 DB CHECK(写入直接 500)的项:has→yes;high_school→senior_high、
  phd→doctor(chk_profile_edu);occasional→sometimes(chk_profile_smoking/drinking)。
- 预填改读服务端真实字段:此前读 profile.hasChildren / occupationCategory / workRegion,
  而服务端返回的是 childrenStatus / occupationCode / workRegionCode → 表单永远回填不上。
- profile-confirm 完成度改为直接采用服务端 profileCompletion(AGENTS 第 9 条:DB 是
  Source of Truth),废弃原先 22 条「读服务端不存在字段」的本地猜测式断言。
- 择偶条件页新增「展示形态 ↔ 服务端规范形态」适配器(toApiPayload / fromApiPayload)。

测试与门禁
- 新增 MatchScoringServiceTest、RecommendationEngineServiceBehaviorTest、
  ProfileSaveUpsertBehaviorTest、ActivityInterestConfirmConsentBehaviorTest 与
  ui-tests/miniapp-profile-contracts.mjs(桩 stores,真跑三个页面的事件处理与 onNext,
  断言提交负载用规范字段名与枚举码)。
- MIN_GUARDS 21→23(并修正上一轮漏改:该值当时仍为 21,与守卫数不符,使「数量下限」
  保护形同虚设)。
- 负向注入实测:把 #6 评分改回桩 50.0、#4 改回恒 insert、#8 改回绕开二阶段、并注入
  orElseThrow 形态裸异常 → 4 个守卫全部变红(含精确定位到 RecommendationEngineService.java:282)。
- 后端:795 tests / 0 failures / 3 skipped;UI 守卫 23/23;语种审计 0 命中;tsc 通过;
  wcc/wcsc 真编译 88 WXSS + 86 WXML / 0 失败。

仍未修(需产品/数据决策,见报告 8.3)
- regionScope 需 GB/T 2260 单值区划码,而小程序选项含「镇江其他辖区」这类非单值项 →
  不臆造区划码(该约束默认 preferred 软约束,不误过滤);
- occupationPreference 后端无对应列;settlementPreference 后端无取值字典。
2026-09-23 14:33:43 +08:00
WorkBuddy 825fd41ee4 fix(auth): 修复刷新令牌链路三层断点,并把 121 处裸异常收敛为携带 HTTP 语义的异常
用户在微信开发者工具里看到首页全站 401,追下去发现「刷新」这条路整条是断的,
且断在三个互不相干的地方 —— 每修好一层,下一层才显形:

1) 前端漏传 userId → /auth/refresh 直接 400(上一笔 63c18d8 已修)
2) 后端把 refresh token 交给 validateToken 校验,而那是用 access 密钥验签的,
   refresh token 由 refresh 密钥签发 → 必然失败。这是本次的核心修复。
3) 失败被抛成裸 RuntimeException → HTTP 500,客户端当作服务端故障重试。
4) 换发出来的新 refresh token 与旧 token **逐字节相同** —— JWT 的 iat 只有秒级精度、
   且未设 jti,同一用户同一秒内签发的令牌完全一致,「刷新即轮换、旧令牌作废」
   这条安全属性形同不存在(Redis 比对旧值自然相等)。三处签发补 UUID jti。

第 2 层此前被两层掩盖:前端漏传 userId 使请求先 400 走不到验签;且全仓
没有任何 /auth/refresh 用例。第 4 层则只在「同一秒内签发两次」时才暴露,
是新增 E2E 用例(换发后立刻用旧令牌再刷一次)把它逼出来的。

同时修复审计中发现的同族问题:
- AnalyticsService 用的是 shared.exception.BusinessException,而全局处理器只映射
  嵌套类 GlobalExceptionHandler.BusinessException —— 该异常无任何 handler,直落 500。
  现让旧类继承被映射的类,从结构上杜绝「未映射的业务异常静默 500」。
- 小程序 authStore 读 result.needPhone,后端返回 isNewUser → `!undefined === true`,
  每个用户一登录就被标记成已绑手机号。同族的第三个字段名不一致缺陷。
- 小程序 api.js 的 401 分支会无条件触发 onSessionExpired→refresh;后端把刷新失败
  语义修正为 401 后,这条路径会自我递归。补 /auth/* 守卫。
- 登录页只认 instanceof Error,把服务端文案(如「账号已被封禁」)丢了。

后端异常收敛(121 处 → 0):
- 业务拒绝改抛 BusinessException + ApiResponseCode(404 不存在 / 403 无权与状态不允许 /
  409 冲突重复 / 400 参数非法),文案一字未改,避免测试与前端文案契约漂移
- 真正的服务端故障(加解密、文件头、上传、签名、实名 Provider)改为显式
  ServerErrorException:仍是 500,但意图明确、ERROR 记全栈、不向客户端泄露内部细节
- 用带显式映射表的脚本执行(fail-closed,遇未知文案即中止),不用启发式规则

守卫(先有判别力再谈全绿):
- 新增 JwtTokenServiceRefreshTokenTest:钉住「refresh 用 refresh 密钥验签」与
  「两类令牌不得互相顶替」(含两把密钥被配成同值时的 type 声明兜底),
  以及「同一秒内签发的两个令牌必须逐字节不同」
- 新增 RuntimeExceptionThrowGuardTest:扫源码断言 main 零裸抛(无白名单副本)
- GlobalExceptionHandlerTest 补三条:别名的实例必须按其业务码回 403 而非 500、
  无法识别的字符串码 fail-closed 到 500(不得退化成无信息的 400)、
  以及继承关系守卫(把别名类改回 RuntimeException 会静默恢复该缺陷,只有它能咬住)
- 新增 tests/e2e/auth_refresh_test.py:唯一覆盖 /auth/refresh 的用例,
  断言换发的 accessToken 真能过鉴权、旧令牌轮换失效、无效凭据 401 而非 500
- http_semantics_test.py 补「Service 层拒绝必须是 4xx 而非 5xx」
- miniapp-auth-contracts.mjs 补 isNewUser 映射(双向)与 /auth/* 401 不递归

验证:
- 定向后端测试 9/9
- 全量后端测试 113 个测试类、0 失败、BUILD SUCCESS
- 本机真实栈 E2E 72 passed(含 7 个新增刷新用例)
- miniapp tsc 0 错误、UI 契约门控 18/18
- 负向注入两处精准变红(RuntimeExceptionThrowGuardTest 与
  JwtTokenServiceRefreshTokenTest 各 1 例),BUILD FAILURE,
  注入前后工作区 SHA256 逐字节一致(零污染)
2026-09-22 21:41:30 +08:00
WorkBuddy 2162612892 fix(service-appointment): 状态冲突/越权改为 4xx,修并发下 500 与弱断言漏判
CI E2E (live stack) 在 main@63c18d8 上失败:test_appointment_concurrent_safety
并发「用户取消」与「运营确认」得到 [200, 500]。根因是 ServiceAppointmentService
四处状态/权限守卫抛裸 RuntimeException,GlobalExceptionHandler 无对应映射,
只能兜底成 HTTP 500 —— 「预约不存在」「状态不允许」「越权」被报成服务端故障。

改动:
- ServiceAppointmentService:四处守卫改用 BusinessException + ApiResponseCode
  (不存在→404 NOT_FOUND,越权→403 FORBIDDEN,状态冲突→409 REDUNDANT_OPERATION)。
- ReferralService.confirmReferral:同一家族的四条守卫同样收敛(预约/报名的
  不存在、越权、非转介类型)。
- tests/e2e/service_appointment_test.py:把 3 处 `!= 200` 的弱断言收紧为 4xx。
  弱断言会把 500 当成「越权被拒」放行 —— 这正是该缺陷长期未被发现的原因,
  此前只有并发用例的 `< 500` 断言咬了它一口。
- ServiceAppointmentOwnerGuardTest:新增「终态不可再确认/取消」「预约不存在」
  用例,并把越权断言从 RuntimeException 收紧到具体业务码(403)。

验证:
- 定向后端测试 35 passed(ServiceAppointment* / MatchmakerReferralPhase13Test)。
- 本机真实栈(重建 backend 镜像):service_appointment_test.py 8/8、全量 E2E 65 passed。
- 负向注入(把守卫改回裸 RuntimeException):2 例精准变红,工作区哈希前后一致。
2026-09-22 19:36:59 +08:00
WorkBuddy dc0a62d4c5 fix(storage): presigned URL 改用对外可达 host,修复客户端直传断链
SeaweedFS 换存储后 MINIO_ENDPOINT 变为 seaweedfs:8333(容器名),
PhotoService 生成的 presigned URL 把该 host 直接下发给客户端,
客户端不在 docker 网络内无法解析 → 直传/直链全断。

- S3Config:新增 amazonS3Signer Bean,指向 object-storage.public-endpoint
  (缺省回退内网 endpoint,向后兼容)。独立签名端是因为 SigV4 签名覆盖
  host 头,事后改 URL 字符串会让签名失效返 403;独立客户端让签名 host
  与客户端实际命中的 host 天然一致。
- PhotoService:去掉 @RequiredArgsConstructor,改显式 @Qualifier 构造器;
  getUploadToken / getSignedUrl / getSignedUrlForAuthorizedReview 三处
  presigned 生成改用 amazonS3Signer;putObject 服务端上传仍走内网 amazonS3。
- application-docker.yml / application-dev.yml:public-endpoint 读
  MINIO_PUBLIC_ENDPOINT(缺省回退 MINIO_ENDPOINT)。
- 三套 compose:注入 MINIO_PUBLIC_ENDPOINT
  (dev localhost:8333 / staging localhost:8334 / prod 留作单一旋钮)。
- 测试:PhotoServiceTest 新增嵌套 PresignedUrlPublicHostTest,用真实
  AmazonS3 客户端(仅本地签名不联网)断言下发 URL host 为对外地址、
  不含 seaweedfs 容器名;同步补齐两处 5→6 参构造调用。

验证:本机 65 E2E 全绿 + storage_link_probe path-style 全通 +
PhotoServiceTest/PhotoUploadValidationTest/PhotoLimitGuardTest 19 例单测全绿。

顺带:backend/maven-settings.xml 的 Central 兜底由投毒的 repo1.maven.org
改为可达的 repo.maven.apache.org,使本机镜像构建可走 aliyun 主源。
2026-09-22 17:10:40 +08:00
WorkBuddy 797b1c46cf refactor(storage): 对象存储从 MinIO 换成 SeaweedFS(钉 4.47)
动机:MinIO 的 GitHub 仓库已于 2026-02 归档,不再产出安全补丁,已累积多个
CVE 且不修;本项目存身份证 / 实名材料 / 用户照片,无人修 CVE 才是真正驱动因素
(Docker Hub 下架只是表象,quay.io 仍在提供)。

变更:
- 三套 compose(dev / staging / prod)的 minio 服务替换为 seaweedfs 4.47:
  覆盖 entrypoint 渲染 -s3.config 注入 identity(S3 网关默认不鉴权,必须显式开启),
  -volume.max=100 解除第二个 bucket 写入时的 InternalError。
- 新增一次性 storage-init 容器,经 filer 接口预建 public / private 桶
  (SeaweedFS PutObject 到不存在的 bucket 直接失败;全仓库原无任何 createBucket)。
- S3Config 显式开启 path-style:AWS SDK v1 默认 virtual-host 会把 bucket 拼进
  主机名,容器名在 DNS 中不存在必然失败(本机有 http_proxy 时易误判为 502 拒绝)。
- ci.yml:镜像校验与起栈步骤改为 seaweedfs + storage-init。
- 运维脚本 health-check.sh / run-uat.sh 的 MinIO 探针改为 SeaweedFS
  (S3 网关开启鉴权后匿名根请求返回 403,200/403 均算存活)。

注:path-style 与建桶属换存储的必要项;presigned URL 的 host 须对外可达这一
既有缺陷(原 minio:9000 直发客户端不可达)留待下一轮「改缺陷」处理。
2026-09-22 14:26:48 +08:00
WorkBuddy 8bd1ab804a fix(organization): 修复创建机构审计缺操作人;补全机构修复的验证并部署 Docker 测试环境
一、机构审计归属缺陷(本轮活栈验收中新发现)
- 现象:audit_logs 中 CREATE_ORGANIZATION 的 operator_id 7/7 为 NULL,
  而 DISABLE/ENABLE_ORGANIZATION 均正确记录操作人。审计不可归属 = 无效审计。
- 根因:OrganizationController.create 持有 @AuthenticationPrincipal OperatorPrincipal,
  却只把 params 传给 Service,未透传 principal.id() → saveAuditLog(null, ...)。
- 修复:createOrganization(params, operatorId) 签名透传;新增测试断言
  createOrganizationRecordsOperatorInAuditLog 长期守卫。
- 复验:新写入的 7 条 CREATE 审计全部带 operator_id,旧 14 条仍为 NULL(前后对照清晰)。

二、补全 rc.22 的验证缺口(此前记为「已修复待验证」)
- 全量回归 757 / 0 / 0 / 3,BUILD SUCCESS,108 个测试类,6:44(基线 741 + 新增 16)。
- 负向注入:ORG_STATUS_ACTIVE 改回 "normal" → 16 例变 3 失败 + 7 错误
  (守卫三条断言各自命中 + 服务侧 7 例被 H2 CHK_ORG_STATUS 拦下)。
  注入采用「仓库只读挂载 + 容器内副本改写」,工作区哈希前后一致,源码零污染。
- 活栈实测(Docker 全栈):创建/停用/启用均 2xx、非法输入 400、不存在 404、全程零 5xx(15/15)。
- 对已部署栈 pytest tests/e2e → 65 passed。

三、Docker 测试环境部署与工具链固化
- 测试镜像 danyang-match-backend-test 与命名卷 danyang-m2-cache 因 Docker Desktop 重装丢失,
  构建定义此前是临时产物 → 落盘为仓库资产 infrastructure/test-toolchain/Dockerfile。
- 本地栈统一使用 docker compose -p danyang(原项目名 infra 与另一项目撞名,
  其 --remove-orphans 会误删本项目容器;已确认邻项目数据无丢失,见 known-risks KR-09)。

四、UI 门禁脆弱性修复
- ui-document-baseline-contracts.mjs 解析 docker-compose.staging.yml 时报假红
  (「守卫自身失效」);真因是该文件为 CRLF 而守卫正则要求 \n 紧随 container_name 之后
  (prod.yml 是 LF 所以此前能过)。解析改为行尾无关,真实断言未削弱。门禁 18/18。

五、文档
- STATUS.md 新增 rc.23 并将 rc.22 由「待验证」订正为「已验证关闭」。
- 实施计划附 B.1、PROGRESS-ASSESSMENT-2026-09-21.md 同步订正。
- known-risks.md:KR-07 解除(Docker 已恢复)、KR-01/KR-06 按复测订正,
  新增 KR-08(Docker Hub 域名 DNS 被投毒,改用国内镜像源绕行)与 KR-09(compose 项目名撞名)。

验证状态:全量回归 757/0/0/3 全绿;UI 契约门禁 18/18;对已部署栈 E2E 65/65;
负向注入可变红;工作区哈希未被注入过程污染。
未验证:GitHub Actions 首次真绿(仍未推送,见 KR-06)。
2026-09-22 00:46:10 +08:00
WorkBuddy c195133680 fix(organization): 机构三处写操作必崩 500 修复 + type 白名单 + 测试库约束补齐
缺陷本体(实施计划附 B.1,2026-09-19 登记并实测复现,本轮处置):
OrganizationService 的 createOrganization / disableOrganization / enableOrganization
分别写入 Constants.ACCOUNT_STATUS_NORMAL("normal") 与 ACCOUNT_STATUS_SUSPENDED("suspended")。
那是 users.account_status 的取值,而 organizations.status 的 CHECK 白名单
(V001 chk_org_status)只有 ('active','inactive') → 三个写操作全部违约,
生产必崩 PSQLException violates check constraint "chk_org_status" → HTTP 500。
二阶影响:activities.organizer_org_id NOT NULL ⇒ 无机构即无法新建活动,
运营端「建机构 → 建活动 → 报名 → 互选」链路的第一环是断的。

修复:
- Constants 新增 ORG_STATUS_ACTIVE / ORG_STATUS_INACTIVE(独立于 users.account_status,
  附注释说明两者白名单不同,禁止互相复用)
- 三处状态值改为 ORG_STATUS_ACTIVE / ORG_STATUS_INACTIVE
- 新增 OrganizationService.ALLOWED_TYPES + type 白名单前置校验:
  非法值返回 VALIDATION_ERROR(400),不再打到 PostgreSQL 才 500
- 「机构不存在」由裸 RuntimeException(→500 SERVER_ERROR) 改为 NOT_FOUND(404)
- 异常类型统一为 GlobalExceptionHandler.BusinessException —— 独立类
  shared.exception.BusinessException 没有 @ExceptionHandler,抛它仍会退化成 500

根因第三重盲区(缺陷长期隐身的直接原因):
schema-h2.sql 的 organizations 表此前未复刻这两条 CHECK,H2 上写 'normal' 畅通无阻。
已补齐 chk_org_type / chk_org_status 及 NOT NULL / DEFAULT,与生产 DDL 逐值一致。

新增守卫:
- OrganizationConstraintParityTest:生产迁移 DDL ↔ 测试库 schema ↔ 后端白名单 ↔
  Constants 四方比对 + 解析器自检(虚构约束名必须解析失败,防断言静默永真)。
  其中 status DEFAULT 的解析必须先把范围切到 organizations 建表块 —— 全文件搜索会
  误命中 users.account_status(本轮自检脚本实测踩到并修正该缺陷)。
- OrganizationServiceTest:走真实 DB 的 H2 集成测试,9 用例覆盖创建/停用/启用/
  停启往返/六个合法 type/非法 type/名称空白/404 语义,断言落库值而非返回值。

⚠️ 未在本机运行测试:本轮探测确认本机无 Docker、无 JDK,wsl.exe 被安全策略阻止
(与 known-risks.md 的 WSL/Docker 故障一致),mvn -o test 与负向注入均无法执行。
已用等价正则的独立脚本验证守卫判别力与两处 DDL 一致性(PASS,脚本留痕
.workbuddy/_verify_org_guard.py)。
补跑全量回归 + 负向注入前,本项记为「待验证」,不得计入已验收。
2026-09-21 20:34:30 +08:00
WorkBuddy 60c591fc68 V1.0.1 纠偏 DoD 补齐:canonical key 字面化(V051+KEY_ALIASES)+ 照片并发突破实测(锁用户行+锁后计数)+ AGENTS 规则27 + known-risks
按文档 DoD/约束逐条复查后补齐的差距(承接 438b38d 主体纠偏):
- CFG-001 字面化:FeatureFlagConfig.KEY_ALIASES(identity.liveness.enabled / 遗留
  livenessVerification.enabled → 实现字段 livenessEnabled,读兼容)+ Controller PUT/DELETE
  落库归一到 canonical 键 + V051 迁移(删遗留键、写入 identity.liveness.enabled=false);
  MigrationValidationTest 基线 50→51。
- PHOTO-004 真实并发突破:新增 PhotoLimitConcurrencyGuardTest(3 线程 H2 并发冲顶)——
  实测咬住「锁照片行+读时计数」在 H2 MVCC 快照漏读、3/3 全成功突破上限的缺陷;
  并发控制改为锁用户行(UserMapper.lockUserRow FOR UPDATE)+ 锁后新语句 COUNT,
  0 张照片亦有锁可加(补首张并发缺口);UserPhotoMapper 计数方法同步替换。
- FF-LIVENESS-003 + canonical 持久化:FeatureFlagPersistenceGuardTest +4
  (canonical 加载 / 遗留别名读兼容 / PUT 落 canonical 键 / 切换即生效无旧缓存)、
  FeatureFlagControllerTest +3(canonical/legacy/reset)。
- 文档同步:AGENTS.md 规则 27(配置治理纪律);docs/governance/known-risks.md
  (7 项含本机 WSL/Docker 故障 KR-07);config-register / open-questions 更新 canonical 口径;
  STATUS rc.21 行补 ⑥⑦⑧。

全量回归 741/0/0/3(基线 717 + 25 新增 − 1 删死测试,宿主机 JDK21+Maven 3.9.9 实测,
本机 WSL/Docker 引擎故障期间);UI 契约门禁 18/18
2026-09-21 11:13:14 +08:00
WorkBuddy 438b38d121 V1.0.1 配置与规则纠偏:照片上限配置化+强校验 / 活体开关闭环 / 推荐阈值快照+规则版本化 / governance 三表+漂移审计
根治三类工程治理问题(技术默认值≠产品规则、配置存在但未闭环、文档代码漂移):
- PROFILE-001:profile.photo.maxCount(默认6未确认)+ ProfilePolicy + 服务器端 FOR UPDATE
  并发强校验(PHOTO_LIMIT_EXCEEDED,拒绝先于 S3 不留孤儿)+ 前端 mergeAppConfig 嵌套键修复
- CFG-001/IDENTITY-001:IdentityPolicy + GET /verification/liveness-status 真实判定点
  (默认关不阻断,开则 enhancedVerified 变化,消除假开关)+ 删死 seed livenessVerification.enabled
- MATCH-001:阈值60定为技术默认(消解 PENDING 矛盾)+ V050 recommendation_records.threshold_snapshot
  规则版本化 + 删死重复配置类 MatchConfig
- DOC-001:新增 docs/governance/(config-register/open-questions/decision-register/
  hardcoded-rule-audit/config-drift-audit)
- TEST-001:新增守卫 PhotoLimitGuardTest(8)/IdentityLivenessGuardTest(5)/
  RecommendationThresholdSnapshotGuardTest(4),均经负向注入可红

全量回归 733/0/0/3(基线717+17新增-1删死测试);UI 契约门禁 18/18;STATUS 升 rc.21
2026-09-21 02:05:15 +08:00
WorkBuddy 6e76e2f2ea feat(account): 数据保留流程落地(36 个月下限,V049)—— closed_at + 人工审核/幂等匿名化/审计 + 运营端点 + 守卫 5 用例;测试库补 user_photos,MyBatis null 更新修复 2026-09-20 22:30:29 +08:00
WorkBuddy 08b9cb2468 fix(p21-2e): 红娘服务拦截接线(blocker 首次生效 + 竞态兜底);2d 确认 Onboarding 本地资格已实现;文档回写
P16 遗留 2e(红娘服务拦截):
- checkCanWithdraw 硬编码 0 → 接 MatchmakerServiceMapper.countActiveByUserId(非终态口径
  submitted/assigned/in_service/waiting_user,与 findActiveByOperatorAndUser 一致),blocker 首次真正生效
- notifyMatchmaker TODO 空转 → terminateActiveMatchmakerServices 竞态兜底(残留非终态置 cancelled);
  不发通知:Phase 18 D2 的 notifications.user_id→users(id) FK 教训
- 守卫 MatchmakerWithdrawBlockerGuardTest 4 用例全绿;首跑暴露测试基建坑:@Transactional Service
  是 CGLIB 代理,ReflectionTestUtils 直调私有方法拿到的是代理实例的 null 字段 → 须先
  AopTestUtils.getTargetObject 解包(已在测试内注释说明)
- 另发现历史遗留:countInProgressByOperator 使用白名单外的 'processing'(已登记)

P04-T10:复核确认 Onboarding 本地资格步骤早已实现(onboarding 消息/动作 + 路由 + /me/status 字段),
JIRA ⏳ → ✅;清除首页语义错位的死状态 isVerified(miniapp tsc EXIT=0)

文档:JIRA 收尾行/计划文档 Task 3.1/3.3 勾选
2026-09-19 19:30:38 +08:00
WorkBuddy 469d7194bd fix(p16/p13a): Task 3.3 三项清偿 —— 互选超时接线、随访审计、随访风险检测(负向验证已证守卫有牙)
P16-T13 互选超时接线:
- 缺陷本体:expires_at 全仓无人写入(配置 48h 在、confirm 的过期过滤在,唯独创建不赋值)
  → (expires_at IS NULL OR expires_at > NOW()) 恒真,「截止后48小时」窗口形同虚设
- 修复:createMutualConsent 新建时写 expires_at = 活动结束时间 + ActivityConfig.mutualDeadlineHours
  (已确认口径「截止后48小时」),活动缺失/无结束时间回退创建时刻
- 守卫:ActivityMutualConsentServiceTest 新增 2 用例;负向验证(摘掉写入)恰 2 红、其余 7 绿
- 原测试 expiredConsentRejectsConfirm 手动补 expires_at 恰好掩盖了创建侧缺陷,如实记录

P13A-T31 随访审计:
- assigned Counselor 读他人咨询正文/随访此前零审计 → getDetailForCounselor 接线
  recordSensitiveAccess(counseling_followup);本人读自己与 manager 摘要视图不审计

P13A-T33 随访风险检测:
- detectCounselingRisk 此前只扫申请摘要 → addFollowup 正文接入同一检测(source=counseling),
  INV-013 加固:不记录正文只记级别(同 apply 模式)
- 守卫:CounselingFollowupAuditAndRiskGuardTest 4 用例(审计落库回查 ×2 + 风险事件落库回查 ×2),全绿
2026-09-19 19:10:25 +08:00
WorkBuddy 28cf25756a test(e2e-recon): E2E 场景对账表(32 项:31 项有真实覆盖、1 项被裁定取代)+ 补 /me/status.localEligibilityStatus 零断言缺口
- 对账表写入收尾计划 Task 2.1 节:首页 11 / 本地资格 11 / 权限 10 逐项映射到
  E2E 用例与后端集成测试;原始清单取自 commit ae1bd2b 占位文件头
- 发现并补齐 1 处缺口:/me/status 的 localEligibilityStatus/localEligibilityType
  此前零断言(实现存在但从未被测)→ 新增 MeStatusLocalEligibilityGuardTest 2 用例
- 首页 #10(counselingEnabled flag 控制入口)登记为被 2026-09-19 产品裁定取代(死配置移除)
- 实测:MeStatusLocalEligibilityGuardTest 2/2 绿(离线容器 3:19)
2026-09-19 18:58:07 +08:00
WorkBuddy d605c04707 feat(flags): PUT /operator/feature-flags 真持久化(V048)
此前 updateFlag 只改 FeatureFlagConfig 内存单例的字段:重启即回默认值、
多实例互不一致,表现为「后台显示已关闭,一次发布后功能又回来了」。

- V048 新建 feature_flag_overrides(flag_key 主键;值存字符串、回读按字段
  声明类型解析;updated_by 记审计)。updated_by 必须建 uuid 而非 varchar,
  否则 UUIDStringTypeHandler 以 uuid 发参数会在 PG 抛
  operator does not exist: character varying = uuid(同 V046 的 actor_id)。
- FeatureFlagOverride/Mapper/Service:upsert 走「先 UPDATE、0 行再 INSERT」,
  不用 PG 的 ON CONFLICT(H2 测试库不认,同 IdempotencyMapper 取舍)。
- FeatureFlagConfig.reloadFromPersistence() 挂 @PostConstruct:该时机晚于
  @ConfigurationProperties 绑定,故 DB 覆盖压得住 yml 默认值。
- PUT 先改内存再落库(内存改动失败即 400,不写库);新增 DELETE 恢复默认。
- 覆盖值无法解析时启动 fail-fast,不静默回退默认(否则「配了不生效」)。

守卫 FeatureFlagPersistenceGuardTest 11 例,核心是「另起实例重新加载后改动仍在」
(重启等价路径),并自检 @PostConstruct 未被摘掉。
负向验证:注入不落库 + 摘钩子 -> 恰好 5 例变红,其余不受影响;还原 md5 一致。

全量回归 700 / 0 / 0 / 3(起点 689,净 +11);UI 契约门禁 18/18。
2026-09-19 17:47:16 +08:00
WorkBuddy 018626935a chore(config): 移除 7 项从未被读取的死配置(5 个功能开关 + 2 个咨询附件桶)
产品裁定:确认移除对系统无影响即可移除。移除前的无影响论证(不是「看着没人用就删」):
全仓搜索 5 个 getter,命中的只有 ① 本类定义处 ② getAllFlags() ③ FeatureFlagControllerTest 一处断言
—— 业务代码零调用点;前端 admin-web/src 与 miniapp 对 feature-flag 零调用(仅一个未使用的 storageKey 常量)。

⚠️ 重要边界:FeatureFlagConfig **整体不能删** —— 同类的 isAppealEnabled(AppealService 4 处)、
isLocalEvidenceUploadEnabled(LocalEligibilityService)、getLocalEvidenceMaxFiles /
getLocalEvidenceMaxFileSizeMB / getLocalEvidenceAllowedTypes(PhotoService)都是**真实生效的 gate**。
本次只删那 5 个死的:counselingEnabled / premaritalEnabled / contentEnabled /
serviceLocationEnabled / localEligibilityEnabled,以及从未被读取的
counselingAttachmentsPrivate / counselingAttachmentsBucket(存储实走 object-storage.bucket-private)。

同步清理 application-docker.yml(application-dev.yml 被 .gitignore 排除,已在本地同步清理):
否则会留下「配了但不生效」的新死配置 —— 正是本次要消除的问题本身。
getAllFlags() 键数 21 → 14;FeatureFlagControllerTest 布尔样例由 counselingEnabled 换成 referralEnabled。

docs(plan): Task 3.1 全闭 + Task 3.2 前两项已完;附 B 增第 14 项;基线 685→689

Task 3.1(隐私设置)两层均已关闭:① upsert ② 桩接通 + 键集以小程序为准。
Task 3.2 第三项「PUT /feature-flags 不持久化」**仍未处理** —— 改动涉及运维流程
(改的是运行时内存态,重启即失效),与「删死配置」风险不同,需确认做成真持久化还是标注只读后关端点。

附 B-14:新增迁移必须同步更新 MigrationValidationTest.LATEST_MIGRATION(本轮实测踩到),
该守卫还断言「迁移文件总数 == 基线」即不允许缺号,属有效守卫。

全量回归 689 / 0 failures / 0 errors / 3 skipped;UI 契约门禁 18/18 绿。
2026-09-19 17:12:05 +08:00
WorkBuddy d9c08d85f9 feat(privacy): 键集以小程序为准 —— V047 补列 + 接通 /account/privacy-settings 桩
产品裁定:隐私设置键集**以小程序为准**,用迁移补齐后端缺失列。

V047 新增 allow_activity_invitation(默认 TRUE)与 show_last_online(默认 FALSE)。
默认值**不对称**,与小程序 data 初值严格一致 —— showLastOnline 写成 TRUE 等于默认公开用户在线状态。
既有 6 列保留不动:UserProfileService 的资料详情 DTO 仍在全量返回它们(第 74-79 行),
那是仍在生效的独立读取出口。本迁移只做加法,不做替换。

AccountLifecycleService 的两个 TODO 桩改为委托 PrivacyService 真实读写。
这是**用户真正走的路径**(小程序调 /account/privacy-settings,而 /me/* 与 /profile/* 无人调用),
此前「保存成功」只是回 {updated:true} 而从不落库。

入参校验:已知键类型错误必须显式抛错,未知键宽容忽略 ——
若静默丢弃,用户会看到「保存成功」而设置从未生效,这类缺陷最难排查。
另容错 Boolean 的字符串形式("true"/"false")。

test(privacy): PrivacySettingsUpsertGuardTest 扩至 8 用例,其中 2 个是契约守卫

契约守卫**直接解析小程序源文件**的 PUT payload 与 data 块得出权威键集/默认值,
不抄契约副本(沿用 EntityMigrationParityTest 的纪律)—— 小程序改键即变红。
① 键集:后端返回键必须与小程序 PUT payload 键完全一致(不多不少)
② 默认值:后端默认值必须与小程序 data 初值一致(含 showLastOnline=false 这条不对称项)
③ 桩已接通:经 /account/privacy-settings 写入必须真落库且可读回
④ 已知键传错类型必须报错,不得静默丢弃
其余 4 个为 upsert 行为守卫(连续两次更新不炸且不产生第二行 / 部分更新不重置 / 默认值 / 读取无副作用)。

负向注入:把桩退回「不落库」→ 恰 2 红(③ 与 ④),其余 6 个正确未红。已还原并复跑全绿。

test(deployment): MigrationValidationTest 基线 46 → 47

新增迁移必须同步更新 LATEST_MIGRATION,否则全量回归报「版本号超出当前基线」。
该守卫同时断言「迁移文件总数 == 基线」(不允许缺号),是有效守卫 ——
正确做法是更新常量,不要为了让测试变绿而改断言方式。
2026-09-19 17:11:46 +08:00
WorkBuddy 04b07b5821 fix(privacy): updatePrivacySettings 改 upsert —— 同一用户第二次更新不再撞 user_id 主键
user_privacy_settings 的主键自 V002 起就是 user_id,旧实现无论记录是否存在都无条件 insert,
导致同一用户第二次更新必然 DuplicateKeyException → 500,隐私设置从此再也改不动。
现改为「存在则按 user_id 更新,不存在才插入」。

更新条件刻意用 user_id 而非实体 id:id 是 V037 为「实体↔表对齐」后补的独立列,
不作为寻址依据 —— 否则历史行 id 缺失时更新会静默失效。

已知边界(未擅自处理):findByUserId 带 deleted_at IS NULL,若将来引入软删路径,
已软删的行会让本方法走插入分支并撞主键。当前不存在任何软删隐私设置的路径,故不可达;
若将来新增软删,需同步改为 PG ON CONFLICT (user_id) DO UPDATE。已在代码注释中标注。

test(privacy): PrivacySettingsUpsertGuardTest —— 4 用例,全走真实落库(H2),不用 mock 断言调用次数
① 连续更新两次:第二次不得抛异常 + 库中仍只有 1 行(防退化成重复插入,
   那会让 findByUserId 的 Optional 返回值抛 TooManyResultsException)
② 部分更新不得把未提交字段重置回默认值
③ 首次更新未提交字段取默认 true
④ 无记录时读取返回全 true 且不落库(固化既有契约)
负向注入已验证:退回无条件 insert → 恰 2 红,失败原因即 DuplicateKeyException;
另 2 个只更新一次的用例正确未红(走不到该分支)。已还原并复跑全绿。

docs(plan): Task 3.1 拆两层并记录进展;附 B 增第 13 项;基线 681→685

⚠️ 关键发现(附 B-13):隐私设置有两套实现,前端走的是桩那一套 ——
小程序 pages/privacy-settings 调 /account/privacy-settings(AccountLifecycleService 桩,不落库),
管理端对隐私端点零调用。故本次修复位于「前端尚未触达」的路径:必要但当前用户不可感知。
真正的用户可见故障是「改隐私设置根本不保存」(桩),属 Task 3.1 上层,仍待契约裁定。
本次修复是该上层工作的必要前置 —— 桩一旦接到 PrivacyService 立刻会撞上此缺陷。

方法论:评估「某修复的价值」必须先确认前端实际调用哪条路径 ——
本项目同业务存在 /me/* 与 /account/* 多套并存端点,后端有真实实现 ≠ 用户能走到。
2026-09-19 15:49:54 +08:00
WorkBuddy 0242ea44e8 fix(mapper): 补 5 个「只有声明、无任何 SQL」的方法;新建 Mapper 绑定守卫持续挡住该类缺陷
全仓无 mapper XML、无自定义 ISqlInjector,自定义方法只能靠注解提供 SQL。
这 5 个方法漏写注解,编译期无感知,调用必抛 Invalid bound statement:
  UserPrivacySettingsMapper.findByUserId  —— 隐私设置读取
  UserPreferenceMapper.findByUserId      —— 推荐引擎 / 匹配资格共 6 处调用
  ReviewReasonMapper.findAllEnabled      —— 审核原因字典下拉
  ReviewReasonMapper.findByCategory
  ReviewItemMapper.findByReviewTaskId
长期未被发现的原因:候选池为空(分支走不到)+ 测试库根本没有这 4 张表(双重不可达)。
补的 SQL 一律带 AND deleted_at IS NULL —— V037 之后这些表都有软删列,
只看 V002 建表会漏掉软删过滤,制造「读到已删数据」的新缺陷。

test(invariants): MapperBindingGuardTest —— 三用例
① 绑定守卫:反射遍历 MapperRegistry 全部 Mapper 的声明方法,hasStatement 为假即失败;
   按「实体有无主键」语义豁免复合主键实体的 selectById/updateById/deleteById 等
   (MP 本就不为无主键实体注入,属正确行为;非类名白名单,故新增复合主键表同样适用)。
② 探测自检:虚构 statementId 必须为假,防断言静默永真。
③ 可执行性:插真实数据后真查一遍 —— 绑定存在 ≠ 能跑(本次即借此确认 JsonNode 映射无碍)。
负向注入已验证:移除 ReviewItemMapper 的 @Select → 守卫报恰 1 项缺失,
且可执行性用例抛真实 Invalid bound statement;还原后复跑全绿。

test(db): schema-h2.sql 补 4 张缺失表(user_privacy_settings / user_preferences /
review_reasons / review_items),按 V002 + V037 之后的最终态建模(含 id、deleted_at 与
11 个匹配权重列),并保留 chk_item_type / chk_item_status / chk_pref_priority 约束。

docs(plan): 附 B 新增第 12 项;更正第 11 项中我自己上一轮的错误断言
—— 「表无 id 列导致 insert 必崩」不成立:V037 已补齐 id/deleted_at/权重列。
真实缺陷是 updatePrivacySettings 对已存在记录也走 insert(非 upsert)→ 第二次更新撞主键冲突。
方法论:判断表结构必须遍历全部迁移,只看 CREATE TABLE 会得出与生产相反的结论。
2026-09-19 15:26:35 +08:00
WorkBuddy b834fa86a0 test(invariants): Phase 23 Task 2.2 —— INV-011 显式行为守卫(9 用例 + 两次负向注入验证)
产出 Inv011EvidenceAccessGuardTest:非领取人 / 跨用户佐证 / 非 local_eligibility 任务一律拒绝,
且 successful 与 denied 两条路径都断言 sensitive_access_logs 真实落库(不用 mock 断言)。
另含 3 个结构性守卫(用户侧端点无用户可控 userId 入参、佐证读取入口的双重权限约束、
佐证读取不暴露可伪造 operatorId)与 1 个守卫自检(参数名反射可读,防断言静默永真)。

前置修复:schema-h2.sql 补 sensitive_access_logs 表 —— 该表此前在测试库完全不存在,
而它是「访问留痕」命题的唯一落点,缺失导致该命题根本无法断言(表漂移盲区,与 Phase 12/15/19 同族)。

负向注入两次,均精确命中:
① 停用 requireClaimOwner → 恰 1 红(非领取人用例),其余 8 绿
② 停用 catch 内 denied 审计 → 恰 2 红(非领取人 + 跨用户),第 3 条走 try 外的另一路径故正确未红
两次注入均已还原并复跑全绿(9/9);V11InvariantsTest 12/12 绿;UI 契约门禁 18/18 绿。

文档:Task 2.2 闭环记录 + 附 A 扩至 7 处校正(含对本文档初稿「机构创建 500 归因」的自我更正,
实测为 chk_org_status 违约而非 id 为空)+ 附 B 扩至 11 项新发现
(5 个 Mapper 方法声明了却无实现、隐私设置三方契约冲突 + 表/实体主键错配、
测试镜像 mvn -o 不可用、本机全量回归会卡住)。
2026-09-19 14:02:52 +08:00
WorkBuddy 69cf6a2cc7 feat(idempotency): 为 30 个写端点实现 Idempotency-Key
规范第八章要求 7 类写端点支持 Idempotency-Key,第 146 章验收口径为
「最终不能生成重复核心数据」。检修前的实际状态:后端零处读取该头,
小程序 25 处调用点发送全部石沉大海。

一处实现、零业务代码改动、逐端点声明:
- V045 新建 idempotency_records + 唯一索引 uk_idempotency_scope
- V046 修正 actor_id 列类型 VARCHAR -> uuid(见下)
- IdempotencyInterceptor:先占位后执行 / 重放首次响应 / 处理中回 409 /
  孤儿占位 300s 可接管 / 失败释放占位
- IdempotencyResponseCacheFilter:缓存响应体供逐字节重放
- WebMvcConfig:本项目第一个通用 WebMvcConfigurer
- @Idempotent 声明 30 处(含补 CheckinController 手工签到的漏标)
- GlobalExceptionHandler:IllegalStateException(30 处业务状态冲突)与
  DuplicateKeyException(并发重复提交的最后防线)此前均无映射 → 落 500,
  而 500 的语义是「可重试」,调用方于是重试并制造更多重复。现映射为 409;
  刻意不映射父类 DataIntegrityViolationException(CHECK 违反应由 500 暴露)。
- schema-h2.sql 补 idempotency_records

V046 的必要性:项目全局注册的 UUIDStringTypeHandler 对任何合法 UUID 形式的
String 参数以显式 uuid 类型发送,而 SQL 侧 stringtype=unspecified 只影响以
unknown 类型发送的参数,覆盖不到它。因此真正的不变量不是「JDBC 参数配好了」,
而是「列类型必须是 uuid」。V045 初版把 actor_id 建成 VARCHAR(64) 时,
带幂等键的请求在真实 PG 上全部 500。

全库扫描确认该不变量已收敛:所有 *_id VARCHAR 列中 actor_id 是唯一违规,
client_message_id / provider_request_id 为外部 id,
resource_id / source_id 在 Java 侧零处作查询条件。
2026-09-18 16:37:11 +08:00
WorkBuddy cbb4f64fdc fix(security): stop operator identity from reaching user endpoints; guard path rules
Continues the HTTP-semantics work. Fixing 401/403 exposed a worse defect on the
same axis: who is allowed to reach which endpoint.

Root cause chain:
- PlatformPrincipal has two implementations (OperatorPrincipal / UserPrincipal),
  chosen by JwtAuthFilter from the token type.
- `@AuthenticationPrincipal UserPrincipal` against an OperatorPrincipal is a type
  mismatch, and Spring Security returns null (errorOnInvalidType defaults to
  false) instead of failing.
- 244 uses of that annotation repo-wide; only 48 null-check. The rest NPE -> 500
  when reached with an operator identity.
- The fallback rule was `.authenticated()`, which an operator token satisfies, so
  such requests did reach user-facing controllers.

Adding ~196 null checks would be mechanical, easy to miss, and does nothing to
constrain future code. The mismatch is between identity type and endpoint, so it
is fixed once, at the authentication layer: the fallback becomes
`.hasRole("USER")` (operator -> ROLE_OPERATOR, user -> ROLE_USER, per JwtAuthFilter).
Operator requests to user endpoints now get 403 before any controller runs.

Trap worth recording: operator endpoints are not all under /operator/ —
/counseling/services/operator/queue, /content/operator/articles,
/activities/operator/checkin/scan, /service-appointments/operator/list and others
sit deeper inside business paths. The first attempt covered them with
`requestMatchers("/**/operator/**")`, which fails at runtime:

  PatternParseException: No more pattern data allowed after {*...} or ** pattern element

Spring's PathPattern permits `**` only as the final element. Worse, the symptom is
a 500 from the whole filter chain (Tomcat HTML error page, not our JSON), and E2E
went from 37 passed to 33 failed — an error far removed from its cause. Now the
nine prefixes are listed explicitly, each ending in /**.

Because explicit enumeration can be forgotten as endpoints are added, and because
the failure mode is "operators locked out of their own admin module" (invisible to
user-side tests), SecurityPathCoverageTest parses the OPERATOR rule prefixes out of
SecurityConfig source, walks every *Controller.java, joins class-level and
method-level mapping paths, and asserts every path containing /operator/ is
covered (/auth/operator/** excluded by design). Two things that would silently
neuter it, both handled: it must parse only the .hasRole("OPERATOR") chain — pulling
in the permitAll or fallback rules would mark everything covered — and the test
working directory is the repo root, not the Maven module root.

Verification: negatively validated by removing /content/operator/** -> exactly one
test turns red listing the six affected ContentController endpoints; restored.
E2E 40 passed (37 + 3 identity-isolation cases). Backend regression 664 / 0
failures / 0 errors / 3 skipped.
2026-09-18 15:05:41 +08:00
WorkBuddy 7682abd85c fix(api): give failures real HTTP semantics; fix 401/403 confusion and CORS default
Phase 23B ended with an E2E assertion changed to expect "HTTP 200 + body.code
== BAD_REQUEST", documented at the time as "this project's convention". That
description was accurate about the code and wrong about the intent: it described
a defect, and I made the test accommodate it instead of fixing it.

Auditing it found the convention was already broken, not merely risky:

- ~100 sites (105, across 30 controllers) do `return ApiResponse.error(...)` /
  `forbidden()` / `notFound()` with no exception and no `@ResponseStatus`, so
  the HTTP status is always 200. Gateways and APM cannot separate 4xx from 5xx;
  callers cannot tell "fix your request" (400) from "don't retry" (403) from
  "check state first" (409) from "retry later" (429).
- miniapp/utils/api.js already had 401/403/429 branches that could never be
  reached, since the backend never returned those codes. The concrete damage:
  an expired token returned 403, so `onSessionExpired` never fired and users
  were stuck with no way back to the login flow.
- SecurityConfig had no `authenticationEntryPoint`, so Spring's default
  `Http403ForbiddenEntryPoint` returned 403 for unauthenticated calls, with a
  framework body that has no `code` field at all.
- CorsConfig defaulted `allowed-origins` to `*` together with
  `allowCredentials(true)`, while its comment claimed "localhost only by
  default". Comment contradicting code is worse than no comment: it reads as
  safe.
- Only 4 files / 15 assertions in the whole backend suite touched HTTP status,
  which is how this survived.

Approach: one definition, two applications, zero controller changes.
- ApiResponseCode gains an `httpStatus` field — the single source of truth. The
  constructor requires it, so a new code cannot be added without one.
- GlobalExceptionHandler reads status from the enum instead of hardcoding
  `@ResponseStatus` per handler.
- New ApiResponseStatusAdvice (ResponseBodyAdvice) fills in the status just
  before the body is written, covering all ~100 direct-return sites without
  touching them. Two constraints, both noted in code: only downgrade when the
  current status is still 2xx (the advice also sees exception-handler results,
  and must not override an already-correct 401/403/409), and never alter the
  body.
- SecurityConfig: entry point returns 401 with a uniform ApiResponse body;
  access denied handler returns 403 likewise.
- CorsConfig: default changed to empty (deny). Both clients are same-origin
  anyway — nginx proxies /api/ and Vite proxies /api — so nothing depends on it.
- miniapp api.js: 401/403/429/409 branches now prefer the server's
  `body.message`, otherwise "账号已被封禁" would be flattened to "无权限访问".

Implementation trap worth remembering: `ServerHttpResponse` has `setStatusCode`
but no getter, so reading the current status requires casting to
`ServletServerHttpResponse`.

Verification: 37 E2E passed (33 + 4 new contract cases), including a live-stack
check that a controller's direct `notFound()` now yields 404 — that path throws
no exception and never reaches the exception handler, so if the advice were
inactive it would silently return 200 while the enum unit tests stayed green.
Backend regression 662 / 0 failures / 0 errors / 3 skipped (+6 new guards).

The first regression run had 3 failures, all tests asserting the old wrong
behavior (anonymous access expected 403; outsiders reading a conversation
expected 200). Fixed the assertions, not the implementation. Where 404 is used
instead of 403 for "invisible", that is deliberate — it prevents probing whether
another user's conversation exists — and the substantive assertions (e.g. that an
unauthorized response must not leak message bodies) were kept.

Not done, needs a product decision: third-party credential issuance, whether
rate limiting is actually active, publishing OpenAPI externally, per-caller
audit and quotas, pagination/idempotency coverage, desensitization boundaries.
2026-09-18 14:34:58 +08:00
WorkBuddy 730dddde30 test(e2e): Phase 23B — rebuild E2E infrastructure; fix 6 real prod defects it exposed
Phase 23 closed as PARTIAL: fake coverage was gone, but the E2E layer itself
was still empty — no conftest.py anywhere, ~15 fixtures that never existed,
6 files crashing at import on os.environ["E2E_PASSWORD"], and CI never ran E2E.
STATUS's "10 E2E files / ~80+ cases ✅" described files that had never executed.

Infrastructure delivered:
- tests/e2e/conftest.py: ApiClient (trust_env=False — a host http_proxy turns
  localhost calls into 502), operator_token(username) / new_user / admin_token
  fixtures, and a session probe that calls pytest.exit(4) when the stack is
  unreachable. NOT skip: skip would launder "did not run" into "passed".
- pytest.ini, tests/e2e/requirements.txt, and tests/e2e/sql/
  seed_e2e_operators.sql — migrations contain NO operator seed at all, so a
  fresh database has zero logins; the seed creates 6 e2e accounts with role
  bindings, idempotent, password injected via `psql -v pwd`.
- CI job `e2e`: compose up -> poll /health/live (timeout = fail) -> seed ->
  pytest -> always teardown. Gate D-23-D is now closed.
- 8 E2E files migrated off import-time env reads; tautological assertions
  (`x == t or not x`), a counselor token standing in for content_editor, and a
  bare `status_code == 200` claiming to prove INV-020 were replaced with real
  permission/behavior assertions.

Six real production defects found only by running against real PostgreSQL
(H2 has zero coverage for all of them):
- D-23B-A (Critical): stale V042__add_appointment_operator_register_permission.sql
  left in target/classes; src had replaced it with V042__add_service_location_
  announcements.sql. Packing without clean put two V042 in the jar -> Flyway
  "Found more than one migration with version 042" -> app would not start.
  Guard added: testBuildOutputHasNoDuplicateMigrationVersions scans build output
  (src is clean, so only this catches it).
- D-23B-B (High): non-UUID path variable reached the DB -> 500 + leaked
  `invalid input syntax for type uuid`. Now rejected at the controller.
- D-23B-C (High): 6 E2E files raised KeyError at import -> never collected.
- D-23B-E (Critical): permissions = 48, role_permissions for super_admin = 18,
  matchmaker = 0. V015's "grant all to super_admin" was a point-in-time snapshot;
  the 30 permissions added later were never granted. On a fresh stack an admin
  cannot create a service station and matchmakers can do nothing. V044 restores
  super_admin to its documented definition (not an expansion); the matchmaker
  permission set needs a product decision and is left as a residual.
- D-23B-I (Medium): IllegalArgumentException (e.g. invalid activity type) was
  unmapped -> 500. Now BAD_REQUEST.
- D-23B-J (High): `operator does not exist: uuid = character varying` — Java
  ids are String, columns are uuid, PostgreSQL does not implicitly cast in
  comparisons. Fixed at the source with JDBC `stringtype=unspecified` (docker
  profile); production datasource comes from deploy env and needs the same.

One judgement retracted: an initial read suggested service_appointment.register
was missing. It is not — the endpoint uses service.assisted_registration, seeded
in V029, and the code deliberately avoids a duplicate permission. Nothing was
changed on that basis.

Verification: 33 E2E passed against a live stack; unreachable stack and a
missing operator password both exit 4 (never skip/pass); backend regression
656 / 0 failures / 0 errors / 3 skipped (baseline 655, +1 guard, negatively
validated by injecting a duplicate V042 -> exactly one test turned red).
2026-09-18 13:50:19 +08:00
WorkBuddy ae1bd2b734 test(phase23): KEEP-VERIFY — remove 44 pass shells + fake INV tests, add shell guard
Phase 23 "可信系统测试重建 (REFACTOR)" re-audit. STATUS claimed "10 E2E
files / ~80+ cases" and "INV-011~020 ✅"; measurement showed the opposite.

D-23-A (Critical): V11InvariantsTest's 14 cases asserted only Constants
strings. INV-016 "Banner target types" actually asserted ARTICLE_STATUS_*;
INV-017 "home 8 entries" declared local arrays then asserted
firstRow.length == 4 (tautology, never called the home service);
INV-018/019/020 used appointment/article/account status constants as
stand-ins for permission and data-scope checks. Rewritten to 12 cases:
4 real service-behavior guards (INV-013 via real CounselingService.getDetail
— Matchmaker sees null userRequestSummary, with an assigned-Counselor
counter-proof so global sanitization cannot fake a pass; INV-016 reads the
production ALLOWED_TARGET_TYPES whitelist and asserts no external/http/url/
web entry; INV-017 calls real getHome and pins all 8 codes + order;
INV-019 asserts content_editor reads null), plus assertBehaviorTestExists
turning "行为验证见 XxxTest" pointers into executable Class.forName contracts.

D-23-B (High): 44 `pass` shells across 6 files in tests/e2e (4 files were
100% shells). Gate forbids skip/pass shells. Rewritten in place as honest
placeholders with the full scenario lists preserved.

D-23-C (High, residual): the pytest E2E suite is not executable at all —
no conftest.py anywhere, ~15 fixtures (api_client/verified_user/
banner_factory/...) do not exist. Only run_v11_tests.py really runs
(16 cases, needs a live stack + E2E_PASSWORD).

D-23-D (High, residual, NOT fixed): CI does not chain E2E as the plan
requires. Needs an infrastructure decision on how CI provisions a live
stack; not implemented rather than written unverified.

D-23-E (Medium): the CI migration job claimed to "验证 migration SQL 语法"
but only checked file non-emptiness, while the real syntax/baseline checks
live in MigrationValidationTest (already run by the backend job). Replaced
with a real sequence check (naming / non-empty / unique versions / no
gaps); verified by extracting the exact inline script and running it.

New gate: scripts/check-no-empty-tests.py (AST scan, blocks pass-only and
unconditionally skipped tests) + a CI test-shell-guard job. Verified both
ways: 0 findings on the real tree, 2 precise findings on injected shells.

Negative validation: 3 injected production defects (external_url in the
banner whitelist, swapped home entry order, isMatchmaker returning the full
record) turned exactly inv016/inv017/inv013 red, 9 others green, zero
unrelated failures; md5sum -c confirmed byte-identical restore.

Full regression: 655 tests / 652 passed / 0 failures / 0 errors / 3 skipped
(baseline 657; -2 because 14 vacuous cases were merged into 12). The 3
skips carry explicit reasons (2x @EnabledIfEnvironmentVariable(CI=true),
1x @Disabled "需要完整数据库连接") — none is a shell.

Phase 23 is PARTIALLY closed: fake coverage is gone, but the UAT gate
requires the end-to-end chain to actually execute, so "失败为零可进入 UAT"
is NOT claimed.
2026-09-18 11:49:18 +08:00
WorkBuddy 2fbdbc32d0 fix(feature-flags): close Phase 22 KEEP-VERIFY — 2 real defects + doc overclaims
KEEP-VERIFY re-audit of the Feature Flag configuration extension (STATUS's
"Phase 22 部署运维配置扩展"): marked ✅ but contained real code defects and
overstated completion.

Real defects fixed:
- D-22-A (High): FeatureFlagController.updateFlag used reflection
  `getMethod("set"+cap, boolean.class)` and only supported boolean flags.
  7 non-boolean flags (livenessProvider, localEvidenceAllowedTypes,
  counselingAttachmentsBucket, localEvidenceMaxFiles, localEvidenceMaxFileSizeMB,
  localEvidenceRetentionDays) failed to update via the API. Now dispatches by
  field type (boolean/int/String).
- D-22-B (Medium): `body.getOrDefault("value", false)` silently set false when
  value was missing and crashed on non-boolean casts. Now returns 400 on missing
  value / type mismatch and leaves the original value unchanged.

Doc overclaims corrected (no silent scope creep):
- T01-T05 module-toggle flags (isCounselingEnabled etc.) have zero business
  call sites -> dead config (exposed but not gating). Left for each module's
  own phase to wire; documented as residual.
- T07 private-storage bucket flags have zero references; storage already uses
  object-storage.bucket-private. Documented as residual.
- STATUS claimed "application.yml defaults (15 flags)"; yml has no `features:`
  block, defaults are Java field defaults. Functionally fine, docs corrected.

T06 upload policy verified actually enforced in LocalEligibilityService +
PhotoService (kept ✅).

Guard tests: new FeatureFlagControllerTest (7 cases: 21 keys exposed, boolean/
string/int update reflected, unknown flag / missing value / wrong type -> 400).
Negative validation: reverting to boolean-only dispatch made exactly 2 update
cases fail red, then green after restore — proves guards are real.

Full regression: 657 tests / 0 failures / 0 errors / 3 skipped (baseline 650, +7).
2026-09-18 11:00:08 +08:00
WorkBuddy 0580da0c62 fix(account-lifecycle): close Phase 21 KEEP-VERIFY — 6 real defects + fake-green tests
KEEP-VERIFY re-audit of account lifecycle (EXTEND): STATUS marked ✅ but
contained hard Gate violations and dead code.

Real defects fixed:
- D-21-A: AuthService.checkAccountStatus did not reject CLOSED → closed
  account could still log in (Gate ❌→✅); added CLOSED branch before
  BANNED/SUSPENDED.
- D-21-B: notifyActiveMatches was a TODO stub → active Matches/conversations
  not closed on withdrawal (Gate ❌→✅); now finds active matches, sets
  UNMATCHED + closes conversation via chatService.closeConversationForMatch.
- D-21-C: cancelPendingActivityRegistrations was a TODO stub → future
  activity registrations not cancelled (Gate ❌→✅); now cancels pending/approved.
- D-21-D: anonymizeServiceHistory existed but was never wired into
  closeAccount → personal identity + counseling text leaked (Gate ❌→✅);
  wired in, anonymizes id_number/real_name with placeholder, plus
  counseling_followups content.
- D-21-E: closeAccount hard-threw on blockers → all cancelXxx unreachable
  (dead code contradiction); changed to informational log + continue cleanup.
- D-21-F: 7× assertTrue(true) fake-green tests → replaced with 11 real DB
  integration tests + 3 login-guard tests (Redis-decoupled via
  ReflectionTestUtils).

Cross-module defenses (RecommendationEngine/MatchEligibility/Capability
reject non-NORMAL) confirmed effective. Remaining (no silent scope creep):
matchmaker in-progress interception still hardcodes 0 (no table), data
retention delete flow is a product decision, privacy-settings endpoint stub.

Full regression: 650 tests / 0 failures / 0 errors / 3 skipped (baseline 648, +2).
2026-09-18 10:12:34 +08:00
WorkBuddy 22c778b111 fix(analytics): close Phase 20 KEEP-VERIFY — 20 defects (fake metric definitions, hardcoded zeros, systemic soft-delete leak, module-wide 403, fake-green tests); add MetricCatalog + unified calculator + V043 grants + 51 guard tests; full regression 648 green
口径造假:
- D1 实名人数用 user_profiles.profile_status != 'rejected' 冒充 → in_progress/need_edit 被算成已实名
- D3 Chat Started 直接数会话行数 → system/risk_notice 系统通知计入
- D4 Match 不分来源 → 活动 10 对互选被当 10 对成功引用(Gate)
- D6 Banner 曝光/点击硬编码 0 → 改返回 null + NOT_INSTRUMENTED 标记

死代码 / 占位:
- D5 活动漏斗全部硬编码 0 + 一行把"用户数"当"活动数"的死代码
- D20 getDailySummary(days) 无边界校验

系统性软删缺陷(D12):
- BaseEntity.deletedAt 无 @TableLogic 且全局无逻辑删除配置
  → selectCount 把软删数据全部计入统计且无任何报错
- 修复 45 处统计调用显式 deleted_at IS NULL(2 轮复验,首轮漏扫 5 处次级路径:
  服务用户数分域明细 / 服务站统计 / 性别与年龄分布)

结构类:
- D15 Dashboard/用户漏斗/专项页各实现一遍同名指标且互不相同 → 抽 AnalyticsMetricsCalculator 统一口径

权限(Gate ⑩):
- data_viewer 在 V001 定义却全仓从未授权(空壳角色)
- platform_admin 的 V015 扩展条件 id >= '20000000-…' 漏掉 V1.0 老权限段
  → 整个 Analytics 模块除 super_admin 外全员 403
- V043 授予两角色;刻意不授予 organization_admin(相关表无组织维度,无范围全量即越权)

假绿测试:
- 删除 AnalyticsServiceTest(5× assertTrue(true),零断言)

新增:
- 指标字典 MetricCatalog(36 指标 × 12 字段)+ MetricStatus/MetricDefinition + docs/ANALYTICS_METRICS.md
- 守卫 51 用例:MetricCatalogGuardTest(11) / AnalyticsMetricsPhase20IntegrationTest(21)
  / AnalyticsSoftDeleteGuardTest(4,结构性) / AnalyticsPermissionGrantTest(6)
  / LocalEligibilityStatusVocabularyGuardTest(9)

跨阶段修复(缺陷本体属账号生命周期模块,按"发现即修"处理):
- X1(Critical)注销写入 user_identities 状态域的 'failed' → 违反生产 V017 chk_elig_verify_status
  → 真实 PG 上 closeAccount 事务回滚、账号注销 100% 不可用(负向验证实测 DataIntegrityViolation)
- X2 checkCanWithdraw 资格拦截用 submitted/pending(该表永不可能出现)→ 死代码
- X3 测试库补 chk_elig_type/chk_elig_verify_status,暴露 ActivityPhase14Test 夹具违规

验证:
- 负向验证两轮共 7 处注入 → 7 个用例精准变红且无无关失败(一处被源码结构+运行时行为两层捕获)
- 还原后 md5sum 逐字节一致,NEG-VAL 残留 0
- 后端全量 648 tests / 0 failures / 0 errors / BUILD SUCCESS;admin-web vue-tsc EXIT=0
2026-09-17 23:25:31 +08:00
WorkBuddy 72b4e9b846 fix(ops-backend/service-station): close Phase 19 KEEP-VERIFY — 25 defects (unreachable announcements + station write privilege escalation; banner 500 + soft-delete leak; content publish dead-end; program approval dead-end/draft leak/zero counts/missing withdraw; zero-coverage test schema) ; add V042 announcement chain + 34 guard tests; full regression 602 green
Backend
- Service Station: expose the 「临时公告」 chain end-to-end (V042 table/service existed
  but had ZERO endpoints -> both admin and miniapp could never reach it): 5 operator
  endpoints (list/create/update/publish/archive) + 1 user-facing read endpoint.
- Fix privilege escalation: create/update were guarded only by the class-level
  service_location.view, making V015's service_location.manage a never-used dead
  permission -> read-only roles could edit stations. Permissions moved to method level.
- Add ServiceLocationOperatorController to OperatorEndpointPermissionCoverageTest
  (it was NOT scanned, which is why the escalation stayed invisible).
- Add create/update validation (address NOT NULL + status CHECK only blew up on PG).
- Banner: validation failures threw bare RuntimeException -> 500; now VALIDATION_ERROR
  -> 400. Add deleted_at IS NULL to both home queries (soft-deleted banners stayed live).
- Content: operator list/detail split (admin «publish» was a dead end because it reused
  the published-only public API); category deactivate/reactivate; article body on edit.
- Service Program: operator member list now includes pending (approval reachable);
  active-only public visibility (draft leak); memberCount/pendingCount aggregation;
  withdraw/removeMember; reuse row on re-apply (UNIQUE(program_id,user_id) crash).
- MeController: add the missing GET /me/service-history route.

Schema / guards
- schema-h2.sql: add service_location_announcements (+ chk_announcement_status) and
  content_categories (+ uk_content_category_code, chk_category_status); move both into
  REQUIRED_IN_TEST_SCHEMA. MigrationValidationTest baseline 41 -> 42.
- V11PermissionMatrixContractTest now scans ALL migrations instead of hardcoding V015+V029.
- New: ServiceLocationAnnouncementPhase19IntegrationTest (24),
  ServiceLocationControllerPermissionContractTest (5),
  ServiceLocationAnnouncementConstraintParityTest (5, prod DDL <-> test schema <->
  backend whitelist <-> admin labels).

Frontend
- admin-web: ServiceStationView gains announcement management (statuses, create/edit,
  publish/archive with confirm, expiry); write buttons gated by service_location.manage.
- miniapp: U50 renders the 临时公告 block via new serviceStationService; page no longer
  talks to utils/api directly; announcement failure degrades to [] instead of erroring.
- ui-tests: U37 four-group + U50 announcement contracts; /home assertion retargeted to
  the service layer.

Verification
- Negative verification: 3 injected defects (drop soft-delete filter / drop test-schema
  CHECK / downgrade write endpoint to view) -> 4 named tests fail precisely, no
  collateral failures; restored, NEG-VAL residue = 0.
- Backend full regression: 602 tests, 0 failures, 0 errors, 3 skipped (baseline 420).
- admin-web: vue-tsc --noEmit EXIT=0, vite build EXIT=0. Miniapp: tsc --noEmit EXIT=0.
- ui-tests 14/14 PASS.
2026-09-17 22:21:31 +08:00
WorkBuddy 3f60611a74 fix(appeal): close Phase 18 KEEP-VERIFY — 8 defects (zero-coverage appeals table + 6 fake-green tests; operator-notification FK crash; appealType CHECK; escalated dead-end; processAppeal race; soft-delete filters; hardcoded resolved; evidence contract); add AppealPhase18IntegrationTest (15); full regression 494 green 2026-09-17 21:07:25 +08:00
WorkBuddy 307c9efb36 fix(risk/report): close Phase 17 KEEP-VERIFY/EXTEND — 6 defects + 1 hidden compile defect (jakarta) + test-coverage blind zone (3 missing tables); add sensitive-access audit + ReportAndRiskPhase17IntegrationTest (7); full regression 488 green 2026-09-17 20:42:09 +08:00
WorkBuddy 556f95a562 fix(activity-mutual): close Phase 16 KEEP-VERIFY/EXTEND — 7 defects + 1 hidden compile defect (reopenEpisode) + 1 test-fidelity; unify dual-impl to confirmActivityInterest; INV-006 fix; +7 tests (481 green) 2026-09-17 16:47:10 +08:00
WorkBuddy 29112eed6e fix(activity): close Phase 15 KEEP-VERIFY/EXTEND — 2 critical + 14 other defects, 3 test-fidelity fixes
Phase 15 (activity registration / review / checkin) verification closure.

Two CRITICAL defects, both "production always had it, test schema never did":
- D1: generateCheckinToken wrote the token to Redis with value "used", while
  scanCheckin rejects any token marked "used" -> every freshly issued check-in
  code was dead on arrival, staff scanning failed 100% of the time.
  Issuing now writes "issued"; "used" is set only after a successful scan.
- D9: UNIQUE(activity_id, user_id) exists in production, but re-registering
  after cancel/reject went through INSERT -> users who cancelled once could
  never register for that activity again (duplicate key 500). The H2 test
  schema lacked the UNIQUE, so the path had zero coverage. Now the original
  row is reused via an atomic conditional UPDATE.

Checkin (D2-D8):
- D2: duplicate check-in was read-then-write; now atomic checkinIfNotCheckedIn
  and repeat scans are idempotent (alreadyCheckedIn) without duplicate audit.
- D3: no data scope at all -> added activity staff assignment (V040) and
  assertCheckinScope; INV-020 (org relation grants nothing).
- D4: manualCheckin did not validate activity existence/cancellation.
- D5: getCheckinStats NPE on missing activity; remainingCount was semantically
  "free seats" -> replaced with notCheckedInCount.
- D6: malformed timestamp caused 500 -> 400. D7: constant-time signature compare.

Registration / review / cancel (D10-D13):
- D10: backend registration list only queried status='approved' -> the review
  screen never saw pending rows (review unusable).
- D11: registration_start_at / registration_end_at were never enforced.
- D12: cancel had no audit log (the only write without one).
- D13: reject/cancel were read-then-full-update -> rejectIfPending/cancelIfActive.

Capacity groups (D14, plan item 2): groups were stored and read back but never
participated in capacity -> incrementIfAvailable / decrementIfPositive now make
approved_count real; activities without groups are unaffected.

Frontend contract (F1-F2):
- F1: admin-web had no check-in surface at all -> ActivityCheckinView.vue
  (paste/scan token, manual check-in, stats) + /activities/checkin route & nav.
- F2: miniapp had no cancel-registration wrapper although the endpoint existed.

Test fidelity (T1-T3):
- T1: test schema gained UNIQUE(activity_id,user_id) + chk_reg_status +
  chk_checkin_status; two new guards parse the real V010 DDL and compare.
- T2: 3 assertTrue(true) placeholders in CheckinServiceTest (covering duplicate
  check-in, expiry, no-sensitive-plaintext) replaced with 5 real guards that
  parse the real source and real production DDL.
- T3: activity_registrations + activity_staff_assignments moved into
  REQUIRED_IN_TEST_SCHEMA; MigrationValidationTest baseline 39 -> 40.

Note: ActivityStaffAssignment must NOT extend BaseEntity - the table is a
composite PK (activity_id, operator_id); BaseEntity would add id/updated_at/
deleted_at to every INSERT and break on PostgreSQL (caught by
EntityMigrationParityTest immediately).

Tests: ActivityPhase15Test (21), CheckinServiceTest (5), 2 new constraint
guards. Negative verification: 3 injected defects -> 8 assertions turned red,
all restored.

Regression: 474 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS
(baseline 450). admin-web vue-tsc 0 error, vite build OK.
2026-09-17 15:07:52 +08:00
WorkBuddy 755a570849 fix(activity): close Phase 14 KEEP-VERIFY/EXTEND — 11 backend + 3 frontend-contract + 2 test-fidelity defects
Phase 14 (multi-type public-welfare activities) verification closure.

Backend (D1-D11):
- D1 CRITICAL: ActivityMapper.countRemainingCapacity had no @Select and no XML
  -> activity detail / register / approve all threw Invalid bound statement.
  Removed the shell; unified on ActivityRegistrationMapper.
- D2 CRITICAL: register & approve were read-then-write -> oversold under
  concurrency. Replaced with single atomic SQL (insertIfCapacityAvailable /
  approveIfCapacityAvailable, affectedRows==0 => full).
- D3 HIGH: activityType / orgRole never validated against production
  chk_activity_type / chk_org_role -> 500 only on PostgreSQL.
- D4 HIGH: ActivityListItemDTO had no activityType -> miniapp category tabs
  always empty (Gate failure).
- D5: male/female counts were fabricated as approvedCount/2; now real
  aggregation over user_profiles.gender.
- D6: minAge/maxAge was a TODO no-op; added assertAgeInRange.
- D7: registration_policy write-only; per-type eligibility now enforced
  (identity / local eligibility / gender quota).
- D8: POST /operator/activities dropped type, multi-org and capacity groups
  and set organizer_org_id to the operator user id; now delegates to
  createActivityFromAdmin (single implementation).
- D9: three operator write endpoints were mounted on the user-side
  ActivityController (dual implementation, zero callers) -> moved.
- D10: NPE on soft-deleted activity in getMyActivities; NPE/500 on missing
  admin form fields (Instant.parse(null)); tolerant time parsing added.
- D11: DTO lacked registeredCount / status / coverFileId / description
  -> card always showed 0 registered, "view detail", no cover, no summary.

Frontend contract (F1-F3):
- F1: miniapp category code "classroom" is not in the production whitelist
  (should be relationship_education); three types had no tab at all.
- F2: ui-activity-card read 7 fields that do not exist on the backend;
  now reads only real DTO fields, plus per-type badge and eligibility hint.
- F3: admin-web had no activity management page at all; added
  ActivityManagementView.vue (type-driven dynamic form, multi-org,
  capacity groups, publish/cancel) + /activities route and nav entry.

Test fidelity (T1-T2):
- T1: deleted two assertTrue(true) placeholder tests (testNoOverbooking,
  testNoDuplicateRegistration) that covered the exact broken invariants.
- T2: test schema lacked V023 columns/constraints and three tables; the
  activity tables were only in the OPTIONAL set (zero coverage) -> schema
  completed and moved to REQUIRED_IN_TEST_SCHEMA.

Tests: ActivityPhase14Test (23 cases), ActivityConstraintGuardTest (9 cases,
parses real db/migration/V*.sql plus miniapp source). Two negative-verification
rounds proved both guard families actually turn red.

Regression: 450 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS
(baseline 420). admin-web vue-tsc 0 error, vite build OK.
2026-09-17 14:02:00 +08:00
WorkBuddy 7a2693b239 fix(counseling): close Phase 13A KEEP-VERIFY/EXTEND — 9 real defects + 4 test-infra defects
Phase 13A(婚恋咨询与婚前辅导)复验关闭。历史 ✅ 不可信:counseling 模块存在双实现,
线上权威 CounselingService(167 行)零测试,唯一被测的 CounselingServiceApp(280 行)
是孤儿类(无注入点、无 HTTP 端点可达,生产永不执行)→ 已合并并删除。

生产缺陷(D1~D9):
- D1 Critical 生产故障 · INV-013 泄露:咨询正文 userRequestSummary 原文被写入 WARN 日志
  (同 Phase 10 D2 一族)→ 改为只记 serviceId + riskLevel,绝不记正文。
- D2 High 越权:申请 userId 取自请求体而非认证主体 → Controller 强制 principal.id()。
- D3 High 并发:分配用「先读后写」→ 两管理员并发分配静默覆盖;
  改 assignIfPending 条件更新(WHERE status='pending' AND assigned_operator_id IS NULL),
  affectedRows=0 明确报错。
- D4 High 越权读:counseling_manager 经 getDetail 可读 followups 正文 → INV-013 绕过通道;
  manager 只加回 userRequestSummary,不装配 followups。
- D5 Medium:无状态机 → 「已完成」可被再取消;新增 ALLOWED_TRANSITIONS + assertTransition。
- D6 Medium 生产故障:GET /counseling/services/intro 后端不存在(且被 /services/{id} 吞掉)
  → 小程序 intro 页 100% 无数据;补端点并声明在变量路径之前。
- D7 Medium 契约:Admin 调用 5 条不存在的 /services/operator/*(404)+ 缺四态
  → 后端补 8 个运营端端点(@PreAuthorize),前端整体重写 + 注册 /counseling 路由。
- D8 Medium 契约:小程序字段名错(userRequestSummary/preferredServiceMode)+ 无幂等
  + my-counseling 未检查 success(403 静默空列表)+ 跳转不存在的页 + WXML 内调用方法。
- D9 Medium 测试保真度:测试库 counseling 两表零 CHECK 约束,
  counseling_services 未列入 EntityMigrationParityTest 任何集合(= 零覆盖)。

测试基建缺陷(T1~T4,不修即恒真假绿):
- T1 守卫找不到 DDL:surefire workingDirectory = ${project.basedir}/.. 使 CWD 为仓库根,
  且 backend/ 是子目录非祖先 → 改为候选后缀 + 逐级向上定位,失败大声抛异常。
- T2 全限定 @com.baomidou...TableField(exist=false) 漏解析(假漂移警报)→ 正则放宽 + 回归守卫。
- T3 OperatorEndpointPermissionCoverageTest 扫到私有辅助方法(假阳性)→ 只扫描真实端点 + 自检。
- T4 两处测试自身逻辑缺陷:陈旧内存快照比较、Instant 纳秒 vs DB 微秒精度。

迁移:新增 V039(counseling 幂等键 + 三个 CHECK 白名单显式固化),基线 38 → 39。

负向验证两轮(均已还原,grep -c NEG-VAL = 0):
- ① DDL 常量守卫:从 V039 移除 'scheduled' → 常量守卫精准变红
  (证明解析的是真实 DDL 而非副本)。
- ② 行为守卫:注入「并发回退先读后写 / manager 读 followups / 状态机守卫失效」
  → 三个用例各自精准变红。

测试:新增 CounselingPhase13ATest(25)+ CounselingConstraintGuardTest(5,解析真实 DDL);
删除孤儿类及其 3 个 DTO、删除测死代码的 CounselingDataScopeTest。
全量回归:Tests run: 420, Failures: 0, Errors: 0, Skipped: 3 / BUILD SUCCESS(4:49)。
基线 391 → 420(净 +29)。admin-web vue-tsc EXIT=0;miniapp tsc EXIT=0。

Tag: phase13a-recovery-2026-09-17
Report: docs/Phase-13A-V11-Recovery-Completion-Report-2026-09-17.md
2026-09-17 01:25:12 +08:00
WorkBuddy 5b3bad326e fix(matchmaker,referral): close Phase 13 KEEP-VERIFY/EXTEND — 7 real defects
KEEP-VERIFY/EXTEND 复验(历史 ✅ 不可信;测试覆盖函数 ≠ 覆盖运行时路径)。
逐条核对运行时路径与生产约束后修复 7 处缺陷,其中 3 处「只在真实 PostgreSQL 上炸」。

D1 (Critical, 生产故障)
  matchmaker_recommendations.status 代码写 liked/passed/confirmed_mutual,
  而 V009 chk_rec_status 只允许 pending_view/viewed/responded 且从未扩展
  → 真实 PG 上用户接受/跳过推荐必崩。测试库未复刻该 CHECK,故长期静默。

D2 (Critical, 生产故障)
  控制器缺省 serviceType="recommendation" 不在 chk_matchmaker_service_type
  白名单 → 提交红娘服务必崩。
  D1+D2 同修于新增 V038__align_matchmaker_status_constraints.sql
  (沿用 V018/V020/V025 的 DROP CONSTRAINT IF EXISTS + 重加白名单惯例)。

D3 (High, 越权写)
  MatchmakerServiceService.recommendPartner 无 assignedOperatorId 归属校验,
  且可把用户推荐给自己 → 补校验 + 禁止自我推荐。

D4 (High, 越权读)
  getServiceDetail 无归属校验且返回完整用户资料(userId/description/
  userProfile/followups)→ 签名加 requesterId,按调用者返回完整/脱敏视图。

D5 (High, 越权写)
  ReferralService 的 userId 取自请求体且零授权校验 → 新增
  assertMatchmakerServesUser,在 referToServiceStation/referToActivity 入口
  强制校验存在进行中的服务关系。

D6 (High, 生产故障)
  MatchmakerFollowupMapper.findByServiceId 只有声明无 @Select
  → Invalid bound statement (not found),getServiceDetail 必崩。
  该缺陷由本轮新增测试首次暴露(历史测试从未调用该路径)→ 补 @Select。

D7 (Medium, 契约缺口)
  admin-web 5 处 API 缺口(4 条 404 + C 端端点被运营端误用)+ 小程序缺转介服务层
  → 后端补 GET /operator/matchmaker/recommendations 与
  GET /referrals/operator/history 两个运营端端点;前端改路径、
  assign 改 POST + body operatorId;新增 miniapp/services/referralService.js。

测试保真度
  - 测试库补 matchmaker_services/recommendations/followups 三表(此前该模块零真实覆盖),
    约束与 V038 对齐;EntityMigrationParityTest 三表移入 REQUIRED_IN_TEST_SCHEMA;
    MigrationValidationTest LATEST_MIGRATION 37 → 38。
  - 新增 MatchmakerReferralPhase13Test(17 用例,@SpringBootTest 走真实方法路径 + 真实落库)。
  - 重写 MatchmakerServiceTest(6 用例):直接解析 db/migration/V*.sql,按文件名顺序应用
    DROP CONSTRAINT IF EXISTS + ADD CONSTRAINT 取"最终有效"白名单,并内置解析器自检。
    【关键】首版守卫硬编码白名单副本,注入 D1 后仍全绿(对漂移零反应)→ 必须解析真实 DDL。
  - 删除 3 处假测试占位(MatchmakerServiceTest.testMatchmakerCannotLike 的 assertTrue(true);
    CoreInvariantsTest INV-007 断言的是状态常量、INV-010 亦为 assertTrue(true)),
    已如实改名并注明行为验证位置。

负向验证(两轮)
  注入 D1(回退 chk_rec_status 白名单)→ 行为守卫 DataIntegrityViolation
  + 常量守卫 Multiple Failures (3 failures) → Tests run: 23, Failures: 1, Errors: 2
  / BUILD FAILURE,守卫有效性得证。缺陷已还原。

全量回归(离线容器,实测)
  [WARNING] Tests run: 391, Failures: 0, Errors: 0, Skipped: 3
  [INFO] BUILD SUCCESS / Total time: 04:59 min
  Phase 12 基线 371 → 391(净 +20)。

报告:docs/Phase-13-V11-Recovery-Completion-Report-2026-09-16.md
Phase 13A 未自动进入,等待用户确认。
2026-09-17 00:01:53 +08:00
WorkBuddy c607e87b45 fix(relationship): Phase 12 复验 —— 修复实体↔生产迁移系统性漂移(生产故障族)/ 双路径 episode 与 NPE / 测试库缺表致首页零覆盖 / 计划项 2 未实现
KEEP-VERIFY 复验发现并修复 4 处真实缺陷:
- D1(Critical,生产故障·同一族系统性缺陷)实体与 db/migration 系统性漂移:23 表 50 列在生产
  迁移中不存在,而测试库 schema-h2.sql 手写补齐 → 全量绿=假绿。其中 11 表属"INSERT 必崩"级
  (缺 id/created_at/updated_at,被 AutoFillHandler 强制填充),含 matches.episode、user_preferences
  权重列、permissions/roles、consent_records、risk_events 等;真实 PG 上 Match 创建、择偶偏好保存、
  权限/角色写入、同意记录、风险事件全部失败。修复:新增 V037(幂等 ADD COLUMN IF NOT EXISTS
  + 存量 episode 回填)。
- D2(High)RelationshipService.createMatch 不写 episode/lastActivityAt,与 MatchService 双路径
  填充不一致;且 episode 为 null 时 m.getEpisode()+1 对 Integer 自动拆箱抛 NPE。修复:统一按
  AGENTS.md #20 推导 + null 兜底。
- D3(High)测试库缺 user_identities 等 7 表 → GET /api/v1/home 在测试环境 BadSqlGrammar,
  首页接口零回归覆盖。修复:schema-h2.sql 补齐 7 表。
- D4(Medium)Phase 12 计划项 2 实际未实现:getting_to_know 分支只产出 counseling。修复:
  HomeApplicationService 补 premarital 引导(与 counseling 并列)。

测试保真度:
- 新增永久守卫 EntityMigrationParityTest(5 用例):把"实体字段 ⊆ 生产迁移累积列"与"测试库覆盖
  关键业务表"固化为自动不变量,区分"必崩级/潜伏级";经两轮负向验证证明会变红(第一轮分级过窄
  已修正:episode 由"潜伏级"纠正为"必崩级")。
- 新增 RelationshipProgressPhase12Test(14 用例,含 8 线程并发确认)与
  HomePremaritalGuidancePhase12Test(5 用例)。
- 删除 2 个假测试类中的 assertTrue(true) 占位(AGENTS.md 明令禁止),改走真实服务方法验证。

Gate:单方确认不改变最终关系 ✅ / 双确认后才 successful ✅ / 历史 Episode 可追踪 ✅ /
新 Episode 不恢复旧聊天权限(conversations.match_id UNIQUE)✅ / 非成员不可代确认 ✅

全量回归:371 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS(71 类,5m07s)
2026-09-16 23:01:13 +08:00
WorkBuddy 52e11a6c65 fix(notification): Phase 11 复验 —— 修复通知类型违反生产 CHECK 约束(生产故障)/ 幂等无库兜底 / 婚前辅导通知未接线
复验模式:KEEP-VERIFY/EXTEND(不轻信历史 ✅,逐条核对运行时路径与生产约束)。

== D3(Critical · 生产故障)通知 type 全量违反生产 CHECK 约束 ==
NotificationService 的 10 个业务模板传入复合 type
(appointment_submitted / counseling_assigned / le_verified / referral_created 等),
而生产 V020__extend_notification_types.sql 的 chk_notification_type 白名单只允许 12 个短名。
真实 PostgreSQL 下这些通知全部落库失败;测试库 schema-h2.sql 未复刻该约束,故历史 334 测试全绿 → 假绿。
修复:
- type 归一为白名单短名,业务细分交由 businessType/title 承担:
  appointment_submitted|_confirmed|_changed -> appointment
  counseling_assigned|_scheduled|_completed  -> counseling
  premarital_scheduled                       -> premarital
  le_need_evidence|le_verified|le_rejected   -> local_eligibility
  referral_created                           -> matchmaker
- 重写 buildPrivacySafeCopy:原以复合 type 为 key 的 Map.ofEntries 归一后产生重复 key 会抛
  IllegalArgumentException(运行期崩溃),改为按短名 switch。
- schema-h2.sql 补齐生产 CHECK 约束(测试保真度下沉)。

== D1(High)通知幂等仅 SELECT-then-INSERT,无 DB 兜底 ==
NotificationMapper.findExisting 为查后插,并发下可重复落库。
修复:新增 V036__add_notification_idempotency_index.sql,建 partial unique index
uk_notification_business(user_id, type, business_type, business_id, content)
WHERE business_id IS NOT NULL(先按 created_at ASC 去重存量数据)。
schema-h2.sql 同步唯一索引(H2 无 partial index,标准 UNIQUE 对 NULL 多行共存语义等价)。

== D2(Medium)婚前辅导通知未接线 ==
premaritalScheduled 零调用;CounselingServiceApp.scheduleCounseling(P13A-T22 管理端路径)完全不发通知。
修复:CounselingService.schedule 与 CounselingServiceApp.scheduleCounseling 均按 service_type 分流,
premarital 走 premaritalScheduled,其余走 counselingScheduled;
管理端路径包 try/catch —— 通知失败不回滚排期事实。

== 测试(基线 334 → 346,+12) ==
- NotificationPhase11IntegrationTest(5):回滚不发通知(Gate 前半)、提交后可见、
  顺序重试幂等、绕过 findExisting 由唯一索引兜底抛异常且最终仅 1 条、business_id IS NULL 多行共存。
- NotificationPrivacyCopyTest(5):反射校验 4 类隐私文案,咨询类不含「咨询」且标题为「服务通知」,
  不泄露姓名/时间/证件类型;未知 type 安全回退;ALLOWED_TYPES 守卫。
- NotificationTemplateTypeGuardTest(2):关键 —— 调用模板方法本身(而非直接传字面量 send),
  断言 10 个模板全部以白名单短名落库。
- MigrationValidationTest:LATEST_MIGRATION 35 -> 36,DDL 校验补 CREATE UNIQUE INDEX 识别。

== 负向验证(两轮,含第一轮失效教训) ==
第一轮:注入复合 type 后 NotificationPhase11IntegrationTest 仍全绿 ——
  因该测试直接 send(..., "appointment", ...) 传字面量,不经过模板方法,属无效守卫。
第二轮:改为 NotificationTemplateTypeGuardTest 调用模板本身,注入后精准抛
  DataIntegrityViolation,守卫有效性得证。教训已写入报告:守卫必须经负向验证证明会变红。

== 证据 ==
全量回归 346 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS
(离线 Docker:danyang-match-backend-test:latest + 卷 danyang-m2-cache)
迁移校验 12/12;定向 19/19。

== Gate 判定 ==
回滚事务不发通知 ✅ | 通知失败不回滚核心事实 ✅ | 隐私安全文案 INV-010 ✅
已提交事务通知失败可重试且不产生重复业务记录 ✅(修复后成立)

== 遗留 gap(登记不自动实现) ==
sendExternal 在事务内同步执行外部副作用,当前为占位实现;接入真实微信推送前必须改为
@TransactionalEventListener(AFTER_COMMIT) / REQUIRES_NEW —— 全仓当前无任何 afterCommit 机制。
2026-09-16 21:50:56 +08:00
WorkBuddy fd7195ecb0 fix(safety): Phase 10 复验 —— 修复图片上传魔数校验死代码(生产故障)/ 高风险词写入日志
对 Phase 10(Image & Chat Safety)执行 KEEP-VERIFY 复验:不采信历史 ✅,逐条核对
图片私有访问、类型/大小/魔数校验、审核与水印、风险识别/举报/违规处理、日志脱敏。
Gate 判定通过,发现并修复 2 处缺陷,并推翻旧报告 2 项安全声明。

D1(Critical,生产故障)图片上传的类型与魔数校验整条是死代码
  - PhotoService.uploadAndSave 用 `header = new byte[Math.min(12, stream.available())]`
    仅分配零值缓冲,从不调用 stream.read(...) → validateMagicNumber 对任何文件都收到
    12 个 0x00 → 恒返回 false → 恒抛"无效的图片文件头"。
  - 影响链(已确认线上触发):小程序 local-eligibility-supplement.ts 以
    photoType='local_eligibility' 调 POST /photos/upload → 本地资格佐证上传 100% 失败。
  - 次生问题:isAllowedMimeType(image-only 白名单)在 local_eligibility 分支之后
    无条件执行,即使魔数修好,application/pdf 仍被挡,而 FeatureFlagConfig
    的 localEvidenceAllowedTypes 明确允许 pdf;另有未 mark 的 stream.reset() 无效调用。
  - 测试为何没发现:PhotoServiceTest 只对 validateMagicNumber 传手写字节做单测,
    从不经过 uploadAndSave,该路径缺陷完全无覆盖。
  - 修复:改用 try(InputStream){ header = s.readNBytes(12); } 真正读取;
    MIME 校验按 photoType 分流(local_eligibility 走自己的白名单含 pdf);
    魔数校验仅对图片执行、PDF 跳过;移除死 reset();上传用全新流与校验流隔离;
    generateFileKey 重载按 Content-Type 推断扩展名,PDF 不再拿到误导性 .jpg key。

D2(Low)高风险词写入普通日志
  - ChatSafetyService.scanContent 把命中的高风险词原样写入 log.warn,
    与同文件 CONTACT 分支"只记 pattern 不记内容"矛盾,且违反本阶段 Gate。
  - 修复:改为 log.warn("Chat content matched HIGH_RISK keyword (blocked)")。

推翻的旧声明(docs/Phase-10-Completion-Report.md)
  - 「伪装恶意图片被拒(Magic Number校验)」不成立:该校验运行时恒失败,
    连合法图片也一律被拒(fail-closed 到不可用),安全属性并非由它提供。
  - 「EXIF清理预留(PhotoService.stripExif占位)」不存在:全仓库无该符号。
    且 getUploadToken 客户端直传路径服务端无法介入清理 EXIF。
  - 已在旧报告顶部加状态注记,指向本次复验报告。

测试
  - 新增 PhotoUploadValidationTest(6 用例),用 MockMultipartFile 真正走一遍
    uploadAndSave:合法 JPEG/PNG 通过且确实调用 putObject、伪造 Content-Type 被魔数挡、
    PDF 佐证不被 image-only 挡且 key 以 .pdf 结尾、非法 MIME 被拒、超大被拒。
  - 负向验证:临时把魔数守卫改为 if(false && ...) → 恰好 spoofedContentTypeRejectedByMagic
    一个用例变红,其余 5 个仍绿;随后还原并 grep 确认无 NEGATIVE-CHECK-TEMP 残留。
  - 全量回归(离线容器 danyang-match-backend-test,mvn -o test):
    334 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS(基线 328,+6)。

登记 gap(不自动实现,待产品确认)
  - 图片水印:全仓库无实现;主计划未列硬需求,仅"开发执行计划修订版"提"高级动态水印"。
  - EXIF 清理:旧声明不存在;客户端直传路径服务端无法介入,需改为接收侧重编码。
  - 直传凭证路径(getUploadToken)无服务端内容校验且不落 DB 记录 → 后续 getSignedUrl
    归属校验会查不到 UserPhoto 行,属契约层缺口,超出本阶段安全 Gate 范围。
  - 违规自动升级:ChatSafetyService 注释明确属 Phase 17 范围,当前 high 仅拦截不落库。

文档:新增 Phase-10-V11-Recovery-Completion-Report-2026-09-16.md;
旧报告加状态注记;主计划 Phase 10 复选框置 ✅;STATUS / JIRA_BACKLOG 同步(334 tests)。

Phase 11 未启动,按计划每阶段出报告后停止等待确认。
2026-09-16 21:00:52 +08:00