forked from baskbuddy/zhangshun
6 changed files with 3515 additions and 0 deletions
@ -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<Long, List<T>>` 强类型键 | |
||||
|
| E2 | `MessageRepository.countUnreadByConversationIds(List<Long> conversationIds, Long readerUserId)` → `List<ConversationUnreadCount>` 投影(`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既有批量查询与会话未读数性能实现已完成验收和测试加固,待独立代码复验。** |
||||
@ -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性能复验通过,可以进入精确暂存与独立提交检查点。** |
||||
File diff suppressed because it is too large
@ -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 **独立复验**(第五节):副作用、隔离与反证。 |
||||
|
* |
||||
|
* <p>与实施测试的差异:本类独立实现夹具与快照(<b>25 张表</b>、列集合不同), |
||||
|
* 并且额外验证三件事 ——</p> |
||||
|
* <ol> |
||||
|
* <li><b>反证</b>:故意改动一行(改 / 增 / 删)时,快照断言**必须失败**, |
||||
|
* 否则「零变化」只是没在比;</li> |
||||
|
* <li><b>RabbitMQ</b>:用 {@code @MockBean RabbitTemplate} 直接断言读路径**零 AMQP 交互**;</li> |
||||
|
* <li><b>回归门禁</b>:本类运行在**共享的 integration H2 库**上,故可以直接检查 |
||||
|
* {@code R01EQueryBudgetAcceptanceTest} 的 {@code r01e-} 夹具是否已零残留 |
||||
|
* (此前缺 {@code @AfterEach} 导致 60 项失败的正是这一点)。</li> |
||||
|
* </ol> |
||||
|
*/ |
||||
|
@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<String, Object> 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<String, List<Map<String, Object>>> snapshot() { |
||||
|
Map<String, List<Map<String, Object>>> 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<Map<String, Object>> q(String sql) { |
||||
|
return jdbc.queryForList(sql); |
||||
|
} |
||||
|
|
||||
|
private static void assertNoChange(Map<String, List<Map<String, Object>>> before, |
||||
|
Map<String, List<Map<String, Object>>> 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<String, List<Map<String, Object>>> 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<String, List<Map<String, Object>>> before = snapshot(); |
||||
|
|
||||
|
List<Map<String, Object>> 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<String, List<Map<String, Object>>> 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<String, List<Map<String, Object>>> before = snapshot(); |
||||
|
jdbc.update("UPDATE users SET nickname = '被篡改' WHERE id = ?", me); |
||||
|
Map<String, List<Map<String, Object>>> after = snapshot(); |
||||
|
|
||||
|
assertThatThrownBy(() -> assertNoChange(before, after, "mutation")) |
||||
|
.as("若此处不抛错,说明快照断言形同虚设") |
||||
|
.isInstanceOf(AssertionError.class) |
||||
|
.hasMessageContaining("users"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("反证:新增一行时,快照断言必须失败") |
||||
|
void snapshotDetectsInsert() { |
||||
|
seed(); |
||||
|
Map<String, List<Map<String, Object>>> before = snapshot(); |
||||
|
jdbc.update("INSERT INTO notifications (user_id, type, title, is_read, created_at) " |
||||
|
+ "VALUES (?, 'APPLICATION', '新增', 0, ?)", me, T); |
||||
|
Map<String, List<Map<String, Object>>> after = snapshot(); |
||||
|
|
||||
|
assertThatThrownBy(() -> assertNoChange(before, after, "insertion")) |
||||
|
.isInstanceOf(AssertionError.class) |
||||
|
.hasMessageContaining("notifications"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("反证:删除一行时,快照断言必须失败") |
||||
|
void snapshotDetectsDelete() { |
||||
|
seed(); |
||||
|
Map<String, List<Map<String, Object>>> before = snapshot(); |
||||
|
jdbc.update("DELETE FROM message_reads WHERE reader_user_id = ?", me); |
||||
|
Map<String, List<Map<String, Object>>> 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<String, List<Map<String, Object>>> before = snapshot(); |
||||
|
jdbc.update("UPDATE messages SET status = 'RECALLED' WHERE conversation_id = ? AND status = 'SENT'", convId); |
||||
|
Map<String, List<Map<String, Object>>> 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<String> 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; |
||||
|
} |
||||
|
} |
||||
@ -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)。 |
||||
|
* |
||||
|
* <p>不做生产改动(E1/E2 已由 R01-D2/D3 交付)。本类证明:</p> |
||||
|
* <ol> |
||||
|
* <li>未读分组 SQL 在 MySQL 上与 H2 上的**逐值一致**;</li> |
||||
|
* <li>1 / 20 / 100 会话下 {@code listConversations} 的 SQL 数恒定;</li> |
||||
|
* <li>{@code EXPLAIN FORMAT=JSON} 的索引使用、access type、temporary / filesort 情况。</li> |
||||
|
* </ol> |
||||
|
* |
||||
|
* <p>EXPLAIN 使用与 {@code MessageRepository.countUnreadByConversationIds} **逐字相同**的 SQL |
||||
|
* 与同一批绑定参数,避免解释一条并不存在的语句。</p> |
||||
|
*/ |
||||
|
@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<String, Object> 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<Map<String, Object>> rows = jdbcTemplate.queryForList( |
||||
|
String.format(UNREAD_SQL, inClause(2)), c1, c2, me); |
||||
|
Map<Long, Long> byConv = new LinkedHashMap<>(); |
||||
|
for (Map<String, Object> 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<Map<String, Object>> list = messagingService.listConversations(me); |
||||
|
Map<Long, Object> svc = new LinkedHashMap<>(); |
||||
|
for (Map<String, Object> 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<Map<String, Object>> list = messagingService.listConversations(me); |
||||
|
assertThat(list).as("size=%d", size).hasSize(size); |
||||
|
for (Map<String, Object> 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<Map<String, Object>> 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<PlanNode> 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<PlanNode> nodes, String alias) { |
||||
|
List<PlanNode> 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<PlanNode> 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<Boolean> 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<Boolean> found = findBooleanValue(child, field); |
||||
|
if (found.isPresent()) return found; |
||||
|
} |
||||
|
} else if (node.isArray()) { |
||||
|
for (JsonNode child : node) { |
||||
|
Optional<Boolean> found = findBooleanValue(child, field); |
||||
|
if (found.isPresent()) return found; |
||||
|
} |
||||
|
} |
||||
|
return Optional.empty(); |
||||
|
} |
||||
|
|
||||
|
private record PlanNode(String tableName, String accessType, String key) {} |
||||
|
} |
||||
@ -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 会话未读数聚合的**查询预算**特征。 |
||||
|
* |
||||
|
* <p>本轮不改生产代码(E1/E2 已由 R01-D2/D3 交付并处于目标终态), |
||||
|
* 本类用于**锁定**其查询次数形态:SQL 数不得随记录数/会话数线性增长。</p> |
||||
|
* |
||||
|
* <p>复用项目既有做法({@code MessageListQueryBudgetIntegrationTest} 的 CountingDataSource 模式): |
||||
|
* 通过只包一层的 DataSource 统计**真实 JDBC 语句**,而非 Mockito verify Repository。 |
||||
|
* 该包装器仅存在于测试源,不新增生产依赖。</p> |
||||
|
*/ |
||||
|
@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<String, Object> 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 外键使用。 |
||||
|
* |
||||
|
* <p>{@code companies.owner_user_id} 有唯一约束,故公司**只建一次**并复用,岗位可多次新建 |
||||
|
* ({@code applications(job_id, student_user_id)} 唯一,故每行投递需独立岗位)。</p> |
||||
|
*/ |
||||
|
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<java.util.Map<String, Object>> 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<java.util.Map<String, Object>> 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<java.util.Map<String, Object>> 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<java.util.Map<String, Object>> 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<java.util.Map<String, Object>> list = messagingService.listConversations(me); |
||||
|
java.util.Map<Long, Object> byConv = new java.util.HashMap<>(); |
||||
|
for (java.util.Map<String, Object> 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<String, String> 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<Long> 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<java.util.Map<String, Object>> list = messagingService.listConversations(me); |
||||
|
baseline[s] = countingDataSource.statements(); |
||||
|
|
||||
|
assertThat(list).as("size=%d", sizes[s]).hasSize(sizes[s]); |
||||
|
for (java.util.Map<String, Object> 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<String, List<Map<String, Object>>> snapshot() { |
||||
|
Map<String, List<Map<String, Object>>> 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<String, List<Map<String, Object>>> before, |
||||
|
Map<String, List<Map<String, Object>>> 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<String, List<Map<String, Object>>> before = snapshot(); |
||||
|
List<java.util.Map<String, Object>> list = messagingService.listConversations(me); |
||||
|
Map<String, List<Map<String, Object>>> 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<String, List<Map<String, Object>>> 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<java.util.Map<String, Object>> 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); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue