# Phase 18 — 申诉 KEEP-VERIFY 复验完成报告 **日期**:2026-09-17 **范围**:申诉(appeal 模块、Miniapp appeal 页面、后台 AppealCenter) **纪律**:KEEP-VERIFY。历史 ✅ 不可信;测试覆盖 ≠ 运行时路径覆盖;逐条核对运行时路径与生产约束;发现真实缺陷即修;补测试保真度;守卫必须解析真实 DDL;离线容器真实编译+执行;每阶段出报告后停止等确认。 --- ## 一、结论 **Gate 达成,复验关闭。** | Gate | 结果 | |---|---| | 申诉通过只解除目标处置 | ✅ `approveAppeal` 只把 `relatedActionId` 指向的处置置 `resolved`;用户其他仍有效的处置保持 `active`(负向验证已证守卫有牙) | | 拒绝后原处置保持有效 | ✅ `rejectAppeal` 不变更 `risk_action`,原处置维持 `active`,账号仍受限 | | 首发由 Feature Flag 控制 | ✅ `features.appeal-enabled`(dev/docker 均 `false`);关闭时 `submitAppeal` 抛"申诉功能暂未开放" | **本轮修复真实缺陷 8 处(含 1 处必崩级测试盲区、2 处「只在 PG 上炸」的生产故障)**,新增集成测试 15 例,删除假绿测试 9 例。 - 全量回归:**494 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**(约 4.4 分) - 基线 488 → 494,**净 +6**(−9 假绿 `AppealServiceTest`,+15 `AppealPhase18IntegrationTest`) - 负向验证:临时让 `approveAppeal` 误伤用户其他有效处置 → `approveResolvesOnlyTargetActionAndKeepsOthersActive` **精准变红**,已还原 - admin-web `vue-tsc --noEmit` **EXITCODE=0** --- ## 二、本轮最关键发现(必记) ### F1:申诉整条真实 DB 路径「零覆盖」盲区(必崩级) `appeals` 表只列在 `EntityMigrationParityTest.TEST_SCHEMA_OPTIONAL_TABLES` 白名单里,而 H2 `schema-h2.sql` **根本没有该表** → `submitAppeal` / `processAppeal` / `approve` / `reject` / `escalate` 整条真实 DB 路径**零覆盖**。 更严重的是 `AppealServiceTest`:9 个用例中 **6 个是 `assertTrue(true)` 假绿**,而它们"覆盖"的恰好是 Gate 四条不变量(仅 ban/suspend 可申诉、幂等、通过解除、拒绝维持);另有 1 个用 `@Mock` 断言 Mockito 默认值(`testFeatureFlagDefaultDisabled` 测的是 mock 不是真实默认值)。 **这是「白名单 = 无人测 + 占位假绿」模式的第八次复现。** ### F2:`escalate` 向 operator 发用户通知 → 撞 FK(只在 PG 上炸) `escalateAppeal` 调用 `notificationService.send(operatorId, ...)`,但 `operators` 与 `users` 是**两张独立表**(V001),`notifications.user_id` 有 `FOREIGN KEY ... REFERENCES users(id)`(V004,从未移除)→ 在 PG 上必然 FK 违约,`@Transactional` 整体回滚 → **升级人工 100% 失败(500)**。测试库 notifications 无 FK,故零覆盖。 ### F3:`appealType` 未校验白名单(只在 PG 上炸) `submitAppeal` 直接透传 `dto.getAppealType()`,生产 `chk_appeal_type CHECK (appeal_type IN ('ban_appeal','suspend_appeal'))` 会拒绝非法值 → 500。测试库无 `appeals` 表故零覆盖。 --- ## 三、真实缺陷清单(对应 Gate) | 编号 | 类别 | 缺陷 | 修复 | |---|---|---|---| | D1 | 测试盲区/必崩级 | `appeals` 表缺失 + `AppealServiceTest` 6 处 `assertTrue(true)` 假绿 → 申诉真实 DB 路径零覆盖 | 补 `appeals` 表(对齐 V013,去外键留两 CHECK);移出白名单入 `REQUIRED_IN_TEST_SCHEMA`;删除假绿测试,新建 `AppealPhase18IntegrationTest`(15 例) | | D2 | 生产故障(PG) | `escalateAppeal` 向 `operatorId` 发用户通知 → 撞 `notifications.user_id → users(id)` FK → 升级事务回滚 | 移除非法通知,升级后进入后台待办队列由风控员决策 | | D3 | 生产故障(PG) | `appealType` 未校验 `chk_appeal_type` 白名单;且未与处置类型一致性校验 | 校验白名单 + 必须与处置类型匹配;缺省按处置类型推导 | | D4 | 功能死胡同 | `escalated` 状态无法再处理(`processAppeal` 仅接受 pending/reviewing)→ 升级后申诉永久滞留;前端 `AppealCenterView` 处理按钮也仅 `pending` | 后端原子认领放行 `escalated`;`getPendingAppeals` 返回 pending+escalated;前端放开处理入口 | | D5 | 并发 | `processAppeal` 先读后写无原子守卫 → 两运营并发处理同一申诉可相互覆盖(approve/reject 竞态) | 新增 `markReviewingIfProcessable` 原子认领(`WHERE status IN ('pending','escalated')`),仅赢家继续 | | D6 | 数据一致性 | `AppealMapper.findByUserId` / `findPending` / `findByStatusPaged` 未过滤 `deleted_at`(`countByStatus` 有) | 三处补 `deleted_at IS NULL` | | D7 | 可维护性 | `approveAppeal` 硬编码 `"resolved"` | 改用 `Constants.RISK_ACTION_STATUS_RESOLVED` | | D8 | 前后端契约 | miniapp 传 `evidenceFileIds`,后端只读 `evidence` → 证据字段名不匹配、永为 null | DTO 增 `evidenceFileIds`,服务层序列化映射进 `evidence` | > 说明:`RISK_ACTION_STATUS_REVOKED`(`"revoked"`)在 `Constants` 中定义但**从无使用点**,且不在生产 `chk_risk_action_status`(`active/resolved/expired`)白名单内;`approveAppeal` 实际使用 `resolved`(正确)。JIRA 旧注"申诉通过 → status→revoked"与实现不符,已在文档中校正为 `resolved`。 --- ## 四、负向验证(守卫有牙) - 临时在 `approveAppeal` 中追加"把用户所有 active 处置一并置 resolved"(模拟"绕过其他处置")→ 定向运行 `AppealPhase18IntegrationTest` → **恰好 1 条用例精准变红**:`approveResolvesOnlyTargetActionAndKeepsOthersActive` 报 `expected: but was: `("其他仍有效的处置不得被解除(Gate)")。已还原,`grep NEG-VAL` = 0。 --- ## 五、证据(实测,非声明) - **全量回归**:`494 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS`(离线容器 `danyang-match-backend-test` `mvn -B -o test`,约 4.4 分)。 - **定向编译验证**:`mvn -B -o test-compile` 通过(284 主源 + 78 测试源)。 - **前端类型检查**:admin-web `vue-tsc --noEmit` EXITCODE=0。 - **新增测试**:`backend/src/test/java/com/danyang/match/platform/appeal/AppealPhase18IntegrationTest.java`(15 例:真实落库/审计、仅 ban/suspend 可申诉、幂等、**Gate A 通过只解除目标处置**、**Gate B 拒绝维持原处置**、原子认领唯一赢家、二次处理被拒、escalate 后可再决策、appealType 白名单/一致性/缺省推导、证据字段映射、软删过滤、Feature Flag 关闭拦截)。 - **删除**:`backend/src/test/java/com/danyang/match/platform/appeal/AppealServiceTest.java`(6 假绿 + mock 默认值断言)。 --- ## 六、改动文件清单 **主代码(src/main)** - `appeal/service/AppealService.java` — 白名单校验、原子认领、escalate 去非法通知、常量替换、证据映射、软删过滤、escalated 可再决策。 - `appeal/mapper/AppealMapper.java` — 三处补 `deleted_at IS NULL`;新增 `findPendingOrEscalated` 与 `markReviewingIfProcessable`。 - `appeal/dto/AppealSubmitDTO.java` — 新增 `evidenceFileIds`(对齐 miniapp 契约)。 - `shared/constant/Constants.java` — 新增 `APPEAL_TYPE_BAN` / `APPEAL_TYPE_SUSPEND`。 **测试(src/test)** - `appeal/AppealPhase18IntegrationTest.java` — 新增 15 例。 - `appeal/AppealServiceTest.java` — 删除(假绿)。 - `deployment/EntityMigrationParityTest.java` — `appeals` 移出白名单、入 `REQUIRED_IN_TEST_SCHEMA`(Phase 18 注释)。 - `resources/schema-h2.sql` — 补 `appeals` 表。 **前端** - `admin-web/src/modules/appeal/AppealCenterView.vue` — escalated 申诉放开处理入口(列表按钮 + 详情决策表单)。 **文档** - `docs/STATUS.md`、`docs/JIRA_BACKLOG.md`、`docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md` 的 Phase 18 段。 - `docs/Phase-18-V11-Recovery-Completion-Report-2026-09-17.md`(本报告)。 - 技能 `danyang-platform-verification` 同步更新。 --- ## 七、下一步 **Phase 19(统一运营后台 + 用户端公益服务页面 — ADD/REFACTOR)未自动进入,等待用户确认后启动。** 是否继续?