diff --git a/src/main/java/cn/van333/wxsend/business/controller/WeComActivePushController.java b/src/main/java/cn/van333/wxsend/business/controller/WeComActivePushController.java new file mode 100644 index 0000000..f185bf4 --- /dev/null +++ b/src/main/java/cn/van333/wxsend/business/controller/WeComActivePushController.java @@ -0,0 +1,55 @@ +package cn.van333.wxsend.business.controller; + +import cn.van333.wxsend.business.dto.WeComActivePushRequest; +import cn.van333.wxsend.business.model.R; +import cn.van333.wxsend.business.service.WeComApplicationTextPushService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; + +/** + * Jarvis 服务端调用的企微应用文本「主动推送」入口(与被动回调 {@link WeComCallbackController} 分离)。 + *
+ * Header: X-WxSend-WeCom-Push-Secret 与 {@code jarvis.wecom.push-secret} 一致
+ */
+@RestController
+@RequestMapping("/wecom")
+public class WeComActivePushController {
+
+ private static final Logger log = LoggerFactory.getLogger(WeComActivePushController.class);
+
+ public static final String HEADER_PUSH_SECRET = "X-WxSend-WeCom-Push-Secret";
+
+ @Value("${jarvis.wecom.push-secret:}")
+ private String pushSecret;
+
+ @Resource
+ private WeComApplicationTextPushService weComApplicationTextPushService;
+
+ @PostMapping(value = "/active-push", consumes = MediaType.APPLICATION_JSON_VALUE)
+ public R activePush(
+ @RequestHeader(value = HEADER_PUSH_SECRET, required = false) String secret,
+ @RequestBody WeComActivePushRequest body) {
+ if (!StringUtils.hasText(pushSecret) || !pushSecret.equals(secret)) {
+ return R.error(403, "拒绝访问");
+ }
+ if (body == null || !StringUtils.hasText(body.getToUser())) {
+ return R.error(400, "toUser 必填");
+ }
+ if (!StringUtils.hasText(body.getContent())) {
+ return R.error(400, "content 不能为空");
+ }
+ try {
+ weComApplicationTextPushService.sendTextToUser(body.getToUser().trim(), body.getContent());
+ return R.ok("sent");
+ } catch (Exception e) {
+ log.warn("企微主动推送失败 toUser={} err={}", body.getToUser(), e.toString());
+ return R.error("推送失败: " + e.getMessage());
+ }
+ }
+}
diff --git a/src/main/java/cn/van333/wxsend/business/dto/WeComActivePushRequest.java b/src/main/java/cn/van333/wxsend/business/dto/WeComActivePushRequest.java
new file mode 100644
index 0000000..d050daa
--- /dev/null
+++ b/src/main/java/cn/van333/wxsend/business/dto/WeComActivePushRequest.java
@@ -0,0 +1,26 @@
+package cn.van333.wxsend.business.dto;
+
+/**
+ * Jarvis 调用的企微应用主动发文本入参(成员 UserID = 明文/XML 中的 FromUserName)
+ */
+public class WeComActivePushRequest {
+
+ private String toUser;
+ private String content;
+
+ public String getToUser() {
+ return toUser;
+ }
+
+ public void setToUser(String toUser) {
+ this.toUser = toUser;
+ }
+
+ public String getContent() {
+ return content;
+ }
+
+ public void setContent(String content) {
+ this.content = content;
+ }
+}
diff --git a/src/main/java/cn/van333/wxsend/business/service/WeComApplicationTextPushService.java b/src/main/java/cn/van333/wxsend/business/service/WeComApplicationTextPushService.java
new file mode 100644
index 0000000..695b0eb
--- /dev/null
+++ b/src/main/java/cn/van333/wxsend/business/service/WeComApplicationTextPushService.java
@@ -0,0 +1,127 @@
+package cn.van333.wxsend.business.service;
+
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.http.HttpRequest;
+import cn.van333.wxsend.business.model.RedisCache;
+import cn.van333.wxsend.util.response.GetTokenResponse;
+import cn.van333.wxsend.util.response.SendRespones;
+import com.alibaba.fastjson2.JSON;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import static cn.hutool.core.thread.ThreadUtil.sleep;
+
+/**
+ * 企微应用「主动」文本消息(message/send),供 Jarvis 经 HTTP 调用 wxSend 端点后由本服务下发。
+ */
+@Service
+public class WeComApplicationTextPushService {
+
+ private static final Logger log = LoggerFactory.getLogger(WeComApplicationTextPushService.class);
+ private static final String QYWX_ORIGIN = "https://qyapi.weixin.qq.com";
+ private static final String GET_TOKEN = QYWX_ORIGIN + "/cgi-bin/gettoken";
+ private static final String SEND = QYWX_ORIGIN + "/cgi-bin/message/send?access_token=";
+ private static final String WX_ACCESS_TOKEN = "WECOM_APP_ACTIVE_PUSH_TOKEN:";
+ /** 企微文本消息 content 上限约 2048,预留余量 */
+ private static final int CONTENT_MAX = 2000;
+
+ @Resource
+ private RedisCache redisCache;
+
+ @Value("${qywx.app.corpId:}")
+ private String corpId;
+
+ @Value("${qywx.app.agentId:}")
+ private String agentId;
+
+ @Value("${qywx.app.secret:}")
+ private String corpSecret;
+
+ public void sendTextToUser(String toUser, String content) throws Exception {
+ if (StrUtil.isBlank(corpId) || StrUtil.isBlank(corpSecret) || StrUtil.isBlank(agentId)) {
+ throw new IllegalStateException("未配置 qywx.app.corpId / agentId / secret,无法主动发应用消息");
+ }
+ if (StrUtil.isBlank(toUser)) {
+ throw new IllegalArgumentException("toUser 不能为空");
+ }
+ String text = content != null ? content : "";
+ if (text.length() > CONTENT_MAX) {
+ text = text.substring(0, CONTENT_MAX) + "…";
+ }
+
+ String token = getAccessToken();
+ if (StrUtil.isBlank(token)) {
+ throw new IllegalStateException("获取企微 access_token 失败");
+ }
+
+ Map