diff --git a/ruoyi-admin/src/main/java/com/ruoyi/jarvis/wecom/WxSendWeComPushClient.java b/ruoyi-admin/src/main/java/com/ruoyi/jarvis/wecom/WxSendWeComPushClient.java new file mode 100644 index 0000000..16d2c66 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/jarvis/wecom/WxSendWeComPushClient.java @@ -0,0 +1,117 @@ +package com.ruoyi.jarvis.wecom; + +import com.alibaba.fastjson2.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * 调用 wxSend 的企微应用文本主动推送(POST /wecom/active-push)。 + */ +@Component +public class WxSendWeComPushClient { + + private static final Logger log = LoggerFactory.getLogger(WxSendWeComPushClient.class); + + public static final String HEADER_PUSH_SECRET = "X-WxSend-WeCom-Push-Secret"; + + @Value("${jarvis.wecom.wxsend-base-url:}") + private String wxsendBaseUrl; + + @Value("${jarvis.wecom.push-secret:}") + private String pushSecret; + + /** + * 在被动回复返回后延迟再发,保证企微侧先出现首条被动消息。 + */ + public void scheduleActivePushes(String toUser, List contents) { + if (!StringUtils.hasText(wxsendBaseUrl) || !StringUtils.hasText(pushSecret) + || !StringUtils.hasText(toUser) || contents == null || contents.isEmpty()) { + return; + } + final String userId = toUser.trim(); + final List list = new ArrayList<>(contents); + CompletableFuture.runAsync(() -> { + try { + Thread.sleep(450); + String base = normalizeBase(wxsendBaseUrl); + String url = base + "/wecom/active-push"; + for (String c : list) { + if (!StringUtils.hasText(c)) { + continue; + } + postJson(url, userId, c.trim()); + Thread.sleep(120); + } + } catch (Exception e) { + log.warn("企微主动推送任务异常 userId={} msg={}", userId, e.toString()); + } + }); + } + + private static String normalizeBase(String base) { + String b = base.trim(); + if (b.endsWith("/")) { + return b.substring(0, b.length() - 1); + } + return b; + } + + private void postJson(String url, String toUser, String content) { + JSONObject body = new JSONObject(); + body.put("toUser", toUser); + body.put("content", content); + byte[] bytes = body.toJSONString().getBytes(StandardCharsets.UTF_8); + HttpURLConnection conn = null; + try { + conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestMethod("POST"); + conn.setConnectTimeout(15000); + conn.setReadTimeout(60000); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json;charset=UTF-8"); + conn.setRequestProperty(HEADER_PUSH_SECRET, pushSecret); + try (OutputStream os = conn.getOutputStream()) { + os.write(bytes); + } + int code = conn.getResponseCode(); + InputStream is = code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream(); + String resp = readAll(is); + if (code < 200 || code >= 300) { + log.warn("wxSend active-push HTTP {} body={}", code, resp); + } else { + log.debug("wxSend active-push OK http={} resp={}", code, resp); + } + } catch (Exception e) { + log.warn("wxSend active-push 请求失败 url={} err={}", url, e.toString()); + } finally { + if (conn != null) { + conn.disconnect(); + } + } + } + + private static String readAll(InputStream is) throws java.io.IOException { + if (is == null) { + return ""; + } + byte[] buf = new byte[4096]; + StringBuilder sb = new StringBuilder(); + int n; + while ((n = is.read(buf)) >= 0) { + sb.append(new String(buf, 0, n, StandardCharsets.UTF_8)); + } + return sb.toString(); + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/WeComInboundController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/WeComInboundController.java index fa1664a..63756be 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/WeComInboundController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/jarvis/WeComInboundController.java @@ -2,8 +2,10 @@ package com.ruoyi.web.controller.jarvis; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.jarvis.domain.dto.WeComInboundRequest; +import com.ruoyi.jarvis.domain.dto.WeComInboundResult; import com.ruoyi.jarvis.service.IWeComInboundService; import com.ruoyi.jarvis.service.IWeComInboundTraceService; +import com.ruoyi.jarvis.wecom.WxSendWeComPushClient; import org.springframework.beans.factory.annotation.Value; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.*; @@ -28,6 +30,8 @@ public class WeComInboundController { private IWeComInboundService weComInboundService; @Resource private IWeComInboundTraceService weComInboundTraceService; + @Resource + private WxSendWeComPushClient wxSendWeComPushClient; @PostMapping("/inbound") public AjaxResult inbound( @@ -37,10 +41,12 @@ public class WeComInboundController { return AjaxResult.error("拒绝访问"); } WeComInboundRequest req = body != null ? body : new WeComInboundRequest(); - String reply = weComInboundService.handleInbound(req); - weComInboundTraceService.recordInbound(req, reply); - Map data = new HashMap<>(2); - data.put("reply", reply != null ? reply : ""); + WeComInboundResult result = weComInboundService.handleInbound(req); + weComInboundTraceService.recordInbound(req, result.toTraceFullText()); + Map data = new HashMap<>(4); + data.put("reply", result.getPassiveReply()); + data.put("activePushCount", result.getActivePushContents().size()); + wxSendWeComPushClient.scheduleActivePushes(req.getFromUserName(), result.getActivePushContents()); return AjaxResult.success(data); } } diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index 842da4a..b67c16b 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -210,10 +210,24 @@ jarvis: # 企微经 wxSend 调用本接口时校验(须与 wxSend 配置一致) wecom: inbound-secret: jarvis_wecom_bridge_change_me + # wxSend 根地址(无尾斜杠),用于 F 录单等第二条起主动推送;与 wxSend server.port 一致 + wxsend-base-url: http://127.0.0.1:36699 + # 须与 wxSend jarvis.wecom.push-secret 一致(Header X-WxSend-WeCom-Push-Secret) + push-secret: jarvis_wecom_push_change_me # 多轮会话:与 JDUtil interaction_state 类似,TTL 与空闲超时(分钟) session-ttl-minutes: 30 session-idle-timeout-minutes: 30 session-sweep-ms: 60000 + # 企微「开」+ 手机号:Jarvis POST 该局域网接口,将响应中的 reply_text 被动回复给用户 + phone-forward: + enabled: true + base-url: http://192.168.8.60:18080 + path: /v1/forward + connect-timeout-ms: 8000 + # wait_reply 时服务端会等多条 Bot 回复,宜适当加大 + read-timeout-ms: 120000 + wait-reply: true + reply-take-nth: 2 # 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 823d483..3ae0518 100644 --- a/ruoyi-admin/src/main/resources/application-prod.yml +++ b/ruoyi-admin/src/main/resources/application-prod.yml @@ -208,9 +208,19 @@ jarvis: base-url: http://192.168.8.60:5008 wecom: inbound-secret: jarvis_wecom_bridge_change_me + wxsend-base-url: http://127.0.0.1:36699 + push-secret: jarvis_wecom_push_change_me session-ttl-minutes: 30 session-idle-timeout-minutes: 30 session-sweep-ms: 60000 + phone-forward: + enabled: true + base-url: http://192.168.8.60:18080 + path: /v1/forward + connect-timeout-ms: 8000 + read-timeout-ms: 120000 + wait-reply: true + reply-take-nth: 2 # Ollama 大模型服务(监控健康度调试用) ollama: base-url: http://192.168.8.34:11434 diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/dto/WeComInboundResult.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/dto/WeComInboundResult.java new file mode 100644 index 0000000..9fff7e1 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/domain/dto/WeComInboundResult.java @@ -0,0 +1,55 @@ +package com.ruoyi.jarvis.domain.dto; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 企微桥接处理结果:首条走被动回复,其余由 Jarvis 调 wxSend 主动推送。 + */ +public class WeComInboundResult { + + private static final String TRACE_SEP = "\n\n————————————\n\n"; + + private final String passiveReply; + private final List activePushContents; + + public WeComInboundResult(String passiveReply, List activePushContents) { + this.passiveReply = passiveReply != null ? passiveReply : ""; + this.activePushContents = activePushContents != null + ? Collections.unmodifiableList(new ArrayList<>(activePushContents)) + : Collections.emptyList(); + } + + public static WeComInboundResult empty() { + return new WeComInboundResult("", Collections.emptyList()); + } + + public static WeComInboundResult passiveOnly(String passive) { + return new WeComInboundResult(passive, Collections.emptyList()); + } + + public String getPassiveReply() { + return passiveReply; + } + + public List getActivePushContents() { + return activePushContents; + } + + public boolean hasActivePush() { + return !activePushContents.isEmpty(); + } + + /** 追踪表落库:被动 + 主动条文拼在一起便于审计 */ + public String toTraceFullText() { + if (activePushContents.isEmpty()) { + return passiveReply; + } + StringBuilder sb = new StringBuilder(passiveReply); + for (String s : activePushContents) { + sb.append(TRACE_SEP).append(s != null ? s : ""); + } + return sb.toString(); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IWeComInboundService.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IWeComInboundService.java index 652d7c6..dcca8cf 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IWeComInboundService.java +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/IWeComInboundService.java @@ -1,6 +1,7 @@ package com.ruoyi.jarvis.service; import com.ruoyi.jarvis.domain.dto.WeComInboundRequest; +import com.ruoyi.jarvis.domain.dto.WeComInboundResult; /** * 企微文本消息业务入口(由 wxSend 通过 HTTPS + 共享密钥调用) @@ -8,7 +9,7 @@ import com.ruoyi.jarvis.domain.dto.WeComInboundRequest; public interface IWeComInboundService { /** - * @return 被动回复文本;无内容时返回空串 + * 首条进入被动回复;其余由控制器异步调 wxSend /wecom/active-push。 */ - String handleInbound(WeComInboundRequest request); + WeComInboundResult handleInbound(WeComInboundRequest request); } diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/InstructionServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/InstructionServiceImpl.java index d1f5e54..05ea4c0 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/InstructionServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/InstructionServiceImpl.java @@ -658,6 +658,19 @@ public class InstructionServiceImpl implements IInstructionService { } if (input.startsWith("单")) { String primary = handleDanWriteDb(input, forceGenerate, isFromConsole); + String norm = input.trim().replace("元", ""); + if (isNewTemplateDanWriteSuccess(primary) && isNewOrderFormInput(norm)) { + JDOrder parsed = parseOrderFromText(norm); + if (isDistributionMarkF(parsed.getDistributionMark())) { + String compact = buildNewFormDanCompactSummary(parsed, norm); + if (compact != null && !compact.isEmpty()) { + List two = new ArrayList<>(2); + two.add(primary); + two.add(compact); + return two; + } + } + } return Collections.singletonList(primary); } return Collections.singletonList(helpText()); @@ -1301,6 +1314,54 @@ public class InstructionServiceImpl implements IInstructionService { || originalInput.contains("备注(下单号码有变动/没法带分机号的写这里):"); } + /** + * 分销标记 F 录单成功后第二条:型号、地址、物流(物流与成功回显一致压缩 3.cn)。 + */ + private String buildNewFormDanCompactSummary(JDOrder order, String originalInput) { + if (order == null) { + return null; + } + String model = order.getModelNumber(); + String address = order.getAddress(); + String logistics = extractOriginalLogisticsLinkNew(originalInput); + if (logistics == null && order.getLogisticsLink() != null) { + logistics = order.getLogisticsLink(); + } + if (isEmpty(model) && isEmpty(address) && isEmpty(logistics)) { + return null; + } + StringBuilder sb = new StringBuilder(); + sb.append("「发货摘要」\n"); + sb.append("————————————\n"); + if (!isEmpty(model)) { + sb.append("型号:").append(model.trim()).append('\n'); + } + if (!isEmpty(address)) { + sb.append("地址:").append(normalizeWhitespace(address.trim())).append('\n'); + } + if (!isEmpty(logistics)) { + sb.append("物流:").append(shortenLogisticsLineForReply(logistics)); + } + return sb.toString().trim(); + } + + private static boolean isDistributionMarkF(String mark) { + if (mark == null) { + return false; + } + return "F".equalsIgnoreCase(mark.trim()); + } + + private static boolean isNewTemplateDanWriteSuccess(String primary) { + if (primary == null || primary.isEmpty()) { + return false; + } + if (primary.contains("[炸弹]") || primary.contains("录单警告")) { + return false; + } + return primary.contains("「录单」已成功"); + } + // ===== "单 …" 写库 ===== private String handleDanWriteDb(String input) { return handleDanWriteDb(input, false, false); diff --git a/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/OpenPhoneForwardService.java b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/OpenPhoneForwardService.java new file mode 100644 index 0000000..6683d5c --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/jarvis/service/impl/OpenPhoneForwardService.java @@ -0,0 +1,161 @@ +package com.ruoyi.jarvis.service.impl; + +import com.alibaba.fastjson2.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 企微「开」+ 手机号:POST 局域网 /v1/forward,将 JSON 中的 {@code reply_text} 作为回显。 + *

+ * 可配置 {@code wait_reply} / {@code reply_take_nth}:服务端先发 text,再等 Bot 回 N 次, + * 将第 N 条内容填入 {@code reply_text}(前几条如「查询中…」由对端丢弃)。 + *

+ */ +@Service +public class OpenPhoneForwardService { + + private static final Logger log = LoggerFactory.getLogger(OpenPhoneForwardService.class); + + private static final Pattern MOBILE_11 = Pattern.compile("(1\\d{10})"); + + @Value("${jarvis.phone-forward.enabled:false}") + private boolean enabled; + + @Value("${jarvis.phone-forward.base-url:}") + private String baseUrl; + + @Value("${jarvis.phone-forward.path:/v1/forward}") + private String path; + + @Value("${jarvis.phone-forward.connect-timeout-ms:8000}") + private int connectTimeoutMs; + + @Value("${jarvis.phone-forward.read-timeout-ms:60000}") + private int readTimeoutMs; + + /** 为 true 时在 POST body 中携带 wait_reply、reply_take_nth(单次请求生效) */ + @Value("${jarvis.phone-forward.wait-reply:true}") + private boolean waitReply; + + /** 与 wait_reply 配合:取第几条 Bot 回复作为 reply_text,须 ≥ 1 */ + @Value("${jarvis.phone-forward.reply-take-nth:2}") + private int replyTakeNth; + + /** + * @return 非 null 表示本条消息已由本服务处理(含错误提示);null 表示不匹配规则 + */ + public String tryReply(String rawContent) { + if (!enabled || !StringUtils.hasText(baseUrl) || rawContent == null) { + return null; + } + String text = rawContent.trim().replaceFirst("^\uFEFF", ""); + if (!text.startsWith("开")) { + return null; + } + String phone = extractFirstMobile11(text); + if (phone == null) { + return null; + } + return doForward(phone); + } + + private static String extractFirstMobile11(String text) { + Matcher m = MOBILE_11.matcher(text); + if (m.find()) { + return m.group(1); + } + return null; + } + + private String doForward(String phone) { + try { + String base = baseUrl.trim(); + if (base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + String p = path.startsWith("/") ? path : "/" + path; + String q; + try { + q = URLEncoder.encode(phone, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + q = phone; + } + String urlStr = base + p + "?text=" + q; + + JSONObject body = new JSONObject(); + body.put("text", phone); + if (waitReply) { + int nth = replyTakeNth >= 1 ? replyTakeNth : 1; + if (replyTakeNth < 1) { + log.warn("phone-forward reply-take-nth={} 无效,已按 1 处理", replyTakeNth); + } + body.put("wait_reply", true); + body.put("reply_take_nth", nth); + } + byte[] bytes = body.toJSONString().getBytes(StandardCharsets.UTF_8); + + HttpURLConnection conn = null; + try { + conn = (HttpURLConnection) new URL(urlStr).openConnection(); + conn.setRequestMethod("POST"); + conn.setConnectTimeout(connectTimeoutMs); + conn.setReadTimeout(readTimeoutMs); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json;charset=UTF-8"); + conn.setRequestProperty("Accept", "*/*"); + try (OutputStream os = conn.getOutputStream()) { + os.write(bytes); + } + int code = conn.getResponseCode(); + InputStream is = code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream(); + String resp = readAll(is); + if (code < 200 || code >= 300) { + log.warn("phone-forward HTTP {} url={} body={}", code, urlStr, resp); + return "「转发服务」请求失败(HTTP " + code + "),请稍后再试。"; + } + JSONObject jo = JSONObject.parseObject(resp); + if (jo == null) { + return "「转发服务」返回异常,请稍后再试。"; + } + String reply = jo.getString("reply_text"); + if (!StringUtils.hasText(reply)) { + return "「转发服务」未返回 reply_text。"; + } + return reply; + } finally { + if (conn != null) { + conn.disconnect(); + } + } + } catch (Exception e) { + log.warn("phone-forward 异常 phone={} err={}", phone, e.toString()); + return "「转发服务」连接失败,请确认 Jarvis 与局域网服务可达。"; + } + } + + private static String readAll(InputStream is) throws java.io.IOException { + if (is == null) { + return ""; + } + byte[] buf = new byte[4096]; + StringBuilder sb = new StringBuilder(); + int n; + while ((n = is.read(buf)) >= 0) { + sb.append(new String(buf, 0, n, StandardCharsets.UTF_8)); + } + return sb.toString(); + } +} 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 f2ba0b2..8575248 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 @@ -3,6 +3,7 @@ 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.domain.dto.WeComInboundResult; import com.ruoyi.jarvis.service.IInstructionService; import com.ruoyi.jarvis.service.ILogisticsService; import com.ruoyi.jarvis.service.IWeComChatSessionService; @@ -14,6 +15,7 @@ import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import javax.annotation.Resource; +import java.util.ArrayList; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -21,6 +23,7 @@ import java.util.regex.Pattern; /** * LinPingFan:全部指令;其他人员:须在超级管理员中识别为本人(wxid=企微 UserID,**或** 企微 UserID 出现在 touser 逗号分隔列表中),且仅「京*」指令 + 京东分享物流链接流程; * 例外:以「单」或「开始」开头且含「分销标记」的录单正文优先于物流(不进入 3.cn 多轮、不占用物流监听)。 + * 以「开」开头且正文含 11 位手机号(1 开头):POST 配置项 jarvis.phone-forward 指向的局域网服务,回显 reply_text。 * 多轮会话使用 Redis({@link WeComChatSession},键 interaction_state:wecom:{FromUserName}),与旧版「开通礼金」interaction_state 思路一致。 */ @Service @@ -49,7 +52,8 @@ public class WeComInboundServiceImpl implements IWeComInboundService { + "当前账号支持:\n" + "· 「京」开头的统计、订单类指令(可先发「京菜单」查看列表)\n" + "· 含 3.cn 的京东物流分享:先发链接,再发备注\n" - + "· 以「单」或「开始」开头,且含「分销标记」的录单正文\n\n" + + "· 以「单」或「开始」开头,且含「分销标记」的录单正文\n" + + "· 以「开」开头且含手机号的查询\n\n" + "如需其他指令,请联系管理员。"; } @@ -83,11 +87,13 @@ public class WeComInboundServiceImpl implements IWeComInboundService { private ILogisticsService logisticsService; @Resource private IWeComChatSessionService weComChatSessionService; + @Resource + private OpenPhoneForwardService openPhoneForwardService; @Override - public String handleInbound(WeComInboundRequest req) { + public WeComInboundResult handleInbound(WeComInboundRequest req) { if (req == null || !StringUtils.hasText(req.getFromUserName())) { - return ""; + return WeComInboundResult.empty(); } String from = req.getFromUserName().trim(); String content = req.getContent() != null ? req.getContent() : ""; @@ -95,7 +101,12 @@ public class WeComInboundServiceImpl implements IWeComInboundService { SuperAdmin row = superAdminService.selectSuperAdminByWecomUserId(from); boolean isSuper = WE_COM_SUPER_USER_ID.equals(from); if (!isSuper && row == null) { - return replyPermissionDenied(); + return WeComInboundResult.passiveOnly(replyPermissionDenied()); + } + + String openPhoneReply = openPhoneForwardService.tryReply(content); + if (openPhoneReply != null) { + return WeComInboundResult.passiveOnly(truncateReply(openPhoneReply)); } final boolean danRecordPriority = isDanRecordPriorityOverLogistics(content); @@ -110,7 +121,7 @@ public class WeComInboundServiceImpl implements IWeComInboundService { String t = content.trim(); if ("取消".equals(t) || "取消录入".equals(t)) { weComChatSessionService.delete(from); - return replyLogisticsCancelled(); + return WeComInboundResult.passiveOnly(replyLogisticsCancelled()); } if (danRecordPriority) { weComChatSessionService.delete(from); @@ -118,16 +129,15 @@ public class WeComInboundServiceImpl implements IWeComInboundService { } else if (isJdShareLogisticsMessage(content)) { String url = extractJd3cnUrl(content); if (url != null && url.equals(session.getLogisticsUrl())) { - // 备注里夹带同一条 3.cn(整段分享文案),不得再占着「待备注」会话 weComChatSessionService.delete(from); String touser = resolveTouser(row, isSuper); log.info("企微物流会话备注(含同款链接)提交 user={} url={} remarkLen={}", from, url, t.length()); logisticsService.enqueueShareLinkForScan(url, content.trim(), touser); - return replyLogisticsRemarkDone(); + return WeComInboundResult.passiveOnly(replyLogisticsRemarkDone()); } if (url != null) { weComChatSessionService.put(from, WeComChatSession.startLogisticsWaitRemark(url)); - return replyLogisticsWaitRemark(url); + return WeComInboundResult.passiveOnly(replyLogisticsWaitRemark(url)); } } else if (t.startsWith("京")) { weComChatSessionService.delete(from); @@ -138,7 +148,7 @@ public class WeComInboundServiceImpl implements IWeComInboundService { String touser = resolveTouser(row, isSuper); log.info("企微物流会话提交备注 user={} url={} remarkLen={}", from, url, t.length()); logisticsService.enqueueShareLinkForScan(url, content.trim(), touser); - return replyLogisticsRemarkDone(); + return WeComInboundResult.passiveOnly(replyLogisticsRemarkDone()); } } @@ -147,31 +157,43 @@ public class WeComInboundServiceImpl implements IWeComInboundService { if (url != null) { weComChatSessionService.put(from, WeComChatSession.startLogisticsWaitRemark(url)); log.info("企微物流多轮会话已创建 user={} url={}", from, url); - return replyLogisticsWaitRemark(url); + return WeComInboundResult.passiveOnly(replyLogisticsWaitRemark(url)); } } if (!isSuper) { - String cmd = content.trim(); - if (!cmd.startsWith("京") && !danRecordPriority) { - return replyGeneralUserScopeHint(); + String cmd = content.trim().replaceFirst("^\uFEFF", ""); + if (!cmd.startsWith("京") && !danRecordPriority && !cmd.startsWith("开")) { + return WeComInboundResult.passiveOnly(replyGeneralUserScopeHint()); } } List parts = instructionService.execute(content, false, isSuper); if (parts == null || parts.isEmpty()) { + return WeComInboundResult.empty(); + } + if (parts.size() == 1) { + return WeComInboundResult.passiveOnly(truncateReply(parts.get(0))); + } + List active = new ArrayList<>(); + for (int i = 1; i < parts.size(); i++) { + active.add(truncateReply(parts.get(i))); + } + return new WeComInboundResult(truncateReply(parts.get(0)), active); + } + + private static String truncateReply(String reply) { + if (reply == null) { return ""; } - String reply = String.join("\n", parts); if (reply.length() > REPLY_MAX_LEN) { - reply = reply.substring(0, REPLY_MAX_LEN) + REPLY_TRUNCATED_HINT; + return reply.substring(0, REPLY_MAX_LEN) + REPLY_TRUNCATED_HINT; } return reply; } /** * 录单正文(指令层走「单…」写库)优先于物流:与 {@link InstructionServiceImpl} 新模板一致。 - * 典型:以「单:」开头、含「分销标记」「下单链接(必须用这个)」等;同条含 3.cn 也先录单、不进物流多轮。 */ private static boolean isDanRecordPriorityOverLogistics(String text) { if (!StringUtils.hasText(text)) {