From 1cbadc3e18a786ef642b0d329adde6b173c7b2ac Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Fri, 16 Oct 2026 09:22:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(messaging):=20=E5=AE=8C=E5=96=84=E7=AE=80?= =?UTF-8?q?=E5=8E=86=E5=BF=AB=E7=85=A7=E6=B6=88=E6=81=AF=E4=B8=8E=E6=8A=95?= =?UTF-8?q?=E9=80=92=E6=B5=81=E7=A8=8B=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/openapi-rc.json | 171 +++++++++++++++ docs/verify_openapi.py | 9 +- .../application/ApplicationStatus.java | 4 +- .../ApplicationMatchRecordRepository.java | 4 +- .../common/exception/ErrorCode.java | 1 + .../exception/ErrorHttpStatusRegistry.java | 1 + .../AccountDeletionCompletionWorker.java | 2 + .../interview/InterviewService.java | 14 ++ .../messaging/ChatResumeController.java | 52 +++++ .../messaging/ChatResumeLifecycle.java | 26 +++ .../messaging/ChatResumeService.java | 152 +++++++++++++ .../messaging/ChatResumeSnapshot.java | 14 ++ .../ChatResumeSnapshotRepository.java | 12 + .../messaging/ConversationSummaryService.java | 60 +++++ .../com/mydreamjob/messaging/Message.java | 13 ++ .../messaging/MessageRepository.java | 6 + .../messaging/MessageViewMapper.java | 13 ++ .../messaging/MessagingService.java | 15 ++ .../messaging/dto/MessageResponse.java | 6 + .../com/mydreamjob/offer/OfferService.java | 6 + .../mydreamjob/profile/ProfileService.java | 6 +- .../profile/StudentProfileRepository.java | 4 + .../migration/V56__chat_resume_snapshots.sql | 16 ++ .../CompanyResumeDownloadIntegrationTest.java | 4 +- .../UnifiedIdentityJarAcceptanceProbe.java | 8 +- .../UnifiedRegistrationIntegrationTest.java | 2 +- ...balExceptionHandlerStatusContractTest.java | 1 + .../r01b/ErrorHttpStatusRegistryTest.java | 8 +- ...obalExceptionHandlerMappingParityTest.java | 6 +- .../R01BIndependentHttpMappingParityTest.java | 15 +- ...ependentCrossDatabaseVerificationTest.java | 12 +- .../LxqModulesMySql57IntegrationTest.java | 4 +- .../LxqModulesMySqlIntegrationTest.java | 4 +- .../interview/InterviewServiceTest.java | 2 + .../ChatWorkflowIntegrationTest.java | 207 ++++++++++++++++++ ...MessageListQueryBudgetIntegrationTest.java | 4 +- .../messaging/MessagingServiceTest.java | 2 + .../mydreamjob/offer/OfferServiceTest.java | 2 + .../mydreamjob/profile/ResumeServiceTest.java | 14 +- 39 files changed, 857 insertions(+), 45 deletions(-) create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeController.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeLifecycle.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeService.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeSnapshot.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeSnapshotRepository.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationSummaryService.java create mode 100644 mydreamjob-server/src/main/resources/db/migration/V56__chat_resume_snapshots.sql create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index d8cb87f..3c8c59e 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -201,6 +201,132 @@ } ], "paths": { + "/conversations/{id}/resume-messages": { + "post": { + "tags": [ + "消息" + ], + "summary": "发送固定版本简历卡片", + "description": "仅投递本人,双方为会话成员且 MATCHED;隐私规则仍生效。clientMessageId 必填,重试复用原值", + "operationId": "send", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMessageResponse" + } + } + } + } + } + } + }, + "/conversations/{conversationId}/messages/{messageId}/resume": { + "get": { + "tags": [ + "消息" + ], + "summary": "授权下载简历快照", + "description": "每次校验成员、投递归属、认证、匹配、隐私、屏蔽及撤回状态,不返回公开文件地址", + "operationId": "download_1", + "parameters": [ + { + "name": "conversationId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "messageId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "byte" + } + } + } + } + } + }, + "delete": { + "tags": [ + "消息" + ], + "summary": "撤销简历卡片授权", + "description": "仅原发送者,幂等,销毁快照字节并推送 RESUME_REVOKED;不受两分钟文本撤回窗口限制", + "operationId": "revoke", + "parameters": [ + { + "name": "conversationId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "messageId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMessageResponse" + } + } + } + } + } + } + }, "/auth/identity/select": { "post": { "tags": [ @@ -11652,6 +11778,45 @@ }, "components": { "schemas": { + "SendRequest": { + "type": "object", + "properties": { + "applicationId": { + "type": "integer", + "format": "int64" + }, + "clientMessageId": { + "type": "string", + "minLength": 1, + "pattern": "[A-Za-z0-9_-]{1,64}" + } + }, + "required": [ + "applicationId", + "clientMessageId" + ] + }, + "ResumeCard": { + "type": "object", + "properties": { + "fileName": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "size": { + "type": "integer", + "format": "int64" + }, + "available": { + "type": "boolean" + }, + "downloadPath": { + "type": "string" + } + } + }, "IdentitySelectionRequest": { "type": "object", "required": [ @@ -18098,6 +18263,12 @@ }, "isRead": { "type": "boolean" + }, + "messageType": { + "type": "string" + }, + "resume": { + "$ref": "#/components/schemas/ResumeCard" } } }, diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 80c0ceb..9a18716 100644 --- a/docs/verify_openapi.py +++ b/docs/verify_openapi.py @@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json' d = json.load(open(p, encoding='utf-8')) print('paths:', len(d['paths'])) -assert len(d['paths']) == 198, f"Expected 198 paths (185 baseline + 13 module paths), got {len(d['paths'])}" +assert len(d['paths']) == 201, f"Expected 201 paths, got {len(d['paths'])}" paths = d['paths'] @@ -716,10 +716,13 @@ for _p in paths: assert not _p.startswith('/actuator'), f'Actuator must not appear in the business contract: {_p}' print(' S03 company resume download contract validated; candidate DTOs remain redacted') -# 统计 operation 总数并校验当前基线 223 个既有 operation 加 18 个模块 operation。 +# 统计 operation 总数并校验当前接口契约基线。 operations = sum(len(methods) for methods in d['paths'].values()) print('operations:', operations) -assert operations == 241, f"Expected 241 operations (223 baseline + 18 module operations), got {operations}" +assert operations == 245, f"Expected 245 operations, got {operations}" +assert 'post' in paths['/conversations/{id}/resume-messages'] +assert {'get', 'delete'} <= paths['/conversations/{conversationId}/messages/{messageId}/resume'].keys() +assert {'messageType', 'resume'} <= d['components']['schemas']['MessageResponse']['properties'].keys() assert paths['/auth/password/setup']['post']['operationId'] == 'setupPassword' assert 'bearerAuth' in json.dumps(paths['/auth/password/setup']['post']['security']) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatus.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatus.java index 696f6f7..9c8e987 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatus.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatus.java @@ -23,8 +23,8 @@ public enum ApplicationStatus { public boolean canTransitionTo(ApplicationStatus target) { if (target == null) return false; return switch (this) { - case SUBMITTED -> target == VIEWED || target == WITHDRAWN; - case VIEWED -> target == INTERVIEW || target == WITHDRAWN; + case SUBMITTED -> target == VIEWED || target == REJECTED || target == WITHDRAWN; + case VIEWED -> target == INTERVIEW || target == REJECTED || target == WITHDRAWN; case INTERVIEW -> target == REJECTED || target == HIRED; case REJECTED, HIRED, WITHDRAWN -> false; // 终态不可再流转 }; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java index 57f74b4..6b26064 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java @@ -15,7 +15,9 @@ public interface ApplicationMatchRecordRepository extends JpaRepository 0 FROM ApplicationMatchRecord r " - + "WHERE r.companyId = :companyId AND r.candidateId = :candidateId AND r.status = 'MATCHED'") + + "JOIN Application a ON a.id = r.applicationId " + + "WHERE r.companyId = :companyId AND r.candidateId = :candidateId AND r.status = 'MATCHED' " + + "AND a.status NOT IN ('REJECTED', 'WITHDRAWN')") boolean existsMatchedApplication(@Param("companyId") Long companyId, @Param("candidateId") Long candidateId); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java index 1e466c6..31dd641 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java @@ -89,6 +89,7 @@ public enum ErrorCode { MESSAGE_NOT_FOUND(8306, "message not found"), MESSAGE_RECALL_WINDOW_EXPIRED(8307, "message recall window expired"), MESSAGE_RECALL_CONCURRENT_UPDATE(8308, "message recall concurrent update"), + RESUME_MESSAGE_UNAVAILABLE(8309, "resume message is no longer available"), // 评价 91xx DUPLICATE_REVIEW(9101, "duplicate review"), diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java index c5d4163..0858167 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java @@ -205,6 +205,7 @@ public final class ErrorHttpStatusRegistry { ErrorCode.MATCH_SCORES_LOCKED, ErrorCode.MESSAGE_RECALL_CONCURRENT_UPDATE, ErrorCode.MESSAGE_RECALL_WINDOW_EXPIRED, + ErrorCode.RESUME_MESSAGE_UNAVAILABLE, ErrorCode.OFFER_COUNTER_OFFER_EXISTS, ErrorCode.OFFER_COUNTER_OFFER_EXPIRED, ErrorCode.OFFER_COUNTER_OFFER_NOT_PENDING, diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java index 805771f..1f7102a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java @@ -39,6 +39,7 @@ public class AccountDeletionCompletionWorker { private final SalaryHistoryCleanupService salaryHistoryCleanupService; private final AuthSessionService authSessionService; private final Clock clock; + private final com.mydreamjob.messaging.ChatResumeLifecycle chatResumeLifecycle; /** * 若用户处于 PENDING 且已到期,则执行完成态迁移 + 脱敏 + 审计。 @@ -71,6 +72,7 @@ public class AccountDeletionCompletionWorker { // 注销完成:清理该用户的个人导出文件并作废未完成任务 dataExportCleanupService.purgeForDeletedUser(rec.getUserId()); salaryHistoryCleanupService.purgeForDeletedUser(rec.getUserId()); + chatResumeLifecycle.purgeForUser(rec.getUserId()); // 完成 + 脱敏审计各写一次 auditService.log(userId, "ACCOUNT_DELETION_COMPLETED", "ACCOUNT_DELETION", rec.getId(), "account deletion completed; userId=" + userId diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewService.java b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewService.java index 690551d..512376f 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewService.java @@ -70,6 +70,10 @@ public class InterviewService { throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH); } + if ("REJECTED".equals(app.getStatus()) || "WITHDRAWN".equals(app.getStatus())) { + throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION); + } + validateTimeRange(req.getStartAt(), req.getEndAt()); validateTypeFields(req.getInterviewType(), req.getMeetingUrl(), req.getAddress()); @@ -179,6 +183,7 @@ public class InterviewService { if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) { throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); } + requireOpenApplication(interview); InterviewStatus current = InterviewStatus.valueOf(interview.getStatus()); if (current != InterviewStatus.SCHEDULED && current != InterviewStatus.CONFIRMED) { @@ -224,6 +229,7 @@ public class InterviewService { InterviewStatus current = InterviewStatus.valueOf(interview.getStatus()); InterviewStatus target = mapAction(req.getAction()); + if (target != InterviewStatus.DECLINED) requireOpenApplication(interview); if (current == target) { return interview; // 幂等 } @@ -309,6 +315,7 @@ public class InterviewService { if (!company.getOwnerUserId().equals(interview.getCompanyUserId())) { throw new BusinessException(ErrorCode.INTERVIEW_ACCESS_DENIED); } + requireOpenApplication(interview); InterviewStatus current = InterviewStatus.valueOf(interview.getStatus()); if (current == InterviewStatus.COMPLETED) { @@ -357,6 +364,13 @@ public class InterviewService { // ── 视图 ──────────────────────────────────────────────────── + private void requireOpenApplication(Interview interview) { + Application application = applicationRepository.findById(interview.getApplicationId()) + .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); + if ("REJECTED".equals(application.getStatus()) || "WITHDRAWN".equals(application.getStatus())) + throw new BusinessException(ErrorCode.INVALID_APPLICATION_STATUS_TRANSITION); + } + public Map toView(Interview interview) { Map v = new HashMap<>(); v.put("id", interview.getId()); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeController.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeController.java new file mode 100644 index 0000000..1a68072 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeController.java @@ -0,0 +1,52 @@ +package com.mydreamjob.messaging; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.messaging.dto.MessageResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.*; +import org.springframework.web.bind.annotation.*; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@RestController @RequestMapping("/conversations") @RequiredArgsConstructor +@Tag(name = "消息", description = "会话创建、消息发送、已读管理") +public class ChatResumeController { + private final ChatResumeService service; + private final com.mydreamjob.security.CurrentUserAccessor current; + private final com.mydreamjob.common.logging.RequestIdAccessor requestIds; + + @Operation(summary = "发送固定版本简历卡片", description = "仅投递本人,双方为会话成员且 MATCHED;隐私规则仍生效。clientMessageId 必填,重试复用原值") + @PostMapping("/{id}/resume-messages") + public ApiResponse send(@PathVariable Long id, + @Valid @RequestBody ChatResumeService.SendRequest body, HttpServletRequest request) { + return ApiResponse.ok(service.send(id,current.requireUserId(),body),requestIds.get(request)); + } + + @Operation(summary = "授权下载简历快照", description = "每次校验成员、投递归属、认证、匹配、隐私、屏蔽及撤回状态,不返回公开文件地址") + @GetMapping("/{conversationId}/messages/{messageId}/resume") + public ResponseEntity download(@PathVariable Long conversationId,@PathVariable Long messageId) { + var file = service.download(conversationId,messageId,current.requireUserId()); + String type = file.contentType(); + if (!java.util.Set.of("application/pdf","application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document").contains(type == null ? "" : type)) + type = MediaType.APPLICATION_OCTET_STREAM_VALUE; + return ResponseEntity.ok().contentType(MediaType.parseMediaType(type)) + .header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment() + .filename("resume" + extension(file.fileName()),java.nio.charset.StandardCharsets.UTF_8).build().toString()) + .header(HttpHeaders.CACHE_CONTROL,"no-store").header("X-Content-Type-Options","nosniff") + .body(file.bytes()); + } + + @Operation(summary = "撤销简历卡片授权", description = "仅原发送者,幂等,销毁快照字节并推送 RESUME_REVOKED;不受两分钟文本撤回窗口限制") + @DeleteMapping("/{conversationId}/messages/{messageId}/resume") + public ApiResponse revoke(@PathVariable Long conversationId,@PathVariable Long messageId, + HttpServletRequest request) { + return ApiResponse.ok(service.revoke(conversationId,messageId,current.requireUserId()),requestIds.get(request)); + } + private String extension(String name) { + int dot = name == null ? -1 : name.lastIndexOf('.'); + return dot < 0 ? "" : name.substring(dot); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeLifecycle.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeLifecycle.java new file mode 100644 index 0000000..3fdb1c4 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeLifecycle.java @@ -0,0 +1,26 @@ +package com.mydreamjob.messaging; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; + +/** 删除简历/账号时同事务销毁已发送快照;重新上传不能复活旧授权。 */ +@Service @RequiredArgsConstructor +public class ChatResumeLifecycle { + private final MessageRepository messages; + private final ChatResumeSnapshotRepository snapshots; + private final com.mydreamjob.messaging.realtime.ChatEventService events; + + @Transactional(propagation = Propagation.MANDATORY) + public void purgeForUser(Long userId) { + for (Message candidate : messages.findBySenderUserIdAndMessageType(userId, "RESUME")) { + Message message = messages.findCurrentByIdForRecall(candidate.getId()).orElseThrow(); + snapshots.purge(message.getId()); + if (Boolean.TRUE.equals(message.getResumeRevoked())) continue; + message.setResumeRevoked(true); + message.setResumeFilename(null); message.setResumeContentType(null); message.setResumeSize(null); + messages.saveAndFlush(message); + events.record("RESUME_REVOKED", message.getConversationId(), userId, message.getId()); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeService.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeService.java new file mode 100644 index 0000000..528b8b2 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeService.java @@ -0,0 +1,152 @@ +package com.mydreamjob.messaging; + +import com.mydreamjob.application.*; +import com.mydreamjob.auth.*; +import com.mydreamjob.company.*; +import com.mydreamjob.job.JobRepository; +import com.mydreamjob.profile.*; +import com.mydreamjob.profile.storage.ResumeStorageService; +import com.mydreamjob.common.exception.*; +import com.mydreamjob.messaging.dto.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** 当前已上传简历的不可变快照;版本标识即 messageId,不接受任意 URL/文件路径。 */ +@Service @RequiredArgsConstructor +public class ChatResumeService { + private final MessagingService messaging; + private final MessageRepository messages; + private final ConversationRepository conversations; + private final ConversationMemberRepository members; + private final ChatResumeSnapshotRepository snapshots; + private final StudentProfileRepository profiles; + private final ResumeStorageService storage; + private final ApplicationRepository applications; + private final JobRepository jobs; + private final CompanyRepository companies; + private final UserRepository users; + private final CompanyResumeAccessPolicy resumeAccess; + private final com.mydreamjob.security.UserRoleGuard roles; + private final com.mydreamjob.safety.BlockRelationRepository blocks; + private final com.mydreamjob.bidirectionalmatch.ApplicationMatchRecordRepository matches; + private final com.mydreamjob.messaging.realtime.ChatEventService events; + private final MessageViewMapper mapper; + @org.springframework.beans.factory.annotation.Value("${app.chat.resume.max-active-bytes:52428800}") + private long maxActiveBytes; + + public record SendRequest(@jakarta.validation.constraints.NotNull Long applicationId, + @jakarta.validation.constraints.NotBlank + @jakarta.validation.constraints.Pattern(regexp = "[A-Za-z0-9_-]{1,64}") String clientMessageId) {} + public record File(String fileName, String contentType, byte[] bytes) { + @Override public String toString() { return "ResumeFile[redacted]"; } + } + + @Transactional + public MessageResponse send(Long conversationId, Long userId, SendRequest req) { + roles.requireStudent(userId); + // 与替换/删除上传文件串行;持久化之前读取完整内容,失败则事务回滚。 + var profile = profiles.lockByUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.PROFILE_NOT_FOUND)); + guard(conversationId, userId, req.applicationId()); + conversations.findLockedById(conversationId).orElseThrow(() -> new BusinessException(ErrorCode.MESSAGE_NOT_FOUND)); + var prior = messages.findByConversationIdAndSenderUserIdAndClientMessageId(conversationId,userId,req.clientMessageId()); + if (prior.isPresent()) { + var applicationId = snapshots.applicationId(prior.get().getId()).orElse(null); + if (!"RESUME".equals(prior.get().getMessageType()) + || !req.applicationId().equals(applicationId)) + throw new BusinessException(ErrorCode.BAD_REQUEST,"clientMessageId already used"); + return view(prior.get(),userId); + } + if (profile.getResumeUrl() == null || profile.getResumeUrl().isBlank()) + throw new BusinessException(ErrorCode.PROFILE_NOT_FOUND); + byte[] bytes = storage.read(profile.getResumeUrl()); + if (bytes.length == 0 || bytes.length > 10 * 1024 * 1024) + throw new BusinessException(ErrorCode.BAD_REQUEST,"invalid resume size"); + if (maxActiveBytes <= 0 || messages.activeResumeBytes(userId) + bytes.length > maxActiveBytes) + throw new BusinessException(ErrorCode.BAD_REQUEST,"resume snapshot quota exceeded; revoke old cards first"); + // 复用既有发送鉴权、屏蔽、幂等锁及提交后推送;事件读取提交后的卡片字段。 + SendMessageRequest message = new SendMessageRequest(); + message.setConversationId(conversationId); message.setContent("发送了一份简历"); + message.setClientMessageId(req.clientMessageId()); + var result = messaging.sendMessage(userId,message); + Message saved = messages.findById(((Number)result.get("id")).longValue()).orElseThrow(); + saved.setMessageType("RESUME"); + saved.setResumeFilename(com.mydreamjob.profile.storage.ResumeFilenameSanitizer.forDownload(profile.getResumeOriginalName(),"resume")); + saved.setResumeContentType(profile.getResumeContentType()); saved.setResumeSize((long)bytes.length); + messages.saveAndFlush(saved); + snapshots.save(ChatResumeSnapshot.builder().messageId(saved.getId()).applicationId(req.applicationId()) + .ownerUserId(userId).content(bytes.clone()).build()); + return view(saved,userId); + } + + @Transactional + public File download(Long conversationId, Long messageId, Long userId) { + messaging.getConversation(conversationId,userId); + Message message = message(conversationId,messageId); + if (!"SENT".equals(message.getStatus()) || Boolean.TRUE.equals(message.getResumeRevoked())) + throw new BusinessException(ErrorCode.RESUME_MESSAGE_UNAVAILABLE); + Long applicationId = snapshots.applicationId(messageId) + .orElseThrow(() -> new BusinessException(ErrorCode.RESUME_MESSAGE_UNAVAILABLE)); + guard(conversationId,userId,applicationId); + var snapshot = snapshots.findById(messageId).orElseThrow(); + User owner = users.findById(snapshot.getOwnerUserId()).orElse(null); + if (owner == null || !Boolean.TRUE.equals(owner.getActive()) || snapshot.getContent() == null) + throw new BusinessException(ErrorCode.RESUME_MESSAGE_UNAVAILABLE); + return new File(message.getResumeFilename(),message.getResumeContentType(),snapshot.getContent().clone()); + } + + @Transactional + public MessageResponse revoke(Long conversationId, Long messageId, Long userId) { + messaging.getConversation(conversationId,userId); + conversations.findLockedById(conversationId).orElseThrow(); + Message message = message(conversationId,messageId); + if (!userId.equals(message.getSenderUserId())) throw new BusinessException(ErrorCode.FORBIDDEN); + snapshots.purge(messageId); + if (!Boolean.TRUE.equals(message.getResumeRevoked())) { + message.setResumeRevoked(true); + message.setResumeFilename(null); message.setResumeContentType(null); message.setResumeSize(null); + message = messages.saveAndFlush(message); + events.record("RESUME_REVOKED",conversationId,userId,messageId); + } + return view(message,userId); + } + + private Message message(Long conversationId, Long messageId) { + Message message = messages.findCurrentByIdForRecall(messageId) + .orElseThrow(() -> new BusinessException(ErrorCode.MESSAGE_NOT_FOUND)); + if (!conversationId.equals(message.getConversationId()) || !"RESUME".equals(message.getMessageType())) + throw new BusinessException(ErrorCode.FORBIDDEN); + return message; + } + + private void guard(Long conversationId, Long actor, Long applicationId) { + messaging.getConversation(conversationId,actor); + var application = applications.findById(applicationId) + .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); + var job = jobs.findById(application.getJobId()).orElseThrow(() -> new BusinessException(ErrorCode.JOB_NOT_FOUND)); + var company = VerifiedCompanyGuard.requireVerified(companies.findById(job.getCompanyId()).orElse(null)); + Long student = application.getStudentUserId(), employer = company.getOwnerUserId(); + if ((!actor.equals(student) && !actor.equals(employer)) + || members.findByConversationId(conversationId).size() != 2 + || !members.existsByConversationIdAndUserId(conversationId,student) + || !members.existsByConversationIdAndUserId(conversationId,employer)) + throw new BusinessException(ErrorCode.FORBIDDEN); + if (actor.equals(student)) roles.requireStudent(actor); else roles.requireCompany(actor); + if ("REJECTED".equals(application.getStatus()) || "WITHDRAWN".equals(application.getStatus())) + throw new BusinessException(ErrorCode.RESUME_MESSAGE_UNAVAILABLE); + if (blocks.existsByBlockerUserIdAndBlockedUserId(student,employer) + || blocks.existsByBlockerUserIdAndBlockedUserId(employer,student)) + throw new BusinessException(ErrorCode.FORBIDDEN); + var match = matches.findByApplicationId(applicationId).orElse(null); + if (match == null || !"MATCHED".equals(match.getStatus())) + throw new BusinessException(ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION); + // 主动发送不会偷偷覆盖学生的 PRIVATE 设置;双方均必须满足当前下载策略。 + resumeAccess.authorizeForDownload(employer,applicationId); + } + + private MessageResponse view(Message message, Long userId) { + return mapper.toResponse(message,userId,conversations.findById(message.getConversationId()) + .map(c -> "TALENT_INVITATION".equals(c.getType())).orElse(false)); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeSnapshot.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeSnapshot.java new file mode 100644 index 0000000..cc827b6 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeSnapshot.java @@ -0,0 +1,14 @@ +package com.mydreamjob.messaging; + +import jakarta.persistence.*; +import lombok.*; + +/** 文件只存在受控数据库快照中;不参与消息列表查询、日志或实体 toString。 */ +@Entity @Table(name = "chat_resume_snapshots") +@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor +public class ChatResumeSnapshot { + @Id @Column(name = "message_id") private Long messageId; + @Column(name = "application_id", nullable = false) private Long applicationId; + @Column(name = "owner_user_id", nullable = false) private Long ownerUserId; + @Lob @Column(columnDefinition = "LONGBLOB") private byte[] content; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeSnapshotRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeSnapshotRepository.java new file mode 100644 index 0000000..e0e6fd0 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeSnapshotRepository.java @@ -0,0 +1,12 @@ +package com.mydreamjob.messaging; + +import org.springframework.data.jpa.repository.*; +import org.springframework.data.repository.query.Param; + +public interface ChatResumeSnapshotRepository extends JpaRepository { + @Query("select r.applicationId from ChatResumeSnapshot r where r.messageId = :id") + java.util.Optional applicationId(@Param("id") Long messageId); + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update ChatResumeSnapshot r set r.content = null where r.messageId = :id") + int purge(@Param("id") Long messageId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationSummaryService.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationSummaryService.java new file mode 100644 index 0000000..dcc70c6 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationSummaryService.java @@ -0,0 +1,60 @@ +package com.mydreamjob.messaging; + +import jakarta.persistence.*; +import java.util.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** 对已完成成员鉴权的会话批量补展示摘要;不查询手机号、实名、简历或虚构在线状态。 */ +@Service @RequiredArgsConstructor +public class ConversationSummaryService { + @PersistenceContext private EntityManager em; + + public void enrich(List> views, Long userId) { + if (views.isEmpty()) return; + List ids = views.stream().map(v -> (Long)v.get("id")).toList(); + Map> contacts = new HashMap<>(); + em.createQuery(""" + select m.conversationId, u.nickname, u.avatarUrl, c.name, c.logoUrl + from ConversationMember m join User u on u.id = m.userId + left join Company c on c.ownerUserId = u.id + where m.conversationId in :ids and m.userId <> :userId + order by m.conversationId, m.id + """,Object[].class).setParameter("ids",ids).setParameter("userId",userId) + .getResultList().forEach(r -> contacts.computeIfAbsent((Long)r[0],key -> new ArrayList<>()).add(r)); + Map last = new HashMap<>(); + em.createQuery(""" + select m.conversationId, m.content, m.createdAt, m.senderUserId, m.status, m.messageType, m.resumeRevoked + from Message m where m.conversationId in :ids and not exists + (select n.id from Message n where n.conversationId = m.conversationId and + (n.createdAt > m.createdAt or (n.createdAt = m.createdAt and n.id > m.id))) + """,Object[].class).setParameter("ids",ids).getResultList() + .forEach(r -> last.put((Long)r[0],r)); + for (Map view : views) { + boolean invitation = "TALENT_INVITATION".equals(view.get("type")); + var peers = contacts.getOrDefault(view.get("id"),List.of()); + String name = "会话成员"; + if (peers.size() == 1) { + Object[] peer = peers.get(0); + // 邀约候选人隐藏昵称/头像;公司公开展示名可见,绝不倒查求职者身份。 + name = peer[3] != null ? peer[3].toString() : invitation ? "求职者" : text(peer[1],"求职者"); + if (peer[3] != null) view.put("companyName",peer[3]); + Object avatar = peer[3] != null ? peer[4] : invitation ? null : peer[2]; + if (avatar != null) view.put("avatarUrl",avatar); + } + view.put("contactName",name); + Object[] message = last.get(view.get("id")); + view.put("lastMessage",message == null ? "" : "RECALLED".equals(message[4]) ? MessageViewMapper.RECALLED_PLACEHOLDER + : "RESUME".equals(message[5]) ? Boolean.TRUE.equals(message[6]) ? "[简历已失效]" : "[简历]" : message[1]); + if (message != null) { + view.put("lastMessageAt",message[2]); + view.put("lastMessageSenderScope",Objects.equals(message[3],userId) ? "CURRENT_USER" : "OTHER_PARTICIPANT"); + } + // 单个 DIRECT 会话可能承载多个岗位,不能捏造唯一岗位名。 + view.put("jobContextAvailable",false); + } + } + private String text(Object value,String fallback) { + return value == null || value.toString().isBlank() ? fallback : value.toString(); + } +} 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 9eb0c4f..5d032a1 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/Message.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/Message.java @@ -38,6 +38,19 @@ public class Message { @Column(name = "request_content_hash", length = 64) private String requestContentHash; + @Builder.Default + @Column(name = "message_type", nullable = false, length = 20) + private String messageType = "TEXT"; + @Column(name = "resume_filename", length = 255) + private String resumeFilename; + @Column(name = "resume_content_type", length = 120) + private String resumeContentType; + @Column(name = "resume_size") + private Long resumeSize; + @Builder.Default + @Column(name = "resume_revoked", nullable = false) + private Boolean resumeRevoked = false; + @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 3a04783..beb1609 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,12 @@ import java.util.List; @Repository public interface MessageRepository extends JpaRepository, MessageUnreadQueries { + List findBySenderUserIdAndMessageType(Long senderUserId, String messageType); + + @Query("select coalesce(sum(m.resumeSize),0) from Message m where m.senderUserId = :userId " + + "and m.messageType = 'RESUME' and m.status = 'SENT' and m.resumeRevoked = false") + long activeResumeBytes(@Param("userId") Long userId); + @Lock(LockModeType.PESSIMISTIC_WRITE) java.util.Optional findByConversationIdAndSenderUserIdAndClientMessageId( Long conversationId, Long senderUserId, String clientMessageId); 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 74f10d8..23f7f0c 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageViewMapper.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageViewMapper.java @@ -23,6 +23,13 @@ public class MessageViewMapper { .status(message.getStatus() == null ? "SENT" : message.getStatus()) .recalledAt(message.getRecalledAt()) .createdAt(message.getCreatedAt()); + if ("RESUME".equals(message.getMessageType())) { + boolean available = !recalled && !Boolean.TRUE.equals(message.getResumeRevoked()); + builder.messageType("RESUME").resume(new MessageResponse.ResumeCard( + "resume" + extension(message.getResumeFilename()), message.getResumeContentType(), + message.getResumeSize(), available, available ? "/conversations/" + message.getConversationId() + + "/messages/" + message.getId() + "/resume" : null)); + } if (talentInvitation) { builder.senderScope(Objects.equals(message.getSenderUserId(), currentUserId) ? "CURRENT_USER" : "OTHER_PARTICIPANT"); @@ -31,4 +38,10 @@ public class MessageViewMapper { } return builder.build(); } + + private String extension(String name) { + if (name == null) return ""; + int dot = name.lastIndexOf('.'); + return dot < 0 ? "" : name.substring(dot).toLowerCase(java.util.Locale.ROOT); + } } 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 2656a45..3e1dd40 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java @@ -51,6 +51,8 @@ public class MessagingService { private final MessageViewMapper messageViewMapper; private final ObjectMapper objectMapper; private final com.mydreamjob.messaging.realtime.ChatEventService chatEvents; + private final ChatResumeSnapshotRepository resumeSnapshots; + private final ConversationSummaryService conversationSummaries; private static final int MAX_PAGE_SIZE = 50; private static final int MAX_CONTENT_LENGTH = 2000; @@ -85,6 +87,7 @@ public class MessagingService { v.put("unreadCount", unreadByConversation.getOrDefault(c.getId(), 0L)); result.add(v); } + conversationSummaries.enrich(result,userId); return result; } @@ -94,6 +97,7 @@ public class MessagingService { assertMember(conversationId, userId); Map v = toView(c, userId); v.put("unreadCount", countUnread(conversationId, userId)); + conversationSummaries.enrich(java.util.List.of(v),userId); return v; } @@ -206,6 +210,8 @@ public class MessagingService { var existing = messageRepository.findByConversationIdAndSenderUserIdAndClientMessageId( req.getConversationId(), userId, req.getClientMessageId()); if (existing.isPresent()) { + if ("RESUME".equals(existing.get().getMessageType())) + throw new BusinessException(ErrorCode.BAD_REQUEST, "clientMessageId belongs to a resume message"); if (!Objects.equals(existing.get().getRequestContentHash(), contentHash(req.getContent()))) throw new BusinessException(ErrorCode.BAD_REQUEST, "clientMessageId already used"); return toView(existing.get(), userId); @@ -343,10 +349,17 @@ public class MessagingService { throw new BusinessException(ErrorCode.MESSAGE_RECALL_CONCURRENT_UPDATE); } + if ("RESUME".equals(message.getMessageType())) resumeSnapshots.purge(messageId); + auditService.log(currentUserId, "MESSAGE_RECALLED", "MESSAGE", messageId, recallAuditDetails(messageId, conversationId, conversation.getType(), auditContext), recallAuditContext(auditContext)); Message recalled = messageRepository.findById(messageId).orElse(message); + if ("RESUME".equals(recalled.getMessageType())) { + recalled.setResumeRevoked(true); recalled.setResumeFilename(null); + recalled.setResumeContentType(null); recalled.setResumeSize(null); + recalled = messageRepository.saveAndFlush(recalled); + } chatEvents.record("MESSAGE_RECALLED", conversationId, currentUserId, messageId); return toMessageResponse(recalled, currentUserId, conversation); } @@ -437,6 +450,8 @@ public class MessagingService { v.put("status", response.getStatus()); if (response.getRecalledAt() != null) v.put("recalledAt", response.getRecalledAt()); v.put("createdAt", response.getCreatedAt()); + if (response.getMessageType() != null) v.put("messageType", response.getMessageType()); + if (response.getResume() != null) v.put("resume", response.getResume()); if (response.getIsRead() != null) v.put("isRead", response.getIsRead()); return v; } 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 b33d26f..e0d4713 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 @@ -30,4 +30,10 @@ public class MessageResponse { private LocalDateTime recalledAt; private LocalDateTime createdAt; private Boolean isRead; + private String messageType; + private ResumeCard resume; + + /** 只含受鉴权接口相对路径,绝不含磁盘路径、学生 ID 或公开文件 URL。 */ + public record ResumeCard(String fileName, String contentType, Long size, + boolean available, String downloadPath) {} } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java index 2596f6b..5133055 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java @@ -298,6 +298,12 @@ public class OfferService { } String fromStatus = offer.getStatus(); + if (to == OfferStatus.SENT || to == OfferStatus.ACCEPTED || to == OfferStatus.NEGOTIATING) { + Application application = applicationRepository.findById(offer.getApplicationId()) + .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); + if (!isApplicationEligible(application.getStatus())) + throw new BusinessException(ErrorCode.OFFER_APPLICATION_NOT_ELIGIBLE); + } Offer saved = applyTransition(offer, to, operatorUserId, actor.name(), req.getReason(), ctx); notifyStatusChange(saved, fromStatus, to); auditService.log(operatorUserId, "UPDATE_OFFER_STATUS", "OFFER", saved.getId(), diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java index c1c2498..2625548 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java @@ -37,6 +37,7 @@ public class ProfileService { private final ResumeStorageService resumeStorageService; private final UserRepository userRepository; private final ResumeFileValidator resumeFileValidator; + private final com.mydreamjob.messaging.ChatResumeLifecycle chatResumeLifecycle; /** R01-D4-1:学生资料/简历更新后登记评分快照刷新。 */ private final CandidateScoringRefreshRequestService scoringRefreshRequestService; @@ -98,7 +99,7 @@ public class ProfileService { assertStudentRole(userId); validateResumeFile(file); - StudentProfile profile = profileRepository.findByUserId(userId) + StudentProfile profile = profileRepository.lockByUserId(userId) .orElse(StudentProfile.builder().userId(userId).build()); String oldPath = profile.getResumeUrl(); @@ -146,8 +147,9 @@ public class ProfileService { @Transactional public void deleteResume(Long userId) { assertStudentRole(userId); - StudentProfile profile = profileRepository.findByUserId(userId) + StudentProfile profile = profileRepository.lockByUserId(userId) .orElseThrow(() -> new BusinessException(ErrorCode.PROFILE_NOT_FOUND)); + chatResumeLifecycle.purgeForUser(userId); if (profile.getResumeUrl() != null) { String storagePath = profile.getResumeUrl(); profile.setResumeUrl(null); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/StudentProfileRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/StudentProfileRepository.java index c9cfb83..d0d43db 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profile/StudentProfileRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/StudentProfileRepository.java @@ -10,5 +10,9 @@ public interface StudentProfileRepository extends JpaRepository findByUserId(Long userId); + @org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE) + @org.springframework.data.jpa.repository.Query("select p from StudentProfile p where p.userId = :userId") + Optional lockByUserId(@org.springframework.data.repository.query.Param("userId") Long userId); + boolean existsByUserId(Long userId); } diff --git a/mydreamjob-server/src/main/resources/db/migration/V56__chat_resume_snapshots.sql b/mydreamjob-server/src/main/resources/db/migration/V56__chat_resume_snapshots.sql new file mode 100644 index 0000000..0501e73 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V56__chat_resume_snapshots.sql @@ -0,0 +1,16 @@ +ALTER TABLE messages ADD COLUMN message_type VARCHAR(20) NOT NULL DEFAULT 'TEXT'; +ALTER TABLE messages ADD COLUMN resume_filename VARCHAR(255) NULL; +ALTER TABLE messages ADD COLUMN resume_content_type VARCHAR(120) NULL; +ALTER TABLE messages ADD COLUMN resume_size BIGINT NULL; +ALTER TABLE messages ADD COLUMN resume_revoked BOOLEAN NOT NULL DEFAULT FALSE; +CREATE INDEX idx_messages_sender_type ON messages(sender_user_id, message_type); +CREATE TABLE chat_resume_snapshots ( + message_id BIGINT PRIMARY KEY, + application_id BIGINT NOT NULL, + owner_user_id BIGINT NOT NULL, + content LONGBLOB NULL, + CONSTRAINT fk_chat_resume_message FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, + CONSTRAINT fk_chat_resume_application FOREIGN KEY (application_id) REFERENCES applications(id) ON DELETE CASCADE, + CONSTRAINT fk_chat_resume_owner FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_chat_resume_owner (owner_user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java index 0694c8c..097de42 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java @@ -462,8 +462,8 @@ class CompanyResumeDownloadIntegrationTest { } assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13); assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18); - assertThat(pathCount - modulePaths).as("186 paths 包含首次密码设置与聊天事件补拉").isEqualTo(186); - assertThat(opCount - moduleOperations).as("224 operations 包含首次密码设置与聊天事件补拉").isEqualTo(224); + assertThat(pathCount - modulePaths).as("188 paths 包含首次密码设置、聊天补拉及简历消息").isEqualTo(188); + assertThat(opCount - moduleOperations).as("227 operations 包含首次密码设置、聊天补拉及简历消息").isEqualTo(227); var download = paths.path("/company/candidates/{applicationId}/resume").path("get"); assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse(); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java index 171cf23..635447f 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java @@ -52,8 +52,8 @@ public final class UnifiedIdentityJarAcceptanceProbe { JsonNode runtime=call("GET","/v3/api-docs",null,null,200); JsonNode contract=JSON.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); assertThat(operations(runtime)).isEqualTo(operations(contract)); checks++; - assertThat(runtime.path("paths").size()).isEqualTo(198); checks++; - assertThat(operations(runtime)).hasSize(241); checks++; + assertThat(runtime.path("paths").size()).isEqualTo(201); checks++; + assertThat(operations(runtime)).hasSize(245); checks++; JsonNode pending=login("19900009602"); assertThat(pending.path("role").asText()).isEqualTo("UNASSIGNED"); checks++; assertThat(pending.path("identitySelectionRequired").asBoolean()).isTrue(); checks++; @@ -125,9 +125,9 @@ public final class UnifiedIdentityJarAcceptanceProbe { } Files.writeString(Path.of("target/identity-probe-result.json"),JSON.writeValueAsString(Map.of( "checks",checks,"failures",0,"mysql","8.4","redis","7.4","rabbitmq","3", - "paths",198,"operations",241,"smsDelivery","mock sender; not real provider", + "paths",201,"operations",245,"smsDelivery","mock sender; not real provider", "fixtureResidual",0,"auditPolicy","append-only; isolated container lifecycle"))); - System.out.println("Unified identity latest-JAR acceptance PASS: "+checks+" checks; 198 paths/241 operations; fixture residual 0"); + System.out.println("Unified identity latest-JAR acceptance PASS: "+checks+" checks; 201 paths/245 operations; fixture residual 0"); } finally {stop();} } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java index d12bbb1..909348a 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java @@ -210,7 +210,7 @@ class UnifiedRegistrationIntegrationTest { } } assertThat(moduleDenied).as("新模块全部拒绝未选择身份账号").isEqualTo(18); - assertThat(denied - moduleDenied).as("既有身份门禁及聊天事件补拉均受保护").isEqualTo(198); + assertThat(denied - moduleDenied).as("既有身份门禁、事件补拉及简历消息均受保护").isEqualTo(201); java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-identity-gate-result.json"), "{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}"); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java index 5d40bd7..af4cb14 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java @@ -112,6 +112,7 @@ class GlobalExceptionHandlerStatusContractTest { java.util.Map.entry("ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE", HttpStatus.CONFLICT), java.util.Map.entry("SALARY_IDEMPOTENCY_CONFLICT", HttpStatus.CONFLICT), java.util.Map.entry("SALARY_POLICY_VERSION_CONFLICT", HttpStatus.CONFLICT), + java.util.Map.entry("RESUME_MESSAGE_UNAVAILABLE", HttpStatus.CONFLICT), java.util.Map.entry("SALARY_INVALID_PARAMETER", HttpStatus.BAD_REQUEST)); private HttpStatus expectedStatus(ErrorCode errorCode) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java index 3b4a1fe..fe77c4f 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java @@ -34,10 +34,10 @@ class ErrorHttpStatusRegistryTest { @Test @DisplayName("Phase B:显式映射包含企业未认证拒绝状态") void explicitMappingCountMatchesBaseline() { - assertThat(registry.explicitMappingCount()).isEqualTo(133); + assertThat(registry.explicitMappingCount()).isEqualTo(134); // 123 个常量只覆盖 122 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与 // DUPLICATE_FEEDBACK 共用(既有事实,未修改 ErrorCode 数字)。 - assertThat(registry.explicitNumericCodeCount()).isEqualTo(132); + assertThat(registry.explicitNumericCodeCount()).isEqualTo(133); assertThat(registry.resolve(ErrorCode.COMPANY_NOT_VERIFIED)).isEqualTo(HttpStatus.FORBIDDEN); } @@ -123,12 +123,12 @@ class ErrorHttpStatusRegistryTest { .isInstanceOf(UnsupportedOperationException.class); assertThatThrownBy(() -> view.remove(ErrorCode.FORBIDDEN)) .isInstanceOf(UnsupportedOperationException.class); - assertThat(view).hasSize(133); + assertThat(view).hasSize(134); Map numeric = ErrorHttpStatusRegistry.numericMappingsView(); assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK)) .isInstanceOf(UnsupportedOperationException.class); - assertThat(numeric).hasSize(132); + assertThat(numeric).hasSize(133); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java index 1d67093..47c033e 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java @@ -82,7 +82,7 @@ class GlobalExceptionHandlerMappingParityTest { private static final Set TOO_MANY_REQUESTS = Set.of( "ABILITY_RETAKE_LIMIT", "REPORT_RATE_LIMITED", "TALENT_INVITATION_RATE_LIMITED", "PASSWORD_RATE_LIMITED"); - private static final Set CONFLICT_ADDITIONS = Set.of("PASSWORD_STATE_CONFLICT"); + private static final Set CONFLICT_ADDITIONS = Set.of("PASSWORD_STATE_CONFLICT", "RESUME_MESSAGE_UNAVAILABLE"); private static final Set UNAUTHORIZED = Set.of("CREDENTIALS_INVALID"); private static final Set PASSWORD_BAD_REQUEST = Set.of("PASSWORD_POLICY_INVALID"); @@ -132,8 +132,8 @@ class GlobalExceptionHandlerMappingParityTest { @Test @DisplayName("B2:显式映射集合与重构前快照逐项相等") void explicitMappingSetEqualsPreRefactorSnapshot() { - assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(133); - assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(132); + assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(134); + assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(133); for (ErrorCode errorCode : ErrorCode.values()) { HttpStatus expected = baselineStatus(errorCode); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java index 554dea4..d4dad39 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java @@ -46,6 +46,8 @@ class R01BIndependentHttpMappingParityTest { "IDENTITY_SELECTION_REQUIRED", HttpStatus.FORBIDDEN, "IDENTITY_SELECTION_CONFLICT", HttpStatus.CONFLICT, "IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE", HttpStatus.SERVICE_UNAVAILABLE); + private static final Map WORKFLOW_ADDITIONS = + Map.of("RESUME_MESSAGE_UNAVAILABLE", HttpStatus.CONFLICT); private static final java.util.Map MODULE_ADDITIONS = java.util.Map.ofEntries( java.util.Map.entry("ONBOARDING_CHECKLIST_ITEM_NOT_FOUND", HttpStatus.NOT_FOUND), @@ -105,7 +107,7 @@ class R01BIndependentHttpMappingParityTest { // HEAD 基线有 169 条;后续阶段新增码单独列出,不修改任何历史基线行。 assertThat(base).hasSize(169); assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK"); - assertThat(ErrorCode.values()).hasSize(188); + assertThat(ErrorCode.values()).hasSize(189); int checked = 0; for (ErrorCode code : ErrorCode.values()) { @@ -114,6 +116,7 @@ class R01BIndependentHttpMappingParityTest { HttpStatus addedStatus = PHASE_A_ADDITIONS.get(code.name()); if (addedStatus == null) addedStatus = PHASE_B_ADDITIONS.get(code.name()); if (addedStatus == null) addedStatus = IDENTITY_ADDITIONS.get(code.name()); + if (addedStatus == null) addedStatus = WORKFLOW_ADDITIONS.get(code.name()); if (addedStatus == null) addedStatus = MODULE_ADDITIONS.get(code.name()); assertThat(addedStatus).as("新增错误码必须逐项明确登记: %s", code.name()).isNotNull(); assertThat(registry.resolve(code)).isEqualTo(addedStatus); @@ -127,7 +130,7 @@ class R01BIndependentHttpMappingParityTest { .isEqualTo(expected[1]); checked++; } - assertThat(checked).isEqualTo(188); + assertThat(checked).isEqualTo(189); } @Test @@ -139,10 +142,10 @@ class R01BIndependentHttpMappingParityTest { assertThat(explicitInBaseline).isEqualTo(115); assertThat(base.size() - explicitInBaseline).isEqualTo(54); - assertThat(registry.explicitMappingCount()).isEqualTo(133); + assertThat(registry.explicitMappingCount()).isEqualTo(134); assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(55); // 123 个显式常量覆盖 122 个唯一数字码(9103 共用)。 - assertThat(registry.explicitNumericCodeCount()).isEqualTo(132); + assertThat(registry.explicitNumericCodeCount()).isEqualTo(133); } @Test @@ -255,10 +258,12 @@ class R01BIndependentHttpMappingParityTest { Map base = baseline(); for (ErrorCode c : ErrorCode.values()) { if (PHASE_A_ADDITIONS.containsKey(c.name()) || PHASE_B_ADDITIONS.containsKey(c.name()) - || IDENTITY_ADDITIONS.containsKey(c.name()) || MODULE_ADDITIONS.containsKey(c.name())) { + || IDENTITY_ADDITIONS.containsKey(c.name()) || WORKFLOW_ADDITIONS.containsKey(c.name()) + || MODULE_ADDITIONS.containsKey(c.name())) { HttpStatus addedStatus = PHASE_A_ADDITIONS.get(c.name()); if (addedStatus == null) addedStatus = PHASE_B_ADDITIONS.get(c.name()); if (addedStatus == null) addedStatus = IDENTITY_ADDITIONS.get(c.name()); + if (addedStatus == null) addedStatus = WORKFLOW_ADDITIONS.get(c.name()); if (addedStatus == null) addedStatus = MODULE_ADDITIONS.get(c.name()); assertThat(registry.resolve(c)).isEqualTo(addedStatus); } else { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java index d6378f6..9e34cf6 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java @@ -881,7 +881,7 @@ class R01EIndependentCrossDatabaseVerificationTest { } @Test - @DisplayName("E2:1 / 20 / 100 会话的真实 JDBC 语句数恒为 3,且 SQL 形态固定(H2 与 MySQL 均实测)") + @DisplayName("E2:1 / 20 / 100 会话的真实 JDBC 语句数恒为 6,且 SQL 形态固定(H2 与 MySQL 均实测)") void e2QueryBudgetIsFlatOnBothEngines() { int[] sizes = {1, 20, 100}; int[] onH2 = new int[3]; @@ -923,12 +923,12 @@ class R01EIndependentCrossDatabaseVerificationTest { + java.util.Arrays.toString(onMy) + "\n--- h2 sql ---\n" + String.join("\n", h2Sql) + "\n--- mysql sql ---\n" + String.join("\n", mySql) + "\n"); - assertThat(onH2).as("E2 H2:含角色加载的固定 SQL 数必须为 4/4/4").containsExactly(4, 4, 4); - assertThat(onMy).as("E2 MySQL:含角色加载的固定 SQL 数必须为 4/4/4").containsExactly(4, 4, 4); + assertThat(onH2).as("E2 H2:含角色加载和两项批量展示的固定 SQL 数必须为 6/6/6").containsExactly(6, 6, 6); + assertThat(onMy).as("E2 MySQL:含角色加载和两项批量展示的固定 SQL 数必须为 6/6/6").containsExactly(6, 6, 6); - // 4 条语句必须是「角色加载 + 成员关系 + 会话 + 分组未读」,且未读查询只出现 1 次 - assertThat(h2Sql).hasSize(4); - assertThat(mySql).hasSize(4); + // 6 条语句为「角色加载 + 成员关系 + 会话 + 分组未读 + 联系人 + 最新消息」,且未读查询只出现 1 次 + assertThat(h2Sql).hasSize(6); + assertThat(mySql).hasSize(6); assertThat(h2Sql.stream().anyMatch(s -> s.contains("user_roles"))) .as("H2:服务层企业门禁必须读取角色集合").isTrue(); assertThat(mySql.stream().anyMatch(s -> s.contains("user_roles"))) diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java index 3168196..a790420 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java @@ -148,8 +148,8 @@ class LxqModulesMySql57IntegrationTest { JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs")) .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()); JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); - assertThat(operations(runtime)).hasSize(242).isEqualTo(operations(contract)); - assertThat(runtime.path("paths").size()).isEqualTo(199); + assertThat(operations(runtime)).hasSize(245).isEqualTo(operations(contract)); + assertThat(runtime.path("paths").size()).isEqualTo(201); assertThat(runtime.path("paths").path("/conversations/{id}/events")) .isEqualTo(contract.path("paths").path("/conversations/{id}/events")); for (String field : Set.of("clientMessageId", "version")) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java index 2cf9ebb..57b0cbf 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java @@ -59,8 +59,8 @@ class LxqModulesMySqlIntegrationTest { JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs")) .andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray()); JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); - assertThat(operations(runtime)).hasSize(242).isEqualTo(operations(contract)); - assertThat(runtime.path("paths").size()).isEqualTo(199); + assertThat(operations(runtime)).hasSize(245).isEqualTo(operations(contract)); + assertThat(runtime.path("paths").size()).isEqualTo(201); assertThat(runtime.path("paths").path("/conversations/{id}/events")) .isEqualTo(contract.path("paths").path("/conversations/{id}/events")); for (String field : Set.of("clientMessageId", "version")) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/interview/InterviewServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/interview/InterviewServiceTest.java index be0ea76..5eae1ec 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/interview/InterviewServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/interview/InterviewServiceTest.java @@ -69,6 +69,8 @@ class InterviewServiceTest { @BeforeEach void setUp() { + lenient().when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of( + Application.builder().id(APPLICATION_ID).jobId(JOB_ID).studentUserId(STUDENT_ID).status("INTERVIEW").build())); ctx = AuditContext.builder().requestId("req-123").build(); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java new file mode 100644 index 0000000..17f4e9a --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java @@ -0,0 +1,207 @@ +package com.mydreamjob.messaging; + +import com.mydreamjob.auth.User; +import com.mydreamjob.company.Company; +import com.mydreamjob.job.Job; +import com.mydreamjob.application.*; +import com.mydreamjob.application.dto.ApplicationStatusRequest; +import com.mydreamjob.bidirectionalmatch.ApplicationMatchRecord; +import com.mydreamjob.privacy.*; +import com.mydreamjob.profile.*; +import com.mydreamjob.profile.storage.ResumeStorageService; +import com.mydreamjob.common.exception.BusinessException; +import jakarta.persistence.*; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.test.web.servlet.MockMvc; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest(properties = "app.chat.resume.max-active-bytes=5") @AutoConfigureMockMvc @ActiveProfiles("integration-test") @Transactional +class ChatWorkflowIntegrationTest { + @Autowired EntityManager em; + @Autowired ChatResumeService resumes; + @Autowired MessagingService messaging; + @Autowired ProfileService profiles; + @Autowired ApplicationService applications; + @Autowired ChatResumeSnapshotRepository snapshots; + @Autowired MessageRepository messages; + @Autowired com.mydreamjob.bidirectionalmatch.ApplicationMatchRecordRepository matches; + @Autowired com.mydreamjob.interview.InterviewService interviews; + @Autowired com.mydreamjob.offer.OfferService offers; + @Autowired MockMvc mvc; + @Autowired com.mydreamjob.auth.AuthSessionService sessions; + @Autowired java.time.Clock clock; + @MockBean RedisTemplate redis; + @MockBean ResumeStorageService storage; + User student, employer, stranger; + Company company; + Application application; + Conversation conversation; + StudentProfile profile; + StudentPrivacySettings privacy; + + @BeforeEach void fixture() { + student=user("候选人昵称",User.Role.STUDENT); + employer=user("招聘人员",User.Role.COMPANY); + stranger=user("第三人",User.Role.STUDENT); + company=Company.builder().ownerUserId(employer.getId()).name("测试企业").verified(true).build(); + em.persist(company); + Job job=Job.builder().companyId(company.getId()).title("研发岗").description("fixture") + .city("上海").employmentType("FULL_TIME").status("PUBLISHED").build(); em.persist(job); + application=Application.builder().jobId(job.getId()).studentUserId(student.getId()).status("SUBMITTED").build(); + em.persist(application); + em.persist(ApplicationMatchRecord.builder().applicationId(application.getId()).jobId(job.getId()) + .companyId(company.getId()).candidateId(student.getId()).companyRequiredCandidateScore(60) + .studentRequiredHrScore(60).candidateInterestScore(90).hrSuitabilityScore(90).status("MATCHED").build()); + conversation=Conversation.builder().type("DIRECT").createdBy(student.getId()).title("测试").build(); em.persist(conversation); + for(User u:List.of(student,employer)) em.persist(ConversationMember.builder() + .conversationId(conversation.getId()).userId(u.getId()).build()); + profile=StudentProfile.builder().userId(student.getId()).resumeUrl("/controlled/original") + .resumeOriginalName("含实名的简历.pdf").resumeContentType("application/pdf").resumeSize(8L).build();em.persist(profile); + privacy=StudentPrivacySettings.builder().studentUserId(student.getId()) + .resumeVisibility(ResumeVisibility.APPLIED_COMPANIES).build();em.persist(privacy); + em.flush(); + when(storage.read(anyString())).thenAnswer(call -> call.getArgument(0).equals("/controlled/original") + ? new byte[]{1,2,3} : new byte[]{8,9}); + } + private User user(String name,User.Role role) { + User u=User.builder().phone("139"+Math.abs(System.nanoTime()%1_000_000_000L)) + .nickname(name).role(role).roles(Set.of(role)).verified(true).active(true).build();em.persist(u);return u; + } + private com.mydreamjob.messaging.dto.MessageResponse send(String key) { + return resumes.send(conversation.getId(),student.getId(),new ChatResumeService.SendRequest(application.getId(),key)); + } + private void denied(Runnable action,int code) {assertEquals(code,assertThrows(BusinessException.class,action::run).getErrorCodeInt());} + + @Test void immutableSnapshotRetryAndReplayKeepOriginalBytes() { + var first=send("snapshot-1"); + assertEquals("RESUME",first.getMessageType());assertEquals("resume.pdf",first.getResume().fileName()); + assertFalse(first.getResume().downloadPath().contains("controlled")); + profile.setResumeUrl("/controlled/replacement");em.flush(); + assertArrayEquals(new byte[]{1,2,3},resumes.download(conversation.getId(),first.getId(),employer.getId()).bytes()); + assertEquals(first.getId(),send("snapshot-1").getId()); + var second=send("snapshot-2"); + assertArrayEquals(new byte[]{8,9},resumes.download(conversation.getId(),second.getId(),employer.getId()).bytes()); + assertEquals(2,snapshots.count()); + var replay=messaging.replayEvents(conversation.getId(),employer.getId(),0,100); + assertFalse(((List)replay.get("items")).isEmpty()); + assertEquals("RESUME",messaging.listMessages(conversation.getId(),employer.getId(),0,20).getItems().get(0).getMessageType()); + } + @Test void revokedSnapshotIsPurgedAndCannotBeResurrectedByRetry() { + var sent=send("revoke-1");resumes.revoke(conversation.getId(),sent.getId(),student.getId()); + assertNull(snapshots.findById(sent.getId()).orElseThrow().getContent()); + denied(()->resumes.download(conversation.getId(),sent.getId(),employer.getId()),8309); + assertFalse(send("revoke-1").getResume().available()); + assertFalse(resumes.revoke(conversation.getId(),sent.getId(),student.getId()).getResume().available()); + } + @Test void recallPurgesBytesAndHidesCardDownload() { + var sent=send("recall-1"); + messaging.recallMessage(student.getId(),conversation.getId(),sent.getId(),null); + assertNull(snapshots.findById(sent.getId()).orElseThrow().getContent()); + denied(()->resumes.download(conversation.getId(),sent.getId(),employer.getId()),8309); + } + @Test void deletingSourcePermanentlyRevokesPreviouslySentSnapshot() { + var sent=send("delete-1");profiles.deleteResume(student.getId()); + profile.setResumeUrl("/controlled/new");em.flush(); + denied(()->resumes.download(conversation.getId(),sent.getId(),employer.getId()),8309); + assertNull(snapshots.findById(sent.getId()).orElseThrow().getContent()); + } + @Test void otherUserCannotDownloadOrRevoke() { + var sent=send("access-1"); + denied(()->resumes.download(conversation.getId(),sent.getId(),stranger.getId()),2002); + denied(()->resumes.revoke(conversation.getId(),sent.getId(),employer.getId()),2002); + } + @Test void currentPrivacyAndCertificationStillApply() { + var sent=send("privacy-1");privacy.setResumeVisibility(ResumeVisibility.PRIVATE);em.flush(); + denied(()->resumes.download(conversation.getId(),sent.getId(),employer.getId()),2002); + } + @Test void noPrivateResumeCanBeSentAndEmployerCannotSendStudentResume() { + privacy.setResumeVisibility(ResumeVisibility.PRIVATE);em.flush(); + denied(()->send("private-1"),2002); + denied(()->resumes.send(conversation.getId(),employer.getId(),new ChatResumeService.SendRequest(application.getId(),"hr-send")),2002); + } + @Test void unverifiedEmployerLosesDownloadAccess() { + var sent=send("cert-1");company.setVerified(false);em.flush(); + assertThrows(BusinessException.class,()->resumes.download(conversation.getId(),sent.getId(),employer.getId())); + } + @Test void earlyRejectionIsTerminalAndBlocksSnapshot() { + var sent=send("reject-1"); + ApplicationStatusRequest request=new ApplicationStatusRequest();request.setStatus("REJECTED"); + assertEquals("REJECTED",applications.updateStatus(company.getId(),application.getId(),request).getStatus()); + assertFalse(matches.existsMatchedApplication(company.getId(),student.getId())); + denied(()->resumes.download(conversation.getId(),sent.getId(),employer.getId()),8309); + request.setStatus("INTERVIEW"); + assertThrows(BusinessException.class,()->applications.updateStatus(company.getId(),application.getId(),request)); + } + @Test void textKeyCannotBeReusedForResumeAndViceVersa() { + var sent=send("shared-key"); + com.mydreamjob.messaging.dto.SendMessageRequest text=new com.mydreamjob.messaging.dto.SendMessageRequest(); + text.setConversationId(conversation.getId());text.setContent("发送了一份简历");text.setClientMessageId("shared-key"); + denied(()->messaging.sendMessage(student.getId(),text),1001); + assertNotNull(sent.getId()); + } + @Test void summaryHasRealPeerAndRedactedInvitationView() { + send("summary-1"); + var forStudent=messaging.getConversation(conversation.getId(),student.getId()); + assertEquals("测试企业",forStudent.get("contactName"));assertEquals("[简历]",forStudent.get("lastMessage")); + var forHr=messaging.getConversation(conversation.getId(),employer.getId()); + assertEquals("候选人昵称",forHr.get("contactName"));assertFalse(forHr.containsKey("online")); + conversation.setType("TALENT_INVITATION");em.flush(); + var anonymous=messaging.getConversation(conversation.getId(),employer.getId()); + assertEquals("求职者",anonymous.get("contactName"));assertFalse(anonymous.containsKey("createdBy")); + var message=messaging.listMessages(conversation.getId(),employer.getId(),0,20).getItems().get(0); + assertNull(message.getSenderUserId());assertEquals("OTHER_PARTICIPANT",message.getSenderScope()); + assertEquals("resume.pdf",message.getResume().fileName()); + } + @Test void downloadIsBinaryAndNeverPublicOrCached() throws Exception { + var sent=send("http-1"); + var token=sessions.createSession(employer,clock.instant(),com.mydreamjob.audit.AuditContext.builder().build()).accessToken(); + String url="/conversations/"+conversation.getId()+"/messages/"+sent.getId()+"/resume"; + mvc.perform(get(url).header("Authorization","Bearer "+token)).andExpect(status().isOk()) + .andExpect(header().string("Cache-Control","no-store")) + .andExpect(content().contentType("application/pdf")).andExpect(content().bytes(new byte[]{1,2,3})); + mvc.perform(get(url)).andExpect(status().isUnauthorized()); + } + + @Test void storageQuotaIsBoundedAndRevocationReleasesIt() { + var first=send("quota-1"); + denied(()->send("quota-2"),1001); + resumes.revoke(conversation.getId(),first.getId(),student.getId()); + assertNotNull(send("quota-3").getId()); + } + + @Test void rejectedApplicationCannotCreateInterviewOrSendExistingOfferDraft() { + var offer=new com.mydreamjob.offer.dto.OfferCreateRequest(); + offer.setApplicationId(application.getId());offer.setTitle("测试录用");offer.setWorkCity("上海"); + offer.setDescription("fixture");offer.setExpireAt(java.time.LocalDateTime.now().plusDays(10)); + var draft=offers.createOffer(employer.getId(),offer,null); + var interview=new com.mydreamjob.interview.dto.InterviewCreateRequest(); + interview.setTitle("测试面试");interview.setInterviewType("ONLINE");interview.setMeetingUrl("https://example.com/meeting"); + interview.setStartAt(java.time.LocalDateTime.now().plusDays(1));interview.setEndAt(java.time.LocalDateTime.now().plusDays(1).plusHours(1)); + var scheduled=interviews.createInterview(employer.getId(),application.getId(),interview,null); + ApplicationStatusRequest reject=new ApplicationStatusRequest();reject.setStatus("REJECTED"); + application.setStatus("VIEWED");em.flush(); + applications.updateStatus(company.getId(),application.getId(),reject); + denied(()->interviews.createInterview(employer.getId(),application.getId(),interview,null),8003); + var confirm=new com.mydreamjob.interview.dto.InterviewRespondRequest();confirm.setAction("CONFIRM"); + denied(()->interviews.respondToInterview(student.getId(),scheduled.getId(),confirm,null),8003); + var send=new com.mydreamjob.offer.dto.OfferStatusRequest();send.setStatus("SENT"); + denied(()->offers.transitionStatus(employer.getId(),draft.getId(),send,null),8106); + } + + @Test void exportRuntimeContractForIndependentComparison() throws Exception { + String json=mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + java.nio.file.Files.writeString(java.nio.file.Path.of("target/workflow-openapi.json"),json); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageListQueryBudgetIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageListQueryBudgetIntegrationTest.java index 4cbb58d..6ee5b21 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageListQueryBudgetIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageListQueryBudgetIntegrationTest.java @@ -152,8 +152,8 @@ class MessageListQueryBudgetIntegrationTest { assertEquals(count, response.size()); assertTrue(response.stream().allMatch(item -> Long.valueOf(1L).equals(item.get("unreadCount"))), "only the unread SENT message must be counted"); - assertTrue(statements <= 3, - "membership, conversations and grouped unread count must be the only query budget: " + assertTrue(statements <= 5, + "membership, conversations, unread count plus two batch display projections: " + statements); } assertEquals(1, measuredStatements.stream().distinct().count(), 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 09aec1a..fe7c123 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingServiceTest.java @@ -45,6 +45,8 @@ class MessagingServiceTest { new com.fasterxml.jackson.databind.ObjectMapper(); @InjectMocks private MessagingService messagingService; @Mock private com.mydreamjob.messaging.realtime.ChatEventService chatEvents; + @Mock private ConversationSummaryService conversationSummaries; + @Mock private ChatResumeSnapshotRepository resumeSnapshots; private static final Long USER_A = 100L; private static final Long USER_B = 200L; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java index 0dce18b..b7e8f8d 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java @@ -69,6 +69,8 @@ class OfferServiceTest { @BeforeEach void setUp() { ctx = AuditContext.builder().requestId("req-123").build(); + lenient().when(applicationRepository.findById(APPLICATION_ID)).thenReturn(Optional.of( + Application.builder().id(APPLICATION_ID).jobId(JOB_ID).studentUserId(STUDENT_ID).status("INTERVIEW").build())); } // ── 辅助 ────────────────────────────────────────────────── diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java index e5b5c36..d3b036f 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java @@ -42,6 +42,8 @@ class ResumeServiceTest { /** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */ @Mock private UserRoleGuard userRoleGuard; + @Mock + private com.mydreamjob.messaging.ChatResumeLifecycle chatResumeLifecycle; @InjectMocks private ProfileService profileService; @@ -85,7 +87,7 @@ class ResumeServiceTest { MultipartFile file = new MockMultipartFile("file", "resume.pdf", "application/pdf", validPdf("content")); - when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(profile)); + when(profileRepository.lockByUserId(STUDENT_ID)).thenReturn(Optional.of(profile)); when(storageService.store(eq(STUDENT_ID), any())).thenReturn("new_resume.pdf"); when(profileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -103,7 +105,7 @@ class ResumeServiceTest { MultipartFile file = new MockMultipartFile("file", "r.pdf", "application/pdf", validPdf("content")); - when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.empty()); + when(profileRepository.lockByUserId(STUDENT_ID)).thenReturn(Optional.empty()); when(storageService.store(eq(STUDENT_ID), any())).thenReturn("r.pdf"); when(profileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -189,7 +191,7 @@ class ResumeServiceTest { mockStudentRole(); MultipartFile file = new MockMultipartFile("file", "resume.pdf", "application/pdf", validPdf("content")); - when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(profile)); + when(profileRepository.lockByUserId(STUDENT_ID)).thenReturn(Optional.of(profile)); when(storageService.store(eq(STUDENT_ID), any())).thenThrow(new RuntimeException("disk full")); assertThrows(RuntimeException.class, () -> profileService.uploadResume(STUDENT_ID, file)); @@ -203,7 +205,7 @@ class ResumeServiceTest { mockStudentRole(); MultipartFile file = new MockMultipartFile("file", "resume.pdf", "application/pdf", validPdf("content")); - when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(profile)); + when(profileRepository.lockByUserId(STUDENT_ID)).thenReturn(Optional.of(profile)); when(storageService.store(eq(STUDENT_ID), any())).thenReturn("new_resume.pdf"); when(profileRepository.save(any())).thenThrow(new RuntimeException("database unavailable")); @@ -216,7 +218,7 @@ class ResumeServiceTest { @Test void deleteResume_databaseFailure_keepsStoredFile() { mockStudentRole(); - when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(profile)); + when(profileRepository.lockByUserId(STUDENT_ID)).thenReturn(Optional.of(profile)); when(profileRepository.save(any())).thenThrow(new RuntimeException("database unavailable")); assertThrows(RuntimeException.class, () -> profileService.deleteResume(STUDENT_ID)); @@ -241,7 +243,7 @@ class ResumeServiceTest { @Test void deleteResume_shouldClearAllFields() { mockStudentRole(); - when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(profile)); + when(profileRepository.lockByUserId(STUDENT_ID)).thenReturn(Optional.of(profile)); when(profileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); profileService.deleteResume(STUDENT_ID);