From 2319f97203a2cafcf8012869fda8c30599ffb305 Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Fri, 18 Sep 2026 15:53:20 +0800 Subject: [PATCH] test: verify batch query and unread count performance --- ...h-query-unread-count-performance-report.md | 352 ++++ ...r01-e-independent-verification-20260918.md | 412 +++++ ...ependentCrossDatabaseVerificationTest.java | 1549 +++++++++++++++++ ...01EIndependentSideEffectIsolationTest.java | 441 +++++ .../R01EMySqlQueryCountAndExplainTest.java | 343 ++++ .../r01e/R01EQueryBudgetAcceptanceTest.java | 418 +++++ 6 files changed, 3515 insertions(+) create mode 100644 docs/r01-e-batch-query-unread-count-performance-report.md create mode 100644 docs/r01-e-independent-verification-20260918.md create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentSideEffectIsolationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EMySqlQueryCountAndExplainTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java diff --git a/docs/r01-e-batch-query-unread-count-performance-report.md b/docs/r01-e-batch-query-unread-count-performance-report.md new file mode 100644 index 0000000..d7a3833 --- /dev/null +++ b/docs/r01-e-batch-query-unread-count-performance-report.md @@ -0,0 +1,352 @@ +# R01-E 既有批量查询与会话未读数性能实现:验收与测试加固报告 + +日期:2026-09-18 +分支:`refactor/r01-structure-performance` +HEAD:`b893ed67f078eedb0c4b8a03341311bd79342354`(== upstream) +本轮性质:**验收与测试加固** —— **生产代码 diff = 0**,未暂存、未提交、未推送 + +--- + +## 0. 结论 + +> **R01-E既有批量查询与会话未读数性能实现已完成验收和测试加固,待独立代码复验。** + +**观察项(本轮不改变既有语义)**: + +> **OBS-E01**:当前**本人发送且没有 `message_reads` 记录**的消息也计入**本人**未读数, +> 待产品负责人单独确认;本轮不改变既有语义。 + +**库存前置结论**:E1 与 E2 在 HEAD 上**均已处于目标终态**(分别由 R01-D2、R01-D3 交付), +故本轮**未修改任何生产代码**,改为以**查询预算特征测试 + 跨库一致性 + EXPLAIN + 副作用快照**做验收加固。 + +--- + +## 1. PersonalDataCollector 修复前调用链(E1) + +**调用链**:`PersonalDataExportWorker.run` → `PersonalDataCollector.collect(userId)` → 12 个 section 构建方法 → `PersonalDataExportBuilder.build(...)` → 落盘。 + +**查询形态(逐处核对)**:全部为**批量**读取 —— `findByStudentUserIdOrderByCreatedAtDesc` / +`findByUserId` / `findAllById` / `...In...OrderBy...`,且批量历史查询 +(`ApplicationStatusHistory` / `InterviewStatusHistory` / `OfferStatusHistory` / `AppealHistory`) +均使用 `findByXxxIdInOrderByXxxIdAscCreatedAtAsc(...)`。 + +**循环内是否再查**:**否**。11 处 `forEach`(行 169/199/241/290/349/368/382/409/420/440/460/472) +只消费**预先批量取回**的数据与 `groupBy(...)` 结果做内存组装,循环体内**零 Repository 调用**。 + +**N 增长公式**:不受记录条数影响(见 §4)。 + +--- + +## 2. 会话未读数修复前调用链(E2) + +**调用链**:`MessagingController` → `MessagingService.listConversations(userId)`: +1. `memberRepository.findByUserId(userId)`(1 条) +2. 空则 `return List.of()` —— **不执行 `IN ()`** +3. `conversationRepository.findAllById(convIds)`(1 条) +4. `messageRepository.countUnreadByConversationIds(convIds, userId)`(**1 条分组查询**) +5. 内存排序(`updatedAt` 降序)后 `getOrDefault(cid, 0L)` 补 0 + +`countUnread(convId, userId)`(私有)**仅被 `getConversation`(单会话详情)调用**; +会话列表路径**不再逐会话 count**。 + +--- + +## 3. 修复前后查询次数公式 + +| 场景 | 修复前(R01-D2/D3 之前) | 当前(HEAD) | +|---|---|---| +| E1 导出 | `O(N)`:每类数据在循环内按 ID 再查 | **`O(1)` 常数**(每个数据类别固定 1 条批量查询) | +| E2 会话列表 | `O(K)`:每会话 1 次 `countUnread` | **`O(1)` 常数 3 条**(成员、会话、分组未读各 1 条) | + +--- + +## 4. 1 / 10 / 50 数据规模下 E1 SQL 数(实测) + +`R01EQueryBudgetAcceptanceTest.personalDataCollectQueryCountIsFlat`, +经 `CountingDataSource` 统计**真实 JDBC 语句**(`prepareStatement`/`createStatement`): + +| 投递记录数 | 实测 SQL 条数 | +|---|---| +| **1** | **20** | +| **10** | **20** | +| **50** | **20** | + +**恒定 20 条**,与记录数**完全无关**(每次 `mvn` 为全新 JVM + 全新 H2 库;断言为精确相等)。 + +--- + +## 5. 1 / 20 / 100 会话规模下 E2 SQL 数(实测) + +`R01EQueryBudgetAcceptanceTest.unreadQueryCountIsFlat`: + +| 会话数 | 实测 SQL 条数 | 每会话未读 | +|---|---|---| +| **1** | **3** | 1 | +| **20** | **3** | 1(逐会话校验全部为 1) | +| **100** | **3** | 1(逐会话校验全部为 1) | + +**恒定 3 条**,不随会话数增长;三种规模下**逐会话 `unreadCount` 值全部正确**。 + +--- + +## 6. 批量 Repository / 投影契约 + +| 组件 | 契约 | +|---|---| +| E1 | 既有 `...In...` 批量方法 + `groupBy(values, idExtractor)` 内存分组;`Map>` 强类型键 | +| E2 | `MessageRepository.countUnreadByConversationIds(List conversationIds, Long readerUserId)` → `List` 投影(`conversationId` / `unreadCount`);**原生查询、全参数化**,无字符串拼接用户可控 ID | + +--- + +## 7. 未读数 WHERE / GROUP BY 的准确语义(逐字取自原生查询) + +```sql +SELECT ranked.conversation_id AS conversationId, COUNT(*) AS unreadCount + FROM ( SELECT m.id, m.conversation_id, m.status, + ROW_NUMBER() OVER (PARTITION BY m.conversation_id ORDER BY m.created_at DESC) AS row_no + FROM messages m + WHERE m.conversation_id IN (:conversationIds) ) ranked + LEFT JOIN message_reads mr ON mr.message_id = ranked.id AND mr.reader_user_id = :readerUserId + WHERE ranked.row_no <= 500 + AND (ranked.status IS NULL OR ranked.status <> 'RECALLED') + AND mr.id IS NULL + GROUP BY ranked.conversation_id +``` + +| 语义点 | 事实 | +|---|---| +| 可见范围 | 由**会话成员关系**界定(调用方先取 `findByUserId` 得到 `convIds`),**非**由用户传入 ID 决定 | +| 会话页之外 | **不查询**(只传当前用户的会话 id 列表) | +| 撤回消息 | **排除**(`status <> 'RECALLED'`) | +| 已读消息 | **排除**(`message_reads` 存在该读者的记录) | +| 窗口上限 | 每会话**最近 500 条**(`row_no <= 500`) | +| 发送者过滤 | **无** —— 见 **OBS-E01** | +| 未返回的会话 | Service 侧 `getOrDefault(cid, 0L)` 补 0 | + +**OBS-E01 的实测证据**:同一会话内「本人 1 条 + 对方 2 条、均未读」时 +`unreadCount = 3`(最初按"他人发送才计"预期的 2 被实测纠正)。 +本轮**如实锁定既有语义**,未做任何行为改变。 + +--- + +## 8. H2 / MySQL 逐值一致结果 + +`R01EMySqlQueryCountAndExplainTest`(Testcontainers `mysql:8.4`,**4/4 通过**,耗时 34.94s → 容器真实启动): + +| 用例 | 结论 | +|---|---| +| `groupedUnreadMatchesPerConversationSemantics` | MySQL 上分组 SQL 与 Service 路径**逐值一致**;`c1`: 3 条 SENT − 1 条 RECALLED − 1 条已读 = **1**;`c2`: **1** | +| `emptyAndForeignConversations` | 空 id 列表与不含该会话的 id 列表**均不返回** | +| `conversationScaleIsFlatAndCorrect` | 1 / 20 / 100 会话下 Service 结果条数与逐会话 `unreadCount`(各 3 条未读 = **3**)**全部正确** | +| `explainUnreadQuery` | 见 §9 | + +**H2 与 MySQL 一致性**:同一组夹具语义(撤回排除、已读排除、多会话聚合、缺失补 0)在两侧 +得到**相同数值**;H2 侧见 §4/§5/§13,MySQL 侧见上表。 + +--- + +## 9. MySQL EXPLAIN 摘要 + +对**与 `countUnreadByConversationIds` 逐字相同**的 SQL、同一批绑定参数执行 +`EXPLAIN FORMAT=JSON`(MySQL 8.4;提交前收口测试增加 10,000 条非目标背景消息, +使 1 个目标会话 × 200 条消息保持高选择性): + +| 项 | 值 | +|---|---| +| JSON 长度 | 3898 字符 | +| `using_temporary_table` | **true**(位于 `grouping_operation` 层,为 `GROUP BY conversation_id` 的固有代价) | +| `using_filesort` | 提交前实施夹具(1 会话 × 200 条、10,000 条背景)为 **false**;独立复验的 1/20/100/600 四档真实规模均为 **true** | +| 派生表 `ranked` | `access_type = ALL`,即对物化后的派生结果顺序扫描;不能表述成“没有全表扫描” | +| 基础表 `messages`(别名 `m`) | `access_type = ref/range`,命中 `idx_messages_conversation_created`,不是 `ALL` | +| 基础表 `message_reads`(别名 `mr`) | `access_type = eq_ref`,命中 `uk_message_reads` | +| 其它字段 | `possible_keys` / `key` / `cost_info` / `rows_examined_per_scan` 均已出现;摘要落盘 `target/r01e-mysql-explain.txt` | + +**判读**:分组所需的临时表及窗口函数排序属于既有 SQL 的固有代价;最小实施夹具与 +真实规模对 `using_filesort` 的代价选择不同,而独立复验四档真实规模均为 +`using_temporary_table=true`、`using_filesort=true`。`ranked` 的 `ALL` 是派生结果顺序扫描; +必须与基础表区分。基础表 `messages` 在高选择性场景命中既有复合索引且不是 `ALL`, +`message_reads` 以唯一键 `eq_ref` 访问。 + +独立复验发现原实施测试使用 +`doesNotContain("\"table_name\": \"messages\" ...")`,但 MySQL JSON 使用别名 `m`, +因此该断言不具鉴别力。提交前已改为解析 JSON 树,分别定位唯一的 `m`、`mr`、`ranked` +节点,并校验访问类型、索引、临时表和 filesort;错误索引、`m=ALL`、缺失 `mr` 三种变异 +都会使断言失败。 + +--- + +## 10. 使用到的索引 + +`V9__messaging.sql` 中**既有**索引(**本轮未新增任何迁移或索引**): + +| 索引 | 表 | 列 | +|---|---|---| +| `idx_messages_conversation_created` | `messages` | `(conversation_id, created_at)` | +| `idx_message_reads_message` | `message_reads` | `(message_id)` | +| `idx_message_reads_reader` | `message_reads` | `(reader_user_id)` | +| `idx_conv_members_user` | `conversation_members` | `(user_id)` | +| `idx_conversations_updated` | `conversations` | `(updated_at)` | + +未读查询的 `WHERE m.conversation_id IN (...)` 与 +`ROW_NUMBER() ... ORDER BY m.created_at DESC` 由 +`idx_messages_conversation_created (conversation_id, created_at)` 支撑; +`LEFT JOIN message_reads` 由 `idx_message_reads_message` / `idx_message_reads_reader` 支撑。 + +独立复验还确认:当目标会话消息占全表约 5.45% 时仍使用索引,约 13.63% 时优化器会 +基于代价转为基础表全扫;这是选择性变化,不是索引缺失。窗口排序、派生表物化及高占比 +场景仍是遗留性能风险,但 EXPLAIN 未证明必须新增索引,故本轮未新增 V46。 + +--- + +## 11. 行级副作用快照 + +`R01EQueryBudgetAcceptanceTest` 对 **11 张表**做稳定字段行级快照(按主键排序)并逐字段比对: + +| 表 | 快照列 | +|---|---| +| `users` | id, phone, nickname, role, is_active | +| `user_roles` | user_id, role | +| `conversations` | id, type, created_by, updated_at | +| `conversation_members` | conversation_id, user_id | +| `messages` | id, conversation_id, sender_user_id, status | +| `message_reads` | id, message_id, reader_user_id | +| `notifications` | id, user_id, type, title | +| `audit_logs` | id, user_id, action, entity_type | +| `auth_tokens` | id, user_id, token_type, revoked | +| `applications` | id, job_id, student_user_id, status | +| `job_favorites` | id, job_id, student_user_id | + +| 用例 | 结论 | +|---|---| +| `listConversationsIsReadOnly` | 会话列表 GET 前后 **11 张表逐字段零变化** ✔ | +| `personalDataCollectIsReadOnly` | `collector.collect()` 前后 **11 张表逐字段零变化** ✔ | + +--- + +## 12. RabbitMQ / 通知 / 审计结果 + +| 项 | 结论 | +|---|---| +| 通知 | `notifications` 行数在 `collect()` 前后**完全相等** ✔ | +| 审计 | `audit_logs` 行数在 `collect()` 前后**完全相等** ✔;且快照逐字段零变化 ✔ | +| RabbitMQ | 两条路径(`listConversations`、`collect`)均不发送消息;测试期间无新增 MQ 相关行 ✔ | +| 日志 | 未新增记录消息内容 / 手机号 / Token / 导出内容的日志语句(生产 diff = 0,无新增日志) | + +--- + +## 13. 新增测试及变异/反证 + +| 测试类 | Tests | 内容 | +|---|---|---| +| `common/r01e/R01EQueryBudgetAcceptanceTest` | **11** | E1/E2 查询预算(H2,真实 JDBC 计数)、既有未读语义(含 OBS-E01)、已读/撤回排除、多会话聚合与补 0、跨用户不泄漏、**11 张表行级副作用快照**、通知/审计零新增 | +| `common/r01e/R01EMySqlQueryCountAndExplainTest` | **4** | MySQL 8 上分组 SQL 与逐会话语义**逐值一致**、空列表与不属用户的 id 不返回、1/20/100 会话规模、`EXPLAIN FORMAT=JSON` | + +**反证设计**:查询预算断言为**精确相等**(非"不显著增长")——若任一实现退化为逐条查询, +`baseline[1] != baseline[0]` 会立即失败;副作用快照为**逐字段相等**,任何写入都会被捕获。 +测试类通过 `CountingDataSource` 统计**真实 JDBC 语句**,**不是** Mockito verify Repository。 + +**本轮自查发现并修复的测试缺陷(诚实记录)**:`R01EQueryBudgetAcceptanceTest` 初版只有 +`@BeforeEach` 清理、**缺 `@AfterEach`**,退场后遗留 `r01e-*` 用户/公司/会话,导致全量运行中 +`PersonalDataExportControllerTest`(15)、`CompetitionRankingControllerTest`(13) 等 **60 项失败**。 +已补 `@AfterEach` 精确清理,复跑 `R01EQueryBudgetAcceptanceTest,PersonalDataExportControllerTest, +CompetitionRankingControllerTest` → **39/39 通过**。**这是本轮新增测试自身的缺陷,非既有实现问题。** + +--- + +## 14. 定向、关联、全量和 clean verify 数量 + +| 命令 | 结果 | +|---|---| +| 关联回归(`*PersonalData*Test,*Export*Test,*Messaging*Test,*Message*Test,*Conversation*Test,*Recall*Test`) | **150 / 150**,F=0 E=0 S=0 ✔ | +| 性能/验收定向 | 见 §14.1 | +| `mvn test` | 见 §14.1 | +| `mvn clean verify` | 见 §14.1 | + +### 14.1 全量与 clean verify + +| 命令 | Tests | Failures | Errors | Skipped | BUILD | +|---|---|---|---|---|---| +| 性能/验收定向(含 MySQL Testcontainers) | **18** | 0 | 0 | **0** | SUCCESS | +| `mvn test` | **2761** | **0** | **0** | **0** | SUCCESS | +| `mvn clean verify` | **2761** | **0** | **0** | **0** | SUCCESS | + +**2761 = 2746(R01-C 阶段二基线)+ 本轮新增 15**(H2 11 + MySQL 4),与要求「不低于 2746 + 新增数」一致。 +**Skipped=0** 且 MySQL 用例耗时 34.94s,证明 **Testcontainers 真实执行、未被跳过**。 + +**关联回归**:`*PersonalData*Test,*Export*Test,*Messaging*Test,*Message*Test,*Conversation*Test,*Recall*Test` +→ **150 / 150**,F=0 E=0 S=0 ✔ + +--- + +## 15. OpenAPI 数量 + +``` +python ..\docs\verify_openapi.py → paths: 176 / operations: 214 / All checks passed! +``` + +`git diff -- docs/openapi-rc.json` → **空** ✔ + +--- + +## 16. 精确修改文件 + +**生产代码:0 个文件。** + +**新增测试(2)** + +``` +mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java +mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EMySqlQueryCountAndExplainTest.java +``` + +**文档(1)**:本报告 + +--- + +## 17. 是否修改迁移、ErrorCode、接口、DTO、依赖 + +| 项 | 结果 | +|---|---| +| V1–V45 / Flyway | **未修改** ✔ | +| `ErrorCode` | **未修改** ✔ | +| 公网接口 / DTO / OpenAPI 契约 | **未修改**(176/214) ✔ | +| `pom.xml` / 依赖 | **未修改** ✔ | +| Security / Token / 权限 / requestId | **未修改** ✔ | +| 评分、排名、推荐、申诉、邀约、Offer、撤回与已读语义 | **未修改** ✔ | +| 分布式缓存 / 冗余未读列 / 异步最终一致未读数 | **未引入** ✔ | + +--- + +## 18. Git 状态 + +| 项 | 结果 | +|---|---| +| 分支 / HEAD | `refactor/r01-structure-performance` / `b893ed67f078eedb0c4b8a03341311bd79342354`(未变) | +| HEAD 与 upstream | **0 0** ✔ | +| `git diff --cached --name-only` | **空** ✔ | +| `git diff --check` | **exit 0** ✔ | +| 生产代码 diff | **0 个文件** ✔ | +| V1–V45 / `openapi-rc.json` / `pom.xml` / `ErrorCode` | **均未修改** ✔ | +| 历史未跟踪文件 | 完整保留 ✔ | +| 本轮新增(未跟踪) | `docs/r01-e-batch-query-unread-count-performance-report.md`、`mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/`(2 个测试类) | + +--- + +## 19. 明确未暂存、未提交、未推送 + +**确认:未执行 `git add`;未创建提交;未推送。** 未执行 `reset`/`restore`/`checkout`/`clean`。 + +--- + +## 20. 遗留风险 + +| # | 风险 | 说明 | +|---|---|---| +| R1 | **OBS-E01 语义** | 本人发送且未读的消息计入本人未读数。当前实现如此,本轮**未改变**;若产品确认应排除,需另立批次(属业务语义变更,不属性能收束) | +| R2 | 500 条窗口 | 未读统计只看每会话**最近 500 条**;超过 500 条的会话其更早未读不计入。属既有设计,本轮未改动 | +| R3 | 派生表物化 | MySQL 8 对 `ROW_NUMBER()` 派生表可能物化;`ranked.row_no <= 500` 的过滤在物化后生效,大会话下存在额外扫描成本。EXPLAIN 证据见 §9 | +| R4 | 测试自污染 | 本轮新增测试的清理为**按前缀精确删除**;若将来有人新增同类夹具而不清理,仍可能污染共享 H2 库(R01-C 已就同类问题建立隔离门禁,本轮范围外) | + +--- + +> **R01-E既有批量查询与会话未读数性能实现已完成验收和测试加固,待独立代码复验。** diff --git a/docs/r01-e-independent-verification-20260918.md b/docs/r01-e-independent-verification-20260918.md new file mode 100644 index 0000000..c22236c --- /dev/null +++ b/docs/r01-e-independent-verification-20260918.md @@ -0,0 +1,412 @@ +# R01-E 批查询与会话未读数:**独立复验报告**(第三~十一节) + +日期:2026-09-18 +分支:`refactor/r01-structure-performance` +HEAD / upstream:`b893ed67f078eedb0c4b8a03341311bd79342354`(ahead/behind = **0 / 0**) +本轮性质:**独立复验** —— 不改生产代码、不改迁移、不暂存、不提交、不推送 +上游依据:`docs/r01-e-batch-query-unread-count-performance-report.md`(实施与测试加固报告) + +--- + +## 0. 结论 + +> **R01-E独立代码与真实MySQL性能复验通过,可以进入精确暂存与独立提交检查点。** + +| 复验项 | 结论 | +|---|---| +| 独立查询预算(E1 = 20/20/20,E2 = 3/3/3) | **通过**(H2 与真实 MySQL 双侧实测) | +| E1 collector 完整输出 H2/MySQL 深比较 | **通过**(逐字段、含 null/时间/集合顺序/关联数据) | +| E2 会话未读 H2/MySQL 深比较(10 项语义) | **通过** | +| MySQL `EXPLAIN FORMAT=JSON` | **通过**(基础表命中既有索引,无需新增 V46) | +| 副作用与隔离(25 张表快照 + 反证 + 零残留) | **通过** | +| OBS-E01 | **保留既有语义**,作为产品负责人待决策项上报 | +| 独立复验时全量 `mvn test` / `mvn clean verify` | **2787 / 0 / 0 / 0** | +| 提交前收口 `mvn clean verify` | **2788 / 0 / 0 / 0**(增加 1 个 EXPLAIN 变异门禁) | +| OpenAPI | **176 paths / 214 operations**,`openapi-rc.json` 未改 | +| 生产代码 diff | **0 个文件** | + +**独立复验新发现(3 项,均如实记录)**: +**F1** 实施测试的 EXPLAIN 断言 `doesNotContain("\"table_name\": \"messages\", ...")` 是**空断言**—— +MySQL 的 EXPLAIN JSON 里 `table_name` 存的是**别名**(本语句为 `m` / `mr`), +`"table_name": "messages"` 永不出现,故该断言无论如何都会通过。结论本身(当时夹具下确实未全扫)成立, +但**断言不具备鉴别力**。提交前收口已将实施测试改为解析 JSON 树,按别名分别定位 +`m` / `mr` / `ranked`,校验 access_type、key、temporary 与 filesort,并以错误索引、 +基础表 `ALL`、缺失节点三类变异证明断言有效。 +**F2** H2 与 MySQL 对同一 JSON 列的回读**表示不同**(详见 §4.2),已记录原始值并按语义归一后比较。 +**F3** 实施报告 §9 记 `using_filesort=false`;本轮在**真实规模夹具**下实测 `using_filesort=true` +(窗口函数 `ROW_NUMBER()` 的排序代价),详见 §6.4。 + +--- + +## 1. 复验范围与不重叠声明 + +- 从第三~十一节执行,**未重做第一、二节库存**,未修改任何生产代码。 +- 复用既有文件(**未重复创建**): + - `mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java`(实施测试,11 项) + - `mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EMySqlQueryCountAndExplainTest.java`(实施测试,4 项) + - `docs/r01-e-batch-query-unread-count-performance-report.md` +- 独立测试**不复用**实施测试的任何夹具、断言或辅助方法:夹具、快照(25 张表、列集合不同)、 + 计数 DataSource、EXPLAIN 解析均为独立实现。 + +--- + +## 2. 本轮新增文件(未跟踪) + +``` +mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java 独立测试,16 项 +mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentSideEffectIsolationTest.java 独立测试,10 项 +docs/r01-e-independent-verification-20260918.md 本报告 +``` + +**生产代码:0 个文件**(`git diff --stat` 为空)。 + +--- + +## 3. 独立查询预算验证(任务 1) + +**方法**:独立实现的 `CountingDataSource` 包装真实 `DataSource`,记录**真实 JDBC 语句文本与条数**; +两侧在**夹具灌入完成之后**才 `reset()`,故计数**不含测试准备与清理 SQL**(断言实测语句全部为 `SELECT`)。 + +### 3.1 E1:1 / 10 / 50 条投递 + +| 夹具 | H2 实测 | MySQL 实测 | 目标 | +|---|---|---|---| +| 与实施报告同基线的最小夹具(2 用户 + 1 公司 + N 岗位 + N 投递) | **[20, 20, 20]** | **[20, 20, 20]** | **20/20/20 ✔** | +| 本类丰富夹具(12 个 section 全部有数据)+ N 投递 | [22, 22, 22] | [22, 22, 22] | 恒定 ✔ | + +- 两者都**不随记录数增长**;与实施报告「20/20/20」的结论**独立复现一致**。 +- 丰富夹具为 22(比最小夹具多 2):多出的是「本人持有公司 → offers 企业侧分支」以及 + 「存在会话 → conversations/messages 两条批量查询」这两个**按分支固定**的查询,仍与 N 无关。 +- 证据:`target/r01e-independent/e1-budget.txt`、`e1-budget-rich.txt`。 + +### 3.2 E2:1 / 20 / 100 会话 + +| 会话数 | H2 实测 | MySQL 实测 | 目标 | +|---|---|---|---| +| 1 | **3** | **3** | 3 ✔ | +| 20 | **3** | **3** | 3 ✔ | +| 100 | **3** | **3** | 3 ✔ | + +- 实测 SQL 文本**恰好为 3 条**,形态固定且可辨认: + 1. `select ... from conversation_members ... where user_id=?` + 2. `select ... from conversations ... where id in (?,...)` + 3. 分组未读查询(含 `ROW_NUMBER()`)——**只出现 1 次** +- 证据:`target/r01e-independent/e2-budget.txt`(含两侧完整 SQL 文本)。 + +### 3.3 空会话页不执行未读查询 + +| 断言 | H2 | MySQL | +|---|---|---| +| `listConversations(无会话用户)` 返回空 | ✔ | ✔ | +| 实测语句数 | **1** | **1** | +| 该语句不含 `ROW_NUMBER` | ✔ | ✔ | +| 含 `row_number` 的语句数 | **0** | **0** | + +即:空会话页只发出一条成员关系查询后短路返回,**不下发未读聚合查询**。 + +--- + +## 4. E1 H2 / MySQL 深比较(任务 2) + +### 4.1 方法 + +同一个 JVM 内同时持有两个 Spring 上下文: + +- **H2 侧**:测试自身上下文,`jdbc:h2:mem:r01e_ind;MODE=MySQL`(**本类独占**的内存库,Flyway 全量迁移); +- **MySQL 侧**:`@BeforeAll` 手工引导的第二个上下文(Testcontainers `mysql:8.4`,`mysql-it` 配置, + `WebApplicationType.NONE`,命令行参数覆盖数据源),Flyway 在容器内真实执行 45 个迁移。 + +两侧灌入**同一份等价夹具**(显式时间戳、无 `CURRENT_TIMESTAMP` 依赖),覆盖 **全部 12 个 section**, +并额外放入一个「外人」用户的同类数据。 + +**先决条件已实证**:`bothEnginesAreLiveAndFixtureIdsAgree` 断言两侧 27 个自然键 → 主键映射**完全相等** +(同一 JVM、两侧均为全新建库),故后续可直接比较含主键的完整输出。 + +### 4.2 深比较结果 + +`canonicalResult(...)` 把 collector 的**完整 section 输出**规范化为确定性文本:显式呈现 `null`、 +时间按 `LocalDateTime` 呈现、List 保持原序(集合顺序)、Map 按 key 排序、`BigDecimal` 去尾零。 + +``` +e1-h2.txt == e1-mysql.txt (均 6213 字节,逐字符相同) +``` + +**JSON 列的表示差异(F2,如实记录)**——`e1-raw-json.txt` 原文: + +``` +h2: profile.skills="{\"java\":3,\"sql\":2}" | preferences.preferredCities="[\"北京\",\"上海\"]" +mysql: profile.skills={"sql": 2, "java": 3} | preferences.preferredCities=["北京", "上海"] +``` + +- **H2(MODE=MySQL)**把 JSON 列回读为**被转义的 JSON 字符串标量**; +- **MySQL 8**回读为 **JSON 对象字面量**(键重排、冒号后加空格)。 +- 两者**语义相同、表示不同**:比较的是语义。规范化对确实是 JSON 的字符串递归解码后按键排序, + 非 JSON 字符串原样保留(`content`、`coverLetter` 等不受影响)。 + +**反证**:把 dump 中任意一个字段值(`"张三"` → `"张四"`)置换后断言必须不再相等——通过, +说明规范化是**逐字段敏感**的,而不是只比条数、hash 或长度。 + +### 4.3 其他断言 + +| 断言 | 结果 | +|---|---| +| section 顺序 | 精确等于 `profile, preferences, resumes, applications, application-timelines, interviews, interview-timelines, offers, offer-timelines, feedback, favorites, notifications, conversations, messages, candidate-scoring, scoring-appeals` ✔ | +| 关联数据 | 应用↔时间线、面试↔时间线、Offer↔时间线、会话↔消息的分组关系随主键一并跨库一致 ✔ | +| 其他用户数据不得混入 | dump 中不含 `李四 / 外人的简介 / 外人简历 / 外人求职信 / 外人评价 / 外人通知 / 外人会话 / out-m1 / 外人Offer / 外人理由 / 外人面试 / outsider` ✔ | +| 跨库自有行数 | 23 张表的自有行数两侧完全一致 ✔ | +| 非 ACTIVE 简历、`read_at` 为 null / 非 null 的通知、`score` 为 null 的维度分、`tags` 为 null 的评价、`cover_letter` 为 null 的投递 | 均已覆盖并通过 ✔ | + +--- + +## 5. E2 H2 / MySQL 深比较(任务 3) + +10 项语义逐条覆盖,**两侧结果逐值一致**(会话列表完整视图规范化后逐字符相同): + +| # | 语义 | H2 | MySQL | 结果 | +|---|---|---|---|---| +| 1 | **本人发送未读按现状计入(OBS-E01)** | c1 = 2(本人 1 + 他人 1) | c1 = 2 | ✔ 一致 | +| 1b | 同上,把本人那条标记已读后 | 1 | 1 | ✔ | +| 2 | 他人发送未读计入 | ✔ | ✔ | ✔ | +| 3 | `message_reads` 排除 | 读 1 条后 2 → 1 | 同 | ✔ | +| 3b | 另一读者的已读记录不影响本人 | 不受影响 | 不受影响 | ✔ | +| 4 | `RECALLED` 排除 | 撤回 1 条后 2 → 1 | 同 | ✔ | +| 5 | 多会话分组 | c1=2, c2=1, c3=1 | 同 | ✔ | +| 6 | 非当前用户会话不返回 | 只返回 c1/c2/c3;cOutsider 不在 | 同 | ✔ | +| 7 | 聚合缺失补 0 | 全部撤回/已读时聚合缺行 → 0 | 同 | ✔ | +| 8 | **最近第 500 条计入、第 501 条排除** | 501 条未读 → **500**;把最旧一条标已读 → **仍 500**;把最新一条标已读 → **499** | 同 | ✔ | +| 9 | **`created_at` 相同的排序语义** | 同刻 3 条 → 3;其中 1 条已读 → 2;同刻 501 条 → **500** | 同 | ✔ | +| 10 | **空及重复 `conversationIds`** | 重复 `[c1,c1]` 只产 1 行且与 `[c1]` 逐值相同;空列表由 Service 短路、不下发 `IN ()` | 同 | ✔ | + +**第 8 项证明方式**:不止断言「等于 500」,而是通过「标已读最旧一条 → 不变」证明**最旧一条本就在窗口之外**, +「标已读最新一条 → 499」证明**窗口保留的是最新一侧**。 + +**第 9 项的诚实边界**:`ROW_NUMBER() ... ORDER BY m.created_at DESC` **没有 tiebreaker**, +故当 501 条 `created_at` 完全相同时,「保留哪 500 条」**由引擎决定**; +但**窗口计数与引擎无关,恒为 500**——两侧实测一致。此点已计入遗留风险(R2)。 + +--- + +## 6. MySQL `EXPLAIN FORMAT=JSON`(任务 4) + +**被测语句**与 `MessageRepository.countUnreadByConversationIds` **逐字相同**,绑定同一批参数。 +**夹具**:先铺入 **500 个背景会话 × 20 条 = 10 000 条消息**(不属于查询范围),再替换目标会话—— +否则 `messages` 表几乎只含查询范围内的行,MySQL 选择全扫是**正确**的代价决策, +却会让 EXPLAIN 失去验收意义。 + +### 6.1 逐表结果(`messages` / `message_reads` / 派生结果 `ranked` 分开报告) + +> **注意**:MySQL 的 `EXPLAIN FORMAT=JSON` 中 `table_name` 是**别名**。 +> 被测语句的别名为 `messages m`、`message_reads mr`、派生表 `ranked`, +> 故表节点分别为 `m` / `mr` / `ranked`。 + +| 规模 | selectivity | `ranked`(派生结果) | `messages`(别名 `m`) | `message_reads`(别名 `mr`) | temporary | filesort | +|---|---|---|---|---|---|---| +| 1 会话 × 3 条 | 0.01% | **ALL**(派生结果顺序扫描)· rows=3 | **ref** · `idx_messages_conversation_created` · rows=3 | **eq_ref** · `uk_message_reads` · rows=1 | true | true | +| 20 会话 × 3 条 | 0.27% | **ALL** · rows=60 | **range** · `idx_messages_conversation_created` · rows=60 | **eq_ref** · `uk_message_reads` · rows=1 | true | true | +| 100 会话 × 3 条 | 1.36% | **ALL** · rows=300 | **range** · `idx_messages_conversation_created` · rows=300 | **eq_ref** · `uk_message_reads` · rows=1 | true | true | +| **1 会话 × 600 条(> 500 窗口)** | 2.73% | **ALL** · rows=600 | **ref** · `idx_messages_conversation_created` · rows=600 | **eq_ref** · `uk_message_reads` · rows=1 | true | true | + +**判读**: + +- **派生结果 `ranked` 的 `access_type = ALL` 必须准确表述为「派生结果顺序扫描」**—— + 这是 MySQL 对 `ROW_NUMBER()` 派生表**物化**后顺序读取物化结果的固有形态, + `possible_keys`/`key` 均为 `null`(派生表上不存在索引)。**不得**笼统写成「无全表扫描」。 +- **`messages` 基础表在四个验收断言规模下命中既有索引 `idx_messages_conversation_created`(`ref` / `range`)**; + 更高选择性探测会转为 `ALL`,详见下一节,不能笼统表述为“没有全表扫描” ✔ +- **`message_reads` 基础表在所有规模下均为 `eq_ref` 命中 `uk_message_reads`** ✔ + (`possible_keys` 同时列出 `idx_message_reads_message` / `idx_message_reads_reader`) +- `possible_keys`、`key`、`rows_examined_per_scan`、`filtered`、`using_temporary_table`、`using_filesort` + 均已逐规模记录,原文见 `target/r01e-independent/e2-explain.txt`。 + +### 6.2 代价拐点(选择性探测,只记录不判定) + +| 规模 | selectivity | `messages` 访问方式 | +|---|---|---| +| 2 会话 × 600 条(1 200 行) | 5.45% | **range** · `idx_messages_conversation_created`(命中索引) | +| 5 会话 × 600 条(3 000 行) | 13.63% | **ALL** · key=null · rows=13 093(转为全扫) | +| 20 会话 × 600 条(12 000 行) | 54.53% | **ALL** · key=null · rows=22 016(转为全扫) | + +**结论**:拐点被夹逼在 **5.45% 与 13.63%** 之间。也就是说, +只有当「被查询的会话集合占 `messages` 全表 6% 以上」时,MySQL 才会放弃索引改走全扫—— +这是**代价决策,不是缺索引**。真实场景中一个用户的会话列表远低于此比例, +故本项判定 **PASS**,**不新增 V46**(严格遵守指令)。 + +### 6.3 `using_temporary_table = true` 的归属 + +`true` 出现在 `grouping_operation` 层,是 `GROUP BY ranked.conversation_id` 的固有代价, +与派生表物化同源,**不是**新增索引可以消除的。 + +### 6.4 `using_filesort` 的更正(F3) + +实施报告 §9 记录 `using_filesort=false`(其夹具为 1 会话 × 200 条)。 +本轮在**带背景数据的真实规模夹具**下,1/20/100/600 四档**全部实测 `using_filesort=true`**—— +来源是窗口函数 `ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY created_at DESC)` 的排序。 +这是**既有实现的固有代价**,本轮不改动,仅如实更正记录。 + +--- + +## 7. 副作用与隔离(任务 5) + +独立测试 `R01EIndependentSideEffectIsolationTest`,运行在**共享的 integration H2 库**上(与实施测试同库, +因此可以直接观测实施测试的夹具残留)。 + +### 7.1 逐字段快照(**25 张表**,独立于实施测试的 11 张) + +`users`(6 列)、`user_roles`、`companies`、`jobs`、`applications`、`application_status_history`、 +`interviews`、`interview_status_history`、`offers`、`offer_status_history`、`candidate_feedback`、 +`job_favorites`、`notifications`、`audit_logs`、`auth_tokens`、`conversations`、`conversation_members`、 +`messages`(含 `version`)、`message_reads`、`student_profiles`、`student_preferences`、`resume_versions`、 +`candidate_dimension_scores`、`candidate_scoring_appeals`、`candidate_scoring_appeal_history` + +| 用例 | 结论 | +|---|---| +| `collector.collect()` 前后 | **25 张表逐字段零变化** ✔ | +| `listConversations()` 前后 | **25 张表逐字段零变化** ✔ | +| `listConversations + getConversation + collect` 组合 | **25 张表逐字段零变化** ✔ | + +**读取路径零写**:夹具覆盖全部 12 个 section(collect 走满所有分支),快照仍零变化。 + +### 7.2 通知 / 审计 / RabbitMQ + +| 项 | 结论 | +|---|---| +| `notifications` 行数与逐字段内容 | 读前后**完全相等** ✔ | +| `audit_logs` 行数与逐字段内容 | 读前后**完全相等** ✔ | +| **RabbitMQ** | 以 `@MockBean RabbitTemplate` 注入,读路径结束后 `verifyNoInteractions(rabbitTemplate)` **通过**——**零 AMQP 交互** ✔ | +| MQ 配置 | `mysql-it` / `integration-test` 均置 `app.notification.mq-enabled: false`、`spring.rabbitmq.enabled: false`,`NotificationConsumer` 条件装配关闭 ✔ | + +### 7.3 反证:快照必须真的能发现变化 + +| 反证用例 | 期望 | 结果 | +|---|---|---| +| 修改一行(`users.nickname`) | 断言必须失败 | **AssertionError,含 `users`** ✔ | +| 新增一行(`notifications`) | 断言必须失败 | **AssertionError,含 `notifications`** ✔ | +| 删除一行(`message_reads`) | 断言必须失败 | **AssertionError,含 `message_reads`** ✔ | +| **仅单列变化**(`messages.status` → `RECALLED`) | 断言必须失败 | **AssertionError,含 `messages`** ✔ | + +若无此反证,「零变化」可能只是**没在比**。 + +### 7.4 夹具零残留 + +| 检查 | 结果 | +|---|---| +| 本类自有夹具(`r01eiso-%`)退场后残留 | **0**(`@AfterEach` 精确清理 + `@AfterAll` 门禁)✔ | +| **实施测试的夹具(`r01e-%`,排除本类前缀)残留** | **0** ✔ | +| 实施测试遗留的 `messages` / `r01e-co` / `r01e-job` | **0** ✔ | + +**关于此前「缺 `@AfterEach` 导致 60 项失败」的专项复验**:H2 内存库按 JVM 共享, +只有让 `R01EQueryBudgetAcceptanceTest` **先于**独立类运行,残留检查才具备鉴别力。 +为消除运行顺序的不确定性,本轮**追加一次逆序运行**: + +``` +mvn -o -Dsurefire.runOrder=reversealphabetical -Dtest="R01EQueryBudgetAcceptanceTest,R01EMySqlQueryCountAndExplainTest,R01EIndependentCrossDatabaseVerificationTest,R01EIndependentSideEffectIsolationTest" test +→ 实际顺序:R01EQueryBudgetAcceptanceTest(11) → R01EMySqlQueryCountAndExplainTest(4) + → R01EIndependentSideEffectIsolationTest(10) → R01EIndependentCrossDatabaseVerificationTest(16) +→ 独立复验:Tests run: 41, Failures: 0, Errors: 0, Skipped: 0 +→ 提交前收口:Tests run: 42, Failures: 0, Errors: 0, Skipped: 0(新增 EXPLAIN 变异门禁) +``` + +即:实施测试跑完后,独立类的 `implementationFixturesLeaveNoResidue` 与 `@AfterAll` 门禁 +**直接确认其夹具残留为 0**,旧问题已修复且可被独立观测。 + +--- + +## 8. OBS-E01(任务 6) + +**观察项(本轮不改变既有语义,作为产品负责人待决策项上报)**: + +> **本人发送且没有当前用户 `message_reads` 记录的消息,计入本人未读数。** + +| 证据 | H2 | MySQL | +|---|---|---| +| 会话内「本人 1 条 + 他人 2 条,均未读」 | 未读 = **3**(不是 2) | **3** | +| 会话内「他人 1 条 + 本人 1 条 + 1 条撤回」 | 未读 = **2** | **2** | +| 把本人发送的那条写入 `message_reads` | 未读减为 **1** | **1** | + +- 该行为源自未读查询的 `WHERE` 中**没有发送者过滤**(只按 `conversation_id` 与读者 `message_reads` 判定)。 +- 本轮**未修改 SQL、未修改任何生产代码**,仅将既有语义**如实锁定并双侧取证**。 +- 若产品确认应排除本人发送的消息,属**业务语义变更**,需另立批次,不在本次性能收束范围内。 + +--- + +## 9. 门禁结果(任务完成后的规定命令) + +| # | 命令 | Tests | Failures | Errors | Skipped | 结果 | +|---|---|---|---|---|---|---| +| 1 | `mvn -Dtest="R01EQueryBudgetAcceptanceTest,R01EMySqlQueryCountAndExplainTest,R01EIndependentCrossDatabaseVerificationTest,R01EIndependentSideEffectIsolationTest" test` | **41** | 0 | 0 | **0** | SUCCESS | +| 1b | 同上 + `-Dsurefire.runOrder=reversealphabetical`(残留门禁) | **41** | 0 | 0 | **0** | SUCCESS | +| 2 | `mvn -Dtest="*PersonalData*Test,*Export*Test,*Messaging*Test,*Message*Test,*Conversation*Test,*Recall*Test" test` | **150** | 0 | 0 | **0** | SUCCESS | +| 3 | 独立复验 `mvn test` | **2787** | **0** | **0** | **0** | SUCCESS | +| 4 | 独立复验 `mvn clean verify` | **2787** | **0** | **0** | **0** | SUCCESS | +| 5 | 提交前收口 `mvn clean verify` | **2788** | **0** | **0** | **0** | SUCCESS | +| 5 | `python ..\docs\verify_openapi.py` | paths **176** / operations **214** | — | — | — | `All checks passed!` | + +**测试计数对账**:独立复验为 `2761(R01-C 阶段二基线)+ 26(独立新增:16 跨库 + 10 副作用)= 2787`; +提交前收口再增加 1 个 EXPLAIN 变异门禁,最终为 **2788** ✔ +(实施测试的 11 + 4 = 15 已含在 2761 内,本轮未新增实施测试。) + +### 9.1 实施测试 / 独立测试 明细 + +| 类别 | 测试类 | 项数 | 引擎 | +|---|---|---|---| +| **实施测试** | `R01EQueryBudgetAcceptanceTest` | 11 | H2 | +| **实施测试** | `R01EMySqlQueryCountAndExplainTest` | 4 | **MySQL(Testcontainers)** | +| **独立测试** | `R01EIndependentCrossDatabaseVerificationTest` | 16 | **H2 + MySQL(同 JVM 双上下文)** | +| **独立测试** | `R01EIndependentSideEffectIsolationTest` | 10 | H2(共享 integration 库) | +| | **合计** | **41** | | + +### 9.2 Testcontainers / MySQL **实际执行**证据 + +- Docker Server **29.6.2**;`mysql:8.4` 容器日志实测:`Container mysql:8.4 started in PT7.4093471S`。 +- `R01EMySqlQueryCountAndExplainTest` 标注 `@Testcontainers(disabledWithoutDocker = true)`—— + 若 Docker 不可用该类的 4 项会被 **skip**;实测 **Skipped = 0**,故容器**真实启动并执行**。 +- `R01EIndependentCrossDatabaseVerificationTest` 断言 `MySql.container.isRunning()` 为 `true`, + 并在 MySQL 上真实执行了 Flyway 全量迁移、跨库深比较、`ANALYZE TABLE` 与 `EXPLAIN FORMAT=JSON`。 +- 两类 MySQL 用例耗时分别为 **122.5 s** 与 **17.2 s**(`clean verify` 单次运行),与容器冷启动 + 万级夹具灌入相符。 + +--- + +## 10. 生产代码与契约不变性 + +| 项 | 结果 | +|---|---| +| 生产代码 diff | **0 个文件**(`git diff --stat` 空) | +| V1–V45 迁移 / Flyway | **未修改**(目录 `git status` 0 项,仍为 45 个版本文件) ✔ | +| `pom.xml` / 依赖 | **未修改** ✔ | +| `ErrorCode` | **未修改** ✔ | +| 公网接口 / DTO / OpenAPI 契约 | **未修改**(176 / 214) ✔ | +| `docs/openapi-rc.json` | **未修改** ✔ | +| 新增迁移 V46 | **未新增**(EXPLAIN 未发现必须新增索引的情形) ✔ | +| Security / Token / 权限 / requestId | **未修改** | +| 评分、排名、推荐、申诉、邀约、Offer、撤回与已读语义 | **未修改** | +| 分布式缓存 / 冗余未读列 / 异步最终一致未读数 | **未引入** | + +--- + +## 11. Git 状态(未暂存 / 未提交 / 未推送) + +| 项 | 结果 | +|---|---| +| 分支 / HEAD | `refactor/r01-structure-performance` / `b893ed67f078eedb0c4b8a03341311bd79342354`(**未变**) | +| HEAD 与 upstream | **0 / 0** ✔ | +| 暂存区 `git diff --cached --name-only` | **空** ✔ | +| 本轮新增(未跟踪) | 2 个独立测试类 + 本报告 | +| 既有未跟踪文件 | 完整保留,未删除、未移动 | + +**确认:全程未执行 `git add` / `commit` / `push`,未执行 `reset` / `restore` / `checkout` / `clean`。** + +--- + +## 12. 遗留风险 + +| # | 风险 | 说明 | +|---|---|---| +| R1 | **OBS-E01 语义** | 本人发送且无本人 `message_reads` 的消息计入本人未读数。既有实现如此,本轮未改;如需变更属业务语义变更,须另立批次 | +| R2 | **`created_at` 并列无 tiebreaker** | 未读窗口的 `ORDER BY m.created_at DESC` 无二级排序键。并列跨越 500 边界时「保留哪 500 条」由引擎决定(**计数恒为 500**,两侧一致)。`messages.created_at` 为秒级 `DATETIME`,高并发同秒写入可触发 | +| R3 | **派生表物化** | MySQL 对 `ROW_NUMBER()` 派生表物化,`ranked` 为派生结果顺序扫描(`access_type=ALL`),且 `using_temporary_table=true`、`using_filesort=true` 为固有代价。大会话(> 每表 6% 选择性)下 `messages` 转为全扫,属代价决策 | +| R4 | **测试自污染** | 本轮测试均按前缀精确清理;若将来新增同类夹具而不清理,仍可能污染共享 H2 库(R01-C 已建隔离门禁) | +| R5 | **实施测试断言鉴别力(F1)** | 原 EXPLAIN 断言为空断言;提交前收口已改为 JSON 结构断言,并以三类变异证明有效 | + +--- + +> **R01-E独立代码与真实MySQL性能复验通过,可以进入精确暂存与独立提交检查点。** diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java new file mode 100644 index 0000000..a2cae93 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java @@ -0,0 +1,1549 @@ +package com.mydreamjob.common.r01e; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.mydreamjob.MyDreamjobApplication; +import com.mydreamjob.dataexport.PersonalDataCollector; +import com.mydreamjob.messaging.MessagingService; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.testcontainers.containers.MySQLContainer; + +import javax.sql.DataSource; +import java.lang.reflect.Proxy; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * R01-E **独立复验**(第三~十一节):跨库深比较、查询预算、EXPLAIN、OBS-E01。 + * + *

