Browse Source

fix(chat): 修复 MySQL 5.7 会话查询并补齐验收契约

fix/mysql57-module-migrations-20261009
Zhang Shun 2 weeks ago
parent
commit
554c6eaef4
  1. 68
      docs/chat-realtime-api.md
  2. 2
      docs/frontend-integration.md
  3. 64
      docs/openapi-rc.json
  4. 28
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java
  5. 9
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageUnreadQueries.java
  6. 88
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageUnreadQueriesImpl.java
  7. 4
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java
  8. 2
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java
  9. 23
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java
  10. 82
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EMySqlQueryCountAndExplainTest.java
  11. 8
      mydreamjob-server/src/test/java/com/mydreamjob/config/MySql57MigrationConfigurationTest.java
  12. 64
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java
  13. 14
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java

68
docs/chat-realtime-api.md

@ -0,0 +1,68 @@
# 轻松聘聊天实时接口
本文补充 `openapi-rc.json` 中的消息发送、事件补拉和消息字段。所有 HTTP 路径以 `/api/v1` 为前缀;下文省略该前缀。
## 连接和发送
通过 STOMP WebSocket 连接 `/api/v1/ws`,在 **STOMP CONNECT 帧**中设置 `Authorization: Bearer <accessToken>`。不得放在 URL 中,refresh token 不可代替 access token。连接成功后订阅唯一用户队列 `/user/queue/chat-events`,收到 `{"type":"CHAT_READY"}` 后开始补拉。业务消息继续通过 HTTP 接口发送,STOMP SEND 不开放。
`POST /conversations/messages` 请求:
```json
{
"conversationId": 1,
"content": "你好",
"clientMessageId": "example-message-001"
}
```
`clientMessageId` 为 1–64 位英文字母、数字、下划线或连字符;为兼容旧客户端可省略,但新客户端必须为一次逻辑发送生成固定标识,并在超时、断线或未收到响应时复用。同一会话、同一发送者下,相同标识和正文返回原消息,不重复生成事件;相同标识搭配不同正文返回 400。撤回后重试也不会恢复旧正文。
消息响应增加 `clientMessageId`(旧消息可为空)和 `version`(消息版本)。`MessageResponse` 在普通会话返回 `senderUserId`;人才邀请会话继续使用 `senderScope` 隐藏用户标识。撤回消息只返回“消息已撤回”占位。
## 事件补拉
`GET /conversations/{id}/events?after=0&size=50` 必须携带普通 HTTP Bearer access token,且当前账号必须属于该会话。`after` 是非负事件游标;`size` 默认 50,合法范围 1–100。
响应的 `data`:
```json
{
"items": [
{
"eventId": 2,
"type": "MESSAGE_CREATED",
"conversationId": 1,
"occurredAt": "2026-10-15T10:00:00",
"actorScope": "OTHER_PARTICIPANT",
"payload": {
"id": 1,
"clientMessageId": "example-message-001",
"version": 0,
"conversationId": 1,
"content": "你好",
"status": "SENT",
"isRead": false
}
}
],
"nextCursor": 2,
"hasMore": false
}
```
事件包括 `MESSAGE_CREATED`、`MESSAGE_READ`、`MESSAGE_RECALLED`。`actorScope` 为 `CURRENT_USER` 或 `OTHER_PARTICIPANT`;`payload.isRead` 表示当前接收用户的已读状态。payload 读取消息当前状态,而不是保存历史正文快照。
客户端按以下顺序恢复:
1. 连接、鉴权、订阅并等待 `CHAT_READY`。
2. 使用该账号、该会话已持久化的补拉游标调用事件接口;首次使用 0。
3. 以 `eventId` 去重,以消息 `id` 和 `version` 合并,避免旧版本覆盖新状态。
4. 本页成功合并后才持久化 `nextCursor`;`hasMore=true` 时继续请求,直到为 false。
5. 补拉期间同时合并实时事件。实时事件 ID 可能乱序,不得直接用其最大值推进补拉游标。同版本的已读状态还需按 `actorScope` 和本地已读操作处理,不能仅按版本丢弃已读事件。
断线后重新获取有效 access token、重连、订阅,再重复上述流程。账号切换必须隔离游标、消息缓存和连接。
## 运行边界
这些接口沿用企业认证、会话成员、屏蔽和现有匹配准入规则;不会改变投递或开聊门槛。目前 WebSocket 使用单实例内存 broker,多实例部署需要共享消息分发能力。Nginx 示例文件须经部署配置和验证后才能使用。

