1
This commit is contained in:
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,8 @@ jarvis:
|
|||||||
wecom:
|
wecom:
|
||||||
bridge-url: https://jarvis.van333.cn/jarvis-api/jarvis/wecom/inbound
|
bridge-url: https://jarvis.van333.cn/jarvis-api/jarvis/wecom/inbound
|
||||||
shared-secret: jarvis_wecom_bridge_change_me
|
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:
|
qywx:
|
||||||
webhook:
|
webhook:
|
||||||
@@ -80,5 +82,7 @@ qywx:
|
|||||||
app:
|
app:
|
||||||
corpId: "ww929e7d6493c6336e"
|
corpId: "ww929e7d6493c6336e"
|
||||||
agentId: "1000012"
|
agentId: "1000012"
|
||||||
|
# 与 agentId 对应的应用 Secret(企微后台应用管理),用于 access_token + message/send
|
||||||
|
secret: ""
|
||||||
token: "34NXCtEjkl"
|
token: "34NXCtEjkl"
|
||||||
encodingAESKey: "bNlE8IhjU34CfXflcBhW3gXPwr8xaEDEhnpUvChvw5i"
|
encodingAESKey: "bNlE8IhjU34CfXflcBhW3gXPwr8xaEDEhnpUvChvw5i"
|
||||||
|
|||||||
Reference in New Issue
Block a user