轻松聘AI开发
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.
 
 
 

259 lines
13 KiB

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<Map<String, Object>> listConversations(Long userId) {
List<ConversationMember> memberships = memberRepository.findByUserId(userId);
if (memberships.isEmpty()) return List.of();
List<Long> convIds = memberships.stream().map(ConversationMember::getConversationId).toList();
List<Conversation> convs = conversationRepository.findAllById(convIds);
convs.sort((a, b) -> b.getUpdatedAt().compareTo(a.getUpdatedAt()));
List<Map<String, Object>> result = new ArrayList<>();
for (Conversation c : convs) {
Map<String, Object> v = toView(c, userId);
v.put("unreadCount", countUnread(c.getId(), userId));
result.add(v);
}
return result;
}
public Map<String, Object> getConversation(Long conversationId, Long userId) {
Conversation c = requireConversation(conversationId);
assertMember(conversationId, userId);
Map<String, Object> v = toView(c, userId);
v.put("unreadCount", countUnread(conversationId, userId));
return v;
}
@Transactional
public Map<String, Object> 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<ConversationMember> 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<String, Object> 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<ConversationMember> 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<Map<String, Object>> 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<String, Object> 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<Message> msgs = messageRepository
.findByConversationIdOrderByCreatedAtDesc(conversationId, PageRequest.of(0, 500)).getContent();
List<Long> msgIds = msgs.stream().map(Message::getId).toList();
List<MessageRead> existing = readRepository.findByMessageIdInAndReaderUserId(msgIds, userId);
Set<Long> 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<Message> msgs = messageRepository
.findByConversationIdOrderByCreatedAtDesc(convId, PageRequest.of(0, 500)).getContent();
if (msgs.isEmpty()) return 0;
List<Long> msgIds = msgs.stream().map(Message::getId).toList();
return msgIds.size() - readRepository.findByMessageIdInAndReaderUserId(msgIds, userId).size();
}
public Map<String, Object> toView(Conversation c) {
Map<String, Object> 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<String, Object> toView(Conversation c, Long currentUserId) {
if (c == null || !"TALENT_INVITATION".equals(c.getType())) return toView(c);
Map<String, Object> 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<String, Object> toView(Message m) {
Map<String, Object> 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<String, Object> toView(Message m, Long currentUserId) {
if (m == null) return null;
return toView(m, currentUserId, isTalentInvitationConversation(m.getConversationId()));
}
private Map<String, Object> toView(Message m, Long currentUserId, boolean talentInvitation) {
if (!talentInvitation) return toView(m);
Map<String, Object> 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);
}
}