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.profile.storage.ResumeOwnershipVerifier; 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 ResumeOwnershipVerifier resumeOwnership; 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); // 在复制快照前验证源文件属于发送者,防止历史污染引用被洗成合法快照。 resumeOwnership.assertOwnedBy(profile.getResumeUrl(), userId); 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)); } }