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:
@@ -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-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<Conversation> 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<ConversationMember> 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<Message> 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<ConversationMember> 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<Message> loadMessages(String conversationId, int limit, int offset) {
|
||||
return messageMapper.findRecentByConversation(conversationId, limit, offset);
|
||||
}
|
||||
|
||||
/** 拉取某个游标之后的新消息(增量同步) */
|
||||
public List<Message> 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 -> {
|
||||
|
||||
Reference in New Issue
Block a user