Compare commits

...

2 Commits

  1. 6
      README.md
  2. 2
      backend-infra/README.md
  3. 18
      backend-infra/nginx-chat-websocket.location.conf.example
  4. 2
      docs/ERD.md
  5. 4
      docs/backend-development-roadmap.md
  6. 68
      docs/chat-realtime-api.md
  7. 4
      docs/frontend-integration.md
  8. 2
      docs/job-review-api.md
  9. 2
      docs/match-api.md
  10. 2
      docs/offer-advance-api.md
  11. 2
      docs/offer-api.md
  12. 66
      docs/openapi-rc.json
  13. 2
      docs/resume-api.md
  14. 2
      docs/review-moderation-api.md
  15. 4
      mydreamjob-server/README.md
  16. 6
      mydreamjob-server/pom.xml
  17. 10
      mydreamjob-server/src/main/java/com/mydreamjob/common/ProductBrand.java
  18. 4
      mydreamjob-server/src/main/java/com/mydreamjob/config/OpenApiSecurityConfig.java
  19. 2
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  20. 3
      mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java
  21. 5
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationRepository.java
  22. 6
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/Message.java
  23. 32
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java
  24. 9
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageUnreadQueries.java
  25. 88
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageUnreadQueriesImpl.java
  26. 2
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageViewMapper.java
  27. 9
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingController.java
  28. 47
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java
  29. 2
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/MessageResponse.java
  30. 4
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/SendMessageRequest.java
  31. 19
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatEvent.java
  32. 10
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatEventRepository.java
  33. 90
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatEventService.java
  34. 112
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatSocketAuth.java
  35. 68
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatSocketConfig.java
  36. 82
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatSocketConnections.java
  37. 18
      mydreamjob-server/src/main/resources/db/migration/V55__chat_realtime_events.sql
  38. 4
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java
  39. 2
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java
  40. 23
      mydreamjob-server/src/test/java/com/mydreamjob/common/ProductBrandTest.java
  41. 23
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java
  42. 82
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EMySqlQueryCountAndExplainTest.java
  43. 8
      mydreamjob-server/src/test/java/com/mydreamjob/config/MySql57MigrationConfigurationTest.java
  44. 64
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java
  45. 14
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java
  46. 1
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallServiceTest.java
  47. 1
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingServiceTest.java
  48. 202
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/realtime/ChatRealtimeIntegrationTest.java
  49. 114
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/realtime/ChatSocketAuthTest.java
  50. 43
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/realtime/ChatSocketConnectionsTest.java
  51. 4
      product-docs/新建 文本文档.txt
  52. 4
      product-docs/职来AI求职招聘平台产品与开发实施方案-v3.1.md
  53. 4
      product-docs/职来AI求职招聘平台产品与开发实施方案.md
  54. 6
      product-docs/职来UI设计系统规范V1.0.md
  55. 6
      product-docs/职来mydreamjob_架构图.puml

6
README.md

@ -1,6 +1,6 @@
# 职来(mydreamjob) # 轻松聘(mydreamjob)
职来是一个面向求职者、企业和平台运营方的 AI 求职招聘平台。本仓库是职来项目的后端与工程协作仓库,不包含“青核校园”React Native 项目。 轻松聘是一个面向求职者、企业和平台运营方的 AI 求职招聘平台。本仓库是轻松聘项目的后端与工程协作仓库,不包含“青核校园”React Native 项目。
## 当前交付基线 ## 当前交付基线
@ -175,4 +175,4 @@ python .\docs\verify_openapi.py
`C:\Users\15642\Desktop\青核\collegeCli` `C:\Users\15642\Desktop\青核\collegeCli`
青核项目不属于本仓库,禁止将其源代码提交到职来仓库。 青核项目不属于本仓库,禁止将其源代码提交到轻松聘仓库。

2
backend-infra/README.md

@ -1,4 +1,4 @@
# 职来本地后端依赖 # 轻松聘本地后端依赖
此目录仅用于本地开发的 MySQL、Redis 和 RabbitMQ,不会修改电脑中已运行的 MySQL 8.0 服务。 此目录仅用于本地开发的 MySQL、Redis 和 RabbitMQ,不会修改电脑中已运行的 MySQL 8.0 服务。

18
backend-infra/nginx-chat-websocket.location.conf.example

@ -0,0 +1,18 @@
# 实时聊天转发片段:合并到现有 HTTPS server 中,不要覆盖整个 Nginx 配置。
# 部署前核对真实后端地址、端口以及 /api/v1 context-path。
# 此示例仅用于单后端实例;多实例需要共享消息代理,不能仅添加负载均衡。
# 前端使用 wss://你的业务域名/api/v1/ws;Token 放在 STOMP CONNECT 头,不放 URL。
location = /api/v1/ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 75s;
proxy_send_timeout 75s;
proxy_buffering off;
}
# 握手限流目前按直接连接来源计数;经过反向代理时来源可能都是网关。
# 大规模部署需在网关增加按可信真实 IP 的限流,并核对后端限流配置。

2
docs/ERD.md

@ -1,4 +1,4 @@
# 职来 (mydreamjob) 数据库 ERD 设计 # 轻松聘 (mydreamjob) 数据库 ERD 设计
> 版本: v0.1 | 更新日期: 2026-08-03 | 状态: Week 1 基线 > 版本: v0.1 | 更新日期: 2026-08-03 | 状态: Week 1 基线

4
docs/backend-development-roadmap.md

@ -1,4 +1,4 @@
# 职来后端一期交付路线图 # 轻松聘后端一期交付路线图
> 当前范围冻结:2026-09-08。当前一期范围、需求、代码入口、验收及责任的唯一明细为 [一期范围与验收清单](phase1-scope-and-acceptance.md)。下方历史规划不再用于判断当前待办或批准新接口/迁移。 > 当前范围冻结:2026-09-08。当前一期范围、需求、代码入口、验收及责任的唯一明细为 [一期范围与验收清单](phase1-scope-and-acceptance.md)。下方历史规划不再用于判断当前待办或批准新接口/迁移。
@ -31,7 +31,7 @@
> >
> 确认日期:2026-08-25 > 确认日期:2026-08-25
> >
> 适用项目:职来(mydreamjob-server) > 适用项目:轻松聘(mydreamjob-server)
> **正式接口文档目录**:`C:\Users\15642\Desktop\zhilai\docs`(OpenAPI、前端联调、验收、各模块 API 文档的唯一正式目录)。 > **正式接口文档目录**:`C:\Users\15642\Desktop\zhilai\docs`(OpenAPI、前端联调、验收、各模块 API 文档的唯一正式目录)。
> 本文档 `mydreamjob-server\docs\backend-development-roadmap.md` 仅保留为「路线图源文件」,不移动、不删除任何文档。 > 本文档 `mydreamjob-server\docs\backend-development-roadmap.md` 仅保留为「路线图源文件」,不移动、不删除任何文档。

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 示例文件须经部署配置和验证后才能使用。

4
docs/frontend-integration.md

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

2
docs/job-review-api.md

@ -1,6 +1,6 @@
# 岗位评价与八维评分卡接口说明 # 岗位评价与八维评分卡接口说明
> 职来 (mydreamjob) · 岗位评价 + 八维评分卡基础模块 > 轻松聘 (mydreamjob) · 岗位评价 + 八维评分卡基础模块
> 基础路径:`/api/v1` · 评价创建/修改/删除需登录;列表/详情/汇总/对比公开 > 基础路径:`/api/v1` · 评价创建/修改/删除需登录;列表/详情/汇总/对比公开
## 1. 概述 ## 1. 概述

2
docs/match-api.md

@ -1,6 +1,6 @@
# 双向匹配基础模块接口说明 # 双向匹配基础模块接口说明
> 职来 (mydreamjob) · 双向匹配基础流程(规则阈值版)与匹配后沟通解锁 > 轻松聘 (mydreamjob) · 双向匹配基础流程(规则阈值版)与匹配后沟通解锁
> 基础路径:`/api/v1` · 全部接口需 `Authorization: Bearer <accessToken>` > 基础路径:`/api/v1` · 全部接口需 `Authorization: Bearer <accessToken>`
## 1. 概述 ## 1. 概述

2
docs/offer-advance-api.md

@ -1,6 +1,6 @@
# Offer 进阶模块接口说明 # Offer 进阶模块接口说明
> 职来 (mydreamjob) · Offer 进阶能力 > 轻松聘 (mydreamjob) · Offer 进阶能力
> 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer <accessToken>` > 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer <accessToken>`
> 在 [Offer 管理基础模块](offer-api.md) 之上增量开发,未修改任何已验收接口的公共签名。 > 在 [Offer 管理基础模块](offer-api.md) 之上增量开发,未修改任何已验收接口的公共签名。

2
docs/offer-api.md

@ -1,6 +1,6 @@
# Offer 管理模块接口说明 # Offer 管理模块接口说明
> 职来 (mydreamjob) · Offer 管理基础模块 > 轻松聘 (mydreamjob) · Offer 管理基础模块
> 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer <accessToken>` > 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer <accessToken>`
## 1. 概述 ## 1. 概述

66
docs/openapi-rc.json

