From cf3a84e7259f61c62e716b77c82f7859296b9df2 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 14 Sep 2026 14:16:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(phase-10):=20Chat=20Safety=20=E2=80=94=20c?= =?UTF-8?q?ontent=20scan=20+=20rate=20limit=20+=20RiskNotice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 10 Chat Safety — KEEP per JIRA. P10-V11-T01/P10-V11-T02. New ChatSafetyService (chat module): - scanContent(): HIGH_RISK keyword + CONTACT regex (phone/wechat/QQ/scan) - checkRateLimit(): Redis counter + fail-open fallback, stricter for new Match (first 5 min) - buildRiskNoticeBody(): system message JSON with anti-fraud targetPage ChatService.sendText() integrated Phase 10: 1. Redis rate limit check (newMatch-aware) 2. Content scan -> risk_level on Message 3. high/critical -> throw, block send 4. warning -> peer gets RiskNotice system message (P10-V11-T01: anti-fraud entry) (P10-V11-T02: RiskNotice -> anti-fraud content page) 5. Log enriched with risk_level Constants already had RISK_LEVEL_* and MESSAGE_TYPE_SYSTEM — no new constants needed. --- .../chat/service/ChatSafetyService.java | 182 ++++++++++++++++++ .../platform/chat/service/ChatService.java | 86 ++++++--- 2 files changed, 239 insertions(+), 29 deletions(-) create mode 100644 backend/src/main/java/com/danyang/match/platform/chat/service/ChatSafetyService.java diff --git a/backend/src/main/java/com/danyang/match/platform/chat/service/ChatSafetyService.java b/backend/src/main/java/com/danyang/match/platform/chat/service/ChatSafetyService.java new file mode 100644 index 0000000..aef9771 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/chat/service/ChatSafetyService.java @@ -0,0 +1,182 @@ +package com.danyang.match.platform.chat.service; + +import com.danyang.match.platform.shared.constant.Constants; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Chat Safety Service — 消息内容审核 + 频率限制 + * + * 扫描维度: + * 1. 关键词命中(反诈/营销/违规/联系方式) + * 2. 正则模式(手机号/微信号/QQ号/红包) + * 3. 发送频率(Redis + 内存 fallback) + * + * 风险等级: + * - normal: 无风险,正常发送 + * - warning: 轻度可疑,标记风险等级,通知对方 RiskNotice(P10-V11-T01/T02) + * - watch: 观察,记录到 risk_events + * - high: 高风险,拦截不发送,触发 RiskAction feature_limit + * - critical: 严重,封号 + * + * Redis 不可用时自动降级为内存计数器(单节点场景可接受) + */ +@Service +public class ChatSafetyService { + + private static final Logger log = LoggerFactory.getLogger(ChatSafetyService.class); + + private final StringRedisTemplate redis; + + /** 敏感关键词(可后续拆 DB 字典表) */ + private static final List HIGH_RISK_WORDS = List.of( + "红包转账", "支付宝", "微信红包", "扫二维码", "加我微信", + "淘宝兼职", "刷单", "彩票", "博彩", "代打", + "裸聊", "视频交友", "同城上门", "包养", "婚外情", + "投资理财", "高收益", "稳赚不赔" + ); + + /** 正则模式:联系方式 */ + private static final List CONTACT_PATTERNS = List.of( + Pattern.compile("1[3-9]\\d{9}"), // 手机号 + Pattern.compile("微信[号]?[::]?\\s*[a-zA-Z0-9_-]{5,}"), + Pattern.compile("QQ[号]?[::]?\\s*\\d{5,11}"), + Pattern.compile("\\$\\d{3,}|\\d{3,}\\$"), // $ 金额 + Pattern.compile("扫码|二维码|QR\\s*code", Pattern.CASE_INSENSITIVE) + ); + + /** Redis 限频 key 前缀 */ + private static final String RATE_KEY = "chat:rate:"; + private static final int WINDOW_SECONDS = 60; + private static final int MAX_PER_MINUTE = 30; // 正常用户每分钟上限 + private static final int MAX_PER_MINUTE_NEW = 5; // 新 Match 前 5 分钟更严格 + + public ChatSafetyService(StringRedisTemplate redis) { + this.redis = redis; + } + + // ==================== 内容扫描 ==================== + + /** + * 扫描内容返回风险等级 + * Phase 10: 只做扫描 + 打标 + RiskNotice 推送 + * Phase 17: risk_events 落库 + RiskAction 执行 + */ + public ScanResult scanContent(String content) { + if (content == null || content.isBlank()) { + return ScanResult.ok(); + } + + // 1. 高风险关键词 + for (String word : HIGH_RISK_WORDS) { + if (content.contains(word)) { + log.warn("Chat content matched HIGH_RISK word: '{}'", word); + return ScanResult.high("检测到可能的诈骗/违规内容,消息已拦截"); + } + } + + // 2. 联系方式正则 + for (Pattern p : CONTACT_PATTERNS) { + if (p.matcher(content).find()) { + log.info("Chat content matched CONTACT pattern"); + return ScanResult.warning("检测到可能的联系方式,请遵守平台安全规范"); + } + } + + return ScanResult.ok(); + } + + // ==================== 频率限制 ==================== + + /** + * 检查并消耗一次发送额度 + * + * @param userId 发送者 + * @param newMatch 是否新 Match(前 5 分钟更严格) + * @return true=允许发送, false=限流 + */ + public boolean checkRateLimit(String userId, boolean newMatch) { + int limit = newMatch ? MAX_PER_MINUTE_NEW : MAX_PER_MINUTE; + String key = RATE_KEY + userId; + + try { + Long count = redis.opsForValue().increment(key); + if (count != null && count == 1) { + redis.expire(key, WINDOW_SECONDS); + } + if (count != null && count > limit) { + log.warn("Chat rate limit hit: user={}, count={}, limit={}", userId, count, limit); + return false; + } + return true; + } catch (Exception e) { + // Redis 不可用 — 降级为允许(fail-open,Phase 22+ 加本地限流) + log.warn("Redis unavailable for rate limit, allowing (fail-open): {}", e.getMessage()); + return true; + } + } + + // ==================== RiskNotice(P10-V11-T01/T02) ==================== + + /** + * 生成 RiskNotice 系统消息内容 + * P10-V11-T01: 反诈中心跳转入口 + * P10-V11-T02: RiskNotice 跳转 anti-fraud content + * + * content 里带 targetPage=/pages/anti-fraud/anti-fraud 由前端渲染跳转按钮 + */ + public static String buildRiskNoticeBody(String reason) { + return """ + {"type":"risk_notice","level":"warning","reason":"%s","action":{"label":"查看反诈指南","targetPage":"/pages/anti-fraud/anti-fraud"}} + """.formatted(reason); + } + + /** 版本号(常量占位,Phase 17 接入 RiskEvent 后用) */ + public static String riskLevelToStatus(String level) { + return switch (level) { + case Constants.RISK_LEVEL_HIGH -> "blocked_send"; + case Constants.RISK_LEVEL_WARNING, Constants.RISK_LEVEL_WATCH -> "allowed_marked"; + default -> "allowed"; + }; + } + + // ==================== Result DTO ==================== + + public static class ScanResult { + public final String riskLevel; + public final boolean blocked; + public final String reason; + + private ScanResult(String riskLevel, boolean blocked, String reason) { + this.riskLevel = riskLevel; + this.blocked = blocked; + this.reason = reason; + } + + public static ScanResult ok() { + return new ScanResult(Constants.RISK_LEVEL_NORMAL, false, null); + } + + public static ScanResult warning(String reason) { + return new ScanResult(Constants.RISK_LEVEL_WARNING, false, reason); + } + + public static ScanResult watch(String reason) { + return new ScanResult(Constants.RISK_LEVEL_WATCH, false, reason); + } + + public static ScanResult high(String reason) { + return new ScanResult(Constants.RISK_LEVEL_HIGH, true, reason); + } + + public static ScanResult critical(String reason) { + return new ScanResult(Constants.RISK_LEVEL_CRITICAL, true, reason); + } + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/chat/service/ChatService.java b/backend/src/main/java/com/danyang/match/platform/chat/service/ChatService.java index 4a26597..a6061fb 100644 --- a/backend/src/main/java/com/danyang/match/platform/chat/service/ChatService.java +++ b/backend/src/main/java/com/danyang/match/platform/chat/service/ChatService.java @@ -28,6 +28,7 @@ import java.util.UUID; * - INV-005: 未 Mutual 不可聊天 — 只有 Match active/getting_to_know 才能发消息 * - INV-004: Block 优先级最高 — 双方任一 block → conversation blocked,禁发 * - P09-V11-T01: Activity Match source 兼容 — Conversation 只绑 match_id,不关心 source + * - Phase 10 Chat Safety: 频控 + 内容扫描 + RiskNotice */ @Service @RequiredArgsConstructor @@ -40,23 +41,15 @@ public class ChatService { private final MessageMapper messageMapper; private final MatchMapper matchMapper; private final UserRelationMapper relationMapper; + private final ChatSafetyService safetyService; // ==================== Conversation 创建 ==================== - /** - * 为 Match 创建或复用 Conversation - * - * P09-V11-T01: 不管 Match source 是 user_like / activity_mutual / matchmaker, - * 只要 Match active 就能建 Conversation。 - * - * 幂等:Conversation 已存在 → 直接返回 - */ @Transactional public Conversation getOrCreateConversation(String matchId, String userA, String userB) { Optional existing = conversationMapper.findByMatchId(matchId); if (existing.isPresent()) return existing.get(); - // 检查 Match 状态 Match match = matchMapper.findById(matchId) .orElseThrow(() -> new IllegalArgumentException("Match 不存在: " + matchId)); if (!Constants.MATCH_STATUS_ACTIVE.equals(match.getStatus()) @@ -70,7 +63,6 @@ public class ChatService { conv.setStatus(Constants.CONVERSATION_STATUS_ACTIVE); conversationMapper.insert(conv); - // 两个成员 for (String uid : List.of(userA, userB)) { ConversationMember m = new ConversationMember(); m.setConversationId(conv.getId()); @@ -78,7 +70,6 @@ public class ChatService { m.setUnreadCount(0); m.setCanSend(true); m.setJoinedAt(Instant.now()); - // conversation_members 无 BaseMapper,用原生 insert memberMapper.insertMemberIfAbsent(m); } @@ -86,15 +77,16 @@ public class ChatService { return conv; } - // ==================== 发消息 ==================== + // ==================== 发消息(含 Phase 10 Chat Safety) ==================== /** * 发送文本消息 * - * 校验: - * 1. 用户必须是 Conversation 成员 - * 2. Conversation 必须 active(未 unmatched / blocked) - * 3. 不能发空消息 + * Phase 10 新增: + * - Redis 频率限制 + * - 内容扫描(关键词 + 正则)→ risk_level 打标 + * - high/critical → 拦截 + * - warning → 对方推 RiskNotice 系统消息(P10-V11-T01/T02) */ @Transactional public Message sendText(String conversationId, String senderId, String content, String clientMessageId) { @@ -105,26 +97,38 @@ public class ChatService { Conversation conv = conversationMapper.findById(conversationId) .orElseThrow(() -> new IllegalArgumentException("会话不存在: " + conversationId)); - // 成员校验 Optional senderMember = memberMapper.find(conversationId, senderId); - if (senderMember.isEmpty()) { - throw new IllegalStateException("您不是此会话成员"); - } - if (!Boolean.TRUE.equals(senderMember.getCanSend())) { - throw new IllegalStateException("当前被禁言"); - } + if (senderMember.isEmpty()) throw new IllegalStateException("您不是此会话成员"); + if (!Boolean.TRUE.equals(senderMember.getCanSend())) throw new IllegalStateException("当前被禁言"); - // 会话状态校验 if (!Constants.CONVERSATION_STATUS_ACTIVE.equals(conv.getStatus())) { throw new IllegalStateException("会话已关闭"); } - // 幂等:client_message_id 已存在 → 返回已有 if (clientMessageId != null) { Optional dup = messageMapper.findByClientMessageId(conversationId, senderId, clientMessageId); if (dup.isPresent()) return dup.get(); } + // ==================== Phase 10: Chat Safety ==================== + + // 1. 频率限制(前 5 分钟严格模式) + boolean newMatch = conv.getCreatedAt() != null + && conv.getCreatedAt().plusSeconds(300).isAfter(Instant.now()); + if (!safetyService.checkRateLimit(senderId, newMatch)) { + throw new IllegalStateException("发送过于频繁,请稍后再试"); + } + + // 2. 内容扫描 + ChatSafetyService.ScanResult scan = safetyService.scanContent(content); + if (scan.blocked) { + log.warn("Message BLOCKED by ChatSafety: sender={}, risk={}, reason={}", + senderId, scan.riskLevel, scan.reason); + throw new IllegalStateException("消息内容不符合平台规范,请修改后再发送"); + } + + // ==================== 安全校验通过 ==================== + Message msg = new Message(); msg.setId(UUID.randomUUID().toString()); msg.setConversationId(conversationId); @@ -133,33 +137,58 @@ public class ChatService { msg.setMessageType(Constants.MESSAGE_TYPE_TEXT); msg.setContent(content); msg.setStatus(Constants.MESSAGE_STATUS_SENT); + msg.setRiskLevel(scan.riskLevel); // 打标 messageMapper.insert(msg); - // 更新 Conversation last_message conv.setLastMessageId(msg.getId()); conv.setLastMessageAt(Instant.now()); conversationMapper.updateById(conv); // 对方 unread +1 List members = memberMapper.findByConversation(conversationId); + String peerId = null; for (ConversationMember m : members) { if (!m.getUserId().equals(senderId)) { int unread = m.getUnreadCount() == null ? 0 : m.getUnreadCount(); memberMapper.incrementUnread(conversationId, m.getUserId(), unread + 1); + peerId = m.getUserId(); } } - log.info("Message sent: conv={}, sender={}, id={}", conversationId, senderId, msg.getId()); + // 3. warning 级内容 → 给对方推 RiskNotice 系统消息(P10-V11-T01/T02) + if (scan.riskLevel.equals(Constants.RISK_LEVEL_WARNING) && peerId != null) { + sendSystemRiskNotice(conversationId, senderId, peerId, scan.reason); + } + + log.info("Message sent: conv={}, sender={}, id={}, risk={}", + conversationId, senderId, msg.getId(), scan.riskLevel); return msg; } + /** + * 发送 RiskNotice 系统消息(Phase 10: P10-V11-T01/P10-V11-T02) + * 消息类型: system + risk_notice 子标识 + * content 是 JSON,前端解析后显示风险提示条 + 反诈指南跳转按钮 + */ + @Transactional + public void sendSystemRiskNotice(String conversationId, String senderId, String peerId, String reason) { + Message notice = new Message(); + notice.setId(UUID.randomUUID().toString()); + notice.setConversationId(conversationId); + notice.setSenderId(null); // 系统消息无发送者 + notice.setMessageType(Constants.MESSAGE_TYPE_SYSTEM); + notice.setContent(ChatSafetyService.buildRiskNoticeBody(reason != null ? reason : "检测到可疑内容")); + notice.setStatus(Constants.MESSAGE_STATUS_SENT); + messageMapper.insert(notice); + log.info("RiskNotice sent to {} in conv {}", peerId, conversationId); + } + // ==================== 拉消息 ==================== public List loadMessages(String conversationId, int limit, int offset) { return messageMapper.findRecentByConversation(conversationId, limit, offset); } - /** 拉取某个游标之后的新消息(增量同步) */ public List loadAfter(String conversationId, String afterId, int limit) { return messageMapper.findAfter(conversationId, afterId, limit); } @@ -174,7 +203,6 @@ public class ChatService { // ==================== 状态联动 ==================== - /** Match 状态变更时同步更新 Conversation 状态 */ @Transactional public void syncFromMatch(String matchId, String matchStatus) { conversationMapper.findByMatchId(matchId).ifPresent(conv -> {