From 6fff2a22176d85e50cbcea8c76f78813beea80f0 Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Wed, 14 Oct 2026 17:25:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E5=A2=9E=E5=8A=A0=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E9=80=9A=E4=BF=A1=E5=B9=B6=E7=BB=9F=E4=B8=80=E8=BD=BB?= =?UTF-8?q?=E6=9D=BE=E8=81=98=E5=93=81=E7=89=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- backend-infra/README.md | 2 +- ...nginx-chat-websocket.location.conf.example | 18 ++ docs/ERD.md | 2 +- docs/backend-development-roadmap.md | 4 +- docs/frontend-integration.md | 2 +- docs/job-review-api.md | 2 +- docs/match-api.md | 2 +- docs/offer-advance-api.md | 2 +- docs/offer-api.md | 2 +- docs/openapi-rc.json | 2 +- docs/resume-api.md | 2 +- docs/review-moderation-api.md | 2 +- mydreamjob-server/README.md | 4 +- mydreamjob-server/pom.xml | 6 +- .../com/mydreamjob/common/ProductBrand.java | 10 + .../config/OpenApiSecurityConfig.java | 4 +- .../com/mydreamjob/config/SecurityConfig.java | 2 + .../mydreamjob/controller/ApiController.java | 3 +- .../messaging/ConversationRepository.java | 5 + .../com/mydreamjob/messaging/Message.java | 6 + .../messaging/MessageRepository.java | 4 + .../messaging/MessageViewMapper.java | 2 + .../messaging/MessagingController.java | 9 + .../messaging/MessagingService.java | 47 +++- .../messaging/dto/MessageResponse.java | 2 + .../messaging/dto/SendMessageRequest.java | 4 + .../messaging/realtime/ChatEvent.java | 19 ++ .../realtime/ChatEventRepository.java | 10 + .../messaging/realtime/ChatEventService.java | 90 ++++++++ .../messaging/realtime/ChatSocketAuth.java | 112 ++++++++++ .../messaging/realtime/ChatSocketConfig.java | 68 ++++++ .../realtime/ChatSocketConnections.java | 82 +++++++ .../migration/V55__chat_realtime_events.sql | 18 ++ .../mydreamjob/common/ProductBrandTest.java | 23 ++ .../messaging/MessageRecallServiceTest.java | 1 + .../messaging/MessagingServiceTest.java | 1 + .../realtime/ChatRealtimeIntegrationTest.java | 202 ++++++++++++++++++ .../realtime/ChatSocketAuthTest.java | 114 ++++++++++ .../realtime/ChatSocketConnectionsTest.java | 43 ++++ product-docs/新建 文本文档.txt | 4 +- ...˜平台产品与开发实施方案-v3.1.md | 4 +- ...招聘平台产品与开发实施方案.md | 4 +- .../职来UI设计系统规范V1.0.md | 6 +- product-docs/职来mydreamjob_架构图.puml | 6 +- 45 files changed, 927 insertions(+), 36 deletions(-) create mode 100644 backend-infra/nginx-chat-websocket.location.conf.example create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/common/ProductBrand.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatEvent.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatEventRepository.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatEventService.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatSocketAuth.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatSocketConfig.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatSocketConnections.java create mode 100644 mydreamjob-server/src/main/resources/db/migration/V55__chat_realtime_events.sql create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/common/ProductBrandTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/messaging/realtime/ChatRealtimeIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/messaging/realtime/ChatSocketAuthTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/messaging/realtime/ChatSocketConnectionsTest.java diff --git a/README.md b/README.md index 1dc812c..26a8a8d 100644 --- a/README.md +++ b/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` -青核项目不属于本仓库,禁止将其源代码提交到职来仓库。 +青核项目不属于本仓库,禁止将其源代码提交到轻松聘仓库。 diff --git a/backend-infra/README.md b/backend-infra/README.md index c94f493..41c400e 100644 --- a/backend-infra/README.md +++ b/backend-infra/README.md @@ -1,4 +1,4 @@ -# 职来本地后端依赖 +# 轻松聘本地后端依赖 此目录仅用于本地开发的 MySQL、Redis 和 RabbitMQ,不会修改电脑中已运行的 MySQL 8.0 服务。 diff --git a/backend-infra/nginx-chat-websocket.location.conf.example b/backend-infra/nginx-chat-websocket.location.conf.example new file mode 100644 index 0000000..5ddf0c0 --- /dev/null +++ b/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 的限流,并核对后端限流配置。 diff --git a/docs/ERD.md b/docs/ERD.md index 00c4ae0..9cbe12e 100644 --- a/docs/ERD.md +++ b/docs/ERD.md @@ -1,4 +1,4 @@ -# 职来 (mydreamjob) 数据库 ERD 设计 +# 轻松聘 (mydreamjob) 数据库 ERD 设计 > 版本: v0.1 | 更新日期: 2026-08-03 | 状态: Week 1 基线 diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index c4129ba..f728c39 100644 --- a/docs/backend-development-roadmap.md +++ b/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` 仅保留为「路线图源文件」,不移动、不删除任何文档。 diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index be172c1..469674d 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -1,4 +1,4 @@ -# 职来后端前端联调说明(RC) +# 轻松聘后端前端联调说明(RC) ## 1. API Base URL diff --git a/docs/job-review-api.md b/docs/job-review-api.md index 2e6dc54..7fc9343 100644 --- a/docs/job-review-api.md +++ b/docs/job-review-api.md @@ -1,6 +1,6 @@ # 岗位评价与八维评分卡接口说明 -> 职来 (mydreamjob) · 岗位评价 + 八维评分卡基础模块 +> 轻松聘 (mydreamjob) · 岗位评价 + 八维评分卡基础模块 > 基础路径:`/api/v1` · 评价创建/修改/删除需登录;列表/详情/汇总/对比公开 ## 1. 概述 diff --git a/docs/match-api.md b/docs/match-api.md index f567fcb..bcce205 100644 --- a/docs/match-api.md +++ b/docs/match-api.md @@ -1,6 +1,6 @@ # 双向匹配基础模块接口说明 -> 职来 (mydreamjob) · 双向匹配基础流程(规则阈值版)与匹配后沟通解锁 +> 轻松聘 (mydreamjob) · 双向匹配基础流程(规则阈值版)与匹配后沟通解锁 > 基础路径:`/api/v1` · 全部接口需 `Authorization: Bearer ` ## 1. 概述 diff --git a/docs/offer-advance-api.md b/docs/offer-advance-api.md index 1d38606..40340d0 100644 --- a/docs/offer-advance-api.md +++ b/docs/offer-advance-api.md @@ -1,6 +1,6 @@ # Offer 进阶模块接口说明 -> 职来 (mydreamjob) · Offer 进阶能力 +> 轻松聘 (mydreamjob) · Offer 进阶能力 > 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer ` > 在 [Offer 管理基础模块](offer-api.md) 之上增量开发,未修改任何已验收接口的公共签名。 diff --git a/docs/offer-api.md b/docs/offer-api.md index 4ef02bf..4139930 100644 --- a/docs/offer-api.md +++ b/docs/offer-api.md @@ -1,6 +1,6 @@ # Offer 管理模块接口说明 -> 职来 (mydreamjob) · Offer 管理基础模块 +> 轻松聘 (mydreamjob) · Offer 管理基础模块 > 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer ` ## 1. 概述 diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index 8954181..eb34d1d 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -1,7 +1,7 @@ { "openapi": "3.1.0", "info": { - "title": "职来 API", + "title": "轻松聘 API", "version": "v1" }, "servers": [ diff --git a/docs/resume-api.md b/docs/resume-api.md index d9d3a5f..0313839 100644 --- a/docs/resume-api.md +++ b/docs/resume-api.md @@ -1,6 +1,6 @@ # 简历版本管理与导出模块接口说明 -> 职来 (mydreamjob) · 简历版本管理与导出基础模块 +> 轻松聘 (mydreamjob) · 简历版本管理与导出基础模块 > 基础路径:`/api/v1` · 除明确标注外全部接口需 `Authorization: Bearer ` ## 1. 概述 diff --git a/docs/review-moderation-api.md b/docs/review-moderation-api.md index af1502a..8b2e703 100644 --- a/docs/review-moderation-api.md +++ b/docs/review-moderation-api.md @@ -1,6 +1,6 @@ # 评价审核与防刷基础模块接口说明 -> 职来 (mydreamjob) · 评价审核与防刷基础模块 +> 轻松聘 (mydreamjob) · 评价审核与防刷基础模块 > 基础路径:`/api/v1` · 审核管理接口仅 ADMIN;评价公开接口保持原路径 ## 1. 概述 diff --git a/mydreamjob-server/README.md b/mydreamjob-server/README.md index 8cd960e..4a225cd 100644 --- a/mydreamjob-server/README.md +++ b/mydreamjob-server/README.md @@ -1,6 +1,6 @@ -# 职来 (mydreamjob) 后端服务 +# 轻松聘 (mydreamjob) 后端服务 -> Spring Boot 3.2.5 + Java 17 | 职来大学生求职服务平台后端基线 +> Spring Boot 3.2.5 + Java 17 | 轻松聘大学生求职服务平台后端基线 ## 项目结构 diff --git a/mydreamjob-server/pom.xml b/mydreamjob-server/pom.xml index 0fb765f..9e79061 100644 --- a/mydreamjob-server/pom.xml +++ b/mydreamjob-server/pom.xml @@ -15,7 +15,7 @@ mydreamjob-server 0.0.1-SNAPSHOT mydreamjob-server - 职来 (mydreamjob) Backend - Week 1 Baseline + 轻松聘 (mydreamjob) Backend - Week 1 Baseline 17 @@ -25,6 +25,10 @@ + + org.springframework.boot + spring-boot-starter-websocket + com.aliyun diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/ProductBrand.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/ProductBrand.java new file mode 100644 index 0000000..78a501f --- /dev/null +++ b/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() { } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/OpenApiSecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/OpenApiSecurityConfig.java index 7cfefb0..4daade7 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/OpenApiSecurityConfig.java +++ b/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")); } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index c9d27ff..df49efc 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/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() // 认证相关 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java b/mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java index 3a75f3c..5e0ed3e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java @@ -31,8 +31,9 @@ public class ApiController { public ResponseEntity>> index(HttpServletRequest request) { Map 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", diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationRepository.java index 7950d42..4f901cd 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationRepository.java @@ -9,5 +9,10 @@ import java.util.Optional; @Repository public interface ConversationRepository extends JpaRepository { + /** 串行化同会话写入,保证幂等检查以及事件游标的提交顺序。 */ + @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 findLockedById(@org.springframework.data.repository.query.Param("id") Long id); + List findByCreatedByOrderByUpdatedAtDesc(Long userId); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/Message.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/Message.java index e3772e0..9eb0c4f 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/Message.java +++ b/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"; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java index 814d29d..9690a6d 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java @@ -16,6 +16,10 @@ import java.util.List; @Repository public interface MessageRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + java.util.Optional findByConversationIdAndSenderUserIdAndClientMessageId( + Long conversationId, Long senderUserId, String clientMessageId); + Page findByConversationIdOrderByCreatedAtDesc(Long conversationId, Pageable pageable); List findByConversationIdInOrderByConversationIdAscCreatedAtDesc(List conversationIds); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageViewMapper.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageViewMapper.java index 7889d73..74f10d8 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageViewMapper.java +++ b/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()) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingController.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingController.java index 9392101..ab7a5a6 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingController.java +++ b/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>> 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>> markRead( diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java index e632e96..2656a45 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java +++ b/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 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 msgs = messageRepository .findByConversationIdOrderByCreatedAtDesc(conversationId, PageRequest.of(0, 500)).getContent(); List msgIds = msgs.stream() @@ -255,8 +272,10 @@ public class MessagingService { Set 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 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 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); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/MessageResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/MessageResponse.java index 992f5ca..b33d26f 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/MessageResponse.java +++ b/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; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/SendMessageRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/SendMessageRequest.java index 22bdcb7..3353488 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/SendMessageRequest.java +++ b/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; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatEvent.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatEvent.java new file mode 100644 index 0000000..40db06a --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatEventRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatEventRepository.java new file mode 100644 index 0000000..4b75d0d --- /dev/null +++ b/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 { + List findByConversationIdAndRecipientUserIdAndIdGreaterThanOrderByIdAsc( + Long conversationId, Long recipientUserId, Long cursor, Pageable pageable); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatEventService.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatEventService.java new file mode 100644 index 0000000..4a7a635 --- /dev/null +++ b/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 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 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); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatSocketAuth.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatSocketAuth.java new file mode 100644 index 0000000..382b6c0 --- /dev/null +++ b/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 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; } + } + }; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatSocketConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatSocketConfig.java new file mode 100644 index 0000000..25b5140 --- /dev/null +++ b/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 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); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatSocketConnections.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/realtime/ChatSocketConnections.java new file mode 100644 index 0000000..1f91a87 --- /dev/null +++ b/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 sessions = new ConcurrentHashMap<>(); + private final java.util.Map 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 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(); + 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) { } + } + }); + } +} diff --git a/mydreamjob-server/src/main/resources/db/migration/V55__chat_realtime_events.sql b/mydreamjob-server/src/main/resources/db/migration/V55__chat_realtime_events.sql new file mode 100644 index 0000000..2123ae2 --- /dev/null +++ b/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; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/ProductBrandTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/ProductBrandTest.java new file mode 100644 index 0000000..3f5ccc3 --- /dev/null +++ b/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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallServiceTest.java index e6fdead..90a783e 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallServiceTest.java +++ b/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; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingServiceTest.java index 6d6c3b3..09aec1a 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingServiceTest.java +++ b/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; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/realtime/ChatRealtimeIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/realtime/ChatRealtimeIntegrationTest.java new file mode 100644 index 0000000..4e73862 --- /dev/null +++ b/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 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 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(); var studentQueue = new LinkedBlockingQueue(); + 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(); 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(); 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 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; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/realtime/ChatSocketAuthTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/realtime/ChatSocketAuthTest.java new file mode 100644 index 0000000..77144dd --- /dev/null +++ b/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 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 connect() { + var attrs = new HashMap(); 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)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/realtime/ChatSocketConnectionsTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/realtime/ChatSocketConnectionsTest.java new file mode 100644 index 0000000..5f880d5 --- /dev/null +++ b/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(); + 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); + } +} diff --git a/product-docs/新建 文本文档.txt b/product-docs/新建 文本文档.txt index d7cf68f..73852d2 100644 --- a/product-docs/新建 文本文档.txt +++ b/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,贴合产品评分分层、求职风险、匹配度业务逻辑) diff --git a/product-docs/职来AI求职招聘平台产品与开发实施方案-v3.1.md b/product-docs/职来AI求职招聘平台产品与开发实施方案-v3.1.md index af1a16d..7020420 100644 --- a/product-docs/职来AI求职招聘平台产品与开发实施方案-v3.1.md +++ b/product-docs/职来AI求职招聘平台产品与开发实施方案-v3.1.md @@ -1,4 +1,4 @@ -# 职来 AI 求职招聘平台产品提案(v3.1 修订版) +# 轻松聘 AI 求职招聘平台产品提案(v3.1 修订版) **文档密级:** 机密 **文档版本:** v3.1 @@ -36,7 +36,7 @@ ## 执行摘要 -"职来"是一款面向大学生、应届生及青年职场人的**可信求职招聘平台移动应用**。通过"AI 驱动的智能求职助手"与"经平台核验的可信企业岗位"相结合,解决求职市场三大痛点:**信息真实性存疑、求职效率低下、沟通成本高昂**。 +"轻松聘"是一款面向大学生、应届生及青年职场人的**可信求职招聘平台移动应用**。通过"AI 驱动的智能求职助手"与"经平台核验的可信企业岗位"相结合,解决求职市场三大痛点:**信息真实性存疑、求职效率低下、沟通成本高昂**。 **核心价值主张:** diff --git a/product-docs/职来AI求职招聘平台产品与开发实施方案.md b/product-docs/职来AI求职招聘平台产品与开发实施方案.md index 5744ead..dbec402 100644 --- a/product-docs/职来AI求职招聘平台产品与开发实施方案.md +++ b/product-docs/职来AI求职招聘平台产品与开发实施方案.md @@ -1,4 +1,4 @@ -# 职来 AI 求职招聘平台产品提案 +# 轻松聘 AI 求职招聘平台产品提案 **文档密级:** 机密 **文档版本:** v3.0 @@ -12,7 +12,7 @@ ## 执行摘要 -"职来"是一款面向大学生、应届生及青年职场人的**可信求职招聘平台移动应用**。本项目旨在通过"AI 驱动的智能求职助手"与"经平台核验的可信企业岗位"相结合,解决当前求职市场面临的三大核心痛点:**信息真实性存疑、求职效率低下、沟通成本高昂**。 +"轻松聘"是一款面向大学生、应届生及青年职场人的**可信求职招聘平台移动应用**。本项目旨在通过"AI 驱动的智能求职助手"与"经平台核验的可信企业岗位"相结合,解决当前求职市场面临的三大核心痛点:**信息真实性存疑、求职效率低下、沟通成本高昂**。 **核心价值主张:** - **可信**:企业及岗位经双重审核,杜绝虚假信息 diff --git a/product-docs/职来UI设计系统规范V1.0.md b/product-docs/职来UI设计系统规范V1.0.md index 9e0d424..0525d5a 100644 --- a/product-docs/职来UI设计系统规范V1.0.md +++ b/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)移动端全平台 -> **维护团队**:产品设计组 \ No newline at end of file +> **适用范围**:轻松聘(mydreamjob)移动端全平台 +> **维护团队**:产品设计组 diff --git a/product-docs/职来mydreamjob_架构图.puml b/product-docs/职来mydreamjob_架构图.puml index 62c23c7..338b533 100644 --- a/product-docs/职来mydreamjob_架构图.puml +++ b/product-docs/职来mydreamjob_架构图.puml @@ -1,10 +1,10 @@ -@startuml 职来mydreamjob_产品开发方案_架构图 +@startuml 轻松聘mydreamjob_产品开发方案_架构图 ' ========================= -' 职来(mydreamjob)产品开发方案 — 架构图 +' 轻松聘(mydreamjob)产品开发方案 — 架构图 ' ========================= -title 职来(mydreamjob)产品开发方案 - 总体架构 +title 轻松聘(mydreamjob)产品开发方案 - 总体架构 skinparam rectangle { BackgroundColor<> #E3F2FD