Browse Source

feat(messaging): 完善简历快照消息与投递流程约束

fix/mysql57-module-migrations-20261009
Zhang Shun 2 weeks ago
parent
commit
1cbadc3e18
  1. 171
      docs/openapi-rc.json
  2. 9
      docs/verify_openapi.py
  3. 4
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatus.java
  4. 4
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java
  5. 1
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  6. 1
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java
  7. 2
      mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java
  8. 14
      mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewService.java
  9. 52
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeController.java
  10. 26
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeLifecycle.java
  11. 152
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeService.java
  12. 14
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeSnapshot.java
  13. 12
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeSnapshotRepository.java
  14. 60
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/ConversationSummaryService.java
  15. 13
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/Message.java
  16. 6
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java
  17. 13
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageViewMapper.java
  18. 15
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java
  19. 6
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/dto/MessageResponse.java
  20. 6
      mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java
  21. 6
      mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java
  22. 4
      mydreamjob-server/src/main/java/com/mydreamjob/profile/StudentProfileRepository.java
  23. 16
      mydreamjob-server/src/main/resources/db/migration/V56__chat_resume_snapshots.sql
  24. 4
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java
  25. 8
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java
  26. 2
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java
  27. 1
      mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java
  28. 8
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java
  29. 6
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java
  30. 15
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java
  31. 12
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java
  32. 4
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java
  33. 4
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java
  34. 2
      mydreamjob-server/src/test/java/com/mydreamjob/interview/InterviewServiceTest.java
  35. 207
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java
  36. 4
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageListQueryBudgetIntegrationTest.java
  37. 2
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingServiceTest.java
  38. 2
      mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java
  39. 14
      mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java

171
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"
}
}
},

9
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'])

4
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; // 终态不可再流转
};

4
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java

@ -15,7 +15,9 @@ public interface ApplicationMatchRecordRepository extends JpaRepository<Applicat
/** 企业(companyId)与候选人(candidateId)之间是否存在已 MATCHED 的投递(用于建会话守卫) */
@Query("SELECT COUNT(r) > 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);

1
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"),

1
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,

2
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

14
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<String, Object> toView(Interview interview) {
Map<String, Object> v = new HashMap<>();
v.put("id", interview.getId());

52
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<MessageResponse> 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<byte[]> 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<MessageResponse> 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);
}
}

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

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

14
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;
}

12
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<ChatResumeSnapshot, Long> {
@Query("select r.applicationId from ChatResumeSnapshot r where r.messageId = :id")
java.util.Optional<Long> 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);
}

60
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<Map<String,Object>> views, Long userId) {
if (views.isEmpty()) return;
List<Long> ids = views.stream().map(v -> (Long)v.get("id")).toList();
Map<Long,List<Object[]>> 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<Long,Object[]> 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<String,Object> 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();
}
}

13
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";

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

@ -16,6 +16,12 @@ import java.util.List;
@Repository
public interface MessageRepository extends JpaRepository<Message, Long>, MessageUnreadQueries {
List<Message> 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<Message> findByConversationIdAndSenderUserIdAndClientMessageId(
Long conversationId, Long senderUserId, String clientMessageId);

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

15
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<String, Object> 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;
}

6
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) {}
}

6
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(),

6
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);

4
mydreamjob-server/src/main/java/com/mydreamjob/profile/StudentProfileRepository.java

@ -10,5 +10,9 @@ public interface StudentProfileRepository extends JpaRepository<StudentProfile,
Optional<StudentProfile> 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<StudentProfile> lockByUserId(@org.springframework.data.repository.query.Param("userId") Long userId);
boolean existsByUserId(Long userId);
}

16
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;

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

@ -462,8 +462,8 @@ class CompanyResumeDownloadIntegrationTest {
}
assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13);
assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18);
assertThat(pathCount - modulePaths).as("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();

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

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

@ -210,7 +210,7 @@ class UnifiedRegistrationIntegrationTest {
}
}
assertThat(moduleDenied).as("新模块全部拒绝未选择身份账号").isEqualTo(18);
assertThat(denied - moduleDenied).as("既有身份门禁及聊天事件补拉均受保护").isEqualTo(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()+"}");
}

1
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) {

8
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<Integer, HttpStatus> numeric = ErrorHttpStatusRegistry.numericMappingsView();
assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK))
.isInstanceOf(UnsupportedOperationException.class);
assertThat(numeric).hasSize(132);
assertThat(numeric).hasSize(133);
}
@Test

6
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java

@ -82,7 +82,7 @@ class GlobalExceptionHandlerMappingParityTest {
private static final Set<String> TOO_MANY_REQUESTS = Set.of(
"ABILITY_RETAKE_LIMIT", "REPORT_RATE_LIMITED", "TALENT_INVITATION_RATE_LIMITED",
"PASSWORD_RATE_LIMITED");
private static final Set<String> CONFLICT_ADDITIONS = Set.of("PASSWORD_STATE_CONFLICT");
private static final Set<String> CONFLICT_ADDITIONS = Set.of("PASSWORD_STATE_CONFLICT", "RESUME_MESSAGE_UNAVAILABLE");
private static final Set<String> UNAUTHORIZED = Set.of("CREDENTIALS_INVALID");
private static final Set<String> 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);

15
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<String, HttpStatus> WORKFLOW_ADDITIONS =
Map.of("RESUME_MESSAGE_UNAVAILABLE", HttpStatus.CONFLICT);
private static final java.util.Map<String, HttpStatus> 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<String, int[]> 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 {

12
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")))

4
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")) {

4
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")) {

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

207
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<String,Object> 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);
}
}

4
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(),

2
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;

2
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()));
}
// ── 辅助 ──────────────────────────────────────────────────

14
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);

Loading…
Cancel
Save