@ -1,7 +1,7 @@
{ {
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "职来 API", "title": "轻松聘 API",
"version": "v1" "version": "v1"
}, },
"servers": [ "servers": [
@ -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": { "/conversations/{id}/messages": {
"get": { "get": {
"tags": [ "tags": [
@ -15160,6 +15213,10 @@
"SendMessageRequest": { "SendMessageRequest": {
"type": "object", "type": "object",
"properties": { "properties": {
"clientMessageId": {
"type": "string",
"pattern": "[A-Za-z0-9_-]{1,64}"
},
"conversationId": { "conversationId": {
"type": "integer", "type": "integer",
"format": "int64" "format": "int64"
@ -18003,6 +18060,13 @@
"type": "integer", "type": "integer",
"format": "int64" "format": "int64"
}, },
"clientMessageId": {
"type": "string"
},
"version": {
"type": "integer",
"format": "int64"
},
"conversationId": { "conversationId": {
"type": "integer", "type": "integer",
"format": "int64" "format": "int64"

2
docs/resume-api.md

@ -1,6 +1,6 @@
# 简历版本管理与导出模块接口说明 # 简历版本管理与导出模块接口说明
> 职来 (mydreamjob) · 简历版本管理与导出基础模块 > 轻松聘 (mydreamjob) · 简历版本管理与导出基础模块
> 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer <accessToken>` > 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer <accessToken>`
## 1. 概述 ## 1. 概述

2
docs/review-moderation-api.md

@ -1,6 +1,6 @@
# 评价审核与防刷基础模块接口说明 # 评价审核与防刷基础模块接口说明
> 职来 (mydreamjob) · 评价审核与防刷基础模块 > 轻松聘 (mydreamjob) · 评价审核与防刷基础模块
> 基础路径:`/api/v1` · 审核管理接口仅 ADMIN;评价公开接口保持原路径 > 基础路径:`/api/v1` · 审核管理接口仅 ADMIN;评价公开接口保持原路径
## 1. 概述 ## 1. 概述

4
mydreamjob-server/README.md

@ -1,6 +1,6 @@
# 职来 (mydreamjob) 后端服务 # 轻松聘 (mydreamjob) 后端服务
> Spring Boot 3.2.5 + Java 17 | 职来大学生求职服务平台后端基线 > Spring Boot 3.2.5 + Java 17 | 轻松聘大学生求职服务平台后端基线
## 项目结构 ## 项目结构

6
mydreamjob-server/pom.xml

@ -15,7 +15,7 @@
<artifactId>mydreamjob-server</artifactId> <artifactId>mydreamjob-server</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>0.0.1-SNAPSHOT</version>
<name>mydreamjob-server</name> <name>mydreamjob-server</name>
<description>职来 (mydreamjob) Backend - Week 1 Baseline</description> <description>轻松聘 (mydreamjob) Backend - Week 1 Baseline</description>
<properties> <properties>
<java.version>17</java.version> <java.version>17</java.version>
@ -25,6 +25,10 @@
</properties> </properties>
<dependencies> <dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- 阿里云国内短信官方 SDK:凭据仅从服务器环境注入 --> <!-- 阿里云国内短信官方 SDK:凭据仅从服务器环境注入 -->
<dependency> <dependency>
<groupId>com.aliyun</groupId> <groupId>com.aliyun</groupId>

10
mydreamjob-server/src/main/java/com/mydreamjob/common/ProductBrand.java

@ -0,0 +1,10 @@
package com.mydreamjob.common;
/** 对外品牌统一入口;不改变包名、数据库、接口路径及部署标识。 */
public final class ProductBrand {
public static final String DISPLAY_NAME = "轻松聘";
public static final String API_TITLE = DISPLAY_NAME + " API";
public static final String API_DESCRIPTION = DISPLAY_NAME + " - 大学生求职服务平台 API";
private ProductBrand() { }
}

4
mydreamjob-server/src/main/java/com/mydreamjob/config/OpenApiSecurityConfig.java

@ -26,7 +26,7 @@ public class OpenApiSecurityConfig {
public OpenAPI homepageOpenAPI() { public OpenAPI homepageOpenAPI() {
return new OpenAPI() return new OpenAPI()
.info(new Info() .info(new Info()
.title("职来 API") .title(com.mydreamjob.common.ProductBrand.API_TITLE)
.version("v1")); .version("v1"));
} }
} }

2
mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java

@ -83,6 +83,8 @@ public class SecurityConfig {
// 健康检查 API // 健康检查 API
.requestMatchers("/health/**").permitAll() .requestMatchers("/health/**").permitAll()
.requestMatchers("/health").permitAll() .requestMatchers("/health").permitAll()
// 仅放行握手;STOMP CONNECT 和订阅由实时通道独立鉴权。
.requestMatchers(HttpMethod.GET, "/ws").permitAll()
// 平台维护的岗位推荐分类字典(公开只读) // 平台维护的岗位推荐分类字典(公开只读)
.requestMatchers(HttpMethod.GET, "/job-recommendation-taxonomies").permitAll() .requestMatchers(HttpMethod.GET, "/job-recommendation-taxonomies").permitAll()
// 认证相关 // 认证相关

3
mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java

@ -31,8 +31,9 @@ public class ApiController {
public ResponseEntity<ApiResponse<Map<String, Object>>> index(HttpServletRequest request) { public ResponseEntity<ApiResponse<Map<String, Object>>> index(HttpServletRequest request) {
Map<String, Object> info = Map.of( Map<String, Object> info = Map.of(
"name", "mydreamjob", "name", "mydreamjob",
"displayName", com.mydreamjob.common.ProductBrand.DISPLAY_NAME,
"version", "0.1.0", "version", "0.1.0",
"description", "职来 - 大学生求职服务平台 API", "description", com.mydreamjob.common.ProductBrand.API_DESCRIPTION,
"endpoints", Map.of( "endpoints", Map.of(
"health", "GET /api/v1/health", "health", "GET /api/v1/health",
"info", "GET /api/v1/info", "info", "GET /api/v1/info",

5
mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationRepository.java

@ -9,5 +9,10 @@ import java.util.Optional;
@Repository @Repository
public interface ConversationRepository extends JpaRepository<Conversation, Long> { public interface ConversationRepository extends JpaRepository<Conversation, Long> {
/** 串行化同会话写入,保证幂等检查以及事件游标的提交顺序。 */
@org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE)
@org.springframework.data.jpa.repository.Query("select c from Conversation c where c.id = :id")
Optional<Conversation> findLockedById(@org.springframework.data.repository.query.Param("id") Long id);
List<Conversation> findByCreatedByOrderByUpdatedAtDesc(Long userId); List<Conversation> findByCreatedByOrderByUpdatedAtDesc(Long userId);
} }

6
mydreamjob-server/src/main/java/com/mydreamjob/messaging/Message.java

@ -32,6 +32,12 @@ public class Message {
@Column(nullable = false, columnDefinition = "text") @Column(nullable = false, columnDefinition = "text")
private String content; private String content;
@Column(name = "client_message_id", length = 64)
private String clientMessageId;
@Column(name = "request_content_hash", length = 64)
private String requestContentHash;
@Column(nullable = false, length = 20) @Column(nullable = false, length = 20)
@Builder.Default @Builder.Default
private String status = "SENT"; private String status = "SENT";

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

@ -14,7 +14,11 @@ import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@Repository @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(
Long conversationId, Long senderUserId, String clientMessageId);
Page<Message> findByConversationIdOrderByCreatedAtDesc(Long conversationId, Pageable pageable); Page<Message> findByConversationIdOrderByCreatedAtDesc(Long conversationId, Pageable pageable);
@ -25,32 +29,6 @@ public interface MessageRepository extends JpaRepository<Message, Long> {
Long getUnreadCount(); 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 使用旧快照。 * 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; }
}
}

2
mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageViewMapper.java

@ -16,6 +16,8 @@ public class MessageViewMapper {
boolean recalled = "RECALLED".equals(message.getStatus()); boolean recalled = "RECALLED".equals(message.getStatus());
MessageResponse.MessageResponseBuilder builder = MessageResponse.builder() MessageResponse.MessageResponseBuilder builder = MessageResponse.builder()
.id(message.getId()) .id(message.getId())
.clientMessageId(message.getClientMessageId())
.version(message.getVersion())
.conversationId(message.getConversationId()) .conversationId(message.getConversationId())
.content(recalled ? RECALLED_PLACEHOLDER : message.getContent()) .content(recalled ? RECALLED_PLACEHOLDER : message.getContent())
.status(message.getStatus() == null ? "SENT" : message.getStatus()) .status(message.getStatus() == null ? "SENT" : message.getStatus())

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

@ -74,6 +74,15 @@ public class MessagingController {
return ResponseEntity.ok(ApiResponse.ok(result, requestIdAccessor.get(request))); return ResponseEntity.ok(ApiResponse.ok(result, requestIdAccessor.get(request)));
} }
@Operation(summary = "补拉会话事件", description = "按持久化游标恢复新消息、已读、撤回;仅当前会话成员")
@GetMapping("/{id}/events")
public ResponseEntity<ApiResponse<Map<String, Object>>> replayEvents(
@PathVariable Long id, @RequestParam(defaultValue = "0") long after,
@RequestParam(defaultValue = "50") int size, HttpServletRequest request) {
return ResponseEntity.ok(ApiResponse.ok(messagingService.replayEvents(
id, currentUserAccessor.requireUserId(), after, size), requestIdAccessor.get(request)));
}
@Operation(summary = "标记已读", description = "将当前会话所有消息标记为已读") @Operation(summary = "标记已读", description = "将当前会话所有消息标记为已读")
@PostMapping("/{id}/read") @PostMapping("/{id}/read")
public ResponseEntity<ApiResponse<Map<String, Object>>> markRead( public ResponseEntity<ApiResponse<Map<String, Object>>> markRead(

47
mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java

@ -50,6 +50,7 @@ public class MessagingService {
private final Clock clock; private final Clock clock;
private final MessageViewMapper messageViewMapper; private final MessageViewMapper messageViewMapper;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final com.mydreamjob.messaging.realtime.ChatEventService chatEvents;
private static final int MAX_PAGE_SIZE = 50; private static final int MAX_PAGE_SIZE = 50;
private static final int MAX_CONTENT_LENGTH = 2000; private static final int MAX_CONTENT_LENGTH = 2000;
@ -190,6 +191,7 @@ public class MessagingService {
requireVerifiedIfCompany(userId); requireVerifiedIfCompany(userId);
requireConversation(req.getConversationId()); requireConversation(req.getConversationId());
assertMember(req.getConversationId(), userId); assertMember(req.getConversationId(), userId);
conversationRepository.findLockedById(req.getConversationId());
if (req.getContent().length() > MAX_CONTENT_LENGTH) throw new BusinessException(ErrorCode.BAD_REQUEST); if (req.getContent().length() > MAX_CONTENT_LENGTH) throw new BusinessException(ErrorCode.BAD_REQUEST);
// V33 屏蔽控制:会话任一成员存在任一方向屏蔽 → 禁止发送(对外统一 403/2002) // V33 屏蔽控制:会话任一成员存在任一方向屏蔽 → 禁止发送(对外统一 403/2002)
List<ConversationMember> members = memberRepository.findByConversationId(req.getConversationId()); List<ConversationMember> members = memberRepository.findByConversationId(req.getConversationId());
@ -198,8 +200,22 @@ public class MessagingService {
assertNotBlockedBetween(userId, m.getUserId()); assertNotBlockedBetween(userId, m.getUserId());
} }
} }
if (req.getClientMessageId() != null) {
if (!req.getClientMessageId().matches("[A-Za-z0-9_-]{1,64}"))
throw new BusinessException(ErrorCode.BAD_REQUEST);
var existing = messageRepository.findByConversationIdAndSenderUserIdAndClientMessageId(
req.getConversationId(), userId, req.getClientMessageId());
if (existing.isPresent()) {
if (!Objects.equals(existing.get().getRequestContentHash(), contentHash(req.getContent())))
throw new BusinessException(ErrorCode.BAD_REQUEST, "clientMessageId already used");
return toView(existing.get(), userId);
}
}
Message msg = messageRepository.save(Message.builder() Message msg = messageRepository.save(Message.builder()
.conversationId(req.getConversationId()).senderUserId(userId).content(req.getContent()).build()); .conversationId(req.getConversationId()).senderUserId(userId).content(req.getContent())
.clientMessageId(req.getClientMessageId())
.requestContentHash(req.getClientMessageId() == null ? null : contentHash(req.getContent())).build());
chatEvents.record("MESSAGE_CREATED", req.getConversationId(), userId, msg.getId());
conversationRepository.findById(req.getConversationId()).ifPresent(c -> { conversationRepository.findById(req.getConversationId()).ifPresent(c -> {
c.setUpdatedAt(msg.getCreatedAt()); conversationRepository.save(c); c.setUpdatedAt(msg.getCreatedAt()); conversationRepository.save(c);
}); });
@ -245,6 +261,7 @@ public class MessagingService {
requireVerifiedIfCompany(userId); requireVerifiedIfCompany(userId);
requireConversation(conversationId); requireConversation(conversationId);
assertMember(conversationId, userId); assertMember(conversationId, userId);
conversationRepository.findLockedById(conversationId);
List<Message> msgs = messageRepository List<Message> msgs = messageRepository
.findByConversationIdOrderByCreatedAtDesc(conversationId, PageRequest.of(0, 500)).getContent(); .findByConversationIdOrderByCreatedAtDesc(conversationId, PageRequest.of(0, 500)).getContent();
List<Long> msgIds = msgs.stream() List<Long> msgIds = msgs.stream()
@ -255,8 +272,10 @@ public class MessagingService {
Set<Long> existingIds = new HashSet<>(); Set<Long> existingIds = new HashSet<>();
for (MessageRead r : existing) existingIds.add(r.getMessageId()); for (MessageRead r : existing) existingIds.add(r.getMessageId());
for (Long mid : msgIds) { for (Long mid : msgIds) {
if (!existingIds.contains(mid)) readRepository.save( if (!existingIds.contains(mid)) {
MessageRead.builder().messageId(mid).readerUserId(userId).build()); readRepository.save(MessageRead.builder().messageId(mid).readerUserId(userId).build());
chatEvents.record("MESSAGE_READ", conversationId, userId, mid);
}
} }
} }
@ -271,6 +290,15 @@ public class MessagingService {
return msgIds.size() - readRepository.findByMessageIdInAndReaderUserId(msgIds, userId).size(); return msgIds.size() - readRepository.findByMessageIdInAndReaderUserId(msgIds, userId).size();
} }
/** 游标只来自补拉响应,不能直接用实时事件 ID 跳过尚未收到的事件。 */
public Map<String, Object> replayEvents(Long conversationId, Long userId, long after, int size) {
requireVerifiedIfCompany(userId);
requireConversation(conversationId);
assertMember(conversationId, userId);
if (after < 0 || size < 1 || size > 100) throw new BusinessException(ErrorCode.BAD_REQUEST);
return chatEvents.replay(conversationId, userId, after, size);
}
/** /**
* 在事务内执行消息撤回。调用者身份、会话成员关系和原发送者关系均由服务端校验。 * 在事务内执行消息撤回。调用者身份、会话成员关系和原发送者关系均由服务端校验。
* capturedNow 由入口统一捕获,便于边界测试,也避免同一请求混用多个时间。 * capturedNow 由入口统一捕获,便于边界测试,也避免同一请求混用多个时间。
@ -281,6 +309,7 @@ public class MessagingService {
requireVerifiedIfCompany(currentUserId); requireVerifiedIfCompany(currentUserId);
Conversation conversation = requireConversation(conversationId); Conversation conversation = requireConversation(conversationId);
assertMember(conversationId, currentUserId); assertMember(conversationId, currentUserId);
conversationRepository.findLockedById(conversationId);
User currentUser = userRepository.findById(currentUserId).orElse(null); User currentUser = userRepository.findById(currentUserId).orElse(null);
if (currentUser != null && hasRole(currentUser, User.Role.ADMIN)) { if (currentUser != null && hasRole(currentUser, User.Role.ADMIN)) {
throw new BusinessException(ErrorCode.FORBIDDEN); throw new BusinessException(ErrorCode.FORBIDDEN);
@ -318,6 +347,7 @@ public class MessagingService {
recallAuditDetails(messageId, conversationId, conversation.getType(), auditContext), recallAuditDetails(messageId, conversationId, conversation.getType(), auditContext),
recallAuditContext(auditContext)); recallAuditContext(auditContext));
Message recalled = messageRepository.findById(messageId).orElse(message); Message recalled = messageRepository.findById(messageId).orElse(message);
chatEvents.record("MESSAGE_RECALLED", conversationId, currentUserId, messageId);
return toMessageResponse(recalled, currentUserId, conversation); return toMessageResponse(recalled, currentUserId, conversation);
} }
@ -398,6 +428,8 @@ public class MessagingService {
Map<String, Object> v = new HashMap<>(); Map<String, Object> v = new HashMap<>();
if (response == null) return v; if (response == null) return v;
v.put("id", response.getId()); v.put("id", response.getId());
if (response.getClientMessageId() != null) v.put("clientMessageId", response.getClientMessageId());
if (response.getVersion() != null) v.put("version", response.getVersion());
v.put("conversationId", response.getConversationId()); v.put("conversationId", response.getConversationId());
if (response.getSenderUserId() != null) v.put("senderUserId", response.getSenderUserId()); if (response.getSenderUserId() != null) v.put("senderUserId", response.getSenderUserId());
if (response.getSenderScope() != null) v.put("senderScope", response.getSenderScope()); if (response.getSenderScope() != null) v.put("senderScope", response.getSenderScope());
@ -409,6 +441,15 @@ public class MessagingService {
return v; return v;
} }
private String contentHash(String content) {
try {
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(content.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
} catch (java.security.NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 unavailable");
}
}
private boolean isTalentInvitationConversation(Long conversationId) { private boolean isTalentInvitationConversation(Long conversationId) {
return conversationId != null && conversationRepository.findById(conversationId) return conversationId != null && conversationRepository.findById(conversationId)
.map(c -> "TALENT_INVITATION".equals(c.getType())).orElse(false); .map(c -> "TALENT_INVITATION".equals(c.getType())).orElse(false);

2
mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/MessageResponse.java

@ -19,6 +19,8 @@ import java.time.LocalDateTime;
public class MessageResponse { public class MessageResponse {
private Long id; private Long id;
private String clientMessageId;
private Long version;
private Long conversationId; private Long conversationId;
/** 仅 DIRECT 会话返回;TALENT_INVITATION 使用 senderScope。 */ /** 仅 DIRECT 会话返回;TALENT_INVITATION 使用 senderScope。 */
private Long senderUserId; private Long senderUserId;

4
mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/SendMessageRequest.java

@ -8,6 +8,10 @@ import lombok.Data;
@Data @Data
public class SendMessageRequest { public class SendMessageRequest {
/** 可选以兼容旧客户端;新客户端重试必须复用同一个标识。 */
@jakarta.validation.constraints.Pattern(regexp = "[A-Za-z0-9_-]{1,64}")
private String clientMessageId;
@NotNull(message = "conversationId is required") @NotNull(message = "conversationId is required")
private Long conversationId; private Long conversationId;

19
mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatEvent.java

@ -0,0 +1,19 @@
package com.mydreamjob.messaging.realtime;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "chat_events")
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
public class ChatEvent {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "conversation_id", nullable = false) private Long conversationId;
@Column(name = "recipient_user_id", nullable = false) private Long recipientUserId;
@Column(name = "actor_user_id", nullable = false) private Long actorUserId;
@Column(name = "message_id", nullable = false) private Long messageId;
@Column(name = "event_type", nullable = false, length = 32) private String eventType;
@Column(name = "created_at", nullable = false) private LocalDateTime createdAt;
}

10
mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatEventRepository.java

@ -0,0 +1,10 @@
package com.mydreamjob.messaging.realtime;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Pageable;
import java.util.List;
public interface ChatEventRepository extends JpaRepository<ChatEvent, Long> {
List<ChatEvent> findByConversationIdAndRecipientUserIdAndIdGreaterThanOrderByIdAsc(
Long conversationId, Long recipientUserId, Long cursor, Pageable pageable);
}

90
mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatEventService.java

@ -0,0 +1,90 @@
package com.mydreamjob.messaging.realtime;
import com.mydreamjob.messaging.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.data.domain.PageRequest;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.*;
/** 事件和业务写入在同一事务;只在提交后推送,发送失败由持久化游标恢复。 */
@Service @RequiredArgsConstructor @Slf4j
public class ChatEventService {
private final ChatEventRepository events;
private final ConversationMemberRepository members;
private final ConversationRepository conversations;
private final MessageRepository messages;
private final MessageReadRepository reads;
private final MessageViewMapper mapper;
private final ApplicationEventPublisher publisher;
private final SimpMessagingTemplate socket;
private final Clock clock;
public record CommittedEvent(Long eventId) {}
@Transactional(propagation = Propagation.MANDATORY)
public void record(String type, Long conversationId, Long actorId, Long messageId) {
for (var member : members.findByConversationId(conversationId)) {
var event = events.save(ChatEvent.builder().conversationId(conversationId)
.recipientUserId(member.getUserId()).actorUserId(actorId).messageId(messageId)
.eventType(type).createdAt(LocalDateTime.now(clock)).build());
publisher.publishEvent(new CommittedEvent(event.getId()));
}
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true)
public void push(CommittedEvent committed) {
try {
events.findById(committed.eventId()).ifPresent(event -> {
var view = view(event);
if (view != null) socket.convertAndSendToUser(event.getRecipientUserId().toString(),
"/queue/chat-events", view);
});
} catch (Exception ignored) {
// 不把已提交的发送伪报为失败,不记录正文或身份凭据。
log.warn("Chat event push failed; cursor replay is available");
}
}
/**
* 返回 items / nextCursor / hasMore;初始 after=0,size 最大值由入口限制为 100。
* 客户端仅在补拉页成功合并之后持久化 nextCursor,并持续翻页直到 hasMore=false。
* 实时帧可能晚到或乱序,不得用推送 eventId 直接推进补拉游标。
* payload 是消息当前状态(并非历史正文快照),按 message.id + version 合并。
* MESSAGE_READ 的 actorScope 表示谁执行已读;payload.isRead 仍表示当前用户已读。
*/
@Transactional(readOnly = true)
public Map<String, Object> replay(Long conversationId, Long userId, long after, int size) {
var rows = events.findByConversationIdAndRecipientUserIdAndIdGreaterThanOrderByIdAsc(
conversationId, userId, after, PageRequest.of(0, size + 1));
boolean hasMore = rows.size() > size;
var page = rows.stream().limit(size).toList();
var items = page.stream().map(this::view).filter(Objects::nonNull).toList();
long next = page.isEmpty() ? after : page.get(page.size() - 1).getId();
return Map.of("items", items, "nextCursor", next, "hasMore", hasMore);
}
private Map<String, Object> view(ChatEvent event) {
// 防止成员移除之后继续读取旧事件。
if (!members.existsByConversationIdAndUserId(event.getConversationId(), event.getRecipientUserId())) return null;
var message = messages.findById(event.getMessageId()).orElse(null);
var conversation = conversations.findById(event.getConversationId()).orElse(null);
if (message == null || conversation == null) return null;
var response = mapper.toResponse(message, event.getRecipientUserId(),
"TALENT_INVITATION".equals(conversation.getType()));
response.setIsRead(reads.existsByMessageIdAndReaderUserId(message.getId(), event.getRecipientUserId()));
return Map.of("eventId", event.getId(), "type", event.getEventType(),
"conversationId", event.getConversationId(), "occurredAt", event.getCreatedAt(),
"actorScope", Objects.equals(event.getActorUserId(), event.getRecipientUserId())
? "CURRENT_USER" : "OTHER_PARTICIPANT", "payload", response);
}
}

112
mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatSocketAuth.java

@ -0,0 +1,112 @@
package com.mydreamjob.messaging.realtime;
import com.mydreamjob.auth.*;
import lombok.RequiredArgsConstructor;
import org.springframework.messaging.*;
import org.springframework.messaging.support.*;
import org.springframework.messaging.simp.stomp.*;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.util.*;
/** 只允许 CONNECT 鉴权和自己的固定队列;不允许 STOMP SEND 绕过 HTTP 服务层。 */
@Component @RequiredArgsConstructor @lombok.extern.slf4j.Slf4j
public class ChatSocketAuth implements ChannelInterceptor {
static final String CLAIMS = "chat.accessClaims";
private final JwtTokenProvider tokens;
private final AuthSessionService sessions;
private final UserRepository users;
private final com.mydreamjob.company.CompanyRepository companies;
private final Clock clock;
private final java.util.concurrent.ConcurrentHashMap<String, AccessTokenClaims> connected =
new java.util.concurrent.ConcurrentHashMap<>();
@org.springframework.context.event.EventListener
public void disconnected(org.springframework.web.socket.messaging.SessionDisconnectEvent event) {
connected.remove(event.getSessionId());
}
boolean active(AccessTokenClaims claims) {
if (claims == null || !clock.instant().isBefore(claims.expiresAt())) return false;
if (!sessions.isSessionActive(claims.userId(), claims.sessionId(), clock.instant())) return false;
return users.findByIdWithRoles(claims.userId()).map(u -> {
if (u.getRole() == User.Role.UNASSIGNED) return false;
if (u.getRoles() != null && u.getRoles().contains(User.Role.COMPANY)) {
return companies.findByOwnerUserId(u.getId())
.map(com.mydreamjob.company.VerifiedCompanyGuard::isVerified).orElse(false);
}
return true;
}).orElse(false);
}
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
var accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor == null) throw denied();
var attrs = accessor.getSessionAttributes();
if (attrs == null) throw denied();
if (accessor.getCommand() == StompCommand.DISCONNECT) return message;
try {
if (accessor.getCommand() == StompCommand.CONNECT) {
if (attrs.containsKey(CLAIMS)) throw denied();
var auth = accessor.getNativeHeader("Authorization");
if (auth == null || auth.size() != 1 || !auth.get(0).startsWith("Bearer ")
|| auth.get(0).length() > 8192) throw denied();
var claims = tokens.parseAndValidateAccessToken(auth.get(0).substring(7), clock.instant());
if (!active(claims)) throw denied();
if (accessor.getSessionId() == null) throw denied();
synchronized (connected) {
if (connected.values().stream().filter(c -> c.userId().equals(claims.userId())).count() >= 5)
throw denied();
connected.put(accessor.getSessionId(), claims);
}
accessor.setUser(new UsernamePasswordAuthenticationToken(claims.userId(), null, List.of()));
attrs.put(CLAIMS, claims);
// 原始 Token 不进入会话属性或后续帧。
accessor.removeNativeHeader("Authorization");
} else {
if (!active((AccessTokenClaims) attrs.get(CLAIMS))) throw denied();
if (accessor.getCommand() == StompCommand.SUBSCRIBE
&& !"/user/queue/chat-events".equals(accessor.getDestination())) throw denied();
if (accessor.getCommand() != StompCommand.SUBSCRIBE
&& accessor.getCommand() != StompCommand.UNSUBSCRIBE
&& accessor.getCommand() != null) throw denied();
}
synchronized (attrs) {
long now = clock.millis();
long start = (long) attrs.getOrDefault("chat.rateStart", now);
int count = (int) attrs.getOrDefault("chat.rateCount", 0);
if (now - start >= 60000) { start = now; count = 0; }
if (++count > 120) throw denied();
attrs.put("chat.rateStart", start);
attrs.put("chat.rateCount", count);
}
return message;
} catch (Exception ignored) {
throw denied();
}
}
private MessageDeliveryException denied() {
return new MessageDeliveryException("Chat connection rejected");
}
/** 推送时再次检查,防止过期、停用或登出后仍然接收消息。 */
public ChannelInterceptor outbound() {
return new ChannelInterceptor() {
@Override public Message<?> preSend(Message<?> message, MessageChannel channel) {
var accessor = MessageHeaderAccessor.getAccessor(message,
org.springframework.messaging.simp.SimpMessageHeaderAccessor.class);
if (accessor == null || accessor.getMessageType() != org.springframework.messaging.simp.SimpMessageType.MESSAGE)
return message;
try {
if (!active(connected.get(accessor.getSessionId()))) {
log.warn("Chat outbound authorization rejected"); return null;
}
return message;
} catch (Exception ignored) { return null; }
}
};
}
}

68
mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatSocketConfig.java

@ -0,0 +1,68 @@
package com.mydreamjob.messaging.realtime;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.*;
import org.springframework.messaging.simp.config.*;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.web.socket.config.annotation.*;
/** 当前使用单实例内置 broker;多实例部署必须改为共享 broker,不能仅添加负载均衡。 */
@Configuration @EnableWebSocketMessageBroker @RequiredArgsConstructor
public class ChatSocketConfig implements WebSocketMessageBrokerConfigurer {
private final ChatSocketAuth auth;
private final ChatSocketConnections connections;
private final org.springframework.beans.factory.ObjectProvider<org.springframework.messaging.simp.SimpMessagingTemplate> socket;
@Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:3000}")
private String origins;
@Bean public ThreadPoolTaskScheduler chatHeartbeatScheduler() {
var scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(2);
scheduler.setThreadNamePrefix("chat-heartbeat-");
return scheduler;
}
@Override public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").addInterceptors(connections.handshakeGuard())
.setAllowedOrigins(java.util.Arrays.stream(origins.split(","))
.map(String::trim).filter(s -> !s.isEmpty()).toArray(String[]::new));
}
@Override public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/queue").setHeartbeatValue(new long[]{10000, 10000})
.setTaskScheduler(chatHeartbeatScheduler());
registry.setUserDestinationPrefix("/user");
registry.setApplicationDestinationPrefixes("/app");
registry.setPreservePublishOrder(true);
configureBrokerChannel(registry.configureBrokerChannel());
}
@Override public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(auth);
}
@Override public void configureClientOutboundChannel(ChannelRegistration registration) {
registration.interceptors(auth.outbound());
}
private void configureBrokerChannel(ChannelRegistration registration) {
registration.interceptors(new org.springframework.messaging.support.ChannelInterceptor() {
@Override public void afterSendCompletion(org.springframework.messaging.Message<?> message,
org.springframework.messaging.MessageChannel channel, boolean sent, Exception exception) {
var headers = org.springframework.messaging.simp.SimpMessageHeaderAccessor.wrap(message);
if (!sent || exception != null
|| headers.getMessageType() != org.springframework.messaging.simp.SimpMessageType.SUBSCRIBE
|| headers.getDestination() == null
|| !headers.getDestination().startsWith("/queue/chat-events-user")
|| headers.getUser() == null || headers.getSessionId() == null) return;
// 同步 broker channel 返回时订阅已经登记。客户端收到 READY 后再补拉,
// 避免“补拉已结束但订阅尚未生效”的窗口漏掉消息。
var ready = org.springframework.messaging.simp.SimpMessageHeaderAccessor.create(
org.springframework.messaging.simp.SimpMessageType.MESSAGE);
ready.setSessionId(headers.getSessionId()); ready.setLeaveMutable(true);
socket.getObject().convertAndSendToUser(headers.getUser().getName(), "/queue/chat-events",
java.util.Map.of("type", "CHAT_READY"), ready.getMessageHeaders());
}
});
}
@Override public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
registration.setMessageSizeLimit(16384).setSendBufferSizeLimit(262144).setSendTimeLimit(10000)
.setTimeToFirstMessage(10000).addDecoratorFactory(connections::decorate);
}
}

82
mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatSocketConnections.java

@ -0,0 +1,82 @@
package com.mydreamjob.messaging.realtime;
import com.mydreamjob.auth.AccessTokenClaims;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.web.socket.*;
import org.springframework.web.socket.handler.WebSocketHandlerDecorator;
import java.time.Clock;
import java.util.concurrent.ConcurrentHashMap;
/** 限制单实例连接数、每用户设备数;定期关闭未鉴权、过期或被撤销的连接。 */
@Component @RequiredArgsConstructor
public class ChatSocketConnections {
private final ChatSocketAuth auth;
private final Clock clock;
private final ConcurrentHashMap<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
private final java.util.Map<String, long[]> attempts = new java.util.HashMap<>();
public org.springframework.web.socket.server.HandshakeInterceptor handshakeGuard() {
return new org.springframework.web.socket.server.HandshakeInterceptor() {
@Override public boolean beforeHandshake(org.springframework.http.server.ServerHttpRequest request,
org.springframework.http.server.ServerHttpResponse response, WebSocketHandler handler,
java.util.Map<String, Object> attributes) {
// 凭据不能进入 URL;只接受后续 CONNECT 的 Authorization 头。
if (request.getURI().getRawQuery() != null) {
response.setStatusCode(org.springframework.http.HttpStatus.BAD_REQUEST); return false;
}
String source = request.getRemoteAddress() == null ? "unknown"
: request.getRemoteAddress().getAddress().getHostAddress();
synchronized (attempts) {
long now = clock.millis();
attempts.entrySet().removeIf(e -> now - e.getValue()[0] >= 60000);
if (!attempts.containsKey(source) && attempts.size() >= 1024) {
response.setStatusCode(org.springframework.http.HttpStatus.TOO_MANY_REQUESTS); return false;
}
var count = attempts.computeIfAbsent(source, key -> new long[]{now, 0});
if (++count[1] > 40) {
response.setStatusCode(org.springframework.http.HttpStatus.TOO_MANY_REQUESTS); return false;
}
}
return true;
}
@Override public void afterHandshake(org.springframework.http.server.ServerHttpRequest request,
org.springframework.http.server.ServerHttpResponse response, WebSocketHandler handler, Exception exception) { }
};
}
public WebSocketHandler decorate(WebSocketHandler handler) {
return new WebSocketHandlerDecorator(handler) {
@Override public void afterConnectionEstablished(WebSocketSession session) throws Exception {
synchronized (sessions) {
if (sessions.size() >= 500) { session.close(CloseStatus.POLICY_VIOLATION); return; }
session.getAttributes().put("chat.openedAt", clock.millis());
sessions.put(session.getId(), session);
}
try { super.afterConnectionEstablished(session); }
catch (Exception ex) { sessions.remove(session.getId()); throw ex; }
}
@Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
sessions.remove(session.getId());
super.afterConnectionClosed(session, status);
}
};
}
@Scheduled(fixedDelay = 10000)
public void expire() {
var perUser = new java.util.HashMap<Long, Integer>();
sessions.values().forEach(session -> {
try {
var claims = (AccessTokenClaims) session.getAttributes().get(ChatSocketAuth.CLAIMS);
boolean invalid = claims == null
? clock.millis() - (long) session.getAttributes().getOrDefault("chat.openedAt", 0L) > 10000
: !auth.active(claims) || perUser.merge(claims.userId(), 1, Integer::sum) > 5;
if (invalid) session.close(CloseStatus.POLICY_VIOLATION);
} catch (Exception ignored) {
try { session.close(CloseStatus.SERVER_ERROR); } catch (Exception closeIgnored) { }
}
});
}
}

18
mydreamjob-server/src/main/resources/db/migration/V55__chat_realtime_events.sql

@ -0,0 +1,18 @@
ALTER TABLE messages ADD COLUMN client_message_id VARCHAR(64) NULL;
ALTER TABLE messages ADD COLUMN request_content_hash VARCHAR(64) NULL;
CREATE UNIQUE INDEX uk_messages_client_id ON messages(conversation_id, sender_user_id, client_message_id);
-- 不复制聊天正文;补拉时读取消息当前状态,避免已撤回正文重新出现。
CREATE TABLE chat_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
conversation_id BIGINT NOT NULL,
recipient_user_id BIGINT NOT NULL,
actor_user_id BIGINT NOT NULL,
message_id BIGINT NOT NULL,
event_type VARCHAR(32) NOT NULL,
created_at DATETIME(6) NOT NULL,
CONSTRAINT fk_chat_event_conversation FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
CONSTRAINT fk_chat_event_recipient FOREIGN KEY (recipient_user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_chat_event_message FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
INDEX idx_chat_events_cursor (conversation_id, recipient_user_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

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(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13);
assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18); assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18);
assertThat(pathCount - modulePaths).as("185 paths 包含首次密码设置").isEqualTo(185); assertThat(pathCount - modulePaths).as("186 paths 包含首次密码设置与聊天事件补拉").isEqualTo(186);
assertThat(opCount - moduleOperations).as("223 operations 包含首次密码设置").isEqualTo(223); assertThat(opCount - moduleOperations).as("224 operations 包含首次密码设置与聊天事件补拉").isEqualTo(224);
var download = paths.path("/company/candidates/{applicationId}/resume").path("get"); var download = paths.path("/company/candidates/{applicationId}/resume").path("get");
assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse(); 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(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"), java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-identity-gate-result.json"),
"{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}"); "{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}");
} }

23
mydreamjob-server/src/test/java/com/mydreamjob/common/ProductBrandTest.java

@ -0,0 +1,23 @@
package com.mydreamjob.common;
import com.mydreamjob.config.OpenApiSecurityConfig;
import com.mydreamjob.controller.ApiController;
import com.mydreamjob.common.logging.RequestIdAccessor;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
class ProductBrandTest {
@Test void apiInformationUsesNewBrandAndPreservesTechnicalName() {
var controller = new ApiController(null, mock(RequestIdAccessor.class));
var info = controller.index(new MockHttpServletRequest()).getBody().getData();
assertEquals("mydreamjob", info.get("name"));
assertEquals("轻松聘", info.get("displayName"));
assertEquals("轻松聘 - 大学生求职服务平台 API", info.get("description"));
assertFalse(info.toString().contains("职来"));
}
@Test void swaggerTitleUsesNewBrand() {
assertEquals("轻松聘 API", new OpenApiSecurityConfig().homepageOpenAPI().getInfo().getTitle());
}
}

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

@ -1202,11 +1202,11 @@ class R01EIndependentCrossDatabaseVerificationTest {
// 4. MySQL EXPLAIN FORMAT=JSON // 4. MySQL EXPLAIN FORMAT=JSON
// ══════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════
/** 与 MessageRepository.countUnreadByConversationIds 逐字一致的原生 SQL。 */ /** 独立复刻 MySQL 8 未读统计分支,使用 JDBC 占位符。 */
static String unreadSql(int placeholders) { static String unreadSql(int placeholders) {
return "SELECT ranked.conversation_id AS conversationId, COUNT(*) AS unreadCount " return "SELECT ranked.conversation_id AS conversationId, COUNT(*) AS unreadCount "
+ "FROM (SELECT m.id, m.conversation_id, m.status, " + "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 (" + "FROM messages m WHERE m.conversation_id IN ("
+ String.join(",", Collections.nCopies(placeholders, "?")) + ")) ranked " + String.join(",", Collections.nCopies(placeholders, "?")) + ")) ranked "
+ "LEFT JOIN message_reads mr ON mr.message_id = ranked.id AND mr.reader_user_id = ? " + "LEFT JOIN message_reads mr ON mr.message_id = ranked.id AND mr.reader_user_id = ? "
@ -1218,7 +1218,7 @@ class R01EIndependentCrossDatabaseVerificationTest {
* EXPLAIN 得到的「表节点」信息。 * EXPLAIN 得到的「表节点」信息。
* *
* <p><b>注意</b>:MySQL 的 {@code EXPLAIN FORMAT=JSON} 里 {@code table_name} 是**别名**。 * <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 messages m}、{@code message_reads mr}、派生表 {@code ranked},
* 故此处表节点分别为 {@code m} / {@code mr} / {@code ranked}。</p> * 故此处表节点分别为 {@code m} / {@code mr} / {@code ranked}。</p>
*/ */
@ -1341,9 +1341,20 @@ class R01EIndependentCrossDatabaseVerificationTest {
assertThat(m.accessType()) assertThat(m.accessType())
.as("%s:基础表 messages 不得全表扫描(access_type=%s, key=%s, possible_keys=%s)", .as("%s:基础表 messages 不得全表扫描(access_type=%s, key=%s, possible_keys=%s)",
label, m.accessType(), m.key(), m.possibleKeys()) label, m.accessType(), m.key(), m.possibleKeys())
.isNotEqualTo("ALL"); .isIn("ref", "range");
assertThat(m.key()).as("%s:messages 必须命中既有索引", label) assertThat(m.key()).as("%s:messages 必须命中会话过滤索引", label).isNotNull();
.isEqualTo("idx_messages_conversation_created"); 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) { for (TableNode r : reads) {
assertThat(r.accessType()) 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> * <li>{@code EXPLAIN FORMAT=JSON} 的索引使用、access type、temporary / filesort 情况。</li>
* </ol> * </ol>
* *
* <p>EXPLAIN 使用与 {@code MessageRepository.countUnreadByConversationIds} **逐字相同**的 SQL * <p>EXPLAIN 独立复刻 {@code MessageUnreadQueriesImpl} 的 MySQL 8 窗口分支;
* 与同一批绑定参数,避免解释一条并不存在的语句。</p> * 筛选、排序和窗口规则一致,使用 JDBC 占位符与同一批绑定参数。</p>
*/ */
@Testcontainers(disabledWithoutDocker = true) @Testcontainers(disabledWithoutDocker = true)
@SpringBootTest @SpringBootTest
@ -63,14 +63,14 @@ class R01EMySqlQueryCountAndExplainTest {
registry.add("spring.datasource.password", MYSQL::getPassword); registry.add("spring.datasource.password", MYSQL::getPassword);
} }
/** 与 MessageRepository 中原生查询**逐字一致**(仅换行为便于阅读)。 */ /** 独立复刻 MySQL 8 原生查询的筛选、排序与窗口规则。 */
private static final String UNREAD_SQL = """ private static final String UNREAD_SQL = """
SELECT ranked.conversation_id AS conversationId, COUNT(*) AS unreadCount SELECT ranked.conversation_id AS conversationId, COUNT(*) AS unreadCount
FROM ( FROM (
SELECT m.id, m.conversation_id, m.status, SELECT m.id, m.conversation_id, m.status,
ROW_NUMBER() OVER ( ROW_NUMBER() OVER (
PARTITION BY m.conversation_id PARTITION BY m.conversation_id
ORDER BY m.created_at DESC ORDER BY m.created_at DESC, m.id DESC
) AS row_no ) AS row_no
FROM messages m FROM messages m
WHERE m.conversation_id IN (%s) WHERE m.conversation_id IN (%s)
@ -248,6 +248,54 @@ class R01EMySqlQueryCountAndExplainTest {
System.out.println("[R01E-MYSQL-EXPLAIN] " + summary); System.out.println("[R01E-MYSQL-EXPLAIN] " + summary);
assertExplainPlan(root); 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 @Test
@ -256,14 +304,21 @@ class R01EMySqlQueryCountAndExplainTest {
String valid = """ String valid = """
{"query_block":{"using_temporary_table":true,"using_filesort":true,"nested_loop":[ {"query_block":{"using_temporary_table":true,"using_filesort":true,"nested_loop":[
{"table":{"table_name":"ranked","access_type":"ALL"}}, {"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"}} {"table":{"table_name":"mr","access_type":"eq_ref","key":"uk_message_reads"}}
]}} ]}}
"""; """;
assertExplainPlan(JSON.readTree(valid)); assertExplainPlan(JSON.readTree(valid));
assertExplainPlan(JSON.readTree(valid.replace("idx_messages_conversation_created", "uk_messages_client_id")));
assertThatThrownBy(() -> assertExplainPlan(JSON.readTree( assertThatThrownBy(() -> assertExplainPlan(JSON.readTree(
valid.replace("idx_messages_conversation_created", "wrong_index")))) valid.replace("idx_messages_conversation_created", "wrong_index"))))
.isInstanceOf(AssertionError.class); .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( assertThatThrownBy(() -> assertExplainPlan(JSON.readTree(
valid.replace("\"access_type\":\"ref\"", "\"access_type\":\"ALL\"")))) valid.replace("\"access_type\":\"ref\"", "\"access_type\":\"ALL\""))))
.isInstanceOf(AssertionError.class); .isInstanceOf(AssertionError.class);
@ -277,7 +332,7 @@ class R01EMySqlQueryCountAndExplainTest {
.isInstanceOf(AssertionError.class); .isInstanceOf(AssertionError.class);
} }
private static void assertExplainPlan(JsonNode root) { private void assertExplainPlan(JsonNode root) {
List<PlanNode> nodes = new ArrayList<>(); List<PlanNode> nodes = new ArrayList<>();
collectPlanNodes(root, nodes); collectPlanNodes(root, nodes);
PlanNode messages = onlyNode(nodes, "m"); PlanNode messages = onlyNode(nodes, "m");
@ -285,7 +340,15 @@ class R01EMySqlQueryCountAndExplainTest {
PlanNode ranked = onlyNode(nodes, "ranked"); PlanNode ranked = onlyNode(nodes, "ranked");
assertThat(messages.accessType()).isIn("ref", "range"); 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(messages.accessType()).isNotEqualTo("ALL");
assertThat(reads.accessType()).isEqualTo("eq_ref"); assertThat(reads.accessType()).isEqualTo("eq_ref");
assertThat(reads.key()).isEqualTo("uk_message_reads"); assertThat(reads.key()).isEqualTo("uk_message_reads");
@ -312,7 +375,8 @@ class R01EMySqlQueryCountAndExplainTest {
out.add(new PlanNode( out.add(new PlanNode(
table.path("table_name").asText(), table.path("table_name").asText(),
table.path("access_type").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)); node.fields().forEachRemaining(entry -> collectPlanNodes(entry.getValue(), out));
} else if (node.isArray()) { } else if (node.isArray()) {
@ -339,5 +403,5 @@ class R01EMySqlQueryCountAndExplainTest {
return Optional.empty(); 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(); var provider = config.getResourceProvider();
assertThat(provider).isNotNull(); assertThat(provider).isNotNull();
var resources = provider.getResources("V", new String[]{".sql"}); 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) { for (var resource : resources) {
String path = "V49__skill_cloud_boolean_type.sql".equals(resource.getFilename()) String path = "V49__skill_cloud_boolean_type.sql".equals(resource.getFilename())
? "db/mysql57/" : "db/migration/"; ? "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 JdbcTemplate jdbc;
@Autowired MockMvc mvc; @Autowired MockMvc mvc;
@Autowired ObjectMapper mapper; @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() { @Test void fullMigrationAndSeedsValidateOnActualServerVersion() {
assertThat(jdbc.queryForObject("select version()", String.class)).startsWith("5.7.36"); 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")) JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()); .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray());
JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertThat(operations(runtime)).hasSize(241).isEqualTo(operations(contract)); assertThat(operations(runtime)).hasSize(242).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(198); 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) { 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")) JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()); .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray());
JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertThat(operations(runtime)).hasSize(241).isEqualTo(operations(contract)); assertThat(operations(runtime)).hasSize(242).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(198); 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) { private Set<String> operations(JsonNode document) {

1
mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallServiceTest.java

@ -46,6 +46,7 @@ class MessageRecallServiceTest {
@org.mockito.Spy private com.fasterxml.jackson.databind.ObjectMapper objectMapper = @org.mockito.Spy private com.fasterxml.jackson.databind.ObjectMapper objectMapper =
new com.fasterxml.jackson.databind.ObjectMapper(); new com.fasterxml.jackson.databind.ObjectMapper();
@InjectMocks private MessagingService messagingService; @InjectMocks private MessagingService messagingService;
@Mock private com.mydreamjob.messaging.realtime.ChatEventService chatEvents;
private static final Long USER = 7L; private static final Long USER = 7L;
private static final Long OTHER = 8L; private static final Long OTHER = 8L;

1
mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingServiceTest.java

@ -44,6 +44,7 @@ class MessagingServiceTest {
@org.mockito.Spy private com.fasterxml.jackson.databind.ObjectMapper objectMapper = @org.mockito.Spy private com.fasterxml.jackson.databind.ObjectMapper objectMapper =
new com.fasterxml.jackson.databind.ObjectMapper(); new com.fasterxml.jackson.databind.ObjectMapper();
@InjectMocks private MessagingService messagingService; @InjectMocks private MessagingService messagingService;
@Mock private com.mydreamjob.messaging.realtime.ChatEventService chatEvents;
private static final Long USER_A = 100L; private static final Long USER_A = 100L;
private static final Long USER_B = 200L; private static final Long USER_B = 200L;

202
mydreamjob-server/src/test/java/com/mydreamjob/messaging/realtime/ChatRealtimeIntegrationTest.java

@ -0,0 +1,202 @@
package com.mydreamjob.messaging.realtime;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.*;
import com.mydreamjob.auth.support.TestAuthTokens;
import com.mydreamjob.company.*;
import com.mydreamjob.messaging.*;
import com.mydreamjob.messaging.dto.SendMessageRequest;
import com.mydreamjob.common.exception.BusinessException;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
import org.springframework.web.socket.messaging.WebSocketStompClient;
import org.springframework.messaging.simp.stomp.*;
import org.springframework.messaging.converter.StringMessageConverter;
import java.lang.reflect.Type;
import java.util.*;
import java.util.concurrent.*;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
"spring.datasource.url=jdbc:h2:mem:chatrealtime;MODE=MySQL;DB_CLOSE_DELAY=-1",
"spring.jpa.show-sql=false", "management.server.port=0"})
@ActiveProfiles("integration-test")
class ChatRealtimeIntegrationTest {
@Autowired MessagingService service;
@Autowired UserRepository users;
@Autowired CompanyRepository companies;
@Autowired ConversationRepository conversations;
@Autowired ConversationMemberRepository members;
@Autowired MessageRepository messages;
@Autowired ChatEventRepository events;
@Autowired PlatformTransactionManager transactions;
@Autowired ObjectMapper mapper;
@Autowired org.springframework.messaging.simp.user.SimpUserRegistry connectedUsers;
@MockBean RedisTemplate<String, Object> redis;
@org.springframework.boot.test.mock.mockito.SpyBean ChatEventService eventService;
@LocalServerPort int port;
Long student, hr, conversation;
@BeforeEach void setup() {
student = user(User.Role.STUDENT); hr = user(User.Role.COMPANY);
companies.save(Company.builder().name("实时聊天测试企业").ownerUserId(hr).verified(true).build());
conversation = conversations.save(Conversation.builder().createdBy(hr).type("DIRECT").build()).getId();
members.save(ConversationMember.builder().conversationId(conversation).userId(student).build());
members.save(ConversationMember.builder().conversationId(conversation).userId(hr).build());
}
private Long user(User.Role role) {
return users.save(User.builder().phone("rt-" + UUID.randomUUID().toString().substring(0, 12))
.role(role).roles(Set.of(role)).build()).getId();
}
private SendMessageRequest request(String key, String content) {
var req = new SendMessageRequest(); req.setConversationId(conversation);
req.setClientMessageId(key); req.setContent(content); return req;
}
@Test void idempotentRetryReturnsSameMessageAndDoesNotRepeatEvents() {
var first = service.sendMessage(student, request("retry-key", "hello"));
var second = service.sendMessage(student, request("retry-key", "hello"));
assertEquals(first.get("id"), second.get("id"));
var replay = service.replayEvents(conversation, hr, 0, 100);
assertEquals(1, ((List<?>) replay.get("items")).size());
assertThrows(BusinessException.class, () -> service.sendMessage(student, request("retry-key", "changed")));
}
@Test void concurrentRetryStoresOnlyOneMessage() throws Exception {
var pool = Executors.newFixedThreadPool(2);
try {
var a = pool.submit(() -> service.sendMessage(student, request("parallel-key", "same")));
var b = pool.submit(() -> service.sendMessage(student, request("parallel-key", "same")));
assertEquals(a.get(10, TimeUnit.SECONDS).get("id"), b.get(10, TimeUnit.SECONDS).get("id"));
} finally { pool.shutdownNow(); }
}
@Test void replayPaginatesWithoutLossAndIncludesReadAndRecall() {
var sent = service.sendMessage(student, request("event-key", "secret"));
service.markRead(conversation, hr);
service.recallMessage(student, conversation, (Long) sent.get("id"), null);
long cursor = 0; var found = new ArrayList<Map<?, ?>>();
Map<String, Object> page;
do {
page = service.replayEvents(conversation, hr, cursor, 1);
for (var item : (List<?>) page.get("items")) found.add((Map<?, ?>) item);
cursor = ((Number) page.get("nextCursor")).longValue();
} while ((Boolean) page.get("hasMore"));
assertEquals(List.of("MESSAGE_CREATED", "MESSAGE_READ", "MESSAGE_RECALLED"),
found.stream().map(e -> e.get("type")).toList());
assertFalse(found.toString().contains("secret"));
}
@Test void unauthorizedAndInvalidCursorRejected() {
Long outsider = user(User.Role.STUDENT);
assertThrows(BusinessException.class, () -> service.replayEvents(conversation, outsider, 0, 50));
assertThrows(BusinessException.class, () -> service.replayEvents(conversation, student, -1, 50));
assertThrows(BusinessException.class, () -> service.replayEvents(conversation, student, 0, 101));
}
@Test void rolledBackSendHasNoPersistedEvents() {
new TransactionTemplate(transactions).execute(status -> {
service.sendMessage(student, request("rollback-key", "discard")); status.setRollbackOnly(); return null;
});
assertTrue(((List<?>) service.replayEvents(conversation, hr, 0, 50).get("items")).isEmpty());
org.mockito.Mockito.verify(eventService, org.mockito.Mockito.never()).push(org.mockito.ArgumentMatchers.any());
}
@Test void invitationReplayDoesNotExposeOtherUserId() {
var c = conversations.findById(conversation).orElseThrow(); c.setType("TALENT_INVITATION"); conversations.save(c);
service.sendMessage(student, request("private-key", "hello"));
var page = mapper.valueToTree(service.replayEvents(conversation, hr, 0, 50));
assertFalse(page.path("items").get(0).path("payload").has("senderUserId"));
assertEquals("OTHER_PARTICIPANT", page.path("items").get(0).path("payload").path("senderScope").asText());
}
@Test void realWebSocketReceivesBothDirectionsAfterCommit() throws Exception {
var client = new WebSocketStompClient(new StandardWebSocketClient());
var scheduler = new org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler();
scheduler.initialize(); client.setTaskScheduler(scheduler);
var converter = new org.springframework.messaging.converter.MappingJackson2MessageConverter();
converter.setObjectMapper(mapper); client.setMessageConverter(converter);
var hrQueue = new LinkedBlockingQueue<String>(); var studentQueue = new LinkedBlockingQueue<String>();
StompSession hrSocket = null, studentSocket = null;
try {
hrSocket = connect(client, hr, "COMPANY", hrQueue);
studentSocket = connect(client, student, "STUDENT", studentQueue);
service.sendMessage(student, request("socket-student", "hello HR"));
assertEquals(1, ((List<?>) service.replayEvents(conversation, hr, 0, 50).get("items")).size());
org.mockito.Mockito.verify(eventService, org.mockito.Mockito.atLeastOnce()).push(org.mockito.ArgumentMatchers.any());
assertEquals("hello HR", mapper.readTree(Objects.requireNonNull(hrQueue.poll(10, TimeUnit.SECONDS)))
.path("payload").path("content").asText());
studentQueue.poll(10, TimeUnit.SECONDS); // 发送者自己的设备也接收事件。
service.sendMessage(hr, request("socket-hr", "hello student"));
assertEquals("hello student", mapper.readTree(Objects.requireNonNull(studentQueue.poll(10, TimeUnit.SECONDS)))
.path("payload").path("content").asText());
} finally {
if (hrSocket != null && hrSocket.isConnected()) hrSocket.disconnect();
if (studentSocket != null && studentSocket.isConnected()) studentSocket.disconnect();
client.stop();
scheduler.shutdown();
}
}
@Test void realReconnectReplaysMoreThanOnePageAndContinuesReceiving() throws Exception {
var client = new WebSocketStompClient(new StandardWebSocketClient());
var scheduler = new org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler(); scheduler.initialize();
client.setTaskScheduler(scheduler);
var converter = new org.springframework.messaging.converter.MappingJackson2MessageConverter();
converter.setObjectMapper(mapper); client.setMessageConverter(converter);
var queue = new LinkedBlockingQueue<String>(); StompSession socket = null;
try {
socket = connect(client, hr, "COMPANY", queue);
service.sendMessage(student, request("online", "before disconnect"));
assertNotNull(queue.poll(10, TimeUnit.SECONDS));
long cursor = ((Number) service.replayEvents(conversation, hr, 0, 50).get("nextCursor")).longValue();
socket.disconnect();
org.awaitility.Awaitility.await().atMost(10, TimeUnit.SECONDS)
.until(() -> connectedUsers.getUser(hr.toString()) == null);
for (int i = 0; i < 55; i++) service.sendMessage(student, request("offline-" + i, "offline " + i));
socket = connect(client, hr, "COMPANY", queue);
var ids = new HashSet<Object>(); boolean hasMore;
do {
var page = service.replayEvents(conversation, hr, cursor, 10);
for (var value : (List<?>) page.get("items")) {
var event = (Map<?, ?>) value;
assertTrue(ids.add(event.get("eventId")), "不能重复补拉事件");
}
cursor = ((Number) page.get("nextCursor")).longValue();
hasMore = (Boolean) page.get("hasMore");
} while (hasMore);
assertEquals(55, ids.size());
queue.clear();
service.sendMessage(student, request("after-reconnect", "live again"));
assertEquals("live again", mapper.readTree(Objects.requireNonNull(queue.poll(10, TimeUnit.SECONDS)))
.path("payload").path("content").asText());
} finally {
if (socket != null && socket.isConnected()) socket.disconnect();
client.stop(); scheduler.shutdown();
}
}
private StompSession connect(WebSocketStompClient client, Long user, String role,
BlockingQueue<String> queue) throws Exception {
var headers = new StompHeaders(); headers.set("Authorization", "Bearer " + TestAuthTokens.accessToken(user, role));
var session = client.connectAsync("ws://localhost:" + port + "/api/v1/ws", new org.springframework.web.socket.WebSocketHttpHeaders(), headers,
new StompSessionHandlerAdapter() {}).get(10, TimeUnit.SECONDS);
session.subscribe("/user/queue/chat-events", new StompFrameHandler() {
@Override public Type getPayloadType(StompHeaders h) { return Map.class; }
@Override public void handleFrame(StompHeaders h, Object payload) {
try { queue.add(mapper.writeValueAsString(payload)); }
catch (Exception ex) { throw new IllegalStateException("Invalid event payload", ex); }
}
});
// 内置 broker 不支持 STOMP receipts;通过真实服务端订阅注册作为测试屏障。
org.awaitility.Awaitility.await().atMost(10, TimeUnit.SECONDS).until(() -> {
var connected = connectedUsers.getUser(user.toString());
return connected != null && connected.getSessions().stream()
.anyMatch(s -> !s.getSubscriptions().isEmpty());
});
var ready = queue.poll(10, TimeUnit.SECONDS);
assertNotNull(ready, "必须收到订阅就绪帧再补拉历史");
assertEquals("CHAT_READY", mapper.readTree(ready).path("type").asText());
return session;
}
}

114
mydreamjob-server/src/test/java/com/mydreamjob/messaging/realtime/ChatSocketAuthTest.java

@ -0,0 +1,114 @@
package com.mydreamjob.messaging.realtime;
import com.mydreamjob.auth.*;
import org.junit.jupiter.api.*;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.messaging.simp.stomp.*;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class ChatSocketAuthTest {
private final Instant now = Instant.parse("2026-10-14T08:00:00Z");
private JwtTokenProvider tokens;
private AuthSessionService sessions;
private UserRepository users;
private ChatSocketAuth guard;
private AccessTokenClaims claims;
@BeforeEach void setup() {
tokens = mock(JwtTokenProvider.class); sessions = mock(AuthSessionService.class);
users = mock(UserRepository.class);
guard = new ChatSocketAuth(tokens, sessions, users,
mock(com.mydreamjob.company.CompanyRepository.class), Clock.fixed(now, ZoneOffset.UTC));
claims = new AccessTokenClaims(1L, "session", "token", "STUDENT", now.minusSeconds(1), now.plusSeconds(60));
when(tokens.parseAndValidateAccessToken("valid", now)).thenReturn(claims);
when(sessions.isSessionActive(1L, "session", now)).thenReturn(true);
when(users.findByIdWithRoles(1L)).thenReturn(Optional.of(User.builder().id(1L).build()));
}
private StompHeaderAccessor frame(StompCommand command, Map<String, Object> attrs) {
var frame = StompHeaderAccessor.create(command);
frame.setSessionId("ws-1"); frame.setSessionAttributes(attrs); frame.setLeaveMutable(true);
return frame;
}
private void send(StompHeaderAccessor frame) {
guard.preSend(MessageBuilder.createMessage(new byte[0], frame.getMessageHeaders()), null);
}
private Map<String, Object> connect() {
var attrs = new HashMap<String, Object>(); var frame = frame(StompCommand.CONNECT, attrs);
frame.setNativeHeader("Authorization", "Bearer valid"); send(frame); return attrs;
}
@Test void connectAuthenticatesAndRemovesRawToken() {
var frame = frame(StompCommand.CONNECT, new HashMap<>());
frame.setNativeHeader("Authorization", "Bearer valid"); send(frame);
assertEquals("1", frame.getUser().getName());
assertNull(frame.getFirstNativeHeader("Authorization"));
}
@Test void anonymousConnectRejected() {
assertThrows(Exception.class, () -> send(frame(StompCommand.CONNECT, new HashMap<>())));
}
@Test void revokedSessionRejected() {
when(sessions.isSessionActive(1L, "session", now)).thenReturn(false);
assertThrows(Exception.class, this::connect);
}
@Test void expiredTokenRejected() {
when(tokens.parseAndValidateAccessToken("valid", now)).thenReturn(
new AccessTokenClaims(1L, "session", "token", "STUDENT", now.minusSeconds(60), now));
assertThrows(Exception.class, this::connect);
}
@Test void unassignedIdentityRejected() {
when(users.findByIdWithRoles(1L)).thenReturn(Optional.of(User.builder().role(User.Role.UNASSIGNED).build()));
assertThrows(Exception.class, this::connect);
}
@Test void ownFixedQueueAllowed() {
var frame = frame(StompCommand.SUBSCRIBE, connect());
frame.setDestination("/user/queue/chat-events"); assertDoesNotThrow(() -> send(frame));
}
@Test void otherUserAndBrokerQueuesRejected() {
var attrs = connect();
for (String path : List.of("/user/2/queue/chat-events", "/queue/chat-events", "/topic/all")) {
var frame = frame(StompCommand.SUBSCRIBE, attrs); frame.setDestination(path);
assertThrows(Exception.class, () -> send(frame));
}
}
@Test void socketSendRejected() {
var frame = frame(StompCommand.SEND, connect()); frame.setDestination("/app/chat");
assertThrows(Exception.class, () -> send(frame));
}
@Test void databaseFailureFailsClosed() {
when(users.findByIdWithRoles(1L)).thenThrow(new IllegalStateException("private detail"));
var error = assertThrows(Exception.class, this::connect);
assertEquals("Chat connection rejected", error.getMessage());
}
@Test void repeatedConnectRejected() {
var frame = frame(StompCommand.CONNECT, connect()); frame.setNativeHeader("Authorization", "Bearer valid");
assertThrows(Exception.class, () -> send(frame));
}
@Test void unverifiedCompanyRejected() {
when(users.findByIdWithRoles(1L)).thenReturn(Optional.of(User.builder().id(1L)
.role(User.Role.COMPANY).roles(Set.of(User.Role.COMPANY)).build()));
assertThrows(Exception.class, this::connect);
}
@Test void revokedSessionCannotSubscribeAgain() {
var attrs = connect(); when(sessions.isSessionActive(1L, "session", now)).thenReturn(false);
var frame = frame(StompCommand.SUBSCRIBE, attrs); frame.setDestination("/user/queue/chat-events");
assertThrows(Exception.class, () -> send(frame));
}
@Test void subscriptionFloodRejected() {
var attrs = connect(); var frame = frame(StompCommand.SUBSCRIBE, attrs);
frame.setDestination("/user/queue/chat-events");
for (int i = 0; i < 119; i++) send(frame);
assertThrows(Exception.class, () -> send(frame));
}
@Test void outboundChecksRevocationBeforeDelivery() {
connect();
var headers = org.springframework.messaging.simp.SimpMessageHeaderAccessor.create(
org.springframework.messaging.simp.SimpMessageType.MESSAGE);
headers.setSessionId("ws-1");
var message = MessageBuilder.createMessage(new byte[0], headers.getMessageHeaders());
assertNotNull(guard.outbound().preSend(message, null));
when(sessions.isSessionActive(1L, "session", now)).thenReturn(false);
assertNull(guard.outbound().preSend(message, null));
}
}

43
mydreamjob-server/src/test/java/com/mydreamjob/messaging/realtime/ChatSocketConnectionsTest.java

@ -0,0 +1,43 @@
package com.mydreamjob.messaging.realtime;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.*;
import org.springframework.http.server.*;
import org.springframework.web.socket.*;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class ChatSocketConnectionsTest {
private final Clock clock = Clock.fixed(Instant.parse("2026-10-14T08:00:00Z"), ZoneOffset.UTC);
@Test void urlCredentialsRejected() throws Exception {
var connections = new ChatSocketConnections(mock(ChatSocketAuth.class), clock);
var request = new MockHttpServletRequest("GET", "/ws"); request.setQueryString("token=secret");
var response = new MockHttpServletResponse();
assertFalse(connections.handshakeGuard().beforeHandshake(new ServletServerHttpRequest(request),
new ServletServerHttpResponse(response), mock(WebSocketHandler.class), new HashMap<>()));
assertEquals(400, response.getStatus());
}
@Test void handshakeFloodRejected() throws Exception {
var connections = new ChatSocketConnections(mock(ChatSocketAuth.class), clock);
var guard = connections.handshakeGuard(); var request = new MockHttpServletRequest("GET", "/ws");
var response = new MockHttpServletResponse();
for (int i = 0; i < 40; i++) assertTrue(guard.beforeHandshake(new ServletServerHttpRequest(request),
new ServletServerHttpResponse(response), mock(WebSocketHandler.class), new HashMap<>()));
assertFalse(guard.beforeHandshake(new ServletServerHttpRequest(request),
new ServletServerHttpResponse(response), mock(WebSocketHandler.class), new HashMap<>()));
assertEquals(429, response.getStatus());
}
@Test void unauthenticatedConnectionIsClosedAndReleased() throws Exception {
var connections = new ChatSocketConnections(mock(ChatSocketAuth.class), clock);
var session = mock(WebSocketSession.class); var attrs = new HashMap<String, Object>();
when(session.getId()).thenReturn("ws-1"); when(session.getAttributes()).thenReturn(attrs);
var handler = connections.decorate(mock(WebSocketHandler.class));
handler.afterConnectionEstablished(session);
attrs.put("chat.openedAt", clock.millis() - 20000);
connections.expire(); verify(session).close(CloseStatus.POLICY_VIOLATION);
handler.afterConnectionClosed(session, CloseStatus.POLICY_VIOLATION);
connections.expire(); verify(session, times(1)).close(CloseStatus.POLICY_VIOLATION);
}
}

4
product-docs/新建 文本文档.txt

@ -1,8 +1,8 @@
UI设计系统完整生成提示词|大学生求职决策APP「职来 mydreamjob」V1.3版 UI设计系统完整生成提示词|大学生求职决策APP「轻松聘 mydreamjob」V1.3版
整体任务说明 整体任务说明
任务:基于《职来(mydreamjob)产品开发方案V1.3》完整文档,生成面向Figma/Sketch可用的移动端标准化设计Token全套规范文档,产品定位大学生可信求职决策工具,核心用户:大三/大四应届生、毕业1-2年应届生、3年内年轻职场人、企业HR;产品核心差异化:企业多维度可信评分、自定义权重排序、评分证据链溯源、匹配门槛沟通机制。整体视觉风格专业、清爽、理性商务工具向,拒绝卡通插画、娱乐化渐变、花哨动效;仅输出浅色标准主题,不开发深色模式。 任务:基于《轻松聘(mydreamjob)产品开发方案V1.3》完整文档,生成面向Figma/Sketch可用的移动端标准化设计Token全套规范文档,产品定位大学生可信求职决策工具,核心用户:大三/大四应届生、毕业1-2年应届生、3年内年轻职场人、企业HR;产品核心差异化:企业多维度可信评分、自定义权重排序、评分证据链溯源、匹配门槛沟通机制。整体视觉风格专业、清爽、理性商务工具向,拒绝卡通插画、娱乐化渐变、花哨动效;仅输出浅色标准主题,不开发深色模式。
画板基准尺寸:390pt × 844pt(iPhone15标准移动端),全局统一4pt网格布局,所有可点击交互触摸热区最小48×48dp。输出仅为规范文档,不生成页面效果图,全页面、组件、小组件、图表、弹窗、空状态、骨架屏严格遵循本套Token。 画板基准尺寸:390pt × 844pt(iPhone15标准移动端),全局统一4pt网格布局,所有可点击交互触摸热区最小48×48dp。输出仅为规范文档,不生成页面效果图,全页面、组件、小组件、图表、弹窗、空状态、骨架屏严格遵循本套Token。
一、全局色彩规范(固定HEX,贴合产品评分分层、求职风险、匹配度业务逻辑) 一、全局色彩规范(固定HEX,贴合产品评分分层、求职风险、匹配度业务逻辑)

4
product-docs/职来AI求职招聘平台产品与开发实施方案-v3.1.md

@ -1,4 +1,4 @@
# 职来 AI 求职招聘平台产品提案(v3.1 修订版) # 轻松聘 AI 求职招聘平台产品提案(v3.1 修订版)
**文档密级:** 机密 **文档密级:** 机密
**文档版本:** v3.1 **文档版本:** v3.1
@ -36,7 +36,7 @@
## 执行摘要 ## 执行摘要
"职来"是一款面向大学生、应届生及青年职场人的**可信求职招聘平台移动应用**。通过"AI 驱动的智能求职助手"与"经平台核验的可信企业岗位"相结合,解决求职市场三大痛点:**信息真实性存疑、求职效率低下、沟通成本高昂**。 "轻松聘"是一款面向大学生、应届生及青年职场人的**可信求职招聘平台移动应用**。通过"AI 驱动的智能求职助手"与"经平台核验的可信企业岗位"相结合,解决求职市场三大痛点:**信息真实性存疑、求职效率低下、沟通成本高昂**。
**核心价值主张:** **核心价值主张:**

4
product-docs/职来AI求职招聘平台产品与开发实施方案.md

@ -1,4 +1,4 @@
# 职来 AI 求职招聘平台产品提案 # 轻松聘 AI 求职招聘平台产品提案
**文档密级:** 机密 **文档密级:** 机密
**文档版本:** v3.0 **文档版本:** v3.0
@ -12,7 +12,7 @@
## 执行摘要 ## 执行摘要
"职来"是一款面向大学生、应届生及青年职场人的**可信求职招聘平台移动应用**。本项目旨在通过"AI 驱动的智能求职助手"与"经平台核验的可信企业岗位"相结合,解决当前求职市场面临的三大核心痛点:**信息真实性存疑、求职效率低下、沟通成本高昂**。 "轻松聘"是一款面向大学生、应届生及青年职场人的**可信求职招聘平台移动应用**。本项目旨在通过"AI 驱动的智能求职助手"与"经平台核验的可信企业岗位"相结合,解决当前求职市场面临的三大核心痛点:**信息真实性存疑、求职效率低下、沟通成本高昂**。
**核心价值主张:** **核心价值主张:**
- **可信**:企业及岗位经双重审核,杜绝虚假信息 - **可信**:企业及岗位经双重审核,杜绝虚假信息

6
product-docs/职来UI设计系统规范V1.0.md

@ -1,4 +1,4 @@
# 职来(mydreamjob)移动端UI设计系统规范 V1.0 # 轻松聘(mydreamjob)移动端UI设计系统规范 V1.0
> **产品定位**:大学生可信求职决策工具 > **产品定位**:大学生可信求职决策工具
> **核心用户**:大三/大四应届生、毕业1-2年应届生、3年内年轻职场人、企业HR > **核心用户**:大三/大四应届生、毕业1-2年应届生、3年内年轻职场人、企业HR
@ -721,5 +721,5 @@
--- ---
> **文档状态**:已完成 > **文档状态**:已完成
> **适用范围**:职来(mydreamjob)移动端全平台 > **适用范围**:轻松聘(mydreamjob)移动端全平台
> **维护团队**:产品设计组 > **维护团队**:产品设计组

6
product-docs/职来mydreamjob_架构图.puml

@ -1,10 +1,10 @@
@startuml 职来mydreamjob_产品开发方案_架构图 @startuml 轻松聘mydreamjob_产品开发方案_架构图
' ========================= ' =========================
' 职来(mydreamjob)产品开发方案 — 架构图 ' 轻松聘(mydreamjob)产品开发方案 — 架构图
' ========================= ' =========================
title 职来(mydreamjob)产品开发方案 - 总体架构 title 轻松聘(mydreamjob)产品开发方案 - 总体架构
skinparam rectangle { skinparam rectangle {
BackgroundColor<<client>> #E3F2FD BackgroundColor<<client>> #E3F2FD

Loading…
Cancel
Save