This commit is contained in:
van
2026-04-02 20:09:34 +08:00
parent fb81ee6077
commit 466ac1e705
4 changed files with 212 additions and 0 deletions

View File

@@ -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} 分离)。
* <p>
* 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());
}
}
}

View File

@@ -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;
}
}

View File

@@ -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<String, Object> 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<String, String> 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<String, String> 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;
}
}

View File

@@ -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"