feat(phase-10): Chat Safety — content scan + rate limit + RiskNotice

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.
This commit is contained in:
Codex
2026-09-14 14:16:47 +08:00
parent eda2ce19a8
commit cf3a84e725
2 changed files with 239 additions and 29 deletions
@@ -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<String> HIGH_RISK_WORDS = List.of(
"红包转账", "支付宝", "微信红包", "扫二维码", "加我微信",
"淘宝兼职", "刷单", "彩票", "博彩", "代打",
"裸聊", "视频交友", "同城上门", "包养", "婚外情",
"投资理财", "高收益", "稳赚不赔"
);
/** 正则模式:联系方式 */
private static final List<Pattern> 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);
}
}
}
@@ -28,6 +28,7 @@ import java.util.UUID;
* - INV-005: 未 Mutual 不可聊天 — 只有 Match active/getting_to_know 才能发消息 * - INV-005: 未 Mutual 不可聊天 — 只有 Match active/getting_to_know 才能发消息
* - INV-004: Block 优先级最高 — 双方任一 block → conversation blocked,禁发 * - INV-004: Block 优先级最高 — 双方任一 block → conversation blocked,禁发
* - P09-V11-T01: Activity Match source 兼容 — Conversation 只绑 match_id,不关心 source * - P09-V11-T01: Activity Match source 兼容 — Conversation 只绑 match_id,不关心 source
* - Phase 10 Chat Safety: 频控 + 内容扫描 + RiskNotice
*/ */
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -40,23 +41,15 @@ public class ChatService {
private final MessageMapper messageMapper; private final MessageMapper messageMapper;
private final MatchMapper matchMapper; private final MatchMapper matchMapper;
private final UserRelationMapper relationMapper; private final UserRelationMapper relationMapper;
private final ChatSafetyService safetyService;
// ==================== Conversation 创建 ==================== // ==================== Conversation 创建 ====================
/**
* 为 Match 创建或复用 Conversation
*
* P09-V11-T01: 不管 Match source 是 user_like / activity_mutual / matchmaker,
* 只要 Match active 就能建 Conversation。
*
* 幂等:Conversation 已存在 → 直接返回
*/
@Transactional @Transactional
public Conversation getOrCreateConversation(String matchId, String userA, String userB) { public Conversation getOrCreateConversation(String matchId, String userA, String userB) {
Optional<Conversation> existing = conversationMapper.findByMatchId(matchId); Optional<Conversation> existing = conversationMapper.findByMatchId(matchId);
if (existing.isPresent()) return existing.get(); if (existing.isPresent()) return existing.get();
// 检查 Match 状态
Match match = matchMapper.findById(matchId) Match match = matchMapper.findById(matchId)
.orElseThrow(() -> new IllegalArgumentException("Match 不存在: " + matchId)); .orElseThrow(() -> new IllegalArgumentException("Match 不存在: " + matchId));
if (!Constants.MATCH_STATUS_ACTIVE.equals(match.getStatus()) if (!Constants.MATCH_STATUS_ACTIVE.equals(match.getStatus())
@@ -70,7 +63,6 @@ public class ChatService {
conv.setStatus(Constants.CONVERSATION_STATUS_ACTIVE); conv.setStatus(Constants.CONVERSATION_STATUS_ACTIVE);
conversationMapper.insert(conv); conversationMapper.insert(conv);
// 两个成员
for (String uid : List.of(userA, userB)) { for (String uid : List.of(userA, userB)) {
ConversationMember m = new ConversationMember(); ConversationMember m = new ConversationMember();
m.setConversationId(conv.getId()); m.setConversationId(conv.getId());
@@ -78,7 +70,6 @@ public class ChatService {
m.setUnreadCount(0); m.setUnreadCount(0);
m.setCanSend(true); m.setCanSend(true);
m.setJoinedAt(Instant.now()); m.setJoinedAt(Instant.now());
// conversation_members 无 BaseMapper,用原生 insert
memberMapper.insertMemberIfAbsent(m); memberMapper.insertMemberIfAbsent(m);
} }
@@ -86,15 +77,16 @@ public class ChatService {
return conv; return conv;
} }
// ==================== 发消息 ==================== // ==================== 发消息(含 Phase 10 Chat Safety) ====================
/** /**
* 发送文本消息 * 发送文本消息
* *
* 校验: * Phase 10 新增:
* 1. 用户必须是 Conversation 成员 * - Redis 频率限制
* 2. Conversation 必须 active(未 unmatched / blocked) * - 内容扫描(关键词 + 正则)→ risk_level 打标
* 3. 不能发空消息 * - high/critical → 拦截
* - warning → 对方推 RiskNotice 系统消息(P10-V11-T01/T02)
*/ */
@Transactional @Transactional
public Message sendText(String conversationId, String senderId, String content, String clientMessageId) { public Message sendText(String conversationId, String senderId, String content, String clientMessageId) {
@@ -105,26 +97,38 @@ public class ChatService {
Conversation conv = conversationMapper.findById(conversationId) Conversation conv = conversationMapper.findById(conversationId)
.orElseThrow(() -> new IllegalArgumentException("会话不存在: " + conversationId)); .orElseThrow(() -> new IllegalArgumentException("会话不存在: " + conversationId));
// 成员校验
Optional<ConversationMember> senderMember = memberMapper.find(conversationId, senderId); Optional<ConversationMember> senderMember = memberMapper.find(conversationId, senderId);
if (senderMember.isEmpty()) { if (senderMember.isEmpty()) throw new IllegalStateException("您不是此会话成员");
throw new IllegalStateException("您不是此会话成员"); if (!Boolean.TRUE.equals(senderMember.getCanSend())) throw new IllegalStateException("当前被禁言");
}
if (!Boolean.TRUE.equals(senderMember.getCanSend())) {
throw new IllegalStateException("当前被禁言");
}
// 会话状态校验
if (!Constants.CONVERSATION_STATUS_ACTIVE.equals(conv.getStatus())) { if (!Constants.CONVERSATION_STATUS_ACTIVE.equals(conv.getStatus())) {
throw new IllegalStateException("会话已关闭"); throw new IllegalStateException("会话已关闭");
} }
// 幂等:client_message_id 已存在 → 返回已有
if (clientMessageId != null) { if (clientMessageId != null) {
Optional<Message> dup = messageMapper.findByClientMessageId(conversationId, senderId, clientMessageId); Optional<Message> dup = messageMapper.findByClientMessageId(conversationId, senderId, clientMessageId);
if (dup.isPresent()) return dup.get(); 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(); Message msg = new Message();
msg.setId(UUID.randomUUID().toString()); msg.setId(UUID.randomUUID().toString());
msg.setConversationId(conversationId); msg.setConversationId(conversationId);
@@ -133,33 +137,58 @@ public class ChatService {
msg.setMessageType(Constants.MESSAGE_TYPE_TEXT); msg.setMessageType(Constants.MESSAGE_TYPE_TEXT);
msg.setContent(content); msg.setContent(content);
msg.setStatus(Constants.MESSAGE_STATUS_SENT); msg.setStatus(Constants.MESSAGE_STATUS_SENT);
msg.setRiskLevel(scan.riskLevel); // 打标
messageMapper.insert(msg); messageMapper.insert(msg);
// 更新 Conversation last_message
conv.setLastMessageId(msg.getId()); conv.setLastMessageId(msg.getId());
conv.setLastMessageAt(Instant.now()); conv.setLastMessageAt(Instant.now());
conversationMapper.updateById(conv); conversationMapper.updateById(conv);
// 对方 unread +1 // 对方 unread +1
List<ConversationMember> members = memberMapper.findByConversation(conversationId); List<ConversationMember> members = memberMapper.findByConversation(conversationId);
String peerId = null;
for (ConversationMember m : members) { for (ConversationMember m : members) {
if (!m.getUserId().equals(senderId)) { if (!m.getUserId().equals(senderId)) {
int unread = m.getUnreadCount() == null ? 0 : m.getUnreadCount(); int unread = m.getUnreadCount() == null ? 0 : m.getUnreadCount();
memberMapper.incrementUnread(conversationId, m.getUserId(), unread + 1); 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; 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<Message> loadMessages(String conversationId, int limit, int offset) { public List<Message> loadMessages(String conversationId, int limit, int offset) {
return messageMapper.findRecentByConversation(conversationId, limit, offset); return messageMapper.findRecentByConversation(conversationId, limit, offset);
} }
/** 拉取某个游标之后的新消息(增量同步) */
public List<Message> loadAfter(String conversationId, String afterId, int limit) { public List<Message> loadAfter(String conversationId, String afterId, int limit) {
return messageMapper.findAfter(conversationId, afterId, limit); return messageMapper.findAfter(conversationId, afterId, limit);
} }
@@ -174,7 +203,6 @@ public class ChatService {
// ==================== 状态联动 ==================== // ==================== 状态联动 ====================
/** Match 状态变更时同步更新 Conversation 状态 */
@Transactional @Transactional
public void syncFromMatch(String matchId, String matchStatus) { public void syncFromMatch(String matchId, String matchStatus) {
conversationMapper.findByMatchId(matchId).ifPresent(conv -> { conversationMapper.findByMatchId(matchId).ifPresent(conv -> {