templateParams) {
+ try {
+ String templateCode = tencentSmsProperties.getTemplates().get(smsType.getValue());
+
+ Credential cred = new Credential(
+ tencentSmsProperties.getSecretId(),
+ tencentSmsProperties.getSecretKey()
+ );
+
+ HttpProfile httpProfile = new HttpProfile();
+ httpProfile.setEndpoint("sms.tencentcloudapi.com");
+
+ ClientProfile clientProfile = new ClientProfile();
+ clientProfile.setHttpProfile(httpProfile);
+
+ SmsClient client = new SmsClient(cred, tencentSmsProperties.getRegionId(), clientProfile);
+
+ SendSmsRequest req = new SendSmsRequest();
+ req.setSmsSdkAppId(tencentSmsProperties.getSdkAppId());
+ req.setSignName(tencentSmsProperties.getSignName());
+ req.setTemplateId(templateCode);
+
+ String[] phoneNumberSet = {"+86" + mobile};
+ req.setPhoneNumberSet(phoneNumberSet);
+
+ String[] templateParamSet = templateParams.values().toArray(new String[0]);
+ req.setTemplateParamSet(templateParamSet);
+
+ SendSmsResponse resp = client.SendSms(req);
+
+ log.info("腾讯云短信发送响应: {}", JSONUtil.toJsonStr(resp));
+
+ return "Ok".equals(resp.getSendStatusSet()[0].getCode());
+
+ } catch (TencentCloudSDKException e) {
+ log.error("腾讯云短信发送失败", e);
+ return false;
+ }
+ }
+}
diff --git a/src/main/java/com/youlai/boot/framework/security/config/SecurityConfig.java b/src/main/java/com/youlai/boot/framework/security/config/SecurityConfig.java
index 190175e6..20d5704d 100644
--- a/src/main/java/com/youlai/boot/framework/security/config/SecurityConfig.java
+++ b/src/main/java/com/youlai/boot/framework/security/config/SecurityConfig.java
@@ -3,6 +3,7 @@ package com.youlai.boot.framework.security.config;
import cn.binarywang.wx.miniapp.api.WxMaService;
import com.youlai.boot.framework.captcha.service.CaptchaService;
import cn.hutool.core.util.ArrayUtil;
+import com.youlai.boot.framework.security.filter.MobileApiSignatureFilter;
import com.youlai.boot.framework.web.filter.RateLimiterFilter;
import com.youlai.boot.framework.security.filter.CaptchaValidationFilter;
import com.youlai.boot.framework.security.filter.TokenAuthenticationFilter;
@@ -67,6 +68,10 @@ public class SecurityConfig {
if (ArrayUtil.isNotEmpty(ignoreUrls)) {
requestMatcherRegistry.requestMatchers(ignoreUrls).permitAll();
}
+
+ // 移动设备专用接口路径(需要设备签名验证,但不需要用户登录)
+ requestMatcherRegistry.requestMatchers("/api/v1/sn/**").permitAll();
+ requestMatcherRegistry.requestMatchers("/api/v1/auth/app/**").permitAll();
// 其他所有请求需登录后访问
requestMatcherRegistry.anyRequest().authenticated();
}
@@ -90,6 +95,8 @@ public class SecurityConfig {
.addFilterBefore(new RateLimiterFilter(redisTemplate, configService), UsernamePasswordAuthenticationFilter.class)
// 验证码校验过滤器
.addFilterBefore(new CaptchaValidationFilter(captchaService), UsernamePasswordAuthenticationFilter.class)
+ // 移动设备API签名验证过滤器(仅对 /api/v1/sn/** 路径生效)
+ .addFilterBefore(new MobileApiSignatureFilter(redisTemplate), UsernamePasswordAuthenticationFilter.class)
// 验证和解析过滤器
.addFilterBefore(new TokenAuthenticationFilter(tokenManager), UsernamePasswordAuthenticationFilter.class)
.build();
diff --git a/src/main/java/com/youlai/boot/framework/security/filter/MobileApiSignatureFilter.java b/src/main/java/com/youlai/boot/framework/security/filter/MobileApiSignatureFilter.java
new file mode 100644
index 00000000..1a9d2d27
--- /dev/null
+++ b/src/main/java/com/youlai/boot/framework/security/filter/MobileApiSignatureFilter.java
@@ -0,0 +1,187 @@
+package com.youlai.boot.framework.security.filter;
+
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.crypto.digest.DigestUtil;
+import com.youlai.boot.common.result.ResultCode;
+import com.youlai.boot.common.result.ResponseWriter;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+/**
+ * 移动设备API签名验证过滤器
+ *
+ * 用于验证 /api/v1/sn/* 路径下的移动设备请求
+ * 验证规则:
+ * 1. 检查必需的设备标识(deviceId)
+ * 2. 检查时间戳(timestamp),防止重放攻击
+ * 3. 验证签名(sign),确保请求未被篡改
+ *
+ * @author Ray.Hao
+ * @since 2026/4/21
+ */
+public class MobileApiSignatureFilter extends OncePerRequestFilter {
+
+ private static final String HEADER_DEVICE_ID = "X-Device-SN";
+ private static final String HEADER_NONCE = "X-Nonce";
+ private static final String HEADER_TIMESTAMP = "X-Timestamp";
+ private static final String HEADER_SIGN = "X-Sign";
+
+
+ /**
+ * 签名有效期(毫秒),默认2分钟
+ */
+ private static final long SIGN_VALID_DURATION = 2 * 60 * 1000L;
+
+ /**
+ * 设备密钥前缀
+ */
+ private static final String DEVICE_SECRET_PREFIX = "device:secret:";
+
+ private final RedisTemplate redisTemplate;
+
+ public MobileApiSignatureFilter(RedisTemplate redisTemplate) {
+ this.redisTemplate = redisTemplate;
+ }
+
+ @Override
+ protected boolean shouldNotFilter(HttpServletRequest request) {
+ String requestURI = request.getRequestURI();
+ return !requestURI.startsWith("/api/v1/sn/");
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+
+ // 1. 获取设备标识
+ String deviceId = request.getHeader(HEADER_DEVICE_ID);
+ if (StrUtil.isBlank(deviceId)) {
+ ResponseWriter.writeSuccess(response, ResultCode.MOBILE_DEVICE_ID_REQUIRED);
+ return;
+ }
+ // 获取随机数
+ String nonce = request.getHeader(HEADER_NONCE);
+ if (StrUtil.isBlank(nonce)) {
+ ResponseWriter.writeSuccess(response, ResultCode.MOBILE_NONCE_REQUIRED);
+ return;
+ }
+
+ // 2. 获取时间戳
+ String timestampStr = request.getHeader(HEADER_TIMESTAMP);
+ if (StrUtil.isBlank(timestampStr)) {
+ ResponseWriter.writeSuccess(response, ResultCode.MOBILE_TIMESTAMP_REQUIRED);
+ return;
+ }
+
+ long timestamp;
+ try {
+ timestamp = Long.parseLong(timestampStr);
+ } catch (NumberFormatException e) {
+ ResponseWriter.writeSuccess(response, ResultCode.MOBILE_TIMESTAMP_INVALID);
+ return;
+ }
+
+ // 3. 验证时间戳是否在有效期内
+ long currentTime = System.currentTimeMillis();
+ if (Math.abs(currentTime - timestamp) > SIGN_VALID_DURATION) {
+ ResponseWriter.writeSuccess(response, ResultCode.MOBILE_TIMESTAMP_EXPIRED);
+ return;
+ }
+
+ // 4. 获取签名
+ String sign = request.getHeader(HEADER_SIGN);
+ if (StrUtil.isBlank(sign)) {
+ ResponseWriter.writeSuccess(response, ResultCode.MOBILE_SIGN_REQUIRED);
+ return;
+ }
+
+ // 5. 获取设备密钥(从Redis或数据库)
+// String deviceSecret = getDeviceSecret(deviceId);
+// if (StrUtil.isBlank(deviceSecret)) {
+// ResponseWriter.writeSuccess(response, ResultCode.MOBILE_DEVICE_NOT_REGISTERED);
+// return;
+// }
+
+ // 6. 验证签名
+ String expectedSign = generateSign(request);
+ logger.info("Expected sign: " + expectedSign);
+ if (!sign.equals(expectedSign)) {
+ ResponseWriter.writeSuccess(response, ResultCode.MOBILE_SIGN_INVALID);
+ return;
+ }
+
+ // 7. 将设备信息存入请求属性,供后续使用
+ request.setAttribute("deviceId", deviceId);
+ request.setAttribute("deviceAuthenticated", true);
+
+ // 8. 继续过滤器链
+ filterChain.doFilter(request, response);
+ }
+
+ /**
+ * 获取设备密钥
+ */
+ private String getDeviceSecret(String deviceId) {
+ Object secret = redisTemplate.opsForValue().get(DEVICE_SECRET_PREFIX + deviceId);
+ return secret != null ? secret.toString() : null;
+ }
+
+ /**
+ * 生成签名
+ *
+ * 签名算法:MD5(sorted_params + timestamp + deviceSecret)
+ */
+ private String generateSign(HttpServletRequest request, String deviceSecret) {
+ // 1. 收集所有请求参数
+ TreeMap params = new TreeMap<>();
+ request.getParameterMap().forEach((key, values) -> {
+ if (values != null && values.length > 0) {
+ params.put(key, values[0]);
+ }
+ });
+
+ // 2. 按字母顺序拼接参数
+ StringBuilder sb = new StringBuilder();
+ params.forEach((key, value) -> {
+ sb.append(key).append("=").append(value).append("&");
+ });
+
+ // 3. 添加时间戳和设备密钥
+ String timestamp = request.getHeader(HEADER_TIMESTAMP);
+ sb.append("timestamp=").append(timestamp)
+ .append("&secret=").append(deviceSecret);
+
+ // 4. 生成MD5签名
+ return DigestUtil.md5Hex(sb.toString(), StandardCharsets.UTF_8);
+ }
+
+ private String generateSign(HttpServletRequest request) {
+ // 1. 收集所有请求参数
+ SortedMap params = new TreeMap<>();
+ params.put(HEADER_DEVICE_ID, request.getHeader(HEADER_DEVICE_ID));
+ params.put(HEADER_NONCE, request.getHeader(HEADER_NONCE));
+ params.put(HEADER_TIMESTAMP, request.getHeader(HEADER_TIMESTAMP));
+
+ // 2. 按字母顺序拼接参数
+ StringBuilder sb = new StringBuilder();
+ params.forEach((key, value) -> {
+ sb.append(key).append("=").append(value).append("&");
+ });
+ sb.setLength(sb.length() - 1);
+
+ // 3. 生成MD5签名
+ return DigestUtil.sha256Hex(sb.toString());
+ }
+}
diff --git a/src/main/java/com/youlai/boot/framework/web/config/JacksonConfig.java b/src/main/java/com/youlai/boot/framework/web/config/JacksonConfig.java
index 7ca0f637..d3aeed62 100644
--- a/src/main/java/com/youlai/boot/framework/web/config/JacksonConfig.java
+++ b/src/main/java/com/youlai/boot/framework/web/config/JacksonConfig.java
@@ -2,6 +2,7 @@ package com.youlai.boot.framework.web.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import tools.jackson.databind.PropertyNamingStrategies;
import tools.jackson.databind.cfg.DateTimeFeature;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.module.SimpleModule;
@@ -41,6 +42,7 @@ public class JacksonConfig {
.addSerializer(Long.class, ToStringSerializer.instance)
.addSerializer(BigInteger.class, ToStringSerializer.instance)
)
+// .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.build();
}
diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml
index a95e360d..024222ac 100644
--- a/src/main/resources/application-dev.yml
+++ b/src/main/resources/application-dev.yml
@@ -2,19 +2,23 @@ server:
port: 8000
spring:
+ threads:
+ virtual:
+ enabled: true # 开启虚拟线程
+
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver # 3.2.0开始支持SPI可省略此配置
- url: jdbc:mysql://www.youlai.tech:3306/youlai_admin?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&allowMultiQueries=true
- username: youlai
- password: 123456
+ url: jdbc:mysql://175.178.213.60:33306/youlai_admin?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&allowMultiQueries=true
+ username: root
+ password: fanhuitong
data:
redis:
- database: 0
- host: www.youlai.tech
- port: 6379
- password: 123456
+ database: 12
+ host: 175.178.213.60
+ port: 26379
+ password: fanhuitong
timeout: 10s
lettuce:
pool:
@@ -50,6 +54,14 @@ spring:
enable: true
# 邮件发送者
from: youlaitech@163.com
+ mongodb:
+ host: 175.178.213.60
+ port: 27027
+ database: device_apks
+ username: fht
+ password: fanhuitong
+ authentication-database: admin
+
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
global-config:
@@ -86,6 +98,7 @@ security:
- /api/v1/auth/refresh-token # 刷新令牌接口
- /api/v1/wxma/auth/** # 微信小程序认证接口(静默登录/手机号快捷登录/绑定手机号)
- /api/v1/logs/** # 日志接口(访问日志列表)
+ - /api/v1/sn/** # 移动设备专用接口(通过设备签名验证)
# 非安全端点路径,完全绕过 Spring Security 的过滤器
unsecured-urls:
- ${springdoc.swagger-ui.path}
@@ -130,18 +143,29 @@ oss:
sms:
# 阿里云短信
aliyun:
- accessKeyId: LTAI5tSMgfxxxxxxdiBJLyR
- accessKeySecret: SoOWRqpjtS7xxxxxxZ2PZiMTJOVC
+ accessKeyId: LTAI5t6DdbXsfbyE91bscHEc
+ accessKeySecret: s37PIUqflWiQT4FSNiwCSC30Bc5ojf
domain: dysmsapi.aliyuncs.com
- regionId: cn-shanghai
- signName: 有来技术
+ regionId: cn-shenzhen
+ signName: 深圳市壹键通讯科技
templates:
# 注册短信验证码模板
- register: SMS_22xxx771
+ register: SMS_506225577
# 登录短信验证码模板
- login: SMS_22xxx772
+ login: SMS_506225577
# 修改手机号短信验证码模板
- change-mobile: SMS_22xxx773
+ change-mobile: SMS_506225577
+
+ tencent:
+ secretId: AKIDJXDqJk2963sUuAE7oIsQtAD4jANNBmCG
+ secretKey: znQq58i8NTQAhR2Qi1KRO9i5HG2jDWcX
+ regionId: ap-guangzhou
+ sdkAppId: "1401023068"
+ signName: 深圳市壹键通讯科技
+ templates:
+ register: "2510826"
+ login: "2496464"
+ change-mobile: "1234569"
# springdoc 配置文档: https://springdoc.org/properties.html
springdoc:
diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml
index 86085e47..667e433d 100644
--- a/src/main/resources/application-prod.yml
+++ b/src/main/resources/application-prod.yml
@@ -5,15 +5,15 @@ spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver # 3.2.0开始支持SPI可省略此配置
- url: jdbc:mysql://www.youlai.tech:3306/youlai_admin?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&allowMultiQueries=true
- username: youlai
- password: 123456
+ url: jdbc:mysql://175.178.213.60:33306/youlai_admin?zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&autoReconnect=true&allowMultiQueries=true
+ username: root
+ password: fanhuitong
data:
redis:
- database: 1
- host: www.youlai.tech
- port: 6379
- password: 123456
+ database: 11
+ host: 175.178.213.60
+ port: 26379
+ password: fanhuitong
timeout: 10s
lettuce:
pool:
@@ -49,6 +49,13 @@ spring:
enable: true
# 邮件发送者
from: youlaitech@163.com
+ mongodb:
+ host: 175.178.213.60
+ port: 27027
+ database: device_apks
+ username: fht
+ password: fanhuitong
+ authentication-database: admin
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
global-config:
@@ -85,6 +92,7 @@ security:
- /api/v1/auth/refresh-token # 刷新令牌接口
- /api/v1/wxma/auth/** # 微信小程序认证接口(静默登录/手机号快捷登录/绑定手机号)
- /api/v1/logs/** # 日志接口(访问日志列表)
+ - /api/v1/sn/** # 移动设备专用接口(通过设备签名验证)
# 非安全端点路径,完全绕过 Spring Security 的过滤器
unsecured-urls:
- ${springdoc.swagger-ui.path}
diff --git a/src/main/resources/mapper/system/DeviceMapper.xml b/src/main/resources/mapper/system/DeviceMapper.xml
new file mode 100644
index 00000000..130c5430
--- /dev/null
+++ b/src/main/resources/mapper/system/DeviceMapper.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+