| | |
| | | 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 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; |
| | | |
| | | /** |
| | | * 短信发送工具类 |
| | |
| | | } |
| | | 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(); |
| | | } |
| | | } |