1
This commit is contained in:
@@ -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<String> contents) {
|
||||||
|
if (!StringUtils.hasText(wxsendBaseUrl) || !StringUtils.hasText(pushSecret)
|
||||||
|
|| !StringUtils.hasText(toUser) || contents == null || contents.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final String userId = toUser.trim();
|
||||||
|
final List<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ package com.ruoyi.web.controller.jarvis;
|
|||||||
|
|
||||||
import com.ruoyi.common.core.domain.AjaxResult;
|
import com.ruoyi.common.core.domain.AjaxResult;
|
||||||
import com.ruoyi.jarvis.domain.dto.WeComInboundRequest;
|
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.IWeComInboundService;
|
||||||
import com.ruoyi.jarvis.service.IWeComInboundTraceService;
|
import com.ruoyi.jarvis.service.IWeComInboundTraceService;
|
||||||
|
import com.ruoyi.jarvis.wecom.WxSendWeComPushClient;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@@ -28,6 +30,8 @@ public class WeComInboundController {
|
|||||||
private IWeComInboundService weComInboundService;
|
private IWeComInboundService weComInboundService;
|
||||||
@Resource
|
@Resource
|
||||||
private IWeComInboundTraceService weComInboundTraceService;
|
private IWeComInboundTraceService weComInboundTraceService;
|
||||||
|
@Resource
|
||||||
|
private WxSendWeComPushClient wxSendWeComPushClient;
|
||||||
|
|
||||||
@PostMapping("/inbound")
|
@PostMapping("/inbound")
|
||||||
public AjaxResult inbound(
|
public AjaxResult inbound(
|
||||||
@@ -37,10 +41,12 @@ public class WeComInboundController {
|
|||||||
return AjaxResult.error("拒绝访问");
|
return AjaxResult.error("拒绝访问");
|
||||||
}
|
}
|
||||||
WeComInboundRequest req = body != null ? body : new WeComInboundRequest();
|
WeComInboundRequest req = body != null ? body : new WeComInboundRequest();
|
||||||
String reply = weComInboundService.handleInbound(req);
|
WeComInboundResult result = weComInboundService.handleInbound(req);
|
||||||
weComInboundTraceService.recordInbound(req, reply);
|
weComInboundTraceService.recordInbound(req, result.toTraceFullText());
|
||||||
Map<String, Object> data = new HashMap<>(2);
|
Map<String, Object> data = new HashMap<>(4);
|
||||||
data.put("reply", reply != null ? reply : "");
|
data.put("reply", result.getPassiveReply());
|
||||||
|
data.put("activePushCount", result.getActivePushContents().size());
|
||||||
|
wxSendWeComPushClient.scheduleActivePushes(req.getFromUserName(), result.getActivePushContents());
|
||||||
return AjaxResult.success(data);
|
return AjaxResult.success(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,10 +210,24 @@ jarvis:
|
|||||||
# 企微经 wxSend 调用本接口时校验(须与 wxSend 配置一致)
|
# 企微经 wxSend 调用本接口时校验(须与 wxSend 配置一致)
|
||||||
wecom:
|
wecom:
|
||||||
inbound-secret: jarvis_wecom_bridge_change_me
|
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 与空闲超时(分钟)
|
# 多轮会话:与 JDUtil interaction_state 类似,TTL 与空闲超时(分钟)
|
||||||
session-ttl-minutes: 30
|
session-ttl-minutes: 30
|
||||||
session-idle-timeout-minutes: 30
|
session-idle-timeout-minutes: 30
|
||||||
session-sweep-ms: 60000
|
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 大模型服务(监控健康度调试用)
|
||||||
ollama:
|
ollama:
|
||||||
base-url: http://192.168.8.34:11434
|
base-url: http://192.168.8.34:11434
|
||||||
|
|||||||
@@ -208,9 +208,19 @@ jarvis:
|
|||||||
base-url: http://192.168.8.60:5008
|
base-url: http://192.168.8.60:5008
|
||||||
wecom:
|
wecom:
|
||||||
inbound-secret: jarvis_wecom_bridge_change_me
|
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-ttl-minutes: 30
|
||||||
session-idle-timeout-minutes: 30
|
session-idle-timeout-minutes: 30
|
||||||
session-sweep-ms: 60000
|
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 大模型服务(监控健康度调试用)
|
||||||
ollama:
|
ollama:
|
||||||
base-url: http://192.168.8.34:11434
|
base-url: http://192.168.8.34:11434
|
||||||
|
|||||||
@@ -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<String> activePushContents;
|
||||||
|
|
||||||
|
public WeComInboundResult(String passiveReply, List<String> 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<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.ruoyi.jarvis.service;
|
package com.ruoyi.jarvis.service;
|
||||||
|
|
||||||
import com.ruoyi.jarvis.domain.dto.WeComInboundRequest;
|
import com.ruoyi.jarvis.domain.dto.WeComInboundRequest;
|
||||||
|
import com.ruoyi.jarvis.domain.dto.WeComInboundResult;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 企微文本消息业务入口(由 wxSend 通过 HTTPS + 共享密钥调用)
|
* 企微文本消息业务入口(由 wxSend 通过 HTTPS + 共享密钥调用)
|
||||||
@@ -8,7 +9,7 @@ import com.ruoyi.jarvis.domain.dto.WeComInboundRequest;
|
|||||||
public interface IWeComInboundService {
|
public interface IWeComInboundService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return 被动回复文本;无内容时返回空串
|
* 首条进入被动回复;其余由控制器异步调 wxSend /wecom/active-push。
|
||||||
*/
|
*/
|
||||||
String handleInbound(WeComInboundRequest request);
|
WeComInboundResult handleInbound(WeComInboundRequest request);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -658,6 +658,19 @@ public class InstructionServiceImpl implements IInstructionService {
|
|||||||
}
|
}
|
||||||
if (input.startsWith("单")) {
|
if (input.startsWith("单")) {
|
||||||
String primary = handleDanWriteDb(input, forceGenerate, isFromConsole);
|
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<String> two = new ArrayList<>(2);
|
||||||
|
two.add(primary);
|
||||||
|
two.add(compact);
|
||||||
|
return two;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return Collections.singletonList(primary);
|
return Collections.singletonList(primary);
|
||||||
}
|
}
|
||||||
return Collections.singletonList(helpText());
|
return Collections.singletonList(helpText());
|
||||||
@@ -1301,6 +1314,54 @@ public class InstructionServiceImpl implements IInstructionService {
|
|||||||
|| originalInput.contains("备注(下单号码有变动/没法带分机号的写这里):");
|
|| 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) {
|
private String handleDanWriteDb(String input) {
|
||||||
return handleDanWriteDb(input, false, false);
|
return handleDanWriteDb(input, false, false);
|
||||||
|
|||||||
@@ -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} 作为回显。
|
||||||
|
* <p>
|
||||||
|
* 可配置 {@code wait_reply} / {@code reply_take_nth}:服务端先发 text,再等 Bot 回 N 次,
|
||||||
|
* 将第 N 条内容填入 {@code reply_text}(前几条如「查询中…」由对端丢弃)。
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package com.ruoyi.jarvis.service.impl;
|
|||||||
import com.ruoyi.jarvis.domain.SuperAdmin;
|
import com.ruoyi.jarvis.domain.SuperAdmin;
|
||||||
import com.ruoyi.jarvis.domain.dto.WeComChatSession;
|
import com.ruoyi.jarvis.domain.dto.WeComChatSession;
|
||||||
import com.ruoyi.jarvis.domain.dto.WeComInboundRequest;
|
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.IInstructionService;
|
||||||
import com.ruoyi.jarvis.service.ILogisticsService;
|
import com.ruoyi.jarvis.service.ILogisticsService;
|
||||||
import com.ruoyi.jarvis.service.IWeComChatSessionService;
|
import com.ruoyi.jarvis.service.IWeComChatSessionService;
|
||||||
@@ -14,6 +15,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
@@ -21,6 +23,7 @@ import java.util.regex.Pattern;
|
|||||||
/**
|
/**
|
||||||
* LinPingFan:全部指令;其他人员:须在超级管理员中识别为本人(wxid=企微 UserID,**或** 企微 UserID 出现在 touser 逗号分隔列表中),且仅「京*」指令 + 京东分享物流链接流程;
|
* LinPingFan:全部指令;其他人员:须在超级管理员中识别为本人(wxid=企微 UserID,**或** 企微 UserID 出现在 touser 逗号分隔列表中),且仅「京*」指令 + 京东分享物流链接流程;
|
||||||
* 例外:以「单」或「开始」开头且含「分销标记」的录单正文优先于物流(不进入 3.cn 多轮、不占用物流监听)。
|
* 例外:以「单」或「开始」开头且含「分销标记」的录单正文优先于物流(不进入 3.cn 多轮、不占用物流监听)。
|
||||||
|
* 以「开」开头且正文含 11 位手机号(1 开头):POST 配置项 jarvis.phone-forward 指向的局域网服务,回显 reply_text。
|
||||||
* 多轮会话使用 Redis({@link WeComChatSession},键 interaction_state:wecom:{FromUserName}),与旧版「开通礼金」interaction_state 思路一致。
|
* 多轮会话使用 Redis({@link WeComChatSession},键 interaction_state:wecom:{FromUserName}),与旧版「开通礼金」interaction_state 思路一致。
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@@ -49,7 +52,8 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
|
|||||||
+ "当前账号支持:\n"
|
+ "当前账号支持:\n"
|
||||||
+ "· 「京」开头的统计、订单类指令(可先发「京菜单」查看列表)\n"
|
+ "· 「京」开头的统计、订单类指令(可先发「京菜单」查看列表)\n"
|
||||||
+ "· 含 3.cn 的京东物流分享:先发链接,再发备注\n"
|
+ "· 含 3.cn 的京东物流分享:先发链接,再发备注\n"
|
||||||
+ "· 以「单」或「开始」开头,且含「分销标记」的录单正文\n\n"
|
+ "· 以「单」或「开始」开头,且含「分销标记」的录单正文\n"
|
||||||
|
+ "· 以「开」开头且含手机号的查询\n\n"
|
||||||
+ "如需其他指令,请联系管理员。";
|
+ "如需其他指令,请联系管理员。";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,11 +87,13 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
|
|||||||
private ILogisticsService logisticsService;
|
private ILogisticsService logisticsService;
|
||||||
@Resource
|
@Resource
|
||||||
private IWeComChatSessionService weComChatSessionService;
|
private IWeComChatSessionService weComChatSessionService;
|
||||||
|
@Resource
|
||||||
|
private OpenPhoneForwardService openPhoneForwardService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String handleInbound(WeComInboundRequest req) {
|
public WeComInboundResult handleInbound(WeComInboundRequest req) {
|
||||||
if (req == null || !StringUtils.hasText(req.getFromUserName())) {
|
if (req == null || !StringUtils.hasText(req.getFromUserName())) {
|
||||||
return "";
|
return WeComInboundResult.empty();
|
||||||
}
|
}
|
||||||
String from = req.getFromUserName().trim();
|
String from = req.getFromUserName().trim();
|
||||||
String content = req.getContent() != null ? req.getContent() : "";
|
String content = req.getContent() != null ? req.getContent() : "";
|
||||||
@@ -95,7 +101,12 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
|
|||||||
SuperAdmin row = superAdminService.selectSuperAdminByWecomUserId(from);
|
SuperAdmin row = superAdminService.selectSuperAdminByWecomUserId(from);
|
||||||
boolean isSuper = WE_COM_SUPER_USER_ID.equals(from);
|
boolean isSuper = WE_COM_SUPER_USER_ID.equals(from);
|
||||||
if (!isSuper && row == null) {
|
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);
|
final boolean danRecordPriority = isDanRecordPriorityOverLogistics(content);
|
||||||
@@ -110,7 +121,7 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
|
|||||||
String t = content.trim();
|
String t = content.trim();
|
||||||
if ("取消".equals(t) || "取消录入".equals(t)) {
|
if ("取消".equals(t) || "取消录入".equals(t)) {
|
||||||
weComChatSessionService.delete(from);
|
weComChatSessionService.delete(from);
|
||||||
return replyLogisticsCancelled();
|
return WeComInboundResult.passiveOnly(replyLogisticsCancelled());
|
||||||
}
|
}
|
||||||
if (danRecordPriority) {
|
if (danRecordPriority) {
|
||||||
weComChatSessionService.delete(from);
|
weComChatSessionService.delete(from);
|
||||||
@@ -118,16 +129,15 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
|
|||||||
} else if (isJdShareLogisticsMessage(content)) {
|
} else if (isJdShareLogisticsMessage(content)) {
|
||||||
String url = extractJd3cnUrl(content);
|
String url = extractJd3cnUrl(content);
|
||||||
if (url != null && url.equals(session.getLogisticsUrl())) {
|
if (url != null && url.equals(session.getLogisticsUrl())) {
|
||||||
// 备注里夹带同一条 3.cn(整段分享文案),不得再占着「待备注」会话
|
|
||||||
weComChatSessionService.delete(from);
|
weComChatSessionService.delete(from);
|
||||||
String touser = resolveTouser(row, isSuper);
|
String touser = resolveTouser(row, isSuper);
|
||||||
log.info("企微物流会话备注(含同款链接)提交 user={} url={} remarkLen={}", from, url, t.length());
|
log.info("企微物流会话备注(含同款链接)提交 user={} url={} remarkLen={}", from, url, t.length());
|
||||||
logisticsService.enqueueShareLinkForScan(url, content.trim(), touser);
|
logisticsService.enqueueShareLinkForScan(url, content.trim(), touser);
|
||||||
return replyLogisticsRemarkDone();
|
return WeComInboundResult.passiveOnly(replyLogisticsRemarkDone());
|
||||||
}
|
}
|
||||||
if (url != null) {
|
if (url != null) {
|
||||||
weComChatSessionService.put(from, WeComChatSession.startLogisticsWaitRemark(url));
|
weComChatSessionService.put(from, WeComChatSession.startLogisticsWaitRemark(url));
|
||||||
return replyLogisticsWaitRemark(url);
|
return WeComInboundResult.passiveOnly(replyLogisticsWaitRemark(url));
|
||||||
}
|
}
|
||||||
} else if (t.startsWith("京")) {
|
} else if (t.startsWith("京")) {
|
||||||
weComChatSessionService.delete(from);
|
weComChatSessionService.delete(from);
|
||||||
@@ -138,7 +148,7 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
|
|||||||
String touser = resolveTouser(row, isSuper);
|
String touser = resolveTouser(row, isSuper);
|
||||||
log.info("企微物流会话提交备注 user={} url={} remarkLen={}", from, url, t.length());
|
log.info("企微物流会话提交备注 user={} url={} remarkLen={}", from, url, t.length());
|
||||||
logisticsService.enqueueShareLinkForScan(url, content.trim(), touser);
|
logisticsService.enqueueShareLinkForScan(url, content.trim(), touser);
|
||||||
return replyLogisticsRemarkDone();
|
return WeComInboundResult.passiveOnly(replyLogisticsRemarkDone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,31 +157,43 @@ public class WeComInboundServiceImpl implements IWeComInboundService {
|
|||||||
if (url != null) {
|
if (url != null) {
|
||||||
weComChatSessionService.put(from, WeComChatSession.startLogisticsWaitRemark(url));
|
weComChatSessionService.put(from, WeComChatSession.startLogisticsWaitRemark(url));
|
||||||
log.info("企微物流多轮会话已创建 user={} url={}", from, url);
|
log.info("企微物流多轮会话已创建 user={} url={}", from, url);
|
||||||
return replyLogisticsWaitRemark(url);
|
return WeComInboundResult.passiveOnly(replyLogisticsWaitRemark(url));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isSuper) {
|
if (!isSuper) {
|
||||||
String cmd = content.trim();
|
String cmd = content.trim().replaceFirst("^\uFEFF", "");
|
||||||
if (!cmd.startsWith("京") && !danRecordPriority) {
|
if (!cmd.startsWith("京") && !danRecordPriority && !cmd.startsWith("开")) {
|
||||||
return replyGeneralUserScopeHint();
|
return WeComInboundResult.passiveOnly(replyGeneralUserScopeHint());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> parts = instructionService.execute(content, false, isSuper);
|
List<String> parts = instructionService.execute(content, false, isSuper);
|
||||||
if (parts == null || parts.isEmpty()) {
|
if (parts == null || parts.isEmpty()) {
|
||||||
|
return WeComInboundResult.empty();
|
||||||
|
}
|
||||||
|
if (parts.size() == 1) {
|
||||||
|
return WeComInboundResult.passiveOnly(truncateReply(parts.get(0)));
|
||||||
|
}
|
||||||
|
List<String> 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 "";
|
return "";
|
||||||
}
|
}
|
||||||
String reply = String.join("\n", parts);
|
|
||||||
if (reply.length() > REPLY_MAX_LEN) {
|
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;
|
return reply;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 录单正文(指令层走「单…」写库)优先于物流:与 {@link InstructionServiceImpl} 新模板一致。
|
* 录单正文(指令层走「单…」写库)优先于物流:与 {@link InstructionServiceImpl} 新模板一致。
|
||||||
* 典型:以「单:」开头、含「分销标记」「下单链接(必须用这个)」等;同条含 3.cn 也先录单、不进物流多轮。
|
|
||||||
*/
|
*/
|
||||||
private static boolean isDanRecordPriorityOverLogistics(String text) {
|
private static boolean isDanRecordPriorityOverLogistics(String text) {
|
||||||
if (!StringUtils.hasText(text)) {
|
if (!StringUtils.hasText(text)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user