package com.mydreamjob.messaging; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditContext; import com.mydreamjob.audit.SecurityAuditService; import com.mydreamjob.bidirectionalmatch.ApplicationMatchRecordRepository; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.company.Company; import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.VerifiedCompanyGuard; import com.mydreamjob.messaging.dto.CreateConversationRequest; import com.mydreamjob.messaging.dto.MessagePageResponse; import com.mydreamjob.messaging.dto.MessageResponse; import com.mydreamjob.messaging.dto.SendMessageRequest; import com.mydreamjob.safety.BlockRelationRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import java.util.*; import java.time.Clock; import java.time.LocalDateTime; @Slf4j @Service @RequiredArgsConstructor public class MessagingService { private final ConversationRepository conversationRepository; private final ConversationMemberRepository memberRepository; private final MessageRepository messageRepository; private final MessageReadRepository readRepository; private final UserRepository userRepository; private final CompanyRepository companyRepository; private final ApplicationMatchRecordRepository applicationMatchRecordRepository; private final BlockRelationRepository blockRelationRepository; private final SecurityAuditService securityAuditService; private final AuditService auditService; private final com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService; private final Clock clock; 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; private static final String RECALLED_STATUS = "RECALLED"; private static final String SENT_STATUS = "SENT"; private static final int RECALL_WINDOW_MINUTES = 2; private void assertMember(Long conversationId, Long userId) { if (!memberRepository.existsByConversationIdAndUserId(conversationId, userId)) throw new BusinessException(ErrorCode.FORBIDDEN); } /** 统一会话存在性检查:不存在返回 404/CONVERSATION_NOT_FOUND,再进入成员校验 */ private Conversation requireConversation(Long conversationId) { return conversationRepository.findById(conversationId) .orElseThrow(() -> new BusinessException(ErrorCode.CONVERSATION_NOT_FOUND)); } public List> listConversations(Long userId) { requireVerifiedIfCompany(userId); List memberships = memberRepository.findByUserId(userId); if (memberships.isEmpty()) return List.of(); List convIds = memberships.stream().map(ConversationMember::getConversationId).toList(); List convs = conversationRepository.findAllById(convIds); Map unreadByConversation = new HashMap<>(); messageRepository.countUnreadByConversationIds(convIds, userId).forEach(row -> unreadByConversation.put(row.getConversationId(), row.getUnreadCount())); convs.sort((a, b) -> b.getUpdatedAt().compareTo(a.getUpdatedAt())); List> result = new ArrayList<>(); for (Conversation c : convs) { Map v = toView(c, userId); v.put("unreadCount", unreadByConversation.getOrDefault(c.getId(), 0L)); result.add(v); } conversationSummaries.enrich(result,userId); return result; } public Map getConversation(Long conversationId, Long userId) { requireVerifiedIfCompany(userId); Conversation c = requireConversation(conversationId); assertMember(conversationId, userId); Map v = toView(c, userId); v.put("unreadCount", countUnread(conversationId, userId)); conversationSummaries.enrich(java.util.List.of(v),userId); return v; } @Transactional public Map createOrGetDirectConversation(Long userId, CreateConversationRequest req) { requireVerifiedIfCompany(userId); Long otherId = req.getParticipantUserId(); if (userId.equals(otherId)) throw new BusinessException(ErrorCode.BAD_REQUEST); if (!userRepository.existsById(otherId)) throw new BusinessException(ErrorCode.USER_NOT_FOUND); assertWithdrawnPairUnlocked(userId, otherId); // 已有 DIRECT 会话 → 返回原会话(历史会话保持可访问,不删除、不失效) List my = memberRepository.findByUserId(userId); for (ConversationMember m : my) { if (memberRepository.existsByConversationIdAndUserId(m.getConversationId(), otherId)) { Conversation existing = conversationRepository.findById(m.getConversationId()).orElse(null); if (existing != null && "DIRECT".equals(existing.getType())) return toView(existing, userId); } } // 新建会话:任意方向存在屏蔽 → 禁止新建 DIRECT 会话 assertNotBlockedBetween(userId, otherId); // 新建会话:企业-求职者组合必须先存在双向匹配成功的投递,防止绕过匹配解锁 assertCompanyCandidatePairHasMatchedApplication(userId, otherId); Conversation c = conversationRepository.save(Conversation.builder().createdBy(userId).type("DIRECT").build()); memberRepository.save(ConversationMember.builder().conversationId(c.getId()).userId(userId).build()); memberRepository.save(ConversationMember.builder().conversationId(c.getId()).userId(otherId).build()); return toView(c); } /** * 防绕过守卫:仅当双方为"企业账号 + 求职者账号"且确实要新建会话时,要求存在一条 * 同时归属双方且状态为 MATCHED 的投递;否则 403。 * 学生-学生、企业-企业及其他组合保持原语义;无法判定身份时(上游已校验存在性)防御性放行。 */ private void assertCompanyCandidatePairHasMatchedApplication(Long userId, Long otherId) { User me = userRepository.findById(userId).orElse(null); User other = userRepository.findById(otherId).orElse(null); if (me == null || other == null) return; boolean meCompany = hasRole(me, User.Role.COMPANY); boolean meStudent = hasRole(me, User.Role.STUDENT); boolean otherCompany = hasRole(other, User.Role.COMPANY); boolean otherStudent = hasRole(other, User.Role.STUDENT); boolean companyCandidatePair = (meCompany && otherStudent) || (meStudent && otherCompany); if (!companyCandidatePair) return; Long companyUserId = meCompany ? userId : otherId; Long studentUserId = meCompany ? otherId : userId; Long companyId = companyRepository.findByOwnerUserId(companyUserId) .map(Company::getId).orElse(null); if (companyId == null || !applicationMatchRecordRepository.existsMatchedApplication(companyId, studentUserId)) { throw new BusinessException(ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION); } } private boolean hasRole(User user, User.Role role) { return user.getRoles() != null && user.getRoles().stream().anyMatch(r -> r == role); } /** 不删除历史会话;有撤回历史的企业/求职者必须重新取得有效匹配才能发送。 */ private void assertWithdrawnPairUnlocked(Long userId, Long otherId) { User me = userRepository.findById(userId).orElse(null); User other = userRepository.findById(otherId).orElse(null); if (me == null || other == null) return; boolean forward = hasRole(me, User.Role.COMPANY) && hasRole(other, User.Role.STUDENT); boolean reverse = hasRole(me, User.Role.STUDENT) && hasRole(other, User.Role.COMPANY); if (!forward && !reverse) return; Long companyUser = forward ? userId : otherId; Long student = forward ? otherId : userId; Long companyId = companyRepository.findByOwnerUserId(companyUser).map(Company::getId).orElse(null); if (companyId != null && applicationMatchRecordRepository.existsWithdrawnApplication(companyId, student) && !applicationMatchRecordRepository.existsMatchedApplication(companyId, student)) { throw new BusinessException(ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION); } } private void requireVerifiedIfCompany(Long userId) { User actor = userRepository.findByIdWithRoles(userId).orElse(null); VerifiedCompanyGuard.requireVerifiedIfCompany(actor, () -> companyRepository.findByOwnerUserId(userId).orElse(null)); } // ── V33 屏蔽控制:任一方向存在屏蔽即禁止站内信交互 ── private boolean isBlocked(Long aId, Long bId) { return blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(aId, bId) || blockRelationRepository.existsByBlockerUserIdAndBlockedUserId(bId, aId); } /** * 对外统一返回 403/2002 + 「message cannot be sent」,不泄露是谁屏蔽了谁; * 内部由审计记录 BLOCKED_SEND_PROHIBITED。屏蔽期间发送失败的消息直接丢弃,不补发。 */ private void assertNotBlockedBetween(Long aId, Long bId) { if (isBlocked(aId, bId)) { AuditContext ctx = currentAuditContext(); // 拒绝型安全审计以独立事务落库,不受本次 403 回滚影响 securityAuditService.logRejected(aId, "BLOCKED_SEND_PROHIBITED", "USER", bId, "Message send prohibited between users " + aId + " and " + bId, ctx); log.info("Blocked send prohibited: reporter={} other={}", aId, bId); throw new BusinessException(ErrorCode.FORBIDDEN, "message cannot be sent"); } } private AuditContext currentAuditContext() { ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attrs != null) return AuditContext.from(attrs.getRequest()); return AuditContext.builder().build(); } @Transactional public Map sendMessage(Long userId, SendMessageRequest req) { requireVerifiedIfCompany(userId); requireConversation(req.getConversationId()); assertMember(req.getConversationId(), userId); conversationRepository.findLockedById(req.getConversationId()); if (req.getContent().length() > MAX_CONTENT_LENGTH) throw new BusinessException(ErrorCode.BAD_REQUEST); // V33 屏蔽控制:会话任一成员存在任一方向屏蔽 → 禁止发送(对外统一 403/2002) List members = memberRepository.findByConversationId(req.getConversationId()); for (ConversationMember m : members) { if (!m.getUserId().equals(userId)) { assertNotBlockedBetween(userId, m.getUserId()); assertWithdrawnPairUnlocked(userId, m.getUserId()); } } if (req.getClientMessageId() != null) { if (!req.getClientMessageId().matches("[A-Za-z0-9_-]{1,64}")) throw new BusinessException(ErrorCode.BAD_REQUEST); var existing = messageRepository.findByConversationIdAndSenderUserIdAndClientMessageId( req.getConversationId(), userId, req.getClientMessageId()); if (existing.isPresent()) { if ("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); } } Message msg = messageRepository.save(Message.builder() .conversationId(req.getConversationId()).senderUserId(userId).content(req.getContent()) .clientMessageId(req.getClientMessageId()) .requestContentHash(req.getClientMessageId() == null ? null : contentHash(req.getContent())).build()); chatEvents.record("MESSAGE_CREATED", req.getConversationId(), userId, msg.getId()); conversationRepository.findById(req.getConversationId()).ifPresent(c -> { c.setUpdatedAt(msg.getCreatedAt()); conversationRepository.save(c); }); // R01-D4-1:成功发送后,若发送者为学生则登记评分刷新(ACTIVITY 维度按发送消息计数)。 // 消息撤回不登记(公式只统计发送消息总数)。 userRepository.findById(userId).ifPresent(u -> { if (u.getRoles() != null && u.getRoles().stream() .anyMatch(r -> r == com.mydreamjob.auth.User.Role.STUDENT)) { scoringRefreshRequestService.requestRefresh(userId, LocalDateTime.now(clock)); } }); return toView(msg, userId); } public MessagePageResponse listMessages(Long conversationId, Long userId, int page, int size) { requireVerifiedIfCompany(userId); Conversation conversation = requireConversation(conversationId); assertMember(conversationId, userId); if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new BusinessException(ErrorCode.BAD_REQUEST); Page result = messageRepository.findByConversationIdOrderByCreatedAtDesc( conversationId, PageRequest.of(page, size)); List messageIds = result.getContent().stream().map(Message::getId).toList(); Set readIds = messageIds.isEmpty() ? Set.of() : readRepository .findByMessageIdInAndReaderUserId(messageIds, userId).stream() .map(MessageRead::getMessageId).collect(java.util.stream.Collectors.toSet()); boolean talentInvitation = "TALENT_INVITATION".equals(conversation.getType()); List items = result.getContent().stream().map(m -> { MessageResponse response = messageViewMapper.toResponse(m, userId, talentInvitation); response.setIsRead(readIds.contains(m.getId())); return response; }).toList(); return MessagePageResponse.builder() .items(items) .total(result.getTotalElements()) .page(result.getNumber()) .size(result.getSize()) .totalPages(result.getTotalPages()) .build(); } @Transactional public void markRead(Long conversationId, Long userId) { requireVerifiedIfCompany(userId); requireConversation(conversationId); assertMember(conversationId, userId); conversationRepository.findLockedById(conversationId); List msgs = messageRepository .findByConversationIdOrderByCreatedAtDesc(conversationId, PageRequest.of(0, 500)).getContent(); List msgIds = msgs.stream() .filter(m -> !RECALLED_STATUS.equals(m.getStatus())) .map(Message::getId).toList(); if (msgIds.isEmpty()) return; List existing = readRepository.findByMessageIdInAndReaderUserId(msgIds, userId); Set existingIds = new HashSet<>(); for (MessageRead r : existing) existingIds.add(r.getMessageId()); for (Long mid : msgIds) { if (!existingIds.contains(mid)) { readRepository.save(MessageRead.builder().messageId(mid).readerUserId(userId).build()); chatEvents.record("MESSAGE_READ", conversationId, userId, mid); } } } private long countUnread(Long convId, Long userId) { List msgs = messageRepository .findByConversationIdOrderByCreatedAtDesc(convId, PageRequest.of(0, 500)).getContent(); if (msgs.isEmpty()) return 0; List msgIds = msgs.stream() .filter(m -> !RECALLED_STATUS.equals(m.getStatus())) .map(Message::getId).toList(); if (msgIds.isEmpty()) return 0; return msgIds.size() - readRepository.findByMessageIdInAndReaderUserId(msgIds, userId).size(); } /** 游标只来自补拉响应,不能直接用实时事件 ID 跳过尚未收到的事件。 */ public Map replayEvents(Long conversationId, Long userId, long after, int size) { requireVerifiedIfCompany(userId); requireConversation(conversationId); assertMember(conversationId, userId); if (after < 0 || size < 1 || size > 100) throw new BusinessException(ErrorCode.BAD_REQUEST); return chatEvents.replay(conversationId, userId, after, size); } /** * 在事务内执行消息撤回。调用者身份、会话成员关系和原发送者关系均由服务端校验。 * capturedNow 由入口统一捕获,便于边界测试,也避免同一请求混用多个时间。 */ @Transactional public MessageResponse recallMessage(Long currentUserId, Long conversationId, Long messageId, AuditContext auditContext, LocalDateTime capturedNow) { requireVerifiedIfCompany(currentUserId); Conversation conversation = requireConversation(conversationId); assertMember(conversationId, currentUserId); conversationRepository.findLockedById(conversationId); User currentUser = userRepository.findById(currentUserId).orElse(null); if (currentUser != null && hasRole(currentUser, User.Role.ADMIN)) { throw new BusinessException(ErrorCode.FORBIDDEN); } Message message = messageRepository.findById(messageId) .orElseThrow(() -> new BusinessException(ErrorCode.MESSAGE_NOT_FOUND)); if (!Objects.equals(message.getConversationId(), conversationId)) { throw new BusinessException(ErrorCode.FORBIDDEN); } if (!Objects.equals(message.getSenderUserId(), currentUserId)) { throw new BusinessException(ErrorCode.FORBIDDEN); } String status = message.getStatus() == null ? SENT_STATUS : message.getStatus(); if (RECALLED_STATUS.equals(status)) { return toMessageResponse(message, currentUserId, conversation); } LocalDateTime createdAt = message.getCreatedAt(); if (createdAt == null || capturedNow == null || capturedNow.isAfter(createdAt.plusMinutes(RECALL_WINDOW_MINUTES))) { throw new BusinessException(ErrorCode.MESSAGE_RECALL_WINDOW_EXPIRED); } Long expectedVersion = message.getVersion() == null ? 0L : message.getVersion(); int updated = messageRepository.recallIfCurrent(messageId, conversationId, currentUserId, expectedVersion, MessageViewMapper.RECALLED_PLACEHOLDER, capturedNow); if (updated != 1) { Message latest = messageRepository.findCurrentByIdForRecall(messageId).orElse(null); if (latest != null && RECALLED_STATUS.equals(latest.getStatus())) { return toMessageResponse(latest, currentUserId, conversation); } 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); } /** 生产入口使用 Spring Clock 捕获一次当前时间。 */ @Transactional public MessageResponse recallMessage(Long currentUserId, Long conversationId, Long messageId, AuditContext auditContext) { Clock effectiveClock = clock == null ? Clock.systemDefaultZone() : clock; return recallMessage(currentUserId, conversationId, messageId, auditContext, LocalDateTime.now(effectiveClock)); } private MessageResponse toMessageResponse(Message message, Long currentUserId, Conversation conversation) { MessageResponse response = messageViewMapper.toResponse(message, currentUserId, conversation != null && "TALENT_INVITATION".equals(conversation.getType())); if (response != null) { response.setIsRead(readRepository.existsByMessageIdAndReaderUserId(message.getId(), currentUserId)); } return response; } private AuditContext recallAuditContext(AuditContext source) { return AuditContext.builder() .requestId(source == null ? null : source.getRequestId()) .build(); } private String recallAuditDetails(Long messageId, Long conversationId, String conversationType, AuditContext context) { String safeConversationType = "DIRECT".equals(conversationType) || "TALENT_INVITATION".equals(conversationType) ? conversationType : "UNKNOWN"; Map details = new LinkedHashMap<>(); details.put("messageId", messageId); details.put("conversationId", conversationId); details.put("conversationType", safeConversationType); details.put("status", "RECALLED"); details.put("requestId", context == null ? null : context.getRequestId()); try { return objectMapper.writeValueAsString(details); } catch (JsonProcessingException ex) { throw new IllegalStateException("message recall audit serialization failed"); } } public Map toView(Conversation c) { Map v = new HashMap<>(); v.put("id", c.getId()); v.put("title", c.getTitle()); v.put("type", c.getType()); v.put("createdBy", c.getCreatedBy()); v.put("createdAt", c.getCreatedAt()); v.put("updatedAt", c.getUpdatedAt()); return v; } /** V42 会话隐私映射:TALENT_INVITATION 不返回 createdBy(其中可能是另一方学生 ID)。 */ public Map toView(Conversation c, Long currentUserId) { if (c == null || !"TALENT_INVITATION".equals(c.getType())) return toView(c); Map v = new HashMap<>(); v.put("id", c.getId()); v.put("title", c.getTitle()); v.put("type", c.getType()); v.put("participantScope", "CURRENT_USER_AND_OTHER_PARTICIPANT"); v.put("createdAt", c.getCreatedAt()); v.put("updatedAt", c.getUpdatedAt()); return v; } public Map toView(Message m) { return toLegacyView(messageViewMapper.toResponse(m, null, false)); } /** V42 消息隐私映射:TALENT_INVITATION 仅返回相对发送者身份。 */ public Map toView(Message m, Long currentUserId) { if (m == null) return null; return toView(m, currentUserId, isTalentInvitationConversation(m.getConversationId())); } private Map toView(Message m, Long currentUserId, boolean talentInvitation) { return toLegacyView(messageViewMapper.toResponse(m, currentUserId, talentInvitation)); } private Map toLegacyView(MessageResponse response) { Map v = new HashMap<>(); if (response == null) return v; v.put("id", response.getId()); if (response.getClientMessageId() != null) v.put("clientMessageId", response.getClientMessageId()); if (response.getVersion() != null) v.put("version", response.getVersion()); v.put("conversationId", response.getConversationId()); if (response.getSenderUserId() != null) v.put("senderUserId", response.getSenderUserId()); if (response.getSenderScope() != null) v.put("senderScope", response.getSenderScope()); v.put("content", response.getContent()); 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; } private String contentHash(String content) { try { return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") .digest(content.getBytes(java.nio.charset.StandardCharsets.UTF_8))); } catch (java.security.NoSuchAlgorithmException impossible) { throw new IllegalStateException("SHA-256 unavailable"); } } private boolean isTalentInvitationConversation(Long conversationId) { return conversationId != null && conversationRepository.findById(conversationId) .map(c -> "TALENT_INVITATION".equals(c.getType())).orElse(false); } }