2
docs/frontend-integration.md

@ -9,6 +9,8 @@
所有接口统一前缀 `/api/v1`,OpenAPI 文档:`GET /api/v1/v3/api-docs`(Swagger UI:`/api/v1/swagger-ui/index.html`)。
聊天实时连接、发送幂等、事件补拉和游标规则见 [聊天实时接口](chat-realtime-api.md)。
## 2. 后端启动(Windows)
先启动基础设施容器:

64
docs/openapi-rc.json

@ -2407,6 +2407,59 @@
}
}
},
"/conversations/{id}/events": {
"get": {
"tags": [
"消息"
],
"summary": "补拉会话事件",
"description": "按持久化游标恢复新消息、已读、撤回;仅当前会话成员",
"operationId": "replayEvents",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "after",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"format": "int64",
"default": 0
}
},
{
"name": "size",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"format": "int32",
"default": 50
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponseMapStringObject"
}
}
}
}
}
}
},
"/conversations/{id}/messages": {
"get": {
"tags": [
@ -15160,6 +15213,10 @@
"SendMessageRequest": {
"type": "object",
"properties": {
"clientMessageId": {
"type": "string",
"pattern": "[A-Za-z0-9_-]{1,64}"
},
"conversationId": {
"type": "integer",
"format": "int64"
@ -18003,6 +18060,13 @@
"type": "integer",
"format": "int64"
},
"clientMessageId": {
"type": "string"
},
"version": {
"type": "integer",
"format": "int64"
},
"conversationId": {
"type": "integer",
"format": "int64"

28
mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java

@ -14,7 +14,7 @@ import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface MessageRepository extends JpaRepository<Message, Long> {
public interface MessageRepository extends JpaRepository<Message, Long>, MessageUnreadQueries {
@Lock(LockModeType.PESSIMISTIC_WRITE)
java.util.Optional<Message> findByConversationIdAndSenderUserIdAndClientMessageId(
@ -29,32 +29,6 @@ public interface MessageRepository extends JpaRepository<Message, Long> {
Long getUnreadCount();
}
/**
* 批量统计各会话最近 500 条消息中的未读数,保持历史 countUnread 的窗口与撤回语义。
*/
@Query(value = """
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
""", nativeQuery = true)
List<ConversationUnreadCount> countUnreadByConversationIds(
@Param("conversationIds") List<Long> conversationIds,
@Param("readerUserId") Long readerUserId);
/**
* 条件更新未命中后的当前读,用于区分并发请求已完成撤回与真实冲突。
* PESSIMISTIC_WRITE 强制读取提交后的最新行,避免 MySQL REPEATABLE READ 使用旧快照。

9
mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageUnreadQueries.java

@ -0,0 +1,9 @@
package com.mydreamjob.messaging;
import java.util.List;
/** 批量统计每个会话最近 500 条消息内的未读数,兼容 MySQL 5.7。 */
public interface MessageUnreadQueries {
List<MessageRepository.ConversationUnreadCount> countUnreadByConversationIds(
List<Long> conversationIds, Long readerUserId);
}

88
mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageUnreadQueriesImpl.java

@ -0,0 +1,88 @@
package com.mydreamjob.messaging;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;
import org.hibernate.Session;
import org.springframework.transaction.annotation.Transactional;
/** Spring Data 仓库片段;只切换 SQL 能力,不改变会话权限和最近 500 条统计规则。 */
public class MessageUnreadQueriesImpl implements MessageUnreadQueries {
@PersistenceContext private EntityManager entityManager;
private volatile Boolean legacyMysql;
@Override
@Transactional(readOnly = true)
public List<MessageRepository.ConversationUnreadCount> countUnreadByConversationIds(
List<Long> conversationIds, Long readerUserId) {
// 保持原 IN 查询的集合语义,避免重复会话 ID 使 UNION 分支重复计数。
conversationIds = conversationIds.stream().distinct().toList();
if (conversationIds.isEmpty()) return List.of();
String sql = isLegacyMysql() ? legacySql(conversationIds.size()) : windowSql(conversationIds.size());
var query = entityManager.createNativeQuery(sql);
for (int i = 0; i < conversationIds.size(); i++) query.setParameter(i + 1, conversationIds.get(i));
query.setParameter(conversationIds.size() + 1, readerUserId);
List<MessageRepository.ConversationUnreadCount> counts = new ArrayList<>();
for (Object result : query.getResultList()) {
Object[] row = (Object[]) result;
counts.add(new UnreadCount(((Number) row[0]).longValue(), ((Number) row[1]).longValue()));
}
return counts;
}
private boolean isLegacyMysql() {
if (legacyMysql == null) {
synchronized (this) {
if (legacyMysql == null) {
legacyMysql = entityManager.unwrap(Session.class).doReturningWork(connection -> {
var metadata = connection.getMetaData();
return "MySQL".equals(metadata.getDatabaseProductName())
&& metadata.getDatabaseMajorVersion() < 8;
});
}
}
}
return legacyMysql;
}
private static String windowSql(int count) {
String placeholders = String.join(",", IntStream.rangeClosed(1, count).mapToObj(i -> "?" + i).toList());
return """
SELECT ranked.conversation_id, COUNT(*)
FROM (
SELECT m.id, m.conversation_id, m.status,
ROW_NUMBER() OVER (PARTITION BY m.conversation_id
ORDER BY m.created_at DESC, m.id 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 = ?%d
WHERE ranked.row_no <= 500
AND (ranked.status IS NULL OR ranked.status <> 'RECALLED') AND mr.id IS NULL
GROUP BY ranked.conversation_id
""".formatted(placeholders, count + 1);
}
private static String legacySql(int count) {
// 每个分支在数据库内取最近 500 条;一次 SQL 避免逐会话往返和把全量历史加载进 Java。
List<String> branches = IntStream.rangeClosed(1, count).mapToObj(i -> """
SELECT m.id, m.conversation_id, m.status FROM messages m
WHERE m.conversation_id = ?%d ORDER BY m.created_at DESC, m.id DESC LIMIT 500
""".formatted(i).strip()).toList();
String bounded = count == 1 ? branches.get(0)
: String.join(" UNION ALL ", branches.stream().map(branch -> "(" + branch + ")").toList());
return """
SELECT recent.conversation_id, COUNT(*) FROM (%s) recent
LEFT JOIN message_reads mr ON mr.message_id = recent.id AND mr.reader_user_id = ?%d
WHERE (recent.status IS NULL OR recent.status <> 'RECALLED') AND mr.id IS NULL
GROUP BY recent.conversation_id
""".formatted(bounded, count + 1);
}
private record UnreadCount(Long conversationId, Long unreadCount)
implements MessageRepository.ConversationUnreadCount {
@Override public Long getConversationId() { return conversationId; }
@Override public Long getUnreadCount() { return unreadCount; }
}
}

4
mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java

@ -462,8 +462,8 @@ class CompanyResumeDownloadIntegrationTest {
}
assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13);
assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18);
assertThat(pathCount - modulePaths).as("185 paths 包含首次密码设置").isEqualTo(185);
assertThat(opCount - moduleOperations).as("223 operations 包含首次密码设置").isEqualTo(223);
assertThat(pathCount - modulePaths).as("186 paths 包含首次密码设置与聊天事件补拉").isEqualTo(186);
assertThat(opCount - moduleOperations).as("224 operations 包含首次密码设置与聊天事件补拉").isEqualTo(224);
var download = paths.path("/company/candidates/{applicationId}/resume").path("get");
assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse();

2
mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java

@ -210,7 +210,7 @@ class UnifiedRegistrationIntegrationTest {
}
}
assertThat(moduleDenied).as("新模块全部拒绝未选择身份账号").isEqualTo(18);
assertThat(denied - moduleDenied).as("既有身份门禁范围不减少").isEqualTo(197);
assertThat(denied - moduleDenied).as("既有身份门禁及聊天事件补拉均受保护").isEqualTo(198);
java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-identity-gate-result.json"),
"{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}");
}

23
mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java

@ -1202,11 +1202,11 @@ class R01EIndependentCrossDatabaseVerificationTest {
// 4. MySQL EXPLAIN FORMAT=JSON
// ══════════════════════════════════════════════════════════
/** 与 MessageRepository.countUnreadByConversationIds 逐字一致的原生 SQL。 */
/** 独立复刻 MySQL 8 未读统计分支,使用 JDBC 占位符。 */
static String unreadSql(int placeholders) {
return "SELECT ranked.conversation_id AS conversationId, COUNT(*) AS unreadCount "
+ "FROM (SELECT m.id, m.conversation_id, m.status, "
+ "ROW_NUMBER() OVER (PARTITION BY m.conversation_id ORDER BY m.created_at DESC) AS row_no "
+ "ROW_NUMBER() OVER (PARTITION BY m.conversation_id ORDER BY m.created_at DESC, m.id DESC) AS row_no "
+ "FROM messages m WHERE m.conversation_id IN ("
+ String.join(",", Collections.nCopies(placeholders, "?")) + ")) ranked "
+ "LEFT JOIN message_reads mr ON mr.message_id = ranked.id AND mr.reader_user_id = ? "
@ -1218,7 +1218,7 @@ class R01EIndependentCrossDatabaseVerificationTest {
* EXPLAIN 得到的「表节点」信息。
*
* <p><b>注意</b>:MySQL 的 {@code EXPLAIN FORMAT=JSON} 里 {@code table_name} 是**别名**。
* 被测语句逐字取自 {@code MessageRepository.countUnreadByConversationIds},
* 被测语句独立复刻 {@code MessageUnreadQueriesImpl} 的 MySQL 8 分支,
* 其别名为 {@code messages m}、{@code message_reads mr}、派生表 {@code ranked},
* 故此处表节点分别为 {@code m} / {@code mr} / {@code ranked}。</p>
*/
@ -1341,9 +1341,20 @@ class R01EIndependentCrossDatabaseVerificationTest {
assertThat(m.accessType())
.as("%s:基础表 messages 不得全表扫描(access_type=%s, key=%s, possible_keys=%s)",
label, m.accessType(), m.key(), m.possibleKeys())
.isNotEqualTo("ALL");
assertThat(m.key()).as("%s:messages 必须命中既有索引", label)
.isEqualTo("idx_messages_conversation_created");
.isIn("ref", "range");
assertThat(m.key()).as("%s:messages 必须命中会话过滤索引", label).isNotNull();
var firstColumns = MySql.jdbc.queryForList("""
SELECT COLUMN_NAME FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'messages'
AND INDEX_NAME = ? AND SEQ_IN_INDEX = 1
""", String.class, m.key());
assertThat(firstColumns).as("%s:所选索引首列必须是 conversation_id", label)
.containsExactly("conversation_id");
long targetRows = Long.parseLong(label.substring(label.indexOf("targetRows=") + "targetRows=".length()));
assertThat(m.rowsExamined()).as("%s:必须给出扫描行数估计", label).isInstanceOf(Number.class);
assertThat(((Number) m.rowsExamined()).longValue())
.as("%s:高选择性场景不得扫描大量无关消息", label)
.isBetween(1L, Math.max(10L, targetRows * 2));
}
for (TableNode r : reads) {
assertThat(r.accessType())

82
mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EMySqlQueryCountAndExplainTest.java

@ -40,8 +40,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
* <li>{@code EXPLAIN FORMAT=JSON} 的索引使用、access type、temporary / filesort 情况。</li>
* </ol>
*
* <p>EXPLAIN 使用与 {@code MessageRepository.countUnreadByConversationIds} **逐字相同**的 SQL
* 与同一批绑定参数,避免解释一条并不存在的语句。</p>
* <p>EXPLAIN 独立复刻 {@code MessageUnreadQueriesImpl} 的 MySQL 8 窗口分支;
* 筛选、排序和窗口规则一致,使用 JDBC 占位符与同一批绑定参数。</p>
*/
@Testcontainers(disabledWithoutDocker = true)
@SpringBootTest
@ -63,14 +63,14 @@ class R01EMySqlQueryCountAndExplainTest {
registry.add("spring.datasource.password", MYSQL::getPassword);
}
/** 与 MessageRepository 中原生查询**逐字一致**(仅换行为便于阅读)。 */
/** 独立复刻 MySQL 8 原生查询的筛选、排序与窗口规则。 */
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
ORDER BY m.created_at DESC, m.id DESC
) AS row_no
FROM messages m
WHERE m.conversation_id IN (%s)
@ -248,6 +248,54 @@ class R01EMySqlQueryCountAndExplainTest {
System.out.println("[R01E-MYSQL-EXPLAIN] " + summary);
assertExplainPlan(root);
compareClientIdIndex(cid, root);
}
/** 仅操作 Testcontainers 隔离库:隐藏新索引,比较相同 SQL、数据与结果,不改生产查询。 */
private void compareClientIdIndex(Long conversationId, JsonNode withIndex) throws Exception {
String sql = String.format(UNREAD_SQL, inClause(1));
var expected = jdbcTemplate.queryForList(sql, conversationId, me);
StringBuilder report = new StringBuilder("fixture: background=10000,target=200; 2 warmups + 7 samples\n");
try {
jdbcTemplate.execute("ALTER TABLE messages ALTER INDEX uk_messages_client_id INVISIBLE");
jdbcTemplate.queryForList("ANALYZE TABLE messages");
String baselineJson = jdbcTemplate.queryForObject("EXPLAIN FORMAT=JSON " + sql,
String.class, conversationId, me);
JsonNode baseline = JSON.readTree(baselineJson);
assertExplainPlan(baseline);
assertThat(jdbcTemplate.queryForList(sql, conversationId, me)).isEqualTo(expected);
report.append("baseline_without_new_index: ").append(messagePlan(baseline))
.append(", median_ms=").append(medianMillis(sql, conversationId)).append('\n');
report.append(jdbcTemplate.queryForObject("EXPLAIN ANALYZE " + sql,
String.class, conversationId, me)).append('\n');
} finally {
jdbcTemplate.execute("ALTER TABLE messages ALTER INDEX uk_messages_client_id VISIBLE");
jdbcTemplate.queryForList("ANALYZE TABLE messages");
}
assertThat(jdbcTemplate.queryForList(sql, conversationId, me)).isEqualTo(expected);
report.append("with_new_index: ").append(messagePlan(withIndex))
.append(", median_ms=").append(medianMillis(sql, conversationId)).append('\n');
report.append(jdbcTemplate.queryForObject("EXPLAIN ANALYZE " + sql,
String.class, conversationId, me)).append('\n');
java.nio.file.Files.writeString(java.nio.file.Path.of("target", "r01e-index-comparison.txt"), report);
System.out.println("[R01E-INDEX-COMPARISON] " + report);
}
private double medianMillis(String sql, Long conversationId) {
List<Long> nanos = new ArrayList<>();
for (int i = 0; i < 9; i++) {
long start = System.nanoTime();
jdbcTemplate.queryForList(sql, conversationId, me);
if (i >= 2) nanos.add(System.nanoTime() - start);
}
nanos.sort(Long::compareTo);
return nanos.get(nanos.size() / 2) / 1_000_000.0;
}
private static PlanNode messagePlan(JsonNode root) {
List<PlanNode> nodes = new ArrayList<>();
collectPlanNodes(root, nodes);
return onlyNode(nodes, "m");
}
@Test
@ -256,14 +304,21 @@ class R01EMySqlQueryCountAndExplainTest {
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":"m","access_type":"ref","key":"idx_messages_conversation_created","rows_examined_per_scan":200}},
{"table":{"table_name":"mr","access_type":"eq_ref","key":"uk_message_reads"}}
]}}
""";
assertExplainPlan(JSON.readTree(valid));
assertExplainPlan(JSON.readTree(valid.replace("idx_messages_conversation_created", "uk_messages_client_id")));
assertThatThrownBy(() -> assertExplainPlan(JSON.readTree(
valid.replace("idx_messages_conversation_created", "wrong_index"))))
.isInstanceOf(AssertionError.class);
assertThatThrownBy(() -> assertExplainPlan(JSON.readTree(
valid.replace("idx_messages_conversation_created", "PRIMARY"))))
.isInstanceOf(AssertionError.class);
assertThatThrownBy(() -> assertExplainPlan(JSON.readTree(
valid.replace("\"rows_examined_per_scan\":200", "\"rows_examined_per_scan\":10000"))))
.isInstanceOf(AssertionError.class);
assertThatThrownBy(() -> assertExplainPlan(JSON.readTree(
valid.replace("\"access_type\":\"ref\"", "\"access_type\":\"ALL\""))))
.isInstanceOf(AssertionError.class);
@ -277,7 +332,7 @@ class R01EMySqlQueryCountAndExplainTest {
.isInstanceOf(AssertionError.class);
}
private static void assertExplainPlan(JsonNode root) {
private void assertExplainPlan(JsonNode root) {
List<PlanNode> nodes = new ArrayList<>();
collectPlanNodes(root, nodes);
PlanNode messages = onlyNode(nodes, "m");
@ -285,7 +340,15 @@ class R01EMySqlQueryCountAndExplainTest {
PlanNode ranked = onlyNode(nodes, "ranked");
assertThat(messages.accessType()).isIn("ref", "range");
assertThat(messages.key()).isEqualTo("idx_messages_conversation_created");
assertThat(messages.key()).isNotBlank();
var leadingColumns = jdbcTemplate.queryForList("""
SELECT COLUMN_NAME FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'messages'
AND INDEX_NAME = ? AND SEQ_IN_INDEX = 1
""", String.class, messages.key());
assertThat(leadingColumns).as("所选索引必须以会话 ID 为首列").containsExactly("conversation_id");
assertThat(messages.rowsExamined()).as("高选择性夹具不得退化为全索引扫描")
.isBetween(1L, 400L);
assertThat(messages.accessType()).isNotEqualTo("ALL");
assertThat(reads.accessType()).isEqualTo("eq_ref");
assertThat(reads.key()).isEqualTo("uk_message_reads");
@ -312,7 +375,8 @@ class R01EMySqlQueryCountAndExplainTest {
out.add(new PlanNode(
table.path("table_name").asText(),
table.path("access_type").asText(),
table.has("key") ? table.path("key").asText() : null));
table.has("key") ? table.path("key").asText() : null,
table.path("rows_examined_per_scan").asLong(-1)));
}
node.fields().forEachRemaining(entry -> collectPlanNodes(entry.getValue(), out));
} else if (node.isArray()) {
@ -339,5 +403,5 @@ class R01EMySqlQueryCountAndExplainTest {
return Optional.empty();
}
private record PlanNode(String tableName, String accessType, String key) {}
private record PlanNode(String tableName, String accessType, String key, long rowsExamined) {}
}

8
mydreamjob-server/src/test/java/com/mydreamjob/config/MySql57MigrationConfigurationTest.java

@ -27,7 +27,13 @@ class MySql57MigrationConfigurationTest {
var provider = config.getResourceProvider();
assertThat(provider).isNotNull();
var resources = provider.getResources("V", new String[]{".sql"});
assertThat(resources).hasSize(54);
var originals = new org.springframework.core.io.support.PathMatchingResourcePatternResolver()
.getResources("classpath*:db/migration/V*.sql");
var filenames = java.util.Arrays.stream(originals).map(org.springframework.core.io.Resource::getFilename)
.collect(java.util.stream.Collectors.toSet());
assertThat(filenames).contains("V49__skill_cloud_boolean_type.sql", "V55__chat_realtime_events.sql");
assertThat(resources).extracting(org.flywaydb.core.api.resource.LoadableResource::getFilename)
.containsExactlyInAnyOrderElementsOf(filenames);
for (var resource : resources) {
String path = "V49__skill_cloud_boolean_type.sql".equals(resource.getFilename())
? "db/mysql57/" : "db/migration/";

64
mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java

@ -43,6 +43,56 @@ class LxqModulesMySql57IntegrationTest {
@Autowired JdbcTemplate jdbc;
@Autowired MockMvc mvc;
@Autowired ObjectMapper mapper;
@Autowired com.mydreamjob.messaging.MessageRepository messages;
@Autowired jakarta.persistence.EntityManagerFactory entityManagerFactory;
@Test void recentWindowUnreadUsesOneBoundedQueryOnMysql57() {
String phone = "q57-" + java.util.UUID.randomUUID().toString().substring(0, 8);
jdbc.update("INSERT INTO users (phone,role,is_active,is_verified) VALUES (?,'STUDENT',1,1)", phone);
Long user = jdbc.queryForObject("SELECT id FROM users WHERE phone=?", Long.class, phone);
java.util.List<Long> conversations = new java.util.ArrayList<>();
try {
for (int c = 0; c < 2; c++) {
String title = phone + "-" + c;
jdbc.update("INSERT INTO conversations (title,type,created_by,created_at,updated_at) "
+ "VALUES (?,'DIRECT',?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)", title, user);
Long id = jdbc.queryForObject("SELECT id FROM conversations WHERE title=?", Long.class, title);
conversations.add(id);
java.util.List<Object[]> batch = new java.util.ArrayList<>();
for (int i = 0; i < 600; i++) batch.add(new Object[]{id,user,"legacy-" + i,
i % 20 == 0 ? "RECALLED" : "SENT"});
jdbc.batchUpdate("INSERT INTO messages (conversation_id,sender_user_id,content,status,created_at) "
+ "VALUES (?,?,?,?,'2026-10-15 08:00:00')", batch);
jdbc.update("INSERT INTO message_reads (message_id,reader_user_id,read_at) "
+ "SELECT id,?,CURRENT_TIMESTAMP FROM messages WHERE conversation_id=? "
+ "AND content IN ('legacy-50','legacy-150','legacy-250','legacy-350','legacy-450','legacy-550')", user,id);
}
var stats = entityManagerFactory.unwrap(org.hibernate.SessionFactory.class).getStatistics();
boolean wasEnabled = stats.isStatisticsEnabled();
stats.setStatisticsEnabled(true);
try {
long before = stats.getPrepareStatementCount();
var counts = messages.countUnreadByConversationIds(conversations, user);
assertThat(stats.getPrepareStatementCount() - before).as("两会话合计只发一条 SQL").isEqualTo(1);
assertThat(counts).hasSize(2);
assertThat(counts).extracting(com.mydreamjob.messaging.MessageRepository.ConversationUnreadCount::getConversationId)
.containsExactlyInAnyOrderElementsOf(conversations);
assertThat(counts).extracting(com.mydreamjob.messaging.MessageRepository.ConversationUnreadCount::getUnreadCount)
.containsOnly(470L);
assertThat(messages.countUnreadByConversationIds(java.util.List.of(), user)).isEmpty();
assertThat(messages.countUnreadByConversationIds(java.util.List.of(conversations.get(0)), user))
.extracting(com.mydreamjob.messaging.MessageRepository.ConversationUnreadCount::getUnreadCount)
.containsExactly(470L);
assertThat(messages.countUnreadByConversationIds(
java.util.List.of(conversations.get(0), conversations.get(0)), user))
.extracting(com.mydreamjob.messaging.MessageRepository.ConversationUnreadCount::getUnreadCount)
.containsExactly(470L);
} finally { stats.setStatisticsEnabled(wasEnabled); }
} finally {
for (Long id : conversations) jdbc.update("DELETE FROM conversations WHERE id=?", id);
jdbc.update("DELETE FROM users WHERE id=?", user);
}
}
@Test void fullMigrationAndSeedsValidateOnActualServerVersion() {
assertThat(jdbc.queryForObject("select version()", String.class)).startsWith("5.7.36");
@ -58,8 +108,18 @@ class LxqModulesMySql57IntegrationTest {
JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray());
JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertThat(operations(runtime)).hasSize(241).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(198);
assertThat(operations(runtime)).hasSize(242).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(199);
assertThat(runtime.path("paths").path("/conversations/{id}/events"))
.isEqualTo(contract.path("paths").path("/conversations/{id}/events"));
for (String field : Set.of("clientMessageId", "version")) {
String pointer = "/components/schemas/MessageResponse/properties/" + field;
assertThat(runtime.at(pointer).isMissingNode()).as(pointer).isFalse();
assertThat(contract.at(pointer)).as(pointer).isEqualTo(runtime.at(pointer));
}
String requestField = "/components/schemas/SendMessageRequest/properties/clientMessageId";
assertThat(runtime.at(requestField).isMissingNode()).isFalse();
assertThat(contract.at(requestField)).isEqualTo(runtime.at(requestField));
}
private Set<String> operations(JsonNode document) {

14
mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java

@ -59,8 +59,18 @@ class LxqModulesMySqlIntegrationTest {
JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray());
JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertThat(operations(runtime)).hasSize(241).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(198);
assertThat(operations(runtime)).hasSize(242).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(199);
assertThat(runtime.path("paths").path("/conversations/{id}/events"))
.isEqualTo(contract.path("paths").path("/conversations/{id}/events"));
for (String field : Set.of("clientMessageId", "version")) {
String pointer = "/components/schemas/MessageResponse/properties/" + field;
assertThat(runtime.at(pointer).isMissingNode()).as(pointer).isFalse();
assertThat(contract.at(pointer)).as(pointer).isEqualTo(runtime.at(pointer));
}
String requestField = "/components/schemas/SendMessageRequest/properties/clientMessageId";
assertThat(runtime.at(requestField).isMissingNode()).isFalse();
assertThat(contract.at(requestField)).isEqualTo(runtime.at(requestField));
}
private Set<String> operations(JsonNode document) {

Loading…
Cancel
Save