codes = entries.stream().map(HomeDTO.QuickEntry::getCode).toList();
+ // AGENTS.md 规则 15:第一排 找对象/公益红娘/公益活动/婚恋咨询;第二排 婚恋课堂/婚前辅导/婚恋反诈/善缘服务站
+ assertEquals(
+ List.of("find_object", "matchmaker", "activities", "counseling",
+ "classroom", "premarital", "anti_fraud", "service_station"),
+ codes,
+ "INV-017: 首页 8 核心入口的编码与顺序必须完全固定(实得 " + codes + ")");
+ assertEquals(8, codes.size(), "INV-017: 核心入口必须恰好 8 个");
}
+ // ==================== INV-018 ====================
+
@Test
- @DisplayName("INV-018: 服务站运营人员数据范围")
- void testStationOperatorDataScope() {
+ @DisplayName("常量口径 INV-018: 服务预约状态取值固定(行为验证见 ServiceAppointmentDataScopeTest / ServiceAppointmentOwnerGuardTest)")
+ void inv018AppointmentStatusVocabulary() {
+ assertBehaviorTestExists("com.danyang.match.platform.service_appointment.ServiceAppointmentDataScopeTest");
+ assertBehaviorTestExists("com.danyang.match.platform.service_appointment.ServiceAppointmentOwnerGuardTest");
assertEquals("submitted", Constants.APPOINTMENT_STATUS_SUBMITTED);
assertEquals("confirmed", Constants.APPOINTMENT_STATUS_CONFIRMED);
assertEquals("completed", Constants.APPOINTMENT_STATUS_COMPLETED);
assertEquals("cancelled", Constants.APPOINTMENT_STATUS_CANCELLED);
}
+ // ==================== INV-019 ====================
+
@Test
- @DisplayName("INV-019: 内容编辑员零用户数据权限")
- void testContentEditorZeroUserAccess() {
+ @DisplayName("常量口径 INV-019: 内容状态取值固定(行为验证见 V11PermissionMatrixContractTest 的角色基线用例)")
+ void inv019ContentStatusVocabulary() {
+ assertBehaviorTestExists("com.danyang.match.platform.security.V11PermissionMatrixContractTest");
+ assertEquals("draft", Constants.ARTICLE_STATUS_DRAFT);
assertEquals("published", Constants.ARTICLE_STATUS_PUBLISHED);
assertEquals("offline", Constants.ARTICLE_STATUS_OFFLINE);
assertEquals("scheduled", Constants.ARTICLE_STATUS_SCHEDULED);
}
@Test
- @DisplayName("INV-020: 组织关系不自动授予数据权限")
- void testOrgRelationshipNoAutoPermission() {
+ @DisplayName("INV-019: 内容编辑员角色不得持有任何用户婚恋数据权限(真实角色-权限基线)")
+ void inv019ContentEditorHasNoUserDataPermission() {
+ // 与 INV-013 相互印证:content_editor 既读不到咨询详情,也不在咨询可见角色集合内
+ CounselingServiceRecord svc = counselingService.apply(
+ USER, Constants.COUNSELING_TYPE_EMOTIONAL_SUPPORT, "内容编辑员不应看到的正文",
+ "user_self", "online", "工作日晚间", null, null);
+ assertNull(counselingService.getDetail(CONTENT_EDITOR, "content_editor", svc.getId()),
+ "INV-019: 内容编辑员不得读取用户婚恋咨询数据");
+ }
+
+ // ==================== INV-020 ====================
+
+ @Test
+ @DisplayName("常量口径 INV-020: 账号状态取值固定(行为验证见 AuthServiceAccountClosedGuardTest / V11MethodSecurityMatrixTest)")
+ void inv020AccountStatusVocabulary() {
+ assertBehaviorTestExists("com.danyang.match.platform.auth.AuthServiceAccountClosedGuardTest");
+ assertBehaviorTestExists("com.danyang.match.platform.security.V11MethodSecurityMatrixTest");
assertEquals("normal", Constants.ACCOUNT_STATUS_NORMAL);
assertEquals("banned", Constants.ACCOUNT_STATUS_BANNED);
assertEquals("suspended", Constants.ACCOUNT_STATUS_SUSPENDED);
}
+ // ==================== 常量完整性(回归保护) ====================
+
@Test
@DisplayName("V1.1常量完整性: 所有新增枚举值存在且非空")
void testV11ConstantsExist() {
@@ -160,6 +289,20 @@ class V11InvariantsTest {
Constants.COUNSELING_STATUS_CLOSED
};
assertEquals(7, statuses.length);
- assertEquals(7, (long) Arrays.stream(statuses).distinct().count());
+ assertEquals(7, java.util.Arrays.stream(statuses).distinct().count(),
+ "咨询状态常量不得出现重复值(手工维护易复制粘贴出错)");
+ }
+
+ // ==================== 辅助 ====================
+
+ /**
+ * 断言「行为验证见 XxxTest」指向的测试类真实存在。
+ *
+ * 本文件曾用常量断言冒充不变量验证,且未指明真正的验证位置。
+ * 这里把指针变成可执行的契约:一旦被引用的测试类被重命名或删除,本用例立即失败。
+ */
+ private void assertBehaviorTestExists(String fqcn) {
+ assertDoesNotThrow(() -> Class.forName(fqcn),
+ "被引用的行为验证测试类必须存在: " + fqcn);
}
}
diff --git a/docs/JIRA_BACKLOG.md b/docs/JIRA_BACKLOG.md
index f7561d5..40fd870 100644
--- a/docs/JIRA_BACKLOG.md
+++ b/docs/JIRA_BACKLOG.md
@@ -179,7 +179,8 @@
| Phase 19 统一运营后台 + 用户端公益服务页面复验 | 已完成(2026-09-17) | 见 `Phase-19-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **25 处真实缺陷**(**D20 服务站公告能力整条不可达:Service + V042 表都在、全仓零端点**;**D21 服务站 `create/update` 越权写:类级 `service_location.view` 兜底,`service_location.manage` 是从未使用的死权限**;D22 该控制器未纳入权限覆盖守卫;**D2/D3 Banner 校验 500 + 软删 Banner 仍上首页**;**D5/D6 Content 发布死路 + 分类停用不可恢复**;D10~D14 ServiceProgram 审批死路/草稿泄露/成员数恒 0/退出未实现/撞唯一键;D9/D23 测试库缺 `content_categories` 与 `service_location_announcements` → 零覆盖;D18 预约 IDOR;D24 迁移基线未跟进 V042);补 V042 公告表 + 运营端五端点 + 用户端只读端点 + 后台公告管理 UI + 小程序 U50 公告区块;新增 3 组守卫(24+5+5 用例)并把服务站运营端收入权限覆盖扫描;负向验证 3 注入 → 4 用例精准变红;后端 **602 tests 全绿**;admin-web `vue-tsc`/`vite build` 双绿;小程序 `tsc` EXIT=0;ui-tests 14/14;**Phase 20 尚未获准** |
| Phase 20 Analytics 数据统计与管理驾驶舱复验 | 已完成(2026-09-17) | 见 `Phase-20-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **20 处真实缺陷**(**D1 实名人数用 `profile_status != 'rejected'` 冒充 → 把"资料还在填"算成已实名**;**D3 Chat Started 直接数会话行数 → 系统通知计入**;**D4 Match 不分来源 → 10 对互选被当 10 对成功引用**;**D5 活动漏斗全部硬编码 0 + 一行把"用户数"当"活动数"的死代码**;**D6 Banner 曝光/点击硬编码 0**;**D12 系统性软删缺陷:`BaseEntity.deletedAt` 无 `@TableLogic` + 全局无逻辑删除配置 → `selectCount` 把软删数据全部计入且无任何报错**,修复 45 处统计调用(含 2 轮复验补扫出的 5 处次级路径);**D15 三套独立实现** → 抽 `AnalyticsMetricsCalculator` 统一口径;**D13/权限 授予脱节:`data_viewer` 空壳角色 + `platform_admin` 被 `id >= '20000000-…'` 条件漏掉 V1.0 老权限段 → 整个模块除 super_admin 外全员 403**;**D14 `AnalyticsServiceTest` = 5×`assertTrue(true)` 假绿 → 删除**;D20 `getDailySummary` 无边界校验;前端 `AnalyticsView.vue` 未接 `/v11-dashboard`);建立指标字典 `MetricCatalog`(36 指标 × 12 字段)+ `docs/ANALYTICS_METRICS.md`;新增 V043 授权(**刻意排除 `organization_admin`** —— 相关表无组织维度,无范围全量数据即越权);新增守卫 `MetricCatalogGuardTest`(11) / `AnalyticsMetricsPhase20IntegrationTest`(21) / `AnalyticsSoftDeleteGuardTest`(4,结构性) / `AnalyticsPermissionGrantTest`(6);**跨阶段修复 X1~X3**(**注销写入 `user_identities` 状态域的 `'failed'` → 违反 V017 `chk_elig_verify_status` → 真实 PG 上账号注销 100% 不可用**;`checkCanWithdraw` 资格拦截死代码;测试库补两条 CHECK 后暴露 `ActivityPhase14Test` 夹具违规)并新增 `LocalEligibilityStatusVocabularyGuardTest`(9);负向验证两轮 7 注入 → 7 用例精准变红并还原(`md5sum` 逐字节一致);后端 **648 tests 全绿**;admin-web `vue-tsc` EXIT=0;**Phase 21 已复验关闭(2026-09-18,650 tests 全绿)** |
| Phase 21 账户生命周期增量复验 | 已完成(2026-09-18) | 见 `docs/Phase-21-V11-Recovery-Completion-Report-2026-09-18.md`;KEEP-VERIFY 复验发现 STATUS 标✅ 但含 **6 处真实缺陷 + 假绿测试**:① `AuthService.checkAccountStatus` 不拒 `CLOSED` → 注销后仍可登录(Gate ❌→✅);② `notifyActiveMatches` 为 TODO 桩 → 注销未关闭活跃 Match/会话(Gate ❌→✅,已实现关闭 Match + `chatService.closeConversationForMatch`);③ `cancelPendingActivityRegistrations` 为 TODO 桩 → 未来活动报名未关闭(Gate ❌→✅);④ `anonymizeServiceHistory` 已实现却**从未被 `closeAccount` 调用(死代码)** → 个人/咨询正文暴露(Gate ❌→✅,已接线 + 脱敏身份证占位符 + `counseling_followups` 正文);⑤ `closeAccount` 在 blockers 处硬抛异常使 `cancelXxx` 永远不可达(死代码矛盾 → 信息性日志 + 继续清理);⑥ 删除 7×`assertTrue(true)` 假绿 → 替换为 11 用例真实 DB 集成测试 + 3 用例登录守卫;跨模块防线(推荐引擎/匹配资格/Capability 已拒非 NORMAL)确认有效;后端 **650 tests 全绿**(起点 648,净 +2);**Phase 22 已复验关闭(2026-09-18,657 tests 全绿)** |
-| Phase 22 部署运维配置扩展(Feature Flag)复验 | 已完成(2026-09-18) | 见 `docs/Phase-22-V11-Recovery-Completion-Report-2026-09-18.md`;KEEP-VERIFY 复验发现 STATUS 标✅ 但含 **2 处真实代码缺陷 + 2 处文档夸大**:① D-22-A(High)`FeatureFlagController.updateFlag` 仅支持布尔 flag(反射 `getMethod("set"+cap, boolean.class)`)→ 7 个非布尔 flag 经 API 更新必失败,已改为按字段类型分派 boolean/int/String;② D-22-B(Medium)`body.getOrDefault("value", false)` 缺 value 静默置 false + 非布尔转型崩,已改为缺 value/类型错→400 且不改原值;T01–T05 模块开关(`isCounselingEnabled` 等)全仓无业务调用点 → 死配置(暴露未 gate,遗留交各模块 Phase);T06 上传策略实测已在 `LocalEligibilityService`+`PhotoService` 真强制 ✅;T07 私有桶零引用 → 死配置(遗留);yml 无 `features:` 块、默认值实为 Java 字段默认(文档纠正);新增 `FeatureFlagControllerTest`(7 用例)+ 负向验证(仅布尔缺陷版恰 2 用例变红);后端 **657 tests 全绿**(起点 650,净 +7);**Phase 23 尚未获准** |
+| Phase 22 部署运维配置扩展(Feature Flag)复验 | 已完成(2026-09-18) | 见 `docs/Phase-22-V11-Recovery-Completion-Report-2026-09-18.md`;KEEP-VERIFY 复验发现 STATUS 标✅ 但含 **2 处真实代码缺陷 + 2 处文档夸大**:① D-22-A(High)`FeatureFlagController.updateFlag` 仅支持布尔 flag(反射 `getMethod("set"+cap, boolean.class)`)→ 7 个非布尔 flag 经 API 更新必失败,已改为按字段类型分派 boolean/int/String;② D-22-B(Medium)`body.getOrDefault("value", false)` 缺 value 静默置 false + 非布尔转型崩,已改为缺 value/类型错→400 且不改原值;T01–T05 模块开关(`isCounselingEnabled` 等)全仓无业务调用点 → 死配置(暴露未 gate,遗留交各模块 Phase);T06 上传策略实测已在 `LocalEligibilityService`+`PhotoService` 真强制 ✅;T07 私有桶零引用 → 死配置(遗留);yml 无 `features:` 块、默认值实为 Java 字段默认(文档纠正);新增 `FeatureFlagControllerTest`(7 用例)+ 负向验证(仅布尔缺陷版恰 2 用例变红);后端 **657 tests 全绿**(起点 650,净 +7);**Phase 23 已复验(2026-09-18,部分关闭)** |
+| Phase 23 可信系统测试重建(REFACTOR)复验 | ⚠️ 部分关闭(2026-09-18) | 见 `docs/Phase-23-V11-Recovery-Completion-Report-2026-09-18.md`;KEEP-VERIFY 复验发现 STATUS 标✅ 但含 **5 处真实缺陷**:① **D-23-A(Critical)** `V11InvariantsTest` 原 14 个用例全为常量字符串断言 —— INV-016「Banner 目标类型」实际断言**文章状态**常量(跑错域)、INV-017「首页 8 入口」是**对自己刚声明的字面量数组做 `length==4` 的重言式**(从未调用首页服务)、INV-018/019/020 用预约/文章/账号状态常量冒充权限与数据范围验证;已重写为 12 个用例,含 **4 个真实服务行为守卫**(INV-013 走真实 `CounselingService.getDetail` 断言 Matchmaker 读到 `userRequestSummary==null` 并以 assigned Counselor 能读正文作反证;INV-016 反射读生产 `ALLOWED_TARGET_TYPES` 断言不含外链;INV-017 真实 `getHome` 断言 8 入口编码与顺序固定;INV-019 断言 `content_editor` 读到 null)+ `assertBehaviorTestExists` 用 `Class.forName` 把「行为验证见 XxxTest」变成可执行契约;② **D-23-B(High)** `tests/e2e/` **44 个 `pass` 空壳**(4 个文件 100% 为空壳,另 2 个混合),违反 Gate「不允许 skip/pass 空壳」→ 已移除并改为诚实占位(保留完整场景清单);③ **D-23-C(High)** pytest E2E 套件**整体不可执行**:全仓无 `conftest.py`,约 15 个 fixture(`api_client`/`verified_user`/`banner_factory`…)不存在,唯一可执行的 `run_v11_tests.py`(16 用例)需活栈 + `E2E_PASSWORD`;④ **D-23-D(High,遗留未修)** CI 未串联 E2E(计划明确要求)→ 需先决定 CI 活栈供给方式,未实施;⑤ **D-23-E(Medium)** CI `migration` job 注释称「验证 SQL 语法」实为只查文件非空(真实语法校验在 `MigrationValidationTest`,随 backend job 跑)→ 改为真实序列校验(命名/非空/版本号唯一/无缺号,43 个文件 1~43 连续唯一)。**新增闸门**:`scripts/check-no-empty-tests.py`(AST 扫描阻断 `pass` 空壳与无条件 `skip`,已双向验证)+ CI `test-shell-guard` job。**证据**:负向注入 3 处生产缺陷 → 恰好 `inv013`/`inv016`/`inv017` 3 个用例精准变红、其余 9 绿、零无关失败,`md5sum -c` 还原一致;`655 tests / 通过 652 / 失败 0 / 错误 0 / 跳过 3`(起点 657,净 −2 因 14 空转用例合并为 12;3 个跳过均有显式原因,非空壳)。**遗留**:E2E 入 CI 待基础设施决策、pytest fixture 体系缺失、INV-011 缺显式守卫。**结论:不宣布「失败为零可进入 UAT」**;**Phase 24 尚未获准** |
| UI-CLEANUP-20260913 | 已完成 | 废弃 UI 代码、冲突规范、旧计划和无效验收证据已清理;`ui-document-baseline-contracts` 锁定唯一视觉入口 |
| UI-ADMIN-20260913 | 已完成(待桌面端目视复验) | 管理端全局框架与工作台已按规格交付;18 modules + 22 routes,vue-tsc 与 vite build 全绿 |
| UI-MINIAPP-20260913 | 已完成 | 小程序 11 个核心页面已重构;11/11 页 TS-only,2026-09-16 通过用户验收 |
diff --git a/docs/Phase-23-V11-Recovery-Completion-Report-2026-09-18.md b/docs/Phase-23-V11-Recovery-Completion-Report-2026-09-18.md
new file mode 100644
index 0000000..8f63fdc
--- /dev/null
+++ b/docs/Phase-23-V11-Recovery-Completion-Report-2026-09-18.md
@@ -0,0 +1,164 @@
+# Phase 23 — 可信系统测试重建(REFACTOR)KEEP-VERIFY 复验报告
+
+- **日期**:2026-09-18
+- **执行方式**:KEEP-VERIFY(历史 ✅ 不可信,逐条核对运行时路径与真实约束)
+- **结果**:**部分关闭(PARTIAL CLOSED)** —— 核心假覆盖已清除并补真实守卫;CI 串联 E2E 一项因需基础设施决策,列为遗留
+- **回归**:**655 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**(起点 657)
+
+---
+
+## 一、Gate 要求的测试报告
+
+计划 Phase 23 Gate:「**不允许 skip/pass 空壳;报告列出总数、通过、失败、跳过及原因,失败为零才进入 UAT。**」
+
+| 项 | 数值 |
+|---|---|
+| 总数(后端 `mvn -o test`) | **655** |
+| 通过 | **652** |
+| 失败 | **0** |
+| 错误 | **0** |
+| 跳过 | **3** |
+
+### 跳过明细及原因(均有显式声明,非空壳)
+
+| 测试类 | 跳过数 | 原因(代码内原文) |
+|---|---|---|
+| `concurrent.ConcurrentTests` | 2 | `@EnabledIfEnvironmentVariable(named = "CI", matches = "true", disabledReason = "本地并发测试跳过")` —— 仅在 CI 环境执行的并发竞态用例 |
+| `core.CoreE2ETest` | 1 | `@Disabled("需要完整数据库连接,仅验证 Spring Context 可加载")` |
+
+> 数量说明:起点 657 → 655,净 **−2**。
+> 原因:`V11InvariantsTest` 原有 **14 个**空转用例被重写合并为 **12 个**(INV-014 状态机 / INV-014 意向 / INV-015 三合一)。
+> 合并后 10 个不变量(INV-011~020)**全部仍有对应用例**,其中 **4 个为真实服务行为验证**。
+
+---
+
+## 二、真实缺陷清单
+
+### D-23-A(Critical):`V11InvariantsTest` 全部 14 个用例只断言常量字符串,且不变量归属错误
+
+计划 Gate 明文禁止:「INV-001~020 必须测试真实服务行为,不能只断言常量字符串存在」。
+
+实测原文件问题:
+
+| 用例 | 声称验证 | 实际断言 | 判定 |
+|---|---|---|---|
+| INV-016「Banner 目标类型验证」 | Banner `target_type` 白名单 | `ARTICLE_STATUS_PUBLISHED/DRAFT`(**文章状态**) | ❌ 完全跑错域 |
+| INV-017「首页 8 核心入口编码固定」 | 首页入口顺序 | 声明局部数组后 `assertEquals(4, firstRow.length)` | ❌ 对自己刚写的字面量做断言(重言式),**从未调用首页服务** |
+| INV-018「服务站数据范围」 | 服务站数据范围 | `APPOINTMENT_STATUS_*`(预约状态) | ❌ 无权限/范围判定 |
+| INV-019「内容编辑员零用户数据」 | 零用户数据权限 | `ARTICLE_STATUS_*`(文章状态) | ❌ 无权限判定 |
+| INV-020「组织关系不授权」 | 组织关系权限 | `ACCOUNT_STATUS_*`(账号状态) | ❌ 无权限判定 |
+| 其余 9 个 | 各自不变量 | 仅 `assertEquals(常量, 字面量)` / `assertNotNull(常量)` | ❌ 不触碰任何服务行为 |
+
+**修复**:重写为 12 个用例 ——
+
+- **真实服务行为(4 个)**
+ - `inv013MatchmakerCannotReadCounselingBody`:真实 `CounselingService.getDetail`;断言 Matchmaker 读到的 `userRequestSummary` 为 `null`,**并以 assigned Counselor 能读到正文作反证**(防止「全局脱敏」造成的假阳性),并断言无关角色返回 `null`。
+ - `inv016BannerTargetTypeWhitelistExcludesExternal`:反射读取生产 `HomeBannerOperatorController.ALLOWED_TARGET_TYPES`,断言含 8 个站内类型且**不含任何 external/http/url/web 外链类型**。
+ - `inv017QuickEntriesFixedOrder`:真实 `HomeApplicationService.getHome(userId)`,断言 8 个入口编码与顺序完全等于 AGENTS.md 规则 15 的顺序。
+ - `inv019ContentEditorHasNoUserDataPermission`:真实 `getDetail` 断言 `content_editor` 读到 `null`。
+- **诚实常量口径(8 个)**:沿用 `CoreInvariantsTest` 既有模式,标题标注「常量口径 … 行为验证见 XxxTest」,并新增 `assertBehaviorTestExists(fqcn)` —— **用 `Class.forName` 断言被引用的真实测试类确实存在**,把文档指针变成可执行契约,防指针腐烂。
+
+### D-23-B(High):`tests/e2e/` 下 44 个 `pass` 空壳(Gate 明令禁止)
+
+| 文件 | 空壳数 / 总测试数 | 说明 |
+|---|---|---|
+| `local_eligibility_test.py` | 11 / 11 | 100% 空壳 |
+| `counseling_permission_test.py` | 10 / 10 | 100% 空壳 |
+| `activity_mutual_consent_test.py` | 8 / 8 | 100% 空壳 |
+| `service_appointment_test.py` | 7 / 7 | 100% 空壳 |
+| `home_v11_test.py` | 7 / 9 | 混合(另 2 个亦不可执行,见 D-23-C) |
+| `counseling_v11_test.py` | 1 / — | 注释自述「在当前测试框架下简化验证」,函数体 `pass` |
+
+**修复**:6 个文件全部改写为**不含任何测试函数的诚实占位**(pytest 收集 0 用例),并在文件头完整保留原场景清单(需求不丢失)+ 说明「后端已有哪个真实测试覆盖该不变量」,避免把「删除空壳」误读为「删除需求」。
+
+### D-23-C(High):pytest E2E 套件整体不可执行
+
+- 全仓库**无 `conftest.py`**,`api_client` / `verified_user` / `banner_factory` / `counselor_client` / `user_a` 等约 15 个 fixture **根本不存在** → 依赖它们的用例连收集都会报错。
+- 唯一真实可执行的 E2E 是 `tests/e2e/run_v11_tests.py`(16 个用例,真实 HTTP 打 `localhost:8085`,需活栈 + `E2E_PASSWORD`)。
+- 因此 STATUS 宣称的「10 个 E2E 文件、~80+ 测试用例」**从未产生过一次真实执行结果**。
+
+### D-23-D(High,遗留未修):CI 未串联 E2E
+
+计划要求「CI 串联迁移、backend test、admin type-check/build、miniapp type-check 和 **E2E**,任何失败阻断发布」。
+实测 `.github/workflows/ci.yml` 只有 backend / admin-web / miniapp / migration 四个 job,**完全没有 E2E**。
+
+**未在本轮修复**,原因:E2E 需要可用的活栈(后端 + PostgreSQL + 种子数据 + `E2E_PASSWORD`),在 CI 中如何供给属基础设施决策,且本地无法验证其正确性 —— 按「不静默扩权 / 不写无法验证的测试」纪律,列为遗留,交由决策后再实施。
+本轮已改为接入**可验证**的 `test-shell-guard` job(见下)。
+
+### D-23-E(Medium):CI 的 migration job 是名不副实的假校验
+
+原 job 注释写「验证 migration SQL 语法(通过检查文件是否存在且非空)」,实际代码只 `if [ ! -s "$f" ]` 判非空 —— **没有任何语法校验**。
+而真正的语法 / 命名 / 基线表校验在后端测试 `MigrationValidationTest` 中,随 backend job 的 `mvn verify` 已运行。
+
+**修复**:改为该测试未覆盖的**序列校验**(命名合规 / 非空 / 版本号唯一 / 无缺号),并如实改写注释说明职责边界。
+
+---
+
+## 三、本轮新增的可验证产物
+
+| 产物 | 作用 | 验证方式 |
+|---|---|---|
+| `backend/.../invariants/V11InvariantsTest.java`(重写) | INV-011~020 真实行为 + 指针契约 | 12 用例全绿;负向注入 3 处 → 精准 3 红 |
+| `scripts/check-no-empty-tests.py` | AST 扫描,机械阻断 `pass` 空壳与无条件 `skip` | 真实目录 0 处;注入后精准报 2 处并 EXIT=1 |
+| `.github/workflows/ci.yml` | 新增 `test-shell-guard` job;`migration` job 改为真实序列校验 | YAML 解析通过(5 job);内联校验脚本抽取实跑通过 |
+
+---
+
+## 四、验证证据
+
+### 1. 负向验证(证明新守卫非假绿)
+
+同时注入 3 处生产缺陷:
+
+| 注入点 | 注入内容 | 预期变红用例 | 实际 |
+|---|---|---|---|
+| `HomeBannerOperatorController.ALLOWED_TARGET_TYPES` | 增加 `"external_url"` | `inv016` | ✅ 变红:`发现: [external_url]` |
+| `HomeApplicationService.buildQuickEntries` | 交换 `classroom` / `premarital` 顺序 | `inv017` | ✅ 变红:实得 `[..., premarital, classroom, ...]` |
+| `CounselingService.getDetail` 的 `isMatchmaker` 分支 | 改为返回全文 `attachFollowups(full)` | `inv013` | ✅ 变红:`expected: but was: <不愿被红娘看到的求助正文>` |
+
+结果:**12 用例中恰好 3 个变红,其余 9 个通过,零无关失败**;`md5sum -c` 确认 3 份生产文件还原一致。
+
+### 2. 空壳守卫双向验证
+
+- 扫描真实 `tests/`(14 个 py 文件):`OK: 未发现 skip/pass 空壳` EXIT=0
+- 注入 `docstring + pass` 与 `@pytest.mark.skip` 各一:精准报出 2 处并 EXIT=1
+
+### 3. CI 校验
+
+- `ci.yml` YAML 解析通过,job 为 `backend / admin-web / miniapp / migration / test-shell-guard`
+- 从 `ci.yml` 中**原样抽取**内联迁移校验脚本并执行:`OK: 43 个迁移文件,版本 1~43 连续且唯一`
+
+### 4. 全量回归
+
+`655 tests, Failures: 0, Errors: 0, Skipped: 3` — **BUILD SUCCESS**
+
+---
+
+## 五、遗留(未静默扩权)
+
+1. **E2E 未真正进入 CI**(D-23-D):需先决定 CI 中活栈的供给方式(docker-compose / service container / 种子数据与 `E2E_PASSWORD` 注入)。
+2. **pytest fixture 体系缺失**(D-23-C):6 个 E2E 文件现为诚实占位;若要恢复执行,需先建 `conftest.py` 与 fixture 工厂。
+3. **INV-011 缺少显式守卫**:`LocalEligibilityService` 全部方法以 `userId` 自限定、零 role 分支,佐证无独立服务端读取入口(仅经受权限控制的 `ReviewController` 审核队列),**结构性满足**;但无测试固化,列为待补。
+4. **E2E 场景待真实实现**:场景清单已完整保留在各占位文件头部(本地资格 11 项、互选 8 项、权限 10 项、预约 7 项、首页 11 项)。
+
+---
+
+## 六、环境事故记录(本机特有,务必注意)
+
+在 `tests/e2e/` 下**删除任意一个文件**,会触发本机常驻监听进程**级联清空整个 `tests/` 目录**(不止 `tests/e2e`,`tests/` 根及其子目录一并消失)。
+AGENTS.md 已记载该进程会清理 `refs/heads/` 下嵌套目录;本次实测表明**其作用范围不止于此**。
+
+- 已发生 2 次,均通过 `git reset -q HEAD tests/ && git checkout -- tests/` 完整恢复(21 个文件全部还原,`git status --porcelain tests/` 为 0)。
+- 结论:**本仓库禁止用 `rm` / `git rm` 删除 `tests/` 下文件**;需要移除内容时改用原地重写(Write/Edit)。本轮最终即采用该方式。
+
+---
+
+## 七、结论
+
+Phase 23 的核心价值不在于新增了多少用例,而在于**清除假覆盖**:
+
+- STATUS 曾宣称「10 个 E2E 文件 ~80+ 用例」「INV-011~020 ✅」;
+- 实测:44 个 `pass` 空壳 + 14 个常量字符串断言 + 整个 pytest 套件不可执行 + CI 无 E2E + 一个假迁移校验;
+- 现:假覆盖归零,INV-013/016/017/019 有经负向验证的真实行为守卫,并新增机械化的空壳阻断闸门。
+
+**建议**:本 Phase 判定为**部分关闭**;在遗留项 1(E2E 进入 CI)决策完成前,**不宜据此宣布「失败为零可进入 UAT」** —— 因为 UAT 前的 Gate 要求的是端到端链路被真实执行,而目前 E2E 一环仍缺。
diff --git a/docs/STATUS.md b/docs/STATUS.md
index ee166b6..d8e36b8 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -2,7 +2,7 @@
> **2026-09-17 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 20 COMPLETE / UI REDESIGN ACCEPTED BY USER / GIT CLEAN`。**
>
-> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)、Phase 13A(婚恋咨询与婚前辅导)、Phase 14(多类型公益活动)、Phase 15(活动报名、审核与签到)、Phase 16(活动互选与双方开启沟通确认)、Phase 17(举报与风控)、Phase 18(申诉)、Phase 19(统一运营后台 + 用户端公益服务页面)、Phase 20(Analytics 数据统计与管理驾驶舱)复验已关闭**,十四轮共修复 130+ 处真实缺陷(含 3 处隐藏编译缺陷),并各自补齐测试保真度与守卫。**Phase 21、22 复验已关闭(2026-09-18);Phase 23 起需用户确认后启动。**
+> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)、Phase 13A(婚恋咨询与婚前辅导)、Phase 14(多类型公益活动)、Phase 15(活动报名、审核与签到)、Phase 16(活动互选与双方开启沟通确认)、Phase 17(举报与风控)、Phase 18(申诉)、Phase 19(统一运营后台 + 用户端公益服务页面)、Phase 20(Analytics 数据统计与管理驾驶舱)复验已关闭**,十四轮共修复 130+ 处真实缺陷(含 3 处隐藏编译缺陷),并各自补齐测试保真度与守卫。**Phase 21、22 复验已关闭(2026-09-18)**;**Phase 23 为「部分关闭」(2026-09-18)** —— 已清除 44 个 `pass` 空壳与 14 个只断言常量的假不变量测试,新增 4 个经负向注入验证的真实行为守卫与空壳阻断闸门,后端回归 655 全绿;但 **E2E 进入 CI 因需「活栈供给方式」基础设施决策而列为遗留,故不宣布「失败为零可进入 UAT」**。**Phase 24 起需用户确认后启动。**
最后更新: 2026-09-18 — 分支 `main`(`feat-ui-v11` 保留为冗余锚点)
@@ -462,7 +462,7 @@
| 内容管理(婚恋课堂/反诈) | Phase 19 | ✅ 已完成(2026-09-17 复验关闭) |
| 专项公益服务项目 | Phase 19 | ✅ 已完成(2026-09-17 复验关闭) |
| 善缘服务站 + 用户端公益服务页面(U37/U49~U54/U60/U61/U64) | Phase 19 | ✅ 已完成(含 V042 临时公告) |
-| V1.1新增不变量测试(INV-011~020) | Phase 23 | ⏳ 待开发 |
+| V1.1新增不变量测试(INV-011~020) | Phase 23 | ⚠️ 部分完成(2026-09-18):原 14 个用例全为常量断言(INV-016 断言文章状态、INV-017 为自造字面量重言式);已重写为 12 个,其中 4 个为真实服务行为(INV-013/016/017/019),已过负向注入验证。详见 `docs/Phase-23-V11-Recovery-Completion-Report-2026-09-18.md` |
### 当前运行状态
@@ -616,26 +616,47 @@ MinIO: localhost:9003 (凭证由本地环境变量提供)
**Phase 21(账户生命周期增量)— 已完成** ✅
-### Phase 23 — V1.1专项测试 ✅ 已完成
-| 任务 | 状态 |
-|------|------|
-| P23-T01 Home V1.1 E2E | ✅ (home_v11_test.py) |
-| P23-T02 Banner timing filter | ✅ (home_v11_test.py) |
-| P23-T03 Banner audience filter | ✅ (home_v11_test.py) |
-| P23-T04 Quick Entry order | ✅ (home_v11_test.py) |
-| P23-T05 Local Eligibility E2E | ✅ (local_eligibility_test.py) |
-| P23-T06 Counseling E2E | ✅ (counseling_v11_test.py) |
-| P23-T07 Counseling permission | ✅ (counseling_permission_test.py) |
-| P23-T08 Service Appointment E2E | ✅ (service_appointment_test.py) |
-| P23-T09 Content E2E | ✅ (content_e2e_test.py) |
-| P23-T10 Multi-type Activity E2E | ✅ (multi_type_activity_test.py) |
-| P23-T11 Activity Mutual Consent E2E | ✅ (activity_mutual_consent_test.py) |
-| P23-T12 Service Station permission | ✅ (service_station_permission_test.py) |
-| P23-T13 Organization relation isolation | ✅ (organization_isolation_test.py) |
-| P23-T14 INV-011~INV-020 | ✅ (V11InvariantsTest.java) |
+### Phase 23 — 可信系统测试重建(REFACTOR)⚠️ 部分关闭(KEEP-VERIFY 复验,2026-09-18)
-E2E测试文件:10个(~80+测试用例)
-Invariant测试:10个(INV-011~INV-020)
+> 下方「Phase 23 — V1.1专项测试 ✅ 已完成」为**历史声明,经实测部分不成立**,以本节为准。
+
+| 项 | 结论 |
+|---|---|
+| 报告 | `docs/Phase-23-V11-Recovery-Completion-Report-2026-09-18.md` |
+| 回归 | **655 tests / 通过 652 / 失败 0 / 错误 0 / 跳过 3**(起点 657,净 −2:原 14 个空转用例合并为 12 个) |
+| 跳过原因 | `ConcurrentTests` ×2(`@EnabledIfEnvironmentVariable(CI=true)`,「本地并发测试跳过」);`CoreE2ETest` ×1(`@Disabled`「需要完整数据库连接」)—— 均有显式声明,非空壳 |
+| D-23-A(Critical) | `V11InvariantsTest` 原 14 个用例全为常量断言:INV-016 断言**文章状态**常量(跑错域)、INV-017 是**自造字面量的重言式**(从未调用首页服务)、INV-018/019/020 用无关状态常量冒充权限验证 → 已重写,新增 4 个真实行为守卫(INV-013 脱敏、INV-016 白名单禁外链、INV-017 首页 8 入口顺序、INV-019 内容编辑员零数据)+ `assertBehaviorTestExists` 指针契约 |
+| D-23-B(High) | `tests/e2e/` **44 个 `pass` 空壳**(4 个文件 100% 为空壳)→ 已移除,改为诚实占位并保留场景清单 |
+| D-23-C(High) | pytest E2E 套件**整体不可执行**:全仓无 `conftest.py`,约 15 个 fixture 不存在;唯一可执行的 `run_v11_tests.py`(16 用例)需活栈 |
+| D-23-D(High,遗留) | **CI 未串联 E2E**(计划明确要求)→ 需先决定 CI 活栈供给方式,未在本轮实施 |
+| D-23-E(Medium) | CI `migration` job 注释称「验证 SQL 语法」但只查文件非空 → 改为真实序列校验(命名/非空/版本号唯一/无缺号),已本地实跑通过 |
+| 新增闸门 | `scripts/check-no-empty-tests.py`(AST 扫描,阻断 `pass` 空壳与无条件 `skip`)+ CI `test-shell-guard` job |
+| 遗留 | ① E2E 进入 CI 待基础设施决策;② pytest fixture 体系缺失;③ INV-011 无显式守卫(结构性满足);④ E2E 场景清单待实现(已保留在占位文件头) |
+| 结论 | **部分关闭**:假覆盖已归零,但 UAT 前 Gate 要求的端到端链路仍未真实执行,**不宜据此宣布可进入 UAT** |
+
+---
+
+### Phase 23 — V1.1专项测试 ✅ 已完成(历史声明,已被上方权威结论取代)
+> ⚠️ 下表为历史进度声明,**经 Phase 23 KEEP-VERIFY 实测部分不成立**,以上方「Phase 23 — 可信系统测试重建(REFACTOR)」为准。
+| 任务 | 历史声明 | 实测结论 |
+|------|---------|---------|
+| P23-T01 Home V1.1 E2E | ✅ (home_v11_test.py) | ❌ 该文件 7/9 为空壳且依赖不存在的 fixture,从未执行;首页聚合实由 `run_v11_tests.py::test_02_home` 覆盖 |
+| P23-T02 Banner timing filter | ✅ (home_v11_test.py) | ❌ 空壳 `pass`,未实现 |
+| P23-T03 Banner audience filter | ✅ (home_v11_test.py) | ❌ 空壳 `pass`,未实现 |
+| P23-T04 Quick Entry order | ✅ (home_v11_test.py) | ⚠️ 原为自造字面量重言式;**现由真实用例 `V11InvariantsTest#inv017QuickEntriesFixedOrder` + `run_v11_tests.py::test_03` 覆盖** |
+| P23-T05 Local Eligibility E2E | ✅ (local_eligibility_test.py) | ❌ 该文件 11/11 全为空壳,从未执行 |
+| P23-T06 Counseling E2E | ✅ (counseling_v11_test.py) | ⚠️ 该文件真实可执行;其中 INV-013 一项为空壳,已移除,**改由 `V11InvariantsTest#inv013` 真实覆盖** |
+| P23-T07 Counseling permission | ✅ (counseling_permission_test.py) | ❌ 该文件 10/10 全为空壳,从未执行 |
+| P23-T08 Service Appointment E2E | ✅ (service_appointment_test.py) | ❌ 该文件 7/7 全为空壳,从未执行 |
+| P23-T09 Content E2E | ✅ (content_e2e_test.py) | ✅ 属实(自包含 requests 用例,真实可执行) |
+| P23-T10 Multi-type Activity E2E | ✅ (multi_type_activity_test.py) | ✅ 属实(自包含 requests 用例) |
+| P23-T11 Activity Mutual Consent E2E | ✅ (activity_mutual_consent_test.py) | ❌ 该文件 8/8 全为空壳;不变量实由后端 `ActivityMutualConsentServiceTest` 真实覆盖 |
+| P23-T12 Service Station permission | ✅ (service_station_permission_test.py) | ✅ 属实(自包含 requests 用例) |
+| P23-T13 Organization relation isolation | ✅ (organization_isolation_test.py) | ✅ 属实(自包含 requests 用例) |
+| P23-T14 INV-011~INV-020 | ✅ (V11InvariantsTest.java) | ❌ 原 14 个用例全为常量字符串断言(INV-016 断言文章状态、INV-017 重言式);**已重写为 12 个,含 4 个真实行为守卫** |
+
+E2E测试文件:10个(~80+测试用例)→ **实测:6 个含 44 个 `pass` 空壳,其中 4 个 100% 为空壳;pytest 侧因无 `conftest.py` 整体不可执行**
+Invariant测试:10个(INV-011~020)→ **实测:原 14 个用例无任何服务行为断言;现 12 个,其中 4 个为真实行为(已过负向注入验证)**
**Phase 22(部署运维配置扩展)— 已完成** ✅
@@ -695,7 +716,7 @@ Invariant测试:10个(INV-011~INV-020)
|-----------|------|
| Phase 01~12 (V1.0基线) | ✅ 已完成 |
| Phase 13~22 (V1.1核心增量) | ✅ 已完成 |
-| Phase 23 (V1.1专项测试) | ✅ 已完成 |
+| Phase 23 (V1.1专项测试) | ⚠️ 部分关闭(2026-09-18):假覆盖已清除 + 4 个真实守卫;E2E 入 CI 待决策 |
| Phase 24~26 (UAT/RC/Production) | ⏳ 运营/上线阶段 |
### Phase 24 — UAT增量 ✅ 已就绪
diff --git a/scripts/check-no-empty-tests.py b/scripts/check-no-empty-tests.py
new file mode 100644
index 0000000..bdf859e
--- /dev/null
+++ b/scripts/check-no-empty-tests.py
@@ -0,0 +1,137 @@
+#!/usr/bin/env python3
+"""
+Phase 23 Gate 守卫:禁止测试空壳(skip / pass 空壳)。
+
+计划 Phase 23 Gate 原文:
+ 「不允许 skip/pass 空壳;报告列出总数、通过、失败、跳过及原因,失败为零才进入 UAT。」
+
+背景(Phase 23 KEEP-VERIFY 实测)
+--------------------------------
+`tests/e2e/` 下曾有 **44 个**函数体只有 `pass` 的测试函数,分属 6 个文件,
+其中 4 个文件 100% 由空壳构成;这些用例从未执行过(且依赖根本不存在的 pytest fixture),
+却在 `docs/STATUS.md` 中被计为 P23-Txx ✅。
+本守卫用 AST(而非 grep)在 CI 中机械阻断空壳回归。
+
+检测项
+------
+1. 函数体为空、或仅为「docstring + pass」的函数(测试函数与非测试的 helper 均计入,
+ 因为空壳 helper 同样是未实现逻辑);
+2. 被 `@pytest.mark.skip` / `skipif` 无理由装饰、或函数体内直接
+ `pytest.skip(...)` 开头跳过的「测试函数」(`test_*`)。
+
+用法
+----
+ python3 scripts/check-no-empty-tests.py [目录 ...] # 默认扫描 tests/
+
+退出码:0 = 无空壳;1 = 发现空壳(并打印 文件:行 清单)。
+"""
+
+from __future__ import annotations
+
+import ast
+import sys
+from pathlib import Path
+
+SKIP_DECORATORS = {"skip", "skipif"}
+
+
+def is_docstring_only_then_pass(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
+ """函数体是否为空,或仅为「docstring + pass」。"""
+ body = [stmt for stmt in node.body]
+ if not body:
+ return True
+ # 去掉开头的 docstring
+ if (
+ isinstance(body[0], ast.Expr)
+ and isinstance(body[0].value, ast.Constant)
+ and isinstance(body[0].value.value, str)
+ ):
+ body = body[1:]
+ if not body:
+ return True
+ return all(isinstance(stmt, ast.Pass) for stmt in body)
+
+
+def has_skip_marker(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
+ """是否被无条件 skip 的装饰器标记。"""
+ for dec in node.decorator_list:
+ target = None
+ if isinstance(dec, ast.Attribute):
+ target = dec.attr
+ elif isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
+ target = dec.func.attr
+ if target in SKIP_DECORATORS and not node.name.startswith("test_"):
+ continue
+ if target in SKIP_DECORATORS:
+ return True
+ return False
+
+
+def starts_with_pytest_skip(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
+ """函数体是否以 pytest.skip(...) 打头(除 docstring 外)。"""
+ body = list(node.body)
+ if (
+ body
+ and isinstance(body[0], ast.Expr)
+ and isinstance(body[0].value, ast.Constant)
+ and isinstance(body[0].value.value, str)
+ ):
+ body = body[1:]
+ if not body:
+ return False
+ stmt = body[0]
+ if not isinstance(stmt, ast.Expr) or not isinstance(stmt.value, ast.Call):
+ return False
+ func = stmt.value.func
+ return isinstance(func, ast.Attribute) and func.attr == "skip"
+
+
+def scan_file(path: Path) -> list[str]:
+ try:
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
+ except SyntaxError as exc: # 语法错误交给 pytest 自己去报,这里不重复处理
+ return [f"{path}:{exc.lineno or 0}: 无法解析(SyntaxError: {exc.msg})"]
+
+ findings: list[str] = []
+ for node in ast.walk(tree):
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ continue
+ if is_docstring_only_then_pass(node):
+ findings.append(
+ f"{path}:{node.lineno}: 空壳函数 `{node.name}`(函数体只有 pass / 为空)"
+ )
+ elif node.name.startswith("test_") and (
+ has_skip_marker(node) or starts_with_pytest_skip(node)
+ ):
+ findings.append(
+ f"{path}:{node.lineno}: 测试 `{node.name}` 被无条件 skip(Gate 禁止 skip 空壳)"
+ )
+ return findings
+
+
+def main(argv: list[str]) -> int:
+ roots = [Path(a) for a in argv[1:]] or [Path("tests")]
+ files: list[Path] = []
+ for root in roots:
+ if root.is_file():
+ files.append(root)
+ else:
+ files.extend(sorted(root.rglob("*.py")))
+
+ all_findings: list[str] = []
+ for f in files:
+ all_findings.extend(scan_file(f))
+
+ if all_findings:
+ print("FAIL: 发现测试空壳(Phase 23 Gate 禁止 skip/pass 空壳):", file=sys.stderr)
+ for line in all_findings:
+ print(" " + line, file=sys.stderr)
+ print(f"\n合计 {len(all_findings)} 处。请实现真实断言,或删除该空壳。", file=sys.stderr)
+ return 1
+
+ print(f"OK: 未发现 skip/pass 空壳(已扫描 {len(files)} 个 Python 文件)")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv))
diff --git a/tests/e2e/activity_mutual_consent_test.py b/tests/e2e/activity_mutual_consent_test.py
index 713aae9..bfa9d3e 100644
--- a/tests/e2e/activity_mutual_consent_test.py
+++ b/tests/e2e/activity_mutual_consent_test.py
@@ -1,57 +1,35 @@
"""
-V1.1 活动互选二阶段同意 E2E 测试骨架
-测试项目: 活动互选流程重构(INV-014, INV-015)
+V1.1 活动互选二阶段同意 E2E —— 空壳已移除(Phase 23 KEEP-VERIFY,2026-09-18)
+
+历史问题
+--------
+原文件 8 个测试函数**全部**为 `pass` 空壳(注释「TODO: P16 实现后完善」),
+依赖不存在的 `activity_factory` / `user_a` / `user_b` 等 fixture(全局无 `conftest.py`),
+从未执行过,却在 `docs/STATUS.md` 中被计为 P23-T11「活动互选 E2E ✅」。
+
+计划 Phase 23 Gate:「**不允许 skip/pass 空壳**」。
+
+当前状态
+--------
+本文件现**不含任何测试函数**,pytest 收集 0 个用例。
+
+仍待真实实现的场景(需求不因移除空壳而丢失)
+------------------------------------------
+1. Activity Mutual Interest 不直接创建 Match(INV-014)
+2. 一方确认不创建 Match(INV-015)
+3. 双方确认后创建新 Match Episode
+4. 已有 active Match 时不重复创建(AGENTS.md #19)
+5. 曾 Unmatch 后重新确认创建新 Episode(AGENTS.md #20)
+6. 一方拒绝则不创建 Match
+7. 超时(expires_at)处理
+8. 单向意向不泄露给对方(INV-006)
+
+现状补充(Phase 23 实测)
+------------------------
+INV-014 / INV-015 在后端已有**真实行为测试**覆盖:
+`backend/.../activity_mutual/ActivityMutualConsentServiceTest`
+(双确认 Gate、decline 不建 Match、复用已有 active Match、幂等、超时、软删过滤)。
+即:E2E 层待补,但不变量本身并非无覆盖。
+
+真实可执行的 V1.1 E2E 见:`tests/e2e/run_v11_tests.py`
"""
-
-import pytest
-
-
-@pytest.mark.e2e
-class TestActivityMutualConsent:
- """活动互选二阶段同意流程测试"""
-
- def test_mutual_interest_does_not_create_match(self, api_client, activity_factory, user_a, user_b):
- """Activity Mutual Interest不直接创建Match(INV-014)"""
- # TODO: P16 实现后完善
- # Given: A和B在活动后互选
- # When: 系统处理互选结果
- # Then: 不创建Match,创建MutualConnectionConsent
- pass
-
- def test_one_side_confirm_no_match(self, api_client, activity_factory, user_a, user_b):
- """一方确认不创建Match(INV-015)"""
- # TODO: P16 实现后完善
- # Given: A和B已达成Mutual Activity Interest
- # When: 只有A确认开启沟通
- # Then: 不创建Match,不创建Conversation
- pass
-
- def test_both_confirm_creates_match_episode(self, api_client, activity_factory, user_a, user_b):
- """双方确认后创建新Match Episode"""
- # TODO: P16 实现后完善
- pass
-
- def test_existing_active_match_not_duplicated(self, api_client, activity_factory, user_a, user_b):
- """已有active Match不重复创建"""
- # TODO: P16 实现后完善
- pass
-
- def test_prior_unmatch_creates_new_episode(self, api_client, activity_factory, user_a, user_b):
- """已Unmatch后重新确认创建新Episode"""
- # TODO: P16 实现后完善
- pass
-
- def test_one_side_decline_no_match(self, api_client, activity_factory, user_a, user_b):
- """一方拒绝不创建Match"""
- # TODO: P16 实现后完善
- pass
-
- def test_timeout_handled(self, api_client, activity_factory, user_a, user_b):
- """超时处理"""
- # TODO: P16 实现后完善
- pass
-
- def test_single_interest_not_leaked(self, api_client, activity_factory, user_a, user_b):
- """单向意向不泄露给对方"""
- # TODO: P16 实现后完善
- pass
diff --git a/tests/e2e/counseling_permission_test.py b/tests/e2e/counseling_permission_test.py
index 2902821..0e7b3e6 100644
--- a/tests/e2e/counseling_permission_test.py
+++ b/tests/e2e/counseling_permission_test.py
@@ -1,73 +1,41 @@
"""
-V1.1 婚恋咨询权限 E2E 测试骨架
-测试项目: Counselor权限隔离、红娘不可见咨询正文
+V1.1 咨询 / 服务站 / 内容编辑员权限 E2E —— 空壳已移除(Phase 23 KEEP-VERIFY,2026-09-18)
+
+历史问题
+--------
+原文件 10 个测试函数**全部**为 `pass` 空壳(注释「TODO: P13A / P17 / P19 实现后完善」),
+依赖不存在的 `counselor_client` / `counseling_service_factory` / `content_editor_client`
+等 fixture(全局无 `conftest.py`),从未执行过,
+却在 `docs/STATUS.md` 中被计为 P23-T07「咨询权限 ✅」。
+
+计划 Phase 23 Gate:「**不允许 skip/pass 空壳**」。
+
+当前状态
+--------
+本文件现**不含任何测试函数**,pytest 收集 0 个用例。
+
+仍待真实实现的场景(需求不因移除空壳而丢失)
+------------------------------------------
+1. Counselor 只能看到被分配给自己的咨询(INV-012)
+2. Counselor 不可查看其他咨询师的服务
+3. 红娘不可查看咨询正文(INV-013)
+4. 普通管理员默认不可查看咨询敏感备注
+5. 咨询管理员可分配咨询人员
+6. 咨询过程中可转交风控
+7. 服务站人员不能浏览全平台用户(INV-018)
+8. 服务站人员可处理本服务站预约
+9. 内容编辑员不能访问用户婚恋档案(INV-019)
+10. 内容编辑员可管理文章内容
+
+现状补充(Phase 23 实测)
+------------------------
+- INV-012 已由 `CounselingPhase13ATest` 的 4 个真实用例覆盖;
+- INV-013 / INV-019 已由 Phase 23 新增的真实行为用例覆盖
+ (`V11InvariantsTest#inv013MatchmakerCannotReadCounselingBody`、
+ `V11InvariantsTest#inv019ContentEditorHasNoUserDataPermission`,
+ 走真实 `CounselingService.getDetail`,已通过负向注入验证);
+- INV-018 已由 `ServiceAppointmentDataScopeTest` 覆盖。
+即:E2E 层待补,但不变量本身已有真实覆盖。
+
+真实可执行的 V1.1 E2E 见:`tests/e2e/run_v11_tests.py`
"""
-
-import pytest
-
-
-@pytest.mark.e2e
-class TestCounselingPermission:
- """咨询权限测试(INV-012, INV-013)"""
-
- def test_counselor_only_sees_assigned_services(self, counselor_client, counseling_service_factory):
- """Counselor只能看到被分配给自己的咨询(INV-012)"""
- # GET /api/v1/operator/counseling/services
- # TODO: P13A 实现后完善
- pass
-
- def test_counselor_cannot_view_other_assigned(self, counselor_client, another_counselor_service_factory):
- """Counselor不可查看其他咨询师的服务"""
- # TODO: P13A 实现后完善
- pass
-
- def test_matchmaker_cannot_view_counseling_body(self, matchmaker_client, counseling_service_factory):
- """红娘不可查看咨询正文(INV-013)"""
- # TODO: P13A 实现后完善
- pass
-
- def test_platform_admin_cannot_view_sensitive_note(self, platform_admin_client, counseling_service_factory):
- """普通管理员默认不可查看咨询敏感备注"""
- # TODO: P13A 实现后完善
- pass
-
- def test_counseling_manager_can_assign(self, counseling_manager_client):
- """咨询管理员可分配咨询人员"""
- # POST /api/v1/operator/counseling/services/{id}/assign
- # TODO: P13A 实现后完善
- pass
-
- def test_counseling_risk_referral(self, counselor_client, counseling_service_factory):
- """咨询过程中可转交风控"""
- # TODO: P13A + P17 实现后完善
- pass
-
-
-@pytest.mark.e2e
-class TestServiceAppointmentPermission:
- """服务站预约权限测试(INV-018)"""
-
- def test_service_station_operator_cannot_browse_all_users(self, station_operator_client):
- """服务站人员不能浏览全平台用户(INV-018)"""
- # TODO: P19 实现后完善
- pass
-
- def test_service_station_operator_can_process_appointments(self, station_operator_client, appointment_factory):
- """服务站人员可处理本服务站预约"""
- # TODO: P19 实现后完善
- pass
-
-
-@pytest.mark.e2e
-class TestContentEditorPermission:
- """内容编辑员权限测试(INV-019)"""
-
- def test_content_editor_cannot_access_user_profiles(self, content_editor_client):
- """内容编辑员不能访问用户婚恋档案(INV-019)"""
- # TODO: P19 实现后完善
- pass
-
- def test_content_editor_can_manage_articles(self, content_editor_client):
- """内容编辑员可管理文章内容"""
- # TODO: P19 实现后完善
- pass
diff --git a/tests/e2e/counseling_v11_test.py b/tests/e2e/counseling_v11_test.py
index 037512c..aba1143 100644
--- a/tests/e2e/counseling_v11_test.py
+++ b/tests/e2e/counseling_v11_test.py
@@ -106,11 +106,18 @@ def test_counselor_assigned_scope():
# ---- INV-013: Matchmaker不能读取Counseling正文 ----
-def test_matchmaker_cannot_read_counseling_body():
- """红娘不得查看咨询正文内容(通过权限隔离)"""
- # 此测试需要实际的红娘 token,验证其无 counseling.service.view 权限
- # 在当前测试框架下简化验证:仅确认 Counselor 端点不被普通用户访问
- pass
+# Phase 23 KEEP-VERIFY(2026-09-18):原 test_matchmaker_cannot_read_counseling_body
+# 的函数体为 `pass` 空壳(注释自述「在当前测试框架下简化验证」),从未真实断言过任何权限,
+# 却在 docs/STATUS.md 中被计为 P23-T06「咨询 E2E ✅」的一部分。
+# 计划 Phase 23 Gate「不允许 skip/pass 空壳」→ 已移除该空壳。
+#
+# INV-013 现由后端真实行为用例覆盖:
+# backend/.../invariants/V11InvariantsTest#inv013MatchmakerCannotReadCounselingBody
+# 走真实 CounselingService.getDetail:断言 Matchmaker 读到的 user_request_summary 为 null,
+# 并以「assigned Counselor 能读到正文」作反证,避免全局脱敏造成的假阳性;
+# 已通过负向注入验证(把 isMatchmaker 分支改为返回全文 → 该用例精准变红)。
+#
+# E2E 层待补场景:用真实红娘 token 调用咨询详情接口并断言被拒。
if __name__ == "__main__":
diff --git a/tests/e2e/home_v11_test.py b/tests/e2e/home_v11_test.py
index eaf1c8b..e1a1aa4 100644
--- a/tests/e2e/home_v11_test.py
+++ b/tests/e2e/home_v11_test.py
@@ -1,87 +1,44 @@
"""
-V1.1 首页 E2E 测试骨架
-测试项目: 善缘丹阳 V1.1 首页结构 + Banner + 8核心入口
+V1.1 首页 E2E —— 空壳已移除(Phase 23 KEEP-VERIFY,2026-09-18)
+
+历史问题
+--------
+原文件 9 个测试函数中 **7 个是 `pass` 空壳**,其余 5 个虽含断言,
+但全部依赖 `api_client` / `authenticated_user` / `banner_factory` /
+`user_factory` / `verified_user` / `searching_user` / `paused_user` /
+`successful_user` / `admin_client` 等**在本仓库中根本不存在**的 pytest fixture
+(全局无 `conftest.py`)→ 整个文件**一次都没执行过**,
+却在 `docs/STATUS.md` 中被计为 P23-T01~T04「首页 E2E ✅」。
+
+计划 Phase 23 Gate:「**不允许 skip/pass 空壳**」。
+
+当前状态
+--------
+本文件现**不含任何测试函数**,pytest 收集 0 个用例。
+
+仍待真实实现的场景(需求不因移除空壳而丢失)
+------------------------------------------
+1. `GET /api/v1/home` 返回 userState
+2. Banner 按时间过滤(未开始 / 已过期不显示)
+3. Banner 按 audience_scope 过滤
+4. 首页 8 核心入口顺序固定(INV-017)
+5. 未审核用户不返回真实推荐
+6. Paused 用户不返回新推荐
+7. Successful 用户不返回找对象推荐
+8. 服务站可展示
+9. Banner 不能创建外部 URL 跳转(INV-016)
+10. `counselingEnabled` flag 控制 `canApplyCounseling`
+11. Capability:`canReceiveRecommendations` 随账号状态变化
+
+现状补充(Phase 23 实测)
+------------------------
+- 首页聚合与 8 入口已有真实 HTTP 覆盖:
+ `tests/e2e/run_v11_tests.py::test_02_home` 与 `::test_03_home_8_entries`;
+- **INV-017 已由 Phase 23 新增的后端真实行为用例覆盖**
+ (`V11InvariantsTest#inv017QuickEntriesFixedOrder`,走真实
+ `HomeApplicationService.getHome(userId)`,断言 8 个编码与顺序完全固定);
+- INV-016 已有 `BannerOperatorPhase19IntegrationTest` 真实覆盖。
+即:E2E 层待补,但不变量本身已有真实覆盖。
+
+真实可执行的 V1.1 E2E 见:`tests/e2e/run_v11_tests.py`
"""
-
-import pytest
-
-
-@pytest.mark.e2e
-class TestHomeV11:
- """V1.1 首页 E2E 测试"""
-
- def test_home_api_returns_user_state(self, api_client, authenticated_user):
- """GET /api/v1/home 返回 userState"""
- resp = api_client.get("/api/v1/home")
- assert resp.status_code == 200
- data = resp.json()
- assert "userState" in data
-
- def test_home_banners_filtered_by_time(self, api_client, banner_factory):
- """Banner按时间过滤:未开始和已过期的Banner不显示"""
- # TODO: P07 实现后完善
- pass
-
- def test_home_banners_filtered_by_audience(self, api_client, banner_factory, user_factory):
- """Banner按用户状态过滤:audience_scope不匹配时不显示"""
- # TODO: P07 实现后完善
- pass
-
- def test_home_quick_entries_fixed_order(self, api_client):
- """首页8核心入口顺序固定"""
- # TODO: P07 实现后完善
- pass
-
- def test_unverified_user_no_recommendations(self, api_client, unverified_user):
- """未审核用户不返回真实推荐"""
- resp = api_client.get("/api/v1/home")
- data = resp.json()
- assert data.get("recommendations") == [] or data.get("recommendations") is None
-
- def test_paused_user_no_new_recommendations(self, api_client, paused_user):
- """Paused状态用户不返回新推荐"""
- resp = api_client.get("/api/v1/home")
- data = resp.json()
- # Paused用户应有推荐区降级提示,而非空列表
- assert "recommendations" in data or "userState" in data
-
- def test_successful_user_no_match_recommendations(self, api_client, successful_user):
- """Successful用户不返回找对象推荐"""
- resp = api_client.get("/api/v1/home")
- data = resp.json()
- # Successful用户首页应转为公益服务推荐
- pass
-
- def test_service_location_shown_when_available(self, api_client, service_location_factory):
- """服务站可展示"""
- # TODO: P19 实现后完善
- pass
-
- def test_banner_cannot_link_external_url(self, admin_client):
- """Banner不能创建外部URL跳转"""
- # TODO: P19 实现后完善
- pass
-
-
-@pytest.mark.e2e
-class TestCapabilityService:
- """CapabilityService 能力判断测试"""
-
- def test_can_receive_recommendations(self, api_client, searching_user):
- """Searching用户canReceiveRecommendations=true"""
- resp = api_client.get("/api/v1/home")
- data = resp.json()
- caps = data.get("capabilities", {})
- assert caps.get("canReceiveRecommendations") is True
-
- def test_paused_user_cannot_receive_recommendations(self, api_client, paused_user):
- """Paused用户canReceiveRecommendations=false"""
- resp = api_client.get("/api/v1/home")
- data = resp.json()
- caps = data.get("capabilities", {})
- assert caps.get("canReceiveRecommendations") is False
-
- def test_counseling_enabled_capability(self, api_client, verified_user):
- """counselingEnabled flag控制canApplyCounseling"""
- # TODO: P22 实现后完善
- pass
diff --git a/tests/e2e/local_eligibility_test.py b/tests/e2e/local_eligibility_test.py
index a3c0fcd..759d3c0 100644
--- a/tests/e2e/local_eligibility_test.py
+++ b/tests/e2e/local_eligibility_test.py
@@ -1,72 +1,39 @@
"""
-V1.1 本地资格 E2E 测试骨架
-测试项目: 本地资格声明 → 佐证上传 → 审核流程
+V1.1 本地资格 E2E —— 空壳已移除(Phase 23 KEEP-VERIFY,2026-09-18)
+
+历史问题
+--------
+原文件 11 个测试函数**全部**为 `pass` 空壳(注释均为「TODO: P04/P05/P06 实现后完善」),
+且依赖 `api_client` / `verified_user` / `local_eligibility_factory` 等
+**在本仓库中根本不存在**的 pytest fixture(全局无 `conftest.py`)。
+这些用例从未执行过,却在 `docs/STATUS.md` 中被计为 P23-T05「本地资格 E2E ✅」。
+
+计划 Phase 23 Gate:「**不允许 skip/pass 空壳**」。
+
+当前状态
+--------
+本文件现**不含任何测试函数**,pytest 将收集到 0 个用例 ——
+以「没有假覆盖」取代「假装有覆盖」。
+
+仍待真实实现的场景(需求不因移除空壳而丢失)
+------------------------------------------
+1. 用户可声明丹阳工作 / 户籍 / 居住
+2. 声明本地资格不默认要求上传佐证
+3. 佐证文件上传需要鉴权
+4. 审核员可访问本地资格佐证(INV-011)
+5. 红娘不可访问本地资格佐证(INV-011)
+6. `/me/status` 返回 localEligibilityStatus
+7. 本地资格未核验影响匹配资格
+8. 本地资格审核进入 `review_type=local_eligibility` 队列
+9. 审核通过更新 local_eligibility 状态
+10. 需补充佐证时触发用户上传
+11. 查看佐证文件须记录审计日志
+
+现状补充(Phase 23 实测)
+------------------------
+`LocalEligibilityService` 全部方法均以 `userId` 自限定、**无任何 role 分支**,
+佐证无独立的服务端读取入口(仅经受权限控制的审核队列 `ReviewController` 访问),
+故 INV-011 在结构上成立;但缺少显式守卫测试,列为遗留待补。
+
+真实可执行的 V1.1 E2E 见:`tests/e2e/run_v11_tests.py`
"""
-
-import pytest
-
-
-@pytest.mark.e2e
-class TestLocalEligibilityE2E:
- """本地资格 E2E 全流程测试"""
-
- def test_declare_local_eligibility(self, api_client, verified_user):
- """用户可声明丹阳工作/户籍/居住"""
- # POST /api/v1/local-eligibility/declaration
- # TODO: P04 实现后完善
- pass
-
- def test_declare_does_not_require_evidence_by_default(self, api_client, verified_user):
- """声明本地资格不默认要求上传佐证"""
- # TODO: P04 实现后完善
- pass
-
- def test_evidence_upload_requires_permission(self, api_client, verified_user):
- """佐证文件上传需要鉴权"""
- # TODO: P04 实现后完善
- pass
-
- def test_reviewer_can_access_evidence(self, operator_client, local_eligibility_factory):
- """审核员可访问本地资格佐证"""
- # TODO: P05 实现后完善
- pass
-
- def test_matchmaker_cannot_access_evidence(self, matchmaker_client, local_eligibility_factory):
- """红娘不可访问本地资格佐证(INV-011)"""
- # TODO: P13 + P05 实现后完善
- pass
-
- def test_local_eligibility_status_in_me_status(self, api_client, verified_user):
- """/me/status 返回 localEligibilityStatus"""
- # TODO: P04 实现后完善
- pass
-
- def test_local_eligibility_affects_match_eligibility(self, api_client, user_factory):
- """本地资格未核验影响匹配资格"""
- # TODO: P06 实现后完善
- pass
-
-
-@pytest.mark.e2e
-class TestLocalEligibilityReview:
- """本地资格审核流程测试"""
-
- def test_review_queue_local_eligibility_type(self, operator_client):
- """本地资格审核进入 review_type=local_eligibility 队列"""
- # TODO: P05 实现后完善
- pass
-
- def test_approve_updates_eligibility_status(self, operator_client, local_eligibility_factory):
- """审核通过更新 local_eligibility 状态"""
- # TODO: P05 实现后完善
- pass
-
- def test_need_evidence_triggers_upload(self, operator_client, local_eligibility_factory):
- """需补充佐证触发用户上传"""
- # TODO: P05 实现后完善
- pass
-
- def test_evidence_access_logged(self, operator_client, local_eligibility_factory):
- """查看佐证文件记录审计日志"""
- # TODO: P05 实现后完善
- pass
diff --git a/tests/e2e/service_appointment_test.py b/tests/e2e/service_appointment_test.py
index fa76657..baa3b98 100644
--- a/tests/e2e/service_appointment_test.py
+++ b/tests/e2e/service_appointment_test.py
@@ -1,46 +1,35 @@
"""
-V1.1 服务预约 E2E 测试骨架
-测试项目: 服务预约全流程 + 并发安全
+V1.1 服务预约 E2E —— 空壳已移除(Phase 23 KEEP-VERIFY,2026-09-18)
+
+历史问题
+--------
+原文件 7 个测试函数**全部**为 `pass` 空壳(注释「TODO: P19 实现后完善」),
+依赖不存在的 `service_location_factory` / `appointment_factory` / `operator_client`
+等 fixture(全局无 `conftest.py`),从未执行过,
+却在 `docs/STATUS.md` 中被计为 P23-T08「服务预约 E2E ✅」。
+
+计划 Phase 23 Gate:「**不允许 skip/pass 空壳**」。
+
+当前状态
+--------
+本文件现**不含任何测试函数**,pytest 收集 0 个用例。
+
+仍待真实实现的场景(需求不因移除空壳而丢失)
+------------------------------------------
+1. 用户可提交服务预约
+2. 预约提交支持幂等(Idempotency-Key)
+3. 后台可确认预约
+4. 同一预约同时取消与确认的并发安全性
+5. 我的预约列表正确
+6. 待确认状态可取消
+7. 已确认状态不可自行取消
+
+现状补充(Phase 23 实测)
+------------------------
+预约 owner 归属与数据范围已有真实后端测试:
+`ServiceAppointmentOwnerGuardTest`、`ServiceAppointmentDataScopeTest`。
+预约创建链路在 `tests/e2e/run_v11_tests.py::test_06_service_appointment`
+中有真实 HTTP 覆盖。E2E 层其余场景待补。
+
+真实可执行的 V1.1 E2E 见:`tests/e2e/run_v11_tests.py`
"""
-
-import pytest
-
-
-@pytest.mark.e2e
-class TestServiceAppointmentE2E:
- """服务预约 E2E 测试"""
-
- def test_submit_appointment(self, api_client, service_location_factory):
- """用户可提交服务预约"""
- # TODO: P19 实现后完善
- pass
-
- def test_appointment_idempotent(self, api_client, service_location_factory):
- """预约提交支持幂等(Idempotency-Key)"""
- # TODO: P19 实现后完善
- pass
-
- def test_operator_can_confirm(self, operator_client, appointment_factory):
- """后台可确认预约"""
- # TODO: P19 实现后完善
- pass
-
- def test_appointment_concurrent_safety(self, api_client, service_location_factory):
- """同一预约同时取消和确认的并发安全性"""
- # TODO: P19 实现后完善
- pass
-
- def test_my_appointments_list(self, api_client, appointment_factory):
- """我的预约列表正确"""
- # TODO: P19 实现后完善
- pass
-
- def test_cancel_before_confirmation(self, api_client, appointment_factory):
- """待确认状态可取消"""
- # TODO: P19 实现后完善
- pass
-
- def test_cancel_after_confirmation_denied(self, api_client, appointment_factory):
- """已确认状态不可自行取消"""
- # TODO: P19 实现后完善
- pass