陈昶聿
5 天以前 8fd8acaa1ae8dbb495deaa3b899187d9a70f2622
【丽水】丽水妇幼 微信消息发送
已修改2个文件
640 ■■■■■ 文件已修改
ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/SmsController.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-common/src/main/java/com/ruoyi/common/utils/sms/SmsUtils.java 575 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/SmsController.java
@@ -1,11 +1,16 @@
package com.ruoyi.web.controller.common;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.gson.Gson;
import com.ruoyi.common.annotation.AddOrgId;
import com.ruoyi.common.annotation.Anonymous;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.dx.MessageSend;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.HttpUtil;
import com.ruoyi.common.utils.RSAPublicKeyExample;
import com.ruoyi.common.utils.StringUtils;
@@ -277,11 +282,67 @@
    @AddOrgId
    @PostMapping("/sendYunMasSms")
    public AjaxResult sendYunMasSms(@RequestBody smsVO vo) throws Exception {
//        String yunMasConfig = configService.selectConfigByKey("ls.msg.yunmas",vo.getOrgid());
        SmsUtils.YunMasConfig yunMasConfig = new SmsUtils.YunMasConfig();
        String yunMasConfigJson = configService.selectConfigByKey("ls.msg.yunmas", vo.getOrgid());
        SmsUtils.YunMasConfig yunMasConfig = SmsUtils.YunMasConfig.of(yunMasConfigJson);
        SmsUtils.SmsResult smsResult = SmsUtils.sendYunMasSms(yunMasConfig, vo.getPhone(), vo.getContent());
        return AjaxResult.success(smsResult);
    }
    /**
     * 丽水妇幼 微信消息发送
     * <p>
     * 外部系统传入患者信息和通知内容,由本系统统一获取 token、解析模板并调用丽水微信 sendMessage 接口。
     *
     * @param request 微信消息请求
     * @return 发送结果
     */
    @Anonymous
    @ApiOperation(value = "丽水妇幼 微信消息发送", notes = "供丽水市妇幼保健院外部调用")
    @Log(title = "丽水妇幼微信消息发送", businessType = BusinessType.OTHER)
    @PostMapping("/sendChatFuyou")
    public AjaxResult sendChatFuyou(@RequestBody LishuiFuyouWxChatRequest request) {
        if (request == null) {
            return AjaxResult.error("请求参数不能为空");
        }
        try {
            SmsUtils.LsWxConfig config = SmsUtils.LsWxConfig.defaultLishui();
            SmsUtils.LsWxMessage message = new SmsUtils.LsWxMessage();
            message.setId(request.getTemplateId());
            message.setPatientId(request.getPatientId());
            message.setPatientName(request.getPatientName());
            message.setIdCard(request.getIdCard());
            message.setMobile(request.getMobile());
            message.setMedCardNo(request.getMedCardNo());
            message.setTitleName(request.getTitleName());
            message.setContent(request.getContent());
            message.setUrl(request.getUrl());
            log.info("【丽水妇幼微信】收到外部调用,手机号:{},模板ID:{}",
                    request.getMobile(), request.getTemplateId());
            String resp = SmsUtils.sendChatLs(config, message);
            log.info("【丽水妇幼微信】外部调用结果:{}", resp);
            JSONObject result;
            try {
                result = JSON.parseObject(resp);
            } catch (Exception e) {
                log.error("【丽水妇幼微信】第三方响应不是合法JSON:{}", resp, e);
                return AjaxResult.error("丽水妇幼微信发送失败:第三方响应异常", resp);
            }
            if (result == null) {
                return AjaxResult.error("丽水妇幼微信发送失败:第三方响应为空", resp);
            }
            if (result.getIntValue("code") != 0) {
                String msg = result.getString("msg");
                return AjaxResult.error("丽水妇幼微信发送失败:"
                        + (StringUtils.isEmpty(msg) ? "第三方接口返回失败" : msg), result);
            }
            return AjaxResult.success("发送成功", result);
        } catch (Exception e) {
            log.error("【丽水妇幼微信】外部调用异常,手机号:{}", request.getMobile(), e);
            return AjaxResult.error("丽水妇幼微信发送异常:" + e.getMessage());
        }
    }
}
ruoyi-common/src/main/java/com/ruoyi/common/utils/sms/SmsUtils.java
@@ -1,6 +1,7 @@
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;
@@ -10,11 +11,20 @@
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;
/**
 * 短信发送工具类
@@ -333,4 +343,569 @@
        }
        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();
    }
}