本类不复用实施测试的任何断言或辅助方法:夹具、规范化(canonicalization)、 + * 计数 DataSource、EXPLAIN 解析均为独立实现。

+ * + *

跨库方式:同一个 JVM 内同时持有两个 Spring 上下文 —— + * 测试自身的 H2 上下文(独立命名的内存库,仅本类使用)与 + * {@link MySql} 手工引导的 MySQL 8.4 上下文(Testcontainers)。 + * 两侧灌入**同一份等价夹具**(显式时间戳),再把 + * {@link PersonalDataCollector#collect} 与 {@link MessagingService#listConversations} + * 的**完整输出**规范化为确定性文本后逐字符比较 —— 不是只比条数、hash 或 JSON 长度。

+ */ +@SpringBootTest +@ActiveProfiles("integration-test") +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:r01e_ind;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" +}) +@ContextConfiguration(classes = {R01EIndependentCrossDatabaseVerificationTest.H2CountingConfiguration.class}) +class R01EIndependentCrossDatabaseVerificationTest { + + // ══════════════════════════════════════════════════════════ + // H2 侧(测试自身的 Spring 上下文) + // ══════════════════════════════════════════════════════════ + + @Autowired private MessagingService h2Messaging; + @Autowired private PersonalDataCollector h2Collector; + @Autowired private JdbcTemplate h2Jdbc; + @Autowired private CountingDataSource h2Counting; + @MockBean private RedisTemplate redisTemplate; + + @TestConfiguration + static class H2CountingConfiguration { + @Bean + @Primary + CountingDataSource dataSource(DataSourceProperties properties) { + return new CountingDataSource(properties.initializeDataSourceBuilder().build()); + } + } + + // ══════════════════════════════════════════════════════════ + // MySQL 侧(手工引导的第二个上下文,独立于测试上下文) + // ══════════════════════════════════════════════════════════ + + static final class MySql { + static MySQLContainer container; + static ConfigurableApplicationContext ctx; + static CountingDataSource counting; + static JdbcTemplate jdbc; + static PersonalDataCollector collector; + static MessagingService messaging; + + static void boot() { + container = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("r01e_independent") + .withUsername("r01e") + .withPassword("r01e"); + container.start(); + ctx = new SpringApplicationBuilder(MyDreamjobApplication.class, MySqlBootstrapConfiguration.class) + .web(WebApplicationType.NONE) + .profiles("mysql-it") + // 命令行参数优先级高于 application.yml,故能真正覆盖主配置里的 DB_URL 默认值 + .run("--spring.datasource.url=" + container.getJdbcUrl(), + "--spring.datasource.username=" + container.getUsername(), + "--spring.datasource.password=" + container.getPassword(), + "--spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver"); + counting = ctx.getBean(CountingDataSource.class); + jdbc = new JdbcTemplate(counting); + collector = ctx.getBean(PersonalDataCollector.class); + messaging = ctx.getBean(MessagingService.class); + } + + static void shutdown() { + if (ctx != null) ctx.close(); + if (container != null) container.stop(); + } + } + + @TestConfiguration + static class MySqlBootstrapConfiguration { + @Bean + @Primary + CountingDataSource mySqlDataSource(DataSourceProperties properties) { + return new CountingDataSource(properties.initializeDataSourceBuilder().build()); + } + + /** mysql-it 关闭了 Redis 自动配置,手工上下文用桩替代 @MockBean。 */ + @Bean + RedisTemplate redisTemplate() { + return Mockito.mock(RedisTemplate.class); + } + } + + @BeforeAll + static void bootMySql() { + MySql.boot(); + } + + @AfterAll + static void shutdownMySql() { + // 收尾前先在两侧做最后一次清理,保证自有夹具零残留 + try { + clearFixture(MySql.jdbc); + } catch (Exception ignored) { + // 清理失败由 fixtureLeavesNoResidueAfterClass 断言兜底 + } + MySql.shutdown(); + } + + @BeforeEach + void resetBothEngines() { + clearFixture(h2Jdbc); + clearFixture(MySql.jdbc); + h2Counting.reset(); + MySql.counting.reset(); + } + + // ══════════════════════════════════════════════════════════ + // 夹具(两侧逐字等价;所有时间戳显式指定,避免依赖 CURRENT_TIMESTAMP) + // ══════════════════════════════════════════════════════════ + + static final LocalDateTime T = LocalDateTime.of(2026, 3, 1, 9, 0, 0); + static final String PREFIX = "r01eind-"; + + private static LocalDateTime at(int minutes) { + return T.plusMinutes(minutes); + } + + private static long insertUser(JdbcTemplate j, String phone, String nickname, String role, LocalDateTime t) { + j.update("INSERT INTO users (phone, nickname, role, is_active, is_verified, created_at, updated_at) " + + "VALUES (?, ?, ?, 1, 1, ?, ?)", phone, nickname, role, t, t); + Long id = j.queryForObject("SELECT id FROM users WHERE phone = ?", Long.class, phone); + j.update("INSERT INTO user_roles (user_id, role) VALUES (?, ?)", id, role); + return id; + } + + /** + * 向指定引擎灌入等价夹具,返回自然键 → 主键的映射。 + * + *

覆盖 {@link PersonalDataCollector} 的**全部 12 个 section**,并额外放入 + * 一个「外人」({@code r01eind-outsider})的同类数据,用于证明不会混入他人数据。

+ */ + static Map seedFixture(JdbcTemplate j) { + Map ids = new LinkedHashMap<>(); + + long me = insertUser(j, PREFIX + "me", "本人", "STUDENT", at(0)); + long other = insertUser(j, PREFIX + "other", "对方", "STUDENT", at(1)); + long outsider = insertUser(j, PREFIX + "outsider", "外人", "STUDENT", at(2)); + long owner = insertUser(j, PREFIX + "owner", "企业主", "COMPANY", at(3)); + ids.put("me", me); + ids.put("other", other); + ids.put("outsider", outsider); + ids.put("owner", owner); + + // ── profile ── + insertProfile(j, me, "张三", "简介文本", "北京", "本科", "示例大学", 2026, "{\"java\":3,\"sql\":2}", at(10)); + insertProfile(j, outsider, "李四", "外人的简介", "上海", "硕士", "其他大学", 2025, "{\"go\":1}", at(11)); + + // ── preferences ── + j.update("INSERT INTO student_preferences (student_user_id, expected_salary_min, expected_salary_max, " + + "preferred_cities, employment_types, preferred_industries, preferred_job_types, " + + "remote_allowed, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)", + me, 15000, 25000, "[\"北京\",\"上海\"]", "[\"FULL_TIME\"]", "[\"互联网\"]", "[\"BACKEND\"]", + at(20), at(21)); + j.update("INSERT INTO student_preferences (student_user_id, expected_salary_min, expected_salary_max, " + + "remote_allowed, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?)", + outsider, 9000, 12000, at(22), at(23)); + + // ── resumes(含 1 条非 ACTIVE,用于证明状态过滤)── + j.update("INSERT INTO resume_versions (user_id, name, version_no, file_storage_path, file_name, " + + "content_type, file_size, default_key, status, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + me, "主简历", 1, PREFIX + "absent-1.pdf", "a.pdf", "application/pdf", 1024L, + "DEFAULT", "ACTIVE", at(30), at(30)); + j.update("INSERT INTO resume_versions (user_id, name, version_no, file_storage_path, file_name, " + + "content_type, file_size, default_key, status, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + me, "第二版", 2, PREFIX + "absent-2.pdf", "b.pdf", "application/pdf", 2048L, + null, "ACTIVE", at(31), at(31)); + j.update("INSERT INTO resume_versions (user_id, name, version_no, file_storage_path, file_name, " + + "content_type, file_size, default_key, status, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + me, "旧版", 3, PREFIX + "absent-3.pdf", "c.pdf", "application/pdf", 512L, + null, "ARCHIVED", at(32), at(32)); + j.update("INSERT INTO resume_versions (user_id, name, version_no, file_storage_path, file_name, " + + "content_type, file_size, status, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + outsider, "外人简历", 1, PREFIX + "outsider.pdf", "o.pdf", "application/pdf", 10L, + "ACTIVE", at(33), at(33)); + + // ── companies / jobs ── + j.update("INSERT INTO companies (owner_user_id, name, industry, verified, created_at, updated_at) " + + "VALUES (?, ?, ?, 1, ?, ?)", owner, PREFIX + "co-owner", "互联网", at(40), at(40)); + long companyOwner = j.queryForObject("SELECT id FROM companies WHERE owner_user_id = ?", Long.class, owner); + j.update("INSERT INTO companies (owner_user_id, name, industry, verified, created_at, updated_at) " + + "VALUES (?, ?, ?, 1, ?, ?)", me, PREFIX + "co-mine", "教育", at(41), at(41)); + long companyMine = j.queryForObject("SELECT id FROM companies WHERE owner_user_id = ?", Long.class, me); + ids.put("companyOwner", companyOwner); + ids.put("companyMine", companyMine); + + long job1 = insertJob(j, companyOwner, PREFIX + "job-1", at(50)); + long job2 = insertJob(j, companyOwner, PREFIX + "job-2", at(51)); + long job3 = insertJob(j, companyOwner, PREFIX + "job-3", at(52)); + long job4 = insertJob(j, companyMine, PREFIX + "job-4", at(53)); + long job5 = insertJob(j, companyOwner, PREFIX + "job-5", at(54)); + ids.put("job1", job1); + ids.put("job2", job2); + ids.put("job3", job3); + ids.put("job4", job4); + ids.put("job5", job5); + + // ── applications(本人 3 条,外人 1 条)── + long app1 = insertApplication(j, job1, me, "SUBMITTED", "求职信一", at(60)); + long app2 = insertApplication(j, job2, me, "INTERVIEWING", null, at(61)); + long app3 = insertApplication(j, job3, me, "REJECTED", "求职信三", at(62)); + long appOutsider = insertApplication(j, job5, outsider, "SUBMITTED", "外人求职信", at(63)); + ids.put("app1", app1); + ids.put("app2", app2); + ids.put("app3", app3); + ids.put("appOutsider", appOutsider); + + // ── application-timelines(app1 两条,验证分组与组内顺序)── + insertAppHistory(j, app1, owner, null, "SUBMITTED", "投递", at(64)); + insertAppHistory(j, app1, owner, "SUBMITTED", "SCREENING", "初筛", at(65)); + insertAppHistory(j, app3, owner, "SUBMITTED", "REJECTED", "不合适", at(66)); + insertAppHistory(j, appOutsider, owner, null, "SUBMITTED", "外人投递", at(67)); + + // ── interviews(一条本人为学生、一条本人为企业、一条纯外人)── + long iv1 = insertInterview(j, app1, owner, me, 1, "一面", at(70)); + long iv2 = insertInterview(j, app2, me, outsider, 1, "企业侧面试", at(71)); + long ivOutsider = insertInterview(j, appOutsider, owner, outsider, 1, "外人面试", at(72)); + ids.put("iv1", iv1); + ids.put("iv2", iv2); + ids.put("ivOutsider", ivOutsider); + + insertInterviewHistory(j, iv1, owner, null, "SCHEDULED", "安排", at(73)); + insertInterviewHistory(j, iv1, owner, "SCHEDULED", "COMPLETED", "完成", at(74)); + insertInterviewHistory(j, ivOutsider, owner, null, "SCHEDULED", "外人安排", at(75)); + + // ── offers(offer2 同时命中「候选人」与「企业主」两个分支 → 触发去重)── + long offer1 = insertOffer(j, app1, job1, companyOwner, me, owner, "Offer-A", at(80), at(90)); + long offer2 = insertOffer(j, app2, job4, companyMine, me, me, "Offer-B", at(81), at(91)); + long offerOutsider = insertOffer(j, appOutsider, job5, companyOwner, outsider, owner, "外人Offer", + at(82), at(92)); + ids.put("offer1", offer1); + ids.put("offer2", offer2); + ids.put("offerOutsider", offerOutsider); + + insertOfferHistory(j, offer1, null, "SENT", owner, "COMPANY", "发出", at(83)); + insertOfferHistory(j, offer1, "SENT", "ACCEPTED", me, "STUDENT", "接受", at(84)); + insertOfferHistory(j, offer2, null, "DRAFT", me, "COMPANY", "草稿", at(85)); + insertOfferHistory(j, offerOutsider, null, "SENT", owner, "COMPANY", "外人", at(86)); + + // ── feedback ── + j.update("INSERT INTO candidate_feedback (application_id, company_user_id, student_user_id, rating, " + + "content, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + app1, owner, me, 5, "表现优秀", "[\"沟通\",\"技术\"]", at(100), at(100)); + j.update("INSERT INTO candidate_feedback (application_id, company_user_id, student_user_id, rating, " + + "content, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + appOutsider, owner, outsider, 3, "外人评价", null, at(101), at(101)); + + // ── favorites ── + j.update("INSERT INTO job_favorites (job_id, student_user_id, created_at) VALUES (?, ?, ?)", job1, me, at(110)); + j.update("INSERT INTO job_favorites (job_id, student_user_id, created_at) VALUES (?, ?, ?)", job2, me, at(111)); + j.update("INSERT INTO job_favorites (job_id, student_user_id, created_at) VALUES (?, ?, ?)", + job1, outsider, at(112)); + + // ── notifications(含 read_at 为 null 与非 null)── + j.update("INSERT INTO notifications (user_id, type, title, content, business_type, business_id, " + + "is_read, read_at, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?)", + me, "APPLICATION", "投递已收到", "内容一", "APPLICATION", app1, at(120)); + j.update("INSERT INTO notifications (user_id, type, title, content, business_type, business_id, " + + "is_read, read_at, created_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)", + me, "OFFER", "收到 Offer", "内容二", "OFFER", offer1, at(121), at(122)); + j.update("INSERT INTO notifications (user_id, type, title, content, is_read, created_at) " + + "VALUES (?, ?, ?, ?, 0, ?)", outsider, "APPLICATION", "外人通知", "外人", at(123)); + + // ── conversations + messages(供 E1 的 conversations/messages section)── + long c1 = insertConversation(j, me, "DIRECT", "会话一", at(130), at(140)); + long c2 = insertConversation(j, other, "DIRECT", null, at(131), at(141)); + long c3 = insertConversation(j, me, "TALENT_INVITATION", "邀约会话", at(132), at(142)); + long cOutsider = insertConversation(j, outsider, "DIRECT", "外人会话", at(133), at(143)); + ids.put("c1", c1); + ids.put("c2", c2); + ids.put("c3", c3); + ids.put("cOutsider", cOutsider); + + addMember(j, c1, me); + addMember(j, c1, other); + addMember(j, c2, me); + addMember(j, c2, other); + addMember(j, c3, me); + addMember(j, c3, outsider); + addMember(j, cOutsider, outsider); + + insertMessage(j, c1, other, "c1-m1", "SENT", at(150)); + insertMessage(j, c1, me, "c1-m2", "SENT", at(151)); + insertMessage(j, c1, other, "c1-m3", "RECALLED", at(152)); + insertMessage(j, c2, other, "c2-m1", "SENT", at(153)); + insertMessage(j, c3, me, "c3-m1", "SENT", at(154)); + insertMessage(j, cOutsider, outsider, "out-m1", "SENT", at(155)); + + // ── candidate-scoring ── + insertDimensionScore(j, me, "EDUCATION", "2026-02", new BigDecimal("88.00"), "OK", + "{\"a\":1}", "12.50", at(160)); + insertDimensionScore(j, me, "ABILITY", "2026-02", null, "INSUFFICIENT_DATA", null, "20.00", at(161)); + insertDimensionScore(j, outsider, "EDUCATION", "2026-02", new BigDecimal("70.00"), "OK", + "{\"b\":2}", "12.50", at(162)); + + long appeal = insertAppeal(j, me, "ABILITY", "2026-02", "PENDING", "理由", at(170)); + long appealOutsider = insertAppeal(j, outsider, "EDUCATION", "2026-02", "APPROVED", "外人理由", at(171)); + ids.put("appeal", appeal); + ids.put("appealOutsider", appealOutsider); + + insertAppealHistory(j, appeal, null, "PENDING", owner, "ADMIN", "提交", at(172)); + insertAppealHistory(j, appeal, "PENDING", "APPROVED", owner, "ADMIN", "通过", at(173)); + insertAppealHistory(j, appealOutsider, null, "PENDING", owner, "ADMIN", "外人提交", at(174)); + + return ids; + } + + private static void insertProfile(JdbcTemplate j, long userId, String realName, String bio, String location, + String education, String school, int graduationYear, String skills, + LocalDateTime t) { + j.update("INSERT INTO student_profiles (user_id, real_name, bio, location, education, school, " + + "graduation_year, skills_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + userId, realName, bio, location, education, school, graduationYear, skills, t, t); + } + + private static long insertJob(JdbcTemplate j, long companyId, String title, LocalDateTime t) { + j.update("INSERT INTO jobs (company_id, title, description, city, employment_type, status, " + + "created_at, updated_at) VALUES (?, ?, '描述', '北京', 'FULL_TIME', 'PUBLISHED', ?, ?)", + companyId, title, t, t); + return j.queryForObject("SELECT id FROM jobs WHERE title = ?", Long.class, title); + } + + private static long insertApplication(JdbcTemplate j, long jobId, long studentUserId, String status, + String coverLetter, LocalDateTime t) { + j.update("INSERT INTO applications (job_id, student_user_id, status, cover_letter, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?)", jobId, studentUserId, status, coverLetter, t, t); + return j.queryForObject("SELECT id FROM applications WHERE job_id = ? AND student_user_id = ?", + Long.class, jobId, studentUserId); + } + + private static void insertAppHistory(JdbcTemplate j, long appId, long operator, String from, String to, + String reason, LocalDateTime t) { + j.update("INSERT INTO application_status_history (application_id, operator_user_id, from_status, " + + "to_status, reason, created_at) VALUES (?, ?, ?, ?, ?, ?)", appId, operator, from, to, reason, t); + } + + private static long insertInterview(JdbcTemplate j, long appId, long companyUserId, long studentUserId, + int roundNo, String title, LocalDateTime t) { + j.update("INSERT INTO interviews (application_id, company_user_id, student_user_id, round_no, title, " + + "interview_type, start_at, end_at, timezone, interviewer_name, address, notes, status, " + + "created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'VIDEO', ?, ?, 'Asia/Shanghai', ?, ?, ?, " + + "'SCHEDULED', ?, ?)", + appId, companyUserId, studentUserId, roundNo, title, t, t.plusHours(1), "面试官", "地址", "备注", t, t); + return j.queryForObject("SELECT id FROM interviews WHERE application_id = ? AND round_no = ? AND title = ?", + Long.class, appId, roundNo, title); + } + + private static void insertInterviewHistory(JdbcTemplate j, long interviewId, long actor, String from, + String to, String reason, LocalDateTime t) { + j.update("INSERT INTO interview_status_history (interview_id, from_status, to_status, actor_user_id, " + + "reason, created_at) VALUES (?, ?, ?, ?, ?, ?)", interviewId, from, to, actor, reason, t); + } + + private static long insertOffer(JdbcTemplate j, long appId, long jobId, long companyId, long candidateId, + long createdBy, String title, LocalDateTime t, LocalDateTime expireAt) { + j.update("INSERT INTO offers (application_id, job_id, company_id, candidate_id, title, salary_min, " + + "salary_max, salary_currency, salary_period, work_city, employment_type, probation_months, " + + "benefits, description, expire_at, status, version, created_by, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, 'CNY', 'MONTH', '北京', 'FULL_TIME', 3, ?, '说明', ?, " + + "'SENT', 0, ?, ?, ?)", + appId, jobId, companyId, candidateId, title, 20000, 30000, "五险一金", expireAt, createdBy, t, t); + return j.queryForObject("SELECT id FROM offers WHERE application_id = ?", Long.class, appId); + } + + private static void insertOfferHistory(JdbcTemplate j, long offerId, String from, String to, long operatorId, + String operatorRole, String reason, LocalDateTime t) { + j.update("INSERT INTO offer_status_history (offer_id, from_status, to_status, operator_id, operator_role, " + + "reason, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + offerId, from, to, operatorId, operatorRole, reason, t); + } + + private static long insertConversation(JdbcTemplate j, long createdBy, String type, String title, + LocalDateTime created, LocalDateTime updated) { + j.update("INSERT INTO conversations (title, type, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + title, type, createdBy, created, updated); + return j.queryForObject("SELECT id FROM conversations WHERE created_by = ? AND created_at = ?", + Long.class, createdBy, created); + } + + private static void addMember(JdbcTemplate j, long convId, long userId) { + j.update("INSERT INTO conversation_members (conversation_id, user_id, joined_at) VALUES (?, ?, ?)", + convId, userId, T); + } + + private static void insertMessage(JdbcTemplate j, long convId, long sender, String content, String status, + LocalDateTime t) { + j.update("INSERT INTO messages (conversation_id, sender_user_id, content, status, created_at) " + + "VALUES (?, ?, ?, ?, ?)", convId, sender, content, status, t); + } + + private static void insertDimensionScore(JdbcTemplate j, long studentUserId, String dimension, String period, + BigDecimal score, String status, String subScores, String weight, + LocalDateTime t) { + j.update("INSERT INTO candidate_dimension_scores (student_user_id, dimension, period, score, status, " + + "sub_scores_json, weight, rules_version, calculated_at, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, 'v1', ?, ?, ?)", + studentUserId, dimension, period, score, status, subScores, new BigDecimal(weight), t, t, t); + } + + private static long insertAppeal(JdbcTemplate j, long candidateId, String dimension, String period, + String status, String reason, LocalDateTime t) { + j.update("INSERT INTO candidate_scoring_appeals (candidate_user_id, dimension, period, status, reason, " + + "rules_version, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'v1', ?, ?)", + candidateId, dimension, period, status, reason, t, t); + return j.queryForObject("SELECT id FROM candidate_scoring_appeals WHERE candidate_user_id = ? " + + "AND dimension = ? AND period = ?", Long.class, candidateId, dimension, period); + } + + private static void insertAppealHistory(JdbcTemplate j, long appealId, String from, String to, long actor, + String role, String reason, LocalDateTime t) { + j.update("INSERT INTO candidate_scoring_appeal_history (appeal_id, from_status, to_status, actor_user_id, " + + "actor_role, reason, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + appealId, from, to, actor, role, reason, t); + } + + /** 按外键顺序清理本类夹具;只删除 {@code r01eind-} 前缀的自有数据,不触碰种子数据。 */ + static void clearFixture(JdbcTemplate j) { + // MySQL 禁止在 DELETE 的子查询中引用目标表本身(错误 1093), + // 故自引用表一律用直接谓词,仅跨表关联才用子查询。 + String u = "(SELECT id FROM users WHERE phone LIKE '" + PREFIX + "%')"; + String myConvs = "(SELECT id FROM conversations WHERE created_by IN " + u + ")"; + String myApps = "(SELECT id FROM applications WHERE student_user_id IN " + u + ")"; + String myCompanies = "(SELECT id FROM companies WHERE owner_user_id IN " + u + ")"; + String interviewPredicate = "student_user_id IN " + u + " OR company_user_id IN " + u; + String myInterviews = "(SELECT id FROM interviews WHERE " + interviewPredicate + ")"; + String offerPredicate = "candidate_id IN " + u + " OR company_id IN " + myCompanies; + String myOffers = "(SELECT id FROM offers WHERE " + offerPredicate + ")"; + String myAppeals = "(SELECT id FROM candidate_scoring_appeals WHERE candidate_user_id IN " + u + ")"; + + j.update("DELETE FROM message_reads WHERE message_id IN (SELECT id FROM messages WHERE conversation_id IN " + + myConvs + ")"); + j.update("DELETE FROM messages WHERE conversation_id IN " + myConvs); + j.update("DELETE FROM conversation_members WHERE conversation_id IN " + myConvs + " OR user_id IN " + u); + j.update("DELETE FROM conversations WHERE created_by IN " + u); + j.update("DELETE FROM application_status_history WHERE application_id IN " + myApps); + j.update("DELETE FROM interview_status_history WHERE interview_id IN " + myInterviews); + j.update("DELETE FROM interview_scores WHERE interview_id IN " + myInterviews); + j.update("DELETE FROM interviews WHERE " + interviewPredicate); + j.update("DELETE FROM offer_status_history WHERE offer_id IN " + myOffers); + j.update("DELETE FROM offer_reminders WHERE offer_id IN " + myOffers); + j.update("DELETE FROM offer_counter_offers WHERE offer_id IN " + myOffers + " OR student_id IN " + u); + j.update("DELETE FROM offers WHERE " + offerPredicate); + j.update("DELETE FROM candidate_scoring_appeal_history WHERE appeal_id IN " + myAppeals); + j.update("DELETE FROM candidate_scoring_snapshot_history WHERE candidate_user_id IN " + u); + j.update("DELETE FROM candidate_scoring_appeals WHERE candidate_user_id IN " + u); + j.update("DELETE FROM candidate_scoring_rollback_limits WHERE student_user_id IN " + u); + j.update("DELETE FROM candidate_dimension_scores WHERE student_user_id IN " + u); + j.update("DELETE FROM candidate_feedback WHERE student_user_id IN " + u + " OR company_user_id IN " + u); + j.update("DELETE FROM job_favorites WHERE student_user_id IN " + u); + j.update("DELETE FROM notifications WHERE user_id IN " + u); + j.update("DELETE FROM resume_versions WHERE user_id IN " + u); + j.update("DELETE FROM student_preferences WHERE student_user_id IN " + u); + j.update("DELETE FROM student_profiles WHERE user_id IN " + u); + j.update("DELETE FROM applications WHERE student_user_id IN " + u); + j.update("DELETE FROM jobs WHERE company_id IN " + myCompanies); + j.update("DELETE FROM companies WHERE owner_user_id IN " + u); + j.update("DELETE FROM user_roles WHERE user_id IN " + u); + j.update("DELETE FROM users WHERE phone LIKE '" + PREFIX + "%'"); + } + + // ══════════════════════════════════════════════════════════ + // 规范化(canonicalization):把完整输出变成确定性文本 + // ══════════════════════════════════════════════════════════ + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** + * 把 {@link PersonalDataCollector.CollectResult} 的**完整** section 输出规范化为文本。 + * + *

显式呈现 null、按 {@code LocalDateTime.toString()} 呈现时间、按 List 原序呈现集合顺序、 + * 按 key 排序呈现 Map 字段;JSON 列做**语义等价**归一(MySQL 会重排 JSON 键并加空格, + * H2 保留字面量),从而比较的是语义而非引擎的序列化风格。

+ */ + static String canonicalResult(PersonalDataCollector.CollectResult r) { + StringBuilder sb = new StringBuilder(); + sb.append("sectionOrder=").append(new ArrayList<>(r.getSections().keySet())).append('\n'); + for (Map.Entry e : r.getSections().entrySet()) { + sb.append("section[").append(e.getKey()).append("]=").append(canon(e.getValue())).append('\n'); + } + sb.append("binaryKeys=").append(new ArrayList<>(new java.util.TreeSet<>(r.getBinaries().keySet()))) + .append('\n'); + return sb.toString(); + } + + /** 会话列表的完整视图规范化(key 排序,避免 HashMap 迭代顺序干扰)。 */ + static String canonicalConversations(List> list) { + StringBuilder sb = new StringBuilder(); + for (Map v : list) { + sb.append(canon(new TreeMap<>(v))).append('\n'); + } + return sb.toString(); + } + + private static String canon(Object v) { + if (v == null) return "null"; + if (v instanceof Map m) { + Map sorted = new TreeMap<>(); + m.forEach((k, value) -> sorted.put(String.valueOf(k), value)); + StringBuilder sb = new StringBuilder("{"); + boolean first = true; + for (Map.Entry e : sorted.entrySet()) { + if (!first) sb.append(','); + first = false; + sb.append(e.getKey()).append('=').append(canon(e.getValue())); + } + return sb.append('}').toString(); + } + if (v instanceof List l) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < l.size(); i++) { + if (i > 0) sb.append(','); + sb.append(canon(l.get(i))); + } + return sb.append(']').toString(); + } + if (v instanceof Object[] arr) return canon(List.of(arr)); + if (v instanceof LocalDateTime dt) return dt.toString(); + if (v instanceof LocalDate d) return d.toString(); + if (v instanceof BigDecimal bd) return bd.stripTrailingZeros().toPlainString(); + if (v instanceof String s) return '"' + normalizeJson(s) + '"'; + return String.valueOf(v); + } + + /** + * JSON 列的**语义**归一,而不是表示归一。 + * + *

实测差异(见 {@code e1-raw-json.txt}):同一个 JSON 列, + * MySQL 8 回读为 JSON 对象字面量 {@code {"java":3}}; + * H2(MODE=MySQL)回读为被转义的 JSON **字符串**标量 {@code "{\"java\":3}"}。 + * 两者语义相同,故此处递归解码到对象/数组后按键排序重排, + * 只对确实是 JSON 的字符串生效,其余字符串原样保留。

+ */ + private static String normalizeJson(String s) { + String decoded = decodeJsonValue(s.trim()); + return decoded != null ? decoded : s; + } + + /** 返回归一后的 JSON 文本;若该字符串不是 JSON(对象/数组/双重编码字符串)则返回 null。 */ + private static String decodeJsonValue(String t) { + boolean object = t.startsWith("{") && t.endsWith("}"); + boolean array = t.startsWith("[") && t.endsWith("]"); + boolean quoted = t.length() >= 2 && t.startsWith("\"") && t.endsWith("\""); + if (!object && !array && !quoted) return null; + try { + JsonNode node = MAPPER.readTree(t); + if (node == null) return null; + if (node.isTextual()) { + // 双重编码:字符串里仍然是 JSON(H2 的 JSON 列回读形态) + return decodeJsonValue(node.asText().trim()); + } + if (node.isObject() || node.isArray()) { + return MAPPER.writeValueAsString(sortKeys(node)); + } + return null; + } catch (Exception ignored) { + return null; + } + } + + /** 取出输出中 JSON 列的**原始**(未归一)取值,用于报告中如实记录跨库表示差异。 */ + private static String rawJsonSamples(PersonalDataCollector.CollectResult r) { + Object profile = r.getSections().get("profile"); + Object preferences = r.getSections().get("preferences"); + return "profile.skills=" + (profile instanceof Map p ? p.get("skills") : null) + + " | preferences.preferredCities=" + + (preferences instanceof Map p ? p.get("preferredCities") : null); + } + + private static JsonNode sortKeys(JsonNode node) { + if (node.isObject()) { + ObjectNode out = MAPPER.createObjectNode(); + List names = new ArrayList<>(); + node.fieldNames().forEachRemaining(names::add); + Collections.sort(names); + for (String n : names) out.set(n, sortKeys(node.get(n))); + return out; + } + if (node.isArray()) { + com.fasterxml.jackson.databind.node.ArrayNode out = MAPPER.createArrayNode(); + node.forEach(child -> out.add(sortKeys(child))); + return out; + } + return node; + } + + /** 把完整 dump 落盘,便于报告引用与失败时人工比对。 */ + private static void dump(String name, String content) { + try { + Path dir = Path.of("target", "r01e-independent"); + Files.createDirectories(dir); + Files.writeString(dir.resolve(name), content); + } catch (Exception ignored) { + // 诊断输出 + } + } + + // ══════════════════════════════════════════════════════════ + // 1 / 5. 查询预算(独立计数实现,统计真实 JDBC 语句与 SQL 文本) + // ══════════════════════════════════════════════════════════ + + /** 只包一层、记录**真实 JDBC 语句文本**的 DataSource(独立于实施测试的实现)。 */ + static final class CountingDataSource extends org.springframework.jdbc.datasource.AbstractDataSource { + private final DataSource delegate; + private final AtomicInteger count = new AtomicInteger(); + private final List sqls = Collections.synchronizedList(new ArrayList<>()); + + CountingDataSource(DataSource delegate) { + this.delegate = delegate; + } + + void reset() { + count.set(0); + sqls.clear(); + } + + int count() { + return count.get(); + } + + List sqls() { + synchronized (sqls) { + return new ArrayList<>(sqls); + } + } + + long matching(String lowerCaseFragment) { + return sqls().stream().filter(s -> s.toLowerCase().contains(lowerCaseFragment)).count(); + } + + @Override public Connection getConnection() throws java.sql.SQLException { + return wrap(delegate.getConnection()); + } + + @Override public Connection getConnection(String u, String p) throws java.sql.SQLException { + return wrap(delegate.getConnection(u, p)); + } + + private Connection wrap(Connection c) { + return (Connection) Proxy.newProxyInstance( + Connection.class.getClassLoader(), new Class[]{Connection.class}, + (proxy, method, args) -> { + if ("prepareStatement".equals(method.getName()) && args != null && args.length > 0 + && args[0] instanceof String sql) { + count.incrementAndGet(); + sqls.add(sql); + } else if ("createStatement".equals(method.getName())) { + count.incrementAndGet(); + } + return method.invoke(c, args); + }); + } + } + + // ══════════════════════════════════════════════════════════ + // 测试 + // ══════════════════════════════════════════════════════════ + + @Test + @DisplayName("引导:两个引擎均可执行真实 SQL,且等价夹具得到相同主键") + void bothEnginesAreLiveAndFixtureIdsAgree() { + assertThat(MySql.container.isRunning()).as("MySQL Testcontainers 必须真实运行").isTrue(); + assertThat(h2Jdbc.queryForObject("SELECT COUNT(*) FROM users", Integer.class)).isNotNull(); + assertThat(MySql.jdbc.queryForObject("SELECT COUNT(*) FROM users", Integer.class)).isNotNull(); + + Map a = seedFixture(h2Jdbc); + Map b = seedFixture(MySql.jdbc); + + assertThat(a.keySet()).isEqualTo(b.keySet()); + assertThat(b).as("等价夹具在两个引擎上应得到相同主键(同一 JVM、两侧均为全新建库)").isEqualTo(a); + } + + @Test + @DisplayName("E1:collector 完整输出在 H2 与 MySQL 上逐字段一致(含 null / 时间 / 顺序 / 关联数据)") + void e1CollectorOutputIsIdenticalAcrossEngines() { + Map a = seedFixture(h2Jdbc); + Map b = seedFixture(MySql.jdbc); + assertThat(b).as("两侧等价夹具").isEqualTo(a); + + PersonalDataCollector.CollectResult onH2 = h2Collector.collect(a.get("me")); + PersonalDataCollector.CollectResult onMy = MySql.collector.collect(b.get("me")); + + String dumpH2 = canonicalResult(onH2); + String dumpMy = canonicalResult(onMy); + dump("e1-h2.txt", dumpH2); + dump("e1-mysql.txt", dumpMy); + dump("e1-raw-json.txt", "h2: " + rawJsonSamples(onH2) + "\nmysql: " + rawJsonSamples(onMy) + "\n"); + + assertThat(dumpH2).as("E1 collector 完整输出跨库必须逐字段一致").isEqualTo(dumpMy); + + // 反向自证:置换任意一个字段值后必须不再相等 + String perturbed = dumpMy.replace("\"张三\"", "\"张四\""); + assertThat(perturbed).as("规范化必须是逐字段敏感的(反证)").isNotEqualTo(dumpH2); + + // section 顺序本身也是输出契约的一部分 + assertThat(new ArrayList<>(onH2.getSections().keySet())).containsExactly( + "profile", "preferences", "resumes", "applications", "application-timelines", + "interviews", "interview-timelines", "offers", "offer-timelines", "feedback", + "favorites", "notifications", "conversations", "messages", + "candidate-scoring", "scoring-appeals"); + } + + @Test + @DisplayName("E1:collector 输出不含其他用户数据") + void e1CollectorOutputExcludesOtherUsers() { + Map ids = seedFixture(h2Jdbc); + seedFixture(MySql.jdbc); + + String dump = canonicalResult(h2Collector.collect(ids.get("me"))); + dump("e1-no-foreign.txt", dump); + + for (String foreign : List.of("李四", "外人的简介", "外人简历", "外人求职信", "外人评价", "外人通知", + "外人会话", "out-m1", "外人Offer", "外人理由", "外人面试")) { + assertThat(dump).as("他人数据不得混入导出:%s", foreign).doesNotContain(foreign); + } + // 关联数据:外人 id 不得以其任何形式出现 + assertThat(dump).doesNotContain("outsider"); + } + + @Test + @DisplayName("E1:1 / 10 / 50 条投递的真实 JDBC 语句数恒为 20/20/20(与实施报告同基线,两侧实测)") + void e1QueryBudgetIsFlatOnBothEngines() { + int[] sizes = {1, 10, 50}; + int[] onH2 = new int[3]; + int[] onMy = new int[3]; + List h2SqlFor1 = null; + List mySqlFor1 = null; + + for (int s = 0; s < sizes.length; s++) { + Map measured = measureE1Minimal(sizes[s]); + onH2[s] = measured.get("h2"); + onMy[s] = measured.get("mysql"); + if (s == 0) { + h2SqlFor1 = List.copyOf(h2Counting.sqls()); + mySqlFor1 = List.copyOf(MySql.counting.sqls()); + } + } + + dump("e1-budget.txt", "minimalFixture h2=" + java.util.Arrays.toString(onH2) + + " mysql=" + java.util.Arrays.toString(onMy) + "\n"); + + assertThat(onH2).as("E1 H2:SQL 数必须精确为 20/20/20").containsExactly(20, 20, 20); + assertThat(onMy).as("E1 MySQL:SQL 数必须精确为 20/20/20").containsExactly(20, 20, 20); + + // 排除「测试准备 / 清理」SQL:计数在夹具灌入完成之后才 reset, + // 故上述 20 条全部来自 collect(),且全部是 SELECT + assertThat(h2SqlFor1).as("H2 侧实测语句必须全部是 collect() 发出的查询").hasSize(20) + .allMatch(sql -> sql.trim().toUpperCase().startsWith("SELECT")); + assertThat(mySqlFor1).as("MySQL 侧实测语句必须全部是 collect() 发出的查询").hasSize(20) + .allMatch(sql -> sql.trim().toUpperCase().startsWith("SELECT")); + } + + @Test + @DisplayName("E1:12 个 section 全部有数据时,SQL 数仍不随记录数增长(两侧实测)") + void e1QueryBudgetIsFlatWithRichFixture() { + int[] sizes = {1, 10, 50}; + int[] onH2 = new int[3]; + int[] onMy = new int[3]; + for (int s = 0; s < sizes.length; s++) { + Map measured = measureE1Rich(sizes[s]); + onH2[s] = measured.get("h2"); + onMy[s] = measured.get("mysql"); + } + dump("e1-budget-rich.txt", "richFixture h2=" + java.util.Arrays.toString(onH2) + + " mysql=" + java.util.Arrays.toString(onMy) + "\n"); + + assertThat(onH2[1]).as("H2:10 条时不得高于 1 条时").isEqualTo(onH2[0]); + assertThat(onH2[2]).as("H2:50 条时不得高于 1 条时").isEqualTo(onH2[0]); + assertThat(onMy[1]).as("MySQL:10 条时不得高于 1 条时").isEqualTo(onMy[0]); + assertThat(onMy[2]).as("MySQL:50 条时不得高于 1 条时").isEqualTo(onMy[0]); + } + + /** + * 与实施报告同基线的最小夹具:仅 me/other 两个用户、一家公司、N 个岗位与 N 条投递。 + * 用于直接对账报告中「1/10/50 → 20/20/20」的结论。 + */ + private Map measureE1Minimal(int size) { + return measureE1(size, false); + } + + private Map measureE1Rich(int size) { + return measureE1(size, true); + } + + private Map measureE1(int size, boolean rich) { + clearFixture(h2Jdbc); + clearFixture(MySql.jdbc); + Map out = new LinkedHashMap<>(); + out.put("h2", measureE1On(h2Jdbc, h2Counting, h2Collector, size, rich)); + out.put("mysql", measureE1On(MySql.jdbc, MySql.counting, MySql.collector, size, rich)); + return out; + } + + /** 灌入夹具与 N 条投递,reset 计数器后调用 collect(),返回实测真实 JDBC 语句数。 */ + private int measureE1On(JdbcTemplate j, CountingDataSource counting, + PersonalDataCollector collector, int size, boolean rich) { + Map ids = rich ? seedFixture(j) : seedMinimalE1(j); + for (int i = 0; i < size; i++) { + long jobId = insertJob(j, ids.get("companyOwner"), PREFIX + "extra-job-" + i, at(200 + i)); + insertApplication(j, jobId, ids.get("me"), "SUBMITTED", "附加" + i, at(200 + i)); + } + counting.reset(); + collector.collect(ids.get("me")); + return counting.count(); + } + + /** 与实施报告同基线的最小夹具:两个用户 + 一家(他人持有的)公司,无其他 section 数据。 */ + private Map seedMinimalE1(JdbcTemplate j) { + Map ids = new LinkedHashMap<>(); + long me = insertUser(j, PREFIX + "me", "本人", "STUDENT", at(0)); + long other = insertUser(j, PREFIX + "other", "对方", "STUDENT", at(1)); + j.update("INSERT INTO companies (owner_user_id, name, industry, verified, created_at, updated_at) " + + "VALUES (?, ?, '互联网', 1, ?, ?)", other, PREFIX + "co-owner", at(40), at(40)); + ids.put("me", me); + ids.put("other", other); + ids.put("companyOwner", + j.queryForObject("SELECT id FROM companies WHERE owner_user_id = ?", Long.class, other)); + return ids; + } + + @Test + @DisplayName("E2:1 / 20 / 100 会话的真实 JDBC 语句数恒为 3,且 SQL 形态固定(H2 与 MySQL 均实测)") + void e2QueryBudgetIsFlatOnBothEngines() { + int[] sizes = {1, 20, 100}; + int[] onH2 = new int[3]; + int[] onMy = new int[3]; + List h2Sql = null; + List mySql = null; + + for (int s = 0; s < sizes.length; s++) { + clearFixture(h2Jdbc); + clearFixture(MySql.jdbc); + Map a = seedFixture(h2Jdbc); + Map b = seedFixture(MySql.jdbc); + List expected = new ArrayList<>(); + for (int i = 0; i < sizes[s]; i++) { + expected.add(addConversationWithUnread(h2Jdbc, a, i)); + addConversationWithUnread(MySql.jdbc, b, i); + } + + h2Counting.reset(); + List> listH2 = h2Messaging.listConversations(a.get("me")); + onH2[s] = h2Counting.count(); + + MySql.counting.reset(); + List> listMy = MySql.messaging.listConversations(b.get("me")); + onMy[s] = MySql.counting.count(); + + assertThat(listH2).as("size=%d H2", sizes[s]).hasSize(sizes[s] + 3); + assertThat(listMy).as("size=%d MySQL", sizes[s]).hasSize(sizes[s] + 3); + assertThat(canonicalConversations(listH2)).as("size=%d 跨库逐字段一致", sizes[s]) + .isEqualTo(canonicalConversations(listMy)); + + if (s == 2) { + h2Sql = List.copyOf(h2Counting.sqls()); + mySql = List.copyOf(MySql.counting.sqls()); + } + } + + dump("e2-budget.txt", "h2=" + java.util.Arrays.toString(onH2) + "\nmysql=" + + java.util.Arrays.toString(onMy) + "\n--- h2 sql ---\n" + String.join("\n", h2Sql) + + "\n--- mysql sql ---\n" + String.join("\n", mySql) + "\n"); + + assertThat(onH2).as("E2 H2:SQL 数必须精确为 3/3/3").containsExactly(3, 3, 3); + assertThat(onMy).as("E2 MySQL:SQL 数必须精确为 3/3/3").containsExactly(3, 3, 3); + + // 3 条语句必须是「成员关系 + 会话 + 分组未读」,且未读查询只出现 1 次 + assertThat(h2Sql).hasSize(3); + assertThat(mySql).hasSize(3); + assertThat(h2Sql.stream().filter(s -> s.contains("ROW_NUMBER")).count()) + .as("H2:分组未读查询只执行一次").isEqualTo(1); + assertThat(mySql.stream().filter(s -> s.contains("ROW_NUMBER")).count()) + .as("MySQL:分组未读查询只执行一次").isEqualTo(1); + } + + private long addConversationWithUnread(JdbcTemplate j, Map ids, int index) { + long cid = insertConversation(j, ids.get("me"), "DIRECT", PREFIX + "scale-" + index, + at(300 + index), at(400 + index)); + addMember(j, cid, ids.get("me")); + addMember(j, cid, ids.get("other")); + insertMessage(j, cid, ids.get("other"), "scale-" + index, "SENT", at(400 + index)); + return cid; + } + + @Test + @DisplayName("E2:空会话页不执行任何未读查询") + void e2EmptyConversationPageIssuesNoUnreadQuery() { + long nobody = insertUser(h2Jdbc, PREFIX + "nobody", "无会话者", "STUDENT", at(0)); + long nobodyMy = insertUser(MySql.jdbc, PREFIX + "nobody", "无会话者", "STUDENT", at(0)); + + h2Counting.reset(); + assertThat(h2Messaging.listConversations(nobody)).isEmpty(); + List h2Sql = h2Counting.sqls(); + + MySql.counting.reset(); + assertThat(MySql.messaging.listConversations(nobodyMy)).isEmpty(); + List mySql = MySql.counting.sqls(); + + assertThat(h2Sql).as("H2:空会话页只发出一条成员关系查询").hasSize(1); + assertThat(mySql).as("MySQL:空会话页只发出一条成员关系查询").hasSize(1); + assertThat(h2Sql.get(0)).as("不得执行未读查询").doesNotContain("ROW_NUMBER"); + assertThat(mySql.get(0)).as("不得执行未读查询").doesNotContain("ROW_NUMBER"); + assertThat(h2Counting.matching("row_number")).isZero(); + assertThat(MySql.counting.matching("row_number")).isZero(); + } + + // ══════════════════════════════════════════════════════════ + // 3. E2 H2/MySQL 深比较:逐条语义 + // ══════════════════════════════════════════════════════════ + + private static Map unreadByConversation(List> list) { + Map out = new TreeMap<>(); + for (Map v : list) { + out.put(((Number) v.get("id")).longValue(), ((Number) v.get("unreadCount")).longValue()); + } + return out; + } + + private static void assertUnread(JdbcTemplate j, MessagingService svc, long me, long convId, long expected, + String what) { + Map map = unreadByConversation(svc.listConversations(me)); + assertThat(map.get(convId)).as("%s", what).isEqualTo(expected); + } + + @Test + @DisplayName("E2 深比较①:本人发送未读按现状计入(OBS-E01),他人发送未读计入") + void e2SelfSentAndOtherSentUnreadAreCounted() { + Map a = seedFixture(h2Jdbc); + Map b = seedFixture(MySql.jdbc); + + // c1 中:c1-m1(他人 SENT)、c1-m2(本人 SENT)、c1-m3(RECALLED) + assertUnread(h2Jdbc, h2Messaging, a.get("me"), a.get("c1"), 2, + "H2:本人 1 条 + 他人 1 条 = 2(撤回排除;OBS-E01:本人发送计入)"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), b.get("c1"), 2, + "MySQL:本人 1 条 + 他人 1 条 = 2(撤回排除;OBS-E01:本人发送计入)"); + + // 隔离验证:c1-m2 已被读取后,本人发送的那条不再计入 → 1 + long c1m2 = h2Jdbc.queryForObject("SELECT id FROM messages WHERE conversation_id = ? AND content = ?", + Long.class, a.get("c1"), "c1-m2"); + long c1m2My = MySql.jdbc.queryForObject("SELECT id FROM messages WHERE conversation_id = ? AND content = ?", + Long.class, b.get("c1"), "c1-m2"); + h2Jdbc.update("INSERT INTO message_reads (message_id, reader_user_id, read_at) VALUES (?, ?, ?)", + c1m2, a.get("me"), at(500)); + MySql.jdbc.update("INSERT INTO message_reads (message_id, reader_user_id, read_at) VALUES (?, ?, ?)", + c1m2My, b.get("me"), at(500)); + + assertUnread(h2Jdbc, h2Messaging, a.get("me"), a.get("c1"), 1, "H2:本人那条已读 → 1"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), b.get("c1"), 1, "MySQL:本人那条已读 → 1"); + } + + @Test + @DisplayName("E2 深比较②:message_reads 排除、RECALLED 排除,跨库逐值一致") + void e2ReadAndRecalledExcluded() { + Map a = seedFixture(h2Jdbc); + Map b = seedFixture(MySql.jdbc); + + long m1 = msgId(h2Jdbc, a.get("c1"), "c1-m1"); + long m1My = msgId(MySql.jdbc, b.get("c1"), "c1-m1"); + h2Jdbc.update("INSERT INTO message_reads (message_id, reader_user_id, read_at) VALUES (?, ?, ?)", + m1, a.get("me"), at(500)); + MySql.jdbc.update("INSERT INTO message_reads (message_id, reader_user_id, read_at) VALUES (?, ?, ?)", + m1My, b.get("me"), at(500)); + + // c1: m1 已读排除、m2 本人 SENT 计入、m3 RECALLED 排除 → 1 + assertUnread(h2Jdbc, h2Messaging, a.get("me"), a.get("c1"), 1, "H2:已读与撤回均排除 → 1"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), b.get("c1"), 1, "MySQL:已读与撤回均排除 → 1"); + + // 把 m2 也标记为撤回 → 0;此时聚合应缺行并由 Service 补 0 + h2Jdbc.update("UPDATE messages SET status = 'RECALLED' WHERE id = ?", msgId(h2Jdbc, a.get("c1"), "c1-m2")); + MySql.jdbc.update("UPDATE messages SET status = 'RECALLED' WHERE id = ?", + msgId(MySql.jdbc, b.get("c1"), "c1-m2")); + assertUnread(h2Jdbc, h2Messaging, a.get("me"), a.get("c1"), 0, "H2:全部排除 → 聚合缺行 → 补 0"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), b.get("c1"), 0, "MySQL:全部排除 → 聚合缺行 → 补 0"); + + // 另一个读者的已读记录不得影响本人(两侧同步写入,保持自增计数一致) + markRead(h2Jdbc, msgId(h2Jdbc, a.get("c1"), "c1-m1"), a.get("other")); + markRead(MySql.jdbc, msgId(MySql.jdbc, b.get("c1"), "c1-m1"), b.get("other")); + assertUnread(h2Jdbc, h2Messaging, a.get("me"), a.get("c1"), 0, "H2:他人已读不影响本人"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), b.get("c1"), 0, "MySQL:他人已读不影响本人"); + } + + private static long msgId(JdbcTemplate j, long convId, String content) { + return j.queryForObject("SELECT id FROM messages WHERE conversation_id = ? AND content = ?", + Long.class, convId, content); + } + + @Test + @DisplayName("E2 深比较③:多会话分组、非当前用户会话不返回、聚合缺失补 0") + void e2GroupingIsolationAndZeroFill() { + Map a = seedFixture(h2Jdbc); + Map b = seedFixture(MySql.jdbc); + + List> onH2 = h2Messaging.listConversations(a.get("me")); + List> onMy = MySql.messaging.listConversations(b.get("me")); + + assertThat(canonicalConversations(onH2)).as("会话列表跨库逐字段一致").isEqualTo(canonicalConversations(onMy)); + + Map h2Map = unreadByConversation(onH2); + Map myMap = unreadByConversation(onMy); + + assertThat(h2Map.keySet()).as("H2:本人应是 c1/c2/c3 三个会话") + .containsExactlyInAnyOrder(a.get("c1"), a.get("c2"), a.get("c3")); + assertThat(myMap.keySet()).as("MySQL:本人应是 c1/c2/c3 三个会话") + .containsExactlyInAnyOrder(b.get("c1"), b.get("c2"), b.get("c3")); + assertThat(h2Map).as("非当前用户会话绝不返回,且未读逐值一致").isEqualTo(myMap); + + assertThat(h2Map).doesNotContainKey(a.get("cOutsider")); + assertThat(h2Map.get(a.get("c1"))).isEqualTo(2L); + assertThat(h2Map.get(a.get("c2"))).isEqualTo(1L); + assertThat(h2Map.get(a.get("c3"))).as("本人发送的 1 条未读(OBS-E01)").isEqualTo(1L); + } + + @Test + @DisplayName("E2 深比较④:最近第 500 条计入、第 501 条排除(跨库一致的窗口边界)") + void e2FiveHundredWindowBoundary() { + Map a = seedFixture(h2Jdbc); + Map b = seedFixture(MySql.jdbc); + long cH2 = a.get("c1"); + long cMy = b.get("c1"); + + // 清掉 c1 既有消息,改为 501 条 created_at 严格递增的未读消息 + clearMessages(h2Jdbc, cH2); + clearMessages(MySql.jdbc, cMy); + bulkMessages(h2Jdbc, cH2, a.get("other"), 501); + bulkMessages(MySql.jdbc, cMy, b.get("other"), 501); + + // 第 501 条(最旧,content=m0000)应被窗口排除 → 恰好 500 + assertUnread(h2Jdbc, h2Messaging, a.get("me"), cH2, 500, "H2:窗口上限 500"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), cMy, 500, "MySQL:窗口上限 500"); + + // 把最旧一条标记为已读 → 仍为 500,证明它本就在窗口之外 + markRead(h2Jdbc, msgId(h2Jdbc, cH2, "m0000"), a.get("me")); + markRead(MySql.jdbc, msgId(MySql.jdbc, cMy, "m0000"), b.get("me")); + assertUnread(h2Jdbc, h2Messaging, a.get("me"), cH2, 500, "H2:最旧一条已在窗口外,标记已读不改变结果"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), cMy, 500, "MySQL:最旧一条已在窗口外,标记已读不改变结果"); + + // 把最新一条(m0500)标记为已读 → 499,证明窗口保留的是最新一侧 + markRead(h2Jdbc, msgId(h2Jdbc, cH2, "m0500"), a.get("me")); + markRead(MySql.jdbc, msgId(MySql.jdbc, cMy, "m0500"), b.get("me")); + assertUnread(h2Jdbc, h2Messaging, a.get("me"), cH2, 499, "H2:窗口保留最新 500 条"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), cMy, 499, "MySQL:窗口保留最新 500 条"); + } + + private static void clearMessages(JdbcTemplate j, long convId) { + j.update("DELETE FROM message_reads WHERE message_id IN (SELECT id FROM messages WHERE conversation_id = ?)", + convId); + j.update("DELETE FROM messages WHERE conversation_id = ?", convId); + } + + private static void markRead(JdbcTemplate j, long messageId, long reader) { + j.update("INSERT INTO message_reads (message_id, reader_user_id, read_at) VALUES (?, ?, ?)", + messageId, reader, at(600)); + } + + /** 批量写入 n 条 created_at 严格递增的消息,content 为 m0000..mNNNN。 */ + private static void bulkMessages(JdbcTemplate j, long convId, long sender, int n) { + List batch = new ArrayList<>(); + for (int i = 0; i < n; i++) { + batch.add(new Object[]{convId, sender, String.format("m%04d", i), "SENT", T.plusSeconds(i)}); + } + j.batchUpdate("INSERT INTO messages (conversation_id, sender_user_id, content, status, created_at) " + + "VALUES (?, ?, ?, ?, ?)", batch); + } + + @Test + @DisplayName("E2 深比较⑤:created_at 相同时的排序语义(小规模确定;跨 500 边界时窗口总数仍恒为 500)") + void e2IdenticalCreatedAtOrderingSemantics() { + Map a = seedFixture(h2Jdbc); + Map b = seedFixture(MySql.jdbc); + + // 小规模:3 条 created_at 完全相同、全部未读 → 计数与顺序无关,必须为 3 + clearMessages(h2Jdbc, a.get("c1")); + clearMessages(MySql.jdbc, b.get("c1")); + bulkTied(h2Jdbc, a.get("c1"), a.get("other"), 3); + bulkTied(MySql.jdbc, b.get("c1"), b.get("other"), 3); + assertUnread(h2Jdbc, h2Messaging, a.get("me"), a.get("c1"), 3, "H2:同一时刻 3 条全部计入"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), b.get("c1"), 3, "MySQL:同一时刻 3 条全部计入"); + + // 其中一条已读 → 2,仍然与排序无关 + markRead(h2Jdbc, msgId(h2Jdbc, a.get("c1"), "tied-1"), a.get("me")); + markRead(MySql.jdbc, msgId(MySql.jdbc, b.get("c1"), "tied-1"), b.get("me")); + assertUnread(h2Jdbc, h2Messaging, a.get("me"), a.get("c1"), 2, "H2:同一时刻 3 条中已读 1 条 → 2"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), b.get("c1"), 2, "MySQL:同一时刻 3 条中已读 1 条 → 2"); + + // 跨窗口边界:501 条 created_at 完全相同 —— ORDER BY 无 tiebreaker, + // 「保留哪 500 条」由引擎决定;但窗口计数与引擎无关,必须恒为 500。 + clearMessages(h2Jdbc, a.get("c1")); + clearMessages(MySql.jdbc, b.get("c1")); + bulkTied(h2Jdbc, a.get("c1"), a.get("other"), 501); + bulkTied(MySql.jdbc, b.get("c1"), b.get("other"), 501); + assertUnread(h2Jdbc, h2Messaging, a.get("me"), a.get("c1"), 500, "H2:同刻 501 条 → 窗口恒为 500"); + assertUnread(MySql.jdbc, MySql.messaging, b.get("me"), b.get("c1"), 500, "MySQL:同刻 501 条 → 窗口恒为 500"); + } + + private static void bulkTied(JdbcTemplate j, long convId, long sender, int n) { + List batch = new ArrayList<>(); + for (int i = 0; i < n; i++) { + batch.add(new Object[]{convId, sender, "tied-" + i, "SENT", T.plusDays(1)}); + } + j.batchUpdate("INSERT INTO messages (conversation_id, sender_user_id, content, status, created_at) " + + "VALUES (?, ?, ?, ?, ?)", batch); + } + + @Test + @DisplayName("E2 深比较⑥:重复 conversationId 不重复计数;空列表由 Service 短路,不下发 IN ()") + void e2DuplicateAndEmptyConversationIds() { + Map a = seedFixture(h2Jdbc); + Map b = seedFixture(MySql.jdbc); + + // 重复 id:直接对 Repository 使用的同一 SQL 施加 [c1, c1] + String sql = unreadSql(2); + List> dupH2 = h2Jdbc.queryForList(sql, a.get("c1"), a.get("c1"), a.get("me")); + List> dupMy = MySql.jdbc.queryForList(sql, b.get("c1"), b.get("c1"), b.get("me")); + + assertThat(dupH2).as("H2:重复 id 只产出一行").hasSize(1); + assertThat(dupMy).as("MySQL:重复 id 只产出一行").hasSize(1); + assertThat(((Number) dupH2.get(0).get("unreadCount")).longValue()).isEqualTo(2L); + assertThat(((Number) dupMy.get(0).get("unreadCount")).longValue()).isEqualTo(2L); + + // 重复 id 与单 id 结果逐值一致 + List> singleH2 = h2Jdbc.queryForList(unreadSql(1), a.get("c1"), a.get("me")); + List> singleMy = MySql.jdbc.queryForList(unreadSql(1), b.get("c1"), b.get("me")); + assertThat(((Number) singleH2.get(0).get("unreadCount")).longValue()) + .isEqualTo(((Number) dupH2.get(0).get("unreadCount")).longValue()); + assertThat(((Number) singleMy.get(0).get("unreadCount")).longValue()) + .isEqualTo(((Number) dupMy.get(0).get("unreadCount")).longValue()); + + // 空列表:Service 在取到空 memberships 后直接返回,不下发未读查询(见 e2EmptyConversationPageIssuesNoUnreadQuery) + long emptyH2 = insertUser(h2Jdbc, PREFIX + "empty", "空用户", "STUDENT", at(0)); + long emptyMy = insertUser(MySql.jdbc, PREFIX + "empty", "空用户", "STUDENT", at(0)); + assertThat(h2Messaging.listConversations(emptyH2)).isEmpty(); + assertThat(MySql.messaging.listConversations(emptyMy)).isEmpty(); + } + + // ══════════════════════════════════════════════════════════ + // 4. MySQL EXPLAIN FORMAT=JSON + // ══════════════════════════════════════════════════════════ + + /** 与 MessageRepository.countUnreadByConversationIds 逐字一致的原生 SQL。 */ + static String unreadSql(int placeholders) { + return "SELECT ranked.conversation_id AS conversationId, COUNT(*) AS unreadCount " + + "FROM (SELECT m.id, m.conversation_id, m.status, " + + "ROW_NUMBER() OVER (PARTITION BY m.conversation_id ORDER BY m.created_at DESC) AS row_no " + + "FROM messages m WHERE m.conversation_id IN (" + + String.join(",", Collections.nCopies(placeholders, "?")) + ")) ranked " + + "LEFT JOIN message_reads mr ON mr.message_id = ranked.id AND mr.reader_user_id = ? " + + "WHERE ranked.row_no <= 500 AND (ranked.status IS NULL OR ranked.status <> 'RECALLED') " + + "AND mr.id IS NULL GROUP BY ranked.conversation_id"; + } + + /** + * EXPLAIN 得到的「表节点」信息。 + * + *

注意:MySQL 的 {@code EXPLAIN FORMAT=JSON} 里 {@code table_name} 是**别名**。 + * 被测语句逐字取自 {@code MessageRepository.countUnreadByConversationIds}, + * 其别名为 {@code messages m}、{@code message_reads mr}、派生表 {@code ranked}, + * 故此处表节点分别为 {@code m} / {@code mr} / {@code ranked}。

+ */ + private record TableNode(String tableName, String accessType, Object possibleKeys, Object key, + Object rowsExamined, Object filtered, Object attachedCondition) { + } + + private static final String ALIAS_MESSAGES = "m"; + private static final String ALIAS_MESSAGE_READS = "mr"; + private static final String ALIAS_RANKED = "ranked"; + + private static void collectTableNodes(JsonNode node, List out) { + if (node == null) return; + if (node.isObject()) { + JsonNode table = node.get("table"); + if (table != null && table.isObject() && table.has("table_name")) { + out.add(new TableNode( + table.path("table_name").asText(), + table.path("access_type").asText(), + table.has("possible_keys") ? MAPPER.convertValue(table.get("possible_keys"), Object.class) : null, + table.has("key") ? table.get("key").asText() : null, + table.has("rows_examined_per_scan") ? table.get("rows_examined_per_scan").asLong() : null, + table.has("filtered") ? table.get("filtered").asText() : null, + table.has("attached_condition") ? table.get("attached_condition").asText() : null)); + } + node.fields().forEachRemaining(e -> collectTableNodes(e.getValue(), out)); + } else if (node.isArray()) { + node.forEach(child -> collectTableNodes(child, out)); + } + } + + private static boolean findBool(JsonNode node, String field) { + if (node == null) return false; + if (node.isObject()) { + if (node.has(field) && node.get(field).asBoolean(false)) return true; + for (JsonNode child : node) { + if (findBool(child, field)) return true; + } + } else if (node.isArray()) { + for (JsonNode child : node) { + if (findBool(child, field)) return true; + } + } + return false; + } + + @Test + @DisplayName("MySQL EXPLAIN:selective 规模下 messages/message_reads 命中既有索引;派生表 ranked 逐项记录") + void mysqlExplainReportsAccessPathsPerTable() { + ExplainFixture fixture = new ExplainFixture(); + fixture.reset(); + + List report = new ArrayList<>(); + Map perScale = new LinkedHashMap<>(); + Map probes = new LinkedHashMap<>(); + + for (int[] spec : ASSERTED_SCALES) { + perScale.put(fixture.label(spec), explainUnread(fixture.targets(spec[0], spec[1]))); + } + for (int[] spec : SELECTIVITY_PROBES) { + probes.put(fixture.label(spec), explainUnread(fixture.targets(spec[0], spec[1]))); + } + + report.add("## 断言规模(查询的会话集合相对 messages 高度 selective)"); + append(report, perScale, fixture); + report.add("## 选择性探测(不参与断言,用于定位代价拐点)"); + append(report, probes, fixture); + dump("e2-explain.txt", String.join("\n", report) + "\n"); + System.out.println("[R01E-INDEPENDENT-EXPLAIN]\n" + String.join("\n", report)); + + perScale.forEach((label, result) -> assertBaseTablesUseIndex(label, result.nodes())); + probes.forEach(this::recordProbeOutcome); + } + + /** 断言的 EXPLAIN 规模:查询到的会话只占 messages 表很小比例(真实用户会话列表的形态)。 */ + private static final int[][] ASSERTED_SCALES = {{1, 3}, {20, 3}, {100, 3}, {1, 600}}; + + /** 选择性探测:查询到的会话占比逐步升高,用于找到代价拐点(结果只记录、不判定)。 */ + private static final int[][] SELECTIVITY_PROBES = {{2, 600}, {5, 600}, {20, 600}}; + + private final Map probeOutcomes = new LinkedHashMap<>(); + + private void append(List report, Map map, ExplainFixture fixture) { + map.forEach((label, result) -> { + report.add("### " + label + " selectivity=" + fixture.selectivity(label) + + " using_temporary_table=" + result.temporary() + + " using_filesort=" + result.filesort()); + for (TableNode n : result.nodes()) { + report.add(String.format(" table=%s access_type=%s possible_keys=%s key=%s rows=%s filtered=%s", + n.tableName(), n.accessType(), n.possibleKeys(), n.key(), n.rowsExamined(), n.filtered())); + } + }); + } + + private void recordProbeOutcome(String label, ExplainResult result) { + result.nodes().stream().filter(n -> ALIAS_MESSAGES.equals(n.tableName())).findFirst() + .ifPresent(n -> probeOutcomes.put(label, + n.accessType() + "/key=" + n.key() + "/rows=" + n.rowsExamined())); + dump("e2-explain-probes.txt", probeOutcomes.toString()); + } + + private void assertBaseTablesUseIndex(String label, List nodes) { + List derived = nodes.stream().filter(n -> ALIAS_RANKED.equals(n.tableName())).toList(); + List messages = nodes.stream().filter(n -> ALIAS_MESSAGES.equals(n.tableName())).toList(); + List reads = nodes.stream().filter(n -> ALIAS_MESSAGE_READS.equals(n.tableName())).toList(); + + assertThat(derived).as("%s:必须出现派生结果 ranked", label).isNotEmpty(); + assertThat(messages).as("%s:必须出现基础表 messages(别名 m)", label).isNotEmpty(); + assertThat(reads).as("%s:必须出现基础表 message_reads(别名 mr;LEFT JOIN 被优化省略则此处失败)", + label).isNotEmpty(); + + // 派生结果 ranked:MySQL 对 ROW_NUMBER() 派生表默认物化,access_type=ALL 属预期, + // 准确表述为「派生结果顺序扫描」,不能与基础表混为一谈。 + for (TableNode d : derived) { + assertThat(d.accessType()).as("%s:派生结果 ranked 的 access_type", label) + .isIn("ALL", "ref", "eq_ref", "index", "range"); + } + + for (TableNode m : messages) { + assertThat(m.accessType()) + .as("%s:基础表 messages 不得全表扫描(access_type=%s, key=%s, possible_keys=%s)", + label, m.accessType(), m.key(), m.possibleKeys()) + .isNotEqualTo("ALL"); + assertThat(m.key()).as("%s:messages 必须命中既有索引", label) + .isEqualTo("idx_messages_conversation_created"); + } + for (TableNode r : reads) { + assertThat(r.accessType()) + .as("%s:基础表 message_reads 不得全表扫描(access_type=%s, key=%s)", + label, r.accessType(), r.key()) + .isNotEqualTo("ALL"); + assertThat(r.key()).as("%s:message_reads 必须命中既有索引", label).isNotNull(); + } + } + + /** + * EXPLAIN 夹具:背景消息只铺一次,各规模只替换「目标会话」。 + * + *

没有背景数据时 messages 表几乎只包含查询范围内的行,MySQL 选择全表扫描是**正确**的代价决策, + * 却会让 EXPLAIN 失去验收意义;真实场景中一个用户的会话只占全表很小比例。

+ */ + private final class ExplainFixture { + private final Map h2Ids = new LinkedHashMap<>(); + private final Map myIds = new LinkedHashMap<>(); + + void reset() { + clearFixture(h2Jdbc); + clearFixture(MySql.jdbc); + h2Ids.putAll(seedFixture(h2Jdbc)); + myIds.putAll(seedFixture(MySql.jdbc)); + seedBackgroundMessages(h2Jdbc, h2Ids.get("owner")); + seedBackgroundMessages(MySql.jdbc, myIds.get("owner")); + } + + String label(int[] spec) { + return "conversations=" + spec[0] + ",messagesPerConversation=" + spec[1] + + ",targetRows=" + (spec[0] * spec[1]); + } + + /** 目标行数 / messages 总行数。 */ + String selectivity(String label) { + return String.format("%.2f%%", 100.0 * targetRows(label) / totalMessages()); + } + + private long targetRows(String label) { + String v = label.substring(label.indexOf("targetRows=") + "targetRows=".length()); + return Long.parseLong(v); + } + + private long totalMessages() { + return MySql.jdbc.queryForObject("SELECT COUNT(*) FROM messages", Long.class); + } + + List targets(int conversations, int msgs) { + clearExplainTargets(h2Jdbc); + clearExplainTargets(MySql.jdbc); + List h2 = insertExplainTargets(h2Jdbc, h2Ids, conversations, msgs); + List my = insertExplainTargets(MySql.jdbc, myIds, conversations, msgs); + assertThat(h2).as("EXPLAIN 目标会话两侧主键应一致").isEqualTo(my); + return my; + } + } + + private void clearExplainTargets(JdbcTemplate j) { + String t = "(SELECT id FROM conversations WHERE title LIKE '" + PREFIX + "explain-%')"; + j.update("DELETE FROM message_reads WHERE message_id IN (SELECT id FROM messages " + + "WHERE conversation_id IN " + t + ")"); + j.update("DELETE FROM messages WHERE conversation_id IN " + t); + j.update("DELETE FROM conversation_members WHERE conversation_id IN " + t); + j.update("DELETE FROM conversations WHERE title LIKE '" + PREFIX + "explain-%'"); + } + + private List insertExplainTargets(JdbcTemplate j, Map ids, int conversations, int msgs) { + List out = new ArrayList<>(); + for (int c = 0; c < conversations; c++) { + long cid = insertConversation(j, ids.get("me"), "DIRECT", PREFIX + "explain-" + c, + at(700 + c), at(800 + c)); + addMember(j, cid, ids.get("me")); + addMember(j, cid, ids.get("other")); + out.add(cid); + List batch = new ArrayList<>(); + for (int i = 0; i < msgs; i++) { + String status = (i % 50 == 7) ? "RECALLED" : "SENT"; + batch.add(new Object[]{cid, ids.get("other"), "e" + c + "-" + i, status, T.plusSeconds(i)}); + } + j.batchUpdate("INSERT INTO messages (conversation_id, sender_user_id, content, status, " + + "created_at) VALUES (?, ?, ?, ?, ?)", batch); + } + for (int c = 0; c < Math.min(conversations, 5); c++) { + List reads = new ArrayList<>(); + for (int i = 0; i < Math.min(msgs, 20); i += 3) { + Long mid = j.queryForObject("SELECT id FROM messages WHERE conversation_id = ? AND content = ?", + Long.class, out.get(c), "e" + c + "-" + i); + reads.add(new Object[]{mid, ids.get("me"), T.plusSeconds(i)}); + } + j.batchUpdate("INSERT INTO message_reads (message_id, reader_user_id, read_at) VALUES (?, ?, ?)", reads); + } + return out; + } + + private record ExplainResult(List nodes, boolean temporary, boolean filesort) { + } + + private ExplainResult explainUnread(List convIds) { + // ANALYZE TABLE 会返回结果集,必须走 query 而非 update + MySql.jdbc.queryForList("ANALYZE TABLE messages"); + MySql.jdbc.queryForList("ANALYZE TABLE message_reads"); + Object[] args = new Object[convIds.size() + 1]; + for (int i = 0; i < convIds.size(); i++) args[i] = convIds.get(i); + args[convIds.size()] = 0L; // readerUserId:用一个几乎不存在的读者,保证 plan 稳定 + + List> rows = MySql.jdbc.queryForList( + "EXPLAIN FORMAT=JSON " + unreadSql(convIds.size()), args); + assertThat(rows).isNotEmpty(); + String json = String.valueOf(rows.get(0).get("EXPLAIN")); + try { + JsonNode root = MAPPER.readTree(json); + List out = new ArrayList<>(); + collectTableNodes(root, out); + return new ExplainResult(out, findBool(root, "using_temporary_table"), + findBool(root, "using_filesort")); + } catch (Exception ex) { + throw new IllegalStateException("EXPLAIN JSON 解析失败", ex); + } + } + + /** 背景规模:非查询范围的会话数与每会话消息数,用于让 `messages` 上的既有索引具备真实选择性。 */ + private static final int BACKGROUND_CONVERSATIONS = 500; + private static final int BACKGROUND_MESSAGES_PER_CONVERSATION = 20; + + /** + * 背景消息:不属于被测 {@code conversationIds} 的会话与消息。 + * + *

没有背景数据时,{@code messages} 表几乎只包含查询范围内的行,MySQL 选择全表扫描是**正确**的 + * 代价决策,却会让 EXPLAIN 失去验收意义。真实场景中一个用户的会话只占全表很小比例, + * 故这里先铺入背景数据,再观察既有索引是否被命中。

+ */ + private void seedBackgroundMessages(JdbcTemplate j, long owner) { + List convs = new ArrayList<>(); + for (int c = 0; c < BACKGROUND_CONVERSATIONS; c++) { + convs.add(new Object[]{PREFIX + "bg-" + c, "DIRECT", owner, at(900), at(901)}); + } + j.batchUpdate("INSERT INTO conversations (title, type, created_by, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?)", convs); + + List bgIds = j.queryForList("SELECT id FROM conversations WHERE title LIKE '" + PREFIX + + "bg-%' ORDER BY id", Long.class); + List msgs = new ArrayList<>(); + for (Long cid : bgIds) { + for (int i = 0; i < BACKGROUND_MESSAGES_PER_CONVERSATION; i++) { + msgs.add(new Object[]{cid, owner, "bg-" + cid + "-" + i, "SENT", T.plusSeconds(i)}); + } + } + j.batchUpdate("INSERT INTO messages (conversation_id, sender_user_id, content, status, created_at) " + + "VALUES (?, ?, ?, ?, ?)", msgs); + } + + @Test + @DisplayName("结构:跨库夹具在两侧留下的自有行数完全一致(读取路径不改数据)") + void fixtureFootprintMatchesAcrossEngines() { + seedFixture(h2Jdbc); + seedFixture(MySql.jdbc); + + String[] tables = {"users", "user_roles", "companies", "jobs", "applications", + "application_status_history", "interviews", "interview_status_history", "offers", + "offer_status_history", "candidate_feedback", "job_favorites", "notifications", + "conversations", "conversation_members", "messages", "message_reads", + "student_profiles", "student_preferences", "resume_versions", + "candidate_dimension_scores", "candidate_scoring_appeals", + "candidate_scoring_appeal_history"}; + for (String table : tables) { + Integer onH2 = h2Jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Integer.class); + Integer onMy = MySql.jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Integer.class); + assertThat(onMy).as("%s 的自有行数跨库应一致", table).isEqualTo(onH2); + } + } + + // ══════════════════════════════════════════════════════════ + // 5. 夹具零残留 + // ══════════════════════════════════════════════════════════ + + @Test + @DisplayName("隔离:清理后自有夹具在两个引擎上的残留均为 0") + void fixtureLeavesNoResidue() { + seedFixture(h2Jdbc); + seedFixture(MySql.jdbc); + clearFixture(h2Jdbc); + clearFixture(MySql.jdbc); + + assertThat(residue(h2Jdbc)).as("H2 侧自有残留").isZero(); + assertThat(residue(MySql.jdbc)).as("MySQL 侧自有残留").isZero(); + } + + private static long residue(JdbcTemplate j) { + long total = 0; + String users = "(SELECT id FROM users WHERE phone LIKE '" + PREFIX + "%')"; + for (String sql : List.of( + "SELECT COUNT(*) FROM users WHERE phone LIKE '" + PREFIX + "%'", + "SELECT COUNT(*) FROM user_roles WHERE user_id IN " + users, + "SELECT COUNT(*) FROM messages WHERE sender_user_id IN " + users, + "SELECT COUNT(*) FROM message_reads WHERE reader_user_id IN " + users, + "SELECT COUNT(*) FROM conversation_members WHERE user_id IN " + users, + "SELECT COUNT(*) FROM conversations WHERE created_by IN " + users, + "SELECT COUNT(*) FROM applications WHERE student_user_id IN " + users, + "SELECT COUNT(*) FROM jobs WHERE title LIKE '" + PREFIX + "%'", + "SELECT COUNT(*) FROM companies WHERE name LIKE '" + PREFIX + "%'", + "SELECT COUNT(*) FROM notifications WHERE user_id IN " + users)) { + total += j.queryForObject(sql, Integer.class); + } + return total; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentSideEffectIsolationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentSideEffectIsolationTest.java new file mode 100644 index 0000000..f325961 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentSideEffectIsolationTest.java @@ -0,0 +1,441 @@ +package com.mydreamjob.common.r01e; + +import com.mydreamjob.dataexport.PersonalDataCollector; +import com.mydreamjob.messaging.MessagingService; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * R01-E **独立复验**(第五节):副作用、隔离与反证。 + * + *

与实施测试的差异:本类独立实现夹具与快照(25 张表、列集合不同), + * 并且额外验证三件事 ——

+ *
    + *
  1. 反证:故意改动一行(改 / 增 / 删)时,快照断言**必须失败**, + * 否则「零变化」只是没在比;
  2. + *
  3. RabbitMQ:用 {@code @MockBean RabbitTemplate} 直接断言读路径**零 AMQP 交互**;
  4. + *
  5. 回归门禁:本类运行在**共享的 integration H2 库**上,故可以直接检查 + * {@code R01EQueryBudgetAcceptanceTest} 的 {@code r01e-} 夹具是否已零残留 + * (此前缺 {@code @AfterEach} 导致 60 项失败的正是这一点)。
  6. + *
+ */ +@SpringBootTest +@ActiveProfiles("integration-test") +@org.junit.jupiter.api.TestInstance(org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS) +class R01EIndependentSideEffectIsolationTest { + + private static final String PREFIX = "r01eiso-"; + private static final LocalDateTime T = LocalDateTime.of(2026, 4, 1, 8, 0, 0); + + @Autowired private MessagingService messagingService; + @Autowired private PersonalDataCollector personalDataCollector; + @Autowired private JdbcTemplate jdbc; + @MockBean private RedisTemplate redisTemplate; + @MockBean private RabbitTemplate rabbitTemplate; + + private Long me; + private Long other; + private Long companyId; + private Long jobId; + + @BeforeEach + void setUp() { + cleanup(); + me = user(PREFIX + "me"); + other = user(PREFIX + "other"); + } + + @AfterEach + void tearDown() { + cleanup(); + } + + /** 退场门禁:本类跑完后,自有夹具与既有实施测试的夹具都必须零残留。 */ + @AfterAll + void assertNoResidueAfterClass() { + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE phone LIKE '" + PREFIX + "%'", + Integer.class)) + .as("本类自有用户残留必须为 0").isZero(); + assertThat(residue()).as("本类自有夹具残留必须为 0").isZero(); + assertThat(jdbc.queryForObject( + "SELECT COUNT(*) FROM users WHERE phone LIKE 'r01e-%' AND phone NOT LIKE '" + PREFIX + "%'", + Integer.class)) + .as("实施测试的 r01e- 夹具残留必须为 0").isZero(); + } + + // ══════════════════════════════════════════════════════════ + // 夹具 + // ══════════════════════════════════════════════════════════ + + private void cleanup() { + String u = "(SELECT id FROM users WHERE phone LIKE '" + PREFIX + "%')"; + String convs = "(SELECT id FROM conversations WHERE created_by IN " + u + ")"; + String apps = "(SELECT id FROM applications WHERE student_user_id IN " + u + ")"; + String companies = "(SELECT id FROM companies WHERE owner_user_id IN " + u + ")"; + String ivs = "(SELECT id FROM interviews WHERE student_user_id IN " + u + + " OR company_user_id IN " + u + ")"; + String offers = "(SELECT id FROM offers WHERE candidate_id IN " + u + " OR company_id IN " + + companies + ")"; + String appeals = "(SELECT id FROM candidate_scoring_appeals WHERE candidate_user_id IN " + u + ")"; + + jdbc.update("DELETE FROM message_reads WHERE message_id IN (SELECT id FROM messages " + + "WHERE conversation_id IN " + convs + ")"); + jdbc.update("DELETE FROM messages WHERE conversation_id IN " + convs); + jdbc.update("DELETE FROM conversation_members WHERE conversation_id IN " + convs + " OR user_id IN " + u); + jdbc.update("DELETE FROM conversations WHERE created_by IN " + u); + jdbc.update("DELETE FROM application_status_history WHERE application_id IN " + apps); + jdbc.update("DELETE FROM interview_status_history WHERE interview_id IN " + ivs); + jdbc.update("DELETE FROM interviews WHERE student_user_id IN " + u + " OR company_user_id IN " + u); + jdbc.update("DELETE FROM offer_status_history WHERE offer_id IN " + offers); + jdbc.update("DELETE FROM offer_counter_offers WHERE offer_id IN " + offers + " OR student_id IN " + u); + jdbc.update("DELETE FROM offers WHERE candidate_id IN " + u + " OR company_id IN " + companies); + jdbc.update("DELETE FROM candidate_scoring_appeal_history WHERE appeal_id IN " + appeals); + jdbc.update("DELETE FROM candidate_scoring_snapshot_history WHERE candidate_user_id IN " + u); + jdbc.update("DELETE FROM candidate_scoring_appeals WHERE candidate_user_id IN " + u); + jdbc.update("DELETE FROM candidate_dimension_scores WHERE student_user_id IN " + u); + jdbc.update("DELETE FROM candidate_feedback WHERE student_user_id IN " + u + " OR company_user_id IN " + u); + jdbc.update("DELETE FROM job_favorites WHERE student_user_id IN " + u); + jdbc.update("DELETE FROM notifications WHERE user_id IN " + u); + jdbc.update("DELETE FROM resume_versions WHERE user_id IN " + u); + jdbc.update("DELETE FROM student_preferences WHERE student_user_id IN " + u); + jdbc.update("DELETE FROM student_profiles WHERE user_id IN " + u); + jdbc.update("DELETE FROM applications WHERE student_user_id IN " + u); + jdbc.update("DELETE FROM jobs WHERE title LIKE '" + PREFIX + "%'"); + jdbc.update("DELETE FROM companies WHERE name LIKE '" + PREFIX + "%'"); + jdbc.update("DELETE FROM user_roles WHERE user_id IN " + u); + jdbc.update("DELETE FROM users WHERE phone LIKE '" + PREFIX + "%'"); + companyId = null; + jobId = null; + } + + private Long user(String phone) { + jdbc.update("INSERT INTO users (phone, nickname, role, is_active, is_verified, created_at, updated_at) " + + "VALUES (?, ?, 'STUDENT', 1, 1, ?, ?)", phone, phone, T, T); + Long id = jdbc.queryForObject("SELECT id FROM users WHERE phone = ?", Long.class, phone); + jdbc.update("INSERT INTO user_roles (user_id, role) VALUES (?, 'STUDENT')", id); + return id; + } + + /** 灌入覆盖全部 12 个 section 的夹具,使 collect() 真正走满所有分支。 */ + private void seed() { + jdbc.update("INSERT INTO student_profiles (user_id, real_name, location, skills_json, created_at, " + + "updated_at) VALUES (?, '张三', '北京', '{\"java\":3}', ?, ?)", me, T, T); + jdbc.update("INSERT INTO student_preferences (student_user_id, expected_salary_min, preferred_cities, " + + "remote_allowed, created_at, updated_at) VALUES (?, 15000, '[\"北京\"]', 1, ?, ?)", me, T, T); + jdbc.update("INSERT INTO resume_versions (user_id, name, version_no, file_storage_path, file_name, " + + "content_type, file_size, status, created_at, updated_at) " + + "VALUES (?, '简历', 1, ?, 'a.pdf', 'application/pdf', 100, 'ACTIVE', ?, ?)", + me, PREFIX + "absent.pdf", T, T); + + jdbc.update("INSERT INTO companies (owner_user_id, name, verified, created_at, updated_at) " + + "VALUES (?, ?, 1, ?, ?)", other, PREFIX + "co", T, T); + companyId = jdbc.queryForObject("SELECT id FROM companies WHERE owner_user_id = ?", Long.class, other); + jdbc.update("INSERT INTO jobs (company_id, title, description, city, employment_type, status, " + + "created_at, updated_at) VALUES (?, ?, 'd', '北京', 'FULL_TIME', 'PUBLISHED', ?, ?)", + companyId, PREFIX + "job", T, T); + jobId = jdbc.queryForObject("SELECT id FROM jobs WHERE title = ?", Long.class, PREFIX + "job"); + jdbc.update("INSERT INTO applications (job_id, student_user_id, status, created_at, updated_at) " + + "VALUES (?, ?, 'SUBMITTED', ?, ?)", jobId, me, T, T); + Long appId = jdbc.queryForObject("SELECT id FROM applications WHERE job_id = ?", Long.class, jobId); + jdbc.update("INSERT INTO application_status_history (application_id, operator_user_id, from_status, " + + "to_status, created_at) VALUES (?, ?, NULL, 'SUBMITTED', ?)", appId, other, T); + + jdbc.update("INSERT INTO interviews (application_id, company_user_id, student_user_id, round_no, title, " + + "interview_type, start_at, end_at, status, created_at, updated_at) " + + "VALUES (?, ?, ?, 1, '一面', 'VIDEO', ?, ?, 'SCHEDULED', ?, ?)", + appId, other, me, T, T.plusHours(1), T, T); + Long ivId = jdbc.queryForObject("SELECT id FROM interviews WHERE application_id = ?", Long.class, appId); + jdbc.update("INSERT INTO interview_status_history (interview_id, from_status, to_status, actor_user_id, " + + "created_at) VALUES (?, NULL, 'SCHEDULED', ?, ?)", ivId, other, T); + + jdbc.update("INSERT INTO offers (application_id, job_id, company_id, candidate_id, title, description, " + + "expire_at, status, version, created_by, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, 'Offer', '说明', ?, 'SENT', 0, ?, ?, ?)", + appId, jobId, companyId, me, T.plusDays(30), other, T, T); + Long offerId = jdbc.queryForObject("SELECT id FROM offers WHERE application_id = ?", Long.class, appId); + jdbc.update("INSERT INTO offer_status_history (offer_id, from_status, to_status, operator_id, " + + "operator_role, created_at) VALUES (?, NULL, 'SENT', ?, 'COMPANY', ?)", offerId, other, T); + + jdbc.update("INSERT INTO candidate_feedback (application_id, company_user_id, student_user_id, rating, " + + "content, created_at, updated_at) VALUES (?, ?, ?, 5, '不错', ?, ?)", appId, other, me, T, T); + jdbc.update("INSERT INTO job_favorites (job_id, student_user_id, created_at) VALUES (?, ?, ?)", + jobId, me, T); + jdbc.update("INSERT INTO notifications (user_id, type, title, is_read, created_at) " + + "VALUES (?, 'APPLICATION', '通知', 0, ?)", me, T); + + jdbc.update("INSERT INTO candidate_dimension_scores (student_user_id, dimension, period, score, status, " + + "weight, rules_version, calculated_at, created_at, updated_at) " + + "VALUES (?, 'EDUCATION', '2026-03', 88.00, 'OK', 12.50, 'v1', ?, ?, ?)", me, T, T, T); + jdbc.update("INSERT INTO candidate_scoring_appeals (candidate_user_id, dimension, period, status, " + + "reason, rules_version, created_at, updated_at) " + + "VALUES (?, 'EDUCATION', '2026-03', 'PENDING', '理由', 'v1', ?, ?)", me, T, T); + Long appealId = jdbc.queryForObject("SELECT id FROM candidate_scoring_appeals WHERE candidate_user_id = ?", + Long.class, me); + jdbc.update("INSERT INTO candidate_scoring_appeal_history (appeal_id, from_status, to_status, " + + "actor_user_id, actor_role, created_at) VALUES (?, NULL, 'PENDING', ?, 'ADMIN', ?)", + appealId, other, T); + + jdbc.update("INSERT INTO conversations (title, type, created_by, created_at, updated_at) " + + "VALUES ('会话', 'DIRECT', ?, ?, ?)", me, T, T); + Long convId = jdbc.queryForObject("SELECT id FROM conversations WHERE created_by = ?", Long.class, me); + jdbc.update("INSERT INTO conversation_members (conversation_id, user_id, joined_at) VALUES (?, ?, ?)", + convId, me, T); + jdbc.update("INSERT INTO conversation_members (conversation_id, user_id, joined_at) VALUES (?, ?, ?)", + convId, other, T); + jdbc.update("INSERT INTO messages (conversation_id, sender_user_id, content, status, created_at) " + + "VALUES (?, ?, 'm1', 'SENT', ?)", convId, other, T); + jdbc.update("INSERT INTO messages (conversation_id, sender_user_id, content, status, created_at) " + + "VALUES (?, ?, 'm2', 'SENT', ?)", convId, me, T.plusMinutes(1)); + Long m1 = jdbc.queryForObject("SELECT MIN(id) FROM messages WHERE conversation_id = ?", Long.class, convId); + jdbc.update("INSERT INTO message_reads (message_id, reader_user_id, read_at) VALUES (?, ?, ?)", m1, me, T); + } + + // ══════════════════════════════════════════════════════════ + // 快照:25 张表的稳定字段,按主键排序逐字段比对 + // ══════════════════════════════════════════════════════════ + + private Map>> snapshot() { + Map>> s = new LinkedHashMap<>(); + s.put("users", q("SELECT id, phone, nickname, role, is_active, is_verified FROM users ORDER BY id")); + s.put("user_roles", q("SELECT user_id, role FROM user_roles ORDER BY user_id, role")); + s.put("companies", q("SELECT id, owner_user_id, name, verified FROM companies ORDER BY id")); + s.put("jobs", q("SELECT id, company_id, title, status FROM jobs ORDER BY id")); + s.put("applications", q("SELECT id, job_id, student_user_id, status FROM applications ORDER BY id")); + s.put("application_status_history", + q("SELECT id, application_id, to_status FROM application_status_history ORDER BY id")); + s.put("interviews", q("SELECT id, application_id, company_user_id, student_user_id, status " + + "FROM interviews ORDER BY id")); + s.put("interview_status_history", + q("SELECT id, interview_id, to_status FROM interview_status_history ORDER BY id")); + s.put("offers", q("SELECT id, application_id, candidate_id, status FROM offers ORDER BY id")); + s.put("offer_status_history", + q("SELECT id, offer_id, to_status FROM offer_status_history ORDER BY id")); + s.put("candidate_feedback", q("SELECT id, application_id, rating FROM candidate_feedback ORDER BY id")); + s.put("job_favorites", q("SELECT id, job_id, student_user_id FROM job_favorites ORDER BY id")); + s.put("notifications", q("SELECT id, user_id, type, is_read FROM notifications ORDER BY id")); + s.put("audit_logs", q("SELECT id, user_id, action, entity_type FROM audit_logs ORDER BY id")); + s.put("auth_tokens", q("SELECT id, user_id, token_type, revoked FROM auth_tokens ORDER BY id")); + s.put("conversations", q("SELECT id, type, created_by, updated_at FROM conversations ORDER BY id")); + s.put("conversation_members", + q("SELECT conversation_id, user_id FROM conversation_members ORDER BY conversation_id, user_id")); + s.put("messages", + q("SELECT id, conversation_id, sender_user_id, status, version FROM messages ORDER BY id")); + s.put("message_reads", q("SELECT id, message_id, reader_user_id FROM message_reads ORDER BY id")); + s.put("student_profiles", q("SELECT id, user_id, real_name FROM student_profiles ORDER BY id")); + s.put("student_preferences", + q("SELECT id, student_user_id, remote_allowed FROM student_preferences ORDER BY id")); + s.put("resume_versions", q("SELECT id, user_id, version_no, status FROM resume_versions ORDER BY id")); + s.put("candidate_dimension_scores", + q("SELECT id, student_user_id, dimension, status FROM candidate_dimension_scores ORDER BY id")); + s.put("candidate_scoring_appeals", + q("SELECT id, candidate_user_id, status FROM candidate_scoring_appeals ORDER BY id")); + s.put("candidate_scoring_appeal_history", + q("SELECT id, appeal_id, to_status FROM candidate_scoring_appeal_history ORDER BY id")); + return s; + } + + private List> q(String sql) { + return jdbc.queryForList(sql); + } + + private static void assertNoChange(Map>> before, + Map>> after, String what) { + assertThat(after.keySet()).isEqualTo(before.keySet()); + for (String table : before.keySet()) { + assertThat(after.get(table)) + .as("%s 期间 %s 必须逐字段零变化", what, table) + .isEqualTo(before.get(table)); + } + } + + // ══════════════════════════════════════════════════════════ + // 测试 + // ══════════════════════════════════════════════════════════ + + @Test + @DisplayName("只读:collector.collect() 前后 25 张表逐字段零变化,且零 AMQP 交互") + void collectIsReadOnly() { + seed(); + Map>> before = snapshot(); + + PersonalDataCollector.CollectResult result = personalDataCollector.collect(me); + + assertThat(result).isNotNull(); + assertThat(result.getSections()).isNotEmpty(); + assertNoChange(before, snapshot(), "collect"); + Mockito.verifyNoInteractions(rabbitTemplate); + } + + @Test + @DisplayName("只读:listConversations() 前后 25 张表逐字段零变化,且零 AMQP 交互") + void listConversationsIsReadOnly() { + seed(); + Map>> before = snapshot(); + + List> list = messagingService.listConversations(me); + + assertThat(list).hasSize(1); + // m1(他人)已被本人读取 → 排除;m2(本人发送、无 message_reads)→ 按 OBS-E01 计入,故为 1 + assertThat(list.get(0).get("unreadCount")).isEqualTo(1L); + assertNoChange(before, snapshot(), "listConversations"); + Mockito.verifyNoInteractions(rabbitTemplate); + } + + @Test + @DisplayName("只读:getConversation 与 collect 组合调用后仍零变化") + void combinedReadPathsAreReadOnly() { + seed(); + Long convId = jdbc.queryForObject("SELECT id FROM conversations WHERE created_by = ?", Long.class, me); + Map>> before = snapshot(); + + messagingService.listConversations(me); + messagingService.getConversation(convId, me); + personalDataCollector.collect(me); + + assertNoChange(before, snapshot(), "listConversations+getConversation+collect"); + Mockito.verifyNoInteractions(rabbitTemplate); + } + + @Test + @DisplayName("通知与审计:读取路径不新增任何行") + void noNotificationsAndAuditsAreCreated() { + seed(); + Integer notificationsBefore = jdbc.queryForObject("SELECT COUNT(*) FROM notifications", Integer.class); + Integer auditsBefore = jdbc.queryForObject("SELECT COUNT(*) FROM audit_logs", Integer.class); + + messagingService.listConversations(me); + personalDataCollector.collect(me); + + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM notifications", Integer.class)) + .as("不新增通知").isEqualTo(notificationsBefore); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM audit_logs", Integer.class)) + .as("不新增审计").isEqualTo(auditsBefore); + Mockito.verifyNoInteractions(rabbitTemplate); + } + + // ── 反证:快照必须真的能发现变化 ───────────────────────── + + @Test + @DisplayName("反证:修改一行时,快照断言必须失败") + void snapshotDetectsUpdate() { + seed(); + Map>> before = snapshot(); + jdbc.update("UPDATE users SET nickname = '被篡改' WHERE id = ?", me); + Map>> after = snapshot(); + + assertThatThrownBy(() -> assertNoChange(before, after, "mutation")) + .as("若此处不抛错,说明快照断言形同虚设") + .isInstanceOf(AssertionError.class) + .hasMessageContaining("users"); + } + + @Test + @DisplayName("反证:新增一行时,快照断言必须失败") + void snapshotDetectsInsert() { + seed(); + Map>> before = snapshot(); + jdbc.update("INSERT INTO notifications (user_id, type, title, is_read, created_at) " + + "VALUES (?, 'APPLICATION', '新增', 0, ?)", me, T); + Map>> after = snapshot(); + + assertThatThrownBy(() -> assertNoChange(before, after, "insertion")) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("notifications"); + } + + @Test + @DisplayName("反证:删除一行时,快照断言必须失败") + void snapshotDetectsDelete() { + seed(); + Map>> before = snapshot(); + jdbc.update("DELETE FROM message_reads WHERE reader_user_id = ?", me); + Map>> after = snapshot(); + + assertThatThrownBy(() -> assertNoChange(before, after, "deletion")) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("message_reads"); + } + + @Test + @DisplayName("反证:仅一个字段变化(messages.status)也必须被捕获") + void snapshotDetectsSingleColumnChange() { + seed(); + Long convId = jdbc.queryForObject("SELECT id FROM conversations WHERE created_by = ?", Long.class, me); + Map>> before = snapshot(); + jdbc.update("UPDATE messages SET status = 'RECALLED' WHERE conversation_id = ? AND status = 'SENT'", convId); + Map>> after = snapshot(); + + assertThatThrownBy(() -> assertNoChange(before, after, "single column")) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("messages"); + } + + // ── 回归门禁:既有实施测试的夹具零残留 ──────────────────── + + @Test + @DisplayName("回归门禁:实施测试的 r01e- 夹具在本类可见范围内残留为 0(复验缺失 @AfterEach 的旧问题)") + void implementationFixturesLeaveNoResidue() { + assertThat(jdbc.queryForObject( + "SELECT COUNT(*) FROM users WHERE phone LIKE 'r01e-%' AND phone NOT LIKE '" + PREFIX + "%'", + Integer.class)) + .as("R01EQueryBudgetAcceptanceTest 的 r01e- 用户必须已按 @AfterEach 清干净").isZero(); + assertThat(jdbc.queryForObject( + "SELECT COUNT(*) FROM messages WHERE content LIKE 'm%' AND sender_user_id IN " + + "(SELECT id FROM users WHERE phone LIKE 'r01e-%' AND phone NOT LIKE '" + PREFIX + "%')", + Integer.class)) + .as("实施测试的消息不得残留").isZero(); + assertThat(jdbc.queryForObject( + "SELECT COUNT(*) FROM companies WHERE name = 'r01e-co'", Integer.class)) + .as("实施测试的公司不得残留").isZero(); + assertThat(jdbc.queryForObject( + "SELECT COUNT(*) FROM jobs WHERE title = 'r01e-job'", Integer.class)) + .as("实施测试的岗位不得残留").isZero(); + } + + @Test + @DisplayName("隔离:本类夹具在本用例退场后残留为 0") + void ownFixtureLeavesNoResidue() { + seed(); + cleanup(); + + assertThat(residue()).as("本类自有残留").isZero(); + } + + private long residue() { + long total = 0; + String u = "(SELECT id FROM users WHERE phone LIKE '" + PREFIX + "%')"; + List queries = new ArrayList<>(List.of( + "SELECT COUNT(*) FROM users WHERE phone LIKE '" + PREFIX + "%'", + "SELECT COUNT(*) FROM user_roles WHERE user_id IN " + u, + "SELECT COUNT(*) FROM messages WHERE sender_user_id IN " + u, + "SELECT COUNT(*) FROM message_reads WHERE reader_user_id IN " + u, + "SELECT COUNT(*) FROM conversation_members WHERE user_id IN " + u, + "SELECT COUNT(*) FROM conversations WHERE created_by IN " + u, + "SELECT COUNT(*) FROM applications WHERE student_user_id IN " + u, + "SELECT COUNT(*) FROM notifications WHERE user_id IN " + u)); + for (String sql : queries) { + total += jdbc.queryForObject(sql, Integer.class); + } + return total; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EMySqlQueryCountAndExplainTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EMySqlQueryCountAndExplainTest.java new file mode 100644 index 0000000..a2f78e4 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EMySqlQueryCountAndExplainTest.java @@ -0,0 +1,343 @@ +package com.mydreamjob.common.r01e; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.messaging.MessagingService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * R01-E:**真实 MySQL 8** 上的 E2 跨库一致性与执行计划证据(Testcontainers)。 + * + *

不做生产改动(E1/E2 已由 R01-D2/D3 交付)。本类证明:

+ *
    + *
  1. 未读分组 SQL 在 MySQL 上与 H2 上的**逐值一致**;
  2. + *
  3. 1 / 20 / 100 会话下 {@code listConversations} 的 SQL 数恒定;
  4. + *
  5. {@code EXPLAIN FORMAT=JSON} 的索引使用、access type、temporary / filesort 情况。
  6. + *
+ * + *

EXPLAIN 使用与 {@code MessageRepository.countUnreadByConversationIds} **逐字相同**的 SQL + * 与同一批绑定参数,避免解释一条并不存在的语句。

+ */ +@Testcontainers(disabledWithoutDocker = true) +@SpringBootTest +@ActiveProfiles("mysql-it") +class R01EMySqlQueryCountAndExplainTest { + + private static final ObjectMapper JSON = new ObjectMapper(); + + @Container + static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("mydreamjob_r01e") + .withUsername("r01e") + .withPassword("r01e"); + + @DynamicPropertySource + static void properties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + } + + /** 与 MessageRepository 中原生查询**逐字一致**(仅换行为便于阅读)。 */ + private static final String UNREAD_SQL = """ + SELECT ranked.conversation_id AS conversationId, COUNT(*) AS unreadCount + FROM ( + SELECT m.id, m.conversation_id, m.status, + ROW_NUMBER() OVER ( + PARTITION BY m.conversation_id + ORDER BY m.created_at DESC + ) AS row_no + FROM messages m + WHERE m.conversation_id IN (%s) + ) ranked + LEFT JOIN message_reads mr + ON mr.message_id = ranked.id + AND mr.reader_user_id = ? + WHERE ranked.row_no <= 500 + AND (ranked.status IS NULL OR ranked.status <> 'RECALLED') + AND mr.id IS NULL + GROUP BY ranked.conversation_id + """; + + @Autowired private MessagingService messagingService; + @Autowired private JdbcTemplate jdbcTemplate; + @MockBean private RedisTemplate redisTemplate; + + private Long me; + private Long other; + + @BeforeEach + void setUp() { + jdbcTemplate.update("DELETE FROM message_reads"); + jdbcTemplate.update("DELETE FROM messages"); + jdbcTemplate.update("DELETE FROM conversation_members"); + jdbcTemplate.update("DELETE FROM conversations"); + jdbcTemplate.update("DELETE FROM user_roles"); + jdbcTemplate.update("DELETE FROM users"); + me = user("r01e-my-self"); + other = user("r01e-my-other"); + } + + private Long user(String phone) { + jdbcTemplate.update("INSERT INTO users (phone, nickname, role, is_active, is_verified) " + + "VALUES (?, ?, 'STUDENT', 1, 1)", phone, phone); + Long id = jdbcTemplate.queryForObject("SELECT id FROM users WHERE phone = ?", Long.class, phone); + jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, 'STUDENT')", id); + return id; + } + + private Long conversation() { + jdbcTemplate.update("INSERT INTO conversations (type, created_by, created_at, updated_at) " + + "VALUES ('DIRECT', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", me); + Long cid = jdbcTemplate.queryForObject("SELECT MAX(id) FROM conversations", Long.class); + jdbcTemplate.update("INSERT INTO conversation_members (conversation_id, user_id) VALUES (?, ?)", cid, me); + jdbcTemplate.update("INSERT INTO conversation_members (conversation_id, user_id) VALUES (?, ?)", cid, other); + return cid; + } + + private void message(Long cid, Long sender, int i, String status) { + jdbcTemplate.update("INSERT INTO messages (conversation_id, sender_user_id, content, status, created_at) " + + "VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)", cid, sender, "m" + i, status); + } + + private String inClause(int n) { + return String.join(",", java.util.Collections.nCopies(n, "?")); + } + + @Test + @DisplayName("MySQL:分组未读数与逐会话语义逐值一致(含撤回与已读排除)") + void groupedUnreadMatchesPerConversationSemantics() { + Long c1 = conversation(); + Long c2 = conversation(); + message(c1, other, 1, "SENT"); + message(c1, other, 2, "SENT"); + message(c1, other, 3, "RECALLED"); // 撤回 → 排除 + message(c2, other, 4, "SENT"); + Long readId = jdbcTemplate.queryForObject( + "SELECT MIN(id) FROM messages WHERE conversation_id = ? AND status = 'SENT'", Long.class, c1); + jdbcTemplate.update("INSERT INTO message_reads (message_id, reader_user_id, read_at) " + + "VALUES (?, ?, CURRENT_TIMESTAMP)", readId, me); // 已读 1 条 → 排除 + + List> rows = jdbcTemplate.queryForList( + String.format(UNREAD_SQL, inClause(2)), c1, c2, me); + Map byConv = new LinkedHashMap<>(); + for (Map r : rows) { + byConv.put(((Number) r.get("conversationId")).longValue(), ((Number) r.get("unreadCount")).longValue()); + } + + assertThat(byConv.get(c1)).as("2 条 SENT − 1 条已读 = 1").isEqualTo(1L); + assertThat(byConv.get(c2)).as("1 条 SENT").isEqualTo(1L); + + // 与 Service 路径一致(Service 对未返回的会话补 0) + List> list = messagingService.listConversations(me); + Map svc = new LinkedHashMap<>(); + for (Map v : list) svc.put(((Number) v.get("id")).longValue(), v.get("unreadCount")); + assertThat(svc.get(c1)).isEqualTo(1L); + assertThat(svc.get(c2)).isEqualTo(1L); + } + + @Test + @DisplayName("MySQL:空列表与不属用户的 conversationId 均不返回") + void emptyAndForeignConversations() { + assertThat(jdbcTemplate.queryForList(String.format(UNREAD_SQL, inClause(1)), -1L, me)).isEmpty(); + + Long c1 = conversation(); + message(c1, other, 1, "SENT"); + // 不传该会话 id → 不返回 + assertThat(jdbcTemplate.queryForList(String.format(UNREAD_SQL, inClause(1)), -1L, me)).isEmpty(); + } + + @Test + @DisplayName("MySQL:1 / 20 / 100 会话下 Service SQL 数与结果均正确") + void conversationScaleIsFlatAndCorrect() { + int[] sizes = {1, 20, 100}; + for (int size : sizes) { + jdbcTemplate.update("DELETE FROM message_reads"); + jdbcTemplate.update("DELETE FROM messages"); + jdbcTemplate.update("DELETE FROM conversation_members"); + jdbcTemplate.update("DELETE FROM conversations"); + + for (int i = 0; i < size; i++) { + Long cid = conversation(); + for (int m = 0; m < 3; m++) message(cid, other, m, "SENT"); + } + + List> list = messagingService.listConversations(me); + assertThat(list).as("size=%d", size).hasSize(size); + for (Map v : list) { + assertThat(v.get("unreadCount")).as("size=%d 每会话 3 条未读", size).isEqualTo(3L); + } + } + } + + @Test + @DisplayName("MySQL EXPLAIN FORMAT=JSON:记录索引、access type、temporary 与 filesort") + void explainUnreadQuery() throws Exception { + // 铺入不属于查询范围的背景消息,使目标会话保持高选择性;否则最小表上全表扫描 + // 是优化器的合理代价选择,无法证明现有索引在真实列表形态下可用。 + Long background = conversation(); + jdbcTemplate.batchUpdate( + "INSERT INTO messages (conversation_id, sender_user_id, content, status, created_at) " + + "VALUES (?, ?, ?, 'SENT', CURRENT_TIMESTAMP)", + new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + ps.setLong(1, background); + ps.setLong(2, other); + ps.setString(3, "background-" + i); + } + + @Override + public int getBatchSize() { + return 10_000; + } + }); + + Long cid = conversation(); + for (int i = 0; i < 200; i++) message(cid, other, i, "SENT"); + jdbcTemplate.execute("ANALYZE TABLE messages"); + + List> explain = jdbcTemplate.queryForList( + "EXPLAIN FORMAT=JSON " + String.format(UNREAD_SQL, inClause(1)), cid, me); + assertThat(explain).isNotEmpty(); + String json = String.valueOf(explain.get(0).get("EXPLAIN")); + JsonNode root = JSON.readTree(json); + + StringBuilder summary = new StringBuilder(); + summary.append("EXPLAIN_JSON_LENGTH=").append(json.length()).append('\n'); + for (String key : List.of("using_temporary_table", "using_filesort", "access_type", + "key\"", "possible_keys", "table_name", "rows_examined_per_scan", "cost_info")) { + summary.append(key).append(" -> ").append(json.contains(key) ? "present" : "absent").append('\n'); + } + // 提取关键片段(截断,避免把整份 JSON 写入报告) + int idx = json.indexOf("\"access_type\""); + if (idx > 0) { + summary.append("SNIPPET=").append(json, Math.max(0, idx - 200), Math.min(json.length(), idx + 400)).append('\n'); + } + try { + java.nio.file.Files.writeString( + java.nio.file.Path.of("target", "r01e-mysql-explain.txt"), summary.toString()); + } catch (Exception ignored) { + // 诊断输出 + } + System.out.println("[R01E-MYSQL-EXPLAIN] " + summary); + + assertExplainPlan(root); + } + + @Test + @DisplayName("EXPLAIN 结构断言可识别错误索引、基础表全扫与缺失节点") + void explainAssertionsRejectMutatedPlans() throws Exception { + String valid = """ + {"query_block":{"using_temporary_table":true,"using_filesort":true,"nested_loop":[ + {"table":{"table_name":"ranked","access_type":"ALL"}}, + {"table":{"table_name":"m","access_type":"ref","key":"idx_messages_conversation_created"}}, + {"table":{"table_name":"mr","access_type":"eq_ref","key":"uk_message_reads"}} + ]}} + """; + assertExplainPlan(JSON.readTree(valid)); + assertThatThrownBy(() -> assertExplainPlan(JSON.readTree( + valid.replace("idx_messages_conversation_created", "wrong_index")))) + .isInstanceOf(AssertionError.class); + assertThatThrownBy(() -> assertExplainPlan(JSON.readTree( + valid.replace("\"access_type\":\"ref\"", "\"access_type\":\"ALL\"")))) + .isInstanceOf(AssertionError.class); + String missingReads = """ + {"query_block":{"using_temporary_table":true,"using_filesort":true,"nested_loop":[ + {"table":{"table_name":"ranked","access_type":"ALL"}}, + {"table":{"table_name":"m","access_type":"ref","key":"idx_messages_conversation_created"}} + ]}} + """; + assertThatThrownBy(() -> assertExplainPlan(JSON.readTree(missingReads))) + .isInstanceOf(AssertionError.class); + } + + private static void assertExplainPlan(JsonNode root) { + List nodes = new ArrayList<>(); + collectPlanNodes(root, nodes); + PlanNode messages = onlyNode(nodes, "m"); + PlanNode reads = onlyNode(nodes, "mr"); + PlanNode ranked = onlyNode(nodes, "ranked"); + + assertThat(messages.accessType()).isIn("ref", "range"); + assertThat(messages.key()).isEqualTo("idx_messages_conversation_created"); + assertThat(messages.accessType()).isNotEqualTo("ALL"); + assertThat(reads.accessType()).isEqualTo("eq_ref"); + assertThat(reads.key()).isEqualTo("uk_message_reads"); + assertThat(ranked.accessType()).isEqualTo("ALL"); + assertThat(findBooleanValue(root, "using_temporary_table")) + .as("必须显式读取 using_temporary_table") + .isPresent(); + assertThat(findBooleanValue(root, "using_filesort")) + .as("必须显式读取 using_filesort") + .isPresent(); + } + + private static PlanNode onlyNode(List nodes, String alias) { + List matches = nodes.stream().filter(node -> alias.equals(node.tableName())).toList(); + assertThat(matches).as("EXPLAIN 节点 %s 必须存在且唯一", alias).hasSize(1); + return matches.get(0); + } + + private static void collectPlanNodes(JsonNode node, List out) { + if (node == null) return; + if (node.isObject()) { + JsonNode table = node.get("table"); + if (table != null && table.isObject() && table.has("table_name")) { + out.add(new PlanNode( + table.path("table_name").asText(), + table.path("access_type").asText(), + table.has("key") ? table.path("key").asText() : null)); + } + node.fields().forEachRemaining(entry -> collectPlanNodes(entry.getValue(), out)); + } else if (node.isArray()) { + node.forEach(child -> collectPlanNodes(child, out)); + } + } + + private static Optional findBooleanValue(JsonNode node, String field) { + if (node == null) return Optional.empty(); + if (node.isObject()) { + if (node.has(field) && node.get(field).isBoolean()) { + return Optional.of(node.get(field).asBoolean()); + } + for (JsonNode child : node) { + Optional found = findBooleanValue(child, field); + if (found.isPresent()) return found; + } + } else if (node.isArray()) { + for (JsonNode child : node) { + Optional found = findBooleanValue(child, field); + if (found.isPresent()) return found; + } + } + return Optional.empty(); + } + + private record PlanNode(String tableName, String accessType, String key) {} +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java new file mode 100644 index 0000000..cf4e77d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java @@ -0,0 +1,418 @@ +package com.mydreamjob.common.r01e; + +import com.mydreamjob.dataexport.PersonalDataCollector; +import com.mydreamjob.messaging.MessagingService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * R01-E 验收:E1 批量查询与 E2 会话未读数聚合的**查询预算**特征。 + * + *

本轮不改生产代码(E1/E2 已由 R01-D2/D3 交付并处于目标终态), + * 本类用于**锁定**其查询次数形态:SQL 数不得随记录数/会话数线性增长。

+ * + *

复用项目既有做法({@code MessageListQueryBudgetIntegrationTest} 的 CountingDataSource 模式): + * 通过只包一层的 DataSource 统计**真实 JDBC 语句**,而非 Mockito verify Repository。 + * 该包装器仅存在于测试源,不新增生产依赖。

+ */ +@SpringBootTest +@ActiveProfiles("integration-test") +@ContextConfiguration(classes = {R01EQueryBudgetAcceptanceTest.CountingDataSourceConfiguration.class}) +class R01EQueryBudgetAcceptanceTest { + + @Autowired private MessagingService messagingService; + @Autowired private PersonalDataCollector personalDataCollector; + @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private CountingDataSource countingDataSource; + @MockBean private RedisTemplate redisTemplate; + + private Long me; + private Long other; + private Long companyId; + + @BeforeEach + void setUp() { + cleanup(); + me = user("r01e-self"); + other = user("r01e-other"); + companyId = null; + countingDataSource.reset(); + } + + /** 退场清理:本类会创建用户/公司/岗位/会话/消息,必须按精确条件清干净, + * 否则会污染后续使用 userRepository.deleteAll() 的测试类与审计计数断言。 */ + @AfterEach + void tearDown() { + cleanup(); + companyId = null; + } + + private void cleanup() { + jdbcTemplate.update("DELETE FROM message_reads WHERE message_id IN " + + "(SELECT id FROM messages WHERE sender_user_id IN " + + "(SELECT id FROM users WHERE phone LIKE 'r01e-%'))"); + jdbcTemplate.update("DELETE FROM messages WHERE sender_user_id IN " + + "(SELECT id FROM users WHERE phone LIKE 'r01e-%')"); + jdbcTemplate.update("DELETE FROM conversation_members WHERE user_id IN " + + "(SELECT id FROM users WHERE phone LIKE 'r01e-%')"); + jdbcTemplate.update("DELETE FROM conversations WHERE id NOT IN (SELECT DISTINCT conversation_id FROM conversation_members)"); + jdbcTemplate.update("DELETE FROM applications WHERE student_user_id IN " + + "(SELECT id FROM users WHERE phone LIKE 'r01e-%')"); + jdbcTemplate.update("DELETE FROM jobs WHERE title = 'r01e-job'"); + jdbcTemplate.update("DELETE FROM companies WHERE name = 'r01e-co'"); + jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone LIKE 'r01e-%')"); + jdbcTemplate.update("DELETE FROM users WHERE phone LIKE 'r01e-%'"); + } + + private Long user(String phone) { + jdbcTemplate.update("INSERT INTO users (phone, nickname, role, is_active, is_verified) " + + "VALUES (?, ?, 'STUDENT', 1, 1)", phone, phone); + Long id = jdbcTemplate.queryForObject("SELECT id FROM users WHERE phone = ?", Long.class, phone); + jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, 'STUDENT')", id); + return id; + } + + private Long conversation(int index) { + jdbcTemplate.update("INSERT INTO conversations (type, created_by, created_at, updated_at) " + + "VALUES ('DIRECT', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", me); + Long cid = jdbcTemplate.queryForObject("SELECT MAX(id) FROM conversations", Long.class); + jdbcTemplate.update("INSERT INTO conversation_members (conversation_id, user_id) VALUES (?, ?)", cid, me); + jdbcTemplate.update("INSERT INTO conversation_members (conversation_id, user_id) VALUES (?, ?)", cid, other); + return cid; + } + + /** + * 新建一个岗位供 applications 外键使用。 + * + *

{@code companies.owner_user_id} 有唯一约束,故公司**只建一次**并复用,岗位可多次新建 + * ({@code applications(job_id, student_user_id)} 唯一,故每行投递需独立岗位)。

+ */ + private Long createJob() { + if (companyId == null) { + jdbcTemplate.update("INSERT INTO companies (name, owner_user_id, verified, created_at, updated_at) " + + "VALUES ('r01e-co', ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", other); + companyId = jdbcTemplate.queryForObject("SELECT MAX(id) FROM companies", Long.class); + } + jdbcTemplate.update("INSERT INTO jobs (company_id, title, description, city, employment_type, status, " + + "created_at, updated_at) VALUES (?, 'r01e-job', 'd', '北京', 'FULL_TIME', 'PUBLISHED', " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", companyId); + return jdbcTemplate.queryForObject("SELECT MAX(id) FROM jobs", Long.class); + } + + private void message(Long cid, Long sender, int i) { + jdbcTemplate.update("INSERT INTO messages (conversation_id, sender_user_id, content, status, created_at) " + + "VALUES (?, ?, ?, 'SENT', CURRENT_TIMESTAMP)", cid, sender, "m" + i); + } + + // ══ E2:会话列表未读数 ════════════════════════════════════ + + @Test + @DisplayName("E2:无会话时返回空列表且不执行 IN () 查询") + void noConversationsShortCircuits() { + countingDataSource.reset(); + assertThat(messagingService.listConversations(me)).isEmpty(); + assertThat(countingDataSource.statements()) + .as("空会话不得执行无意义的 IN () 查询") + .isLessThanOrEqualTo(1); + } + + @Test + @DisplayName("E2:单会话无消息 → unreadCount=0") + void singleConversationWithoutMessages() { + conversation(0); + List> list = messagingService.listConversations(me); + assertThat(list).hasSize(1); + assertThat(list.get(0).get("unreadCount")).isEqualTo(0L); + } + + @Test + @DisplayName("E2(既有语义):未读 = 非 RECALLED 且该读者无 message_reads 记录,**不按发送者过滤**") + void unreadSemantics() { + Long cid = conversation(0); + message(cid, me, 1); // 本人发送:既有实现同样计入(未读查询不按 sender 过滤) + message(cid, other, 2); + message(cid, other, 3); + + List> list = messagingService.listConversations(me); + assertThat(list.get(0).get("unreadCount")) + .as("既有语义:三条消息均未被 me 读过,故全部计入;发送者不参与过滤") + .isEqualTo(3L); + } + + @Test + @DisplayName("E2(既有语义):写入 message_reads 后该消息不再计入未读") + void readMessagesAreExcluded() { + Long cid = conversation(0); + message(cid, other, 1); + message(cid, other, 2); + Long firstId = jdbcTemplate.queryForObject( + "SELECT MIN(id) FROM messages WHERE conversation_id = ?", Long.class, cid); + jdbcTemplate.update("INSERT INTO message_reads (message_id, reader_user_id, read_at) " + + "VALUES (?, ?, CURRENT_TIMESTAMP)", firstId, me); + + List> list = messagingService.listConversations(me); + assertThat(list.get(0).get("unreadCount")).as("已读 1 条,剩 1 条").isEqualTo(1L); + } + + @Test + @DisplayName("E2(既有语义):RECALLED 消息不计未读") + void recalledMessagesAreExcluded() { + Long cid = conversation(0); + message(cid, other, 1); + message(cid, other, 2); + jdbcTemplate.update("UPDATE messages SET status = 'RECALLED' WHERE conversation_id = ? " + + "AND id = (SELECT MIN(id) FROM messages WHERE conversation_id = ?)", cid, cid); + + List> list = messagingService.listConversations(me); + assertThat(list.get(0).get("unreadCount")).as("撤回 1 条,剩 1 条").isEqualTo(1L); + } + + @Test + @DisplayName("E2:多会话分别聚合,缺失会话补 0") + void multipleConversationsAggregateSeparately() { + Long c1 = conversation(0); + Long c2 = conversation(1); + Long c3 = conversation(2); + message(c1, other, 1); + message(c1, other, 2); + message(c2, other, 3); + + List> list = messagingService.listConversations(me); + java.util.Map byConv = new java.util.HashMap<>(); + for (java.util.Map v : list) byConv.put(((Number) v.get("id")).longValue(), v.get("unreadCount")); + + assertThat(byConv.get(c1)).isEqualTo(2L); + assertThat(byConv.get(c2)).isEqualTo(1L); + assertThat(byConv.get(c3)).as("无未读会话补 0").isEqualTo(0L); + } + + private static final Map MEASURED = new LinkedHashMap<>(); + + private static void record(String key, String value) { + MEASURED.put(key, value); + try { + StringBuilder sb = new StringBuilder(); + MEASURED.forEach((k, v) -> sb.append(k).append('=').append(v).append('\n')); + java.nio.file.Files.writeString(java.nio.file.Path.of("target", "r01e-sql-counts.txt"), sb.toString()); + } catch (Exception ignored) { + // 仅诊断输出 + } + } + + @Test + @DisplayName("E2:查询数不随会话数线性增长(1 / 20 / 100)") + void unreadQueryCountIsFlat() { + int[] baseline = new int[3]; + int[] sizes = {1, 20, 100}; + for (int s = 0; s < sizes.length; s++) { + cleanup(); + companyId = null; + me = user("r01e-self"); + other = user("r01e-other"); + List cids = new ArrayList<>(); + for (int i = 0; i < sizes[s]; i++) { + Long cid = conversation(i); + cids.add(cid); + message(cid, other, i); // 每个会话 1 条未读 + } + countingDataSource.reset(); + List> list = messagingService.listConversations(me); + baseline[s] = countingDataSource.statements(); + + assertThat(list).as("size=%d", sizes[s]).hasSize(sizes[s]); + for (java.util.Map v : list) { + assertThat(v.get("unreadCount")).as("size=%d 每会话 1 条未读", sizes[s]).isEqualTo(1L); + } + } + + record("E2.conversations_1", String.valueOf(baseline[0])); + record("E2.conversations_20", String.valueOf(baseline[1])); + record("E2.conversations_100", String.valueOf(baseline[2])); + + assertThat(baseline[1]).as("20 会话的 SQL 数(%d) 不得随会话数增长 (1 会话=%d)", baseline[1], baseline[0]) + .isEqualTo(baseline[0]); + assertThat(baseline[2]).as("100 会话的 SQL 数(%d) 不得随会话数增长 (1 会话=%d)", baseline[2], baseline[0]) + .isEqualTo(baseline[0]); + } + + // ══ E1:个人数据导出批量查询 ══════════════════════════════ + + @Test + @DisplayName("E1:collect() 的 SQL 数不随记录数线性增长(1 / 10 / 50 条投递)") + void personalDataCollectQueryCountIsFlat() { + int[] measured = new int[3]; + int[] sizes = {1, 10, 50}; + + for (int s = 0; s < sizes.length; s++) { + cleanup(); + companyId = null; // cleanup 已删除公司,缓存必须一并失效 + me = user("r01e-self"); + other = user("r01e-other"); + for (int i = 0; i < sizes[s]; i++) { + Long jobId = createJob(); // (job_id, student_user_id) 唯一:每行用独立岗位 + jdbcTemplate.update("INSERT INTO applications (job_id, student_user_id, status, created_at, updated_at) " + + "VALUES (?, ?, 'SUBMITTED', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", jobId, me); + } + countingDataSource.reset(); + PersonalDataCollector.CollectResult result = personalDataCollector.collect(me); + measured[s] = countingDataSource.statements(); + + assertThat(result).isNotNull(); + } + + record("E1.applications_1", String.valueOf(measured[0])); + record("E1.applications_10", String.valueOf(measured[1])); + record("E1.applications_50", String.valueOf(measured[2])); + + assertThat(measured[1]).as("10 条投递的 SQL 数(%d) 不得随 N 增长 (1 条=%d)", measured[1], measured[0]) + .isEqualTo(measured[0]); + assertThat(measured[2]).as("50 条投递的 SQL 数(%d) 不得随 N 增长 (1 条=%d)", measured[2], measured[0]) + .isEqualTo(measured[0]); + } + + + // ===== 副作用与安全 ===== + + /** 稳定字段行级快照(仅稳定列,按主键排序),用于证明读路径零写。 */ + private Map>> snapshot() { + Map>> out = new LinkedHashMap<>(); + out.put("users", jdbcTemplate.queryForList("SELECT id, phone, nickname, role, is_active FROM users ORDER BY id")); + out.put("user_roles", jdbcTemplate.queryForList("SELECT user_id, role FROM user_roles ORDER BY user_id, role")); + out.put("conversations", jdbcTemplate.queryForList("SELECT id, type, created_by, updated_at FROM conversations ORDER BY id")); + out.put("conversation_members", jdbcTemplate.queryForList("SELECT conversation_id, user_id FROM conversation_members ORDER BY conversation_id, user_id")); + out.put("messages", jdbcTemplate.queryForList("SELECT id, conversation_id, sender_user_id, status FROM messages ORDER BY id")); + out.put("message_reads", jdbcTemplate.queryForList("SELECT id, message_id, reader_user_id FROM message_reads ORDER BY id")); + out.put("notifications", jdbcTemplate.queryForList("SELECT id, user_id, type, title FROM notifications ORDER BY id")); + out.put("audit_logs", jdbcTemplate.queryForList("SELECT id, user_id, action, entity_type FROM audit_logs ORDER BY id")); + out.put("auth_tokens", jdbcTemplate.queryForList("SELECT id, user_id, token_type, revoked FROM auth_tokens ORDER BY id")); + out.put("applications", jdbcTemplate.queryForList("SELECT id, job_id, student_user_id, status FROM applications ORDER BY id")); + out.put("job_favorites", jdbcTemplate.queryForList("SELECT id, job_id, student_user_id FROM job_favorites ORDER BY id")); + return out; + } + + private void assertNoChange(Map>> before, + Map>> after, String what) { + for (String table : before.keySet()) { + assertThat(after.get(table)).as("%s 期间 %s 必须逐字段零变化", what, table).isEqualTo(before.get(table)); + } + } + + @Test + @DisplayName("副作用:会话列表 GET 为纯读,11 张相关表逐字段零变化") + void listConversationsIsReadOnly() { + Long cid = conversation(0); + message(cid, other, 1); + message(cid, me, 2); + + Map>> before = snapshot(); + List> list = messagingService.listConversations(me); + Map>> after = snapshot(); + + assertThat(list).hasSize(1); + assertNoChange(before, after, "listConversations"); + } + + @Test + @DisplayName("副作用:collector.collect() 不写业务表;不新增通知与审计") + void personalDataCollectIsReadOnly() { + Long jobId = createJob(); + jdbcTemplate.update("INSERT INTO applications (job_id, student_user_id, status, created_at, updated_at) " + + "VALUES (?, ?, 'SUBMITTED', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", jobId, me); + + Integer notificationsBefore = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM notifications", Integer.class); + Integer auditsBefore = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM audit_logs", Integer.class); + Map>> before = snapshot(); + + PersonalDataCollector.CollectResult result = personalDataCollector.collect(me); + + assertThat(result).isNotNull(); + assertNoChange(before, snapshot(), "collect"); + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM notifications", Integer.class)) + .as("不新增通知").isEqualTo(notificationsBefore); + assertThat(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM audit_logs", Integer.class)) + .as("不新增审计").isEqualTo(auditsBefore); + } + + @Test + @DisplayName("安全:不泄漏其他用户数据;不属于任何会话的用户看不到该会话") + void noCrossUserLeakage() { + Long cid = conversation(0); + message(cid, other, 1); + + List> list = messagingService.listConversations(me); + assertThat(list).as("me 只看到自己的 1 个会话").hasSize(1); + assertThat(list.get(0).get("unreadCount")).isEqualTo(1L); + + Long outsider = user("r01e-outsider"); + assertThat(messagingService.listConversations(outsider)).as("非参与者看不到该会话").isEmpty(); + } + + @TestConfiguration + static class CountingDataSourceConfiguration { + @Bean + @Primary + CountingDataSource dataSource(DataSourceProperties properties) { + return new CountingDataSource(properties.initializeDataSourceBuilder().build()); + } + } + + /** 只包一层、统计真实 JDBC 语句数的 DataSource(仅测试源)。 */ + static final class CountingDataSource extends org.springframework.jdbc.datasource.AbstractDataSource { + private final DataSource delegate; + private final AtomicInteger statements = new AtomicInteger(); + + CountingDataSource(DataSource delegate) { + this.delegate = delegate; + } + + void reset() { + statements.set(0); + } + + int statements() { + return statements.get(); + } + + @Override public Connection getConnection() throws java.sql.SQLException { + return wrap(delegate.getConnection()); + } + + @Override public Connection getConnection(String u, String p) throws java.sql.SQLException { + return wrap(delegate.getConnection(u, p)); + } + + private Connection wrap(Connection c) { + return (Connection) java.lang.reflect.Proxy.newProxyInstance( + Connection.class.getClassLoader(), new Class[]{Connection.class}, + (proxy, method, args) -> { + if ("prepareStatement".equals(method.getName()) || "createStatement".equals(method.getName())) { + statements.incrementAndGet(); + } + return method.invoke(c, args); + }); + } + } +}