diff --git a/src/main/java/com/budwk/app/base/sms/SmsService.java b/src/main/java/com/budwk/app/base/sms/SmsService.java index e01131e..4c25955 100644 --- a/src/main/java/com/budwk/app/base/sms/SmsService.java +++ b/src/main/java/com/budwk/app/base/sms/SmsService.java @@ -2,17 +2,14 @@ package com.budwk.app.base.sms; import com.budwk.app.base.sms.model.MsgPlatformResponse; import com.budwk.app.base.sms.model.MsgPlatformSmsRequest; -import com.budwk.app.base.sms.model.MsgPlatformWechatRequest; - -import java.util.List; public interface SmsService { /** - * 发送短信消息。 + * 发送阿里云短信消息。 * - * @param request 短信请求参数,需传接收人、发送人、模板ID或消息内容等字段 - * @return 通讯平台返回结果,返回类型为 {@link MsgPlatformResponse} + * @param request 短信请求参数,需传接收人、模板 Code、模板变量或消息内容等字段 + * @return 阿里云短信发送结果,返回类型为 {@link MsgPlatformResponse} */ MsgPlatformResponse sendSms(MsgPlatformSmsRequest request); @@ -22,7 +19,7 @@ public interface SmsService { * * @param account 接收人工号 * @param content 短信内容 - * @return 通讯平台返回结果,返回类型为 {@link MsgPlatformResponse} + * @return 阿里云短信发送结果,返回类型为 {@link MsgPlatformResponse} */ MsgPlatformResponse sendSmsByAccount(String account, String content); @@ -32,25 +29,7 @@ public interface SmsService { * * @param mobile 接收人手机号 * @param content 短信内容 - * @return 通讯平台返回结果,返回类型为 {@link MsgPlatformResponse} + * @return 阿里云短信发送结果,返回类型为 {@link MsgPlatformResponse} */ MsgPlatformResponse sendSmsByMobile(String mobile, String content); - - /** - * 发送微信消息。 - * - * @param request 微信请求参数,需传接收人、发送人、消息类型、模板ID或消息内容等字段 - * @return 通讯平台返回结果,返回类型为 {@link MsgPlatformResponse} - */ - MsgPlatformResponse sendWechat(MsgPlatformWechatRequest request); - - /** - * 发送微信文本消息的便捷方法。 - * controller 只需要传接收人工号或手机号,以及微信文本内容即可。 - * - * @param accountOrMobile 接收人工号或手机号 - * @param content 微信文本内容 - * @return 通讯平台返回结果,返回类型为 {@link MsgPlatformResponse} - */ - MsgPlatformResponse sendWechatTextByAccountOrMobile(String accountOrMobile, String content); } diff --git a/src/main/java/com/budwk/app/base/sms/impl/SmsServiceImpl.java b/src/main/java/com/budwk/app/base/sms/impl/SmsServiceImpl.java index 184a58d..77ed993 100644 --- a/src/main/java/com/budwk/app/base/sms/impl/SmsServiceImpl.java +++ b/src/main/java/com/budwk/app/base/sms/impl/SmsServiceImpl.java @@ -1,544 +1,477 @@ package com.budwk.app.base.sms.impl; -import cn.hutool.core.util.StrUtil; -import cn.hutool.crypto.digest.DigestUtil; -import cn.hutool.http.HttpRequest; -import cn.hutool.http.HttpResponse; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import com.budwk.app.base.exception.BaseException; import com.budwk.app.base.sms.SmsService; -import com.budwk.app.base.sms.model.*; +import com.budwk.app.base.sms.model.MsgPlatformRecipient; +import com.budwk.app.base.sms.model.MsgPlatformResponse; +import com.budwk.app.base.sms.model.MsgPlatformSmsRequest; +import com.budwk.app.sys.models.Sys_user; import com.budwk.app.web.commons.base.Globals; import lombok.extern.slf4j.Slf4j; -import org.nutz.integration.jedis.RedisService; +import org.nutz.dao.Cnd; +import org.nutz.dao.Dao; import org.nutz.ioc.impl.PropertiesProxy; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; -import org.nutz.json.Json; -import org.nutz.lang.Lang; import org.nutz.lang.Strings; -import org.nutz.lang.util.NutMap; -import org.nutz.log.Log; -import org.nutz.log.Logs; -import java.io.*; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.URL; +import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; -import java.util.Base64; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.stream.Collectors; /** - * @ClassName SmsJshvcServiceImpl - * @Author JyuHsin - * @Date 2025/11/26 19:32 - * @Version 1.0 - * @Description TODO + * 阿里云短信发送服务。 + * 统一封装阿里云 SendSms 接口,业务代码只需要传接收人工号/手机号和短信内容; + * 签名、模板、模板变量名等固定参数从 sms.aliyun.* 配置中读取。 */ @Slf4j @IocBean public class SmsServiceImpl implements SmsService { - private static final Log log = Logs.get(); - private static final String SMS_API_PATH = "/tp_mp/api/SmsService/saveSmsInfo"; - private static final String WECHAT_API_PATH = "/tp_mp/api/WechatService/saveWechatInfo"; - private static final int MAX_RECEIVER_COUNT = 500; - @Inject - private PropertiesProxy conf; + private static final String SMS_ACTION = "SendSms"; + private static final String SMS_VERSION = "2017-05-25"; + private static final String SIGN_ALGORITHM = "ACS3-HMAC-SHA256"; + private static final String EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; - /** - * 发送短信消息。 - * - * @param request 短信请求参数,需传接收人、发送人、模板ID或消息内容等字段 - * @return 通讯平台返回结果,返回类型为 {@link MsgPlatformResponse} - */ - @Override - public MsgPlatformResponse sendSms(MsgPlatformSmsRequest request) { - validateCommonRequest(request); - JSONObject payload = buildCommonPayload(request, true); - return doPost(SMS_API_PATH, payload); - } + @Inject + private PropertiesProxy conf; - /** - * 通过工号发送短信文本消息的便捷方法。 - * 该方法适合 controller 里只传“工号 + 内容”的简单场景,发送人信息从配置中读取。 - * - * @param account 接收人工号 - * @param content 短信内容 - * @return 通讯平台返回结果,返回类型为 {@link MsgPlatformResponse} - */ - @Override - public MsgPlatformResponse sendSmsByAccount(String account, String content) { - MsgPlatformSmsRequest request = new MsgPlatformSmsRequest(); - request.setInfo(content); - request.setRecipients(buildAccountRecipients(account)); - fillDefaultSenderInfo(request); - return sendSms(request); - } + @Inject + private Dao dao; - /** - * 通过手机号发送短信文本消息的便捷方法。 - * 该方法适合 controller 里只传“手机号 + 内容”的简单场景,发送人信息从配置中读取。 - * - * @param mobile 接收人手机号 - * @param content 短信内容 - * @return 通讯平台返回结果,返回类型为 {@link MsgPlatformResponse} - */ - @Override - public MsgPlatformResponse sendSmsByMobile(String mobile, String content) { - MsgPlatformSmsRequest request = new MsgPlatformSmsRequest(); - request.setInfo(content); - request.setRecipients(buildMobileRecipients(mobile)); - request.setCustomVar(Map.of("1","","2","","3",content)); - fillDefaultSenderInfo(request); - return sendSms(request); - } + /** + * 发送短信消息。 + * 参数说明: + * 1. recipients:接收人列表,mobile 为手机号;如果 mobile 为空但 account 不为空,会按工号查询 sys_user.mobile。 + * 2. templateId:可选,阿里云短信模板 Code;为空时读取 sms.aliyun.template-code。 + * 3. sendUserSign:可选,阿里云短信签名;为空时读取 sms.aliyun.sign-name。 + * 4. customVar:可选,阿里云模板变量 Map;为空时使用 sms.aliyun.template-param-name 作为变量名,info 作为变量值。 + * 返回值为 MsgPlatformResponse,code=0/result=true 表示阿里云返回 Code=OK,msgId 保存阿里云 BizId。 + * + * @param request 短信发送请求参数 + * @return 阿里云短信发送结果 + */ + @Override + public MsgPlatformResponse sendSms(MsgPlatformSmsRequest request) { + MsgPlatformResponse skipResponse = checkSendEnabled(); + if (skipResponse != null) { + return skipResponse; + } + validateSmsRequest(request); - /** - * 发送微信消息。 - * - * @param request 微信请求参数,需传接收人、发送人、消息类型、模板ID或消息内容等字段 - * @return 通讯平台返回结果,返回类型为 {@link MsgPlatformResponse} - */ - @Override - public MsgPlatformResponse sendWechat(MsgPlatformWechatRequest request) { - validateCommonRequest(request); - if (Strings.isBlank(request.getWechatType())) { - request.setWechatType("text"); - } - if ("news".equalsIgnoreCase(request.getWechatType()) && Strings.isBlank(request.getInfo())) { - if (request.getNewsItems() == null || request.getNewsItems().isEmpty()) { - throw new BaseException("微信图文消息必须传 info 或 newsItems"); - } - request.setInfo(JSONUtil.toJsonStr(request.getNewsItems())); - } - JSONObject payload = buildCommonPayload(request, false); - payload.set("wechat_type", request.getWechatType()); - return doPost(WECHAT_API_PATH, payload); - } + String phoneNumbers = resolvePhoneNumbers(request); + String signName = Strings.isBlank(request.getSendUserSign()) ? getRequiredConfig("sms.aliyun.sign-name", "请先配置 sms.aliyun.sign-name") : request.getSendUserSign(); + String templateCode = Strings.isBlank(request.getTemplateId()) ? getRequiredConfig("sms.aliyun.template-code", "请先配置 sms.aliyun.template-code") : request.getTemplateId(); + String templateParam = buildTemplateParam(request); - /** - * 发送微信文本消息的便捷方法。 - * 该方法适合 controller 里只传“工号/手机号 + 内容”的简单场景,发送人信息从配置中读取。 - * - * @param accountOrMobile 接收人工号或手机号 - * @param content 微信文本内容 - * @return 通讯平台返回结果,返回类型为 {@link MsgPlatformResponse} - */ - @Override - public MsgPlatformResponse sendWechatTextByAccountOrMobile(String accountOrMobile, String content) { - MsgPlatformWechatRequest request = new MsgPlatformWechatRequest(); - request.setInfo(content); - request.setWechatType("text"); - request.setRecipients(buildWechatRecipients(accountOrMobile)); - fillDefaultSenderInfo(request); - return sendWechat(request); - } + return sendToAliyun(phoneNumbers, signName, templateCode, templateParam); + } - /** - * 组装平台公共请求体。 - * - * @param request 平台请求参数 - * @param includeMobile 是否在 person_info 第 5 段写入手机号 - * @return 返回发送给第三方平台的 JSON 对象 - */ - private JSONObject buildCommonPayload(MsgPlatformBaseRequest request, boolean includeMobile) { - JSONObject payload = new JSONObject(); - payload.set("tp_name", getRequiredConfig("msg.platform.tp-name", "请先配置 msg.platform.tp-name")); - payload.set("secret_key", getSecretKey()); - payload.set("person_info", resolvePersonInfo(request, includeMobile)); - payload.set("template_id", Strings.sNull(request.getTemplateId())); - payload.set("info", Strings.sNull(request.getInfo())); - payload.set("send_priority", Strings.isBlank(request.getSendPriority()) ? "3" : request.getSendPriority()); - payload.set("send_user_id", Strings.sNull(request.getSendUserId())); - payload.set("send_user_name", Strings.sNull(request.getSendUserName())); - payload.set("send_time", Strings.sNull(request.getSendTime())); - payload.set("send_unit_id", Strings.sNull(request.getSendUnitId())); - payload.set("send_unit_name", Strings.sNull(request.getSendUnitName())); - payload.set("send_user_sign", Strings.sNull(request.getSendUserSign())); - payload.set("receipt_id", Strings.isBlank(request.getReceiptId()) ? "0" : request.getReceiptId()); - if (request.getCustomVar() != null && !request.getCustomVar().isEmpty()) { - payload.set("custom_var", JSONUtil.toJsonStr(request.getCustomVar())); - } - return payload; - } + /** + * 通过工号发送阿里云短信。 + * 参数说明: + * account 传系统用户工号,对应 sys_user.loginname;方法内部查询 sys_user.mobile。 + * content 传模板变量内容,默认写入 sms.aliyun.template-param-name 对应变量。 + * 返回值为 MsgPlatformResponse,code=0/result=true 表示发送请求提交成功。 + * + * @param account 接收人工号 + * @param content 短信模板变量内容 + * @return 阿里云短信发送结果 + */ + @Override + public MsgPlatformResponse sendSmsByAccount(String account, String content) { + if (Strings.isBlank(account)) { + throw new BaseException("接收人工号不能为空"); + } + Sys_user user = dao.fetch(Sys_user.class, Cnd.where(Sys_user::getLoginname, "=", account)); + if (user == null) { + throw new BaseException("未找到工号对应用户:" + account); + } + return sendSmsByMobile(user.getMobile(), content); + } - /** - * 校验公共请求参数,避免 controller 传错参数后才到第三方接口报错。 - * - * @param request 平台请求参数 - */ - private void validateCommonRequest(MsgPlatformBaseRequest request) { - if (request == null) { - throw new BaseException("请求参数不能为空"); - } - if (Strings.isBlank(request.getPersonInfo()) && (request.getRecipients() == null || request.getRecipients().isEmpty())) { - throw new BaseException("personInfo 和 recipients 不能同时为空"); - } - if (Strings.isBlank(request.getTemplateId()) && Strings.isBlank(request.getInfo())) { - throw new BaseException("templateId 和 info 至少需要传一个"); - } - if (Strings.isBlank(request.getSendUserId())) { - throw new BaseException("sendUserId 不能为空"); - } - if (Strings.isBlank(request.getSendUserName())) { - throw new BaseException("sendUserName 不能为空"); - } - if (Strings.isBlank(request.getSendPriority())) { - request.setSendPriority("3"); - } - if ("4".equals(request.getSendPriority()) && Strings.isBlank(request.getSendTime())) { - throw new BaseException("sendPriority=4 时必须传 sendTime"); - } - validateReceiverCount(request.getPersonInfo(), request.getRecipients()); - } + /** + * 通过手机号发送阿里云短信。 + * 参数说明: + * mobile 传 11 位手机号。 + * content 传模板变量内容,默认写入 sms.aliyun.template-param-name 对应变量。 + * 返回值为 MsgPlatformResponse,code=0/result=true 表示发送请求提交成功。 + * + * @param mobile 接收人手机号 + * @param content 短信模板变量内容 + * @return 阿里云短信发送结果 + */ + @Override + public MsgPlatformResponse sendSmsByMobile(String mobile, String content) { + MsgPlatformRecipient recipient = new MsgPlatformRecipient(); + recipient.setMobile(mobile); - /** - * 解析 person_info。 - * 已传原始 personInfo 时直接使用;否则根据 recipients 自动拼接平台要求的格式。 - * - * @param request 平台请求参数 - * @param includeMobile 是否在第 5 段写入手机号 - * @return 平台要求的 person_info 字符串 - */ - private String resolvePersonInfo(MsgPlatformBaseRequest request, boolean includeMobile) { - if (Strings.isNotBlank(request.getPersonInfo())) { - return request.getPersonInfo(); - } - List recipients = request.getRecipients(); - if (recipients == null || recipients.isEmpty()) { - throw new BaseException("接收人列表不能为空"); - } - return recipients.stream().map(recipient -> buildReceiverLine(recipient, includeMobile)).collect(Collectors.joining("^@^")); - } + MsgPlatformSmsRequest request = new MsgPlatformSmsRequest(); + request.setInfo(content); + request.setRecipients(List.of(recipient)); + return sendSms(request); + } - /** - * 将单个接收人转成平台要求的分隔符格式。 - * - * @param recipient 接收人对象 - * @param includeMobile 是否在最后一段补手机号 - * @return 单个接收人的 person_info 片段 - */ - private String buildReceiverLine(MsgPlatformRecipient recipient, boolean includeMobile) { - if (recipient == null) { - throw new BaseException("接收人信息中存在空对象"); - } - List segments = new ArrayList<>(5); - segments.add(Strings.sNull(recipient.getName())); - segments.add(Strings.sNull(recipient.getAccount())); - segments.add(Strings.sNull(recipient.getUnitId())); - segments.add(Strings.sNull(recipient.getUnitName())); - if (includeMobile) { - segments.add(resolveSmsMobile(recipient)); - } else { - segments.add(""); - } - return String.join("|", segments); - } + /** + * 检查当前环境是否允许真实发送短信。 + * 线下项目以 Globals.sso=false 识别,直接跳过阿里云调用,避免本地或离线环境误发短信; + * 阿里云短信开关未启用时同样返回跳过结果,不影响待办主流程继续执行。 + * + * @return 不允许发送时返回跳过响应;允许发送时返回 null + */ + private MsgPlatformResponse checkSendEnabled() { + if (!Globals.sso) { + log.warn("线下项目不允许发送短信,已跳过阿里云短信调用"); + return buildSkippedResponse("线下项目不允许发送短信,已跳过实际发送"); + } + if (!Globals.MyConfig.getBoolean("AppSms", false)) { + log.warn("系统参数 AppSms 未开启,已跳过阿里云短信调用"); + return buildSkippedResponse("系统参数 AppSms 未开启,已跳过实际发送"); + } + if (!conf.getBoolean("sms.aliyun.enabled", false)) { + log.warn("阿里云短信未启用,已跳过阿里云短信调用"); + return buildSkippedResponse("阿里云短信未启用,已跳过实际发送"); + } + return null; + } - /** - * 解析短信接收人手机号。 - * 如果 controller 传的是手机号,则校验手机号格式; - * 如果传的是工号,则允许手机号为空,按“姓名|工号|部门ID|部门名称|”格式发送。 - * - * @param recipient 接收人信息 - * @return 短信 person_info 第 5 段内容 - */ - private String resolveSmsMobile(MsgPlatformRecipient recipient) { - String mobile = recipient.getMobile(); - if (Strings.isBlank(mobile)) { - if (Strings.isBlank(recipient.getAccount())) { - throw new BaseException("短信接收人手机号和工号不能同时为空"); - } - return ""; - } - if (!mobile.matches("^\\d{11}$")) { - throw new BaseException("短信接收人手机号必须为11位数字"); - } - return mobile; - } + /** + * 构建跳过发送的统一响应。 + * 该响应表示业务流程可继续,但没有发生真实外部短信调用。 + * + * @param message 跳过原因 + * @return 统一短信响应对象 + */ + private MsgPlatformResponse buildSkippedResponse(String message) { + MsgPlatformResponse response = new MsgPlatformResponse(); + response.setHttpStatus(200); + response.setCode(0); + response.setResult(true); + response.setMsg(message); + return response; + } - /** - * 校验接收人数量,避免超过第三方平台单次上限。 - * - * @param personInfo 原始 person_info - * @param recipients 接收人列表 - */ - private void validateReceiverCount(String personInfo, List recipients) { - int receiverCount; - if (Strings.isNotBlank(personInfo)) { - receiverCount = personInfo.split("\\^@\\^", -1).length; - } else { - receiverCount = recipients == null ? 0 : recipients.size(); - } - if (receiverCount > MAX_RECEIVER_COUNT) { - throw new BaseException("单次发送人数不能超过" + MAX_RECEIVER_COUNT + "人"); - } - } + /** + * 校验阿里云短信请求参数。 + * 必须至少提供接收人和模板变量内容;模板变量可通过 customVar 显式传入。 + * + * @param request 短信请求参数 + */ + private void validateSmsRequest(MsgPlatformSmsRequest request) { + if (request == null) { + throw new BaseException("短信请求参数不能为空"); + } + if (request.getRecipients() == null || request.getRecipients().isEmpty()) { + throw new BaseException("短信接收人不能为空"); + } + if ((request.getCustomVar() == null || request.getCustomVar().isEmpty()) && Strings.isBlank(request.getInfo())) { + throw new BaseException("短信模板变量内容不能为空"); + } + } - /** - * 构建工号发送专用接收人列表。 - * - * @param account controller 传入的工号 - * @return 仅包含一个接收人的列表 - */ - private List buildAccountRecipients(String account) { - if (Strings.isBlank(account)) { - throw new BaseException("接收人工号不能为空"); - } - MsgPlatformRecipient recipient = new MsgPlatformRecipient(); - recipient.setAccount(account); - List recipients = new ArrayList<>(1); - recipients.add(recipient); - return recipients; - } + /** + * 解析接收人手机号。 + * recipients.mobile 优先;mobile 为空时,使用 recipients.account 按工号查询 sys_user.mobile。 + * + * @param request 短信请求参数 + * @return 逗号分隔的手机号列表 + */ + private String resolvePhoneNumbers(MsgPlatformSmsRequest request) { + List mobiles = new ArrayList<>(); + for (MsgPlatformRecipient recipient : request.getRecipients()) { + if (recipient == null) { + continue; + } + String mobile = recipient.getMobile(); + if (Strings.isBlank(mobile) && Strings.isNotBlank(recipient.getAccount())) { + Sys_user user = dao.fetch(Sys_user.class, Cnd.where(Sys_user::getLoginname, "=", recipient.getAccount())); + mobile = user == null ? "" : user.getMobile(); + } + if (Strings.isBlank(mobile)) { + log.warn("短信接收人缺少手机号,account={}", recipient.getAccount()); + continue; + } + if (!mobile.matches("^1\\d{10}$")) { + log.warn("短信接收人手机号格式不正确,mobile={}", mobile); + continue; + } + mobiles.add(mobile); + } + if (mobiles.isEmpty()) { + throw new BaseException("没有可发送短信的有效手机号"); + } + return mobiles.stream().distinct().collect(Collectors.joining(",")); + } - /** - * 构建手机号发送专用接收人列表。 - * - * @param mobile controller 传入的手机号 - * @return 仅包含一个接收人的列表 - */ - private List buildMobileRecipients(String mobile) { - if (Strings.isBlank(mobile)) { - throw new BaseException("接收人手机号不能为空"); - } - if (!mobile.matches("^\\d{11}$")) { - throw new BaseException("接收人手机号必须为11位数字"); - } - MsgPlatformRecipient recipient = new MsgPlatformRecipient(); - recipient.setMobile(mobile); - List recipients = new ArrayList<>(1); - recipients.add(recipient); - return recipients; - } + /** + * 构建阿里云模板变量。 + * 如果业务传了 customVar,则直接作为模板变量;否则用 sms.aliyun.template-param-name 配置项包裹 info。 + * + * @param request 短信请求参数 + * @return JSON 字符串格式的模板变量 + */ + private String buildTemplateParam(MsgPlatformSmsRequest request) { + Map customVar = request.getCustomVar(); + if (customVar != null && !customVar.isEmpty()) { + return JSONUtil.toJsonStr(customVar); + } + String paramName = conf.get("sms.aliyun.template-param-name", "content"); + JSONObject params = new JSONObject(); + params.set(paramName, request.getInfo()); + return JSONUtil.toJsonStr(params); + } - /** - * 构建微信便捷发送专用接收人列表。 - * - * @param accountOrMobile controller 传入的企业微信成员ID、工号或手机号 - * @return 仅包含一个接收人的列表 - */ - private List buildWechatRecipients(String accountOrMobile) { - if (Strings.isBlank(accountOrMobile)) { - throw new BaseException("微信接收人工号或手机号不能为空"); - } - MsgPlatformRecipient recipient = new MsgPlatformRecipient(); - recipient.setAccount(accountOrMobile); - List recipients = new ArrayList<>(1); - recipients.add(recipient); - return recipients; - } + /** + * 调用阿里云 SendSms 接口。 + * 使用阿里云 V3 签名机制,HTTP 请求参数放在 query 中,body 为空。 + * + * @param phoneNumbers 手机号,多个手机号用英文逗号分隔 + * @param signName 阿里云短信签名 + * @param templateCode 阿里云短信模板 Code + * @param templateParam 模板变量 JSON 字符串 + * @return 阿里云短信发送结果 + */ + private MsgPlatformResponse sendToAliyun(String phoneNumbers, String signName, String templateCode, String templateParam) { + String endpoint = conf.get("sms.aliyun.endpoint", "dysmsapi.aliyuncs.com"); + String accessKeyId = getRequiredConfig("sms.aliyun.access-key-id", "请先配置 sms.aliyun.access-key-id"); + String accessKeySecret = getRequiredConfig("sms.aliyun.access-key-secret", "请先配置 sms.aliyun.access-key-secret"); - /** - * 为便捷发送方法补默认发送人信息。 - * 这些字段从配置中读取,避免 controller 每次重复传固定参数。 - * - * @param request 平台请求参数 - */ - private void fillDefaultSenderInfo(MsgPlatformBaseRequest request) { - request.setSendUserId(getRequiredConfig("msg.platform.default-send-user-id", "请先配置 msg.platform.default-send-user-id")); - request.setSendUserName(getRequiredConfig("msg.platform.default-send-user-name", "请先配置 msg.platform.default-send-user-name")); - request.setSendUnitId(conf.get("msg.platform.default-send-unit-id", "")); - request.setSendUnitName(conf.get("msg.platform.default-send-unit-name", "")); - request.setSendUserSign(conf.get("msg.platform.default-send-user-sign", "")); - request.setTemplateId(conf.get("msg.platform.default-template-id", "")); - } + Map queryParams = Map.of( + "PhoneNumbers", phoneNumbers, + "SignName", signName, + "TemplateCode", templateCode, + "TemplateParam", templateParam + ); + Map headers = buildSignedHeaders(endpoint, accessKeyId, accessKeySecret, queryParams); + String requestUrl = "https://" + endpoint + "/?" + canonicalQueryString(queryParams); - /** - * 调用地大通讯平台 REST 接口。 - * - * @param apiPath 具体接口路径 - * @param payload 发送报文 - * @return 平台响应结果 - */ - private MsgPlatformResponse doPost(String apiPath, JSONObject payload) { + HttpURLConnection connection = null; + try { + connection = (HttpURLConnection) new URL(requestUrl).openConnection(); + connection.setRequestMethod("POST"); + connection.setDoOutput(false); + connection.setDoInput(true); + connection.setUseCaches(false); + connection.setConnectTimeout(conf.getInt("sms.aliyun.connect-timeout", 5000)); + connection.setReadTimeout(conf.getInt("sms.aliyun.read-timeout", 10000)); + for (Map.Entry header : headers.entrySet()) { + connection.setRequestProperty(header.getKey(), header.getValue()); + } - if (!Globals.sso){ - log.error("【log】开发模式不允许发送消息通知"); - MsgPlatformResponse response = new MsgPlatformResponse(); - response.setHttpStatus(200); - response.setCode(0); - response.setResult(true); - response.setMsg("开发模式不允许发送消息通知"); - response.setRawBody(JSONUtil.toJsonStr(payload)); - return response; - } - if (!Globals.MyConfig.getBoolean("AppSms", false)) { - log.error("【log】短信配置未开启"); - MsgPlatformResponse response = new MsgPlatformResponse(); - response.setHttpStatus(200); - response.setCode(0); - response.setResult(true); - response.setMsg("短信配置未开启"); - response.setRawBody(JSONUtil.toJsonStr(payload)); - return response; - } - if (!conf.getBoolean("msg.platform.enabled", false)) { - MsgPlatformResponse response = new MsgPlatformResponse(); - response.setHttpStatus(200); - response.setCode(0); - response.setResult(true); - response.setMsg("通讯平台未启用,已跳过实际发送"); - response.setRawBody(JSONUtil.toJsonStr(payload)); - return response; - } - HttpURLConnection connection = null; - try { - String requestBody = JSONUtil.toJsonStr(payload); - log.error("发送内容:=================================================="+requestBody); - String requestUrl = buildRequestUrl(apiPath); - log.warnf("发送地址:=================================================="+requestUrl); - connection = (HttpURLConnection) new URL(requestUrl).openConnection(); - connection.setRequestMethod("POST"); - connection.setDoOutput(true); - connection.setDoInput(true); - connection.setUseCaches(false); - connection.setRequestProperty("Content-Type", "application/json;charset=UTF-8"); - connection.setConnectTimeout(conf.getInt("msg.platform.connect-timeout", 5000)); - connection.setReadTimeout(conf.getInt("msg.platform.read-timeout", 10000)); - try (OutputStream outputStream = connection.getOutputStream()) { - outputStream.write(requestBody.getBytes(StandardCharsets.UTF_8)); - outputStream.flush(); - } - int httpStatus = connection.getResponseCode(); - String responseBody = readResponseBody(connection, httpStatus); - MsgPlatformResponse response = parseResponse(httpStatus, responseBody); - log.warnf("接口调用返回结果=============================="+responseBody); - if (response.getCode() != null && response.getCode() != 0) { - log.warnf("通讯平台调用失败, httpStatus=%s, body=%s", httpStatus, responseBody); - } - return response; - } catch (IOException e) { - log.error("调用通讯平台接口异常", e); - throw new BaseException("调用通讯平台接口失败: " + e.getMessage()); - } finally { - if (connection != null) { - connection.disconnect(); - } - } - } + int httpStatus = connection.getResponseCode(); + String responseBody = readResponseBody(connection, httpStatus); + return parseAliyunResponse(httpStatus, responseBody); + } catch (IOException e) { + log.error("调用阿里云短信接口异常", e); + throw new BaseException("调用阿里云短信接口失败:" + e.getMessage()); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } - /** - * 构建完整请求地址,支持配置带或不带结尾斜杠的 base-url。 - * - * @param apiPath 接口路径 - * @return 完整请求地址 - */ - private String buildRequestUrl(String apiPath) { - String baseUrl = getRequiredConfig("msg.platform.base-url", "请先配置 msg.platform.base-url"); - if (baseUrl.endsWith("/")) { - return baseUrl.substring(0, baseUrl.length() - 1) + apiPath; - } - return baseUrl + apiPath; - } + /** + * 构建阿里云 V3 签名请求头。 + * 参与签名的头包括 host、x-acs-action、x-acs-content-sha256、x-acs-date、x-acs-signature-nonce、x-acs-version。 + * + * @param endpoint 阿里云短信服务域名 + * @param accessKeyId AccessKey ID + * @param accessKeySecret AccessKey Secret + * @param queryParams SendSms 请求参数 + * @return 已包含 Authorization 的请求头 + */ + private Map buildSignedHeaders(String endpoint, String accessKeyId, String accessKeySecret, Map queryParams) { + String date = Instant.now().truncatedTo(ChronoUnit.SECONDS).toString(); + String nonce = UUID.randomUUID().toString(); + String signedHeaders = "host;x-acs-action;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;x-acs-version"; + String canonicalHeaders = "host:" + endpoint + "\n" + + "x-acs-action:" + SMS_ACTION + "\n" + + "x-acs-content-sha256:" + EMPTY_BODY_SHA256 + "\n" + + "x-acs-date:" + date + "\n" + + "x-acs-signature-nonce:" + nonce + "\n" + + "x-acs-version:" + SMS_VERSION + "\n"; + String canonicalRequest = "POST\n" + + "/\n" + + canonicalQueryString(queryParams) + "\n" + + canonicalHeaders + "\n" + + signedHeaders + "\n" + + EMPTY_BODY_SHA256; + String stringToSign = SIGN_ALGORITHM + "\n" + sha256Hex(canonicalRequest); + String signature = hmacSha256Hex(stringToSign, accessKeySecret); + String authorization = SIGN_ALGORITHM + + " Credential=" + accessKeyId + + ",SignedHeaders=" + signedHeaders + + ",Signature=" + signature; - /** - * 读取第三方接口响应报文。 - * - * @param connection HttpURLConnection - * @param httpStatus HTTP 状态码 - * @return 原始响应字符串 - * @throws IOException 读取流失败时抛出 - */ - private String readResponseBody(HttpURLConnection connection, int httpStatus) throws IOException { - InputStream inputStream = httpStatus >= 200 && httpStatus < 400 ? connection.getInputStream() : connection.getErrorStream(); - if (inputStream == null) { - return ""; - } - try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { - StringBuilder builder = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - builder.append(line); - } - return builder.toString(); - } - } + return Map.of( + "host", endpoint, + "x-acs-action", SMS_ACTION, + "x-acs-version", SMS_VERSION, + "x-acs-date", date, + "x-acs-signature-nonce", nonce, + "x-acs-content-sha256", EMPTY_BODY_SHA256, + "Authorization", authorization + ); + } - /** - * 解析平台响应体。 - * - * @param httpStatus HTTP 状态码 - * @param responseBody 原始响应字符串 - * @return 解析后的平台响应对象 - */ - private MsgPlatformResponse parseResponse(int httpStatus, String responseBody) { - MsgPlatformResponse response = new MsgPlatformResponse(); - response.setHttpStatus(httpStatus); - response.setRawBody(responseBody); - if (Strings.isBlank(responseBody)) { - response.setCode(httpStatus); - response.setResult(false); - response.setMsg("通讯平台未返回响应内容"); - return response; - } - if (!JSONUtil.isTypeJSON(responseBody)) { - response.setCode(httpStatus); - response.setResult(false); - response.setMsg("通讯平台返回内容不是合法JSON"); - return response; - } - JSONObject jsonObject = JSONUtil.parseObj(responseBody); - response.setCode(jsonObject.getInt("code")); - response.setMsg(jsonObject.getStr("msg")); - response.setResult(jsonObject.getBool("result")); - response.setMsgId(jsonObject.getStr("msg_id")); - response.setRemainingQuota(jsonObject.getInt("remaining_quota")); - response.setDescription(jsonObject.getStr("description")); - return response; - } + /** + * 生成规范化 QueryString,按参数名排序后进行 RFC3986 编码。 + * + * @param params 请求参数 + * @return 规范化 QueryString + */ + private String canonicalQueryString(Map params) { + return params.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> percentEncode(entry.getKey()) + "=" + percentEncode(entry.getValue())) + .collect(Collectors.joining("&")); + } - /** - * 获取平台密钥。 - * 优先读取已加密密钥;未配置时再根据原始密钥按“Base64 后 SHA”规则自动计算。 - * - * @return 平台要求的 secret_key - */ - private String getSecretKey() { - String encryptedSecretKey = conf.get("msg.platform.encrypted-secret-key", ""); - if (Strings.isNotBlank(encryptedSecretKey)) { - return encryptedSecretKey; - } - String rawSecret = getRequiredConfig("msg.platform.raw-secret-key", "请先配置 msg.platform.raw-secret-key 或 msg.platform.encrypted-secret-key"); - return shaHex(rawSecret); - } + /** + * RFC3986 URL 编码,匹配阿里云 OpenAPI 签名要求。 + * + * @param value 原始字符串 + * @return 编码后的字符串 + */ + private String percentEncode(String value) { + return URLEncoder.encode(Strings.sNull(value), StandardCharsets.UTF_8) + .replace("+", "%20") + .replace("*", "%2A") + .replace("%7E", "~"); + } - /** - * 读取必填配置项。 - * - * @param key 配置 key - * @param errorMessage 配置缺失时抛出的异常信息 - * @return 配置值 - */ - private String getRequiredConfig(String key, String errorMessage) { - String value = conf.get(key, ""); - if (Strings.isBlank(value)) { - throw new BaseException(errorMessage); - } - return value; - } + /** + * 解析阿里云短信响应。 + * + * @param httpStatus HTTP 状态码 + * @param responseBody 原始响应体 + * @return 统一响应对象 + */ + private MsgPlatformResponse parseAliyunResponse(int httpStatus, String responseBody) { + MsgPlatformResponse response = new MsgPlatformResponse(); + response.setHttpStatus(httpStatus); + response.setRawBody(responseBody); + if (Strings.isBlank(responseBody) || !JSONUtil.isTypeJSON(responseBody)) { + response.setCode(httpStatus); + response.setResult(false); + response.setMsg("阿里云短信返回内容为空或不是合法JSON"); + return response; + } - /** - * 生成 SHA 十六进制摘要。 - * - * @param content 待加密内容 - * @return SHA 十六进制字符串 - */ - private String shaHex(String content) { - try { - MessageDigest messageDigest = MessageDigest.getInstance("SHA"); - messageDigest.update(content.getBytes()); - byte[] hash = messageDigest.digest(); - return Base64.getEncoder().encodeToString(hash); - } catch (Exception e) { - throw new BaseException("生成通讯平台 secret_key 失败: " + e.getMessage()); - } - } + JSONObject jsonObject = JSONUtil.parseObj(responseBody); + String code = jsonObject.getStr("Code"); + response.setCode("OK".equalsIgnoreCase(code) ? 0 : httpStatus); + response.setResult("OK".equalsIgnoreCase(code)); + response.setMsg(jsonObject.getStr("Message")); + response.setMsgId(jsonObject.getStr("BizId")); + response.setDescription(code); + return response; + } + + /** + * 读取 HTTP 响应内容。 + * + * @param connection HTTP 连接 + * @param httpStatus HTTP 状态码 + * @return 响应体字符串 + * @throws IOException 读取失败时抛出 + */ + private String readResponseBody(HttpURLConnection connection, int httpStatus) throws IOException { + InputStream inputStream = httpStatus >= 200 && httpStatus < 400 ? connection.getInputStream() : connection.getErrorStream(); + if (inputStream == null) { + return ""; + } + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + StringBuilder builder = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + builder.append(line); + } + return builder.toString(); + } + } + + /** + * 读取必填配置。 + * + * @param key 配置 key + * @param errorMessage 缺失时提示 + * @return 配置值 + */ + private String getRequiredConfig(String key, String errorMessage) { + String value = conf.get(key, ""); + if (Strings.isBlank(value)) { + throw new BaseException(errorMessage); + } + return value; + } + + /** + * 生成 SHA256 十六进制摘要。 + * + * @param content 原始内容 + * @return 十六进制摘要 + */ + private String sha256Hex(String content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return bytesToHex(digest.digest(content.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new BaseException("生成阿里云签名摘要失败:" + e.getMessage()); + } + } + + /** + * 生成 HMAC-SHA256 十六进制签名。 + * + * @param content 签名原文 + * @param secret AccessKey Secret + * @return 十六进制签名 + */ + private String hmacSha256Hex(String content, String secret) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return bytesToHex(mac.doFinal(content.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new BaseException("生成阿里云短信签名失败:" + e.getMessage()); + } + } + + /** + * 字节数组转十六进制字符串。 + * + * @param bytes 字节数组 + * @return 十六进制字符串 + */ + private String bytesToHex(byte[] bytes) { + StringBuilder builder = new StringBuilder(bytes.length * 2); + for (byte item : bytes) { + String hex = Integer.toHexString(item & 0xff); + if (hex.length() == 1) { + builder.append('0'); + } + builder.append(hex); + } + return builder.toString(); + } } diff --git a/src/main/java/com/budwk/app/base/sms/model/MsgPlatformBaseRequest.java b/src/main/java/com/budwk/app/base/sms/model/MsgPlatformBaseRequest.java deleted file mode 100644 index d7ecb8d..0000000 --- a/src/main/java/com/budwk/app/base/sms/model/MsgPlatformBaseRequest.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.budwk.app.base.sms.model; - -import lombok.Data; - -import java.util.List; -import java.util.Map; - -/** - * 通讯平台公共请求参数。 - */ -@Data -public class MsgPlatformBaseRequest { - /** - * 接收人信息原始字符串。 - * 如果 controller 已经按平台要求拼好了 person_info,可以直接传该字段; - * 否则可只传 recipients,由 service 自动组装。 - */ - private String personInfo; - - /** - * 接收人列表。 - * 当 personInfo 为空时,service 会根据该列表自动拼接平台要求的 person_info 字符串。 - */ - private List recipients; - - /** - * 模板ID。 - * templateId 和 info 至少需要传一个,使用模板发送时优先传该字段。 - */ - private String templateId; - - /** - * 消息内容。 - * templateId 和 info 至少需要传一个;不使用模板时,需要传完整消息内容。 - */ - private String info; - - /** - * 发送优先级。 - * 3 表示立即发送,4 表示定时发送。 - */ - private String sendPriority = "3"; - - /** - * 发送人账号。 - */ - private String sendUserId; - - /** - * 发送人姓名。 - */ - private String sendUserName; - - /** - * 模板变量。 - * 使用模板发送时可传该字段,service 会自动转成平台要求的 JSON 字符串。 - */ - private Map customVar; - - /** - * 定时发送时间,格式必须为 yyyy-MM-dd HH:mm:ss。 - * 当 sendPriority=4 时该字段必填。 - */ - private String sendTime; - - /** - * 发送机构ID。 - */ - private String sendUnitId; - - /** - * 发送机构名称。 - */ - private String sendUnitName; - - /** - * 发送签名。 - * 旧通用模板或特定签名场景可传该字段。 - */ - private String sendUserSign; - - /** - * 回执ID。 - * 不需要回执时可保持默认值 0。 - */ - private String receiptId = "0"; -} diff --git a/src/main/java/com/budwk/app/base/sms/model/MsgPlatformNewsItem.java b/src/main/java/com/budwk/app/base/sms/model/MsgPlatformNewsItem.java deleted file mode 100644 index 29d103a..0000000 --- a/src/main/java/com/budwk/app/base/sms/model/MsgPlatformNewsItem.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.budwk.app.base.sms.model; - -import lombok.Data; - -/** - * 微信图文消息单条数据。 - */ -@Data -public class MsgPlatformNewsItem { - /** - * 图文标题。 - */ - private String title; - - /** - * 图文描述。 - */ - private String description; - - /** - * 点击跳转链接。 - */ - private String url; - - /** - * 图文封面图片链接。 - */ - private String picurl; -} diff --git a/src/main/java/com/budwk/app/base/sms/model/MsgPlatformRecipient.java b/src/main/java/com/budwk/app/base/sms/model/MsgPlatformRecipient.java index e12214b..df59a5b 100644 --- a/src/main/java/com/budwk/app/base/sms/model/MsgPlatformRecipient.java +++ b/src/main/java/com/budwk/app/base/sms/model/MsgPlatformRecipient.java @@ -3,34 +3,34 @@ package com.budwk.app.base.sms.model; import lombok.Data; /** - * 通讯平台接收人信息。 + * 短信接收人信息。 */ @Data public class MsgPlatformRecipient { /** - * 接收人姓名,对应 person_info 中的第 1 段。 + * 接收人姓名,便于日志排查。 */ private String name; /** - * 接收人账号,对应 person_info 中的第 2 段。 - * 短信场景一般传学工号;微信场景可传企业微信成员ID、学工号或手机号。 + * 接收人工号。 + * mobile 为空时,可用该字段按 sys_user.loginname 查询手机号。 */ private String account; /** - * 接收人部门ID,对应 person_info 中的第 3 段。 + * 接收人部门ID,当前阿里云短信发送不使用,预留给调用方传递上下文。 */ private String unitId; /** - * 接收人部门名称,对应 person_info 中的第 4 段。 + * 接收人部门名称,当前阿里云短信发送不使用,预留给调用方传递上下文。 */ private String unitName; /** - * 接收人手机号,对应短信 person_info 中的第 5 段。 - * 微信接口不会使用该字段,微信接口会自动补空串以保留分隔符格式。 + * 接收人手机号。 + * 阿里云短信发送时优先使用该字段,必须为 11 位大陆手机号。 */ private String mobile; } diff --git a/src/main/java/com/budwk/app/base/sms/model/MsgPlatformResponse.java b/src/main/java/com/budwk/app/base/sms/model/MsgPlatformResponse.java index 2a5f8e2..3ce1703 100644 --- a/src/main/java/com/budwk/app/base/sms/model/MsgPlatformResponse.java +++ b/src/main/java/com/budwk/app/base/sms/model/MsgPlatformResponse.java @@ -3,7 +3,7 @@ package com.budwk.app.base.sms.model; import lombok.Data; /** - * 通讯平台响应结果。 + * 短信发送响应结果。 */ @Data public class MsgPlatformResponse { @@ -13,37 +13,37 @@ public class MsgPlatformResponse { private Integer httpStatus; /** - * 平台返回业务状态码,0 表示成功。 + * 业务状态码,0 表示阿里云返回 OK。 */ private Integer code; /** - * 平台返回消息提示。 + * 阿里云返回消息提示。 */ private String msg; /** - * 平台返回业务是否成功。 + * 是否发送成功。 */ private Boolean result; /** - * 发送成功后的消息ID。 + * 发送成功后的阿里云 BizId。 */ private String msgId; /** - * 短信剩余额度,仅短信接口返回。 + * 短信剩余额度,当前阿里云接口不返回,预留兼容字段。 */ private Integer remainingQuota; /** - * 平台失败描述。 + * 阿里云返回 Code,失败时可用于定位错误类型。 */ private String description; /** - * 平台原始响应报文,排查问题时可直接查看。 + * 阿里云原始响应报文,排查问题时可直接查看。 */ private String rawBody; } diff --git a/src/main/java/com/budwk/app/base/sms/model/MsgPlatformSmsRequest.java b/src/main/java/com/budwk/app/base/sms/model/MsgPlatformSmsRequest.java index 8f086da..688bb7c 100644 --- a/src/main/java/com/budwk/app/base/sms/model/MsgPlatformSmsRequest.java +++ b/src/main/java/com/budwk/app/base/sms/model/MsgPlatformSmsRequest.java @@ -1,7 +1,42 @@ package com.budwk.app.base.sms.model; +import lombok.Data; + +import java.util.List; +import java.util.Map; + /** - * 短信发送请求参数。 + * 阿里云短信发送请求参数。 */ -public class MsgPlatformSmsRequest extends MsgPlatformBaseRequest { +@Data +public class MsgPlatformSmsRequest { + /** + * 接收人列表。 + * mobile 为接收手机号;mobile 为空但 account 不为空时,会按工号查询 sys_user.mobile。 + */ + private List recipients; + + /** + * 阿里云短信模板 Code。 + * 为空时使用 sms.aliyun.template-code 配置。 + */ + private String templateId; + + /** + * 短信模板变量内容。 + * customVar 为空时,会使用 sms.aliyun.template-param-name 作为变量名包装该内容。 + */ + private String info; + + /** + * 阿里云模板变量。 + * key 为模板变量名,value 为变量内容,例如模板中有 ${content} 时传 content -> 具体通知内容。 + */ + private Map customVar; + + /** + * 阿里云短信签名。 + * 为空时使用 sms.aliyun.sign-name 配置。 + */ + private String sendUserSign; } diff --git a/src/main/java/com/budwk/app/base/sms/model/MsgPlatformWechatRequest.java b/src/main/java/com/budwk/app/base/sms/model/MsgPlatformWechatRequest.java deleted file mode 100644 index a6ccbff..0000000 --- a/src/main/java/com/budwk/app/base/sms/model/MsgPlatformWechatRequest.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.budwk.app.base.sms.model; - -import lombok.Data; -import lombok.EqualsAndHashCode; - -import java.util.List; - -/** - * 微信发送请求参数。 - */ -@Data -@EqualsAndHashCode(callSuper = true) -public class MsgPlatformWechatRequest extends MsgPlatformBaseRequest { - /** - * 微信消息类型。 - * 可传 text、news、image、file、video、voice,默认 text。 - */ - private String wechatType = "text"; - - /** - * 图文消息列表。 - * 当 wechatType=news 且未直接传 info 时,service 会将该列表转成平台要求的 JSON 数组字符串。 - */ - private List newsItems; -} diff --git a/src/main/java/com/budwk/app/flow/listenter/ProcessTaskStartEventListener.java b/src/main/java/com/budwk/app/flow/listenter/ProcessTaskStartEventListener.java index d030783..0c66238 100644 --- a/src/main/java/com/budwk/app/flow/listenter/ProcessTaskStartEventListener.java +++ b/src/main/java/com/budwk/app/flow/listenter/ProcessTaskStartEventListener.java @@ -58,8 +58,8 @@ public class ProcessTaskStartEventListener implements ProcessEventListener { List taskActors = dao.query(ProcessTaskActor.class, Cnd.where(ProcessTaskActor::getProcessTaskId, "=", task.getId())); for (ProcessTaskActor taskActor : taskActors) { - // 模拟发送消息 - String message = StrUtil.format("您有一条待办任务,实例:{},处理环节:{}", taskActor.getActorName(), instanceName, taskDisplayName); + // 待办通知内容会同步进入本地消息和短信模板变量,需明确包含接收人、实例和处理环节。 + String message = StrUtil.format("{},您有一条待办任务,实例:{},处理环节:{}", taskActor.getActorName(), instanceName, taskDisplayName); log.info(message); globalMessageSendService.sendMessage(taskDisplayName, message, 2, List.of(taskActor.getActorId()), null); } diff --git a/src/main/java/com/budwk/app/sys/services/impl/SysMsgServiceImpl.java b/src/main/java/com/budwk/app/sys/services/impl/SysMsgServiceImpl.java index 0b0ac40..3ac5707 100644 --- a/src/main/java/com/budwk/app/sys/services/impl/SysMsgServiceImpl.java +++ b/src/main/java/com/budwk/app/sys/services/impl/SysMsgServiceImpl.java @@ -6,7 +6,6 @@ import cn.hutool.http.HtmlUtil; import com.budwk.app.base.constant.RedisConstant; import com.budwk.app.base.service.impl.BaseServiceImpl; import com.budwk.app.base.sms.SmsService; -import com.budwk.app.base.sms.model.MsgPlatformWechatRequest; import com.budwk.app.base.utils.ManyAddOrRenewUtil; import com.budwk.app.sys.models.Sys_msg; import com.budwk.app.sys.models.Sys_msg_user; @@ -106,7 +105,6 @@ public class SysMsgServiceImpl extends BaseServiceImpl implements SysMs ThreadUtil.execute(() -> { for (String loginName : loginNames) { smsService.sendSmsByAccount(loginName,sysMsg.getNote()); - smsService.sendWechatTextByAccountOrMobile(loginName,sysMsg.getNote()); } }); diff --git a/src/main/java/com/budwk/app/web/commons/auth/satoken/aop/SaCheckLoginInterceptor.java b/src/main/java/com/budwk/app/web/commons/auth/satoken/aop/SaCheckLoginInterceptor.java index 04fd258..6f9644f 100644 --- a/src/main/java/com/budwk/app/web/commons/auth/satoken/aop/SaCheckLoginInterceptor.java +++ b/src/main/java/com/budwk/app/web/commons/auth/satoken/aop/SaCheckLoginInterceptor.java @@ -15,6 +15,10 @@ import java.lang.reflect.Method; public class SaCheckLoginInterceptor implements MethodInterceptor { @Override public void filter(InterceptorChain chain) throws Throwable { + if (SaTokenAuthIgnoreUtil.isIgnore()) { + chain.doChain(); + return; + } Method method = chain.getCallingMethod(); SaCheckLogin at = method.getAnnotation(SaCheckLogin.class); SaManager.getStpLogic(at.type()).checkByAnnotation(at); diff --git a/src/main/java/com/budwk/app/web/commons/auth/satoken/aop/SaCheckPermissionInterceptor.java b/src/main/java/com/budwk/app/web/commons/auth/satoken/aop/SaCheckPermissionInterceptor.java index c1004bf..2eac099 100644 --- a/src/main/java/com/budwk/app/web/commons/auth/satoken/aop/SaCheckPermissionInterceptor.java +++ b/src/main/java/com/budwk/app/web/commons/auth/satoken/aop/SaCheckPermissionInterceptor.java @@ -15,6 +15,10 @@ import java.lang.reflect.Method; public class SaCheckPermissionInterceptor implements MethodInterceptor { @Override public void filter(InterceptorChain chain) throws Throwable { + if (SaTokenAuthIgnoreUtil.isIgnore()) { + chain.doChain(); + return; + } Method method = chain.getCallingMethod(); SaCheckPermission at = method.getAnnotation(SaCheckPermission.class); SaManager.getStpLogic(at.type()).checkByAnnotation(at); diff --git a/src/main/java/com/budwk/app/web/commons/auth/satoken/aop/SaCheckRoleInterceptor.java b/src/main/java/com/budwk/app/web/commons/auth/satoken/aop/SaCheckRoleInterceptor.java index 7df25c0..c87fd80 100644 --- a/src/main/java/com/budwk/app/web/commons/auth/satoken/aop/SaCheckRoleInterceptor.java +++ b/src/main/java/com/budwk/app/web/commons/auth/satoken/aop/SaCheckRoleInterceptor.java @@ -15,6 +15,10 @@ import java.lang.reflect.Method; public class SaCheckRoleInterceptor implements MethodInterceptor { @Override public void filter(InterceptorChain chain) throws Throwable { + if (SaTokenAuthIgnoreUtil.isIgnore()) { + chain.doChain(); + return; + } Method method = chain.getCallingMethod(); SaCheckRole at = method.getAnnotation(SaCheckRole.class); SaManager.getStpLogic(at.type()).checkByAnnotation(at); diff --git a/src/main/java/com/budwk/app/web/commons/auth/satoken/aop/SaTokenAuthIgnoreUtil.java b/src/main/java/com/budwk/app/web/commons/auth/satoken/aop/SaTokenAuthIgnoreUtil.java new file mode 100644 index 0000000..985a40d --- /dev/null +++ b/src/main/java/com/budwk/app/web/commons/auth/satoken/aop/SaTokenAuthIgnoreUtil.java @@ -0,0 +1,60 @@ +package com.budwk.app.web.commons.auth.satoken.aop; + +import com.budwk.app.web.commons.base.Globals; +import org.nutz.lang.Lang; +import org.nutz.lang.Strings; +import org.nutz.mvc.Mvcs; + +import javax.servlet.http.HttpServletRequest; +import java.util.Arrays; +import java.util.regex.Pattern; + +/** + * Sa-Token 注解认证白名单工具。 + *

+ * 用于集中维护不需要登录、权限、角色校验即可访问的 URL,匹配规则与旧 Shiro 体系中的 + * authIgnoreUrlArr 保持一致:数组中的每一项都按正则表达式与当前请求 URI 完整匹配。 + */ +public class SaTokenAuthIgnoreUtil { + + /** + * 不需要认证授权的 URL。 + *

+ * 参数说明:数组中的字符串为请求 URI 正则表达式,匹配的是去除项目 contextPath 后的路径。 + * 返回值说明:通过 {@link #isIgnore()} 返回 boolean,true 表示当前请求跳过 Sa-Token 注解校验。 + */ + private static final String[] AUTH_IGNORE_URL_ARR = Lang.array("/", + "/platform/activity/basic/scope/getScopeUser", + "/platform/fitnessWalk/stepManage/getActivityDateStep", + "/platform/fitnessWalk/stepManage/winningRecord", + "/platform/fitnessWalk/stepManage/updateStepMonth", + "/platform/fitnessWalk/stepRanking/getUserStepRanking", + "/platform/fitnessWalk/stepManage/getActivityQualifyProgressBar", + "/platform/fitnessWalk/stepWining/getUserStepWining", + "/platform/fitnessWalk/common/getIssueById", + "/platform/fitnessWalk/common/saveAnswer", + "/platform/fitnessWalk/stepWining/prizeOption", + "/platform/fitnessWalk/stepWining/prizeUsers", + "/platform/fitnessWalk/punchLottery/judgeWinningToPunch", + "/platform/fitnessWalk/punchLottery/getLotteryRecord", + "/platform/fitnessWalk/punchLottery/doExchange", + "/platform/fitnessWalk/punchLottery/getPunchWining", + "/platform/fitnessWalk/punchLottery/doReadLottery", + "/platform/fitnessWalk/activityRelation/isCurrentUserRelationActivityQualified", + "/platform/fitnessWalk/welfare/addWelfareList", + "/platform/fitnessWalk/weapp/.*"); + + /** + * 判断当前请求是否命中匿名访问白名单。 + * + * @return boolean true 表示当前 URL 不需要执行登录、权限、角色注解校验;false 表示继续走原有 Sa-Token 校验逻辑 + */ + public static boolean isIgnore() { + HttpServletRequest request = Mvcs.getReq(); + if (request == null) { + return false; + } + String requestURI = Strings.sNull(request.getRequestURI()).replaceFirst("^" + Pattern.quote(Strings.sNull(Globals.AppBase)), ""); + return Arrays.stream(AUTH_IGNORE_URL_ARR).anyMatch(v -> Pattern.compile(v).matcher(requestURI).matches()); + } +} diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/message/strategy/impl/SmsMessageSendStrategy.java b/src/main/java/com/budwk/app/zhgh/dayofficework/message/strategy/impl/SmsMessageSendStrategy.java index dee3355..2419e9d 100644 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/message/strategy/impl/SmsMessageSendStrategy.java +++ b/src/main/java/com/budwk/app/zhgh/dayofficework/message/strategy/impl/SmsMessageSendStrategy.java @@ -74,23 +74,23 @@ public class SmsMessageSendStrategy implements GlobalMessageSendStrategy { /** * 发送短信消息。 - * 接收人由全局消息服务传入的用户ID或登录名查询得到,发送时使用用户工号对接通讯平台; - * 返回结果由 SmsService 内部解析并记录,当前策略只负责逐个用户触发发送并记录异常。 + * 接收人由全局消息服务传入的用户ID或登录名查询得到,发送时使用用户手机号对接阿里云短信; + * 单个用户手机号缺失或发送失败只记录日志,不影响其他待审核人接收短信。 */ private void sendSmsMessage(String title, String content, Integer type, List users, JSONObject config) { for (Sys_user user : users) { try { - String loginName = user.getLoginname(); - if (loginName == null || loginName.trim().isEmpty()) { - log.warn("用户{}没有工号,跳过短信发送", user.getUsername()); + String mobile = user.getMobile(); + if (mobile == null || mobile.trim().isEmpty()) { + log.warn("用户{}没有手机号,跳过短信发送", user.getUsername()); continue; } // 构建短信内容 String smsContent = buildSmsContent(title, content, type); - smsService.sendSmsByAccount(loginName, smsContent); - log.info("短信发送完成:用户{},工号:{}", user.getUsername(), loginName); + smsService.sendSmsByMobile(mobile, smsContent); + log.info("短信发送完成:用户{},手机号:{}", user.getUsername(), mobile); } catch (Exception e) { log.error("发送短信给用户{}时出错:{}", user.getUsername(), e.getMessage(), e); } @@ -103,10 +103,8 @@ public class SmsMessageSendStrategy implements GlobalMessageSendStrategy { private String buildSmsContent(String title, String content, Integer type) { StringBuilder sb = new StringBuilder(); - // 根据消息类型添加前缀 - if (type != null && type == 2) { - sb.append("【待办通知】"); - } else { + // 待办短信由阿里云模板统一展示“待办提醒”,这里不再重复拼接【待办通知】前缀。 + if (type == null || type != 2) { sb.append("【系统通知】"); } diff --git a/src/main/java/com/budwk/app/zhgh/dayofficework/message/strategy/impl/WechatMessageSendStrategy.java b/src/main/java/com/budwk/app/zhgh/dayofficework/message/strategy/impl/WechatMessageSendStrategy.java deleted file mode 100644 index a4b0e19..0000000 --- a/src/main/java/com/budwk/app/zhgh/dayofficework/message/strategy/impl/WechatMessageSendStrategy.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.budwk.app.zhgh.dayofficework.message.strategy.impl; - -import cn.hutool.json.JSONObject; -import com.budwk.app.base.sms.SmsService; -import com.budwk.app.sys.models.Sys_user; -import com.budwk.app.zhgh.dayofficework.message.enums.GlobalMessageChannel; -import com.budwk.app.zhgh.dayofficework.message.strategy.GlobalMessageSendStrategy; -import lombok.extern.slf4j.Slf4j; -import org.nutz.dao.Cnd; -import org.nutz.dao.Dao; -import org.nutz.ioc.loader.annotation.Inject; -import org.nutz.ioc.loader.annotation.IocBean; - -import java.util.List; - -/** - * 微信发送策略 - */ -@IocBean -@Slf4j -public class WechatMessageSendStrategy implements GlobalMessageSendStrategy { - - @Inject - private Dao dao; - @Inject - private SmsService smsService; - - @Override - public GlobalMessageChannel getChannel() { - return GlobalMessageChannel.WECHAT; - } - - @Override - public boolean supportAsync() { - return true; - } - - @Override - public void send(String title, String content, Integer type, List receiverIds, JSONObject config) { - try { - log.info("开始发送微信消息,标题:{},接收人数量:{}", title, receiverIds.size()); - - // receiverIds 为 sys_user.id,按用户ID查询后使用工号对接通讯平台微信文本消息接口。 - List users = dao.query(Sys_user.class, Cnd.where(Sys_user::getId, "in", receiverIds)); - sendWechatMessage(title, content, type, users, config); - - log.info("微信消息发送完成,标题:{},接收人数量:{}", title, users.size()); - } catch (Exception e) { - log.error("微信消息发送失败,标题:{},错误:{}", title, e.getMessage(), e); - throw new RuntimeException("微信消息发送失败:" + e.getMessage(), e); - } - } - - @Override - public void sendByLoginName(String title, String content, Integer type, List receiverLoginNames, JSONObject config) { - try { - log.info("开始发送微信消息(按登录名),标题:{},接收人数量:{}", title, receiverLoginNames.size()); - - // receiverLoginNames 为 sys_user.loginname,查询用户后仍使用工号作为通讯平台接收人标识。 - List users = dao.query(Sys_user.class, Cnd.where(Sys_user::getLoginname, "in", receiverLoginNames)); - sendWechatMessage(title, content, type, users, config); - - log.info("微信消息发送完成(按登录名),标题:{},接收人数量:{}", title, users.size()); - } catch (Exception e) { - log.error("微信消息发送失败(按登录名),标题:{},错误:{}", title, e.getMessage(), e); - throw new RuntimeException("微信消息发送失败:" + e.getMessage(), e); - } - } - - /** - * 发送微信文本消息。 - * 接收人来自全局消息服务传入的用户ID或登录名,方法内部转换为 Sys_user 后取工号发送; - * SmsService 返回 MsgPlatformResponse,当前策略按单人发送记录日志,单个用户失败不影响其他接收人。 - */ - private void sendWechatMessage(String title, String content, Integer type, List users, JSONObject config) { - for (Sys_user user : users) { - try { - String loginName = user.getLoginname(); - if (loginName == null || loginName.trim().isEmpty()) { - log.warn("用户{}没有工号,跳过微信发送", user.getUsername()); - continue; - } - - String wechatContent = buildWechatContent(title, content, type); - smsService.sendWechatTextByAccountOrMobile(loginName, wechatContent); - log.info("微信发送完成:用户{},工号:{}", user.getUsername(), loginName); - } catch (Exception e) { - log.error("发送微信给用户{}时出错:{}", user.getUsername(), e.getMessage(), e); - } - } - } - - /** - * 构建微信文本内容。 - * type=2 表示待办消息,其余类型按系统通知处理,内容格式保持与短信策略一致。 - */ - private String buildWechatContent(String title, String content, Integer type) { - StringBuilder sb = new StringBuilder(); - - if (type != null && type == 2) { - sb.append("【待办通知】"); - } else { - sb.append("【系统通知】"); - } - - sb.append(title); - if (content != null && !content.trim().isEmpty()) { - sb.append(":").append(content); - } - - return sb.toString(); - } -}