forked from baskbuddy/zhangshun
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
156 lines
9.5 KiB
156 lines
9.5 KiB
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));
|
|
}
|
|
}
|
|
|