7 Commits

Author SHA1 Message Date
future e81ec8cca7 删除gym-payment模块,与dev分支保持一致 2026-06-24 23:23:55 +08:00
future 840e24dc3e 合并dev分支 2026-06-24 23:11:01 +08:00
future b5f8523554 完成登录功能 2026-06-24 23:11:01 +08:00
future b291dacf3d 修复丢失文件 2026-06-24 23:11:01 +08:00
future 8cb7bc6ae8 feat: 添加签到模块和数据库表结构
- 新增gym-checkIn签到模块
- 创建sign_in_record签到记录表(V13迁移脚本)
- 添加签到相关路由配置
- 更新应用配置以支持签到模块
- 添加Redis密钥常量
2026-06-24 23:11:00 +08:00
future 91b005975a 签到模块 2026-06-24 23:08:54 +08:00
future 2ffd1aa7d6 修复项目构建 2026-06-24 23:08:53 +08:00
36 changed files with 515 additions and 1556 deletions
-202
View File
@@ -1,202 +0,0 @@
import java.util.Base64;
import java.math.BigInteger;
import java.io.ByteArrayInputStream;
public class KeyAnalyzer {
public static void main(String[] args) {
String privateKeyBase64 = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCcxAwrhZF+izz1gxQKOr4jnC05obIBHbl0DzHOcd4CaaDV7Kv1NRJKi33JhdDsW4JGgu16e5Rtzq1VzU5VWz5EKgGL46maOFwCkngJUTP/LC3JVf/wtJmYCm8xNE8C7GNNIqKzYEvUOEfXqagpVvVrGsQ9FpzFp2rP9hBmHY3yizVyPX/uT9S+af5TRxiaItj3SSJGgloaEMrnKOpb/EH7JwPSS0liAyT/NxPfOyyZHc22AvaAIOE5y+0PMUIKPuIdfpOrej3LVpO1Arc2hSgmdB+YIPSiBVYPXa6AuAmil9mpbtSikQJ7Uu7lX4JyTW4QxQ06rPFKnFWVKkzivAElAgMBAAECggEAJd0AJ37iTlMpDQ90xqe7hvRQxAu256gbQ9nrqLY97g0/KIw6WEZSPakFX6gvdvb/NzKmUyAIEKGLoh6tXdZk6qfOqc/6BeK47nIcBfwT9/zerjNUVvn34w4aHyNINieMMHQ+Id8PUZmqWH+Euz9ilVTosuyEPwUZulLvUQqwXzU5VnwVghURbUhDd+ecBJACWgemRun6d5241PQXNYAdH1k7cETd8GfIi3qclhhJrxi7tu5tq4YGCXQIoz7HCLim7GIvT0M+FRgSw2EOrHnAQNFeQ/vQbP71ttLoTxehL6Se9dfWrV5OI+Y/T7vR2F84Qt0iNbaxyJGir7siKDFIwQKBgQDXDzinx3/TasplM78pR/0CtuuKr1Ch02LOPrTosJ1qf1OohxQThowhOTxMsBlgYSKu9s1QRffUUXEqYXxd2B6lzDKfggwO6U2XxIcxWeNow0xoFfqcXYSg7Ga2sCr9uhdwxIdFQNF7SNBpT8ht4fJrRX6mWY1nHybpyTDQ4xoQNQKBgQC6m+yiOoi5JD3zVSSJq/iq5DJPA5B4aoP+t5u9lp2Q7iVO0QI5ilBlEKGE4VOU0glnXlDTfuqEYooMY85ekl4WGb3AOT0PhLL2i+gO2nlWBzf4HPzB/hibjfyPyniRM03cHkG3HXucL7Sne6FwERcfjEqjUd2cdP1l89PNrq4rMQKBgQCMmjABSWYh8/y1I5rEQ4OAJdVjC3GdC1Xa35ZpVybjvLEWSpHunhW5lvD8dllw8LC7UTI0XDpGPqTM/4VO2YBYB2PFc0Gs8g0/v0ZgFpOeJ6kpl80MM/wFNemFYTIKRoMSv/psZY9PmfBgGcBBTuquBXZjDcNr+yr2yAm5V/DvTQKBgHqRi94KoF8q5N39IKCkqhJlDH5FkxDktYoKw2rFkPzuzuZz9gghRyj6wXxsG9/2DWMt2dzw0czehFoa/CO188KEadPmRKr6uCmkP2nyKhxNZX+8WnB5G2Sg4DD6BjMpBYz8+qDx5ozx8LDJTYI0V4HLPgMD9JGdbgsXGhlREOkhAoGALr6IQOXnviWNAhCdc7rrsaMLMPbLZ1wqzWtQUG1JxDobbpzEP4CW/mvW5pMn58mSBg5qbXhyDI4fFP0CPb98QIz2tGnIzYyFzdKmF5Z1N7X1OF9O+tsSqASoBZzTqB4fr/o4mz0s9JCeriBR2LWjsbsDU13DTLsfQpWbtOnIy70=";
System.out.println("========== 密钥分析开始 ==========");
System.out.println("密钥长度: " + privateKeyBase64.length());
byte[] keyBytes = Base64.getDecoder().decode(privateKeyBase64);
System.out.println("解码后长度: " + keyBytes.length + " bytes");
// 打印前30字节
System.out.print("前30字节(hex): ");
for (int i = 0; i < 30 && i < keyBytes.length; i++) {
System.out.printf("%02X ", keyBytes[i]);
}
System.out.println();
// 解析ASN.1结构
try {
parseASN1(keyBytes);
} catch (Exception e) {
System.out.println("解析失败: " + e.getMessage());
e.printStackTrace();
}
}
static void parseASN1(byte[] keyBytes) throws Exception {
ByteArrayInputStream bis = new ByteArrayInputStream(keyBytes);
// 读取SEQUENCE
int tag = bis.read();
System.out.println("第一个tag: 0x" + Integer.toHexString(tag));
if (tag == 0x30) {
System.out.println("这是SEQUENCE");
// 读取长度
int len = readASN1Length(bis);
System.out.println("SEQUENCE长度: " + len);
// 检查下一个tag
int nextTag = bis.read();
System.out.println("下一个tag: 0x" + Integer.toHexString(nextTag));
if (nextTag == 0x02) {
// INTEGER
int intLen = readASN1Length(bis);
byte[] versionBytes = new byte[intLen];
bis.read(versionBytes);
int version = new BigInteger(versionBytes).intValue();
System.out.println("版本INTEGER值: " + version);
// 检查这是PKCS#8还是PKCS#1
// PKCS#8: version=0后是SEQUENCE(算法标识符)
// PKCS#1: version=0后是INTEGER(modulus)
int afterVersionTag = bis.read();
System.out.println("版本后的tag: 0x" + Integer.toHexString(afterVersionTag));
if (afterVersionTag == 0x30) {
System.out.println(">>> 这是PKCS#8格式 (version后是SEQUENCE)");
// 解析PKCS#8
// AlgorithmIdentifier: SEQUENCE { OID, NULL }
int algLen = readASN1Length(bis);
System.out.println("AlgorithmIdentifier长度: " + algLen);
// 跳过AlgorithmIdentifier内容
byte[] algBytes = new byte[algLen];
bis.read(algBytes);
// 打印OID
System.out.print("OID bytes: ");
for (int i = 0; i < algBytes.length; i++) {
System.out.printf("%02X ", algBytes[i]);
}
System.out.println();
// 下一个应该是OCTET STRING (包含私钥)
int octetTag = bis.read();
System.out.println("下一个tag: 0x" + Integer.toHexString(octetTag));
if (octetTag == 0x04) {
System.out.println("这是OCTET STRING (包含私钥数据)");
int octetLen = readASN1Length(bis);
System.out.println("OCTET STRING长度: " + octetLen);
// OCTET STRING内容是PKCS#1私钥
byte[] pkcs1Bytes = new byte[octetLen];
bis.read(pkcs1Bytes);
System.out.print("PKCS#1私钥前20字节: ");
for (int i = 0; i < 20; i++) {
System.out.printf("%02X ", pkcs1Bytes[i]);
}
System.out.println();
// 解析PKCS#1私钥
ByteArrayInputStream pkcs1Stream = new ByteArrayInputStream(pkcs1Bytes);
int pkcs1Tag = pkcs1Stream.read();
System.out.println("PKCS#1第一个tag: 0x" + Integer.toHexString(pkcs1Tag));
if (pkcs1Tag == 0x30) {
int pkcs1Len = readASN1Length(pkcs1Stream);
System.out.println("PKCS#1 SEQUENCE长度: " + pkcs1Len);
// version
int vTag = pkcs1Stream.read();
int vLen = readASN1Length(pkcs1Stream);
byte[] vBytes = new byte[vLen];
pkcs1Stream.read(vBytes);
System.out.println("PKCS#1版本: " + new BigInteger(vBytes));
// 解析私钥参数
parsePKCS1(pkcs1Stream);
}
}
} else if (afterVersionTag == 0x02) {
System.out.println(">>> 这是PKCS#1格式 (version后是INTEGER)");
// 继续解析PKCS#1
parsePKCS1(bis);
} else {
System.out.println(">>> 未知格式 (tag: 0x" + Integer.toHexString(afterVersionTag) + ")");
}
}
}
}
static void parsePKCS1(ByteArrayInputStream bis) throws Exception {
System.out.println("\n========== 解析PKCS#1私钥参数 ==========");
// modulus
BigInteger modulus = readASN1Integer(bis);
System.out.println("modulus长度: " + modulus.bitLength() + " bits");
// publicExponent
BigInteger publicExponent = readASN1Integer(bis);
System.out.println("publicExponent: " + publicExponent);
// privateExponent
BigInteger privateExponent = readASN1Integer(bis);
System.out.println("privateExponent长度: " + privateExponent.bitLength() + " bits");
// prime1
BigInteger prime1 = readASN1Integer(bis);
System.out.println("prime1长度: " + prime1.bitLength() + " bits");
// prime2
BigInteger prime2 = readASN1Integer(bis);
System.out.println("prime2长度: " + prime2.bitLength() + " bits");
// 验证 prime1 * prime2 == modulus
BigInteger calculatedModulus = prime1.multiply(prime2);
boolean valid = calculatedModulus.equals(modulus);
System.out.println("\n验证 prime1 * prime2 == modulus: " + valid);
if (!valid) {
System.out.println(">>> 密钥数学关系不正确!这是无效的RSA私钥");
System.out.println("计算得到的modulus长度: " + calculatedModulus.bitLength() + " bits");
}
// exponent1
BigInteger exponent1 = readASN1Integer(bis);
System.out.println("exponent1长度: " + exponent1.bitLength() + " bits");
// exponent2
BigInteger exponent2 = readASN1Integer(bis);
System.out.println("exponent2长度: " + exponent2.bitLength() + " bits");
// coefficient
BigInteger coefficient = readASN1Integer(bis);
System.out.println("coefficient长度: " + coefficient.bitLength() + " bits");
System.out.println("\n========== 解析完成 ==========");
}
static int readASN1Length(ByteArrayInputStream bis) throws Exception {
int firstByte = bis.read();
if ((firstByte & 0x80) == 0) {
return firstByte;
}
int numBytes = firstByte & 0x7F;
int length = 0;
for (int i = 0; i < numBytes; i++) {
length = (length << 8) | (bis.read() & 0xFF);
}
return length;
}
static BigInteger readASN1Integer(ByteArrayInputStream bis) throws Exception {
int tag = bis.read();
if (tag != 0x02) throw new Exception("期望INTEGER tag: 0x02, 实际: 0x" + Integer.toHexString(tag));
int len = readASN1Length(bis);
byte[] data = new byte[len];
bis.read(data);
return new BigInteger(1, data);
}
}
+14 -60
View File
@@ -10,11 +10,12 @@
<version>1.0.0</version>
</parent>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-auth</artifactId>
<packaging>jar</packaging>
<name>Gym Auth</name>
<description>Phone Authentication Module - Phone Number Login Services</description>
<description>Authentication module for Gym Management System</description>
<dependencies>
<dependency>
@@ -29,93 +30,46 @@
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-sys</artifactId>
<artifactId>gym-member</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-member</artifactId>
<artifactId>manage-sys</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-commons</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.25</version>
</dependency>
<!-- 阿里云一键登录服务 -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-dypnsapi</artifactId>
<version>1.2.12</version>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.6.0</version>
<version>4.6.3</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
<version>2.0.0</version>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<executions>
<execution>
<id>default-jar</id>
<phase>package</phase>
<goals>
<goal>jar</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>21</source>
<target>21</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
<source>${java.version}</source>
<target>${java.version}</target>
</configuration>
</plugin>
</plugins>
@@ -1,7 +0,0 @@
package cn.novalon.gym.manage.auth.config;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AuthConfig {
}
@@ -4,44 +4,20 @@ import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* 阿里云短信配置属性类
*
* @author auto-generated
* @date 2026-06-20
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "alibaba.cloud.sms")
public class SmsProperties {
/**
* 访问密钥ID
*/
private String accessKeyId;
/**
* 访问密钥密钥
*/
private String accessKeySecret;
/**
* 短信签名名称
*/
private String signName;
/**
* 短信模板CODE
*/
private String templateCode;
/**
* 短信验证码长度(默认6位)
*/
private int codeLength = 6;
private int codeLength = 4;
/**
* 短信验证码有效期(秒,默认300秒=5分钟)
*/
private long codeExpireSeconds = 300;
}
}
@@ -7,9 +7,6 @@ import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 手机号验证码登录请求DTO
*/
@Data
@Builder
@NoArgsConstructor
@@ -1,28 +1,23 @@
package cn.novalon.gym.manage.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 手机号一键登录请求DTO
* uniapp官方一键登录流程:前端通过云函数获取access_token和openid传给后端换取手机号
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PhoneLoginDto {
@NotBlank(message = "access_token不能为空")
private String accessToken;
@NotBlank(message = "openid不能为空")
private String openid;
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
private String nickname;
private String avatar;
}
}
@@ -1,22 +0,0 @@
package cn.novalon.gym.manage.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 发送短信验证码请求DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SendCodeRequest {
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
}
@@ -1,25 +0,0 @@
package cn.novalon.gym.manage.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 短信验证码登录请求DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SmsLoginDto {
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
@NotBlank(message = "验证码不能为空")
private String code;
}
@@ -2,7 +2,6 @@ package cn.novalon.gym.manage.auth.handler;
import cn.novalon.gym.manage.auth.dto.PhoneCodeLoginDto;
import cn.novalon.gym.manage.auth.dto.PhoneLoginDto;
import cn.novalon.gym.manage.auth.dto.SendCodeRequest;
import cn.novalon.gym.manage.auth.service.PhoneAuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -39,8 +38,8 @@ public class PhoneAuthHandler {
public Mono<ServerResponse> sendSmsCode(ServerRequest request) {
log.info("收到发送短信验证码请求");
return request.bodyToMono(SendCodeRequest.class)
.flatMap(req -> phoneAuthService.sendSmsCode(req.getPhone()))
return request.bodyToMono(PhoneLoginDto.class)
.flatMap(dto -> phoneAuthService.sendSmsCode(dto.getPhone()))
.flatMap(success -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("success", success, "message", success ? "验证码发送成功" : "验证码发送失败")));
@@ -1,37 +1,15 @@
package cn.novalon.gym.manage.auth.service;
import cn.novalon.gym.manage.auth.dto.PhoneLoginDto;
import cn.novalon.gym.manage.auth.dto.PhoneCodeLoginDto;
import cn.novalon.gym.manage.auth.dto.PhoneLoginDto;
import cn.novalon.gym.manage.auth.vo.PhoneLoginVO;
import reactor.core.publisher.Mono;
/**
* 手机号认证服务接口
*/
public interface PhoneAuthService {
/**
* 手机号一键登录(uniapp官方运营商认证)
* 已注册则直接登录,未注册则自动注册后登录
*
* @param request 登录请求
* @return 登录响应
*/
Mono<PhoneLoginVO> oneClickLogin(PhoneLoginDto request);
/**
* 发送短信验证码(阿里云)
*
* @param phone 手机号
* @return 是否发送成功
*/
Mono<Boolean> sendSmsCode(String phone);
/**
* 手机号验证码登录(阿里云)
*
* @param request 登录请求
* @return 登录响应
*/
Mono<PhoneLoginVO> codeLogin(PhoneCodeLoginDto request);
}
@@ -1,35 +1,12 @@
package cn.novalon.gym.manage.auth.service;
/**
* 短信服务接口
*
* @author auto-generated
* @date 2026-06-20
*/
import reactor.core.publisher.Mono;
public interface SmsService {
/**
* 发送短信验证码
*
* @param phone 手机号
* @return 发送结果
*/
boolean sendVerificationCode(String phone);
Mono<Boolean> sendVerificationCode(String phone);
/**
* 验证短信验证码
*
* @param phone 手机号
* @param code 验证码
* @return 验证结果
*/
boolean verifyCode(String phone, String code);
Mono<Boolean> verifyCode(String phone, String code);
/**
* 获取验证码(用于测试或特殊场景)
*
* @param phone 手机号
* @return 验证码
*/
String getVerificationCode(String phone);
}
Mono<String> getVerificationCode(String phone);
}
@@ -1,6 +1,5 @@
package cn.novalon.gym.manage.auth.service.impl;
import cn.novalon.gym.manage.auth.config.SmsProperties;
import cn.novalon.gym.manage.auth.dto.PhoneCodeLoginDto;
import cn.novalon.gym.manage.auth.dto.PhoneLoginDto;
import cn.novalon.gym.manage.auth.service.PhoneAuthService;
@@ -16,13 +15,6 @@ import cn.novalon.gym.manage.member.util.AesUtil;
import cn.novalon.gym.manage.member.util.EsSyncUtils;
import cn.novalon.gym.manage.member.util.MemberNoGenerator;
import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.dypnsapi.model.v20170525.GetMobileRequest;
import com.aliyuncs.dypnsapi.model.v20170525.GetMobileResponse;
import com.aliyuncs.exceptions.ClientException;
import com.aliyuncs.exceptions.ServerException;
import com.aliyuncs.profile.DefaultProfile;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -43,7 +35,6 @@ public class PhoneAuthServiceImpl implements PhoneAuthService {
private final EsSyncUtils esSyncUtils;
private final JwtTokenProvider jwtTokenProvider;
private final SmsService smsService;
private final SmsProperties smsProperties;
private EsSyncUtils.EntitySyncer<Member, MemberES, String> memberSyncer;
@@ -54,76 +45,32 @@ public class PhoneAuthServiceImpl implements PhoneAuthService {
@Override
public Mono<PhoneLoginVO> oneClickLogin(PhoneLoginDto request) {
log.info("手机号一键登录请求, accessToken: {}, openid: {}", request.getAccessToken(), request.getOpenid());
log.info("手机号一键登录, phone: {}", request.getPhone());
return Mono.fromCallable(() -> getPhoneByToken(request.getAccessToken(), request.getOpenid()))
.flatMap(phone -> {
log.info("通过access_token获取手机号成功: {}", maskPhone(phone));
String encryptedPhone = encryptPhone(phone);
String encryptedPhone = encryptPhone(request.getPhone());
return memberRepository.findByPhone(encryptedPhone)
.flatMap(existingMember -> {
log.info("手机号已注册,直接登录, memberId: {}", existingMember.getId());
return doLogin(existingMember, false, request);
})
.switchIfEmpty(Mono.defer(() -> {
log.info("手机号未注册,创建新会员");
return createNewMemberAndLogin(request, encryptedPhone);
}));
return memberRepository.findByPhone(encryptedPhone)
.flatMap(existingMember -> {
log.info("手机号已注册,直接登录, memberId: {}", existingMember.getId());
return doLogin(existingMember, false, request);
})
.onErrorResume(e -> {
log.error("一键登录失败", e);
return Mono.error(new SystemException(ErrorCode.AUTH_PHONE_ERROR, "手机号获取失败: " + e.getMessage()));
});
}
/**
* 通过access_token和openid获取手机号
* 使用阿里云DYPNS API
*/
private String getPhoneByToken(String accessToken, String openid) throws Exception {
DefaultProfile profile = DefaultProfile.getProfile("cn-hangzhou", smsProperties.getAccessKeyId(), smsProperties.getAccessKeySecret());
IAcsClient client = new DefaultAcsClient(profile);
GetMobileRequest request = new GetMobileRequest();
request.setAccessToken(accessToken);
try {
GetMobileResponse response = client.getAcsResponse(request);
if ("OK".equals(response.getCode())) {
return response.getGetMobileResultDTO().getMobile();
}
log.warn("DYPNS API返回错误: code={}, message={}", response.getCode(), response.getMessage());
throw new SystemException(ErrorCode.AUTH_PHONE_ERROR, "手机号获取失败: " + response.getMessage());
} catch (ServerException e) {
log.error("阿里云DYPNS API服务端异常", e);
throw new SystemException(ErrorCode.AUTH_PHONE_ERROR, "手机号获取失败");
} catch (ClientException e) {
log.error("阿里云DYPNS API客户端异常", e);
throw new SystemException(ErrorCode.AUTH_PHONE_ERROR, "手机号获取失败");
}
}
/**
* 手机号脱敏显示
*/
private String maskPhone(String phone) {
if (phone == null || phone.length() < 11) {
return phone;
}
return phone.substring(0, 3) + "****" + phone.substring(7);
.switchIfEmpty(Mono.defer(() -> {
log.info("手机号未注册,创建新会员");
return createNewMemberAndLogin(request, encryptedPhone);
}));
}
@Override
public Mono<Boolean> sendSmsCode(String phone) {
log.info("发送短信验证码, phone: {}", phone);
return Mono.fromCallable(() -> smsService.sendVerificationCode(phone));
return smsService.sendVerificationCode(phone);
}
@Override
public Mono<PhoneLoginVO> codeLogin(PhoneCodeLoginDto request) {
log.info("手机号验证码登录, phone: {}", request.getPhone());
return Mono.fromCallable(() -> smsService.verifyCode(request.getPhone(), request.getCode()))
return smsService.verifyCode(request.getPhone(), request.getCode())
.flatMap(verified -> {
if (!verified) {
log.warn("验证码验证失败, phone: {}", request.getPhone());
@@ -140,6 +87,7 @@ public class PhoneAuthServiceImpl implements PhoneAuthService {
.switchIfEmpty(Mono.defer(() -> {
log.info("手机号未注册,创建新会员");
PhoneLoginDto registerRequest = new PhoneLoginDto();
registerRequest.setPhone(request.getPhone());
return createNewMemberAndLogin(registerRequest, encryptedPhone);
}));
});
@@ -205,7 +153,7 @@ public class PhoneAuthServiceImpl implements PhoneAuthService {
vo.setNeedCompleteInfo(needCompleteInfo);
vo.setNickname(member.getNickname());
vo.setAvatar(member.getAvatar());
vo.setPhone(member.getPhone() != null ? decryptPhone(member.getPhone()) : null);
vo.setPhone(decryptPhone(member.getPhone()));
return vo;
}
@@ -226,4 +174,4 @@ public class PhoneAuthServiceImpl implements PhoneAuthService {
return null;
}
}
}
}
@@ -1,148 +1,198 @@
package cn.novalon.gym.manage.auth.service.impl;
import cn.hutool.core.util.RandomUtil;
import cn.novalon.gym.manage.auth.config.SmsProperties;
import cn.novalon.gym.manage.auth.service.SmsService;
import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
import cn.novalon.gym.manage.common.util.RedisUtil;
import com.aliyun.dysmsapi20170525.Client;
import com.aliyun.dysmsapi20170525.models.SendSmsRequest;
import com.aliyun.dysmsapi20170525.models.SendSmsResponse;
import com.aliyun.teaopenapi.models.Config;
import com.aliyuncs.CommonRequest;
import com.aliyuncs.CommonResponse;
import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.exceptions.ClientException;
import com.aliyuncs.http.MethodType;
import com.aliyuncs.profile.DefaultProfile;
import com.aliyuncs.profile.IClientProfile;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
/**
* 短信服务实现类
*
* @author auto-generated
* @date 2026-06-20
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SmsServiceImpl implements SmsService {
private static final long SEND_INTERVAL_SECONDS = 60;
private static final long CODE_EXPIRE_SECONDS = 300;
private final SmsProperties smsProperties;
private final RedisUtil redisUtil;
private final ObjectMapper objectMapper;
@Override
public boolean sendVerificationCode(String phone) {
public Mono<Boolean> sendVerificationCode(String phone) {
log.info("发送短信验证码, phone: {}", phone);
try {
// 生成验证码
String code = generateCode();
String rateLimitKey = RedisKeyConstants.SMS_CODE + phone + ":rate_limit";
// 发送短信
boolean sent = sendSms(phone, code);
return redisUtil.get(rateLimitKey, Long.class)
.defaultIfEmpty(0L)
.flatMap(lastSendTime -> {
long currentTime = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);
if (sent) {
// 将验证码存入Redis,5分钟过期
String cacheKey = RedisKeyConstants.SMS_CODE + phone;
redisUtil.setWithExpire(cacheKey, code, smsProperties.getCodeExpireSeconds())
.doOnSuccess(result -> log.info("验证码已缓存, phone: {}, code: {}, expire: {}s",
phone, code, smsProperties.getCodeExpireSeconds()))
.block();
if (currentTime - lastSendTime < SEND_INTERVAL_SECONDS) {
long remainingSeconds = SEND_INTERVAL_SECONDS - (currentTime - lastSendTime);
log.warn("发送频率限制, phone: {}, 剩余时间: {}秒", phone, remainingSeconds);
return Mono.just(false);
}
log.info("短信验证码发送成功, phone: {}", phone);
return true;
}
return Mono.fromCallable(() -> {
try {
IAcsClient client = createClient();
log.warn("短信验证码发送失败, phone: {}", phone);
return false;
CommonRequest request = new CommonRequest();
request.setSysMethod(MethodType.POST);
request.setSysDomain("dypnsapi.aliyuncs.com");
request.setSysVersion("2017-05-25");
request.setSysAction("SendSmsVerifyCode");
request.putQueryParameter("PhoneNumber", phone);
request.putQueryParameter("SignName", smsProperties.getSignName());
request.putQueryParameter("TemplateCode", smsProperties.getTemplateCode());
request.putQueryParameter("TemplateParam", "{\"code\":\"##code##\",\"min\":\"5\"}");
request.putQueryParameter("ReturnVerifyCode", "true");
} catch (Exception e) {
log.error("发送短信验证码异常, phone: {}", phone, e);
return false;
}
log.info("阿里云号码认证请求参数 - signName: {}, templateCode: {}, templateParam: {}",
smsProperties.getSignName(),
smsProperties.getTemplateCode(),
"{\"code\":\"##code##\",\"min\":\"5\"}");
CommonResponse response = client.getCommonResponse(request);
String responseData = response.getData();
log.info("阿里云号码认证原始响应: {}", responseData);
JsonNode jsonNode = objectMapper.readTree(responseData);
JsonNode codeNode = jsonNode.get("Code");
if (codeNode == null) {
log.error("阿里云响应中找不到Code字段, 原始响应: {}", responseData);
return false;
}
String code = codeNode.asText();
if ("OK".equals(code)) {
JsonNode requestIdNode = jsonNode.get("RequestId");
String requestId = requestIdNode != null ? requestIdNode.asText() : "unknown";
log.info("短信验证码发送成功, phone: {}, requestId: {}", phone, requestId);
// 提取验证码并存入Redis
JsonNode modelNode = jsonNode.get("Model");
if (modelNode != null) {
JsonNode verifyCodeNode = modelNode.get("VerifyCode");
if (verifyCodeNode != null) {
String verifyCode = verifyCodeNode.asText();
String smsCodeKey = RedisKeyConstants.SMS_CODE + phone;
redisUtil.setWithExpire(smsCodeKey, verifyCode, CODE_EXPIRE_SECONDS).subscribe();
log.info("验证码已存入Redis, phone: {}, key: {}, expire: {}秒", phone, smsCodeKey, CODE_EXPIRE_SECONDS);
} else {
log.warn("响应中未找到Model.VerifyCode字段, 原始响应: {}", responseData);
}
} else {
log.warn("响应中未找到Model字段, 原始响应: {}", responseData);
}
redisUtil.setWithExpire(rateLimitKey, currentTime, SEND_INTERVAL_SECONDS).subscribe();
return true;
}
JsonNode messageNode = jsonNode.get("Message");
String message = messageNode != null ? messageNode.asText() : "unknown";
log.error("短信验证码发送失败, phone: {}, code: {}, message: {}", phone, code, message);
return false;
} catch (Exception e) {
log.error("发送短信验证码异常, phone: {}, 异常信息: {}", phone, e.getMessage(), e);
return false;
}
});
});
}
@Override
public boolean verifyCode(String phone, String code) {
public Mono<Boolean> verifyCode(String phone, String code) {
log.info("验证短信验证码, phone: {}", phone);
try {
String cacheKey = RedisKeyConstants.SMS_CODE + phone;
String cachedCode = redisUtil.get(cacheKey, String.class).block();
return Mono.fromCallable(() -> {
try {
IAcsClient client = createClient();
if (cachedCode == null) {
log.warn("验证码已过期或不存在, phone: {}", phone);
CommonRequest request = new CommonRequest();
request.setSysMethod(MethodType.POST);
request.setSysDomain("dypnsapi.aliyuncs.com");
request.setSysVersion("2017-05-25");
request.setSysAction("CheckSmsVerifyCode");
request.putQueryParameter("PhoneNumber", phone);
request.putQueryParameter("VerifyCode", code);
log.info("阿里云号码认证核验参数 - phone: {}, code: {}", phone, code);
CommonResponse response = client.getCommonResponse(request);
String responseData = response.getData();
log.info("阿里云号码认证核验原始响应: {}", responseData);
JsonNode jsonNode = objectMapper.readTree(responseData);
JsonNode codeNode = jsonNode.get("Code");
if (codeNode == null) {
log.error("阿里云核验响应中找不到Code字段, 原始响应: {}", responseData);
return false;
}
String responseCode = codeNode.asText();
JsonNode modelNode = jsonNode.get("Model");
JsonNode verifyResultNode = modelNode != null ? modelNode.get("VerifyResult") : null;
boolean verifyResult = verifyResultNode != null && "PASS".equals(verifyResultNode.asText());
if ("OK".equals(responseCode) && verifyResult) {
log.info("验证码验证成功, phone: {}", phone);
return true;
}
JsonNode messageNode = jsonNode.get("Message");
String message = messageNode != null ? messageNode.asText() : "unknown";
log.warn("验证码验证失败, phone: {}, code: {}, message: {}, result: {}",
phone, responseCode, message, verifyResult);
return false;
} catch (Exception e) {
log.error("验证短信验证码异常, phone: {}, 异常信息: {}", phone, e.getMessage(), e);
return false;
}
if (cachedCode.equals(code)) {
// 验证成功后删除验证码
redisUtil.delete(cacheKey).block();
log.info("验证码验证成功, phone: {}", phone);
return true;
}
log.warn("验证码不匹配, phone: {}, input: {}, cached: {}", phone, code, cachedCode);
return false;
} catch (Exception e) {
log.error("验证短信验证码异常, phone: {}", phone, e);
return false;
}
});
}
@Override
public String getVerificationCode(String phone) {
public Mono<String> getVerificationCode(String phone) {
try {
String cacheKey = RedisKeyConstants.SMS_CODE + phone;
return redisUtil.get(cacheKey, String.class).block();
return redisUtil.get(cacheKey, String.class);
} catch (Exception e) {
log.error("获取验证码异常, phone: {}", phone, e);
return null;
return Mono.empty();
}
}
/**
* 生成验证码
*/
private String generateCode() {
return RandomUtil.randomNumbers(smsProperties.getCodeLength());
private IAcsClient createClient() throws ClientException {
IClientProfile profile = DefaultProfile.getProfile(
"cn-hangzhou",
smsProperties.getAccessKeyId(),
smsProperties.getAccessKeySecret());
DefaultProfile.addEndpoint("cn-hangzhou", "cn-hangzhou", "Dypnsapi", "dypnsapi.aliyuncs.com");
return new DefaultAcsClient(profile);
}
/**
* 发送短信
*/
private boolean sendSms(String phone, String code) throws Exception {
log.info("调用阿里云短信API发送短信, phone: {}, code: {}", phone, code);
// 创建阿里云短信客户端
Config config = new Config()
.setAccessKeyId(smsProperties.getAccessKeyId())
.setAccessKeySecret(smsProperties.getAccessKeySecret())
.setEndpoint("dysmsapi.aliyuncs.com");
Client client = new Client(config);
// 构建发送短信请求
SendSmsRequest request = new SendSmsRequest()
.setPhoneNumbers(phone)
.setSignName(smsProperties.getSignName())
.setTemplateCode(smsProperties.getTemplateCode())
.setTemplateParam("{\"code\":\"" + code + "\"}");
// 发送短信
SendSmsResponse response = client.sendSms(request);
log.info("阿里云短信API响应, phone: {}, code: {}, message: {}",
phone, response.getBody().getCode(), response.getBody().getMessage());
// 判断发送结果
if ("OK".equals(response.getBody().getCode())) {
return true;
}
log.error("阿里云短信发送失败, phone: {}, code: {}, message: {}",
phone, response.getBody().getCode(), response.getBody().getMessage());
return false;
}
}
}
@@ -5,9 +5,6 @@ import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 手机号一键登录响应VO
*/
@Data
@Builder
@NoArgsConstructor
@@ -1,5 +1 @@
cn.novalon.gym.manage.auth.handler.PhoneAuthHandler
cn.novalon.gym.manage.auth.service.impl.PhoneAuthServiceImpl
cn.novalon.gym.manage.auth.service.impl.SmsServiceImpl
cn.novalon.gym.manage.auth.config.AuthConfig
cn.novalon.gym.manage.auth.config.SmsProperties
+11 -6
View File
@@ -35,6 +35,11 @@
<artifactId>manage-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-sys</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-db</artifactId>
@@ -81,8 +86,8 @@
<artifactId>gym-member</artifactId>
<version>${project.version}</version>
</dependency>
<!-- ZXing QR Code依赖 -->
<!-- ZXing二维码生成库 -->
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
@@ -93,20 +98,20 @@
<artifactId>javase</artifactId>
<version>3.5.3</version>
</dependency>
<!-- 阿里云OSS SDK -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
<version>3.17.1</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
@@ -71,6 +71,22 @@ public class Member extends BaseEntity {
@Column("official_open_id")
private String officialOpenId;
// 阿里云号码认证OpenID
@Column("dypns_open_id")
private String dypnsOpenId;
// 身份证号码(AES加密存储)
@Column("id_card")
private String idCard;
// 真实姓名(AES加密存储)
@Column("real_name")
private String realName;
// 注册渠道:SMS-短信验证码,ONE_CLICK-一键登录,WECHAT-微信授权
@Column("register_channel")
private String registerChannel;
// 软删除
@Column("is_deleted")
private Boolean isDeleted;
-62
View File
@@ -1,62 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-manage-api</artifactId>
<version>1.0.0</version>
</parent>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-payment</artifactId>
<packaging>jar</packaging>
<name>Gym Payment</name>
<description>支付模块 - 支付宝App支付</description>
<dependencies>
<!-- Spring Boot WebFlux -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- 通用模块 (含Redis工具类) -->
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-common</artifactId>
<version>${project.version}</version>
</dependency>
<!-- 支付宝SDK -->
<dependency>
<groupId>com.alipay.sdk</groupId>
<artifactId>alipay-sdk-java</artifactId>
<version>4.35.120.ALL</version>
</dependency>
<!-- JSON处理 -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Swagger -->
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-annotations</artifactId>
<version>2.2.19</version>
</dependency>
</dependencies>
</project>
@@ -1,32 +0,0 @@
package cn.novalon.gym.manage.payment.config;
import com.alipay.api.AlipayClient;
import com.alipay.api.DefaultAlipayClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 支付宝客户端配置
*/
@Configuration
public class AlipayClientConfig {
private final AlipayProperties alipayProperties;
public AlipayClientConfig(AlipayProperties alipayProperties) {
this.alipayProperties = alipayProperties;
}
@Bean
public AlipayClient alipayClient() {
return new DefaultAlipayClient(
alipayProperties.getGateway(),
alipayProperties.getAppId(),
alipayProperties.getAppPrivateKey(),
"json",
alipayProperties.getCharset(),
alipayProperties.getAlipayPublicKey(),
alipayProperties.getSignType()
);
}
}
@@ -1,57 +0,0 @@
package cn.novalon.gym.manage.payment.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 支付宝配置属性
* 沙箱环境配置
*/
@Data
@Component
@ConfigurationProperties(prefix = "alipay")
public class AlipayProperties {
/**
* 应用ID
*/
private String appId;
/**
* 应用私钥 (PKCS#8格式)
*/
private String appPrivateKey;
/**
* 支付宝公钥
*/
private String alipayPublicKey;
/**
* 签名方式
*/
private String signType = "RSA2";
/**
* 编码格式
*/
private String charset = "UTF-8";
/**
* 网关地址
* 沙箱环境: https://openapi-sandbox.dl.alipaydev.com/gateway.do
* 正式环境: https://openapi.alipay.com/gateway.do
*/
private String gateway;
/**
* 异步通知地址
*/
private String notifyUrl;
/**
* 是否沙箱环境
*/
private boolean sandbox = true;
}
@@ -1,52 +0,0 @@
package cn.novalon.gym.manage.payment.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 统一API响应
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
private int code;
private String message;
private T data;
public static <T> ApiResponse<T> success(T data) {
return ApiResponse.<T>builder()
.code(200)
.message("success")
.data(data)
.build();
}
public static <T> ApiResponse<T> success(String message, T data) {
return ApiResponse.<T>builder()
.code(200)
.message(message)
.data(data)
.build();
}
public static <T> ApiResponse<T> error(int code, String message) {
return ApiResponse.<T>builder()
.code(code)
.message(message)
.data(null)
.build();
}
public static <T> ApiResponse<T> error(String message) {
return ApiResponse.<T>builder()
.code(500)
.message(message)
.data(null)
.build();
}
}
@@ -1,54 +0,0 @@
package cn.novalon.gym.manage.payment.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 创建支付请求DTO
*/
@Data
@Schema(description = "创建支付请求")
public class CreatePaymentRequest {
@Schema(description = "会员ID")
private Long memberId;
@Schema(description = "订单类型: MEMBER_CARD-会员卡, GROUP_COURSE-团课, GOODS-商品")
private String orderType;
@Schema(description = "商品描述")
private String goodsDesc;
@Schema(description = "交易金额(单位:分)")
private String transAmt;
@Schema(description = "交易类型: ALIPAY-支付宝, WECHAT-微信")
private String tradeType;
@Schema(description = "备注")
private String remark;
@Schema(description = "账户号")
private String acctId;
@Schema(description = "过期时间(yyyyMMddHHmmss)")
private String timeExpire;
@Schema(description = "延迟入账标识")
private String delayAcctFlag;
@Schema(description = "手续费标识")
private Integer feeFlag;
@Schema(description = "禁用支付方式")
private String limitPayType;
@Schema(description = "渠道号")
private String channelNo;
@Schema(description = "支付场景")
private String payScene;
@Schema(description = "异步通知URL")
private String notifyUrl;
}
@@ -1,54 +0,0 @@
package cn.novalon.gym.manage.payment.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 支付响应DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "支付响应")
public class PaymentResponse {
@Schema(description = "订单ID")
private String orderId;
@Schema(description = "支付状态: PENDING-待支付, SUCCESS-成功, FAIL-失败, CLOSED-关闭")
private String status;
@Schema(description = "支付链接(App支付返回 scheme)")
private String payUrl;
@Schema(description = "二维码链接 (扫码支付)")
private String qrCode;
@Schema(description = "完整支付信息")
private String payInfo;
@Schema(description = "H5支付链接")
private String h5PayUrl;
@Schema(description = "错误码")
private String errorCode;
@Schema(description = "错误消息")
private String errorMsg;
@Schema(description = "交易金额(分)")
private String transAmt;
@Schema(description = "商品描述")
private String goodsDesc;
@Schema(description = "交易类型")
private String tradeType;
@Schema(description = "支付成功时间")
private String payTime;
}
@@ -1,140 +0,0 @@
package cn.novalon.gym.manage.payment.handler;
import cn.novalon.gym.manage.payment.dto.ApiResponse;
import cn.novalon.gym.manage.payment.dto.CreatePaymentRequest;
import cn.novalon.gym.manage.payment.dto.PaymentResponse;
import cn.novalon.gym.manage.payment.service.PaymentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import reactor.core.publisher.MonoSink;
import java.util.HashMap;
import java.util.Map;
/**
* 支付处理器
* 提供支付宝App支付相关接口
*/
@Slf4j
@Component
@Tag(name = "支付管理", description = "支付宝App支付接口")
public class PaymentHandler {
private final PaymentService paymentService;
public PaymentHandler(PaymentService paymentService) {
this.paymentService = paymentService;
}
@Operation(summary = "创建支付订单", description = "创建支付宝App支付订单")
public Mono<ServerResponse> createPayment(ServerRequest request) {
return request.bodyToMono(CreatePaymentRequest.class)
.flatMap(createReq -> {
log.info("[Payment] 创建支付订单: memberId={}, orderType={}, goodsDesc={}, transAmt={}",
createReq.getMemberId(), createReq.getOrderType(), createReq.getGoodsDesc(), createReq.getTransAmt());
PaymentResponse result = paymentService.alipayAppPay(createReq);
if ("FAIL".equals(result.getStatus())) {
return ServerResponse.ok()
.bodyValue(ApiResponse.error(result.getErrorMsg()));
}
return ServerResponse.ok()
.bodyValue(ApiResponse.success(result));
})
.onErrorResume(e -> {
log.error("[Payment] 创建支付订单异常", e);
return ServerResponse.ok()
.bodyValue(ApiResponse.error("创建支付订单失败: " + e.getMessage()));
});
}
@Operation(summary = "创建扫码支付订单", description = "创建支付宝扫码支付订单,返回二维码链接")
public Mono<ServerResponse> createQrCodePayment(ServerRequest request) {
return request.bodyToMono(CreatePaymentRequest.class)
.flatMap(createReq -> {
log.info("[Payment] 创建扫码支付订单: memberId={}, orderType={}, goodsDesc={}, transAmt={}",
createReq.getMemberId(), createReq.getOrderType(), createReq.getGoodsDesc(), createReq.getTransAmt());
PaymentResponse result = paymentService.alipayQrCodePay(createReq);
if ("FAIL".equals(result.getStatus())) {
return ServerResponse.ok()
.bodyValue(ApiResponse.error(result.getErrorMsg()));
}
return ServerResponse.ok()
.bodyValue(ApiResponse.success(result));
})
.onErrorResume(e -> {
log.error("[Payment] 创建扫码支付订单异常", e);
return ServerResponse.ok()
.bodyValue(ApiResponse.error("创建扫码支付订单失败: " + e.getMessage()));
});
}
@Operation(summary = "查询支付状态", description = "查询支付订单状态")
public Mono<ServerResponse> getPaymentStatus(ServerRequest request) {
String orderId = request.pathVariable("orderId");
log.info("[Payment] 查询支付状态: orderId={}", orderId);
PaymentResponse result = paymentService.getPaymentStatus(orderId);
return ServerResponse.ok()
.bodyValue(ApiResponse.success(result));
}
@Operation(summary = "支付宝异步通知", description = "接收支付宝异步回调通知")
public Mono<ServerResponse> alipayNotify(ServerRequest request) {
return request.bodyToMono(Map.class)
.flatMap(params -> {
log.info("[Payment] 收到支付宝异步通知: params={}", params);
// 转换Map类型
Map<String, String> notifyParams = new HashMap<>();
if (params instanceof Map) {
((Map<?, ?>) params).forEach((key, value) ->
notifyParams.put(String.valueOf(key), String.valueOf(value)));
}
String result = paymentService.handleAlipayNotify(notifyParams);
return ServerResponse.ok()
.bodyValue(result);
})
.onErrorResume(e -> {
log.error("[Payment] 异步通知处理异常", e);
return ServerResponse.ok()
.bodyValue("fail");
});
}
@Operation(summary = "申请退款", description = "申请支付退款")
public Mono<ServerResponse> refundPayment(ServerRequest request) {
String orderId = request.pathVariable("orderId");
return request.bodyToMono(Map.class)
.flatMap(body -> {
String refundAmt = String.valueOf(body.get("refundAmt"));
log.info("[Payment] 申请退款: orderId={}, refundAmt={}", orderId, refundAmt);
boolean success = paymentService.refund(orderId, refundAmt);
if (success) {
return ServerResponse.ok()
.bodyValue(ApiResponse.success("退款申请成功"));
} else {
return ServerResponse.ok()
.bodyValue(ApiResponse.error("退款申请失败"));
}
})
.onErrorResume(e -> {
log.error("[Payment] 退款异常: orderId={}", orderId, e);
return ServerResponse.ok()
.bodyValue(ApiResponse.error("退款失败: " + e.getMessage()));
});
}
}
@@ -1,59 +0,0 @@
package cn.novalon.gym.manage.payment.service;
import cn.novalon.gym.manage.payment.dto.CreatePaymentRequest;
import cn.novalon.gym.manage.payment.dto.PaymentResponse;
/**
* 支付服务接口
*/
public interface PaymentService {
/**
* 创建支付订单
*
* @param request 创建支付请求
* @return 支付响应
*/
PaymentResponse createPayment(CreatePaymentRequest request);
/**
* 查询支付状态
*
* @param orderId 订单ID
* @return 支付响应
*/
PaymentResponse getPaymentStatus(String orderId);
/**
* 支付宝App支付
*
* @param request 创建支付请求
* @return 支付响应
*/
PaymentResponse alipayAppPay(CreatePaymentRequest request);
/**
* 支付宝扫码支付(二维码支付)
*
* @param request 创建支付请求
* @return 支付响应(包含二维码链接)
*/
PaymentResponse alipayQrCodePay(CreatePaymentRequest request);
/**
* 处理支付宝异步通知
*
* @param params 通知参数
* @return 处理结果
*/
String handleAlipayNotify(java.util.Map<String, String> params);
/**
* 申请退款
*
* @param orderId 订单ID
* @param refundAmt 退款金额(分)
* @return 退款结果
*/
boolean refund(String orderId, String refundAmt);
}
@@ -1,445 +0,0 @@
package cn.novalon.gym.manage.payment.service.impl;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.payment.config.AlipayProperties;
import cn.novalon.gym.manage.payment.dto.CreatePaymentRequest;
import cn.novalon.gym.manage.payment.dto.PaymentResponse;
import cn.novalon.gym.manage.payment.service.PaymentService;
import com.alipay.api.AlipayApiException;
import com.alipay.api.AlipayClient;
import com.alipay.api.internal.util.AlipaySignature;
import com.alipay.api.request.AlipayTradeAppPayRequest;
import com.alipay.api.request.AlipayTradePrecreateRequest;
import com.alipay.api.request.AlipayTradeQueryRequest;
import com.alipay.api.request.AlipayTradeRefundRequest;
import com.alipay.api.response.AlipayTradeAppPayResponse;
import com.alipay.api.response.AlipayTradePrecreateResponse;
import com.alipay.api.response.AlipayTradeQueryResponse;
import com.alipay.api.response.AlipayTradeRefundResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* 支付服务实现
* App支付 (alipay.trade.app.pay)
*/
@Slf4j
@Service
public class PaymentServiceImpl implements PaymentService {
private static final String PROCESSED_TRADE_PREFIX = "alipay:processed:trade:";
private static final String REFUND_ORDER_PREFIX = "alipay:refund:order:";
private static final String ORDER_CACHE_PREFIX = "alipay:order:";
private static final long IDEMPOTENT_EXPIRE_SECONDS = 86400; // 24小时过期
private final AlipayClient alipayClient;
private final AlipayProperties alipayProperties;
private final RedisUtil redisUtil;
/**
* 订单本地缓存 (作为Redis的二级缓存)
*/
private final Map<String, PaymentResponse> localOrderCache = new ConcurrentHashMap<>();
public PaymentServiceImpl(AlipayClient alipayClient, AlipayProperties alipayProperties, RedisUtil redisUtil) {
this.alipayClient = alipayClient;
this.alipayProperties = alipayProperties;
this.redisUtil = redisUtil;
}
@Override
public PaymentResponse createPayment(CreatePaymentRequest request) {
// App支付使用 alipay.trade.app.pay
return alipayAppPay(request);
}
@Override
public PaymentResponse getPaymentStatus(String orderId) {
// 先查本地缓存
PaymentResponse cached = localOrderCache.get(orderId);
if (cached != null && "SUCCESS".equals(cached.getStatus())) {
return cached;
}
// 查Redis缓存
PaymentResponse redisCached = getOrderFromRedis(orderId);
if (redisCached != null && "SUCCESS".equals(redisCached.getStatus())) {
localOrderCache.put(orderId, redisCached);
return redisCached;
}
try {
AlipayTradeQueryRequest queryRequest = new AlipayTradeQueryRequest();
queryRequest.setBizContent("{\"out_trade_no\":\"" + orderId + "\"}");
AlipayTradeQueryResponse response = alipayClient.execute(queryRequest);
if (response.isSuccess()) {
Date sendPayDate = response.getSendPayDate();
String payTimeStr = sendPayDate != null ? new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(sendPayDate) : null;
PaymentResponse result = PaymentResponse.builder()
.orderId(orderId)
.status(response.getTradeStatus())
.payTime(payTimeStr)
.transAmt(response.getTotalAmount())
.build();
if ("TRADE_SUCCESS".equals(response.getTradeStatus())) {
result.setStatus("SUCCESS");
// 更新缓存
saveOrderToRedis(orderId, result);
localOrderCache.put(orderId, result);
}
return result;
}
} catch (AlipayApiException e) {
log.error("查询支付状态失败: orderId={}", orderId, e);
}
return PaymentResponse.builder()
.orderId(orderId)
.status(cached != null ? cached.getStatus() : (redisCached != null ? redisCached.getStatus() : "PENDING"))
.build();
}
@Override
public PaymentResponse alipayAppPay(CreatePaymentRequest request) {
// 生成订单号
String orderId = generateOrderId();
try {
AlipayTradeAppPayRequest payRequest = new AlipayTradeAppPayRequest();
payRequest.setNotifyUrl(alipayProperties.getNotifyUrl());
// 构建业务参数 (测试金额:固定1分钱)
String bizContent = "{\"out_trade_no\":\"" + orderId + "\","
+ "\"total_amount\":\"0.01\","
+ "\"subject\":\"" + request.getGoodsDesc() + "\","
+ "\"product_code\":\"QUICK_MSECURITY_PAY\","
+ "\"timeout_express\":\"30m\"}";
log.info("[Alipay] App支付请求: orderId={}, bizContent={}", orderId, bizContent);
payRequest.setBizContent(bizContent);
AlipayTradeAppPayResponse response = alipayClient.sdkExecute(payRequest);
if (response.isSuccess()) {
String payUrl = response.getBody();
// App唤起支付宝的scheme格式: alipay://...
// 实际使用时,前端使用 plus.runtime.openURL(payUrl) 唤起支付宝
log.info("[Alipay] App支付创建成功: orderId={}, payUrl={}", orderId, payUrl);
PaymentResponse result = PaymentResponse.builder()
.orderId(orderId)
.status("PENDING")
.payUrl(payUrl)
.payInfo(response.getBody())
.goodsDesc(request.getGoodsDesc())
.transAmt(request.getTransAmt())
.tradeType(request.getTradeType())
.build();
// 缓存订单到Redis和本地
saveOrderToRedis(orderId, result);
localOrderCache.put(orderId, result);
return result;
} else {
log.error("[Alipay] App支付创建失败: orderId={}, error={}", orderId, response.getMsg());
return PaymentResponse.builder()
.orderId(orderId)
.status("FAIL")
.errorCode(response.getCode())
.errorMsg(response.getMsg())
.build();
}
} catch (AlipayApiException e) {
log.error("[Alipay] App支付异常: orderId={}", orderId, e);
String errCode = e.getErrCode() != null ? e.getErrCode() : "NETWORK_ERROR";
String errMsg = e.getErrMsg() != null ? e.getErrMsg() : extractErrorMessage(e);
return PaymentResponse.builder()
.orderId(orderId)
.status("FAIL")
.errorCode(errCode)
.errorMsg(errMsg)
.build();
}
}
/**
* 支付宝扫码支付 (alipay.trade.precreate)
* 生成付款二维码,用户使用支付宝扫码完成支付
*/
@Override
public PaymentResponse alipayQrCodePay(CreatePaymentRequest request) {
String orderId = generateOrderId();
try {
AlipayTradePrecreateRequest precreateRequest = new AlipayTradePrecreateRequest();
precreateRequest.setNotifyUrl(alipayProperties.getNotifyUrl());
// 构建业务参数
String bizContent = "{\"out_trade_no\":\"" + orderId + "\","
+ "\"total_amount\":\"0.01\","
+ "\"subject\":\"" + request.getGoodsDesc() + "\","
+ "\"timeout_express\":\"30m\"}";
log.info("[Alipay] 扫码支付请求: orderId={}, bizContent={}", orderId, bizContent);
precreateRequest.setBizContent(bizContent);
AlipayTradePrecreateResponse response = alipayClient.execute(precreateRequest);
if (response.isSuccess()) {
String qrCode = response.getQrCode(); // 二维码链接
log.info("[Alipay] 扫码支付创建成功: orderId={}, qrCode={}", orderId, qrCode);
PaymentResponse result = PaymentResponse.builder()
.orderId(orderId)
.status("PENDING")
.qrCode(qrCode)
.payInfo(response.getBody())
.goodsDesc(request.getGoodsDesc())
.transAmt(request.getTransAmt())
.tradeType("QRCODE")
.build();
// 缓存订单到Redis和本地
saveOrderToRedis(orderId, result);
localOrderCache.put(orderId, result);
return result;
} else {
log.error("[Alipay] 扫码支付创建失败: orderId={}, error={}", orderId, response.getMsg());
return PaymentResponse.builder()
.orderId(orderId)
.status("FAIL")
.errorCode(response.getCode())
.errorMsg(response.getMsg())
.build();
}
} catch (AlipayApiException e) {
log.error("[Alipay] 扫码支付异常: orderId={}", orderId, e);
String errCode = e.getErrCode() != null ? e.getErrCode() : "NETWORK_ERROR";
String errMsg = e.getErrMsg() != null ? e.getErrMsg() : extractErrorMessage(e);
return PaymentResponse.builder()
.orderId(orderId)
.status("FAIL")
.errorCode(errCode)
.errorMsg(errMsg)
.build();
}
}
@Override
public String handleAlipayNotify(Map<String, String> params) {
String outTradeNo = params.get("out_trade_no");
try {
// 1. 幂等处理:检查是否已处理过该通知(使用Redis)
if (isTradeProcessed(outTradeNo)) {
log.info("[Alipay] 幂等处理:通知已处理,跳过, outTradeNo={}", outTradeNo);
return "success";
}
// 2. 验签
boolean signVerified = AlipaySignature.rsaCheckV1(
params,
alipayProperties.getAlipayPublicKey(),
alipayProperties.getCharset(),
alipayProperties.getSignType()
);
if (!signVerified) {
log.warn("[Alipay] 异步通知验签失败: outTradeNo={}", outTradeNo);
return "fail";
}
// 3. 关键信息校验
// 校验 app_id 是否匹配
String notifyAppId = params.get("app_id");
if (!alipayProperties.getAppId().equals(notifyAppId)) {
log.warn("[Alipay] app_id 不匹配: expected={}, actual={}", alipayProperties.getAppId(), notifyAppId);
return "fail";
}
// 校验卖家ID (seller_id)
String sellerId = params.get("seller_id");
if (sellerId != null && !sellerId.isEmpty()) {
// 商家ID校验逻辑根据实际情况添加
log.debug("[Alipay] 卖家ID校验: seller_id={}", sellerId);
}
// 校验交易金额
String notifyTotalAmount = params.get("total_amount");
PaymentResponse cachedOrder = getOrderFromRedis(outTradeNo);
if (cachedOrder != null && notifyTotalAmount != null) {
// 金额校验:确保通知金额与订单金额一致(防止金额篡改)
log.debug("[Alipay] 交易金额校验: orderId={}, amount={}", outTradeNo, notifyTotalAmount);
}
String tradeStatus = params.get("trade_status");
log.info("[Alipay] 异步通知验签成功: outTradeNo={}, tradeStatus={}", outTradeNo, tradeStatus);
// 4. 判断交易状态
if ("TRADE_SUCCESS".equals(tradeStatus) || "TRADE_FINISHED".equals(tradeStatus)) {
// 更新订单状态
if (cachedOrder != null) {
cachedOrder.setStatus("SUCCESS");
cachedOrder.setPayTime(params.get("gmt_payment"));
saveOrderToRedis(outTradeNo, cachedOrder);
localOrderCache.put(outTradeNo, cachedOrder);
}
// 5. 标记为已处理(幂等,使用Redis)
markTradeProcessed(outTradeNo);
return "success";
}
return "fail";
} catch (AlipayApiException e) {
log.error("[Alipay] 异步通知处理异常: outTradeNo={}", outTradeNo, e);
return "fail";
}
}
@Override
public boolean refund(String orderId, String refundAmt) {
// 1. 退款幂等检查:防止重复退款(使用Redis)
if (isOrderRefunded(orderId)) {
log.info("[Alipay] 退款幂等:订单已退款过,跳过, orderId={}", orderId);
return true;
}
try {
AlipayTradeRefundRequest refundRequest = new AlipayTradeRefundRequest();
refundRequest.setBizContent("{\"out_trade_no\":\"" + orderId + "\",\"refund_amount\":\"" + refundAmt + "\"}");
AlipayTradeRefundResponse response = alipayClient.execute(refundRequest);
if (response.isSuccess()) {
// 2. 校验退款是否真正成功:必须判断 fund_change=Y
String fundChange = response.getFundChange();
if ("Y".equals(fundChange)) {
log.info("[Alipay] 退款成功: orderId={}, refundAmt={}, fundChange={}", orderId, refundAmt, fundChange);
// 标记为已退款(幂等,使用Redis)
markOrderRefunded(orderId);
return true;
} else {
log.warn("[Alipay] 退款返回Y但fund_change!=Y: orderId={}, fundChange={}", orderId, fundChange);
return false;
}
} else {
log.error("[Alipay] 退款失败: orderId={}, error={}", orderId, response.getMsg());
}
} catch (AlipayApiException e) {
log.error("[Alipay] 退款异常: orderId={}", orderId, e);
}
return false;
}
// ==================== Redis操作方法 ====================
/**
* 检查交易是否已处理(幂等)
*/
private boolean isTradeProcessed(String outTradeNo) {
try {
return Boolean.TRUE.equals(redisUtil.hasKey(PROCESSED_TRADE_PREFIX + outTradeNo).block());
} catch (Exception e) {
log.error("[Alipay] 检查交易处理状态异常: outTradeNo={}", outTradeNo, e);
return false;
}
}
/**
* 标记交易已处理(幂等)
*/
private void markTradeProcessed(String outTradeNo) {
try {
redisUtil.setWithExpire(PROCESSED_TRADE_PREFIX + outTradeNo, "1", IDEMPOTENT_EXPIRE_SECONDS).block();
} catch (Exception e) {
log.error("[Alipay] 标记交易处理状态异常: outTradeNo={}", outTradeNo, e);
}
}
/**
* 检查订单是否已退款(幂等)
*/
private boolean isOrderRefunded(String orderId) {
try {
return Boolean.TRUE.equals(redisUtil.hasKey(REFUND_ORDER_PREFIX + orderId).block());
} catch (Exception e) {
log.error("[Alipay] 检查订单退款状态异常: orderId={}", orderId, e);
return false;
}
}
/**
* 标记订单已退款(幂等)
*/
private void markOrderRefunded(String orderId) {
try {
redisUtil.setWithExpire(REFUND_ORDER_PREFIX + orderId, "1", IDEMPOTENT_EXPIRE_SECONDS).block();
} catch (Exception e) {
log.error("[Alipay] 标记订单退款状态异常: orderId={}", orderId, e);
}
}
/**
* 从Redis获取订单
*/
private PaymentResponse getOrderFromRedis(String orderId) {
try {
return redisUtil.get(ORDER_CACHE_PREFIX + orderId, PaymentResponse.class).block();
} catch (Exception e) {
log.error("[Alipay] 获取Redis订单异常: orderId={}", orderId, e);
return null;
}
}
/**
* 保存订单到Redis
*/
private void saveOrderToRedis(String orderId, PaymentResponse response) {
try {
redisUtil.setWithExpire(ORDER_CACHE_PREFIX + orderId, response, IDEMPOTENT_EXPIRE_SECONDS).block();
} catch (Exception e) {
log.error("[Alipay] 保存订单到Redis异常: orderId={}", orderId, e);
}
}
/**
* 生成订单号
*/
private String generateOrderId() {
String timestamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
String uuid = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
return timestamp + uuid;
}
/**
* 提取异常中的错误信息(兜底)
*/
private String extractErrorMessage(AlipayApiException e) {
Throwable cause = e.getCause();
if (cause != null && cause.getMessage() != null && cause.getMessage().contains("504")) {
return "支付宝沙箱网关连接超时(504),请检查服务器网络能否访问 openapi-sandbox.dl.alipaydev.com";
}
if (cause != null) {
return "网络异常: " + cause.getClass().getSimpleName() + " - " + (cause.getMessage() != null ? cause.getMessage().substring(0, Math.min(cause.getMessage().length(), 200)) : "");
}
if (e.getMessage() != null) {
return "支付宝接口异常: " + (e.getMessage().length() > 200 ? e.getMessage().substring(0, 200) : e.getMessage());
}
return "支付宝接口调用失败,请稍后重试";
}
}
+1 -6
View File
@@ -48,11 +48,6 @@
<artifactId>gym-checkIn</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-payment</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-dataCount</artifactId>
@@ -63,7 +58,7 @@
<artifactId>gym-auth</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
@@ -1,10 +1,11 @@
package cn.novalon.gym.manage.app.config;
import cn.novalon.gym.manage.auth.handler.PhoneAuthHandler;
import cn.novalon.gym.manage.checkIn.handler.CheckInHandler;
import cn.novalon.gym.manage.datacount.handler.DataStatisticsHandler;
import cn.novalon.gym.manage.file.handler.SysFileHandler;
import cn.novalon.gym.manage.auth.handler.PhoneAuthHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseBookingHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseRecommendHandler;
@@ -36,3 +36,11 @@ logging:
cn.novalon.manage: DEBUG
org.springframework.r2dbc: DEBUG
org.springframework.web: TRACE
alibaba:
cloud:
sms:
access-key-id: LTAI5t8GhorWLu5WkEx8MDZz
access-key-secret: jNDwb9IHvTIESUezLYHZRT5c5NEaCz
sign-name: 云渚科技验证平台
template-code: 100001
@@ -16,8 +16,8 @@ wechat:
# 手机号加密配置
phone-encryption:
secret-key: ${PHONE_ENCRYPTION_SECRET_KEY}
iv: ${PHONE_ENCRYPTION_IV}
secret-key: ${PHONE_ENCRYPTION_SECRET_KEY:P8539ANjWJWsRbVHZKhM8Q==}
iv: ${PHONE_ENCRYPTION_IV:3tHp07uMRYh1xKsIXvYJMA==}
spring:
elasticsearch:
@@ -0,0 +1,19 @@
package cn.novalon.gym.manage.common.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "auth")
public class AuthConfig {
private String accessKeyId;
private String accessKeySecret;
private Integer tokenExpireSeconds = 86400;
private Integer refreshTokenExpireSeconds = 604800;
}
@@ -0,0 +1,54 @@
package cn.novalon.gym.manage.common.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {
private Integer code;
private String message;
private T data;
private Long timestamp;
public static <T> Result<T> success(T data) {
return Result.<T>builder()
.code(200)
.message("success")
.data(data)
.timestamp(System.currentTimeMillis())
.build();
}
public static <T> Result<T> success(String message) {
return Result.<T>builder()
.code(200)
.message(message)
.timestamp(System.currentTimeMillis())
.build();
}
public static <T> Result<T> fail(Integer code, String message) {
return Result.<T>builder()
.code(code)
.message(message)
.timestamp(System.currentTimeMillis())
.build();
}
public static <T> Result<T> fail(String message) {
return Result.<T>builder()
.code(500)
.message(message)
.timestamp(System.currentTimeMillis())
.build();
}
}
@@ -0,0 +1,86 @@
package cn.novalon.gym.manage.common.util;
import cn.novalon.gym.manage.common.config.JwtProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtUtil {
private final JwtProperties jwtProperties;
private SecretKey getSigningKey() {
String secret = jwtProperties.getSecret();
if (secret.length() < 32) {
StringBuilder sb = new StringBuilder(secret);
while (sb.length() < 32) {
sb.append(secret);
}
secret = sb.substring(0, 32);
}
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generateToken(String userId, String phone) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtProperties.getExpiration());
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("phone", phone);
return Jwts.builder()
.setClaims(claims)
.setSubject(userId)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKey())
.compact();
}
public String generateRefreshToken(String userId) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtProperties.getExpiration() * 7);
return Jwts.builder()
.setSubject(userId)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKey())
.compact();
}
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
public String getUserIdFromToken(String token) {
return parseToken(token).getSubject();
}
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (Exception e) {
log.warn("JWT token validation failed: {}", e.getMessage());
return false;
}
}
}
@@ -0,0 +1,99 @@
-- ============================================
-- 签到记录表(sign_in_record
-- ============================================
-- Step 1: 创建 sign_in_record 表
CREATE TABLE IF NOT EXISTS sign_in_record (
-- ========== 主键和基础字段 ==========
id BIGSERIAL PRIMARY KEY, -- 自增主键
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 记录创建时间
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 记录更新时间
-- ========== 会员相关字段 ==========
member_id BIGINT NOT NULL, -- 会员ID,关联member_user表
member_card_id BIGINT, -- 签到时使用的会员卡ID
-- ========== 签到核心字段 ==========
sign_in_time TIMESTAMP NOT NULL, -- 签到入场时间
sign_in_type VARCHAR(20) NOT NULL, -- 签到方式:QR_CODE-扫码签到,MANUAL-手动签到,FACE-人脸识别
sign_in_status VARCHAR(20) NOT NULL DEFAULT 'SUCCESS', -- 签到状态:SUCCESS-成功,FAILED-失败
-- ========== 验证和错误信息 ==========
verification_details TEXT, -- JSONB格式,存储会员卡验证时的快照数据
fail_reason VARCHAR(500), -- 失败时的具体原因文案
-- ========== 操作人信息 ==========
operator_id BIGINT, -- 操作人ID(前台人员),自助签到时为NULL
operator_name VARCHAR(100), -- 操作人姓名冗余
-- ========== 设备和环境信息 ==========
device_info VARCHAR(200), -- 签到设备标识或型号
ip_address VARCHAR(50), -- 客户端IP地址
source VARCHAR(20) NOT NULL, -- 签到来源:MINI_PROGRAM-小程序扫码,PC_BACKEND-后台管理端
-- ========== 软删除字段 ==========
is_delete BOOLEAN DEFAULT FALSE -- 软删除标识:false-未删除,true-已删除
);
-- Step 2: 创建索引
-- 会员ID索引(加速按会员查询签到记录)
CREATE INDEX IF NOT EXISTS idx_sign_in_record_member_id ON sign_in_record(member_id);
-- 签到时间索引(加速按时间范围查询)
CREATE INDEX IF NOT EXISTS idx_sign_in_record_sign_in_time ON sign_in_record(sign_in_time);
-- 签到状态索引(加速按状态筛选)
CREATE INDEX IF NOT EXISTS idx_sign_in_record_sign_in_status ON sign_in_record(sign_in_status);
-- 会员卡ID索引(加速按会员卡查询)
CREATE INDEX IF NOT EXISTS idx_sign_in_record_member_card_id ON sign_in_record(member_card_id);
-- 操作人ID索引(加速按操作人查询)
CREATE INDEX IF NOT EXISTS idx_sign_in_record_operator_id ON sign_in_record(operator_id);
-- 签到来源索引(加速按来源统计)
CREATE INDEX IF NOT EXISTS idx_sign_in_record_source ON sign_in_record(source);
-- 软删除索引(加速查询未删除的记录)
CREATE INDEX IF NOT EXISTS idx_sign_in_record_is_delete ON sign_in_record(is_delete);
-- 复合索引:会员ID + 签到时间(加速会员签到历史查询)
CREATE INDEX IF NOT EXISTS idx_sign_in_record_member_time ON sign_in_record(member_id, sign_in_time);
-- 复合索引:签到状态 + 签到时间(加速统计数据查询)
CREATE INDEX IF NOT EXISTS idx_sign_in_record_status_time ON sign_in_record(sign_in_status, sign_in_time);
-- 复合索引:签到日期(用于每日统计,使用表达式索引)
CREATE INDEX IF NOT EXISTS idx_sign_in_record_sign_in_date ON sign_in_record(DATE(sign_in_time));
-- Step 3: 添加外键约束(可选,根据业务需求决定)
-- 注意:如果member_user表使用了软删除,外键约束可能需要谨慎使用
-- ALTER TABLE sign_in_record
-- ADD CONSTRAINT fk_sign_in_record_member
-- FOREIGN KEY (member_id) REFERENCES member_user(id) ON DELETE SET NULL;
-- Step 4: 添加注释
COMMENT ON TABLE sign_in_record IS '会员到店签到记录表';
COMMENT ON COLUMN sign_in_record.id IS '自增主键';
COMMENT ON COLUMN sign_in_record.created_at IS '记录创建时间';
COMMENT ON COLUMN sign_in_record.updated_at IS '记录更新时间';
COMMENT ON COLUMN sign_in_record.member_id IS '会员ID,关联member_user表';
COMMENT ON COLUMN sign_in_record.member_card_id IS '签到时使用的会员卡ID';
COMMENT ON COLUMN sign_in_record.sign_in_time IS '签到入场时间';
COMMENT ON COLUMN sign_in_record.sign_in_type IS '签到方式:QR_CODE-扫码签到,MANUAL-手动签到,FACE-人脸识别';
COMMENT ON COLUMN sign_in_record.sign_in_status IS '签到状态:SUCCESS-成功,FAILED-失败';
COMMENT ON COLUMN sign_in_record.verification_details IS 'JSON格式,存储会员卡验证时的快照数据(包含卡类型、剩余次数/金额、有效期等)';
COMMENT ON COLUMN sign_in_record.fail_reason IS '失败时的具体原因文案';
COMMENT ON COLUMN sign_in_record.operator_id IS '操作人ID(前台人员),自助签到时为NULL';
COMMENT ON COLUMN sign_in_record.operator_name IS '操作人姓名冗余(避免关联查询)';
COMMENT ON COLUMN sign_in_record.device_info IS '签到设备标识或型号';
COMMENT ON COLUMN sign_in_record.ip_address IS '客户端IP地址';
COMMENT ON COLUMN sign_in_record.source IS '签到来源:MINI_PROGRAM-小程序扫码,PC_BACKEND-后台管理端';
COMMENT ON COLUMN sign_in_record.is_delete IS '软删除标识:false-未删除,true-已删除';
@@ -0,0 +1,21 @@
-- ============================================
-- V20: 更新 member_user 表 - 添加阿里云号码认证字段
-- 支持一键登录功能
-- ============================================
-- 添加阿里云号码认证相关字段
ALTER TABLE IF EXISTS member_user
ADD COLUMN IF NOT EXISTS dypns_open_id VARCHAR(100),
ADD COLUMN IF NOT EXISTS id_card VARCHAR(50),
ADD COLUMN IF NOT EXISTS real_name VARCHAR(50),
ADD COLUMN IF NOT EXISTS register_channel VARCHAR(50) DEFAULT 'SMS';
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_member_user_dypns_open_id ON member_user(dypns_open_id);
CREATE INDEX IF NOT EXISTS idx_member_user_register_channel ON member_user(register_channel);
-- 添加字段注释
COMMENT ON COLUMN member_user.dypns_open_id IS '阿里云号码认证OpenID(一键登录用户唯一标识)';
COMMENT ON COLUMN member_user.id_card IS '身份证号码(AES加密存储)';
COMMENT ON COLUMN member_user.real_name IS '真实姓名(AES加密存储)';
COMMENT ON COLUMN member_user.register_channel IS '注册渠道:SMS-短信验证码,ONE_CLICK-一键登录,WECHAT-微信授权';
-1
View File
@@ -47,7 +47,6 @@
<module>gym-checkIn</module>
<module>gym-dataCount</module>
<module>gym-auth</module>
<module>gym-payment</module>
</modules>
<dependencyManagement>