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 jsonMap = new HashMap<>(); + jsonMap.put("touser", toUser.trim()); + int agentIdInt; + try { + agentIdInt = Integer.parseInt(agentId.trim()); + } catch (NumberFormatException e) { + throw new IllegalStateException("qywx.app.agentId 非法: " + agentId); + } + jsonMap.put("agentid", agentIdInt); + jsonMap.put("safe", 0); + jsonMap.put("msgtype", "text"); + Map textBlock = new HashMap<>(); + textBlock.put("content", text); + jsonMap.put("text", textBlock); + + String body = JSON.toJSONString(jsonMap); + String resp = HttpRequest.post(SEND + token) + .body(body) + .timeout(30_000) + .execute() + .body(); + log.info("企微主动文本下发 toUser={} respSnippet={}", toUser, + resp != null && resp.length() > 200 ? resp.substring(0, 200) + "..." : resp); + + SendRespones sendResp = JSON.parseObject(resp, SendRespones.class); + if (sendResp == null || !Integer.valueOf(0).equals(sendResp.getErrcode())) { + String err = sendResp != null ? sendResp.getErrmsg() : "空响应"; + throw new IllegalStateException("企微 message/send 失败: " + err); + } + } + + private String getAccessToken() { + if (StrUtil.isBlank(corpId) || StrUtil.isBlank(corpSecret)) { + return null; + } + String cacheKey = WX_ACCESS_TOKEN + corpSecret; + String cached = redisCache.getCacheObject(cacheKey); + if (StrUtil.isNotEmpty(cached)) { + return cached; + } + Map map = new HashMap<>(); + map.put("corpid", corpId.trim()); + map.put("corpsecret", corpSecret.trim()); + String jsonStr = JSON.toJSONString(map); + for (int i = 0; i < 3; i++) { + String responseStr = HttpRequest.post(GET_TOKEN) + .body(jsonStr) + .timeout(20_000) + .execute() + .body(); + if (StrUtil.isNotEmpty(responseStr)) { + GetTokenResponse response = JSON.parseObject(responseStr, GetTokenResponse.class); + if (response != null && Integer.valueOf(0).equals(response.getErrcode()) + && StrUtil.isNotEmpty(response.getAccess_token())) { + redisCache.setCacheObject(cacheKey, response.getAccess_token(), 7200, TimeUnit.SECONDS); + return response.getAccess_token(); + } + log.warn("gettoken 失败 attempt={} body={}", i, responseStr); + } + sleep(500); + } + return null; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 29b5cb0..0887e57 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -70,6 +70,8 @@ jarvis: wecom: bridge-url: https://jarvis.van333.cn/jarvis-api/jarvis/wecom/inbound shared-secret: jarvis_wecom_bridge_change_me + # Jarvis 调 POST /wecom/active-push 时的 Header,须与 ruoyi jarvis.wecom.push-secret 一致 + push-secret: jarvis_wecom_push_change_me qywx: webhook: @@ -80,5 +82,7 @@ qywx: app: corpId: "ww929e7d6493c6336e" agentId: "1000012" + # 与 agentId 对应的应用 Secret(企微后台应用管理),用于 access_token + message/send + secret: "" token: "34NXCtEjkl" encodingAESKey: "bNlE8IhjU34CfXflcBhW3gXPwr8xaEDEhnpUvChvw5i"