liusheng
2026-11-03 7191c6d38f03f2e1baf39f959227ce9c038e4e96
ruoyi-common/src/main/java/com/ruoyi/common/utils/sms/SmsUtils.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,911 @@
package com.ruoyi.common.utils.sms;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.annotation.JSONField;
import com.ruoyi.common.utils.HttpUtil;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.http.HttpUtils;
import com.ruoyi.common.utils.sign.Md5Utils;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
 * çŸ­ä¿¡å‘送工具类
 *
 * @author ruoyi
 */
@Slf4j
public final class SmsUtils {
    private SmsUtils() {
    }
    /**
     * äº‘MAS æ¯æ‰¹æ¬¡æœ€å¤šæ‰‹æœºå·æ•°é‡
     */
    private static final int YUNMAS_MAX_MOBILES = 5000;
    /**
     * äº‘MAS è¿”回码说明
     */
    private static final Map<String, String> YUNMAS_RSPCOD_DESC = new HashMap<>();
    static {
        YUNMAS_RSPCOD_DESC.put("success", "数据验证通过");
        YUNMAS_RSPCOD_DESC.put("IllegalMac", "mac校验不通过");
        YUNMAS_RSPCOD_DESC.put("IllegalSignId", "无效的签名编码");
        YUNMAS_RSPCOD_DESC.put("InvalidMessage", "非法消息,请求数据解析失败");
        YUNMAS_RSPCOD_DESC.put("InvalidUsrOrPwd", "非法用户名/密码");
        YUNMAS_RSPCOD_DESC.put("NoSignId", "未匹配到对应的签名信息");
        YUNMAS_RSPCOD_DESC.put("TooManyMobiles", "手机号数量超限(>5000)");
    }
    /**
     * äº‘MAS æŽ¥å£é…ç½®ã€‚
     * <p>
     * æ³¨æ„ï¼šsecretKey åªå‚与 mac è®¡ç®—,不能放进请求报文。
     */
    @Data
    public static class YunMasConfig {
        /**
         * æŽ¥å£åœ°å€
         */
        private String url;
        /**
         * ä¼ä¸šåç§°
         */
        private String ecName;
        /**
         * æŽ¥å£è´¦å·ç”¨æˆ·å
         */
        private String apId;
        /**
         * å¯†é’¥ï¼ˆä»…参与 mac è®¡ç®—)
         */
        private String secretKey;
        /**
         * ç­¾åç¼–码
         */
        private String sign;
        /**
         * æ‰©å±•码:精确匹配填空字符串;模糊匹配可填空字符串或自定义扩展码(与服务代码合计不超过20位)
         */
        private String addSerial;
        public String getAddSerial() {
            return addSerial == null ? "" : addSerial;
        }
        /**
         * ä¸½æ°´å¦‡å¹¼é»˜è®¤é…ç½®ï¼ˆæœªåœ¨ sys_config ä¸­é…ç½®æ—¶ä½¿ç”¨ï¼‰
         */
        public static YunMasConfig defaultLishui() {
            YunMasConfig config = new YunMasConfig();
            config.setUrl("http://9.208.5.35:1992/sms/norsubmit");
            config.setEcName("丽水市妇幼保健院");
            config.setApId("dxjkkf");
            config.setSecretKey("1q2w3e4r5t6y");
            config.setSign("D9gKbwdt7");
            config.setAddSerial("");
            return config;
        }
        /**
         * æ ¹æ®é…ç½®å†…容(json)构建,未配置的字段沿用默认值
         *
         * @param configJson å½¢å¦‚ {"url":"...","ecName":"...","apId":"...","secretKey":"...","sign":"...","addSerial":""}
         */
        public static YunMasConfig of(String configJson) {
            YunMasConfig config = defaultLishui();
            if (StringUtils.isEmpty(configJson)) {
                return config;
            }
            JSONObject json;
            try {
                json = JSON.parseObject(configJson);
            } catch (Exception e) {
                log.error("【云MAS】配置解析失败,使用默认配置,配置内容:{}", configJson, e);
                return config;
            }
            if (json == null) {
                return config;
            }
            if (StringUtils.isNotEmpty(json.getString("url"))) {
                config.setUrl(json.getString("url"));
            }
            if (StringUtils.isNotEmpty(json.getString("ecName"))) {
                config.setEcName(json.getString("ecName"));
            }
            if (StringUtils.isNotEmpty(json.getString("apId"))) {
                config.setApId(json.getString("apId"));
            }
            if (StringUtils.isNotEmpty(json.getString("secretKey"))) {
                config.setSecretKey(json.getString("secretKey"));
            }
            if (StringUtils.isNotEmpty(json.getString("sign"))) {
                config.setSign(json.getString("sign"));
            }
            if (json.containsKey("addSerial")) {
                config.setAddSerial(json.getString("addSerial"));
            }
            return config;
        }
        /**
         * å…³é”®å‚数是否齐全
         */
        public boolean isValid() {
            return StringUtils.isNotEmpty(url) && StringUtils.isNotEmpty(ecName)
                    && StringUtils.isNotEmpty(apId) && StringUtils.isNotEmpty(secretKey)
                    && StringUtils.isNotEmpty(sign);
        }
    }
    /**
     * çŸ­ä¿¡å‘送结果
     */
    @Data
    public static class SmsResult {
        /**
         * å¹³å°è¿”回的状态码(success è¡¨ç¤ºæ ¡éªŒé€šè¿‡ï¼‰
         */
        private String rspcod;
        /**
         * æ¶ˆæ¯æ‰¹æ¬¡å·ï¼Œå¤±è´¥æ—¶ä¸ºç©º
         */
        private String msgGroup;
        /**
         * å¹³å°è¿”回的数据校验结果
         */
        private boolean success;
        /**
         * æœ¬åœ°å¼‚常/失败描述,便于排查
         */
        private String message;
        /**
         * æ˜¯å¦å‘送成功(平台校验通过)
         */
        @JSONField(serialize = false, deserialize = false)
        public boolean isOk() {
            return success && "success".equals(rspcod);
        }
        public static SmsResult fail(String message) {
            SmsResult result = new SmsResult();
            result.setSuccess(false);
            result.setMessage(message);
            return result;
        }
    }
    /**
     * æ­å·žå¸‚一医院吴山院区我短信接口
     *
     * @param url      çŸ­ä¿¡URL
     * @param userName çŸ­ä¿¡å¸å·
     * @param passWord çŸ­ä¿¡å¯†ç 
     * @param phone    æ‰‹æœºå·ç»„合如 13500000001,13500000002
     * @param content  çŸ­ä¿¡å†…容
     * @return å‘送结果
     */
    public static String sendSms(String url, String userName, String passWord, String phone, String content) {
        long timestamp = new Date().getTime();
        String sign = Md5Utils.hash(userName + timestamp + Md5Utils.hash(passWord));
        content = "【杭州市肿瘤医院】" + content;
        String jsonMsg = "{" +
                "\"userName\": \"" + userName + "\"," +
                "\"content\": \"" + content + "\"," +
                "\"phoneList\": [" + phone + "]," +
                "\"timestamp\": " + timestamp + "," +
                "\"sign\": \"" + sign + "\"" +
                "}";
        Boolean sendMsg = HttpUtil.postJsonRequest(url, jsonMsg);
        return sendMsg.toString();
    }
    /**
     * æ­å·žå¸‚一医院湖滨院区我短信接口
     *
     * @param url      çŸ­ä¿¡URL
     * @param userName çŸ­ä¿¡å¸å·
     * @param passWord çŸ­ä¿¡å¯†ç 
     * @param phone    æ‰‹æœºå·ç»„合如 13500000001,13500000002
     * @param content  çŸ­ä¿¡å†…容
     * @return å‘送结果
     */
    public static String sendSms1(String url, String userName, String passWord, String phone, String content) {
        long timestamp = new Date().getTime();
        String sign = Md5Utils.hash(userName + timestamp + Md5Utils.hash(passWord));
        content = "【杭州市第一人民医院】" + content;
        String jsonMsg = "{" +
                "\"userName\": \"" + userName + "\"," +
                "\"content\": \"" + content + "\"," +
                "\"phoneList\": [" + phone + "]," +
                "\"timestamp\": " + timestamp + "," +
                "\"sign\": \"" + sign + "\"" +
                "}";
        Boolean sendMsg = HttpUtil.postJsonRequest(url, jsonMsg);
        return sendMsg.toString();
    }
    /**
     * ä¸­å›½ç§»åŠ¨äº‘MAS æ™®é€šçŸ­ä¿¡å‘送(一对一/多对一模式)
     * <p>
     * æŽ¥å£ï¼šPOST {url},报文为 json çš„ base64 ç¼–码。
     * <ul>
     *     <li>mac æŒ‰ ecName、apId、secretKey、mobiles、content、sign、addSerial é¡ºåºæ‹¼æŽ¥åŽå– MD5(32位小写)</li>
     *     <li>secretKey åªå‚与 mac è®¡ç®—,不放进请求报文</li>
     *     <li>签名由平台根据 sign ç¼–码自动追加,content ä¸­ä¸è¦å†æ‰‹å·¥æ‹¼ã€ç­¾åã€‘</li>
     * </ul>
     *
     * @param config  äº‘MAS é…ç½®
     * @param mobiles æ”¶ä¿¡æ‰‹æœºå·ï¼Œè‹±æ–‡é€—号分隔,如 13800138000,13800138001,每批不超过 5000 ä¸ª
     * @param content çŸ­ä¿¡å†…容(mac ç”¨æœ€ç»ˆå†…容计算,计算后不得再修改)
     * @return å‘送结果,通过 {@link SmsResult#isOk()} åˆ¤æ–­æ˜¯å¦æˆåŠŸ
     */
    public static SmsResult sendYunMasSms(YunMasConfig config, String mobiles, String content) {
        if (config == null || !config.isValid()) {
            return SmsResult.fail("云MAS配置不完整");
        }
        if (StringUtils.isEmpty(mobiles)) {
            return SmsResult.fail("收信手机号不能为空");
        }
        if (StringUtils.isEmpty(content)) {
            return SmsResult.fail("短信内容不能为空");
        }
        if (mobiles.split(",").length > YUNMAS_MAX_MOBILES) {
            return SmsResult.fail("手机号数量超限(>" + YUNMAS_MAX_MOBILES + ")");
        }
        String addSerial = config.getAddSerial();
        // 1. mac å¿…须用报文里最终发送的内容计算
        String mac = Md5Utils.hash(config.getEcName() + config.getApId() + config.getSecretKey()
                + mobiles + content + config.getSign() + addSerial);
        // 2. ç”¨ json ç»„装报文,避免内容中的引号/反斜杠破坏报文;secretKey ä¸è½æŠ¥æ–‡
        JSONObject body = new JSONObject(true);
        body.put("ecName", config.getEcName());
        body.put("apId", config.getApId());
        body.put("mobiles", mobiles);
        body.put("content", content);
        body.put("sign", config.getSign());
        body.put("addSerial", addSerial);
        body.put("mac", mac);
        String requestBody = Base64.getEncoder()
                .encodeToString(body.toJSONString().getBytes(StandardCharsets.UTF_8));
        try {
            String resp = HttpUtil.postJsonRequestForString(config.getUrl(), requestBody);
            log.info("【云MAS】发送短信,手机号:{},响应:{}", mobiles, resp);
            if (StringUtils.isEmpty(resp)) {
                return SmsResult.fail("云MAS响应为空");
            }
            SmsResult result = JSON.parseObject(resp, SmsResult.class);
            if (result == null) {
                return SmsResult.fail("云MAS响应解析失败:" + resp);
            }
            if (!result.isOk()) {
                String desc = YUNMAS_RSPCOD_DESC.getOrDefault(result.getRspcod(), "未知错误");
                result.setMessage(desc);
                log.error("【云MAS】发送失败,手机号:{},rspcod:{}({}),响应:{}",
                        mobiles, result.getRspcod(), desc, resp);
            }
            return result;
        } catch (Exception e) {
            log.error("【云MAS】发送异常,手机号:{},原因:{}", mobiles, e.getMessage(), e);
            return SmsResult.fail("云MAS发送异常:" + e.getMessage());
        }
    }
    /**
     * æ­å·žå¸‚一医院吴山院区微信接口
     *
     * @param url    å¾®ä¿¡URL
     * @param phone  æ‰‹æœºå· 13500000001
     * @param idCard èº«ä»½è¯
     * @return å‘送结果
     */
    public static String sendChat(String url, String phone, String idCard) {
        String urlTemp = "http://192.200.54.14:5003/sfjk/SendMessage?sfzh="
                + idCard
                + "&title=您好,邀请您填写出院调查表,请点击填写。&content=待随访&phone=" + phone
                + "&url=" + url + "&key=ff76f8904f5f32b5ee1739e8ea46e60g";
        String sendMsg = "";
        try {
            log.info("【sendChat】市一微信公众号请求参数:{}", urlTemp);
            sendMsg = HttpUtils.sendPost(urlTemp);
            log.info("【sendChat】市一微信公众号返回值:{}", sendMsg);
        } catch (Exception ex) {
            sendMsg = ex.getMessage();
            log.error(ex.getMessage());
        }
        return sendMsg;
    }
    /**
     * ä¸½æ°´å¾®ä¿¡ app-api å‰ç¼€
     */
    private static final String LS_WX_APP_PREFIX = "/app-api";
    /**
     * ä¸½æ°´å¾®ä¿¡å¼€æ”¾æŽ¥å£å‰ç¼€
     */
    private static final String LS_WX_API_PREFIX = LS_WX_APP_PREFIX + "/cross/open-api";
    /**
     * èŽ·å–è®¿é—®ä»¤ç‰Œ
     */
    private static final String LS_WX_TOKEN_PATH = "/token";
    /**
     * èŽ·å–æ¶ˆæ¯æ¨¡æ¿
     */
    private static final String LS_WX_TEMPLATE_PATH = "/getTemplate";
    /**
     * å‘送消息
     */
    private static final String LS_WX_SEND_PATH = "/sendMessage";
    /**
     * è®¿é—®ä»¤ç‰Œè¯·æ±‚头
     */
    private static final String LS_WX_TOKEN_HEADER = "openApiToken";
    /**
     * ä»¤ç‰Œç¼“存(key:baseUrl|appId)
     */
    private static final Map<String, LsWxToken> LS_WX_TOKEN_CACHE = new ConcurrentHashMap<>();
    /**
     * ä»¤ç‰Œæå‰åˆ·æ–°çš„秒数,避免临界过期
     */
    private static final long LS_WX_TOKEN_REFRESH_AHEAD_SECONDS = 60L;
    /**
     * ä¸½æ°´å¾®ä¿¡æŽ¥å£é…ç½®
     */
    @Data
    public static class LsWxConfig {
        /**
         * åº”用名称
         */
        private String appName;
        /**
         * æŽ¥å£æ ¹åœ°å€ï¼Œå¦‚ http://127.0.0.1:8080
         * <p>
         * å¯ä»¥åªå¡« scheme://host:port,也可以填到 /app-api æˆ– /app-api/cross/open-api。
         */
        private String baseUrl;
        /**
         * åº”用ID
         */
        private String appId;
        /**
         * åº”用密钥
         */
        private String appSecret;
        /**
         * é»˜è®¤æ¶ˆæ¯æ¨¡æ¿ID,消息里未指定时使用
         */
        private Long templateId;
        /**
         * é»˜è®¤æ¶ˆæ¯æ¨¡æ¿åç§°ï¼Œæœªé…ç½®æ¨¡æ¿ID时通过 getTemplate æŒ‰åç§°æŸ¥æ‰¾
         */
        private String templateName;
        /**
         * è¿žæŽ¥è¶…时,单位:毫秒
         */
        private Integer connectTimeout = 5000;
        /**
         * è¯»å–超时,单位:毫秒
         */
        private Integer readTimeout = 10000;
        /**
         * ä¸½æ°´é»˜è®¤é…ç½®
         */
        public static LsWxConfig defaultLishui() {
            LsWxConfig config = new LsWxConfig();
            config.setAppName("患者随访");
            config.setBaseUrl("https://wmini.lsfby.com.cn/app-api");
            config.setAppId("kpzEHgZSteCO4lz74");
            config.setAppSecret("HKUDVMJSVTKQADIXV7XXRLNBQM8HNNL7");
            // æ¶ˆæ¯æ¨¡æ¿ï¼šè¯ŠåŽéšè®¿æé†’(未指定模板ID时按名称从 getTemplate æŸ¥è¯¢ï¼‰
            config.setTemplateName("诊后随访提醒");
            return config;
        }
        /**
         * æ ¹æ®é…ç½®å†…容(json)构建,未配置的字段沿用默认值
         *
         * @param configJson å½¢å¦‚
         *                   {"appName":"患者随访","baseUrl":"https://wmini.lsfby.com.cn/app-api","appId":"...","appSecret":"...","templateId":16,"templateName":"诊后随访提醒"}
         */
        public static LsWxConfig of(String configJson) {
            LsWxConfig config = defaultLishui();
            if (StringUtils.isEmpty(configJson)) {
                return config;
            }
            JSONObject json;
            try {
                json = JSON.parseObject(configJson);
            } catch (Exception e) {
                log.error("【丽水微信】配置解析失败,使用默认配置,配置内容:{}", configJson, e);
                return config;
            }
            if (json == null) {
                return config;
            }
            if (StringUtils.isNotEmpty(json.getString("appName"))) {
                config.setAppName(json.getString("appName"));
            }
            if (StringUtils.isNotEmpty(json.getString("baseUrl"))) {
                config.setBaseUrl(json.getString("baseUrl"));
            } else if (StringUtils.isNotEmpty(json.getString("url"))) {
                config.setBaseUrl(json.getString("url"));
            } else if (StringUtils.isNotEmpty(json.getString("address"))) {
                config.setBaseUrl(json.getString("address"));
            }
            if (StringUtils.isNotEmpty(json.getString("appId"))) {
                config.setAppId(json.getString("appId"));
            }
            if (StringUtils.isNotEmpty(json.getString("appSecret"))) {
                config.setAppSecret(json.getString("appSecret"));
            }
            if (json.containsKey("templateId")) {
                config.setTemplateId(json.getLong("templateId"));
            } else if (json.containsKey("id")) {
                config.setTemplateId(json.getLong("id"));
            }
            if (StringUtils.isNotEmpty(json.getString("templateName"))) {
                config.setTemplateName(json.getString("templateName"));
            } else if (StringUtils.isNotEmpty(json.getString("tmpName"))) {
                config.setTemplateName(json.getString("tmpName"));
            }
            if (json.containsKey("connectTimeout")) {
                config.setConnectTimeout(json.getInteger("connectTimeout"));
            }
            if (json.containsKey("readTimeout")) {
                config.setReadTimeout(json.getInteger("readTimeout"));
            }
            return config;
        }
        /**
         * å…³é”®å‚数是否齐全
         */
        public boolean isValid() {
            return StringUtils.isNotEmpty(baseUrl) && StringUtils.isNotEmpty(appId)
                    && StringUtils.isNotEmpty(appSecret);
        }
        /**
         * æ‹¼æŽ¥å¼€æ”¾æŽ¥å£åœ°å€
         */
        private String url(String path) {
            String prefix = baseUrl == null ? "" : baseUrl.trim();
            while (prefix.endsWith("/")) {
                prefix = prefix.substring(0, prefix.length() - 1);
            }
            if (prefix.endsWith(LS_WX_API_PREFIX)) {
                return prefix + path;
            }
            if (prefix.endsWith(LS_WX_APP_PREFIX)) {
                return prefix + "/cross/open-api" + path;
            }
            return prefix + LS_WX_API_PREFIX + path;
        }
    }
    /**
     * ä¸½æ°´å¾®ä¿¡æ¶ˆæ¯å‚数,字段与 sendMessage æŽ¥å£ä¸€è‡´
     */
    @Data
    public static class LsWxMessage {
        /**
         * æ¶ˆæ¯æ¨¡æ¿ID,为空时使用配置中的模板
         */
        private Long id;
        /**
         * æ‚£è€…编号
         */
        private String patientId;
        /**
         * æ‚£è€…姓名
         */
        private String patientName;
        /**
         * è¯ä»¶å·
         */
        private String idCard;
        /**
         * æ‰‹æœºå·ç 
         */
        private String mobile;
        /**
         * å°±è¯Šå¡å·
         */
        private String medCardNo;
        /**
         * é€šçŸ¥æ ‡é¢˜
         */
        private String titleName;
        /**
         * é€šçŸ¥å†…容
         */
        private String content;
        /**
         * é“¾æŽ¥åœ°å€
         */
        private String url;
    }
    /**
     * è®¿é—®ä»¤ç‰Œç¼“存对象
     */
    private static class LsWxToken {
        private final String token;
        private final long expireAt;
        private LsWxToken(String token, long expireAt) {
            this.token = token;
            this.expireAt = expireAt;
        }
        private String getToken() {
            return token;
        }
        private long getExpireAt() {
            return expireAt;
        }
    }
    /**
     * ä¸½æ°´å¾®ä¿¡æ¶ˆæ¯å‘送(先获取/复用访问令牌,再调用 sendMessage)
     * <p>
     * æŽ¥å£æµç¨‹ï¼š
     * <ol>
     *     <li>GET /app-api/cross/open-api/token?appId=...&appSecret=... èŽ·å– openApiToken</li>
     *     <li>POST /app-api/cross/open-api/sendMessage,请求头携带 openApiToken</li>
     * </ol>
     *
     * @param config  ä¸½æ°´å¾®ä¿¡é…ç½®
     * @param message æ¶ˆæ¯å‚æ•°
     * @return æŽ¥å£åŽŸå§‹å“åº”ï¼›æœ¬åœ°æ ¡éªŒå¤±è´¥æ—¶è¿”å›ž {"code":-1,"msg":"失败原因"}
     */
    public static String sendChatLs(LsWxConfig config, LsWxMessage message) {
        if (config == null || !config.isValid()) {
            return lsWxFail("丽水微信配置不完整,请配置 baseUrl、appId、appSecret");
        }
        if (message == null) {
            return lsWxFail("丽水微信消息参数不能为空");
        }
        if (StringUtils.isEmpty(message.getMobile()) && StringUtils.isEmpty(message.getIdCard())
                && StringUtils.isEmpty(message.getPatientId()) && StringUtils.isEmpty(message.getMedCardNo())) {
            return lsWxFail("手机号码、证件号、患者编号、就诊卡号不能全部为空");
        }
        try {
            String token = getLsWxToken(config);
            if (StringUtils.isEmpty(token)) {
                return lsWxFail("获取丽水微信访问令牌失败");
            }
            // æ¨¡æ¿ä¼˜å…ˆçº§ï¼šæ¶ˆæ¯æŒ‡å®š > é…ç½®æŒ‡å®š > æŒ‰æ¨¡æ¿åç§°æŸ¥è¯¢
            Long templateId = message.getId();
            if (templateId == null) {
                templateId = config.getTemplateId();
            }
            if (templateId == null) {
                templateId = findLsWxTemplateId(config, token, config.getTemplateName());
                if (templateId == null && StringUtils.isNotEmpty(config.getTemplateName())) {
                    // å¯èƒ½æ˜¯ç¼“存的令牌已失效,刷新令牌后重试一次
                    LS_WX_TOKEN_CACHE.remove(tokenCacheKey(config));
                    token = getLsWxToken(config);
                    if (StringUtils.isNotEmpty(token)) {
                        templateId = findLsWxTemplateId(config, token, config.getTemplateName());
                    }
                }
            }
            if (templateId == null) {
                return lsWxFail("未找到丽水微信消息模板,请指定消息模板ID或模板名称");
            }
            JSONObject body = new JSONObject(true);
            body.put("id", templateId);
            body.put("patientId", nvl(message.getPatientId()));
            body.put("patientName", nvl(message.getPatientName()));
            body.put("idCard", nvl(message.getIdCard()));
            body.put("mobile", message.getMobile());
            body.put("medCardNo", nvl(message.getMedCardNo()));
            body.put("titleName", nvl(message.getTitleName()));
            body.put("content", nvl(message.getContent()));
            body.put("url", nvl(message.getUrl()));
            String requestBody = body.toJSONString();
            Map<String, String> headers = new HashMap<>(2);
            headers.put(LS_WX_TOKEN_HEADER, token);
            String sendUrl = config.url(LS_WX_SEND_PATH);
            log.info("【丽水微信】发送消息,地址:{},参数:{}", sendUrl, requestBody);
            String resp = lsWxRequest("POST", sendUrl, requestBody, headers, config);
            log.info("【丽水微信】发送结果:{}", resp);
            // ä»¤ç‰Œå¤±æ•ˆæ—¶æ¸…除缓存并重试一次
            if (isLsWxTokenInvalid(resp)) {
                LS_WX_TOKEN_CACHE.remove(tokenCacheKey(config));
                token = getLsWxToken(config);
                if (StringUtils.isNotEmpty(token)) {
                    headers.put(LS_WX_TOKEN_HEADER, token);
                    resp = lsWxRequest("POST", sendUrl, requestBody, headers, config);
                    log.info("【丽水微信】令牌失效重试结果:{}", resp);
                }
            }
            return resp;
        } catch (Exception e) {
            log.error("【丽水微信】发送异常,手机号:{},原因:{}", message.getMobile(), e.getMessage(), e);
            return lsWxFail("丽水微信发送异常:" + e.getMessage());
        }
    }
    /**
     * ä¸½æ°´å¾®ä¿¡æ¶ˆæ¯å‘送(常用随访参数)
     *
     * @param config      ä¸½æ°´å¾®ä¿¡é…ç½®
     * @param mobile      æ‰‹æœºå·ç 
     * @param idCard      è¯ä»¶å·
     * @param patientName æ‚£è€…姓名
     * @param titleName   é€šçŸ¥æ ‡é¢˜
     * @param content     é€šçŸ¥å†…容
     * @param url         é“¾æŽ¥åœ°å€
     * @return æŽ¥å£åŽŸå§‹å“åº”ï¼›æœ¬åœ°æ ¡éªŒå¤±è´¥æ—¶è¿”å›ž {"code":-1,"msg":"失败原因"}
     */
    public static String sendChatLs(LsWxConfig config, String mobile, String idCard, String patientName,
                                    String titleName, String content, String url) {
        LsWxMessage message = new LsWxMessage();
        message.setMobile(mobile);
        message.setIdCard(idCard);
        message.setPatientName(patientName);
        message.setTitleName(titleName);
        message.setContent(content);
        message.setUrl(url);
        return sendChatLs(config, message);
    }
    /**
     * ä¸½æ°´å¾®ä¿¡æ¶ˆæ¯å‘送(配置直接传 sys_config ä¸­çš„ json)
     *
     * @param configJson  ä¸½æ°´å¾®ä¿¡é…ç½® json
     * @param mobile      æ‰‹æœºå·ç 
     * @param idCard      è¯ä»¶å·
     * @param patientName æ‚£è€…姓名
     * @param titleName   é€šçŸ¥æ ‡é¢˜
     * @param content     é€šçŸ¥å†…容
     * @param url         é“¾æŽ¥åœ°å€
     * @return æŽ¥å£åŽŸå§‹å“åº”ï¼›æœ¬åœ°æ ¡éªŒå¤±è´¥æ—¶è¿”å›ž {"code":-1,"msg":"失败原因"}
     */
    public static String sendChatLs(String configJson, String mobile, String idCard, String patientName,
                                    String titleName, String content, String url) {
        return sendChatLs(LsWxConfig.of(configJson), mobile, idCard, patientName, titleName, content, url);
    }
    /**
     * èŽ·å–ä¸½æ°´å¾®ä¿¡è®¿é—®ä»¤ç‰Œï¼Œä¼˜å…ˆä½¿ç”¨ç¼“å­˜
     */
    private static String getLsWxToken(LsWxConfig config) throws IOException {
        String cacheKey = tokenCacheKey(config);
        long now = System.currentTimeMillis();
        LsWxToken cached = LS_WX_TOKEN_CACHE.get(cacheKey);
        if (cached != null && cached.getExpireAt() > now) {
            return cached.getToken();
        }
        String url = config.url(LS_WX_TOKEN_PATH)
                + "?appId=" + encode(config.getAppId())
                + "&appSecret=" + encode(config.getAppSecret());
        String resp = lsWxRequest("GET", url, null, null, config);
        log.info("【丽水微信】获取token响应:{}", resp);
        if (StringUtils.isEmpty(resp)) {
            return null;
        }
        JSONObject json = JSON.parseObject(resp);
        if (json == null || json.getIntValue("code") != 0) {
            log.error("【丽水微信】获取token失败:{}", resp);
            return null;
        }
        JSONObject data = json.getJSONObject("data");
        String token = data == null ? null : data.getString("token");
        if (StringUtils.isEmpty(token)) {
            log.error("【丽水微信】获取token失败,响应中没有token:{}", resp);
            return null;
        }
        long expiresIn = data.getLongValue("expiresIn");
        if (expiresIn <= 0) {
            expiresIn = 3000L;
        }
        long expireAt = now + Math.max(expiresIn - LS_WX_TOKEN_REFRESH_AHEAD_SECONDS, 1L) * 1000L;
        LS_WX_TOKEN_CACHE.put(cacheKey, new LsWxToken(token, expireAt));
        return token;
    }
    /**
     * æ ¹æ®æ¨¡æ¿åç§°æŸ¥è¯¢æ¨¡æ¿ID
     */
    private static Long findLsWxTemplateId(LsWxConfig config, String token, String templateName) throws IOException {
        if (StringUtils.isEmpty(templateName)) {
            return null;
        }
        String url = config.url(LS_WX_TEMPLATE_PATH) + "?appId=" + encode(config.getAppId());
        Map<String, String> headers = new HashMap<>(2);
        headers.put(LS_WX_TOKEN_HEADER, token);
        String resp = lsWxRequest("GET", url, null, headers, config);
        log.info("【丽水微信】获取模板响应:{}", resp);
        if (StringUtils.isEmpty(resp)) {
            return null;
        }
        JSONObject json = JSON.parseObject(resp);
        if (json == null || json.getIntValue("code") != 0) {
            log.error("【丽水微信】获取模板失败:{}", resp);
            return null;
        }
        JSONArray data = json.getJSONArray("data");
        if (data == null) {
            return null;
        }
        for (int i = 0; i < data.size(); i++) {
            JSONObject item = data.getJSONObject(i);
            if (item != null && templateName.equals(item.getString("tmpName"))) {
                return item.getLong("id");
            }
        }
        log.error("【丽水微信】未找到模板:{}", templateName);
        return null;
    }
    /**
     * åˆ¤æ–­å“åº”是否属于令牌失效
     */
    private static boolean isLsWxTokenInvalid(String resp) {
        if (StringUtils.isEmpty(resp)) {
            return false;
        }
        try {
            JSONObject json = JSON.parseObject(resp);
            if (json == null) {
                return false;
            }
            int code = json.getIntValue("code");
            if (code == 401 || code == 403) {
                return true;
            }
            String msg = json.getString("msg");
            if (StringUtils.isEmpty(msg)) {
                return false;
            }
            String lower = msg.toLowerCase();
            return (lower.contains("token") || msg.contains("令牌"))
                    && (lower.contains("expire") || lower.contains("invalid")
                    || msg.contains("失效") || msg.contains("过期"));
        } catch (Exception e) {
            return false;
        }
    }
    /**
     * å‘送丽水微信 HTTP è¯·æ±‚
     */
    private static String lsWxRequest(String method, String url, String body,
                                      Map<String, String> headers, LsWxConfig config) throws IOException {
        HttpURLConnection connection = null;
        try {
            connection = (HttpURLConnection) new URL(url).openConnection();
            connection.setRequestMethod(method);
            connection.setConnectTimeout(config.getConnectTimeout() == null ? 5000 : config.getConnectTimeout());
            connection.setReadTimeout(config.getReadTimeout() == null ? 10000 : config.getReadTimeout());
            connection.setRequestProperty("Accept", "*/*");
            if (headers != null) {
                for (Map.Entry<String, String> entry : headers.entrySet()) {
                    connection.setRequestProperty(entry.getKey(), entry.getValue());
                }
            }
            if (body != null) {
                connection.setDoOutput(true);
                connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
                try (OutputStream os = connection.getOutputStream()) {
                    os.write(body.getBytes(StandardCharsets.UTF_8));
                }
            }
            int status = connection.getResponseCode();
            InputStream inputStream = status >= 200 && status < 300
                    ? connection.getInputStream() : connection.getErrorStream();
            String resp = readStream(inputStream);
            if (status < 200 || status >= 300) {
                log.warn("【丽水微信】请求返回非200,地址:{},状态码:{},响应:{}", url, status, resp);
            }
            return resp;
        } finally {
            if (connection != null) {
                connection.disconnect();
            }
        }
    }
    /**
     * è¯»å–响应流
     */
    private static String readStream(InputStream inputStream) throws IOException {
        if (inputStream == null) {
            return "";
        }
        StringBuilder result = new StringBuilder();
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
            String line;
            while ((line = reader.readLine()) != null) {
                result.append(line);
            }
        }
        return result.toString();
    }
    /**
     * ä»¤ç‰Œç¼“å­˜key
     */
    private static String tokenCacheKey(LsWxConfig config) {
        return config.getBaseUrl() + "|" + config.getAppId();
    }
    /**
     * URL参数编码
     */
    private static String encode(String value) {
        if (value == null) {
            return "";
        }
        try {
            return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
        } catch (Exception e) {
            return value;
        }
    }
    /**
     * null转空字符串
     */
    private static String nvl(String value) {
        return value == null ? "" : value;
    }
    /**
     * æž„造本地失败响应
     */
    private static String lsWxFail(String msg) {
        JSONObject json = new JSONObject(true);
        json.put("code", -1);
        json.put("msg", msg);
        return json.toJSONString();
    }
}