From a515ec33fb7c3b5c83a994ce87ae41ab460b9ecb Mon Sep 17 00:00:00 2001 From: van Date: Wed, 1 Apr 2026 02:10:14 +0800 Subject: [PATCH] 1 --- .../src/main/resources/application-dev.yml | 4 + .../src/main/resources/application-prod.yml | 3 + .../jarvis/domain/dto/WeComChatSession.java | 74 +++++++++++ .../service/IWeComChatSessionService.java | 15 +++ .../impl/WeComChatSessionServiceImpl.java | 122 ++++++++++++++++++ .../service/impl/WeComInboundServiceImpl.java | 40 +++--- 6 files changed, 243 insertions(+), 15 deletions(-) create mode 100644 ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/dto/WeComChatSession.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IWeComChatSessionService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/WeComChatSessionServiceImpl.java diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index 33c837d..b12a519 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -208,6 +208,10 @@ jarvis: # 企微经 wxSend 调用本接口时校验(须与 wxSend 配置一致) wecom: inbound-secret: jarvis_wecom_bridge_change_me + # 多轮会话:与 JDUtil interaction_state 类似,TTL 与空闲超时(分钟) + session-ttl-minutes: 30 + session-idle-timeout-minutes: 30 + session-sweep-ms: 60000 # Ollama 大模型服务(监控健康度调试用) ollama: base-url: http://192.168.8.34:11434 diff --git a/ruoyi-admin/src/main/resources/application-prod.yml b/ruoyi-admin/src/main/resources/application-prod.yml index 799f450..daa637f 100644 --- a/ruoyi-admin/src/main/resources/application-prod.yml +++ b/ruoyi-admin/src/main/resources/application-prod.yml @@ -207,6 +207,9 @@ jarvis: base-url: http://192.168.8.60:5008 wecom: inbound-secret: jarvis_wecom_bridge_change_me + session-ttl-minutes: 30 + session-idle-timeout-minutes: 30 + session-sweep-ms: 60000 # Ollama 大模型服务(监控健康度调试用) ollama: base-url: http://192.168.8.34:11434 diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/dto/WeComChatSession.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/dto/WeComChatSession.java new file mode 100644 index 0000000..599cb6d --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/dto/WeComChatSession.java @@ -0,0 +1,74 @@ +package com.ruoyi.jarvis.domain.dto; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * 企微侧多轮交互会话(与 Jarvis_java JDUtil 中 interaction_state + Redis 存 JSON 方式对齐) + */ +public class WeComChatSession { + + public static final String SCENE_JD_LOGISTICS_SHARE = "JD_LOGISTICS_SHARE"; + public static final String STEP_WAIT_REMARK = "WAIT_REMARK"; + + private static final DateTimeFormatter FMT = DateTimeFormatter.ISO_LOCAL_DATE_TIME; + + /** 业务场景 */ + private String scene; + /** 当前步骤 */ + private String step; + /** 已识别的京东 3.cn 物流短链 */ + private String logisticsUrl; + /** 最近一次交互时间(超时清理用) */ + private String lastInteractionTime; + + public static WeComChatSession startLogisticsWaitRemark(String logisticsUrl) { + WeComChatSession s = new WeComChatSession(); + s.setScene(SCENE_JD_LOGISTICS_SHARE); + s.setStep(STEP_WAIT_REMARK); + s.setLogisticsUrl(logisticsUrl); + s.touch(); + return s; + } + + public void touch() { + this.lastInteractionTime = LocalDateTime.now().format(FMT); + } + + public boolean isLogisticsWaitRemark() { + return SCENE_JD_LOGISTICS_SHARE.equals(scene) && STEP_WAIT_REMARK.equals(step) + && logisticsUrl != null && !logisticsUrl.isEmpty(); + } + + public String getScene() { + return scene; + } + + public void setScene(String scene) { + this.scene = scene; + } + + public String getStep() { + return step; + } + + public void setStep(String step) { + this.step = step; + } + + public String getLogisticsUrl() { + return logisticsUrl; + } + + public void setLogisticsUrl(String logisticsUrl) { + this.logisticsUrl = logisticsUrl; + } + + public String getLastInteractionTime() { + return lastInteractionTime; + } + + public void setLastInteractionTime(String lastInteractionTime) { + this.lastInteractionTime = lastInteractionTime; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IWeComChatSessionService.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IWeComChatSessionService.java new file mode 100644 index 0000000..b69a699 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IWeComChatSessionService.java @@ -0,0 +1,15 @@ +package com.ruoyi.jarvis.service; + +import com.ruoyi.jarvis.domain.dto.WeComChatSession; + +/** + * 企微多轮会话(Redis JSON,键前缀与旧版 JDUtil「interaction_state」思路一致:interaction_state:wecom:{userId}) + */ +public interface IWeComChatSessionService { + + WeComChatSession get(String wecomUserId); + + void put(String wecomUserId, WeComChatSession session); + + void delete(String wecomUserId); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/WeComChatSessionServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/WeComChatSessionServiceImpl.java new file mode 100644 index 0000000..b842296 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/WeComChatSessionServiceImpl.java @@ -0,0 +1,122 @@ +package com.ruoyi.jarvis.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.ruoyi.jarvis.domain.dto.WeComChatSession; +import com.ruoyi.jarvis.service.IWeComChatSessionService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +@Service +public class WeComChatSessionServiceImpl implements IWeComChatSessionService { + + private static final Logger log = LoggerFactory.getLogger(WeComChatSessionServiceImpl.class); + + /** 与 JDUtil 的 interaction_state 命名风格一致,避免与个人微信会话键冲突加 wecom 命名空间 */ + public static final String REDIS_PREFIX = "interaction_state:wecom:"; + + private static final DateTimeFormatter FMT = DateTimeFormatter.ISO_LOCAL_DATE_TIME; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + @Value("${jarvis.wecom.session-ttl-minutes:30}") + private long sessionTtlMinutes; + + @Value("${jarvis.wecom.session-idle-timeout-minutes:30}") + private long idleTimeoutMinutes; + + @Override + public WeComChatSession get(String wecomUserId) { + if (!StringUtils.hasText(wecomUserId)) { + return null; + } + String json = stringRedisTemplate.opsForValue().get(REDIS_PREFIX + wecomUserId.trim()); + if (!StringUtils.hasText(json)) { + return null; + } + try { + WeComChatSession s = JSON.parseObject(json, WeComChatSession.class); + if (s == null || !StringUtils.hasText(s.getLastInteractionTime())) { + return null; + } + LocalDateTime last = LocalDateTime.parse(s.getLastInteractionTime(), FMT); + if (ChronoUnit.MINUTES.between(last, LocalDateTime.now()) > idleTimeoutMinutes) { + stringRedisTemplate.delete(REDIS_PREFIX + wecomUserId.trim()); + return null; + } + return s; + } catch (Exception e) { + log.warn("解析企微会话失败 userId={}", wecomUserId, e); + return null; + } + } + + @Override + public void put(String wecomUserId, WeComChatSession session) { + if (!StringUtils.hasText(wecomUserId) || session == null) { + return; + } + session.touch(); + stringRedisTemplate.opsForValue().set( + REDIS_PREFIX + wecomUserId.trim(), + JSON.toJSONString(session), + sessionTtlMinutes, + TimeUnit.MINUTES); + } + + @Override + public void delete(String wecomUserId) { + if (!StringUtils.hasText(wecomUserId)) { + return; + } + stringRedisTemplate.delete(REDIS_PREFIX + wecomUserId.trim()); + } + + /** + * 参考 JDUtil#cleanUpTimeoutStates:按 lastInteractionTime 清理超长空闲会话(Redis TTL 与业务空闲均可生效) + */ + @Scheduled(fixedDelayString = "${jarvis.wecom.session-sweep-ms:60000}") + public void sweepIdleSessions() { + try { + Set keys = stringRedisTemplate.keys(REDIS_PREFIX + "*"); + if (keys == null || keys.isEmpty()) { + return; + } + LocalDateTime now = LocalDateTime.now(); + for (String key : keys) { + String json = stringRedisTemplate.opsForValue().get(key); + if (!StringUtils.hasText(json)) { + continue; + } + try { + WeComChatSession s = JSON.parseObject(json, WeComChatSession.class); + if (s == null || !StringUtils.hasText(s.getLastInteractionTime())) { + stringRedisTemplate.delete(key); + continue; + } + LocalDateTime last = LocalDateTime.parse(s.getLastInteractionTime(), FMT); + if (ChronoUnit.MINUTES.between(last, now) > idleTimeoutMinutes) { + stringRedisTemplate.delete(key); + log.debug("企微会话超时删除 key={}", key); + } + } catch (Exception e) { + log.debug("企微会话扫描跳过 key={} {}", key, e.getMessage()); + } + } + } catch (Exception e) { + log.warn("企微会话扫描异常 {}", e.getMessage()); + } + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/WeComInboundServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/WeComInboundServiceImpl.java index 08e0ab6..ac0304a 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/WeComInboundServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/WeComInboundServiceImpl.java @@ -1,31 +1,34 @@ package com.ruoyi.jarvis.service.impl; import com.ruoyi.jarvis.domain.SuperAdmin; +import com.ruoyi.jarvis.domain.dto.WeComChatSession; import com.ruoyi.jarvis.domain.dto.WeComInboundRequest; import com.ruoyi.jarvis.service.IInstructionService; import com.ruoyi.jarvis.service.ILogisticsService; +import com.ruoyi.jarvis.service.IWeComChatSessionService; import com.ruoyi.jarvis.service.IWeComInboundService; import com.ruoyi.jarvis.service.SuperAdminService; -import org.springframework.data.redis.core.StringRedisTemplate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import javax.annotation.Resource; import java.util.List; -import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; /** - * LinPingFan:全部指令;其他人员:须 super_admin.wxid=企微UserID,且仅「京*」指令 + 京东分享物流链接流程 + * LinPingFan:全部指令;其他人员:须 super_admin.wxid=企微UserID,且仅「京*」指令 + 京东分享物流链接流程。 + * 多轮录入使用 Redis 会话({@link WeComChatSession},键 interaction_state:wecom:*),与旧版「开通礼金」interaction_state 机制一致。 */ @Service public class WeComInboundServiceImpl implements IWeComInboundService { + private static final Logger log = LoggerFactory.getLogger(WeComInboundServiceImpl.class); + public static final String WE_COM_SUPER_USER_ID = "LinPingFan"; - private static final String REDIS_PENDING = "jarvis:wecom:jdlog:wait:"; - private static final int PENDING_TTL_HOURS = 24; private static final Pattern JD_3CN = Pattern.compile("https://3\\.cn/[A-Za-z0-9\\-]+"); private static final int REPLY_MAX_LEN = 3500; @@ -36,7 +39,7 @@ public class WeComInboundServiceImpl implements IWeComInboundService { @Resource private ILogisticsService logisticsService; @Resource - private StringRedisTemplate stringRedisTemplate; + private IWeComChatSessionService weComChatSessionService; @Override public String handleInbound(WeComInboundRequest req) { @@ -52,27 +55,34 @@ public class WeComInboundServiceImpl implements IWeComInboundService { return "无权限:请在后台「超级管理员」中维护企微 UserID(wxid 字段)"; } - String pendingKey = REDIS_PENDING + from; - String pending = stringRedisTemplate.opsForValue().get(pendingKey); - if (StringUtils.hasText(pending)) { + WeComChatSession session = weComChatSessionService.get(from); + if (session != null && !session.isLogisticsWaitRemark()) { + weComChatSessionService.delete(from); + session = null; + } + + if (session != null && session.isLogisticsWaitRemark()) { String t = content.trim(); if ("取消".equals(t) || "取消录入".equals(t)) { - stringRedisTemplate.delete(pendingKey); + weComChatSessionService.delete(from); return "已取消物流链接录入"; } if (isJdShareLogisticsMessage(content)) { String url = extractJd3cnUrl(content); if (url != null) { - stringRedisTemplate.opsForValue().set(pendingKey, url, PENDING_TTL_HOURS, TimeUnit.HOURS); + weComChatSessionService.put(from, WeComChatSession.startLogisticsWaitRemark(url)); return "收到物流链接 " + url + " ,请输入备注信息"; } } if (t.startsWith("京")) { - stringRedisTemplate.delete(pendingKey); + weComChatSessionService.delete(from); + log.info("企微用户 {} 在京指令下中断物流备注会话", from); } else { - stringRedisTemplate.delete(pendingKey); + String url = session.getLogisticsUrl(); + weComChatSessionService.delete(from); String touser = resolveTouser(row, isSuper); - boolean ok = logisticsService.fetchLogisticsByShareLinkAndPush(pending, content.trim(), touser); + log.info("企微物流会话提交备注 user={} url={} remarkLen={}", from, url, t.length()); + boolean ok = logisticsService.fetchLogisticsByShareLinkAndPush(url, content.trim(), touser); return ok ? "已查询并推送运单(接收人见超级管理员 touser)" : "物流查询或推送失败,请稍后重试"; } } @@ -80,7 +90,7 @@ public class WeComInboundServiceImpl implements IWeComInboundService { if (isJdShareLogisticsMessage(content)) { String url = extractJd3cnUrl(content); if (url != null) { - stringRedisTemplate.opsForValue().set(pendingKey, url, PENDING_TTL_HOURS, TimeUnit.HOURS); + weComChatSessionService.put(from, WeComChatSession.startLogisticsWaitRemark(url)); return "收到物流链接 " + url + " ,请输入备注信息"; } }