Author SHA1 Message Date
future 01c5f086e6 feat: 添加签到模块和数据库表结构
- 新增gym-checkIn签到模块
- 创建sign_in_record签到记录表(V13迁移脚本)
- 添加签到相关路由配置
- 更新应用配置以支持签到模块
- 添加Redis密钥常量
2026-06-23 23:42:31 +08:00
future a91416917b 签到模块 2026-06-23 23:42:31 +08:00
future e518d5d0a0 修复项目构建 2026-06-23 23:40:49 +08:00
future ffcea3104e Merge branch 'feature/member-register' of https://git.f.novalon.cn/novalon/gym-manage into feature/member-register 2026-06-23 22:48:21 +08:00
future af707e1bb4 添加签到表迁移文件 2026-06-09 17:27:44 +08:00
34 changed files with 316 additions and 1717 deletions
-6
View File
@@ -1,6 +0,0 @@
Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.4.2
Build-Jdk-Spec: 21
Implementation-Title: Gym Payment
Implementation-Version: 1.0.0
@@ -1,3 +0,0 @@
artifactId=gym-payment
groupId=cn.novalon.gym.manage
version=1.0.0
@@ -1,85 +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>
<artifactId>gym-payment</artifactId>
<packaging>jar</packaging>
<name>Gym Payment</name>
<description>Payment Module - Integrates Huifu Payment Gateway</description>
<dependencies>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-common</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-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.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
</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>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -1 +0,0 @@
cn.novalon.gym.manage.payment.config.HuifuPayConfig
@@ -1,407 +0,0 @@
-- Novalon管理系统数据库初始化脚本
-- 版本: V1
-- 描述: 创建所有核心表结构(合并版)
-- ============================================
-- 用户与角色相关表
-- ============================================
-- 用户表
CREATE TABLE IF NOT EXISTS sys_user (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
email VARCHAR(100),
phone VARCHAR(20),
nickname VARCHAR(100),
status INTEGER DEFAULT 1,
role_id BIGINT,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 角色表
CREATE TABLE IF NOT EXISTS sys_role (
id BIGSERIAL PRIMARY KEY,
role_name VARCHAR(100) NOT NULL,
role_key VARCHAR(100) NOT NULL UNIQUE,
role_sort INTEGER DEFAULT 0,
status INTEGER DEFAULT 1,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 用户角色关联表(支持多对多关系)
CREATE TABLE IF NOT EXISTS user_role (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
CONSTRAINT fk_user_role_user FOREIGN KEY (user_id) REFERENCES sys_user(id) ON DELETE CASCADE,
CONSTRAINT fk_user_role_role FOREIGN KEY (role_id) REFERENCES sys_role(id) ON DELETE CASCADE,
CONSTRAINT uk_user_role UNIQUE (user_id, role_id)
);
-- ============================================
-- 权限相关表
-- ============================================
-- 权限表
CREATE TABLE IF NOT EXISTS sys_permission (
id BIGSERIAL PRIMARY KEY,
permission_name VARCHAR(100) NOT NULL,
permission_code VARCHAR(100) NOT NULL UNIQUE,
resource VARCHAR(200) NOT NULL,
action VARCHAR(50) NOT NULL,
description VARCHAR(500),
status INTEGER DEFAULT 1,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 角色权限关联表
CREATE TABLE IF NOT EXISTS sys_role_permission (
id BIGSERIAL PRIMARY KEY,
role_id BIGINT NOT NULL,
permission_id BIGINT NOT NULL,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (role_id) REFERENCES sys_role(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES sys_permission(id) ON DELETE CASCADE,
UNIQUE (role_id, permission_id)
);
-- ============================================
-- 菜单相关表
-- ============================================
-- 菜单表
CREATE TABLE IF NOT EXISTS sys_menu (
id BIGSERIAL PRIMARY KEY,
menu_name VARCHAR(50) NOT NULL,
parent_id BIGINT DEFAULT 0,
order_num INTEGER DEFAULT 0,
menu_type VARCHAR(1) DEFAULT 'C',
perms VARCHAR(100),
component VARCHAR(200),
status INTEGER DEFAULT 1,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- ============================================
-- 字典相关表
-- ============================================
-- 字典类型表
CREATE TABLE IF NOT EXISTS sys_dict_type (
id BIGSERIAL PRIMARY KEY,
dict_name VARCHAR(100) NOT NULL,
dict_type VARCHAR(100) NOT NULL UNIQUE,
status VARCHAR(1) DEFAULT '0',
remark VARCHAR(500),
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 字典数据表
CREATE TABLE IF NOT EXISTS sys_dict_data (
id BIGSERIAL PRIMARY KEY,
dict_sort INTEGER DEFAULT 0,
dict_label VARCHAR(100) NOT NULL,
dict_value VARCHAR(100) NOT NULL,
dict_type VARCHAR(100) NOT NULL,
css_class VARCHAR(100),
list_class VARCHAR(100),
is_default VARCHAR(1) DEFAULT 'N',
status VARCHAR(1) DEFAULT '0',
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 字典表(通用字典)
CREATE TABLE IF NOT EXISTS sys_dictionary (
id BIGSERIAL PRIMARY KEY,
type VARCHAR(100) NOT NULL,
code VARCHAR(100) NOT NULL,
name VARCHAR(100) NOT NULL,
value VARCHAR(500),
remark VARCHAR(500),
sort INTEGER DEFAULT 0,
create_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- ============================================
-- 系统配置表
-- ============================================
-- 系统配置表
CREATE TABLE IF NOT EXISTS sys_config (
id BIGSERIAL PRIMARY KEY,
config_name VARCHAR(100) NOT NULL,
config_key VARCHAR(100) NOT NULL UNIQUE,
config_value VARCHAR(500) NOT NULL,
config_type VARCHAR(1) DEFAULT 'N',
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- ============================================
-- 日志相关表
-- ============================================
-- 登录日志表
CREATE TABLE IF NOT EXISTS sys_login_log (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50),
ip VARCHAR(50),
location VARCHAR(255),
browser VARCHAR(50),
os VARCHAR(50),
status VARCHAR(1),
message VARCHAR(255),
login_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 异常日志表
CREATE TABLE IF NOT EXISTS sys_exception_log (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50),
title VARCHAR(100),
exception_name VARCHAR(100),
method_name VARCHAR(255),
method_params TEXT,
exception_msg TEXT,
exception_stack TEXT,
ip VARCHAR(50),
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 操作日志表
CREATE TABLE IF NOT EXISTS operation_log (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50),
operation VARCHAR(100),
method VARCHAR(200),
params TEXT,
result TEXT,
ip VARCHAR(50),
duration BIGINT,
status VARCHAR(1) DEFAULT '0',
error_msg TEXT,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 审计日志表
CREATE TABLE IF NOT EXISTS audit_log (
id BIGSERIAL PRIMARY KEY,
entity_type VARCHAR(100) NOT NULL,
entity_id BIGINT,
operation_type VARCHAR(20) NOT NULL,
operator VARCHAR(100),
operation_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
before_data JSONB,
after_data JSONB,
changed_fields TEXT[],
ip_address VARCHAR(50),
user_agent TEXT,
description TEXT,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 审计日志归档表
CREATE TABLE IF NOT EXISTS audit_log_archive (
id BIGSERIAL PRIMARY KEY,
entity_type VARCHAR(100) NOT NULL,
entity_id BIGINT,
operation_type VARCHAR(20) NOT NULL,
operator VARCHAR(100),
operation_time TIMESTAMP,
before_data JSONB,
after_data JSONB,
changed_fields TEXT[],
ip_address VARCHAR(50),
user_agent TEXT,
description TEXT,
created_at TIMESTAMP,
archived_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ============================================
-- 通知与消息表
-- ============================================
-- 系统公告表
CREATE TABLE IF NOT EXISTS sys_notice (
id BIGSERIAL PRIMARY KEY,
notice_title VARCHAR(50) NOT NULL,
notice_type VARCHAR(1) NOT NULL,
notice_content TEXT,
status VARCHAR(1) DEFAULT '0',
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 用户消息表
CREATE TABLE IF NOT EXISTS sys_user_message (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
notice_id BIGINT,
message_title VARCHAR(255),
message_content TEXT,
is_read VARCHAR(1) DEFAULT '0',
read_time TIMESTAMP,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- ============================================
-- 文件管理表
-- ============================================
-- 文件管理表
CREATE TABLE IF NOT EXISTS sys_file (
id BIGSERIAL PRIMARY KEY,
file_name VARCHAR(255) NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_size BIGINT,
file_type VARCHAR(100),
file_extension VARCHAR(10),
storage_type VARCHAR(50),
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- ============================================
-- OAuth2相关表
-- ============================================
-- OAuth2客户端表
CREATE TABLE IF NOT EXISTS oauth2_client (
id BIGSERIAL PRIMARY KEY,
client_id VARCHAR(100) NOT NULL UNIQUE,
client_secret VARCHAR(255) NOT NULL,
client_name VARCHAR(100),
web_server_redirect_uri VARCHAR(500),
scope VARCHAR(500),
authorized_grant_types VARCHAR(500),
access_token_validity_seconds INTEGER,
refresh_token_validity_seconds INTEGER,
auto_approve VARCHAR(1) DEFAULT 'false',
enabled VARCHAR(1) DEFAULT 'true',
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- ============================================
-- 表注释
-- ============================================
COMMENT ON TABLE sys_user IS '系统用户表';
COMMENT ON TABLE sys_role IS '系统角色表';
COMMENT ON TABLE user_role IS '用户角色关联表';
COMMENT ON TABLE sys_permission IS '系统权限表';
COMMENT ON TABLE sys_role_permission IS '角色权限关联表';
COMMENT ON TABLE sys_menu IS '系统菜单表';
COMMENT ON TABLE sys_dict_type IS '字典类型表';
COMMENT ON TABLE sys_dict_data IS '字典数据表';
COMMENT ON TABLE sys_dictionary IS '通用字典表';
COMMENT ON TABLE sys_config IS '系统配置表';
COMMENT ON TABLE sys_login_log IS '登录日志表';
COMMENT ON TABLE sys_exception_log IS '异常日志表';
COMMENT ON TABLE operation_log IS '操作日志表';
COMMENT ON TABLE audit_log IS '审计日志表';
COMMENT ON TABLE audit_log_archive IS '审计日志归档表';
COMMENT ON TABLE sys_notice IS '系统公告表';
COMMENT ON TABLE sys_user_message IS '用户消息表';
COMMENT ON TABLE sys_file IS '文件管理表';
COMMENT ON TABLE oauth2_client IS 'OAuth2客户端表';
COMMENT ON TABLE sys_exception_log IS '异常日志表';
COMMENT ON COLUMN sys_exception_log.id IS '主键ID';
COMMENT ON COLUMN sys_exception_log.username IS '操作用户';
COMMENT ON COLUMN sys_exception_log.title IS '异常标题';
COMMENT ON COLUMN sys_exception_log.exception_name IS '异常名称';
COMMENT ON COLUMN sys_exception_log.method_name IS '方法名称';
COMMENT ON COLUMN sys_exception_log.method_params IS '方法参数';
COMMENT ON COLUMN sys_exception_log.exception_msg IS '异常消息';
COMMENT ON COLUMN sys_exception_log.exception_stack IS '异常堆栈';
COMMENT ON COLUMN sys_exception_log.ip IS 'IP地址';
COMMENT ON COLUMN sys_exception_log.create_time IS '创建时间';
COMMENT ON TABLE audit_log IS '审计日志表';
COMMENT ON COLUMN audit_log.id IS '主键ID';
COMMENT ON COLUMN audit_log.entity_type IS '实体类型(如User, Role等)';
COMMENT ON COLUMN audit_log.entity_id IS '实体ID';
COMMENT ON COLUMN audit_log.operation_type IS '操作类型(CREATE, UPDATE, DELETE';
COMMENT ON COLUMN audit_log.operator IS '操作人';
COMMENT ON COLUMN audit_log.operation_time IS '操作时间';
COMMENT ON COLUMN audit_log.before_data IS '变更前数据(JSON格式)';
COMMENT ON COLUMN audit_log.after_data IS '变更后数据(JSON格式)';
COMMENT ON COLUMN audit_log.changed_fields IS '变更字段列表';
COMMENT ON COLUMN audit_log.ip_address IS 'IP地址';
COMMENT ON COLUMN audit_log.description IS '操作描述';
COMMENT ON COLUMN audit_log.created_at IS '记录创建时间';
COMMENT ON TABLE audit_log_archive IS '审计日志归档表';
COMMENT ON COLUMN audit_log_archive.id IS '主键ID';
COMMENT ON COLUMN audit_log_archive.entity_type IS '实体类型(如User, Role等)';
COMMENT ON COLUMN audit_log_archive.entity_id IS '实体ID';
COMMENT ON COLUMN audit_log_archive.operation_type IS '操作类型(CREATE, UPDATE, DELETE';
COMMENT ON COLUMN audit_log_archive.operator IS '操作人';
COMMENT ON COLUMN audit_log_archive.operation_time IS '操作时间';
COMMENT ON COLUMN audit_log_archive.before_data IS '变更前数据(JSON格式)';
COMMENT ON COLUMN audit_log_archive.after_data IS '变更后数据(JSON格式)';
COMMENT ON COLUMN audit_log_archive.changed_fields IS '变更字段列表';
COMMENT ON COLUMN audit_log_archive.ip_address IS 'IP地址';
COMMENT ON COLUMN audit_log_archive.user_agent IS '用户代理';
COMMENT ON COLUMN audit_log_archive.description IS '操作描述';
COMMENT ON COLUMN audit_log_archive.created_at IS '记录创建时间';
COMMENT ON COLUMN audit_log_archive.archived_at IS '归档时间';
@@ -0,0 +1,43 @@
package cn.novalon.gym.manage.checkIn.config;
import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.HandlerMapping;
import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping;
import org.springframework.web.reactive.socket.WebSocketHandler;
import org.springframework.web.reactive.socket.server.support.WebSocketHandlerAdapter;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class WebSocketConfig {
@Autowired
private MyWebSocketHandler myWebSocketHandler;
/**
* 注册 WebSocket 路由映射
* 路径对应前端连接的 ws://xxx/webSocket/checkIn
*/
@Bean
public HandlerMapping webSocketMapping() {
Map<String, WebSocketHandler> map = new HashMap<>();
map.put("/webSocket/checkIn", myWebSocketHandler);
SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping();
mapping.setUrlMap(map);
mapping.setOrder(10); // 设置优先级
return mapping;
}
/**
* 注册 WebSocket 处理器适配器(必须)
*/
@Bean
public WebSocketHandlerAdapter handlerAdapter() {
return new WebSocketHandlerAdapter();
}
}
@@ -206,4 +206,8 @@ public class SignInRecord {
public void restore() {
this.isDelete = false;
}
}
<<<<<<< HEAD
}
=======
}
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
@@ -1,7 +1,12 @@
package cn.novalon.gym.manage.checkIn.handler;
import cn.novalon.gym.manage.checkIn.service.impl.CheckServiceImpl;
<<<<<<< HEAD
import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
=======
import cn.novalon.gym.manage.checkIn.vo.SignInRecordVO;
import cn.novalon.gym.manage.checkIn.vo.SignInStatsVO;
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
import cn.novalon.gym.manage.sys.util.AuthUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -12,6 +17,7 @@ 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 cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
@@ -36,6 +42,7 @@ public class CheckInHandler {
public Mono<ServerResponse> checkIn(ServerRequest request) {
Long memberId = authUtil.getMemberIdOrThrow(request);
return request.bodyToMono(Map.class)
.flatMap(body -> {
String qrContent = (String) body.get("qrContent");
@@ -81,7 +88,12 @@ public class CheckInHandler {
* @return
*/
public Mono<ServerResponse> getSignInRecords(ServerRequest request) {
<<<<<<< HEAD
Long memberId = authUtil.getMemberIdOrThrow(request);
=======
Long memberId = 1L;
// authUtil.getMemberIdOrThrow(request);
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
String startDateStr = request.queryParam("startDate").orElse(null);
String endDateStr = request.queryParam("endDate").orElse(null);
@@ -127,7 +139,12 @@ public class CheckInHandler {
* @return
*/
public Mono<ServerResponse> getSignInStatistics(ServerRequest request) {
<<<<<<< HEAD
Long memberId = authUtil.getMemberIdOrThrow(request);
=======
Long memberId = 1L;
// authUtil.getMemberIdOrThrow(request);
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
String startDateStr = request.queryParam("startDate").orElse(null);
String endDateStr = request.queryParam("endDate").orElse(null);
@@ -152,7 +169,12 @@ public class CheckInHandler {
* @return
*/
public Mono<ServerResponse> exportSignInRecords(ServerRequest request) {
<<<<<<< HEAD
Long memberId = authUtil.getMemberIdOrThrow(request);
=======
Long memberId = 1L;
// authUtil.getMemberIdOrThrow(request);
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
String startDateStr = request.queryParam("startDate").orElse(null);
String endDateStr = request.queryParam("endDate").orElse(null);
@@ -190,4 +212,8 @@ public class CheckInHandler {
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "success", "data", stats)));
}
}
<<<<<<< HEAD
}
=======
}
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
@@ -78,4 +78,8 @@ public interface ICheckInService {
* @return 签到统计VO
*/
Mono<SignInStatsVO> getDailySignInStats(LocalDate date);
}
<<<<<<< HEAD
}
=======
}
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
@@ -2,6 +2,11 @@ package cn.novalon.gym.manage.checkIn.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.json.JSONUtil;
<<<<<<< HEAD
import cn.hutool.extra.qrcode.QrCodeUtil;
import cn.hutool.extra.qrcode.QrConfig;
=======
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
import cn.novalon.gym.manage.checkIn.config.QRCodeConfig;
import cn.novalon.gym.manage.checkIn.constant.QRRedisKey;
import cn.novalon.gym.manage.checkIn.entity.SignInRecord;
@@ -19,8 +24,11 @@ import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.enums.MemberCardType;
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
<<<<<<< HEAD
=======
import cn.hutool.extra.qrcode.QrCodeUtil;
import cn.hutool.extra.qrcode.QrConfig;
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -84,7 +92,10 @@ public class CheckServiceImpl implements ICheckInService {
public Mono<String> checkIn(Long memberId, String qrContent) {
String key = RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now();
<<<<<<< HEAD
=======
// 先检查当天是否已经签到(从数据库查询,作为重复签到的额外保障)
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
return checkTodayAlreadySignedIn(memberId)
.flatMap(existingRecord -> {
String checkInTime = existingRecord.getSignInTime().format(DATE_FORMATTER);
@@ -126,11 +137,14 @@ public class CheckServiceImpl implements ICheckInService {
});
}
<<<<<<< HEAD
=======
/**
* 检查会员当天是否已经签到
* @param memberId 会员ID
* @return 如果已签到返回签到记录,否则返回空
*/
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
private Mono<SignInRecord> checkTodayAlreadySignedIn(Long memberId) {
LocalDateTime startOfDay = LocalDate.now().atStartOfDay();
LocalDateTime endOfDay = LocalDate.now().atTime(LocalTime.MAX);
@@ -138,6 +152,11 @@ public class CheckServiceImpl implements ICheckInService {
return signInRecordRepository.findByMemberIdAndDate(memberId, startOfDay, endOfDay);
}
<<<<<<< HEAD
private Mono<String> processCheckIn(Long memberId, Long memberCardRecordId, Map<String, Object> redisMap, String qrContent) {
LocalDateTime now = LocalDateTime.now();
=======
/**
* 处理签到逻辑
*/
@@ -145,6 +164,7 @@ public class CheckServiceImpl implements ICheckInService {
LocalDateTime now = LocalDateTime.now();
// 发送实时进度通知
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_CARD", "正在验证会员卡...");
return memberCardRecordRepository.findById(memberCardRecordId)
@@ -163,10 +183,15 @@ public class CheckServiceImpl implements ICheckInService {
return Mono.error(new RuntimeException("会员卡已过期"));
}
<<<<<<< HEAD
MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_BOOKING", "会员卡验证通过,正在检查预约信息...");
=======
// 发送实时进度通知
MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_BOOKING", "会员卡验证通过,正在检查预约信息...");
// 检查是否有需要签到的团课预约
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
return validateBooking(memberId, now)
.then(memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(cardRecord.getMemberCardId())
.switchIfEmpty(Mono.defer(() -> {
@@ -174,7 +199,10 @@ public class CheckServiceImpl implements ICheckInService {
return Mono.error(new RuntimeException("会员卡类型不存在"));
}))
.flatMap(card -> {
<<<<<<< HEAD
=======
// 发送实时进度通知
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
MyWebSocketHandler.sendProgress(qrContent, "DEDUCT_USAGE", "正在扣减会员卡次数...");
return deductCardUsage(cardRecord, card)
@@ -195,9 +223,12 @@ public class CheckServiceImpl implements ICheckInService {
});
}
<<<<<<< HEAD
=======
/**
* 验证预约信息
*/
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
private Mono<Void> validateBooking(Long memberId, LocalDateTime now) {
return groupCourseBookingService.getBookingsByMemberId(memberId)
.filter(booking -> {
@@ -229,9 +260,12 @@ public class CheckServiceImpl implements ICheckInService {
});
}
<<<<<<< HEAD
=======
/**
* 扣减会员卡使用次数/金额
*/
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
private Mono<MemberCardRecord> deductCardUsage(MemberCardRecord record, MemberCard card) {
MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType());
LocalDateTime now = LocalDateTime.now();
@@ -267,9 +301,12 @@ public class CheckServiceImpl implements ICheckInService {
}
}
<<<<<<< HEAD
=======
/**
* 保存签到记录
*/
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
private Mono<Void> saveSignInRecord(Long memberId, Long memberCardRecordId, Long memberCardId) {
SignInRecord record = SignInRecord.builder()
.memberId(memberId)
@@ -284,9 +321,12 @@ public class CheckServiceImpl implements ICheckInService {
return signInRecordRepository.save(record).then();
}
<<<<<<< HEAD
=======
/**
* 构建成功响应
*/
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
private String buildSuccessResponse(LocalDateTime dateTime) {
Map<String, Object> res = new HashMap<>();
res.put("message", "签到成功");
@@ -294,9 +334,12 @@ public class CheckServiceImpl implements ICheckInService {
return JSONUtil.toJsonStr(res);
}
<<<<<<< HEAD
=======
/**
* 查找会员的有效会员卡(优先选择有效期最早到期的)
*/
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
private Mono<MemberCardRecord> findValidMemberCard(Long memberId) {
return memberCardRecordRepository.findActiveCardsByMemberId(memberId)
.filter(record -> {
@@ -314,8 +357,11 @@ public class CheckServiceImpl implements ICheckInService {
.next();
}
<<<<<<< HEAD
=======
// ==================== 签到记录管理功能 ====================
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
@Override
public Flux<SignInRecordVO> getSignInRecords(Long memberId, LocalDate startTime, LocalDate endTime) {
LocalDateTime start = startTime.atStartOfDay();
@@ -413,9 +459,12 @@ public class CheckServiceImpl implements ICheckInService {
);
}
<<<<<<< HEAD
=======
/**
* 转换实体到VO
*/
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
private SignInRecordVO convertToVO(SignInRecord record) {
SignInRecordVO vo = new SignInRecordVO();
vo.setId(record.getId());
@@ -77,7 +77,11 @@ public class MyWebSocketHandler implements WebSocketHandler {
.doOnError(e -> {
log.error("接收消息出错,sessionId={}", sessionId, e);
})
<<<<<<< HEAD
.subscribe();
=======
.subscribe(); // 必须订阅,否则不会执行
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
// 发送流给客户端
return session.send(sink.asFlux().map(session::textMessage))
@@ -100,7 +104,10 @@ public class MyWebSocketHandler implements WebSocketHandler {
* @return 是否发送成功
*/
public static boolean sendMessageToClient(String qrContent, String message) {
<<<<<<< HEAD
=======
// 先清理超时连接
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
cleanupTimeoutConnections();
Sinks.Many<String> sink = qrContentToSink.get(qrContent);
@@ -130,6 +137,77 @@ public class MyWebSocketHandler implements WebSocketHandler {
String progressMessage = buildMessage("PROGRESS", message);
sendMessageToClient(qrContent, progressMessage);
log.debug("发送进度消息: qrContent={}, step={}, message={}", qrContent, step, message);
<<<<<<< HEAD
=======
}
/**
* 发送签到成功消息
*
* @param qrContent 二维码内容
* @param memberId 会员ID
* @param signInTime 签到时间
*/
public static void sendSuccess(String qrContent, Long memberId, String signInTime) {
String successMessage = buildMessage("SUCCESS", "签到成功!欢迎光临\n会员ID: " + memberId + "\n签到时间: " + signInTime);
sendMessageToClient(qrContent, successMessage);
log.info("发送成功消息: qrContent={}, memberId={}", qrContent, memberId);
}
/**
* 发送签到失败消息
*
* @param qrContent 二维码内容
* @param reason 失败原因
*/
public static void sendFailure(String qrContent, String reason) {
String failureMessage = buildMessage("FAILURE", "签到失败:" + reason);
sendMessageToClient(qrContent, failureMessage);
log.warn("发送失败消息: qrContent={}, reason={}", qrContent, reason);
}
/**
* 构建标准消息格式
*
* @param type 消息类型
* @param content 消息内容
* @return 格式化后的消息字符串
*/
private static String buildMessage(String type, String content) {
return JSONUtil.toJsonStr(Map.of(
"type", type,
"content", content,
"timestamp", System.currentTimeMillis()
));
}
/**
* 清理超时连接
*/
private static void cleanupTimeoutConnections() {
LocalDateTime now = LocalDateTime.now();
qrContentToCreateTime.entrySet().removeIf(entry -> {
LocalDateTime createTime = entry.getValue();
long secondsDiff = java.time.Duration.between(createTime, now).getSeconds();
if (secondsDiff > TIMEOUT_SECONDS) {
String qrContent = entry.getKey();
qrContentToSink.remove(qrContent);
log.debug("清理超时连接: qrContent={}, 超时时间={}秒", qrContent, secondsDiff);
return true;
}
return false;
});
}
/**
* 获取当前连接数
*
* @return 连接数
*/
public static int getConnectionCount() {
cleanupTimeoutConnections();
return qrContentToSink.size();
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
}
/**
@@ -199,4 +277,4 @@ public class MyWebSocketHandler implements WebSocketHandler {
cleanupTimeoutConnections();
return qrContentToSink.size();
}
}
}
@@ -25,7 +25,10 @@ import reactor.test.StepVerifier;
import java.time.LocalDate;
import java.time.LocalDateTime;
<<<<<<< HEAD
=======
import java.time.LocalTime;
>>>>>>> f52451d (feat: 添加签到模块和数据库表结构)
import java.util.HashMap;
import java.util.Map;
@@ -1,5 +1,6 @@
package cn.novalon.gym.manage.member.service.impl;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.entity.MemberCardTransaction;
@@ -15,7 +16,6 @@ import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
import cn.novalon.gym.manage.member.service.IMemberCardService;
import cn.novalon.gym.manage.member.service.IMemberCardTransactionService;
import cn.novalon.gym.manage.common.util.RedisUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
-95
View File
@@ -1,95 +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>
<artifactId>gym-payment</artifactId>
<packaging>jar</packaging>
<name>Gym Payment</name>
<description>Payment Module - Integrates Huifu Payment Gateway</description>
<dependencies>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-common</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-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.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.78.1</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
<version>1.78.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
</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>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -1,23 +0,0 @@
package cn.novalon.gym.manage.payment.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsWebFilter;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
@Configuration
public class CorsConfig {
@Bean
public CorsWebFilter corsWebFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("*");
config.addAllowedMethod("*");
config.addAllowedHeader("*");
config.setAllowCredentials(false);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsWebFilter(source);
}
}
@@ -1,34 +0,0 @@
package cn.novalon.gym.manage.payment.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Data
@Configuration
@ConfigurationProperties(prefix = "payment.huifu")
public class HuifuPayConfig {
private String sysId;
private String productId;
private String huifuId;
private String acctId;
private String privateKey;
private String publicKey;
private String createUrl;
private String queryUrl;
private String refundUrl;
private String notifyUrl;
private String version = "1.0";
}
@@ -1,58 +0,0 @@
package cn.novalon.gym.manage.payment.dto.request;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PaymentCreateRequest {
@NotNull(message = "会员ID不能为空")
private Long memberId;
@NotBlank(message = "订单类型不能为空")
@Size(max = 50, message = "订单类型长度不能超过50")
private String orderType;
@NotBlank(message = "商品描述不能为空")
@Size(max = 128, message = "商品描述长度不能超过128")
private String goodsDesc;
@NotBlank(message = "交易金额不能为空")
private String transAmt;
@NotBlank(message = "交易类型不能为空")
@Size(max = 16, message = "交易类型长度不能超过16")
private String tradeType;
@Size(max = 255, message = "备注长度不能超过255")
private String remark;
@Size(max = 9, message = "账户号长度不能超过9")
private String acctId;
private String timeExpire;
private String delayAcctFlag;
private Integer feeFlag;
@Size(max = 128, message = "禁用支付方式长度不能超过128")
private String limitPayType;
@Size(max = 32, message = "渠道号长度不能超过32")
private String channelNo;
private String payScene;
private String notifyUrl;
}
@@ -1,28 +0,0 @@
package cn.novalon.gym.manage.payment.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PaymentCreateResponse {
private String orderId;
private String tradeType;
private String qrCode;
private String payInfo;
private String transAmt;
private String payStatus;
private String message;
}
@@ -1,30 +0,0 @@
package cn.novalon.gym.manage.payment.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PaymentQueryResponse {
private String orderId;
private String tradeType;
private String transAmt;
private String payStatus;
private String outTransId;
private String qrCode;
private String payTime;
private String message;
}
@@ -1,22 +0,0 @@
package cn.novalon.gym.manage.payment.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PaymentRefundResponse {
private String orderId;
private String refundAmt;
private String refundStatus;
private String message;
}
@@ -1,78 +0,0 @@
package cn.novalon.gym.manage.payment.handler;
import cn.novalon.gym.manage.payment.dto.request.PaymentCreateRequest;
import cn.novalon.gym.manage.payment.service.IPaymentService;
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 java.util.HashMap;
import java.util.Map;
@Slf4j
@Component
@Tag(name = "支付管理", description = "斗拱聚合支付相关操作")
public class PaymentHandler {
private final IPaymentService paymentService;
public PaymentHandler(IPaymentService paymentService) {
this.paymentService = paymentService;
}
@Operation(summary = "创建支付", description = "创建支付订单,支持微信、支付宝等多种支付方式")
public Mono<ServerResponse> createPayment(ServerRequest request) {
log.info("========== PaymentHandler.createPayment 被调用 ==========");
return request.bodyToMono(PaymentCreateRequest.class)
.flatMap(paymentService::createPayment)
.flatMap(response -> ServerResponse.ok().bodyValue(response))
.onErrorResume(e -> ServerResponse.badRequest().bodyValue(buildErrorResponse("创建支付失败: " + e.getMessage())));
}
@Operation(summary = "查询支付状态", description = "根据订单号查询支付状态")
public Mono<ServerResponse> queryPayment(ServerRequest request) {
String orderId = request.pathVariable("orderId");
return paymentService.queryPayment(orderId)
.flatMap(response -> ServerResponse.ok().bodyValue(response))
.onErrorResume(e -> ServerResponse.badRequest().bodyValue(buildErrorResponse(e.getMessage())));
}
@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"));
return paymentService.refundPayment(orderId, refundAmt);
})
.flatMap(response -> ServerResponse.ok().bodyValue(response))
.onErrorResume(e -> ServerResponse.badRequest().bodyValue(buildErrorResponse("退款失败: " + e.getMessage())));
}
@Operation(summary = "支付回调", description = "支付成功回调通知")
public Mono<ServerResponse> handleNotify(ServerRequest request) {
return request.bodyToMono(String.class)
.flatMap(paymentService::handleNotify)
.then(ServerResponse.ok().bodyValue(buildSuccessResponse("处理成功")))
.onErrorResume(e -> ServerResponse.badRequest().bodyValue(buildErrorResponse("处理失败: " + e.getMessage())));
}
private Map<String, Object> buildErrorResponse(String message) {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", message);
return response;
}
private Map<String, Object> buildSuccessResponse(String message) {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", message);
return response;
}
}
@@ -1,19 +0,0 @@
package cn.novalon.gym.manage.payment.service;
import cn.novalon.gym.manage.payment.dto.request.PaymentCreateRequest;
import cn.novalon.gym.manage.payment.dto.response.PaymentCreateResponse;
import cn.novalon.gym.manage.payment.dto.response.PaymentQueryResponse;
import cn.novalon.gym.manage.payment.dto.response.PaymentRefundResponse;
import reactor.core.publisher.Mono;
public interface IPaymentService {
Mono<PaymentCreateResponse> createPayment(PaymentCreateRequest request);
Mono<PaymentQueryResponse> queryPayment(String orderId);
Mono<PaymentRefundResponse> refundPayment(String orderId, String refundAmt);
Mono<Void> handleNotify(String notifyBody);
}
@@ -1,723 +0,0 @@
package cn.novalon.gym.manage.payment.service.impl;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import cn.novalon.gym.manage.payment.dto.request.PaymentCreateRequest;
import cn.novalon.gym.manage.payment.dto.response.PaymentCreateResponse;
import cn.novalon.gym.manage.payment.dto.response.PaymentQueryResponse;
import cn.novalon.gym.manage.payment.dto.response.PaymentRefundResponse;
import cn.novalon.gym.manage.payment.service.IPaymentService;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
public class PaymentServiceImpl implements IPaymentService {
private final OkHttpClient okHttpClient;
private final Map<String, PaymentInfo> paymentCache = new ConcurrentHashMap<>();
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
// ==================== 配置 ====================
private static final String SYS_ID = "6666000207573586"; // 代理商
private static final String PRODUCT_ID = "XLSISV";
private static final String HUIFU_ID = "6666000207581039"; // 商户号
private static final String ACCT_ID = "F28308086";
private static final String ALIPAY_CHANNEL = "hlm001";
private static final String NOTIFY_URL = "http://localhost:8084/api/payment/notify";
// ===== v4 接口 =====
private static final String CREATE_URL = "https://api.huifu.com/v4/trade/payment/create";
private static final String QUERY_URL = "https://api.huifu.com/v4/trade/payment/query";
private static final String REFUND_URL = "https://api.huifu.com/v4/trade/payment/refund";
private static final String CLOSE_URL = "https://api.huifu.com/v4/trade/payment/close";
// ==================== 密钥 ====================
// 商户私钥用于签名请求
private static final String MERCHANT_PRIVATE_KEY =
"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=";
// 汇付公钥用于验证回调签名
private static final String HUIFU_PUBLIC_KEY =
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuqolFneAH66z3/3gaIYIaRZOIk/UYzdsXIm0RyawYBYAOu/NJ7ul8CRIrRlt5vd98HodW2yPrXA4+VHF3AS9UE4WTDpo9qV5brhqQSr/lAuZtEwMZwUWwgdnGFMkUFd9RvyGXAqY0bsQrcQgQ6zGjZHzlMljogDR3iblG0ak5ssD2TSC2W+1cxu+id+FP6onZXlXizuClTyIRh17m7CbS6rl0P3M96MlTdCzTeBw/Y54CiegBJI2wOrm2Qa6Dg6KRc+YkaJWjuRJVJkwjk8JhSyALno9oEzuDKaAXlsQlxeIhmAy4esRrZGrMV8SG0gwUZIP8lduPjQE95lCqqJ0gQIDAQAB";
public PaymentServiceImpl() {
this.okHttpClient = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.retryOnConnectionFailure(false)
.build();
// 验证私钥是否有效
validatePrivateKey();
}
private void validatePrivateKey() {
try {
log.info("========== 验证私钥 ==========");
String privateKeyBase64 = MERCHANT_PRIVATE_KEY.replaceAll("\\s", "");
log.info("私钥长度(去空格后): {}", privateKeyBase64.length());
byte[] privateKeyBytes = Base64.getDecoder().decode(privateKeyBase64);
log.info("私钥解码后长度: {} bytes", privateKeyBytes.length);
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(privateKeyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PrivateKey privateKey = keyFactory.generatePrivate(spec);
log.info("私钥加载成功,算法: {}", privateKey.getAlgorithm());
// 测试签名
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initSign(privateKey);
signature.update("test".getBytes(StandardCharsets.UTF_8));
signature.sign();
log.info("私钥签名测试成功!");
log.info("========== 私钥验证完成 ==========");
} catch (Exception e) {
log.error("========== 私钥验证失败 ==========", e);
}
}
// ==================== 1. 创建支付订单 ====================
@Override
public Mono<PaymentCreateResponse> createPayment(PaymentCreateRequest request) {
log.info("========== createPayment 方法被调用 ==========");
String orderId = UUID.randomUUID().toString().replace("-", "");
String reqDate = LocalDateTime.now().format(DATE_FORMATTER);
String reqSeqId = "RQ" + System.currentTimeMillis();
// ===== 构建 data 参数 =====
Map<String, Object> data = new HashMap<>();
data.put("req_date", reqDate);
data.put("req_seq_id", reqSeqId);
data.put("huifu_id", HUIFU_ID);
data.put("trade_type", "ALIPAY");
data.put("pay_type", "APP"); // APP 支付返回 alipay_scheme
data.put("trans_amt", request.getTransAmt() != null ? request.getTransAmt() : "1");
data.put("goods_desc", request.getGoodsDesc() != null ? request.getGoodsDesc() : "会员卡");
data.put("acct_id", ACCT_ID);
data.put("notify_url", NOTIFY_URL);
data.put("remark", request.getRemark() != null ? request.getRemark() : "");
// ===== 支付宝参数 =====
data.put("alipay_channel", ALIPAY_CHANNEL);
String dataJson = JSONUtil.toJsonStr(data);
String sign = generateSign(dataJson);
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("sys_id", SYS_ID);
requestBody.put("product_id", PRODUCT_ID);
requestBody.put("data", dataJson);
requestBody.put("sign", sign);
return Mono.fromCallable(() -> {
String jsonBody = JSONUtil.toJsonStr(requestBody);
log.info("========== 发起支付宝支付请求 (APP) ==========");
log.info("请求URL: {}", CREATE_URL);
log.info("请求Body: {}", jsonBody);
log.info("==================================");
Request httpRequest = new Request.Builder()
.url(CREATE_URL)
.post(RequestBody.create(jsonBody, MediaType.parse("application/json; charset=utf-8")))
.addHeader("Content-Type", "application/json; charset=UTF-8")
.addHeader("Accept", "application/json")
.build();
try (Response response = okHttpClient.newCall(httpRequest).execute()) {
int httpCode = response.code();
String responseBody = response.body() != null ? response.body().string() : "";
log.info("========== 收到响应 ==========");
log.info("HTTP状态码: {}", httpCode);
log.info("响应Body: {}", responseBody);
log.info("===============================");
if (httpCode >= 400) {
if (responseBody != null && !responseBody.isEmpty()) {
try {
JSONObject errorJson = JSONUtil.parseObj(responseBody);
String errorMsg = errorJson.getStr("error_msg");
String errorCode = errorJson.getStr("error_code");
if (errorMsg != null) {
throw new RuntimeException("支付失败: " + errorMsg + " (code: " + errorCode + ")");
}
} catch (Exception e) {
// 忽略
}
}
throw new RuntimeException("支付请求失败: HTTP " + httpCode);
}
if (responseBody == null || responseBody.isEmpty()) {
throw new RuntimeException("响应体为空");
}
return parseCreateResponse(responseBody, orderId, request);
} catch (IOException e) {
log.error("网络请求异常", e);
throw new RuntimeException("网络请求异常: " + e.getMessage(), e);
}
});
}
// ==================== 2. 查询支付状态 ====================
@Override
public Mono<PaymentQueryResponse> queryPayment(String orderId) {
PaymentInfo paymentInfo = paymentCache.get(orderId);
if (paymentInfo == null) {
return Mono.error(new RuntimeException("支付记录不存在"));
}
String reqDate = LocalDateTime.now().format(DATE_FORMATTER);
String reqSeqId = "RQ" + System.currentTimeMillis();
Map<String, Object> data = new HashMap<>();
data.put("req_date", reqDate);
data.put("req_seq_id", reqSeqId);
data.put("huifu_id", HUIFU_ID);
data.put("out_trans_id", paymentInfo.outTransId);
String dataJson = JSONUtil.toJsonStr(data);
String sign = generateSign(dataJson);
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("sys_id", SYS_ID);
requestBody.put("product_id", PRODUCT_ID);
requestBody.put("data", dataJson);
requestBody.put("sign", sign);
return Mono.fromCallable(() -> {
String jsonBody = JSONUtil.toJsonStr(requestBody);
log.info("查询支付状态, orderId={}", orderId);
Request httpRequest = new Request.Builder()
.url(QUERY_URL)
.post(RequestBody.create(jsonBody, MediaType.parse("application/json; charset=utf-8")))
.addHeader("Content-Type", "application/json; charset=UTF-8")
.addHeader("Accept", "application/json")
.build();
try (Response response = okHttpClient.newCall(httpRequest).execute()) {
int httpCode = response.code();
String responseBody = response.body() != null ? response.body().string() : "";
log.info("查询响应: code={}, body={}", httpCode, responseBody);
if (httpCode >= 400) {
throw new RuntimeException("查询失败: HTTP " + httpCode);
}
if (responseBody == null || responseBody.isEmpty()) {
throw new RuntimeException("查询响应为空");
}
return parseQueryResponse(responseBody, orderId);
}
});
}
// ==================== 3. 申请退款 ====================
@Override
public Mono<PaymentRefundResponse> refundPayment(String orderId, String refundAmt) {
PaymentInfo paymentInfo = paymentCache.get(orderId);
if (paymentInfo == null) {
return Mono.error(new RuntimeException("支付记录不存在"));
}
String reqDate = LocalDateTime.now().format(DATE_FORMATTER);
String reqSeqId = "RQ" + System.currentTimeMillis();
Map<String, Object> data = new HashMap<>();
data.put("req_date", reqDate);
data.put("req_seq_id", reqSeqId);
data.put("huifu_id", HUIFU_ID);
data.put("out_trans_id", paymentInfo.outTransId);
data.put("trans_amt", refundAmt);
String dataJson = JSONUtil.toJsonStr(data);
String sign = generateSign(dataJson);
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("sys_id", SYS_ID);
requestBody.put("product_id", PRODUCT_ID);
requestBody.put("data", dataJson);
requestBody.put("sign", sign);
return Mono.fromCallable(() -> {
String jsonBody = JSONUtil.toJsonStr(requestBody);
log.info("发起退款请求, orderId={}", orderId);
Request httpRequest = new Request.Builder()
.url(REFUND_URL)
.post(RequestBody.create(jsonBody, MediaType.parse("application/json; charset=utf-8")))
.addHeader("Content-Type", "application/json; charset=UTF-8")
.addHeader("Accept", "application/json")
.build();
try (Response response = okHttpClient.newCall(httpRequest).execute()) {
int httpCode = response.code();
String responseBody = response.body() != null ? response.body().string() : "";
log.info("退款响应: code={}, body={}", httpCode, responseBody);
if (httpCode >= 400) {
throw new RuntimeException("退款失败: HTTP " + httpCode);
}
return parseRefundResponse(responseBody, orderId, refundAmt);
}
});
}
// ==================== 4. 关闭订单 ====================
public Mono<Void> closePayment(String orderId) {
PaymentInfo paymentInfo = paymentCache.get(orderId);
if (paymentInfo == null) {
return Mono.error(new RuntimeException("支付记录不存在"));
}
String reqDate = LocalDateTime.now().format(DATE_FORMATTER);
String reqSeqId = "RQ" + System.currentTimeMillis();
Map<String, Object> data = new HashMap<>();
data.put("req_date", reqDate);
data.put("req_seq_id", reqSeqId);
data.put("huifu_id", HUIFU_ID);
data.put("out_trans_id", paymentInfo.outTransId);
String dataJson = JSONUtil.toJsonStr(data);
String sign = generateSign(dataJson);
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("sys_id", SYS_ID);
requestBody.put("product_id", PRODUCT_ID);
requestBody.put("data", dataJson);
requestBody.put("sign", sign);
return Mono.fromRunnable(() -> {
try {
String jsonBody = JSONUtil.toJsonStr(requestBody);
log.info("关闭订单请求, orderId={}", orderId);
Request httpRequest = new Request.Builder()
.url(CLOSE_URL)
.post(RequestBody.create(jsonBody, MediaType.parse("application/json; charset=utf-8")))
.addHeader("Content-Type", "application/json; charset=UTF-8")
.addHeader("Accept", "application/json")
.build();
try (Response response = okHttpClient.newCall(httpRequest).execute()) {
String responseBody = response.body() != null ? response.body().string() : "";
log.info("关闭订单响应, code={}, body={}", response.code(), responseBody);
}
} catch (Exception e) {
log.error("关闭订单异常", e);
}
});
}
// ==================== 5. 处理异步通知 ====================
@Override
public Mono<Void> handleNotify(String notifyBody) {
return Mono.fromRunnable(() -> {
log.info("========== 收到支付回调通知 ==========");
log.info("回调内容: {}", notifyBody);
try {
JSONObject notifyJson = JSONUtil.parseObj(notifyBody);
String sign = notifyJson.getStr("sign");
String data = notifyJson.getStr("data");
// 直接打印回调信息不验证签名
log.info("签名: {}", sign);
log.info("数据: {}", data);
if (data != null && !data.isEmpty()) {
JSONObject dataJson = JSONUtil.parseObj(data);
String respCode = dataJson.getStr("resp_code");
String respDesc = dataJson.getStr("resp_desc");
String outTransId = dataJson.getStr("out_trans_id");
String transStatus = dataJson.getStr("trans_status");
String transAmt = dataJson.getStr("trans_amt");
String finishDate = dataJson.getStr("finish_date");
String finishTime = dataJson.getStr("finish_time");
log.info("========== 回调数据解析 ==========");
log.info("响应码: {}", respCode);
log.info("响应描述: {}", respDesc);
log.info("商户订单号: {}", outTransId);
log.info("交易状态: {}", transStatus);
log.info("交易金额: {}", transAmt);
log.info("交易完成日期: {}", finishDate);
log.info("交易完成时间: {}", finishTime);
log.info("===================================");
// 更新本地支付状态
if ("S".equals(transStatus) || "TRADE_SUCCESS".equals(transStatus)) {
paymentCache.values().stream()
.filter(info -> outTransId != null && outTransId.equals(info.outTransId))
.findFirst()
.ifPresent(info -> {
info.payStatus = transStatus;
info.payTime = LocalDateTime.now();
log.info("支付状态更新成功, orderId={}, status={}", info.orderId, transStatus);
});
}
}
} catch (Exception e) {
log.error("处理回调异常", e);
}
});
}
// ==================== 签名方法 ====================
private String generateSign(String dataJson) {
Exception originalException = null;
System.err.println(">>>>>>>>> [PaymentServiceImpl] generateSign 开始执行 <<<<<<<<<<");
System.err.flush();
try {
System.err.println(">>>>>>>>> 开始签名方法 <<<<<<<<<<");
System.err.flush();
log.error("========== 开始签名 ==========");
// 打印密钥的前几个字节来检查格式
String privateKeyBase64 = MERCHANT_PRIVATE_KEY.replaceAll("\\s", "");
byte[] keyBytes = Base64.getDecoder().decode(privateKeyBase64);
System.err.println(">>>>>>>>> 密钥前20字节(hex): " + bytesToHex(keyBytes, 20) + " <<<<<<<<<<");
System.err.println(">>>>>>>>> 密钥长度: " + keyBytes.length + " bytes <<<<<<<<<<");
System.err.flush();
// 检查是否是PKCS#8格式 (应该以30 82开头)
if (keyBytes.length > 2) {
System.err.println(">>>>>>>>> 密钥前2字节(hex): " + bytesToHex(keyBytes, 2) + " <<<<<<<<<<");
boolean startsWith30 = (keyBytes[0] & 0xFF) == 0x30;
System.err.println(">>>>>>>>> 密钥是否以0x30开头: " + startsWith30 + " <<<<<<<<<<");
System.err.flush();
// 打印更多头部字节来识别密钥类型
if (keyBytes.length > 4) {
System.err.println(">>>>>>>>> 密钥前5字节(hex): " + bytesToHex(keyBytes, 5) + " <<<<<<<<<<");
System.err.flush();
// RSA PKCS#8 私钥通常以 30 82 04** 开头
// EC PKCS#8 私钥通常以 30 82 04** 开头 (和RSA一样需要看后面的内容区分)
// 如果是 30 82且第4个字节是 0x00 0x01可能是RSA私钥
// 如果是 30 82且第4个字节是 0x02 0x03可能是EC私钥
}
}
log.info("私钥长度(去空格后): {}", privateKeyBase64.length());
// 检查私钥格式
if (privateKeyBase64.length() < 100) {
throw new RuntimeException("私钥长度异常: " + privateKeyBase64.length());
}
log.info("私钥前50字符: {}", privateKeyBase64.substring(0, 50));
log.info("私钥后50字符: {}", privateKeyBase64.substring(privateKeyBase64.length() - 50));
log.info("开始解码私钥...");
byte[] privateKeyBytes = Base64.getDecoder().decode(privateKeyBase64);
log.info("私钥解码后长度: {} bytes", privateKeyBytes.length);
// 直接尝试使用PKCS8EncodedKeySpec加载
log.info("开始生成 PrivateKey 对象...");
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(privateKeyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PrivateKey privateKey = keyFactory.generatePrivate(spec);
log.info("私钥生成成功,算法: {}", privateKey.getAlgorithm());
log.info("开始签名...");
Signature signature = Signature.getInstance("SHA256withRSA");
log.info("调用 initSign...");
signature.initSign(privateKey);
log.info("initSign 完成");
log.info("开始 update...");
signature.update(dataJson.getBytes(StandardCharsets.UTF_8));
log.info("update 完成,开始 sign...");
String sign = Base64.getEncoder().encodeToString(signature.sign());
log.info("签名生成成功, 签名长度: {}", sign.length());
log.info("========== 签名完成 ==========");
return sign;
} catch (RuntimeException e) {
log.error("签名生成失败(RuntimeException): {} - {}", e.getClass().getName(), e.getMessage(), e);
throw e;
} catch (Exception e) {
log.error("签名生成失败(Exception): {} - {}", e.getClass().getName(), e.getMessage(), e);
throw new RuntimeException("签名生成失败: " + e.getMessage(), e);
}
}
private boolean verifySign(String sign, String dataJson) {
try {
String publicKeyBase64 = HUIFU_PUBLIC_KEY.replaceAll("\\s", "");
byte[] publicKeyBytes = Base64.getDecoder().decode(publicKeyBase64);
X509EncodedKeySpec spec = new X509EncodedKeySpec(publicKeyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PublicKey publicKey = keyFactory.generatePublic(spec);
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initVerify(publicKey);
signature.update(dataJson.getBytes(StandardCharsets.UTF_8));
return signature.verify(Base64.getDecoder().decode(sign));
} catch (Exception e) {
log.error("验签失败", e);
return false;
}
}
// ==================== 响应解析方法 ====================
private PaymentCreateResponse parseCreateResponse(String responseBody, String orderId, PaymentCreateRequest request) {
JSONObject responseJson = JSONUtil.parseObj(responseBody);
String dataStr = responseJson.getStr("data");
JSONObject data = JSONUtil.parseObj(dataStr);
String respCode = data.getStr("resp_code");
if (!"000000".equals(respCode)) {
throw new RuntimeException("支付创建失败: " + data.getStr("resp_desc") + " (code: " + respCode + ")");
}
// APP 支付优先取 alipay_scheme
String payUrl = data.getStr("alipay_scheme");
if (payUrl == null) {
payUrl = data.getStr("pay_url");
}
if (payUrl == null) {
payUrl = data.getStr("pay_info");
}
PaymentInfo paymentInfo = new PaymentInfo();
paymentInfo.orderId = orderId;
paymentInfo.memberId = request.getMemberId();
paymentInfo.orderType = request.getOrderType();
paymentInfo.transAmt = request.getTransAmt();
paymentInfo.goodsDesc = request.getGoodsDesc();
paymentInfo.payStatus = "PENDING";
paymentInfo.outTransId = data.getStr("out_trans_id");
paymentInfo.payUrl = payUrl;
paymentInfo.remark = request.getRemark();
paymentCache.put(orderId, paymentInfo);
log.info("支付创建成功, orderId={}, outTransId={}, payUrl={}", orderId, paymentInfo.outTransId, payUrl);
return PaymentCreateResponse.builder()
.orderId(orderId)
.payInfo(payUrl)
.transAmt(request.getTransAmt())
.payStatus("PENDING")
.message("支付创建成功")
.build();
}
private PaymentQueryResponse parseQueryResponse(String responseBody, String orderId) {
JSONObject responseJson = JSONUtil.parseObj(responseBody);
String dataStr = responseJson.getStr("data");
JSONObject data = JSONUtil.parseObj(dataStr);
String respCode = data.getStr("resp_code");
if (!"000000".equals(respCode)) {
throw new RuntimeException("查询失败: " + data.getStr("resp_desc"));
}
PaymentInfo paymentInfo = paymentCache.get(orderId);
if (paymentInfo != null) {
paymentInfo.payStatus = data.getStr("trans_status");
}
return PaymentQueryResponse.builder()
.orderId(orderId)
.tradeType(data.getStr("trade_type"))
.transAmt(data.getStr("trans_amt"))
.payStatus(data.getStr("trans_status"))
.outTransId(data.getStr("out_trans_id"))
.payTime(data.getStr("end_time"))
.message("查询成功")
.build();
}
private PaymentRefundResponse parseRefundResponse(String responseBody, String orderId, String refundAmt) {
JSONObject responseJson = JSONUtil.parseObj(responseBody);
String dataStr = responseJson.getStr("data");
JSONObject data = JSONUtil.parseObj(dataStr);
String respCode = data.getStr("resp_code");
if (!"000000".equals(respCode)) {
throw new RuntimeException("退款失败: " + data.getStr("resp_desc"));
}
PaymentInfo paymentInfo = paymentCache.get(orderId);
if (paymentInfo != null) {
paymentInfo.payStatus = "REFUNDED";
}
return PaymentRefundResponse.builder()
.orderId(orderId)
.refundAmt(refundAmt)
.refundStatus(data.getStr("trans_status"))
.message("退款成功")
.build();
}
// ==================== 工具方法 ====================
private static String bytesToHex(byte[] bytes, int length) {
int len = Math.min(bytes.length, length);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < len; i++) {
sb.append(String.format("%02X ", bytes[i] & 0xFF));
}
return sb.toString();
}
/**
* 将PKCS#1格式的RSA私钥转换为PKCS#8格式
* PKCS#1: SEQUENCE { version, modulus, publicExponent, privateExponent, prime1, prime2, ... }
* PKCS#8: SEQUENCE { version, algorithmIdentifier, OCTET STRING (containing PKCS#1) }
*/
private byte[] convertPKCS1ToPKCS8(byte[] pkcs1Key) {
try {
log.info("检测到PKCS#1格式私钥,开始转换为PKCS#8格式...");
log.info("PKCS#1密钥长度: {} bytes", pkcs1Key.length);
// RSA算法OID: 1.2.840.113549.1.1.1
byte[] rsaOid = new byte[] {
0x06, 0x09, 0x2A, (byte)0x86, 0x48, (byte)0x86, (byte)0xF7, 0x0D, 0x01, 0x01, 0x01
};
// 构建AlgorithmIdentifier: SEQUENCE { OID, NULL }
// SEQUENCE (2 bytes: tag + length) + OID + NULL = 15 bytes total
byte[] algorithmIdentifier = new byte[15];
int idx = 0;
algorithmIdentifier[idx++] = 0x30; // SEQUENCE tag
algorithmIdentifier[idx++] = 0x0D; // length = 13 bytes for OID + NULL
System.arraycopy(rsaOid, 0, algorithmIdentifier, idx, rsaOid.length);
idx += rsaOid.length;
algorithmIdentifier[idx++] = 0x05; // NULL tag
algorithmIdentifier[idx++] = 0x00; // NULL value
log.info("AlgorithmIdentifier长度: {} bytes", algorithmIdentifier.length);
// 计算 OCTET STRING 长度编码需要的字节数
int octetStringLengthBytes = 1; // tag
if (pkcs1Key.length > 127) {
if (pkcs1Key.length > 255) {
octetStringLengthBytes += 3; // 0x82 + 2 length bytes
} else {
octetStringLengthBytes += 2; // 0x81 + 1 length byte
}
} else {
octetStringLengthBytes += 1; // 1 length byte
}
// 计算 version INTEGER 编码 (3 bytes: tag + length + value)
int versionBytes = 3;
// 计算 total length of inner content (version + algorithmId + octetString)
int innerContentLength = versionBytes + algorithmIdentifier.length + octetStringLengthBytes + pkcs1Key.length;
// 计算 outer SEQUENCE 长度编码需要的字节数
int sequenceLengthBytes = 1; // tag
if (innerContentLength > 127) {
if (innerContentLength > 255) {
sequenceLengthBytes += 3; // 0x82 + 2 length bytes
} else {
sequenceLengthBytes += 2; // 0x81 + 1 length byte
}
} else {
sequenceLengthBytes += 1;
}
// 分配最终数组
int totalLen = sequenceLengthBytes + innerContentLength;
byte[] pkcs8Key = new byte[totalLen];
log.info("PKCS#8密钥分配: {} bytes", totalLen);
int offset = 0;
// outer SEQUENCE
pkcs8Key[offset++] = 0x30;
if (innerContentLength > 127) {
if (innerContentLength > 255) {
pkcs8Key[offset++] = (byte)0x82;
pkcs8Key[offset++] = (byte)((innerContentLength >> 8) & 0xFF);
pkcs8Key[offset++] = (byte)(innerContentLength & 0xFF);
} else {
pkcs8Key[offset++] = (byte)0x81;
pkcs8Key[offset++] = (byte)innerContentLength;
}
} else {
pkcs8Key[offset++] = (byte)innerContentLength;
}
// version = 0
pkcs8Key[offset++] = 0x02;
pkcs8Key[offset++] = 0x01;
pkcs8Key[offset++] = 0x00;
// algorithmIdentifier
System.arraycopy(algorithmIdentifier, 0, pkcs8Key, offset, algorithmIdentifier.length);
offset += algorithmIdentifier.length;
// privateKey as OCTET STRING
pkcs8Key[offset++] = 0x04;
if (pkcs1Key.length > 127) {
if (pkcs1Key.length > 255) {
pkcs8Key[offset++] = (byte)0x82;
pkcs8Key[offset++] = (byte)((pkcs1Key.length >> 8) & 0xFF);
pkcs8Key[offset++] = (byte)(pkcs1Key.length & 0xFF);
} else {
pkcs8Key[offset++] = (byte)0x81;
pkcs8Key[offset++] = (byte)pkcs1Key.length;
}
} else {
pkcs8Key[offset++] = (byte)pkcs1Key.length;
}
// 复制 PKCS#1 私钥
System.arraycopy(pkcs1Key, 0, pkcs8Key, offset, pkcs1Key.length);
log.info("PKCS#1到PKCS#8转换完成,最终长度: {} bytes", pkcs8Key.length);
return pkcs8Key;
} catch (Exception e) {
log.error("PKCS#1到PKCS#8转换失败", e);
throw new RuntimeException("密钥格式转换失败: " + e.getMessage(), e);
}
}
// ==================== 内部缓存类 ====================
private static class PaymentInfo {
String orderId;
Long memberId;
String orderType;
String tradeType;
String transAmt;
String goodsDesc;
String payStatus;
String outTransId;
String payUrl;
String hfSeqId;
LocalDateTime payTime;
String remark;
}
}
@@ -1 +0,0 @@
cn.novalon.gym.manage.payment.config.HuifuPayConfig
@@ -61,6 +61,7 @@ public final class RedisKeyConstants {
* appType: miniapp(小程序), mp(公众号)
*/
public static final String WECHAT_ACCESS_TOKEN = "wechat:access_token:";
<<<<<<< HEAD
// ==================== 认证模块 ====================
@@ -70,4 +71,6 @@ public final class RedisKeyConstants {
* 用途存储登录/注册等场景的短信验证码
*/
public static final String SMS_CODE = "sms:code:";
=======
>>>>>>> 08cf82a (签到模块)
}
@@ -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-已删除';
@@ -63,7 +63,7 @@ public class JwtAuthenticationFilter extends AbstractGatewayFilterFactory<JwtAut
path.equals("/api/auth/login") ||
path.equals("/api/groupCourse/page") ||
path.startsWith("/api/checkIn") ||
path.startsWith("/api/payment") ||
path.startsWith("/api/payment") ||
path.startsWith("/actuator/info");
}
@@ -1,4 +1,4 @@
package cn.novalon.gym.manage.sys.config;
package cn.novalon.gym.manage.sys.config;
import cn.novalon.gym.manage.sys.audit.OperationLogWebFilter;
import cn.novalon.gym.manage.sys.security.JwtAuthenticationFilter;
@@ -66,7 +66,6 @@ public class SecurityConfig {
.pathMatchers("/api/payment/notify").permitAll()
.pathMatchers("/api/payment/refund").permitAll();
if (isDevOrTest) {
spec.pathMatchers("/swagger-ui.html").permitAll()
.pathMatchers("/swagger-ui/**").permitAll()
-1
View File
@@ -46,7 +46,6 @@
<module>gym-groupCourse</module>
<module>gym-checkIn</module>
<module>gym-dataCount</module>
<module>gym-payment</module>
<module>gym-auth</module>
</modules>
@@ -1,6 +0,0 @@
Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.4.2
Build-Jdk-Spec: 21
Implementation-Title: Gym Payment
Implementation-Version: 1.0.0
@@ -1,3 +0,0 @@
artifactId=gym-payment
groupId=cn.novalon.gym.manage
version=1.0.0
@@ -1,85 +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>
<artifactId>gym-payment</artifactId>
<packaging>jar</packaging>
<name>Gym Payment</name>
<description>Payment Module - Integrates Huifu Payment Gateway</description>
<dependencies>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-common</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-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.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
</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>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -1 +0,0 @@
cn.novalon.gym.manage.payment.config.HuifuPayConfig