| | |
| | | <artifactId>jiguang-sdk</artifactId> |
| | | <version>5.3.0</version> |
| | | </dependency> |
| | | <!-- 阿里云短信 --> |
| | | <dependency> |
| | | <groupId>com.aliyun</groupId> |
| | | <artifactId>dysmsapi20170525</artifactId> |
| | | <version>2.0.10</version> |
| | | </dependency> |
| | | </dependencies> |
| | | |
| | | <build> |
| New file |
| | |
| | | package com.tcm.training.admin.app.auth.config; |
| | | |
| | | import org.springframework.boot.context.properties.ConfigurationProperties; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | /** |
| | | * APP 阿里云短信发送配置。 |
| | | */ |
| | | @Component |
| | | @ConfigurationProperties(prefix = "app.auth.sms.aliyun") |
| | | public class AppAliyunSmsProperties { |
| | | |
| | | private String accessKeyId = ""; |
| | | private String accessKeySecret = ""; |
| | | private String signName = ""; |
| | | private Templates templates = new Templates(); |
| | | |
| | | /** |
| | | * 获取阿里云 AccessKey ID。 |
| | | */ |
| | | public String getAccessKeyId() { |
| | | return accessKeyId; |
| | | } |
| | | |
| | | /** |
| | | * 设置阿里云 AccessKey ID。 |
| | | */ |
| | | public void setAccessKeyId(String accessKeyId) { |
| | | this.accessKeyId = accessKeyId; |
| | | } |
| | | |
| | | /** |
| | | * 获取阿里云 AccessKey Secret。 |
| | | */ |
| | | public String getAccessKeySecret() { |
| | | return accessKeySecret; |
| | | } |
| | | |
| | | /** |
| | | * 设置阿里云 AccessKey Secret。 |
| | | */ |
| | | public void setAccessKeySecret(String accessKeySecret) { |
| | | this.accessKeySecret = accessKeySecret; |
| | | } |
| | | |
| | | /** |
| | | * 获取短信签名。 |
| | | */ |
| | | public String getSignName() { |
| | | return signName; |
| | | } |
| | | |
| | | /** |
| | | * 设置短信签名。 |
| | | */ |
| | | public void setSignName(String signName) { |
| | | this.signName = signName; |
| | | } |
| | | |
| | | /** |
| | | * 获取验证码模板配置。 |
| | | */ |
| | | public Templates getTemplates() { |
| | | return templates; |
| | | } |
| | | |
| | | /** |
| | | * 设置验证码模板配置。 |
| | | */ |
| | | public void setTemplates(Templates templates) { |
| | | this.templates = templates; |
| | | } |
| | | |
| | | /** |
| | | * 四类验证码模板编码配置。 |
| | | */ |
| | | public static class Templates { |
| | | |
| | | private String login = ""; |
| | | private String register = ""; |
| | | private String passwordReset = ""; |
| | | private String phoneChange = ""; |
| | | |
| | | /** |
| | | * 获取登录验证码模板编码。 |
| | | */ |
| | | public String getLogin() { |
| | | return login; |
| | | } |
| | | |
| | | /** |
| | | * 设置登录验证码模板编码。 |
| | | */ |
| | | public void setLogin(String login) { |
| | | this.login = login; |
| | | } |
| | | |
| | | /** |
| | | * 获取注册验证码模板编码。 |
| | | */ |
| | | public String getRegister() { |
| | | return register; |
| | | } |
| | | |
| | | /** |
| | | * 设置注册验证码模板编码。 |
| | | */ |
| | | public void setRegister(String register) { |
| | | this.register = register; |
| | | } |
| | | |
| | | /** |
| | | * 获取忘记密码验证码模板编码。 |
| | | */ |
| | | public String getPasswordReset() { |
| | | return passwordReset; |
| | | } |
| | | |
| | | /** |
| | | * 设置忘记密码验证码模板编码。 |
| | | */ |
| | | public void setPasswordReset(String passwordReset) { |
| | | this.passwordReset = passwordReset; |
| | | } |
| | | |
| | | /** |
| | | * 获取修改手机号验证码模板编码。 |
| | | */ |
| | | public String getPhoneChange() { |
| | | return phoneChange; |
| | | } |
| | | |
| | | /** |
| | | * 设置修改手机号验证码模板编码。 |
| | | */ |
| | | public void setPhoneChange(String phoneChange) { |
| | | this.phoneChange = phoneChange; |
| | | } |
| | | } |
| | | } |
| | |
| | | public class AppSmsAuthProperties { |
| | | |
| | | private int expireSeconds = 300; |
| | | private boolean debugEnabled = false; |
| | | private String debugCode = ""; |
| | | private DebugScene login = new DebugScene(); |
| | | private DebugScene register = new DebugScene(); |
| | | private DebugScene passwordReset = new DebugScene(); |
| | | private DebugScene phoneChange = new DebugScene(); |
| | | |
| | | /** |
| | | * 获取验证码有效期秒数。 |
| | |
| | | } |
| | | |
| | | /** |
| | | * 获取是否启用开发调试验证码。 |
| | | * 获取短信登录调试验证码配置。 |
| | | */ |
| | | public boolean isDebugEnabled() { |
| | | return debugEnabled; |
| | | public DebugScene getLogin() { |
| | | return login; |
| | | } |
| | | |
| | | /** |
| | | * 设置是否启用开发调试验证码。 |
| | | * 设置短信登录调试验证码配置。 |
| | | */ |
| | | public void setDebugEnabled(boolean debugEnabled) { |
| | | this.debugEnabled = debugEnabled; |
| | | public void setLogin(DebugScene login) { |
| | | this.login = login; |
| | | } |
| | | |
| | | /** |
| | | * 获取开发调试验证码。 |
| | | * 获取注册调试验证码配置。 |
| | | */ |
| | | public String getDebugCode() { |
| | | return debugCode; |
| | | public DebugScene getRegister() { |
| | | return register; |
| | | } |
| | | |
| | | /** |
| | | * 设置开发调试验证码。 |
| | | * 设置注册调试验证码配置。 |
| | | */ |
| | | public void setDebugCode(String debugCode) { |
| | | this.debugCode = debugCode; |
| | | public void setRegister(DebugScene register) { |
| | | this.register = register; |
| | | } |
| | | |
| | | /** |
| | | * 获取重置密码调试验证码配置。 |
| | | */ |
| | | public DebugScene getPasswordReset() { |
| | | return passwordReset; |
| | | } |
| | | |
| | | /** |
| | | * 设置重置密码调试验证码配置。 |
| | | */ |
| | | public void setPasswordReset(DebugScene passwordReset) { |
| | | this.passwordReset = passwordReset; |
| | | } |
| | | |
| | | /** |
| | | * 获取修改手机号调试验证码配置。 |
| | | */ |
| | | public DebugScene getPhoneChange() { |
| | | return phoneChange; |
| | | } |
| | | |
| | | /** |
| | | * 设置修改手机号调试验证码配置。 |
| | | */ |
| | | public void setPhoneChange(DebugScene phoneChange) { |
| | | this.phoneChange = phoneChange; |
| | | } |
| | | |
| | | /** |
| | | * 单个短信验证码场景的调试配置。 |
| | | */ |
| | | public static class DebugScene { |
| | | |
| | | private boolean enabled = false; |
| | | private String code = ""; |
| | | |
| | | /** |
| | | * 获取当前场景是否启用调试验证码。 |
| | | */ |
| | | public boolean isEnabled() { |
| | | return enabled; |
| | | } |
| | | |
| | | /** |
| | | * 设置当前场景是否启用调试验证码。 |
| | | */ |
| | | public void setEnabled(boolean enabled) { |
| | | this.enabled = enabled; |
| | | } |
| | | |
| | | /** |
| | | * 获取当前场景调试验证码。 |
| | | */ |
| | | public String getCode() { |
| | | return code; |
| | | } |
| | | |
| | | /** |
| | | * 设置当前场景调试验证码。 |
| | | */ |
| | | public void setCode(String code) { |
| | | this.code = code; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.app.auth.service; |
| | | |
| | | import com.tcm.training.admin.app.auth.config.AppAliyunSmsProperties; |
| | | import com.tcm.training.admin.common.PhoneMasker; |
| | | import com.tcm.training.admin.utils.MsgUtils; |
| | | import org.slf4j.Logger; |
| | | import org.slf4j.LoggerFactory; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import java.util.Collections; |
| | | import java.util.Map; |
| | | |
| | | /** |
| | | * APP 短信发送阿里云实现。 |
| | | */ |
| | | @Service |
| | | public class AliyunAppSmsSender implements AppSmsSender { |
| | | |
| | | private static final Logger log = LoggerFactory.getLogger(AliyunAppSmsSender.class); |
| | | |
| | | private final MsgUtils msgUtils; |
| | | private final AppAliyunSmsProperties properties; |
| | | |
| | | /** |
| | | * 注入阿里云短信工具与模板配置。 |
| | | */ |
| | | public AliyunAppSmsSender(MsgUtils msgUtils, AppAliyunSmsProperties properties) { |
| | | this.msgUtils = msgUtils; |
| | | this.properties = properties; |
| | | } |
| | | |
| | | /** |
| | | * 发送 APP 登录验证码。 |
| | | */ |
| | | @Override |
| | | public void sendLoginCode(String phone, String code) { |
| | | sendCode(phone, code, properties.getTemplates().getLogin(), "登录"); |
| | | } |
| | | |
| | | /** |
| | | * 发送 APP 注册验证码。 |
| | | */ |
| | | @Override |
| | | public void sendRegisterCode(String phone, String code) { |
| | | sendCode(phone, code, properties.getTemplates().getRegister(), "注册"); |
| | | } |
| | | |
| | | /** |
| | | * 发送 APP 忘记密码验证码。 |
| | | */ |
| | | @Override |
| | | public void sendPasswordResetCode(String phone, String code) { |
| | | sendCode(phone, code, properties.getTemplates().getPasswordReset(), "忘记密码"); |
| | | } |
| | | |
| | | /** |
| | | * 发送 APP 修改手机号验证码。 |
| | | */ |
| | | @Override |
| | | public void sendPhoneChangeCode(String phone, String code) { |
| | | sendCode(phone, code, properties.getTemplates().getPhoneChange(), "修改手机号"); |
| | | } |
| | | |
| | | /** |
| | | * 按场景发送验证码短信,并统一处理配置校验与日志。 |
| | | */ |
| | | private void sendCode(String phone, String code, String templateCode, String sceneName) { |
| | | validateSmsConfig(templateCode); |
| | | Map<String, String> params = Collections.singletonMap("code", code); |
| | | try { |
| | | msgUtils.sendMsg(phone, params, templateCode, properties.getSignName()); |
| | | log.info("APP{}验证码发送成功,手机号={}, templateCode={}", sceneName, PhoneMasker.mask(phone), templateCode); |
| | | } catch (Exception exception) { |
| | | log.error("APP{}验证码发送失败,手机号={}, templateCode={}", sceneName, PhoneMasker.mask(phone), templateCode, exception); |
| | | if (exception instanceof IllegalStateException) { |
| | | throw (IllegalStateException) exception; |
| | | } |
| | | throw new IllegalStateException("短信发送失败,请稍后重试"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 校验阿里云短信发送配置是否完整。 |
| | | */ |
| | | private void validateSmsConfig(String templateCode) { |
| | | if (isBlank(properties.getAccessKeyId()) |
| | | || isBlank(properties.getAccessKeySecret()) |
| | | || isBlank(properties.getSignName()) |
| | | || isBlank(templateCode)) { |
| | | throw new IllegalStateException("短信发送配置不完整"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 判断字符串是否为空白。 |
| | | */ |
| | | private boolean isBlank(String value) { |
| | | return value == null || value.trim().isEmpty(); |
| | | } |
| | | } |
| | |
| | | @Service |
| | | public class AppSmsCodeService { |
| | | |
| | | private static final String SCENE_LOGIN = "login"; |
| | | private static final String SCENE_REGISTER = "register"; |
| | | private static final String SCENE_PASSWORD_RESET = "password-reset"; |
| | | private static final String SCENE_PHONE_CHANGE = "phone-change"; |
| | | private static final String LOGIN_CODE_CACHE_PREFIX = "app:sms:login:"; |
| | | private static final String REGISTER_CODE_CACHE_PREFIX = "app:sms:register:"; |
| | | private static final String PASSWORD_RESET_CODE_CACHE_PREFIX = "app:sms:password-reset:"; |
| | |
| | | * 校验登录验证码,成功后销毁缓存中的一次性验证码。 |
| | | */ |
| | | public void validateLoginCode(String phone, String code) { |
| | | validateCode(phone, code, LOGIN_CODE_CACHE_PREFIX); |
| | | validateCode(phone, code, LOGIN_CODE_CACHE_PREFIX, SCENE_LOGIN); |
| | | } |
| | | |
| | | /** |
| | | * 校验注册验证码,成功后销毁缓存中的一次性验证码。 |
| | | */ |
| | | public void validateRegisterCode(String phone, String code) { |
| | | validateCode(phone, code, REGISTER_CODE_CACHE_PREFIX); |
| | | validateCode(phone, code, REGISTER_CODE_CACHE_PREFIX, SCENE_REGISTER); |
| | | } |
| | | |
| | | /** |
| | | * 校验忘记密码验证码,成功后销毁缓存中的一次性验证码。 |
| | | */ |
| | | public void validatePasswordResetCode(String phone, String code) { |
| | | validateCode(phone, code, PASSWORD_RESET_CODE_CACHE_PREFIX); |
| | | validateCode(phone, code, PASSWORD_RESET_CODE_CACHE_PREFIX, SCENE_PASSWORD_RESET); |
| | | } |
| | | |
| | | /** |
| | | * 校验修改手机号验证码,成功后销毁缓存中的一次性验证码。 |
| | | */ |
| | | public void validatePhoneChangeCode(String phone, String code) { |
| | | validateCode(phone, code, PHONE_CHANGE_CODE_CACHE_PREFIX); |
| | | validateCode(phone, code, PHONE_CHANGE_CODE_CACHE_PREFIX, SCENE_PHONE_CHANGE); |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | private AppSmsCodeSendResponse createCode(String phone, String cachePrefix, String scene) { |
| | | String code = generateCode(); |
| | | if (SCENE_LOGIN.equals(scene)) { |
| | | appSmsSender.sendLoginCode(phone, code); |
| | | } else if (SCENE_REGISTER.equals(scene)) { |
| | | appSmsSender.sendRegisterCode(phone, code); |
| | | } else if (SCENE_PASSWORD_RESET.equals(scene)) { |
| | | appSmsSender.sendPasswordResetCode(phone, code); |
| | | } else { |
| | | appSmsSender.sendPhoneChangeCode(phone, code); |
| | | } |
| | | stringRedisTemplate.opsForValue().set( |
| | | buildCacheKey(cachePrefix, phone), |
| | | code, |
| | | appSmsAuthProperties.getExpireSeconds(), |
| | | TimeUnit.SECONDS |
| | | ); |
| | | if ("login".equals(scene)) { |
| | | appSmsSender.sendLoginCode(phone, code); |
| | | } else if ("register".equals(scene)) { |
| | | appSmsSender.sendRegisterCode(phone, code); |
| | | } else if ("password-reset".equals(scene)) { |
| | | appSmsSender.sendPasswordResetCode(phone, code); |
| | | } else { |
| | | appSmsSender.sendPhoneChangeCode(phone, code); |
| | | } |
| | | return new AppSmsCodeSendResponse(appSmsAuthProperties.getExpireSeconds()); |
| | | } |
| | | |
| | | /** |
| | | * 统一校验指定场景的验证码并执行一次性销毁。 |
| | | */ |
| | | private void validateCode(String phone, String code, String cachePrefix) { |
| | | if (matchesDebugCode(code)) { |
| | | // 开发环境允许固定验证码直通,便于前后端联调。 |
| | | private void validateCode(String phone, String code, String cachePrefix, String scene) { |
| | | if (matchesDebugCode(scene, code)) { |
| | | // 调试验证码按场景控制,生产环境仅放开短信登录场景。 |
| | | return; |
| | | } |
| | | String cacheKey = buildCacheKey(cachePrefix, phone); |
| | |
| | | } |
| | | |
| | | /** |
| | | * 判断是否命中本地调试验证码。 |
| | | * 判断当前场景是否命中调试验证码。 |
| | | */ |
| | | private boolean matchesDebugCode(String code) { |
| | | if (!appSmsAuthProperties.isDebugEnabled()) { |
| | | private boolean matchesDebugCode(String scene, String code) { |
| | | AppSmsAuthProperties.DebugScene debugScene = resolveDebugScene(scene); |
| | | if (debugScene == null || !debugScene.isEnabled()) { |
| | | return false; |
| | | } |
| | | String debugCode = appSmsAuthProperties.getDebugCode(); |
| | | String debugCode = debugScene.getCode(); |
| | | return debugCode != null && !debugCode.trim().isEmpty() && debugCode.equals(code); |
| | | } |
| | | |
| | | /** |
| | | * 读取指定场景的调试验证码配置。 |
| | | */ |
| | | private AppSmsAuthProperties.DebugScene resolveDebugScene(String scene) { |
| | | if (SCENE_LOGIN.equals(scene)) { |
| | | return appSmsAuthProperties.getLogin(); |
| | | } |
| | | if (SCENE_REGISTER.equals(scene)) { |
| | | return appSmsAuthProperties.getRegister(); |
| | | } |
| | | if (SCENE_PASSWORD_RESET.equals(scene)) { |
| | | return appSmsAuthProperties.getPasswordReset(); |
| | | } |
| | | if (SCENE_PHONE_CHANGE.equals(scene)) { |
| | | return appSmsAuthProperties.getPhoneChange(); |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * 构造手机号维度的验证码缓存 key。 |
| | | */ |
| | | private String buildCacheKey(String cachePrefix, String phone) { |
| New file |
| | |
| | | package com.tcm.training.admin.utils; |
| | | |
| | | import com.fasterxml.jackson.core.JsonProcessingException; |
| | | import com.fasterxml.jackson.databind.ObjectMapper; |
| | | import com.tcm.training.admin.app.auth.config.AppAliyunSmsProperties; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import com.aliyun.dysmsapi20170525.models.SendSmsRequest; |
| | | import com.aliyun.dysmsapi20170525.models.SendSmsResponse; |
| | | import com.aliyun.tea.TeaException; |
| | | import com.aliyun.teaopenapi.models.Config; |
| | | import com.aliyun.teautil.models.RuntimeOptions; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | import java.util.Map; |
| | | |
| | | @Component |
| | | @Slf4j |
| | | public class MsgUtils { |
| | | |
| | | private final AppAliyunSmsProperties properties; |
| | | private final ObjectMapper objectMapper; |
| | | |
| | | /** |
| | | * 注入阿里云短信配置与 JSON 序列化工具。 |
| | | */ |
| | | @Autowired |
| | | public MsgUtils(AppAliyunSmsProperties properties, ObjectMapper objectMapper) { |
| | | this.properties = properties; |
| | | this.objectMapper = objectMapper; |
| | | } |
| | | |
| | | /** |
| | | * 使用AK&SK初始化账号Client |
| | | * @param accessKeyId |
| | | * @param accessKeySecret |
| | | * @return Client |
| | | * @throws Exception |
| | | */ |
| | | public static com.aliyun.dysmsapi20170525.Client createClient(String accessKeyId, String accessKeySecret) throws Exception { |
| | | Config config = new Config() |
| | | // 您的 AccessKey ID |
| | | .setAccessKeyId(accessKeyId) |
| | | // 您的 AccessKey Secret |
| | | .setAccessKeySecret(accessKeySecret); |
| | | // 访问的域名 |
| | | config.endpoint = "dysmsapi.aliyuncs.com"; |
| | | return new com.aliyun.dysmsapi20170525.Client(config); |
| | | } |
| | | |
| | | /** |
| | | * 发送阿里云短信验证码。 |
| | | */ |
| | | public void sendMsg(String phone, Map<String,String> params, String templateCode, String signName) throws Exception { |
| | | com.aliyun.dysmsapi20170525.Client client = MsgUtils.createClient( |
| | | properties.getAccessKeyId(), |
| | | properties.getAccessKeySecret() |
| | | ); |
| | | SendSmsRequest sendSmsRequest = new SendSmsRequest() |
| | | .setSignName(signName) |
| | | .setTemplateCode(templateCode) |
| | | .setPhoneNumbers(phone); |
| | | if (params != null && !params.isEmpty()) { |
| | | sendSmsRequest.setTemplateParam(toTemplateParam(params)); |
| | | } |
| | | RuntimeOptions runtime = new RuntimeOptions(); |
| | | try { |
| | | SendSmsResponse sendSmsResponse = client.sendSmsWithOptions(sendSmsRequest, runtime); |
| | | String responseCode = sendSmsResponse.getBody() == null ? null : sendSmsResponse.getBody().getCode(); |
| | | if (!"OK".equalsIgnoreCase(responseCode)) { |
| | | String message = sendSmsResponse.getBody() == null ? "短信发送失败" : sendSmsResponse.getBody().getMessage(); |
| | | throw new IllegalStateException(message == null || message.trim().isEmpty() ? "短信发送失败,请稍后重试" : message); |
| | | } |
| | | log.info("阿里云短信发送成功,templateCode={}, statusCode={}", templateCode, sendSmsResponse.getStatusCode()); |
| | | } catch (TeaException error) { |
| | | com.aliyun.teautil.Common.assertAsString(error.message); |
| | | log.error("阿里云短信发送失败,templateCode={}, message={}", templateCode, error.message); |
| | | throw new IllegalStateException("短信发送失败,请稍后重试"); |
| | | } catch (Exception _error) { |
| | | TeaException error = new TeaException(_error.getMessage(), _error); |
| | | com.aliyun.teautil.Common.assertAsString(error.message); |
| | | log.error("阿里云短信发送异常,templateCode={}, message={}", templateCode, error.message); |
| | | throw new IllegalStateException("短信发送失败,请稍后重试"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 将模板变量转换为阿里云要求的 JSON 字符串。 |
| | | */ |
| | | private String toTemplateParam(Map<String, String> params) throws JsonProcessingException { |
| | | return objectMapper.writeValueAsString(params); |
| | | } |
| | | } |
| | |
| | | path: ./logs |
| | | auth: |
| | | sms: |
| | | # 本地联调可开启固定验证码 |
| | | debug-enabled: true |
| | | debug-code: "666666" |
| | | aliyun: |
| | | access-key-id: your-access-key-id |
| | | access-key-secret: your-access-key-secret |
| | | sign-name: your-sign-name |
| | | templates: |
| | | login: SMS_505135234 |
| | | register: SMS_505065330 |
| | | password-reset: SMS_505095303 |
| | | phone-change: SMS_505135235 |
| | | # 本地联调可按场景开启固定验证码 |
| | | login: |
| | | enabled: true |
| | | code: "666666" |
| | | register: |
| | | enabled: true |
| | | code: "666666" |
| | | password-reset: |
| | | enabled: true |
| | | code: "666666" |
| | | phone-change: |
| | | enabled: true |
| | | code: "666666" |
| | |
| | | app: |
| | | auth: |
| | | sms: |
| | | debug-enabled: false |
| | | debug-code: "" |
| | | aliyun: |
| | | access-key-id: "LTAI5t65dd3j1vT5kxAp8KHy" |
| | | access-key-secret: "XPbEgmD3eSQLfPfFvuGhQchJzqWpF8" |
| | | sign-name: "海诊通海南健康科技" |
| | | templates: |
| | | login: SMS_505135234 |
| | | register: SMS_505065330 |
| | | password-reset: SMS_505095303 |
| | | phone-change: SMS_505135235 |
| | | # 生产环境仅允许短信登录使用默认验证码,其他场景继续走真实验证码。 |
| | | login: |
| | | enabled: true |
| | | code: "666666" |
| | | register: |
| | | enabled: false |
| | | code: "" |
| | | password-reset: |
| | | enabled: false |
| | | code: "" |
| | | phone-change: |
| | | enabled: false |
| | | code: "" |
| | |
| | | sms: |
| | | # 短信验证码有效期(秒) |
| | | expire-seconds: 300 |
| | | # 默认关闭开发调试验证码,避免误入生产环境 |
| | | debug-enabled: false |
| | | # 开发环境调试验证码,默认留空,需在 application-local.yml 中显式开启 |
| | | debug-code: "" |
| | | aliyun: |
| | | access-key-id: "" |
| | | access-key-secret: "" |
| | | sign-name: "" |
| | | templates: |
| | | login: "" |
| | | register: "" |
| | | password-reset: "" |
| | | phone-change: "" |
| | | # 默认关闭所有场景调试验证码,避免误入生产环境 |
| | | login: |
| | | enabled: false |
| | | code: "" |
| | | register: |
| | | enabled: false |
| | | code: "" |
| | | password-reset: |
| | | enabled: false |
| | | code: "" |
| | | phone-change: |
| | | enabled: false |
| | | code: "" |
| | | wechat: |
| | | # 微信开放平台 AppId,当前阶段可先留空,补齐后启用服务端换票模式 |
| | | app-id: "" |
| New file |
| | |
| | | package com.tcm.training.admin.app.auth.service; |
| | | |
| | | import com.tcm.training.admin.app.auth.config.AppAliyunSmsProperties; |
| | | import com.tcm.training.admin.utils.MsgUtils; |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.junit.jupiter.api.extension.ExtendWith; |
| | | import org.mockito.ArgumentCaptor; |
| | | import org.mockito.Captor; |
| | | import org.mockito.Mock; |
| | | import org.mockito.junit.jupiter.MockitoExtension; |
| | | |
| | | import java.util.Map; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertThrows; |
| | | import static org.mockito.ArgumentMatchers.eq; |
| | | import static org.mockito.Mockito.doNothing; |
| | | import static org.mockito.Mockito.doThrow; |
| | | import static org.mockito.Mockito.verify; |
| | | |
| | | /** |
| | | * APP 阿里云短信发送实现测试。 |
| | | */ |
| | | @ExtendWith(MockitoExtension.class) |
| | | class AliyunAppSmsSenderTest { |
| | | |
| | | @Mock |
| | | private MsgUtils msgUtils; |
| | | |
| | | @Captor |
| | | private ArgumentCaptor<Map<String, String>> paramsCaptor; |
| | | |
| | | private AppAliyunSmsProperties properties; |
| | | |
| | | private AliyunAppSmsSender aliyunAppSmsSender; |
| | | |
| | | /** |
| | | * 每个用例前构造阿里云短信发送器。 |
| | | */ |
| | | @BeforeEach |
| | | void setUp() { |
| | | properties = new AppAliyunSmsProperties(); |
| | | properties.setAccessKeyId("test-ak"); |
| | | properties.setAccessKeySecret("test-sk"); |
| | | properties.setSignName("海诊通"); |
| | | properties.getTemplates().setLogin("SMS_505135234"); |
| | | properties.getTemplates().setRegister("SMS_505065330"); |
| | | properties.getTemplates().setPasswordReset("SMS_505095303"); |
| | | properties.getTemplates().setPhoneChange("SMS_505135235"); |
| | | |
| | | aliyunAppSmsSender = new AliyunAppSmsSender(msgUtils, properties); |
| | | } |
| | | |
| | | /** |
| | | * 登录验证码应使用登录模板并透传 code 参数。 |
| | | */ |
| | | @Test |
| | | void sendLoginCodeShouldUseLoginTemplate() throws Exception { |
| | | doNothing().when(msgUtils).sendMsg(eq("13800138000"), paramsCaptor.capture(), eq("SMS_505135234"), eq("海诊通")); |
| | | |
| | | aliyunAppSmsSender.sendLoginCode("13800138000", "666666"); |
| | | |
| | | assertEquals("666666", paramsCaptor.getValue().get("code")); |
| | | } |
| | | |
| | | /** |
| | | * 短信发送失败时应向上抛出统一业务异常。 |
| | | */ |
| | | @Test |
| | | void sendRegisterCodeShouldThrowWhenMsgUtilsFails() throws Exception { |
| | | doThrow(new IllegalStateException("短信发送失败,请稍后重试")) |
| | | .when(msgUtils).sendMsg(eq("13800138001"), org.mockito.ArgumentMatchers.anyMap(), eq("SMS_505065330"), eq("海诊通")); |
| | | |
| | | IllegalStateException exception = assertThrows(IllegalStateException.class, |
| | | () -> aliyunAppSmsSender.sendRegisterCode("13800138001", "123456")); |
| | | |
| | | assertEquals("短信发送失败,请稍后重试", exception.getMessage()); |
| | | } |
| | | |
| | | /** |
| | | * 模板配置不完整时应直接拒绝发送。 |
| | | */ |
| | | @Test |
| | | void sendPhoneChangeCodeShouldFailWhenTemplateMissing() { |
| | | properties.getTemplates().setPhoneChange(""); |
| | | |
| | | IllegalStateException exception = assertThrows(IllegalStateException.class, |
| | | () -> aliyunAppSmsSender.sendPhoneChangeCode("13800138002", "123456")); |
| | | |
| | | assertEquals("短信发送配置不完整", exception.getMessage()); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.app.auth.service; |
| | | |
| | | import com.tcm.training.admin.app.auth.config.AppSmsAuthProperties; |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.junit.jupiter.api.extension.ExtendWith; |
| | | import org.mockito.Mock; |
| | | import org.mockito.junit.jupiter.MockitoExtension; |
| | | import org.springframework.data.redis.core.StringRedisTemplate; |
| | | import org.springframework.data.redis.core.ValueOperations; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertThrows; |
| | | import static org.mockito.ArgumentMatchers.anyString; |
| | | import static org.mockito.ArgumentMatchers.anyLong; |
| | | import static org.mockito.ArgumentMatchers.eq; |
| | | import static org.mockito.Mockito.never; |
| | | import static org.mockito.Mockito.verify; |
| | | import static org.mockito.Mockito.when; |
| | | |
| | | /** |
| | | * APP 短信验证码场景化调试配置测试。 |
| | | */ |
| | | @ExtendWith(MockitoExtension.class) |
| | | class AppSmsCodeServiceTest { |
| | | |
| | | @Mock |
| | | private StringRedisTemplate stringRedisTemplate; |
| | | |
| | | @Mock |
| | | private ValueOperations<String, String> valueOperations; |
| | | |
| | | @Mock |
| | | private AppSmsSender appSmsSender; |
| | | |
| | | private AppSmsAuthProperties appSmsAuthProperties; |
| | | |
| | | private AppSmsCodeService appSmsCodeService; |
| | | |
| | | /** |
| | | * 每个用例前重新构造短信验证码服务。 |
| | | */ |
| | | @BeforeEach |
| | | void setUp() { |
| | | appSmsAuthProperties = new AppSmsAuthProperties(); |
| | | appSmsAuthProperties.getLogin().setEnabled(true); |
| | | appSmsAuthProperties.getLogin().setCode("666666"); |
| | | appSmsAuthProperties.getRegister().setEnabled(false); |
| | | appSmsAuthProperties.getRegister().setCode(""); |
| | | appSmsAuthProperties.getPasswordReset().setEnabled(false); |
| | | appSmsAuthProperties.getPasswordReset().setCode(""); |
| | | appSmsAuthProperties.getPhoneChange().setEnabled(false); |
| | | appSmsAuthProperties.getPhoneChange().setCode(""); |
| | | |
| | | appSmsCodeService = new AppSmsCodeService( |
| | | stringRedisTemplate, |
| | | appSmsSender, |
| | | appSmsAuthProperties |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * 仅开启登录调试验证码时,666666 应允许短信登录直接通过。 |
| | | */ |
| | | @Test |
| | | void validateLoginCodeShouldAllowConfiguredDebugCode() { |
| | | assertDoesNotThrow(() -> appSmsCodeService.validateLoginCode("13800138000", "666666")); |
| | | verify(stringRedisTemplate, never()).opsForValue(); |
| | | } |
| | | |
| | | /** |
| | | * 未开启注册调试验证码时,666666 不应绕过真实验证码校验。 |
| | | */ |
| | | @Test |
| | | void validateRegisterCodeShouldRejectDebugCodeWhenSceneDisabled() { |
| | | when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); |
| | | when(valueOperations.get(anyString())).thenReturn(null); |
| | | |
| | | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, |
| | | () -> appSmsCodeService.validateRegisterCode("13800138000", "666666")); |
| | | |
| | | assertEquals("验证码已失效,请重新获取", exception.getMessage()); |
| | | } |
| | | |
| | | /** |
| | | * 短信发送成功后才应写入验证码缓存。 |
| | | */ |
| | | @Test |
| | | void createLoginCodeShouldCacheCodeAfterSendSucceeds() { |
| | | when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); |
| | | |
| | | appSmsCodeService.createLoginCode("13800138000"); |
| | | |
| | | verify(appSmsSender).sendLoginCode(eq("13800138000"), anyString()); |
| | | verify(valueOperations).set(anyString(), anyString(), anyLong(), eq(java.util.concurrent.TimeUnit.SECONDS)); |
| | | } |
| | | |
| | | /** |
| | | * 短信发送失败时不应写入验证码缓存。 |
| | | */ |
| | | @Test |
| | | void createLoginCodeShouldNotCacheCodeWhenSendFails() { |
| | | org.mockito.Mockito.doThrow(new IllegalStateException("短信发送失败,请稍后重试")) |
| | | .when(appSmsSender).sendLoginCode(eq("13800138000"), anyString()); |
| | | |
| | | IllegalStateException exception = assertThrows(IllegalStateException.class, |
| | | () -> appSmsCodeService.createLoginCode("13800138000")); |
| | | |
| | | assertEquals("短信发送失败,请稍后重试", exception.getMessage()); |
| | | verify(valueOperations, never()).set(anyString(), anyString(), anyLong(), eq(java.util.concurrent.TimeUnit.SECONDS)); |
| | | } |
| | | } |
| | |
| | | "pages": [{ |
| | | "path": "pages/home/home", |
| | | "style": { |
| | | "navigationStyle": "custom" |
| | | "navigationStyle": "custom", |
| | | "enablePullDownRefresh": true |
| | | } |
| | | }, { |
| | | "path": "pages/login/login", |
| | |
| | | <script setup lang="ts"> |
| | | import UpdatePopup from "@/components/updatePopup.vue"; |
| | | import { ref, onMounted, computed } from "vue"; |
| | | import { onPageScroll, onShow } from "@dcloudio/uni-app"; |
| | | import { onPageScroll, onPullDownRefresh, onShow } from "@dcloudio/uni-app"; |
| | | import { getBannerList, getHomeFeaturedContent, getHomeLiveCourseList } from "./service"; |
| | | |
| | | interface HomeBannerItem { |
| | |
| | | const defaultLiveCover = '/static/image/home/class.png'; |
| | | const defaultCourseCover = '/static/image/home/class.png'; |
| | | const defaultPopularizationCover = '/static/image/home/zixun.png'; |
| | | const refreshing = ref(false); |
| | | |
| | | /** |
| | | * 初始化导航栏所需系统信息。 |
| | |
| | | * 每次进入首页都刷新首页模块数据,确保展示最新 banner 与直播状态。 |
| | | */ |
| | | onShow(() => { |
| | | loadBannerList(); |
| | | loadHomeLiveCourseList(); |
| | | loadFeaturedContent(); |
| | | refreshHomeData(); |
| | | }); |
| | | |
| | | /** |
| | | * 首页下拉时重新拉取首页聚合数据,并在结束后关闭原生刷新状态。 |
| | | */ |
| | | onPullDownRefresh(async () => { |
| | | try { |
| | | await refreshHomeData(); |
| | | } finally { |
| | | uni.stopPullDownRefresh(); |
| | | } |
| | | }); |
| | | |
| | | /** |
| | |
| | | }; |
| | | |
| | | /** |
| | | * 聚合刷新首页三块内容,避免 onShow 和下拉刷新分别维护重复请求逻辑。 |
| | | */ |
| | | const refreshHomeData = async () => { |
| | | if (refreshing.value) { |
| | | return; |
| | | } |
| | | refreshing.value = true; |
| | | try { |
| | | await Promise.all([ |
| | | loadBannerList(), |
| | | loadHomeLiveCourseList(), |
| | | loadFeaturedContent() |
| | | ]); |
| | | } finally { |
| | | refreshing.value = false; |
| | | } |
| | | }; |
| | | |
| | | /** |
| | | * 将课程价格格式化为首页展示文案。 |
| | | */ |
| | | const formatPrice = (price?: number | string) => { |