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-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 -> {
|
||||||
|
|||||||
Reference in New Issue
Block a user