package com.mydreamjob.messaging; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; 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.messaging.dto.CreateConversationRequest; 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.*; @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 static final int MAX_PAGE_SIZE = 50; private static final int MAX_CONTENT_LENGTH = 2000; 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) { List memberships = memberRepository.findByUserId(userId); if (memberships.isEmpty()) return List.of(); List convIds = memberships.stream().map(ConversationMember::getConversationId).toList(); List convs = conversationRepository.findAllById(convIds); 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", countUnread(c.getId(), userId)); result.add(v); } return result; } public Map getConversation(Long conversationId, Long userId) { Conversation c = requireConversation(conversationId); assertMember(conversationId, userId); Map v = toView(c, userId); v.put("unreadCount", countUnread(conversationId, userId)); return v; } @Transactional public Map createOrGetDirectConversation(Long userId, CreateConversationRequest req) { 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); // 已有 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); } // ── 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) { requireConversation(req.getConversationId()); assertMember(req.getConversationId(), userId); 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()); } } Message msg = messageRepository.save(Message.builder() .conversationId(req.getConversationId()).senderUserId(userId).content(req.getContent()).build()); conversationRepository.findById(req.getConversationId()).ifPresent(c -> { c.setUpdatedAt(msg.getCreatedAt()); conversationRepository.save(c); }); return toView(msg, userId); } public Page> listMessages(Long conversationId, Long userId, int page, int size) { Conversation conversation = requireConversation(conversationId); assertMember(conversationId, userId); if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) throw new BusinessException(ErrorCode.BAD_REQUEST); return messageRepository.findByConversationIdOrderByCreatedAtDesc(conversationId, PageRequest.of(page, size)) .map(m -> { Map v = toView(m, userId, "TALENT_INVITATION".equals(conversation.getType())); v.put("isRead", readRepository.existsByMessageIdAndReaderUserId(m.getId(), userId)); return v; }); } @Transactional public void markRead(Long conversationId, Long userId) { requireConversation(conversationId); assertMember(conversationId, userId); List msgs = messageRepository .findByConversationIdOrderByCreatedAtDesc(conversationId, PageRequest.of(0, 500)).getContent(); List msgIds = msgs.stream().map(Message::getId).toList(); 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()); } } 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().map(Message::getId).toList(); return msgIds.size() - readRepository.findByMessageIdInAndReaderUserId(msgIds, userId).size(); } 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) { Map v = new HashMap<>(); v.put("id", m.getId()); v.put("conversationId", m.getConversationId()); v.put("senderUserId", m.getSenderUserId()); v.put("content", m.getContent()); v.put("createdAt", m.getCreatedAt()); return v; } /** 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) { if (!talentInvitation) return toView(m); Map v = new HashMap<>(); v.put("id", m.getId()); v.put("conversationId", m.getConversationId()); v.put("senderScope", Objects.equals(m.getSenderUserId(), currentUserId) ? "CURRENT_USER" : "OTHER_PARTICIPANT"); v.put("content", m.getContent()); v.put("createdAt", m.getCreatedAt()); return v; } private boolean isTalentInvitationConversation(Long conversationId) { return conversationId != null && conversationRepository.findById(conversationId) .map(c -> "TALENT_INVITATION".equals(c.getType())).orElse(false); } }