diff --git a/src/main/java/cn/van333/wxsend/business/controller/WXWebhookController.java b/src/main/java/cn/van333/wxsend/business/controller/WXWebhookController.java new file mode 100644 index 0000000..6ce2dd5 --- /dev/null +++ b/src/main/java/cn/van333/wxsend/business/controller/WXWebhookController.java @@ -0,0 +1,170 @@ +package cn.van333.wxsend.business.controller; + +import cn.hutool.core.util.StrUtil; +import cn.van333.wxsend.business.model.R; +import cn.van333.wxsend.business.service.LogService; +import cn.van333.wxsend.util.QywxWebhookUtil; +import cn.van333.wxsend.util.TokenUtil; +import cn.van333.wxsend.util.request.WebhookMessageRequest; +import cn.van333.wxsend.util.response.SendRespones; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/wx/webhook") +public class WXWebhookController { + + private static final Logger logger = LoggerFactory.getLogger(LogService.class); + + @Value("${qywx.webhook.key:}") + private String defaultKey; + + @Value("${qywx.webhook.secret:}") + private String defaultSecret; + + @RequestMapping("/send") + @ResponseBody + public R send(@RequestBody WebhookMessageRequest req) { + logger.info("webhook send req: {}", req); + + if (!TokenUtil.checkToken(req.getVanToken())) { + return R.error("vanToken无效"); + } + + String key = StrUtil.isNotBlank(req.getKey()) ? req.getKey() : defaultKey; + String secret = StrUtil.isNotBlank(req.getSecret()) ? req.getSecret() : defaultSecret; + if (StrUtil.isBlank(key)) { + return R.error("缺少webhook key"); + } + if (StrUtil.isBlank(req.getMsgtype())) { + return R.error("缺少msgtype"); + } + + SendRespones resp; + switch (req.getMsgtype().toLowerCase()) { + case "text": + resp = QywxWebhookUtil.sendText(key, secret, req.getContent(), req.getMentionedList(), req.getMentionedMobileList()); + break; + case "markdown": + resp = QywxWebhookUtil.sendMarkdown(key, secret, req.getContent()); + break; + case "image": + if (StrUtil.isNotBlank(req.getImageBase64()) && StrUtil.isNotBlank(req.getImageMd5())) { + resp = QywxWebhookUtil.sendImageByBase64(key, secret, req.getImageBase64(), req.getImageMd5()); + } else if (StrUtil.isNotBlank(req.getImageUrl())) { + resp = QywxWebhookUtil.sendImageByUrl(key, secret, req.getImageUrl()); + } else { + return R.error("image需要imageBase64+imageMd5或imageUrl"); + } + break; + case "news": + if (req.getNewsArticles() == null || req.getNewsArticles().isEmpty()) { + return R.error("news需要articles"); + } + java.util.List> arts = new java.util.ArrayList<>(); + for (cn.van333.wxsend.util.request.NewsArticle a : req.getNewsArticles()) { + java.util.Map m = new java.util.HashMap<>(); + if (StrUtil.isNotBlank(a.getTitle())) m.put("title", a.getTitle()); + if (StrUtil.isNotBlank(a.getDescription())) m.put("description", a.getDescription()); + if (StrUtil.isNotBlank(a.getUrl())) m.put("url", a.getUrl()); + if (StrUtil.isNotBlank(a.getPicurl())) m.put("picurl", a.getPicurl()); + arts.add(m); + } + resp = QywxWebhookUtil.sendNews(key, secret, arts); + break; + case "file": + if (StrUtil.isNotBlank(req.getFileBase64()) && StrUtil.isNotBlank(req.getFileName())) { + byte[] bytes = java.util.Base64.getDecoder().decode(req.getFileBase64()); + resp = QywxWebhookUtil.sendFile(key, secret, bytes, req.getFileName()); + } else if (StrUtil.isNotBlank(req.getFileUrl())) { + try { + cn.hutool.http.HttpResponse r = cn.hutool.http.HttpRequest.get(req.getFileUrl()).execute(); + String disp = r.header("Content-Disposition"); + String name = req.getFileName(); + if (StrUtil.isBlank(name)) { + name = "file"; + } + resp = QywxWebhookUtil.sendFile(key, secret, r.bodyBytes(), name); + } catch (Exception e) { + return R.error("下载文件失败:" + e.getMessage()); + } + } else { + return R.error("file需要fileBase64+fileName或fileUrl"); + } + break; + case "template_card": + if (StrUtil.isBlank(req.getCardType())) { + return R.error("template_card需要cardType"); + } + java.util.Map card = new java.util.HashMap<>(); + card.put("card_type", req.getCardType()); + if (StrUtil.isNotBlank(req.getCardSourceIconUrl()) || StrUtil.isNotBlank(req.getCardSourceDesc())) { + java.util.Map source = new java.util.HashMap<>(); + if (StrUtil.isNotBlank(req.getCardSourceIconUrl())) source.put("icon_url", req.getCardSourceIconUrl()); + if (StrUtil.isNotBlank(req.getCardSourceDesc())) source.put("desc", req.getCardSourceDesc()); + card.put("source", source); + } + if (StrUtil.isNotBlank(req.getCardMainTitle()) || StrUtil.isNotBlank(req.getCardMainTitleDesc())) { + java.util.Map mainTitle = new java.util.HashMap<>(); + if (StrUtil.isNotBlank(req.getCardMainTitle())) mainTitle.put("title", req.getCardMainTitle()); + if (StrUtil.isNotBlank(req.getCardMainTitleDesc())) mainTitle.put("desc", req.getCardMainTitleDesc()); + card.put("main_title", mainTitle); + } + if (StrUtil.isNotBlank(req.getCardEmphasisContentTitle()) || StrUtil.isNotBlank(req.getCardEmphasisContentDesc())) { + java.util.Map emphasis = new java.util.HashMap<>(); + if (StrUtil.isNotBlank(req.getCardEmphasisContentTitle())) emphasis.put("title", req.getCardEmphasisContentTitle()); + if (StrUtil.isNotBlank(req.getCardEmphasisContentDesc())) emphasis.put("desc", req.getCardEmphasisContentDesc()); + card.put("emphasis_content", emphasis); + } + if (StrUtil.isNotBlank(req.getCardQuoteAreaType()) || StrUtil.isNotBlank(req.getCardQuoteAreaText())) { + java.util.Map quote = new java.util.HashMap<>(); + if (StrUtil.isNotBlank(req.getCardQuoteAreaType())) quote.put("type", Integer.parseInt(req.getCardQuoteAreaType())); + if (StrUtil.isNotBlank(req.getCardQuoteAreaText())) quote.put("quote_text", req.getCardQuoteAreaText()); + card.put("quote_area", quote); + } + if (StrUtil.isNotBlank(req.getCardJumpUrl())) { + card.put("card_action", new java.util.HashMap() {{ put("type", 1); put("url", req.getCardJumpUrl()); }}); + } + if (req.getCardHorizontalContents() != null && !req.getCardHorizontalContents().isEmpty()) { + java.util.List> list = new java.util.ArrayList<>(); + for (cn.van333.wxsend.util.request.TemplateCardHorizontalContent hc : req.getCardHorizontalContents()) { + java.util.Map m = new java.util.HashMap<>(); + if (StrUtil.isNotBlank(hc.getKeyname())) m.put("keyname", hc.getKeyname()); + java.util.Map val = new java.util.HashMap<>(); + val.put("type", 0); + if (StrUtil.isNotBlank(hc.getValue())) val.put("content", hc.getValue()); + if (StrUtil.isNotBlank(hc.getUrl())) val.put("url", hc.getUrl()); + m.put("value", val); + list.add(m); + } + card.put("horizontal_content_list", list); + } + if (req.getCardJumps() != null && !req.getCardJumps().isEmpty()) { + java.util.List> list = new java.util.ArrayList<>(); + for (cn.van333.wxsend.util.request.TemplateCardJump j : req.getCardJumps()) { + java.util.Map m = new java.util.HashMap<>(); + if (StrUtil.isNotBlank(j.getType())) m.put("type", Integer.parseInt(j.getType())); + if (StrUtil.isNotBlank(j.getUrl())) m.put("url", j.getUrl()); + if (StrUtil.isNotBlank(j.getTitle())) m.put("title", j.getTitle()); + list.add(m); + } + card.put("jump_list", list); + } + resp = QywxWebhookUtil.sendTemplateCard(key, secret, card); + break; + default: + return R.error("不支持的msgtype"); + } + if (resp.getErrcode() != null && resp.getErrcode().equals(0)) { + return R.ok("Webhook消息发送成功"); + } + return R.error(resp.getErrmsg() == null ? "发送失败" : resp.getErrmsg()); + } +} + + diff --git a/src/main/java/cn/van333/wxsend/util/QywxWebhookUtil.java b/src/main/java/cn/van333/wxsend/util/QywxWebhookUtil.java new file mode 100644 index 0000000..bc90ed3 --- /dev/null +++ b/src/main/java/cn/van333/wxsend/util/QywxWebhookUtil.java @@ -0,0 +1,183 @@ +package cn.van333.wxsend.util; + +import cn.van333.wxsend.business.service.LogService; +import cn.van333.wxsend.util.response.SendRespones; +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.io.resource.ResourceUtil; +import cn.hutool.crypto.digest.DigestUtil; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import com.alibaba.fastjson2.JSON; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static cn.hutool.http.HttpRequest.post; + +/** + * 企业微信群机器人 Webhook 工具 + * 参考文档:https://developer.work.weixin.qq.com/document/path/99110 + */ +public class QywxWebhookUtil { + + private static final Logger logger = LoggerFactory.getLogger(LogService.class); + private static final String QYWX_ORIGIN = "https://qyapi.weixin.qq.com"; + private static final String WEBHOOK_SEND = QYWX_ORIGIN + "/cgi-bin/webhook/send"; + + public static String buildUrl(String key) { + return WEBHOOK_SEND + "?key=" + key; + } + + public static String buildSignedUrl(String key, String secret) { + if (secret == null || secret.isEmpty()) { + return buildUrl(key); + } + long timestamp = System.currentTimeMillis() / 1000; + String stringToSign = timestamp + "\n" + secret; + String sign = hmacSha256Base64(secret, stringToSign); + String encodedSign = URLEncoder.encode(sign, StandardCharsets.UTF_8); + return buildUrl(key) + "×tamp=" + timestamp + "&sign=" + encodedSign; + } + + public static SendRespones sendText(String key, String secret, String content, + List mentionedList, List mentionedMobileList) { + Map body = new HashMap<>(); + body.put("msgtype", "text"); + + Map text = new HashMap<>(); + text.put("content", content); + if (mentionedList != null && !mentionedList.isEmpty()) { + text.put("mentioned_list", mentionedList); + } + if (mentionedMobileList != null && !mentionedMobileList.isEmpty()) { + text.put("mentioned_mobile_list", mentionedMobileList); + } + body.put("text", text); + + return doSend(buildSignedUrl(key, secret), body); + } + + public static SendRespones sendMarkdown(String key, String secret, String content) { + Map body = new HashMap<>(); + body.put("msgtype", "markdown"); + Map markdown = new HashMap<>(); + markdown.put("content", content); + body.put("markdown", markdown); + return doSend(buildSignedUrl(key, secret), body); + } + + public static SendRespones sendImageByBase64(String key, String secret, String base64, String md5) { + Map body = new HashMap<>(); + body.put("msgtype", "image"); + Map image = new HashMap<>(); + image.put("base64", base64); + image.put("md5", md5); + body.put("image", image); + return doSend(buildSignedUrl(key, secret), body); + } + + public static SendRespones sendImageByUrl(String key, String secret, String imageUrl) { + try { + HttpResponse resp = HttpRequest.get(imageUrl).execute(); + byte[] bytes = resp.bodyBytes(); + String base64 = java.util.Base64.getEncoder().encodeToString(bytes); + String md5 = DigestUtil.md5Hex(bytes); + return sendImageByBase64(key, secret, base64, md5); + } catch (Exception e) { + return new SendRespones(500, e.getMessage()); + } + } + + public static SendRespones sendNews(String key, String secret, java.util.List> articles) { + Map body = new HashMap<>(); + body.put("msgtype", "news"); + Map news = new HashMap<>(); + news.put("articles", articles); + body.put("news", news); + return doSend(buildSignedUrl(key, secret), body); + } + + public static SendRespones sendFile(String key, String secret, byte[] fileBytes, String fileName) { + try { + String uploadUrl = QYWX_ORIGIN + "/cgi-bin/webhook/upload_media?key=" + key + "&type=file"; + String boundary = "----WebKitFormBoundary" + System.currentTimeMillis(); + String contentType = "multipart/form-data; boundary=" + boundary; + + StringBuilder sb = new StringBuilder(); + sb.append("--").append(boundary).append("\r\n"); + sb.append("Content-Disposition: form-data; name=\"media\"; filename=\"") + .append(fileName).append("\"\r\n"); + sb.append("Content-Type: application/octet-stream\r\n\r\n"); + + byte[] prefix = sb.toString().getBytes(StandardCharsets.UTF_8); + byte[] suffix = ("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8); + + byte[] body = new byte[prefix.length + fileBytes.length + suffix.length]; + System.arraycopy(prefix, 0, body, 0, prefix.length); + System.arraycopy(fileBytes, 0, body, prefix.length, fileBytes.length); + System.arraycopy(suffix, 0, body, prefix.length + fileBytes.length, suffix.length); + + String responseStr = HttpRequest.post(uploadUrl) + .header("Content-Type", contentType) + .body(body) + .execute().body(); + logger.info("文件上传响应: {}", responseStr); + com.alibaba.fastjson2.JSONObject obj = JSON.parseObject(responseStr); + String mediaId = obj.getString("media_id"); + if (mediaId == null) { + return new SendRespones(500, obj.getString("errmsg")); + } + Map sendBody = new HashMap<>(); + sendBody.put("msgtype", "file"); + Map file = new HashMap<>(); + file.put("media_id", mediaId); + sendBody.put("file", file); + return doSend(buildSignedUrl(key, secret), sendBody); + } catch (Exception e) { + return new SendRespones(500, e.getMessage()); + } + } + + public static SendRespones sendTemplateCard(String key, String secret, Map card) { + Map body = new HashMap<>(); + body.put("msgtype", "template_card"); + body.put("template_card", card); + return doSend(buildSignedUrl(key, secret), body); + } + + private static SendRespones doSend(String url, Map body) { + String json = JSON.toJSONString(body); + logger.info("企业微信 Webhook 请求: url={}, body={}", url, json); + String responseStr = post(url).body(json).execute().body(); + logger.info("企业微信 Webhook 响应: {}", responseStr); + try { + SendRespones resp = JSON.parseObject(responseStr, SendRespones.class); + if (resp == null) { + return new SendRespones(500, "empty response"); + } + return resp; + } catch (Exception e) { + return new SendRespones(500, e.getMessage()); + } + } + + private static String hmacSha256Base64(String secret, String data) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] signData = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + return java.util.Base64.getEncoder().encodeToString(signData); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} + + diff --git a/src/main/java/cn/van333/wxsend/util/request/NewsArticle.java b/src/main/java/cn/van333/wxsend/util/request/NewsArticle.java new file mode 100644 index 0000000..2ff4195 --- /dev/null +++ b/src/main/java/cn/van333/wxsend/util/request/NewsArticle.java @@ -0,0 +1,42 @@ +package cn.van333.wxsend.util.request; + +public class NewsArticle { + private String title; + private String description; + private String url; + private String picurl; + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getPicurl() { + return picurl; + } + + public void setPicurl(String picurl) { + this.picurl = picurl; + } +} + + diff --git a/src/main/java/cn/van333/wxsend/util/request/TemplateCardHorizontalContent.java b/src/main/java/cn/van333/wxsend/util/request/TemplateCardHorizontalContent.java new file mode 100644 index 0000000..b22b689 --- /dev/null +++ b/src/main/java/cn/van333/wxsend/util/request/TemplateCardHorizontalContent.java @@ -0,0 +1,33 @@ +package cn.van333.wxsend.util.request; + +public class TemplateCardHorizontalContent { + private String keyname; + private String value; + private String url; + + public String getKeyname() { + return keyname; + } + + public void setKeyname(String keyname) { + this.keyname = keyname; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } +} + + diff --git a/src/main/java/cn/van333/wxsend/util/request/TemplateCardJump.java b/src/main/java/cn/van333/wxsend/util/request/TemplateCardJump.java new file mode 100644 index 0000000..2b39f42 --- /dev/null +++ b/src/main/java/cn/van333/wxsend/util/request/TemplateCardJump.java @@ -0,0 +1,33 @@ +package cn.van333.wxsend.util.request; + +public class TemplateCardJump { + private String type; // 0: URL 跳转 + private String url; + private String title; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } +} + + diff --git a/src/main/java/cn/van333/wxsend/util/request/WebhookMessageRequest.java b/src/main/java/cn/van333/wxsend/util/request/WebhookMessageRequest.java new file mode 100644 index 0000000..91d4b5f --- /dev/null +++ b/src/main/java/cn/van333/wxsend/util/request/WebhookMessageRequest.java @@ -0,0 +1,251 @@ +package cn.van333.wxsend.util.request; + +import java.util.List; + +public class WebhookMessageRequest { + + private String vanToken; + private String key; + private String secret; // 可选加签 secret + private String msgtype; // text 或 markdown + private String content; // 文本/markdown 内容 + private List mentionedList; + private List mentionedMobileList; + + // image 专用字段(任选其一): + private String imageBase64; // 图片base64(不含 data: 前缀) + private String imageMd5; // 图片md5(32位小写十六进制) + private String imageUrl; // 图片直链,若提供则后端下载计算base64+md5 + + // file 专用字段(任选其一): + private String fileUrl; // 文件直链 + private String fileBase64; // 文件base64 + private String fileName; // 文件名(当使用base64时需要) + + // news 专用字段: + private List newsArticles; + + // template_card 专用字段(简化版,涵盖常用字段) + private String cardType; // text_notice 或 news_notice + private String cardSourceIconUrl; + private String cardSourceDesc; + private String cardMainTitle; + private String cardMainTitleDesc; + private String cardEmphasisContentTitle; + private String cardEmphasisContentDesc; + private String cardQuoteAreaType; + private String cardQuoteAreaText; + private String cardJumpUrl; // 整卡跳转 + private List cardHorizontalContents; + private List cardJumps; + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public String getSecret() { + return secret; + } + + public void setSecret(String secret) { + this.secret = secret; + } + + public String getMsgtype() { + return msgtype; + } + + public void setMsgtype(String msgtype) { + this.msgtype = msgtype; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public List getMentionedList() { + return mentionedList; + } + + public void setMentionedList(List mentionedList) { + this.mentionedList = mentionedList; + } + + public List getMentionedMobileList() { + return mentionedMobileList; + } + + public void setMentionedMobileList(List mentionedMobileList) { + this.mentionedMobileList = mentionedMobileList; + } + + public String getVanToken() { + return vanToken; + } + + public void setVanToken(String vanToken) { + this.vanToken = vanToken; + } + + public String getImageBase64() { + return imageBase64; + } + + public void setImageBase64(String imageBase64) { + this.imageBase64 = imageBase64; + } + + public String getImageMd5() { + return imageMd5; + } + + public void setImageMd5(String imageMd5) { + this.imageMd5 = imageMd5; + } + + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + public String getFileUrl() { + return fileUrl; + } + + public void setFileUrl(String fileUrl) { + this.fileUrl = fileUrl; + } + + public String getFileBase64() { + return fileBase64; + } + + public void setFileBase64(String fileBase64) { + this.fileBase64 = fileBase64; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public List getNewsArticles() { + return newsArticles; + } + + public void setNewsArticles(List newsArticles) { + this.newsArticles = newsArticles; + } + + public String getCardType() { + return cardType; + } + + public void setCardType(String cardType) { + this.cardType = cardType; + } + + public String getCardSourceIconUrl() { + return cardSourceIconUrl; + } + + public void setCardSourceIconUrl(String cardSourceIconUrl) { + this.cardSourceIconUrl = cardSourceIconUrl; + } + + public String getCardSourceDesc() { + return cardSourceDesc; + } + + public void setCardSourceDesc(String cardSourceDesc) { + this.cardSourceDesc = cardSourceDesc; + } + + public String getCardMainTitle() { + return cardMainTitle; + } + + public void setCardMainTitle(String cardMainTitle) { + this.cardMainTitle = cardMainTitle; + } + + public String getCardMainTitleDesc() { + return cardMainTitleDesc; + } + + public void setCardMainTitleDesc(String cardMainTitleDesc) { + this.cardMainTitleDesc = cardMainTitleDesc; + } + + public String getCardEmphasisContentTitle() { + return cardEmphasisContentTitle; + } + + public void setCardEmphasisContentTitle(String cardEmphasisContentTitle) { + this.cardEmphasisContentTitle = cardEmphasisContentTitle; + } + + public String getCardEmphasisContentDesc() { + return cardEmphasisContentDesc; + } + + public void setCardEmphasisContentDesc(String cardEmphasisContentDesc) { + this.cardEmphasisContentDesc = cardEmphasisContentDesc; + } + + public String getCardQuoteAreaType() { + return cardQuoteAreaType; + } + + public void setCardQuoteAreaType(String cardQuoteAreaType) { + this.cardQuoteAreaType = cardQuoteAreaType; + } + + public String getCardQuoteAreaText() { + return cardQuoteAreaText; + } + + public void setCardQuoteAreaText(String cardQuoteAreaText) { + this.cardQuoteAreaText = cardQuoteAreaText; + } + + public String getCardJumpUrl() { + return cardJumpUrl; + } + + public void setCardJumpUrl(String cardJumpUrl) { + this.cardJumpUrl = cardJumpUrl; + } + + public List getCardHorizontalContents() { + return cardHorizontalContents; + } + + public void setCardHorizontalContents(List cardHorizontalContents) { + this.cardHorizontalContents = cardHorizontalContents; + } + + public List getCardJumps() { + return cardJumps; + } + + public void setCardJumps(List cardJumps) { + this.cardJumps = cardJumps; + } +} + + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9143f24..c28119d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -65,3 +65,10 @@ logging: level: cn.van333: debug org.springframework: warn + +qywx: + webhook: + # 默认 webhook key,可在请求体中显式传入key覆盖 + key: "89c1806f-8eb4-4c13-8931-a9e94306d04a" + # 机器人安全设置中的加签secret(可选)。若不开启加签可留空 + secret: ""