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.Collections; 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; /** * 与 {@link #scheduleActivePushes(String, String, List)} 相同顺序与延迟,但在当前线程同步执行,并返回是否全部成功。 * {@code wecomCallbackAgentId} 与回调 XML 中 AgentId 一致时须传入,便于 wxSend 选用对应应用 secret 调用 message/send。 */ public boolean pushAfterPassiveDelaySync(String toUser, String wecomCallbackAgentId, List contents) { final String userId = toUser != null ? toUser.trim() : ""; final List list = contents != null ? new ArrayList<>(contents) : Collections.emptyList(); final String agentOpt = StringUtils.hasText(wecomCallbackAgentId) ? wecomCallbackAgentId.trim() : null; if (!StringUtils.hasText(wxsendBaseUrl)) { log.error("企微主动推送未执行:未配置 jarvis.wecom.wxsend-base-url(用户会话中收不到查询结果或报错)"); return false; } if (!StringUtils.hasText(pushSecret)) { log.error("企微主动推送未执行:未配置 jarvis.wecom.push-secret"); return false; } if (!StringUtils.hasText(userId)) { log.error("企微主动推送未执行:目标成员 UserID 为空"); return false; } if (list.isEmpty()) { log.warn("企微主动推送未执行:推送内容为空 userId={}", userId); return false; } try { Thread.sleep(450); String base = normalizeBase(wxsendBaseUrl); String url = base + "/wecom/active-push"; boolean anySent = false; boolean allOk = true; for (String c : list) { if (!StringUtils.hasText(c)) { continue; } anySent = true; if (!postJson(url, userId, c.trim(), agentOpt)) { allOk = false; } Thread.sleep(120); } if (!anySent) { log.error("企微主动推送:无有效正文段 userId={}", userId); return false; } if (!allOk) { log.error("企微主动推送部分失败 userId={}(请检查 wxSend /wecom/active-push 与密钥)", userId); } return allOk; } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error("企微主动推送被中断 userId={}", userId, e); return false; } catch (Exception e) { log.error("企微主动推送异常 userId={}", userId, e); return false; } } /** * 在被动回复返回后再发,保证企微侧先出现首条被动消息。 * 无分段(如「开/慢开」仅异步 TG、被动已单独回执)时不调度,避免空列表告警。 */ public void scheduleActivePushes(String toUser, String wecomCallbackAgentId, List contents) { final String agentPass = wecomCallbackAgentId != null ? wecomCallbackAgentId.trim() : ""; final List list = contents != null ? new ArrayList<>(contents) : Collections.emptyList(); if (list.isEmpty()) { return; } final String userId = toUser != null ? toUser.trim() : ""; CompletableFuture.runAsync(() -> { boolean ok = pushAfterPassiveDelaySync( userId, StringUtils.hasText(agentPass) ? agentPass : null, list); if (!ok) { log.error( "scheduleActivePushes 未完全成功 userId={}(用户可能未收到会话内的后续分段)", userId); } }); } private static String normalizeBase(String base) { String b = base.trim(); if (b.endsWith("/")) { return b.substring(0, b.length() - 1); } return b; } /** @return HTTP 2xx 且无异常时为 true */ private boolean postJson(String url, String toUser, String content, String agentIdOpt) { JSONObject body = new JSONObject(); body.put("toUser", toUser); body.put("content", content); if (StringUtils.hasText(agentIdOpt)) { body.put("agentId", agentIdOpt.trim()); } 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.error("wxSend active-push HTTP {} url={} body={}", code, url, resp); return false; } if (StringUtils.hasText(resp)) { try { JSONObject jo = JSONObject.parseObject(resp); if (jo != null && jo.containsKey("code")) { Integer biz = jo.getInteger("code"); if (biz != null && biz != 200) { log.error( "wxSend active-push 业务失败 http={} code={} msg={} body={}", code, biz, jo.getString("msg"), resp); return false; } } } catch (Exception parseSkip) { // 非 JSON 则仅以 HTTP 为准 } } log.debug("wxSend active-push OK http={} resp={}", code, resp); return true; } catch (Exception e) { log.error("wxSend active-push 请求失败 url={} err={}", url, e.toString(), e); return false; } 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(); } }