63c18d89c21cade8101fb7cce8f098f83e332af9
60
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 主源。 |
||
|
|
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 直发客户端不可达)留待下一轮「改缺陷」处理。 |
||
|
|
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)。 |
||
|
|
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)。
补跑全量回归 + 负向注入前,本项记为「待验证」,不得计入已验收。
|
||
|
|
60c591fc68 |
V1.0.1 纠偏 DoD 补齐:canonical key 字面化(V051+KEY_ALIASES)+ 照片并发突破实测(锁用户行+锁后计数)+ AGENTS 规则27 + known-risks
按文档 DoD/约束逐条复查后补齐的差距(承接
|
||
|
|
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 |
||
|
|
6e76e2f2ea | feat(account): 数据保留流程落地(36 个月下限,V049)—— closed_at + 人工审核/幂等匿名化/审计 + 运营端点 + 守卫 5 用例;测试库补 user_photos,MyBatis null 更新修复 | ||
|
|
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 勾选 |
||
|
|
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),全绿 |
||
|
|
28cf25756a |
test(e2e-recon): E2E 场景对账表(32 项:31 项有真实覆盖、1 项被裁定取代)+ 补 /me/status.localEligibilityStatus 零断言缺口
- 对账表写入收尾计划 Task 2.1 节:首页 11 / 本地资格 11 / 权限 10 逐项映射到
E2E 用例与后端集成测试;原始清单取自 commit
|
||
|
|
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。 |
||
|
|
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 绿。
|
||
|
|
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,否则全量回归报「版本号超出当前基线」。
该守卫同时断言「迁移文件总数 == 基线」(不允许缺号),是有效守卫 ——
正确做法是更新常量,不要为了让测试变绿而改断言方式。
|
||
|
|
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/* 多套并存端点,后端有真实实现 ≠ 用户能走到。
|
||
|
|
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 会得出与生产相反的结论。 |
||
|
|
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 不可用、本机全量回归会卡住)。 |
||
|
|
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 侧零处作查询条件。 |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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).
|
||
|
|
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.
|
||
|
|
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). |
||
|
|
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). |
||
|
|
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 |
||
|
|
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. |
||
|
|
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 | ||
|
|
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 | ||
|
|
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) | ||
|
|
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. |
||
|
|
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. |
||
|
|
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
|
||
|
|
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 未自动进入,等待用户确认。
|
||
|
|
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) |
||
|
|
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 机制。 |
||
|
|
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 未启动,按计划每阶段出报告后停止等待确认。 |
||
|
|
03a03c7c11 |
fix(chat): Phase 09 复验 —— 修复 WebSocket 下行死链路 / Unmatch 后可发消息 / Block 未落库 / 聊天 IDOR
对 Phase 09(Text Chat)执行 KEEP-VERIFY/EXTEND 复验:不采信历史 ✅,逐条核对
REST 发送 + WebSocket 接收、幂等、重连、未读计数、非 active Match 禁聊、Unmatch/
Block 即时禁聊、活动双确认会话复用。共发现并修复 4 处真实缺陷 + 1 处规范不符。
D1(Critical)WebSocket 下行整条链路是死代码
- 无 HandshakeInterceptor,无任一环节把 userId 写入 session attributes,
extractUserId 恒返回 null;sendToUser/sendToConversation 全仓库零调用点。
- 新增 ChatHandshakeInterceptor:从 ?token= 或 Authorization: Bearer 解析 JWT,
校验通过后写 session attr userId;失败 fail-closed 拒绝握手;仅接受 type=access。
- 重写 ChatWebSocketHandler:按规范 {event,data} 信封推送;未认证连接直接
close(NOT_ACCEPTABLE);同账号重连由新连接接管、旧连接 close(NORMAL);
上行仅支持 ping->pong,禁止客户端自订阅。
- ChatService.sendText 落库+递增未读后推送 message.new;RiskNotice 同推。
- 移除 WebSocketConfig 的 .withSockJS()(SockJS 改写端点致原生 WS 无法直连)。
D2(Critical)Unmatch 后仍可发消息
- unmatch 只改 Match,不联动会话;sendText 仅校验 conv.status==active。
- 新增 ChatService.assertMatchAlive(conv):按 matchId 查 Match,状态非
{active,getting_to_know} 抛 "匹配已解除,无法发送消息"。sendText 在 conv.status
校验之后调用。
- MatchController.unmatch 落库后调 chatService.syncFromMatch 推 conversation.closed。
- syncFromMatch 重写:alive 恢复 active;非 alive 只推 conversation.closed,
不改 status(会话仍以灰显态留在列表,属未授权产品决策,登记待决)。
D3(Critical)RelationService.block 调静态 MatchService.unmatch 只改内存态不落库
- 结果:Block 后 matches.status 仍为 active。
- 补 matchMapper.updateById(m) 持久化 + chatService.closeConversationForMatch(
CONVERSATION_STATUS_BLOCKED) 通知对端。RelationshipService.block 同步补会话关闭通知。
D4(Critical 安全)聊天 4 个接口无成员校验 -> IDOR 越权读私聊
- MessageController.load/markRead、ConversationController.detail/unread 补
chatService.isMember(conversationId, userId) 校验;非成员返回
ApiResponse.notFound()(body 级 success=false code=NOT_FOUND,HTTP 200,
与 MatchController.authorizedMatch 同约定),不区分"不存在"与"无权"以防探测。
D5(Medium)上行订阅/信封不符规范
- REST API 接口详细设计规范.md 第124-126章要求禁止客户端自订阅,事件名
message.new/message.read/conversation.closed,信封 {event,data}。已对齐。
测试保真度 + 测试补齐
- schema-h2.sql 新增 uk_message_client_id(sender_id, client_message_id),
对齐生产 V004:71;H2 不支持过滤索引故省略 WHERE(NULL 语义等价)。
否则 service 的 SELECT-then-INSERT 竞态在测试中无 DB 兜底,形成测试盲区。
- 新增 ChatPhase09IntegrationTest(13 用例):幂等重试不重复投递、推送信封字段、
未读增减清零、Match unmatched 拒发、Unmatch/Block 端点即时禁聊+收 closed 事件、
活动双确认复用同一消息体系、未形成 Match 无法建会话、非成员读消息/详情/未读
被拒且不泄露正文、DB 唯一索引兜底重复 clientMessageId、无幂等键系统消息可共存。
- 新增 ChatWebSocketHandlerTest(7 用例):userId 路由、信封格式、未认证关连、
断开不推、重连替换旧连接、上行仅 ping。
验证证据
- 全量回归(离线容器 danyang-match-backend-test,mvn -o test):
328 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS(基线 308,+20)。
- 负向验证:临时注释 assertMatchAlive + 两处 isMember,恰好 3 个用例变红,
证明守卫有效;随后还原并 grep 确认无残留。
文档
- 新增 Phase-09-V11-Recovery-Completion-Report-2026-09-16.md(完整报告)。
- Phase-09-V11-Completion-Report.md 顶部加状态注记,推翻其 4 条 WebSocket 结论。
- 计划/STATUS/JIRA_BACKLOG 同步 Phase 09 完成态与测试总数(328)。
Phase 10 未启动,按计划每阶段出报告后停止等待确认。
|
||
|
|
4e478ce6ba |
fix(invariants): Phase 08 复验 —— 修复 INV-004 搜索/详情绕过 Block + matches.source 取值越界
复验(KEEP-VERIFY)在既有 Phase 08 实现中发现 3 处真实缺陷并修复:
D1(严重)DiscoverService.searchUsers 不含查看者身份,查询条件既无排除自己也无任何 Block 条件,被拉黑者仍可被搜到并点开 —— INV-004「搜索不可见」整体失效。改为接收 viewerUserId,排除自己,并用新增的 UserRelationMapper.findBlockedUserIdsBothWays(UNION 双向)一次取回后 notIn 批量排除,避免 N+1。
D2 详情路径(DiscoverService / UserProfileService)均无 Block 判断,知道 userId 即可读到被拉黑者资料。两条路径统一加双向判断并返回 null,由 Controller 转 404(不用「已隐藏」以免被用来探测存在);UserProfileService 中该判断排在隐私设置之前,拉黑不能被对方「允许搜索可见」覆盖。
D3(潜伏,仅 PostgreSQL 暴露)RelationshipService.like() 把关系类型值 "liked" 当 match source 写入 matches.source,违反 V003:91 的 chk_match_source。测试库 schema-h2.sql 该列是裸 VARCHAR(32)、无约束,故长期静默通过。改传 MATCH_SOURCE_USER_LIKE。canonical 路径 RelationService.like 传值本就正确,线上未触发。
测试保真度对齐:schema-h2.sql 补 chk_match_source(与 V003 逐字对齐)与 user_profiles 表(此前测试库无此表,资料查询无法端到端测试)。
新增 DiscoverBlockInvisibilityIntegrationTest(9 用例,@SpringBootTest 真实 H2 + 真实 SQL),覆盖正向/反向拉黑、对称性、排除自己、详情 404 与查自己不受限。P0FixVerificationTest 补 source 回归守卫。DiscoverServiceTest 同步构造签名。
负向验证:临时还原 D3 修复后,4 个用例因 CHK_MATCH_SOURCE 约束违反报错,证明守卫有效。
证据:后端全量 308 tests / 0 failures / 0 errors / 3 skipped(基线 299);UI 契约 13/13 GREEN。报告 docs/Phase-08-V11-Recovery-Completion-Report-2026-09-16.md。
同步 STATUS.md / JIRA_BACKLOG.md(EPIC-P08-V11 置 ✅)/ 客户需求基线计划 Phase 08 复选框;首轮报告加状态注记(其「Known Issues 无」已被推翻)。.gitignore 忽略 .workbuddy/。
|
||
|
|
bc4531a734 |
feat(api): 补齐 /me/* 命名空间,恢复 17 个 404 调用 + 修 /me/status 过期桩
背景:小程序 services/*.js 按 REST 规范(§16 / §22-§40 / §80 / §83-§84)统一调用
/me/*,但后端从未注册 /me 基路径,个人资料、照片、择偶条件、隐私设置、关系状态、
我的举报/申诉等约 17 个调用全部 404。
后端
- 新增 me/controller/MeController:把既有 service 能力暴露到规范路径(纯委托,不复制
业务规则,AGENTS.md #26)。旧路径(/profile/*、/photos、/relationship/*、
/account/privacy-settings、/risk/reports/my、/appeals/my)全部保留。
- 响应形状按小程序 JSDoc 适配(小程序被 86 页引用,是权威契约):
· /me/relationship-status 返回 status(旧 /relationship/status 返回 relationshipStatus,
字段名不同,前端读的是 result.status)
· /me/profile/review-status 返回 {status, items, submittedAt, reviewedAt},时间统一 epoch 毫秒
· /me/profile/submit-review 返回 {reviewStatus, submittedAt}
- 小程序三条历史写法一并兼容:/me/privacy-settings、/me/photos/{id}/main、/me/appeals
修复
- AuthService.getMeStatus:verified / profileStatus / reviewStatus 三项写死为
false / not_started / not_submitted,注释标注"Phase 03/04/05 填充",但那三个 Phase
早已交付,导致 /me/status 长期返回错误的实名与审核状态。改为 capabilityService
+ userProfileMapper + reviewTaskMapper 的真实来源(与首页聚合同一判定)。
- PhotoController.deletePhoto:原用 ((Map<?,?>) p).get("id") 强转 UserPhoto 实体,
必然抛 ClassCastException,删除照片恒定 500。改用实体主键比对。
- PhotoService.setMainAvatar(新增):数据模型以 photo_type='avatar' 标识头像(见
UserPhotoMapper#findAvatar),无 is_main 字段,故"设主图"= 换类型并保证至多一张。
注意:插入该方法时曾把 deletePhoto 原有的 @Transactional 挤到新方法上,导致
"Transactional is not a repeatable annotation" 并引发 Lombok 全面停摆(满屏
找不到 getter/setter),已修正注解归属。
验证
- 后端全量:299 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS(+2 为新增契约测试)
- 新增 MeRouteContractTest(2 例):同时断言 HTTP 方法,锁定 21 条 /me/* 与 13 条旧路径
- 小程序 tsc --noEmit 通过;UI 契约门禁 13/13
文档
- STATUS.md:/me/* 从"待裁决"改为"已完成";核心待清债务 1 项 → 0 项
- API_INVENTORY.md:顶部"待收敛"警告改为"已收敛",补全 /me/* 端点表
|
||
|
|
3244e02e7e |
refactor(api): 关系/匹配域采用 2026-09-14 canonical 路径,消除三代并存
## 问题:同一能力存在三代路径,前端指向的是已废止的一代
按 mtime 排查后发现"最新"并非上一轮以为的 /relations/*,而是 2026-09-14 的 match 模块:
| 代际 | 位置 | 日期 | 路径 |
|---|---|---|---|
| canonical | platform.match: Like/Block/MatchController | 09-14 12:13~12:14 | /likes /blocks /matches |
| 二代 | platform.relationship: Relationship(Status)Controller | 09-10 10:59 | /relations/* /relationship/* |
| 一代(规范) | REST API 接口详细设计规范.md | 08-28 | /relationships/*(多一个 hips) |
| 前端 | miniapp/services/matchService.js | 08-29 | 混用二代 + 一代 |
后果:缘分三榜、黑名单页、成功牵手流程全部 404;另有两份重复 success 实现 + 一份桩实现。
## 裁决:采用 2026-09-14 最新路径,前端与二代让位(用户指令)
### 补齐 canonical 模块缺口(此前只有 javadoc 声明或服务方法,无控制器)
- RelationService.unlike() + LikeController `DELETE /likes/{targetUserId}`
- 新增 PassController `POST /passes`(RelationService.pass 在 09-14 模块内却无控制器,补齐与 /likes、/blocks 对称)
- BlockController `GET /blocks`(javadoc 早声明"我的 Block 列表"但未实现,mapper findBlockedByUser 已就绪)
- MatchController `POST /matches/{id}/success/request|confirm|decline`(javadoc 标"Phase 12 预留"),
委派真实实现 RelationshipStatusService;authorizedMatch 对"不存在"与"无权"统一回 404,避免探测他人 Match
- LikeResponse 对齐前端 LikeResult:{ relationStatus, mutualMatch, matchedConversationId, matchId }
Mutual 时调 ChatService.getOrCreateConversation 取会话 id → "双向喜欢→跳聊天"链路真正打通
- LikeController/PassController/BlockController 补 targetUserId 空值校验
### 移除被取代的路由与桩实现
- RelationshipController 移除 like/cancelLike/pass/block/unblock/matches/unmatch/success,仅保留 /fate/*
- RelationshipStatusController 移除 /success/*(已由 /matches/{id}/success/* 取代),保留关系状态生命周期
- RelationshipService 删除 requestSuccess/confirmSuccess 桩 ——
前者自述 "TODO: 查询 relationship_success_requests 表" 却返回硬编码 status="pending",
等于绕过 AGENTS 硬规则 6「成功牵手必须双确认」
- 注意:RelationshipService.like/block/pass 被 P0FixVerificationTest /
RelationshipBlockPriorityPhase08Test 直接测试,方法体保留
### 列表信封统一
- CursorPage 从 chat.dto 提升到 shared.dto(消除 match 模块反向依赖 chat DTO)
- /relations/fate/mutual|liked|likers 与 GET /matches 改返回 { items, nextCursor }
### 前端对齐
- matchService.js 全部路径改到 canonical;补 declineSuccess
- matchStore 补 _decorateBlockedList,修复"模板渲染 blockedAtText 但全仓从未计算"的缺陷
(与上一轮 lastMessageAtText 同源,同类字段一律在 store 装饰一次)
### 文档
- docs/api/API_INVENTORY.md 关系/缘分/匹配三节重写为 canonical 路径;
顶部加 /me/* 路径族显式警告(小程序按规范写 /me/*,后端实为 /profile/*、/photos、/relationship/*,
约 15 个端点会 404,收敛方向未裁决,防止后续照抄踩坑)
- docs/STATUS.md:Backend 回归债务由「待清(277 tests 4F/8E)」更新为「已清偿(297 tests 全绿)」
## 验证
- 新增 CanonicalRouteContractTest(3 例):用 RequestMappingHandlerMapping 断言
canonical 路径已注册、被取代的旧路径不再注册、关系状态生命周期保留 —— 路由再漂移即红
- 后端 297 tests, 0 failures, 0 errors, 3 skipped, BUILD SUCCESS
- 小程序 tsc --noEmit 通过
|
||
|
|
687a156350 |
fix(chat): 会话/消息响应契约对齐小程序,修复聊天页字段断链
## 问题(三处真实断链)
后端 chat 模块字段名与小程序契约不一致,但自动化测试此前未覆盖,故一直未被发现:
1. 会话 DTO 用 otherUserId/otherDisplayName/otherAvatarUrl,
而前端(types/index.d.ts + pages/chat/chat.ts + components/conversation-item)
只读 targetUserId/targetDisplayName/targetAvatarUrl。
→ chat.ts 取不到对方 userId(无兜底,落到空串),
导致"查看资料 / 拉黑 / 举报"点击后提示"暂无更多信息"并失效。
2. 消息响应只给 createdAt(ISO 字符串),前端读 Message.sentAt: number(epoch 毫秒)。
→ chat.ts 时间分隔 ts 恒为 0,消息时间分隔与时间文案不显示。
3. 会话对象缺 relationStatus,前端 conversation-item 依赖它做灰显与"已拉黑"提示。
## 裁决依据
按"以文件修改时间为准"的裁决统一到前端约定:
- 前端 types/index.d.ts 被 86 个页面引用,是声明式契约
- pages/chat/chat.ts(09-04)是最新的真实消费者
- targetUserId 已是项目内既有惯例(ActivityMatchItemDTO)
- admin-web 不消费 /conversations,无第二方受影响
## 变更
- ConversationDTO:字段改名 target*;新增 relationStatus;lastMessageAt 改 epoch 毫秒
- ChatService.toConversationDTO:同步映射;relationStatus 按 Block > unmatched > mutual_match > neutral 推导
- Constants:新增 RELATION_STATUS_NEUTRAL / MUTUAL_MATCH / UNMATCHED(不硬编码)
- MessageResponse:新增 sentAt(epoch 毫秒)
- miniapp/utils/datetime.js(新增):收敛时间格式化,
补上模板依赖但全仓从未计算的 lastMessageAtText
- messageStore:会话进入 store 时统一装饰 lastMessageAtText
- chat.ts:_formatTime 改为复用共享工具;移除已失效的 otherDisplayName 兜底
## 验证
- 新增 ChatContractJsonTest(4 例)锁定字段名、sentAt 数值类型、
并断言 other*/status 旧字段名不再出现
- 后端 294 tests, 0 failures, 0 errors(原 290 + 新增 4)
- 小程序 tsc --noEmit 通过
遗留(未在本轮处理):平台尚未实现头像 URL 解析(user_photos 只存 fileKey),
targetAvatarUrl 保持 null,前端 ui-avatar 回退昵称首字,不影响展示。
|
||
|
|
eaf8fdf195 |
refactor(backend): P0 阻断修复 + 按最新修改时间收敛重复模块与契约
## P0 阻断修复
- 编译:修复实体类与服务类同名导致 Lombok 注解轮次中止的问题
(MatchmakerService/CounselingService -> *Record),196 个编译错误归零
- 权限矩阵:platform_admin 按设计文档收紧为拒绝 5 项高危权限(不改测试阈值)
- 启动:修复 8 组 MyBatis-Plus 同名 bean 冲突(ConflictingBeanDefinition)
与 URL 路由歧义(Ambiguous mapping),应用可正常启动
## 模块收敛(裁决规则:以文件修改时间为准)
- messaging 包整体删除,能力迁移至 chat 包
* 会话列表/详情/未读端点、ConversationDTO、CursorPage
* 消息游标分页统一为 { items, nextCursor }
- relationship 的 Match/UserRelation entity 与 mapper 合并进 match 包
- MatchmakerUserController 合并进 MatchmakerController,删除冗余 Controller
- 补齐 INV-004:chat sendText 增加双向 Block 校验,拉黑后禁止发送
## 契约对齐
- 红娘服务提交请求兼容前端 { requirement } 字段
- 消息与会话分页契约前后端统一
## 测试
- 迁移 6 个风控用例至 ChatSafetyServiceTest
- 清理 messaging 测试包遗留:6 个真实常量断言归位 chat 包,
移除 2 个 assertTrue(true) 占位假测试(Block 真实行为已由
P0FixVerificationTest / RelationshipBlockPriorityPhase08Test 覆盖)
- schema-h2.sql 对齐 PostgreSQL 真实表结构(matches 补列、
conversation_members 改复合主键)
结果:290 tests, 0 failures, 0 errors, 3 skipped, BUILD SUCCESS
|
||
|
|
918368159c |
feat(phase-13A): CounselingService — INV-012/013 + 婚恋咨询 + 婚前辅导
Phase 13A Counseling — ADD new domain module per AGENTS.md rule #21/#22.
counseling module (5 files):
- Entity: CounselingService -> counseling_services
- Entity: CounselingFollowup -> counseling_followups
- Mapper: CounselingServiceMapper (findFull + findSanitized INV-013)
- Service: CounselingService
INV-012: assertAssigned() guards schedule/complete
INV-013: getDetail() returns sanitized for matchmaker/operator,
full for user/assigned-counselor/manager
- Controller: 7 endpoints (user/apply/list/detail, counselor/assigned/schedule/complete, manager/assign)
Permission model (Service layer guarded):
User(self) -> full detail
Assigned Counselor -> full detail
counseling_manager -> full detail
Matchmaker/Operator -> sanitized only (no user_request_summary)
service_type: counseling(婚恋咨询) / premarital(婚前辅导)
|
||
|
|
5cbd931fa2 |
feat(phase-13): MatchmakerService — referral + manual recommendation + user confirmation
Phase 13 Matchmaker — EXTEND per JIRA. P13-V11-T01 through P13-V11-T05.
New matchmaker module (5 files):
- Entity: MatchmakerService (request), MatchmakerRecommendation (manual rec)
- Mapper: MatchmakerServiceMapper, MatchmakerRecommendationMapper
- Service: MatchmakerService
T01 createReferral(type=counseling) — referral only, does NOT submit on behalf
T02 createReferral(type=activity)
T03 createReferral(type=service_station)
T04 referral_source追溯: every referral carries operator_id + service_id + type
T05 用户拥有最终决定权: acceptRecommendation() calls RelationService.like()
as the USER, not the matchmaker. Matchmaker NEVER creates Match directly.
If mutual, updates Match.source='matchmaker' for traceability.
- Controller: user endpoints (submit/list/pending-recommendations/accept/pass/viewed)
Core AGENTS.md #5 compliance:
- Matchmaker can only 'recommend', NEVER 'like on behalf of'
- User must actively accept recommendation (calls acceptRecommendation -> RelationService.like)
- createRecommendation creates pending_view record + notification, nothing else
- confirm mutual only when BOTH users independently Like (existing Like flow)
Status machines:
- matchmaker_services: submitted -> assigned -> processing -> completed
- matchmaker_recommendations: pending_view -> viewed -> liked/passed/confirmed_mutual
|
||
|
|
0418856ce3 |
feat(phase-12): RelationshipStageService — home service suggestions
Phase 12 Relationship — KEEP/EXTEND per JIRA. 不增加复杂状态. New RelationshipStageService (match module): P12-V11-T01 Successful match -> premarital_suggestion P12-V11-T02 GettingToKnow match -> matchmaker + counseling P12-V11-T03 7-day silence in GettingToKnow -> counseling_recommendation Rules are simple, based on existing Match.status + lastActivityAt: - successful -> 婚前辅导建议 - getting_to_know -> 红娘服务 + 婚恋咨询建议 - getting_to_know + 7d silence -> 婚恋咨询促活建议 - no Match -> 默认红娘 + 婚恋课堂 Integration point: HomeApplicationService.buildRecommendations() just inject RelationshipStageService and append suggestions to home DTO. |
||
|
|
e2a8a8c85f |
feat(phase-11): NotificationService — 10 business templates + privacy-safe external copy
Phase 11 Notification — EXTEND per JIRA. P11-V11-T01 through P11-V11-T11.
New notification module (4 files):
- Entity: Notification -> notifications (existing DB table)
- Mapper: findByUser/countUnread/markRead/markAllRead + findExisting(idempotent)
- Service: NotificationService with 10 business templates
T01 appointmentSubmitted T02 appointmentConfirmed T03 appointmentChanged
T04 counselingAssigned T05 counselingScheduled T06 counselingCompleted
T07 premaritalScheduled T08 leNeedEvidence T09 leVerified
T10 leRejected
T11 buildPrivacySafeCopy -> external lock-screen copy NEVER reveals
sensitive counseling type names (INV-010 compliance)
- Controller: GET list, GET unread, PUT markRead/{id}, PUT readAll
Design invariants:
- sendExternal() wrapped in try/catch -> failure does NOT rollback core service
- Idempotency via (user, type, business_type, business_id, content) dedup
- External copy uses neutral words '服务/预约' for counseling events, not '咨询'
|
||
|
|
cf3a84e725 |
feat(phase-10): Chat Safety — content scan + rate limit + RiskNotice
Phase 10 Chat Safety — KEEP per JIRA. P10-V11-T01/P10-V11-T02.
New ChatSafetyService (chat module):
- scanContent(): HIGH_RISK keyword + CONTACT regex (phone/wechat/QQ/scan)
- checkRateLimit(): Redis counter + fail-open fallback, stricter for new Match (first 5 min)
- buildRiskNoticeBody(): system message JSON with anti-fraud targetPage
ChatService.sendText() integrated Phase 10:
1. Redis rate limit check (newMatch-aware)
2. Content scan -> risk_level on Message
3. high/critical -> throw, block send
4. warning -> peer gets RiskNotice system message (P10-V11-T01: anti-fraud entry)
(P10-V11-T02: RiskNotice -> anti-fraud content page)
5. Log enriched with risk_level
Constants already had RISK_LEVEL_* and MESSAGE_TYPE_SYSTEM — no new constants needed.
|
||
|
|
eda2ce19a8 |
feat(phase-09): Text Chat full backend + WebSocket
Phase 09 Chat — KEEP per JIRA. P09-V11-T01 Activity Match source compatibility.
New chat module (11 files):
- Entity: Conversation, ConversationMember (复合主键), Message
- Mapper: ConversationMapper, ConversationMemberMapper, MessageMapper
- Service: ChatService (getOrCreateConversation/sendText/loadMessages/markAsRead/syncFromMatch)
- Controller: ConversationController (POST /from-match, GET /{id}, GET list)
MessageController (POST send, GET load history, PUT mark read)
- WebSocket: WebSocketConfig + ChatWebSocketHandler (/ws/chat, JSON protocol)
Core invariants:
- INV-005: ChatService requires Match status active/getting_to_know
- Conversation.status sync with Match.status (syncFromMatch)
- Member.canSend + Conversation.status + sender membership all validated
- Client-side idempotency via client_message_id
- P09-V11-T01: Conversation binds only match_id — works identically for
user_like / activity_mutual / matchmaker Match sources
WebSocket note: Memory-based session map; multi-node will need Redis Pub/Sub.
|
||
|
|
8b62b5b5df |
feat(phase-08): Like / Match / Block full backend implementation
Phase 08 Like/Match/Block — KEEP per JIRA, incremental P08-V11-T01. New entities (match module): - UserRelation -> user_relations (liked/passed/blocked) - Match -> matches (source: user_like/activity_mutual/matchmaker, episode-aware) New mappers: - UserRelationMapper: findOne/countBlockedEitherWay/countReverseLike - MatchMapper: findActiveBetween/findLatestBetween/findActiveByUser/findHistoryByUser New services: - RelationService: like/pass/block/unblock + mutual detection + capability guard - INV-004: Block priority highest (auto-unmatch, mutual block check) - INV-005: Like only creates Match on Mutual - MatchService (static): createOrReuseMatch factory - AGENTS.md #19: existing active Match -> reuse - AGENTS.md #20: prior unmatched -> new Match with episode+1 - source-aware: user_like / activity_mutual / matchmaker New controllers: - POST /api/v1/likes - POST /api/v1/blocks, DELETE /api/v1/blocks/{target} - GET /api/v1/matches, GET /api/v1/matches/{id}, POST /api/v1/matches/{id}/unmatch Integrates with CapabilityService for base eligibility checks on Like. |
||
|
|
dd58ab6eae |
feat(capability): extract CapabilityService — AGENTS.md rule #26 compliance
New module backend/capability/: - CapabilityService: getCapabilities(entry point for frontend), plus public helpers (isAccountNormal/isIdentityVerified/isProfileApproved/ isSearching/isBaseEligible/hasLocalEligibility/entry builder) - CapabilityController: GET /api/v1/capabilities endpoint HomeApplicationService refactored: - Injects CapabilityService, delegates buildCapabilities() and QuickEntry construction + recommendation eligibility checks - Removes duplicated private methods (isSearching/isIdentityVerified/ isProfileApproved/entry) — single source of truth now in CapabilityService Contracts updated to check CapabilityService.java for capability-related assertions (setDisabledReason/isIdentityVerified/ACCOUNT_STATUS_NORMAL) |
||
|
|
ea2bb2439d | chore: complete infrastructure config, contracts, reference images and ui redesign artifacts |