Browse Source

feat(chat): 增加实时通信并统一轻松聘品牌

fix/mysql57-module-migrations-20261009
Zhang Shun 2 weeks ago
parent
commit
6fff2a2217
  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. 2
      docs/frontend-integration.md
  7. 2
      docs/job-review-api.md
  8. 2
      docs/match-api.md
  9. 2
      docs/offer-advance-api.md
  10. 2
      docs/offer-api.md
  11. 2
      docs/openapi-rc.json
  12. 2
      docs/resume-api.md
  13. 2
      docs/review-moderation-api.md
  14. 4
      mydreamjob-server/README.md
  15. 6
      mydreamjob-server/pom.xml
  16. 10
      mydreamjob-server/src/main/java/com/mydreamjob/common/ProductBrand.java
  17. 4
      mydreamjob-server/src/main/java/com/mydreamjob/config/OpenApiSecurityConfig.java
  18. 2
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  19. 3
      mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java
  20. 5
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationRepository.java
  21. 6
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/Message.java
  22. 4
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java
  23. 2
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageViewMapper.java
  24. 9
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingController.java
  25. 47
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java
  26. 2
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/MessageResponse.java
  27. 4
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/SendMessageRequest.java
  28. 19
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatEvent.java
  29. 10
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatEventRepository.java
  30. 90
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatEventService.java
  31. 112
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatSocketAuth.java
  32. 68
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatSocketConfig.java
  33. 82
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatSocketConnections.java
  34. 18
      mydreamjob-server/src/main/resources/db/migration/V55__chat_realtime_events.sql
  35. 23
      mydreamjob-server/src/test/java/com/mydreamjob/common/ProductBrandTest.java
  36. 1
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallServiceTest.java
  37. 1
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingServiceTest.java
  38. 202
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/realtime/ChatRealtimeIntegrationTest.java
  39. 114
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/realtime/ChatSocketAuthTest.java
  40. 43
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/realtime/ChatSocketConnectionsTest.java
  41. 4
      product-docs/新建 文本文档.txt
  42. 4
      product-docs/职来AI求职招聘平台产品与开发实施方案-v3.1.md
  43. 4
      product-docs/职来AI求职招聘平台产品与开发实施方案.md
  44. 6
      product-docs/职来UI设计系统规范V1.0.md
  45. 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`
青核项目不属于本仓库,禁止将其源代码提交到职来仓库。
青核项目不属于本仓库,禁止将其源代码提交到轻松聘仓库。

2
backend-infra/README.md

@ -1,4 +1,4 @@
# 职来本地后端依赖
# 轻松聘本地后端依赖
此目录仅用于本地开发的 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 基线

4
docs/backend-development-roadmap.md

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

2
docs/frontend-integration.md

@ -1,4 +1,4 @@
# 职来后端前端联调说明(RC)
# 轻松聘后端前端联调说明(RC)
## 1. API Base URL

2
docs/job-review-api.md

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

2
docs/match-api.md

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

2
docs/offer-advance-api.md

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

2
docs/offer-api.md

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

2
docs/openapi-rc.json

@ -1,7 +1,7 @@
{
"openapi": "3.1.0",
"info": {
"title": "职来 API",
"title": "轻松聘 API",
"version": "v1"
},
"servers": [

2
docs/resume-api.md

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

2
docs/review-moderation-api.md

@ -1,6 +1,6 @@
# 评价审核与防刷基础模块接口说明
> 职来 (mydreamjob) · 评价审核与防刷基础模块
> 轻松聘 (mydreamjob) · 评价审核与防刷基础模块
> 基础路径:`/api/v1` · 审核管理接口仅 ADMIN;评价公开接口保持原路径
## 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>
<version>0.0.1-SNAPSHOT</version>
<name>mydreamjob-server</name>
<description>职来 (mydreamjob) Backend - Week 1 Baseline</description>
<description>轻松聘 (mydreamjob) Backend - Week 1 Baseline</description>
<properties>
<java.version>17</java.version>
@ -25,6 +25,10 @@
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- 阿里云国内短信官方 SDK:凭据仅从服务器环境注入 -->
<dependency>
<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() {
return new OpenAPI()
.info(new Info()
.title("职来 API")
.title(com.mydreamjob.common.ProductBrand.API_TITLE)
.version("v1"));
}
}
}

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

@ -83,6 +83,8 @@ public class SecurityConfig {
// 健康检查 API
.requestMatchers("/health/**").permitAll()
.requestMatchers("/health").permitAll()
// 仅放行握手;STOMP CONNECT 和订阅由实时通道独立鉴权。
.requestMatchers(HttpMethod.GET, "/ws").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) {
Map<String, Object> info = Map.of(
"name", "mydreamjob",
"displayName", com.mydreamjob.common.ProductBrand.DISPLAY_NAME,
"version", "0.1.0",
"description", "职来 - 大学生求职服务平台 API",
"description", com.mydreamjob.common.ProductBrand.API_DESCRIPTION,
"endpoints", Map.of(
"health", "GET /api/v1/health",
"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
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);
}

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

@ -32,6 +32,12 @@ public class Message {
@Column(nullable = false, columnDefinition = "text")
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)
@Builder.Default
private String status = "SENT";

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

@ -16,6 +16,10 @@ import java.util.List;
@Repository
public interface MessageRepository extends JpaRepository<Message, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
java.util.Optional<Message> findByConversationIdAndSenderUserIdAndClientMessageId(
Long conversationId, Long senderUserId, String clientMessageId);
Page<Message> findByConversationIdOrderByCreatedAtDesc(Long conversationId, Pageable pageable);
List<Message> findByConversationIdInOrderByConversationIdAscCreatedAtDesc(List<Long> conversationIds);

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

@ -16,6 +16,8 @@ public class MessageViewMapper {
boolean recalled = "RECALLED".equals(message.getStatus());
MessageResponse.MessageResponseBuilder builder = MessageResponse.builder()
.id(message.getId())
.clientMessageId(message.getClientMessageId())
.version(message.getVersion())
.conversationId(message.getConversationId())
.content(recalled ? RECALLED_PLACEHOLDER : message.getContent())
.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)));
}
@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 = "将当前会话所有消息标记为已读")
@PostMapping("/{id}/read")
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 MessageViewMapper messageViewMapper;
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_CONTENT_LENGTH = 2000;
@ -190,6 +191,7 @@ public class MessagingService {
requireVerifiedIfCompany(userId);
requireConversation(req.getConversationId());
assertMember(req.getConversationId(), userId);
conversationRepository.findLockedById(req.getConversationId());
if (req.getContent().length() > MAX_CONTENT_LENGTH) throw new BusinessException(ErrorCode.BAD_REQUEST);
// V33 屏蔽控制:会话任一成员存在任一方向屏蔽 → 禁止发送(对外统一 403/2002)
List<ConversationMember> members = memberRepository.findByConversationId(req.getConversationId());
@ -198,8 +200,22 @@ public class MessagingService {
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()
.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 -> {
c.setUpdatedAt(msg.getCreatedAt()); conversationRepository.save(c);
});
@ -245,6 +261,7 @@ public class MessagingService {
requireVerifiedIfCompany(userId);
requireConversation(conversationId);
assertMember(conversationId, userId);
conversationRepository.findLockedById(conversationId);
List<Message> msgs = messageRepository
.findByConversationIdOrderByCreatedAtDesc(conversationId, PageRequest.of(0, 500)).getContent();
List<Long> msgIds = msgs.stream()
@ -255,8 +272,10 @@ public class MessagingService {
Set<Long> existingIds = new HashSet<>();
for (MessageRead r : existing) existingIds.add(r.getMessageId());
for (Long mid : msgIds) {
if (!existingIds.contains(mid)) readRepository.save(
MessageRead.builder().messageId(mid).readerUserId(userId).build());
if (!existingIds.contains(mid)) {
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();
}
/** 游标只来自补拉响应,不能直接用实时事件 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 由入口统一捕获,便于边界测试,也避免同一请求混用多个时间。
@ -281,6 +309,7 @@ public class MessagingService {
requireVerifiedIfCompany(currentUserId);
Conversation conversation = requireConversation(conversationId);
assertMember(conversationId, currentUserId);
conversationRepository.findLockedById(conversationId);
User currentUser = userRepository.findById(currentUserId).orElse(null);
if (currentUser != null && hasRole(currentUser, User.Role.ADMIN)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
@ -318,6 +347,7 @@ public class MessagingService {
recallAuditDetails(messageId, conversationId, conversation.getType(), auditContext),
recallAuditContext(auditContext));
Message recalled = messageRepository.findById(messageId).orElse(message);
chatEvents.record("MESSAGE_RECALLED", conversationId, currentUserId, messageId);
return toMessageResponse(recalled, currentUserId, conversation);
}
@ -398,6 +428,8 @@ public class MessagingService {
Map<String, Object> v = new HashMap<>();
if (response == null) return v;
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());
if (response.getSenderUserId() != null) v.put("senderUserId", response.getSenderUserId());
if (response.getSenderScope() != null) v.put("senderScope", response.getSenderScope());
@ -409,6 +441,15 @@ public class MessagingService {
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) {
return conversationId != null && conversationRepository.findById(conversationId)
.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 {
private Long id;
private String clientMessageId;
private Long version;
private Long conversationId;
/** 仅 DIRECT 会话返回;TALENT_INVITATION 使用 senderScope。 */
private Long senderUserId;

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

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

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());
}
}

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 =
new com.fasterxml.jackson.databind.ObjectMapper();
@InjectMocks private MessagingService messagingService;
@Mock private com.mydreamjob.messaging.realtime.ChatEventService chatEvents;
private static final Long USER = 7L;
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 =
new com.fasterxml.jackson.databind.ObjectMapper();
@InjectMocks private MessagingService messagingService;
@Mock private com.mydreamjob.messaging.realtime.ChatEventService chatEvents;
private static final Long USER_A = 100L;
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。
一、全局色彩规范(固定HEX,贴合产品评分分层、求职风险、匹配度业务逻辑)

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

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

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

@ -1,4 +1,4 @@
# 职来 AI 求职招聘平台产品提案
# 轻松聘 AI 求职招聘平台产品提案
**文档密级:** 机密
**文档版本:** 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
@ -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 {
BackgroundColor<<client>> #E3F2FD

Loading…
Cancel
Save