Author SHA1 Message Date
liwentao f1614c7d45 变基至dev,并修复配置 2026-06-26 14:13:57 +08:00
liwentao 886e2748d5 Merge remote-tracking branch 'origin/feature/manage-for-cuit' into feature/manage-for-cuit
# Conflicts:
#	gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java
2026-06-26 14:01:14 +08:00
liwentao 1a5aa9b3ef 新增答辩用后台管理系统 2026-06-26 13:57:13 +08:00
liwentao 0140bb0cc8 新增答辩用后台管理系统 2026-06-25 18:40:23 +08:00
future 35e7532f1b 解决bom字符 2026-06-25 01:22:33 +08:00
future 0d143be7b3 与dev重组登录 2026-06-25 01:12:42 +08:00
future 2ffd1aa7d6 修复项目构建 2026-06-24 23:08:53 +08:00
liwentao 581cc995c6 修复后端bug,更新测试数据 2026-06-24 07:27:44 +08:00
liwentao 2251f31524 新增支付宝连接沙箱模拟支付(暂不可用 2026-06-24 04:59:32 +08:00
futureandfujia 4481819eba 修复丢失文件 2026-06-24 00:46:47 +08:00
futureandfujia 79eb07599e feat: 添加签到模块和数据库表结构
- 新增gym-checkIn签到模块
- 创建sign_in_record签到记录表(V13迁移脚本)
- 添加签到相关路由配置
- 更新应用配置以支持签到模块
- 添加Redis密钥常量
2026-06-24 00:46:47 +08:00
futureandfujia a7f31083cf 签到模块 2026-06-24 00:46:47 +08:00
futureandfujia 524182c478 修复项目构建 2026-06-24 00:46:47 +08:00
future 3586a7d74b 登录 2026-06-23 22:47:35 +08:00
liwentao 8da58a8f51 新增功能:团课签到前验证是否到店签到 2026-06-18 10:48:23 +08:00
liwentao 0b2146f237 新增团课扫码签到功能,文档已更新 2026-06-18 10:11:47 +08:00
liwentao 7cc9a68144 新增团课推荐功能 2026-06-15 16:43:12 +08:00
liwentao b5c8a087dd 新增团课多条件查询 2026-06-15 14:09:19 +08:00
liwentao 7a94145819 新增团课类型,类型标签,以及相关功能 2026-06-11 13:57:46 +08:00
213 changed files with 25236 additions and 1394 deletions
+202
View File
@@ -0,0 +1,202 @@
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);
}
}
@@ -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 '归档时间';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,530 @@
# 团课推荐模块 API 文档
> **文档版本**: v1.0
> **创建日期**: 2026-06-15
> **作者**: 张翔
> **状态**: 正式发布
---
## 📋 目录
1. [概述](#概述)
2. [基础路径](#基础路径)
3. [团课推荐管理接口](#团课推荐管理接口)
- [获取所有团课推荐](#获取所有团课推荐)
- [获取所有启用的团课推荐](#获取所有启用的团课推荐)
- [根据ID获取团课推荐](#根据ID获取团课推荐)
- [根据团课ID获取推荐](#根据团课ID获取推荐)
- [创建团课推荐](#创建团课推荐)
- [更新团课推荐](#更新团课推荐)
- [删除团课推荐](#删除团课推荐)
- [启用团课推荐](#启用团课推荐)
- [禁用团课推荐](#禁用团课推荐)
4. [数据模型](#数据模型)
- [GroupCourseRecommend(团课推荐)](#GroupCourseRecommend团课推荐)
5. [状态码说明](#状态码说明)
6. [业务规则](#业务规则)
---
## 概述
团课推荐模块提供团课推荐信息的创建、编辑、查询、删除和状态管理功能。推荐信息包含团课ID、推荐标题、推荐内容、推荐理由、优先级等必要信息,支持按优先级排序展示。
## 基础路径
所有接口的基础路径为: `http://{host}:{port}/api/groupCourse/recommend`
---
## 团课推荐管理接口
### 获取所有团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/recommend/list` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**请求参数**:
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| sortBy | string | 否 | priority | 排序字段(支持:priority、createdAt、updatedAt |
| sortOrder | string | 否 | desc | 排序方式(asc-升序,desc-降序) |
**成功响应** (200 OK):
```json
[
{
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
"recommendReason": "教练专业,课程内容丰富",
"priority": 10,
"isActive": true,
"groupCourse": {
"id": 1,
"courseName": "瑜伽入门",
"coachId": 1,
"courseType": 1,
"startTime": "2026-06-15T09:00:00",
"endTime": "2026-06-15T10:00:00",
"maxMembers": 20,
"currentMembers": 15,
"status": 0,
"location": "健身房A区",
"coverImage": "https://example.com/yoga.jpg",
"description": "适合初学者的瑜伽课程"
},
"createdAt": "2026-06-15T10:00:00",
"updatedAt": "2026-06-15T10:00:00"
}
]
```
---
### 获取所有启用的团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/recommend/active` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**功能说明**: 获取系统中所有已启用的团课推荐列表,按优先级从高到低排序。
**成功响应** (200 OK):
```json
[
{
"id": 2,
"courseId": 3,
"recommendTitle": "新学员推荐",
"recommendContent": "专为新学员设计的入门课程,轻松上手",
"recommendReason": "零基础友好,教练耐心指导",
"priority": 20,
"isActive": true,
"groupCourse": {
"id": 3,
"courseName": "基础有氧",
"coachId": 2,
"courseType": 2,
"startTime": "2026-06-16T18:00:00",
"endTime": "2026-06-16T19:00:00",
"maxMembers": 30,
"currentMembers": 8,
"status": 0,
"location": "健身房B区",
"coverImage": "https://example.com/aerobic.jpg",
"description": "适合所有健身水平的有氧课程"
},
"createdAt": "2026-06-15T11:00:00",
"updatedAt": "2026-06-15T11:00:00"
}
]
```
---
### 根据ID获取团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/recommend/{id}` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课推荐ID |
**成功响应** (200 OK):
```json
{
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
"recommendReason": "教练专业,课程内容丰富",
"priority": 10,
"isActive": true,
"groupCourse": {
"id": 1,
"courseName": "瑜伽入门",
"coachId": 1,
"courseType": 1,
"startTime": "2026-06-15T09:00:00",
"endTime": "2026-06-15T10:00:00",
"maxMembers": 20,
"currentMembers": 15,
"status": 0,
"location": "健身房A区",
"coverImage": "https://example.com/yoga.jpg",
"description": "适合初学者的瑜伽课程"
},
"createdAt": "2026-06-15T10:00:00",
"updatedAt": "2026-06-15T10:00:00"
}
```
**失败响应** (404 Not Found):
```json
{}
```
---
### 根据团课ID获取推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/recommend/course/{courseId}` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| courseId | Long | 是 | 团课ID |
**成功响应** (200 OK):
```json
[
{
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
"recommendReason": "教练专业,课程内容丰富",
"priority": 10,
"isActive": true,
"groupCourse": {
"id": 1,
"courseName": "瑜伽入门",
"coachId": 1,
"courseType": 1,
"startTime": "2026-06-15T09:00:00",
"endTime": "2026-06-15T10:00:00",
"maxMembers": 20,
"currentMembers": 15,
"status": 0,
"location": "健身房A区"
},
"createdAt": "2026-06-15T10:00:00",
"updatedAt": "2026-06-15T10:00:00"
}
]
```
---
### 创建团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | POST |
| **接口路径** | `/api/groupCourse/recommend` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**请求体**:
```json
{
"courseId": 1,
"recommendTitle": "本周热门课程",
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
"recommendReason": "教练专业,课程内容丰富",
"priority": 10,
"isActive": true
}
```
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| courseId | Long | **是** | - | 团课ID(必须是有效的团课) |
| recommendTitle | String | 否 | - | 推荐标题 |
| recommendContent | String | 否 | - | 推荐内容 |
| recommendReason | String | 否 | - | 推荐理由 |
| priority | Integer | 否 | 0 | 优先级(数字越大优先级越高) |
| isActive | Boolean | 否 | true | 是否启用 |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课推荐创建成功",
"data": {
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
"recommendReason": "教练专业,课程内容丰富",
"priority": 10,
"isActive": true,
"createdAt": "2026-06-15T10:00:00",
"updatedAt": "2026-06-15T10:00:00"
}
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "团课ID不能为空"
}
```
---
### 更新团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | PUT |
| **接口路径** | `/api/groupCourse/recommend/{id}` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课推荐ID |
**请求体**:
```json
{
"recommendTitle": "本周热门课程(更新)",
"recommendContent": "更新后的推荐内容",
"recommendReason": "更新后的推荐理由",
"priority": 15,
"isActive": true
}
```
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| courseId | Long | 否 | 团课ID |
| recommendTitle | String | 否 | 推荐标题 |
| recommendContent | String | 否 | 推荐内容 |
| recommendReason | String | 否 | 推荐理由 |
| priority | Integer | 否 | 优先级 |
| isActive | Boolean | 否 | 是否启用 |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课推荐更新成功",
"data": {
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程(更新)",
"recommendContent": "更新后的推荐内容",
"recommendReason": "更新后的推荐理由",
"priority": 15,
"isActive": true,
"updatedAt": "2026-06-15T12:00:00"
}
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "团课推荐不存在"
}
```
---
### 删除团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | DELETE |
| **接口路径** | `/api/groupCourse/recommend/{id}` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课推荐ID |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课推荐删除成功"
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "团课推荐不存在"
}
```
---
### 启用团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | POST |
| **接口路径** | `/api/groupCourse/recommend/{id}/enable` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课推荐ID |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课推荐启用成功",
"data": {
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"isActive": true
}
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "团课推荐不存在"
}
```
---
### 禁用团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | POST |
| **接口路径** | `/api/groupCourse/recommend/{id}/disable` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课推荐ID |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课推荐禁用成功",
"data": {
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"isActive": false
}
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "团课推荐不存在"
}
```
---
## 数据模型
### GroupCourseRecommend(团课推荐)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| id | Long | 主键ID |
| courseId | Long | 团课ID(关联group_course.id |
| recommendTitle | String | 推荐标题 |
| recommendContent | String | 推荐内容 |
| recommendReason | String | 推荐理由 |
| priority | Integer | 优先级(数字越大优先级越高),默认0 |
| isActive | Boolean | 是否启用,默认true |
| groupCourse | GroupCourse | 关联的团课信息(查询时自动填充) |
| createdBy | String | 创建人 |
| updatedBy | String | 更新人 |
| createdAt | LocalDateTime | 创建时间 |
| updatedAt | LocalDateTime | 更新时间 |
| deletedAt | LocalDateTime | 删除时间(软删除) |
---
## 状态码说明
### 推荐状态
| 状态值 | 含义 |
|--------|------|
| true | 启用 |
| false | 禁用 |
---
## 业务规则
### 团课推荐管理
1. **创建推荐**:团课ID为必填项,且必须是有效的团课
2. **优先级排序**:获取启用的推荐列表时,按优先级从高到低排序
3. **删除推荐**:采用软删除机制,数据保留可恢复
4. **状态管理**:支持启用/禁用推荐状态,禁用的推荐不会在推荐列表中显示
---
## 附录:错误响应格式
所有接口的错误响应统一格式:
```json
{
"success": false,
"message": "错误描述信息"
}
```
---
*文档结束*
+87
View File
@@ -0,0 +1,87 @@
一、基础有氧与热身(难度 1-3
主要是低冲击、低技巧,用于建立运动基础。
慢走/椭圆机轻松模式:1(几乎无难度,适合所有人)
固定自行车(低阻力):2(注意座椅高度调节即可)
跑步机慢跑:3(需要基本协调性,膝盖有压力)
跳绳(连续基础跳):3(需要手脚配合,心肺要求明显)
二、固定器械训练(难度 2-5
轨迹固定,主要考验力量和耐力,技巧要求低。
坐姿腿屈伸/腿弯举:2(很容易找到发力感)
坐姿推胸机:3(需注意肩胛后收,避免耸肩)
高位下拉(坐姿):3(需控制不要过度后仰)
史密斯机深蹲:4(轨迹固定,但需保持核心稳定)
蝴蝶机夹胸:3(易用肘关节代偿,需锁定肩关节)
三、自重基础动作(难度 3-7
需要一定的力量-体重比和身体控制能力。
平板支撑:3(耐力考验,技巧低)
跪姿俯卧撑:3(上肢力量较弱者首选)
标准俯卧撑:5(需核心收紧,身体成直线)
引体向上(弹力带辅助):6(背部和手臂力量要求高)
标准引体向上:8(力量-体重比极高,多数男性无法完成1次)
徒手深蹲:3(注意膝盖方向与背部直立)
单腿深蹲(手枪蹲):8(需要极高下肢力量、柔韧性和平衡)
四、自由重量杠铃/哑铃(难度 5-9)
技巧风险最高,需要神经系统协调和长期动作打磨。
哑铃二头弯举:4(容易晃动借力,但较安全)
哑铃侧平举:5(极易用斜方肌代偿,真正练到三角肌中束很难)
杠铃卧推:7(肩关节压力大,起桥、沉肩、稳定手腕均有技巧,有压伤风险)
杠铃深蹲(颈后):8(全身协调性、核心抗压、杠位放置、呼吸模式,学习曲线陡峭)
传统硬拉:9(风险极高,需要精确的脊柱中立、髋铰链、背阔肌收紧,错误时伤腰)
高翻/抓举(奥运举重):10(需要爆发力、柔韧、精准衔接,非数月训练不能掌握)
五、高强度与爆发力(难度 6-10
对心肺、神经系统和恢复能力要求极高。
波比跳(标准版):6(连续做时心肺压力极大)
冲刺跑(短跑):7(对腘绳肌和脚踝爆发力要求高)
跳箱(合理高度):6(需要落地缓冲技巧)
负重雪橇推:6(主要考验腿部耐力和意志力)
双力臂(引体向上后翻腕上杠):9(需要爆发引体 + 极高相对力量)
六、柔韧与平衡类(难度 3-8
考验本体感觉和关节活动度。
静态拉伸(坐姿体前屈):2(无风险,但需要坚持)
瑜伽下犬式:3(常见,但需背部与手臂对齐)
单腿罗马尼亚硬拉(徒手):6(极考验平衡和髋稳定)
全深蹲(脚跟贴地,亚洲蹲):5(踝关节灵活度限制多数人)
竖叉/横叉:8(需要数月甚至数年拉伸)
+129
View File
@@ -0,0 +1,129 @@
<?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-auth</artifactId>
<packaging>jar</packaging>
<name>Gym Auth</name>
<description>Phone Authentication Module - Phone Number Login Services</description>
<dependencies>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-db</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>gym-member</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>
<!-- 阿里云号码认证服务(新版SDK -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dypnsapi20170525</artifactId>
<version>1.0.6</version>
</dependency>
<!-- 阿里云一键登录服务(旧版SDK,保留兼容) -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-dypnsapi</artifactId>
<version>1.2.12</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.6.0</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
<version>2.0.0</version>
</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>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,7 @@
package cn.novalon.gym.manage.auth.config;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AuthConfig {
}
@@ -0,0 +1,47 @@
package cn.novalon.gym.manage.auth.config;
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;
/**
* 短信验证码有效期(秒,默认300秒=5分钟)
*/
private long codeExpireSeconds = 300;
}
@@ -0,0 +1,26 @@
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 PhoneCodeLoginDto {
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
@NotBlank(message = "验证码不能为空")
@Pattern(regexp = "^\\d{4,6}$", message = "验证码格式不正确")
private String code;
}
@@ -0,0 +1,28 @@
package cn.novalon.gym.manage.auth.dto;
import jakarta.validation.constraints.NotBlank;
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;
private String nickname;
private String avatar;
}
@@ -0,0 +1,22 @@
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;
}
@@ -0,0 +1,25 @@
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;
}
@@ -0,0 +1,59 @@
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;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
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.Map;
@Slf4j
@Component
@RequiredArgsConstructor
@Tag(name = "手机号认证", description = "手机号一键登录与验证码登录")
public class PhoneAuthHandler {
private final PhoneAuthService phoneAuthService;
@Operation(summary = "手机号一键登录", description = "使用uniapp官方运营商认证,直接手机号登录或注册")
public Mono<ServerResponse> oneClickLogin(ServerRequest request) {
log.info("收到手机号一键登录请求");
return request.bodyToMono(PhoneLoginDto.class)
.flatMap(phoneAuthService::oneClickLogin)
.flatMap(response -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(response));
}
@Operation(summary = "发送短信验证码", description = "使用阿里云发送短信验证码")
public Mono<ServerResponse> sendSmsCode(ServerRequest request) {
log.info("收到发送短信验证码请求");
return request.bodyToMono(SendCodeRequest.class)
.flatMap(req -> phoneAuthService.sendSmsCode(req.getPhone()))
.flatMap(success -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("success", success, "message", success ? "验证码发送成功" : "验证码发送失败")));
}
@Operation(summary = "手机号验证码登录", description = "使用阿里云短信验证码登录或注册")
public Mono<ServerResponse> codeLogin(ServerRequest request) {
log.info("收到手机号验证码登录请求");
return request.bodyToMono(PhoneCodeLoginDto.class)
.flatMap(phoneAuthService::codeLogin)
.flatMap(response -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(response));
}
}
@@ -0,0 +1,37 @@
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.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);
}
@@ -0,0 +1,37 @@
package cn.novalon.gym.manage.auth.service;
import reactor.core.publisher.Mono;
/**
* 短信服务接口
*
* @author auto-generated
* @date 2026-06-20
*/
public interface SmsService {
/**
* 发送短信验证码
*
* @param phone 手机号
* @return 发送结果
*/
Mono<Boolean> sendVerificationCode(String phone);
/**
* 验证短信验证码
*
* @param phone 手机号
* @param code 验证码
* @return 验证结果
*/
Mono<Boolean> verifyCode(String phone, String code);
/**
* 获取验证码(用于测试或特殊场景)
*
* @param phone 手机号
* @return 验证码
*/
Mono<String> getVerificationCode(String phone);
}
@@ -0,0 +1,229 @@
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;
import cn.novalon.gym.manage.auth.service.SmsService;
import cn.novalon.gym.manage.auth.vo.PhoneLoginVO;
import cn.novalon.gym.manage.common.exception.ErrorCode;
import cn.novalon.gym.manage.common.exception.SystemException;
import cn.novalon.gym.manage.member.entity.Member;
import cn.novalon.gym.manage.member.es.entity.MemberES;
import cn.novalon.gym.manage.member.es.repository.MemberESRepository;
import cn.novalon.gym.manage.member.repository.IMemberRepository;
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;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class PhoneAuthServiceImpl implements PhoneAuthService {
private final IMemberRepository memberRepository;
private final MemberESRepository memberESRepository;
private final EsSyncUtils esSyncUtils;
private final JwtTokenProvider jwtTokenProvider;
private final SmsService smsService;
private final SmsProperties smsProperties;
private EsSyncUtils.EntitySyncer<Member, MemberES, String> memberSyncer;
@PostConstruct
public void init() {
this.memberSyncer = esSyncUtils.bind(Member.class, MemberES.class, memberESRepository);
}
@Override
public Mono<PhoneLoginVO> oneClickLogin(PhoneLoginDto request) {
log.info("手机号一键登录请求, accessToken: {}, openid: {}", request.getAccessToken(), request.getOpenid());
return Mono.fromCallable(() -> getPhoneByToken(request.getAccessToken(), request.getOpenid()))
.flatMap(phone -> {
log.info("通过access_token获取手机号成功: {}", maskPhone(phone));
String encryptedPhone = encryptPhone(phone);
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);
}));
})
.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);
}
@Override
public Mono<Boolean> sendSmsCode(String phone) {
log.info("发送短信验证码, phone: {}", phone);
return smsService.sendVerificationCode(phone);
}
@Override
public Mono<PhoneLoginVO> codeLogin(PhoneCodeLoginDto request) {
log.info("手机号验证码登录, phone: {}", request.getPhone());
return smsService.verifyCode(request.getPhone(), request.getCode())
.flatMap(verified -> {
if (!verified) {
log.warn("验证码验证失败, phone: {}", request.getPhone());
return Mono.error(new SystemException(ErrorCode.SYSTEM_INTERNAL_ERROR, "验证码错误或已过期"));
}
String encryptedPhone = encryptPhone(request.getPhone());
return memberRepository.findByPhone(encryptedPhone)
.flatMap(existingMember -> {
log.info("手机号已注册,直接登录, memberId: {}", existingMember.getId());
return doLogin(existingMember, false, null);
})
.switchIfEmpty(Mono.defer(() -> {
log.info("手机号未注册,创建新会员");
PhoneLoginDto registerRequest = new PhoneLoginDto();
return createNewMemberAndLogin(registerRequest, encryptedPhone);
}));
});
}
private Mono<PhoneLoginVO> createNewMemberAndLogin(PhoneLoginDto request, String encryptedPhone) {
String memberNo = MemberNoGenerator.generate();
log.info("生成会员号: {}", memberNo);
Member member = new Member();
member.setMemberNo(memberNo);
member.setPhone(encryptedPhone);
member.setNickname(request != null ? request.getNickname() : null);
member.setAvatar(request != null ? request.getAvatar() : null);
member.setLastLoginAt(LocalDateTime.now());
member.setIsDeleted(false);
return memberRepository.save(member)
.doOnSuccess(memberSyncer::sync)
.flatMap(savedMember -> {
log.info("新会员创建成功, memberId: {}, memberNo: {}", savedMember.getId(), savedMember.getMemberNo());
return doLogin(savedMember, true, request);
});
}
private Mono<PhoneLoginVO> doLogin(Member member, boolean isNewUser, PhoneLoginDto request) {
log.info("登录成功, memberId: {}", member.getId());
member.setLastLoginAt(LocalDateTime.now());
if (isNewUser && request != null) {
if ((member.getNickname() == null || member.getNickname().isEmpty())
&& request.getNickname() != null && !request.getNickname().isEmpty()) {
member.setNickname(request.getNickname());
}
if ((member.getAvatar() == null || member.getAvatar().isEmpty())
&& request.getAvatar() != null && !request.getAvatar().isEmpty()) {
member.setAvatar(request.getAvatar());
}
}
return memberRepository.save(member)
.doOnSuccess(memberSyncer::sync)
.map(this::buildLoginResponse);
}
private PhoneLoginVO buildLoginResponse(Member member) {
boolean needCompleteInfo = member.getNickname() == null || member.getNickname().isEmpty();
List<String> roles = new ArrayList<>();
String accessToken = jwtTokenProvider.generateToken(String.valueOf(member.getId()), member.getId(), roles);
log.info("JWT Token 生成成功, memberId: {}", member.getId());
PhoneLoginVO vo = new PhoneLoginVO();
vo.setMemberId(member.getId());
vo.setMemberNo(member.getMemberNo());
vo.setAccessToken(accessToken);
vo.setRefreshToken(accessToken);
vo.setExpiresIn(86400);
vo.setIsNewUser(member.getCreatedAt() == null ? false :
member.getCreatedAt().isAfter(LocalDateTime.now().minusMinutes(1)));
vo.setNeedCompleteInfo(needCompleteInfo);
vo.setNickname(member.getNickname());
vo.setAvatar(member.getAvatar());
vo.setPhone(member.getPhone() != null ? decryptPhone(member.getPhone()) : null);
return vo;
}
private String encryptPhone(String phoneNumber) {
try {
return AesUtil.encrypt(phoneNumber);
} catch (Exception e) {
log.error("手机号加密失败", e);
throw new SystemException(ErrorCode.SYSTEM_INTERNAL_ERROR, "手机号加密失败");
}
}
private String decryptPhone(String encryptedPhone) {
try {
return AesUtil.decrypt(encryptedPhone);
} catch (Exception e) {
log.error("手机号解密失败", e);
return null;
}
}
}
@@ -0,0 +1,198 @@
package cn.novalon.gym.manage.auth.service.impl;
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.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.LocalDateTime;
import java.time.ZoneOffset;
@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 Mono<Boolean> sendVerificationCode(String phone) {
log.info("发送短信验证码, phone: {}", phone);
String rateLimitKey = RedisKeyConstants.SMS_CODE + phone + ":rate_limit";
return redisUtil.get(rateLimitKey, Long.class)
.defaultIfEmpty(0L)
.flatMap(lastSendTime -> {
long currentTime = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);
if (currentTime - lastSendTime < SEND_INTERVAL_SECONDS) {
long remainingSeconds = SEND_INTERVAL_SECONDS - (currentTime - lastSendTime);
log.warn("发送频率限制, phone: {}, 剩余时间: {}秒", phone, remainingSeconds);
return Mono.just(false);
}
return Mono.fromCallable(() -> {
try {
IAcsClient client = createClient();
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");
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 Mono<Boolean> verifyCode(String phone, String code) {
log.info("验证短信验证码, phone: {}", phone);
return Mono.fromCallable(() -> {
try {
IAcsClient client = createClient();
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;
}
});
}
@Override
public Mono<String> getVerificationCode(String phone) {
try {
String cacheKey = RedisKeyConstants.SMS_CODE + phone;
return redisUtil.get(cacheKey, String.class);
} catch (Exception e) {
log.error("获取验证码异常, phone: {}", phone, e);
return Mono.empty();
}
}
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);
}
}
@@ -0,0 +1,36 @@
package cn.novalon.gym.manage.auth.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 手机号一键登录响应VO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PhoneLoginVO {
private Long memberId;
private String memberNo;
private String phone;
private String accessToken;
private String refreshToken;
private Integer expiresIn;
private Boolean isNewUser;
private Boolean needCompleteInfo;
private String nickname;
private String avatar;
}
@@ -0,0 +1,5 @@
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
+5
View File
@@ -118,6 +118,11 @@
<artifactId>hutool-all</artifactId> <artifactId>hutool-all</artifactId>
<version>5.8.25</version> <version>5.8.25</version>
</dependency> </dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency> <dependency>
<groupId>com.google.zxing</groupId> <groupId>com.google.zxing</groupId>
<artifactId>core</artifactId> <artifactId>core</artifactId>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<FindBugsFilter>
<Match>
<Class name="~.*\.entity\..*" />
</Match>
<Match>
<Class name="~.*\.dto\..*" />
</Match>
<Match>
<Class name="~.*\.converter\..*" />
</Match>
</FindBugsFilter>
@@ -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();
}
}
@@ -2,6 +2,8 @@ package cn.novalon.gym.manage.checkIn.handler;
import cn.novalon.gym.manage.checkIn.service.impl.CheckServiceImpl; import cn.novalon.gym.manage.checkIn.service.impl.CheckServiceImpl;
import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler; import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
import cn.novalon.gym.manage.checkIn.vo.SignInRecordVO;
import cn.novalon.gym.manage.checkIn.vo.SignInStatsVO;
import cn.novalon.gym.manage.sys.util.AuthUtil; import cn.novalon.gym.manage.sys.util.AuthUtil;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -12,6 +14,7 @@ import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
@@ -36,6 +39,7 @@ public class CheckInHandler {
public Mono<ServerResponse> checkIn(ServerRequest request) { public Mono<ServerResponse> checkIn(ServerRequest request) {
Long memberId = authUtil.getMemberIdOrThrow(request); Long memberId = authUtil.getMemberIdOrThrow(request);
return request.bodyToMono(Map.class) return request.bodyToMono(Map.class)
.flatMap(body -> { .flatMap(body -> {
String qrContent = (String) body.get("qrContent"); String qrContent = (String) body.get("qrContent");
@@ -190,4 +194,47 @@ public class CheckInHandler {
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "success", "data", stats))); .bodyValue(Map.of("code", 200, "message", "success", "data", stats)));
} }
/**
* 管理员查询所有签到记录(支持排序)
*
* GET /api/checkIn/admin/records
*/
public Mono<ServerResponse> getAllSignInRecords(ServerRequest request) {
String startDateStr = request.queryParam("startDate").orElse(null);
String endDateStr = request.queryParam("endDate").orElse(null);
String sortBy = request.queryParam("sortBy").orElse("signInTime");
String sortOrder = request.queryParam("sortOrder").orElse("desc");
LocalDate startDate = startDateStr != null ? LocalDate.parse(startDateStr, DATE_FORMATTER) : LocalDate.now().minusDays(30);
LocalDate endDate = endDateStr != null ? LocalDate.parse(endDateStr, DATE_FORMATTER) : LocalDate.now();
log.info("管理员查询所有签到记录, startDate: {}, endDate: {}, sortBy: {}, sortOrder: {}", startDate, endDate, sortBy, sortOrder);
return checkService.getAllSignInRecords(startDate, endDate, sortBy, sortOrder)
.collectList()
.flatMap(records -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "success", "data", records)));
}
/**
* 管理员查询签到统计(不限会员)
*
* GET /api/checkIn/admin/statistics
*/
public Mono<ServerResponse> getAllSignInStatistics(ServerRequest request) {
String startDateStr = request.queryParam("startDate").orElse(null);
String endDateStr = request.queryParam("endDate").orElse(null);
LocalDate startDate = startDateStr != null ? LocalDate.parse(startDateStr, DATE_FORMATTER) : LocalDate.now().minusDays(30);
LocalDate endDate = endDateStr != null ? LocalDate.parse(endDateStr, DATE_FORMATTER) : LocalDate.now();
log.info("管理员查询签到统计, startDate: {}, endDate: {}", startDate, endDate);
return checkService.getAllSignInStats(startDate, endDate)
.flatMap(stats -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "success", "data", stats)));
}
} }
@@ -57,6 +57,12 @@ public interface SignInRecordRepository extends R2dbcRepository<SignInRecord, Lo
@Query("SELECT * FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false ORDER BY sign_in_time DESC") @Query("SELECT * FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false ORDER BY sign_in_time DESC")
Flux<SignInRecord> findByTimeRange(LocalDateTime startTime, LocalDateTime endTime); Flux<SignInRecord> findByTimeRange(LocalDateTime startTime, LocalDateTime endTime);
/**
* 根据时间范围查询签到记录(支持动态排序)
*/
@Query("SELECT * FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false ORDER BY sign_in_time DESC")
Flux<SignInRecord> findByTimeRangeSorted(LocalDateTime startTime, LocalDateTime endTime);
/** /**
* 统计会员在时间范围内的签到次数 * 统计会员在时间范围内的签到次数
*/ */
@@ -78,4 +78,20 @@ public interface ICheckInService {
* @return 签到统计VO * @return 签到统计VO
*/ */
Mono<SignInStatsVO> getDailySignInStats(LocalDate date); Mono<SignInStatsVO> getDailySignInStats(LocalDate date);
/**
* 管理员查询所有签到记录(支持排序)
*
* @param startTime 开始时间
* @param endTime 结束时间
* @param sortBy 排序字段
* @param sortOrder 排序方向
* @return 签到记录列表(含会员姓名、卡类型)
*/
Flux<SignInRecordVO> getAllSignInRecords(LocalDate startTime, LocalDate endTime, String sortBy, String sortOrder);
/**
* 管理员查询签到统计(不限会员)
*/
Mono<SignInStatsVO> getAllSignInStats(LocalDate startTime, LocalDate endTime);
} }
@@ -2,6 +2,8 @@ package cn.novalon.gym.manage.checkIn.service.impl;
import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.bean.BeanUtil;
import cn.hutool.json.JSONUtil; import cn.hutool.json.JSONUtil;
import cn.hutool.extra.qrcode.QrCodeUtil;
import cn.hutool.extra.qrcode.QrConfig;
import cn.novalon.gym.manage.checkIn.config.QRCodeConfig; import cn.novalon.gym.manage.checkIn.config.QRCodeConfig;
import cn.novalon.gym.manage.checkIn.constant.QRRedisKey; import cn.novalon.gym.manage.checkIn.constant.QRRedisKey;
import cn.novalon.gym.manage.checkIn.entity.SignInRecord; import cn.novalon.gym.manage.checkIn.entity.SignInRecord;
@@ -14,13 +16,13 @@ import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
import cn.novalon.gym.manage.common.constant.RedisKeyConstants; import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
import cn.novalon.gym.manage.common.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
import cn.novalon.gym.manage.member.entity.Member;
import cn.novalon.gym.manage.member.entity.MemberCard; import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord; import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.enums.MemberCardType; import cn.novalon.gym.manage.member.enums.MemberCardType;
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository; import cn.novalon.gym.manage.member.repository.IMemberRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRepository; import cn.novalon.gym.manage.member.repository.MemberCardRepository;
import cn.hutool.extra.qrcode.QrCodeUtil; import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
import cn.hutool.extra.qrcode.QrConfig;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -47,6 +49,7 @@ public class CheckServiceImpl implements ICheckInService {
private final MemberCardRepository memberCardRepository; private final MemberCardRepository memberCardRepository;
private final SignInRecordRepository signInRecordRepository; private final SignInRecordRepository signInRecordRepository;
private final IGroupCourseBookingService groupCourseBookingService; private final IGroupCourseBookingService groupCourseBookingService;
private final IMemberRepository memberRepository;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@@ -430,6 +433,83 @@ public class CheckServiceImpl implements ICheckInService {
return vo; return vo;
} }
@Override
public Flux<SignInRecordVO> getAllSignInRecords(LocalDate startTime, LocalDate endTime, String sortBy, String sortOrder) {
LocalDateTime start = startTime.atStartOfDay();
LocalDateTime end = endTime.atTime(LocalTime.MAX);
Flux<SignInRecord> recordFlux = signInRecordRepository.findByTimeRangeSorted(start, end);
return recordFlux
.flatMap(record -> {
// fetch member name
Mono<String> memberNameMono = memberRepository.findById(record.getMemberId())
.map(Member::getNickname)
.defaultIfEmpty("未知");
// fetch card type name
Mono<String> cardTypeMono = record.getMemberCardId() != null
? memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(record.getMemberCardId())
.map(MemberCard::getMemberCardName)
.defaultIfEmpty("未知")
: Mono.just("-");
return Mono.zip(memberNameMono, cardTypeMono)
.map(tuple -> {
SignInRecordVO vo = convertToVO(record);
vo.setMemberName(tuple.getT1());
vo.setMemberCardType(tuple.getT2());
return vo;
});
})
.collectList()
.flatMapMany(list -> {
// in-memory sort
boolean asc = "asc".equalsIgnoreCase(sortOrder);
java.util.Comparator<SignInRecordVO> comparator;
switch (sortBy != null ? sortBy : "signInTime") {
case "id":
comparator = java.util.Comparator.comparing(SignInRecordVO::getId, java.util.Comparator.nullsLast(Long::compareTo));
break;
case "memberName":
comparator = java.util.Comparator.comparing(SignInRecordVO::getMemberName, java.util.Comparator.nullsLast(String::compareTo));
break;
case "signInTime":
default:
comparator = java.util.Comparator.comparing(SignInRecordVO::getSignInTime, java.util.Comparator.nullsLast(java.time.LocalDateTime::compareTo));
break;
}
if (!asc) {
comparator = comparator.reversed();
}
list.sort(comparator);
return Flux.fromIterable(list);
});
}
@Override
public Mono<SignInStatsVO> getAllSignInStats(LocalDate startTime, LocalDate endTime) {
LocalDateTime start = startTime.atStartOfDay();
LocalDateTime end = endTime.atTime(LocalTime.MAX);
return Mono.zip(
(Object[] results) -> {
Long total = (Long) results[0];
Long success = (Long) results[1];
Long members = (Long) results[2];
SignInStatsVO stats = new SignInStatsVO();
stats.setTotalCount(total);
stats.setSuccessCount(success);
stats.setStartDate(startTime);
stats.setEndDate(endTime);
stats.setUniqueMemberCount(members);
stats.setSuccessRate(total > 0 ? (double) success / total * 100.0 : 0.0);
return stats;
},
signInRecordRepository.countByTimeRange(start, end),
signInRecordRepository.countSuccessByTimeRange(start, end),
signInRecordRepository.countDistinctMembersByTimeRange(start, end)
);
}
private long getSecondsUntilEndOfDay() { private long getSecondsUntilEndOfDay() {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59); LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59);
@@ -59,6 +59,16 @@ public class SignInRecordVO {
*/ */
private String source; private String source;
/**
* 会员姓名(关联查询)
*/
private String memberName;
/**
* 会员卡类型名称(关联查询)
*/
private String memberCardType;
/** /**
* 创建时间 * 创建时间
*/ */
+14 -2
View File
@@ -100,8 +100,20 @@
<build> <build>
<plugins> <plugins>
<plugin> <plugin>
<groupId>org.springframework.boot</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <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> </plugin>
</plugins> </plugins>
</build> </build>
@@ -26,7 +26,7 @@ public class DataStatisticsDao {
* 统计指定时间范围内新增会员数 * 统计指定时间范围内新增会员数
*/ */
public Mono<Long> countNewMembers(LocalDateTime startTime, LocalDateTime endTime) { public Mono<Long> countNewMembers(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE created_at >= :startTime AND created_at < :endTime AND is_deleted = false") return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE created_at >= :startTime AND created_at < :endTime AND deleted_at IS NULL")
.bind("startTime", startTime) .bind("startTime", startTime)
.bind("endTime", endTime) .bind("endTime", endTime)
.map(row -> row.get(0, Long.class)) .map(row -> row.get(0, Long.class))
@@ -37,7 +37,7 @@ public class DataStatisticsDao {
* 统计总会员数 * 统计总会员数
*/ */
public Mono<Long> countTotalMembers() { public Mono<Long> countTotalMembers() {
return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE is_deleted = false") return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE deleted_at IS NULL")
.map(row -> row.get(0, Long.class)) .map(row -> row.get(0, Long.class))
.one(); .one();
} }
@@ -4,6 +4,8 @@ import cn.novalon.gym.manage.datacount.domain.*;
import cn.novalon.gym.manage.datacount.service.IDataStatisticsService; import cn.novalon.gym.manage.datacount.service.IDataStatisticsService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -25,6 +27,8 @@ import java.time.format.DateTimeFormatter;
@Tag(name = "数据统计", description = "数据统计相关操作") @Tag(name = "数据统计", description = "数据统计相关操作")
public class DataStatisticsHandler { public class DataStatisticsHandler {
private static final Logger log = LoggerFactory.getLogger(DataStatisticsHandler.class);
@Autowired @Autowired
private IDataStatisticsService dataStatisticsService; private IDataStatisticsService dataStatisticsService;
@@ -35,6 +39,7 @@ public class DataStatisticsHandler {
return dataStatisticsService.getStatisticsSummaryWithCache(query) return dataStatisticsService.getStatisticsSummaryWithCache(query)
.flatMap(summary -> ServerResponse.ok().bodyValue(summary)) .flatMap(summary -> ServerResponse.ok().bodyValue(summary))
.onErrorResume(e -> { .onErrorResume(e -> {
log.error("获取综合统计数据失败", e);
StatisticsSummary errorSummary = StatisticsSummary.builder() StatisticsSummary errorSummary = StatisticsSummary.builder()
.statDate(LocalDateTime.now().toLocalDate().toString()) .statDate(LocalDateTime.now().toLocalDate().toString())
.generatedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)) .generatedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
@@ -13,6 +13,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
@@ -22,7 +23,9 @@ import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters; import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@@ -173,9 +176,25 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
@Override @Override
public Mono<StatisticsSummary> getStatisticsSummary(StatisticsQuery query) { public Mono<StatisticsSummary> getStatisticsSummary(StatisticsQuery query) {
Mono<MemberStatistics> memberStatsMono = getMemberStatistics(query); String statDate = query.getStartTime() != null
Mono<BookingStatistics> bookingStatsMono = getBookingStatistics(query); ? query.getStartTime().toLocalDate().toString()
Mono<SignInStatistics> signInStatsMono = getSignInStatistics(query); : LocalDateTime.now().toLocalDate().toString();
Mono<MemberStatistics> memberStatsMono = getMemberStatistics(query)
.onErrorResume(e -> {
log.error("获取会员统计数据失败", e);
return Mono.just(MemberStatistics.builder().statDate(statDate).build());
});
Mono<BookingStatistics> bookingStatsMono = getBookingStatistics(query)
.onErrorResume(e -> {
log.error("获取预约统计数据失败", e);
return Mono.just(BookingStatistics.builder().statDate(statDate).build());
});
Mono<SignInStatistics> signInStatsMono = getSignInStatistics(query)
.onErrorResume(e -> {
log.error("获取签到统计数据失败", e);
return Mono.just(SignInStatistics.builder().statDate(statDate).build());
});
return Mono.zip(memberStatsMono, bookingStatsMono, signInStatsMono) return Mono.zip(memberStatsMono, bookingStatsMono, signInStatsMono)
.map(tuple -> StatisticsSummary.builder() .map(tuple -> StatisticsSummary.builder()
@@ -188,21 +207,75 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
} }
@Override @Override
public reactor.core.publisher.Flux<DataStatistics> queryHistoricalStatistics(StatisticsQuery query) { public Flux<DataStatistics> queryHistoricalStatistics(StatisticsQuery query) {
// 历史统计数据查询(从Redis缓存中获取) // 历史统计数据查询(从Redis缓存中获取)
String cacheKey = buildCacheKey(query); String cacheKey = buildCacheKey(query);
return redisUtil.get(cacheKey, String.class) return redisUtil.get(cacheKey, String.class)
.flatMapMany(json -> { .flatMapMany(json -> {
try { try {
java.util.List<DataStatistics> stats = objectMapper.readValue(json, List<DataStatistics> stats = objectMapper.readValue(json,
objectMapper.getTypeFactory().constructCollectionType(java.util.List.class, DataStatistics.class)); objectMapper.getTypeFactory().constructCollectionType(List.class, DataStatistics.class));
return reactor.core.publisher.Flux.fromIterable(stats); return Flux.fromIterable(stats);
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to parse historical statistics from cache", e); log.error("Failed to parse historical statistics from cache", e);
return reactor.core.publisher.Flux.empty(); return Flux.empty();
} }
}) })
.switchIfEmpty(reactor.core.publisher.Flux.empty()); .switchIfEmpty(buildLiveStatistics(query));
}
/**
* 缓存未命中时,从数据库实时构建统计数据
*/
private Flux<DataStatistics> buildLiveStatistics(StatisticsQuery query) {
LocalDateTime startTime = getStartTime(query);
LocalDateTime endTime = getEndTime(query);
String periodType = query.getPeriodType() != null ? query.getPeriodType() : "DAY";
LocalDateTime statDate = endTime;
Mono<Long> memberCountMono = dataStatisticsDao.countNewMembers(startTime, endTime);
Mono<Long> totalMembersMono = dataStatisticsDao.countTotalMembers();
Mono<Long> bookingCountMono = dataStatisticsDao.countBookings(startTime, endTime);
Mono<Long> signInCountMono = dataStatisticsDao.countSignIns(startTime, endTime);
return Mono.zip(memberCountMono, totalMembersMono, bookingCountMono, signInCountMono)
.flatMapMany(tuple -> {
long newMembers = tuple.getT1() != null ? tuple.getT1() : 0L;
long totalMembers = tuple.getT2() != null ? tuple.getT2() : 0L;
long bookings = tuple.getT3() != null ? tuple.getT3() : 0L;
long signIns = tuple.getT4() != null ? tuple.getT4() : 0L;
List<DataStatistics> list = new ArrayList<>();
DataStatistics memberStat = DataStatistics.builder()
.statType(DataStatistics.StatType.MEMBER)
.periodType(periodType)
.statDate(statDate)
.count(totalMembers)
.extraData("新增" + newMembers + "")
.build();
list.add(memberStat);
DataStatistics bookingStat = DataStatistics.builder()
.statType(DataStatistics.StatType.BOOKING)
.periodType(periodType)
.statDate(statDate)
.count(bookings)
.extraData("新增" + bookings + "条预约")
.build();
list.add(bookingStat);
DataStatistics signInStat = DataStatistics.builder()
.statType(DataStatistics.StatType.SIGN_IN)
.periodType(periodType)
.statDate(statDate)
.count(signIns)
.extraData("新增" + signIns + "次签到")
.build();
list.add(signInStat);
return Flux.fromIterable(list);
});
} }
@Override @Override
+21 -2
View File
@@ -81,13 +81,32 @@
<artifactId>gym-member</artifactId> <artifactId>gym-member</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<!-- ZXing QR Code依赖 -->
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<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>
</dependency>
</dependencies> </dependencies>
<build> <build>
<plugins> <plugins>
<plugin> <plugin>
<groupId>org.springframework.boot</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
</plugin> </plugin>
</plugins> </plugins>
</build> </build>
@@ -4,8 +4,10 @@ package cn.novalon.gym.manage.groupcourse.converter;
import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.bean.BeanUtil;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseBookingEntity; import cn.novalon.gym.manage.groupcourse.entity.GroupCourseBookingEntity;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity; import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseTypeEntity;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -124,4 +126,33 @@ public class GroupCourseConverter {
.map(this::toBookingEntity) .map(this::toBookingEntity)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
/**
* 将团课类型实体转换为领域模型
*/
public GroupCourseType toGroupCourseType(GroupCourseTypeEntity entity){
if(entity == null){
return null;
}
GroupCourseType groupCourseType = new GroupCourseType();
BeanUtil.copyProperties(entity, groupCourseType);
log.debug("转换团课类型实体到领域模型:typeId={}", entity.getId());
return groupCourseType;
}
/**
* 将团课类型领域模型转换为实体
*/
public GroupCourseTypeEntity toGroupCourseTypeEntity(GroupCourseType domain){
if(domain == null){
return null;
}
GroupCourseTypeEntity entity = new GroupCourseTypeEntity();
BeanUtil.copyProperties(domain, entity);
if (domain.getId() != null) {
entity.markNotNew();
}
log.debug("转换团课类型领域模型到实体:typeId={}", domain.getId());
return entity;
}
} }
@@ -0,0 +1,27 @@
package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.groupcourse.entity.CourseLabelEntity;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
public interface CourseLabelDao extends R2dbcRepository<CourseLabelEntity, Long> {
Mono<CourseLabelEntity> findByIdIsAndDeletedAtIsNull(Long id);
Flux<CourseLabelEntity> findAllByDeletedAtIsNull();
Flux<CourseLabelEntity> findByLabelNameContainingAndDeletedAtIsNull(String labelName);
Mono<CourseLabelEntity> findByLabelNameAndDeletedAtIsNull(String labelName);
@Modifying
@Query("UPDATE course_label SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
}
@@ -0,0 +1,38 @@
package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.groupcourse.entity.CourseTypeLabelEntity;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface CourseTypeLabelDao extends R2dbcRepository<CourseTypeLabelEntity, Long> {
Flux<CourseTypeLabelEntity> findByTypeIdAndDeletedAtIsNull(Long typeId);
Flux<CourseTypeLabelEntity> findByLabelIdAndDeletedAtIsNull(Long labelId);
Mono<CourseTypeLabelEntity> findByTypeIdAndLabelIdAndDeletedAtIsNull(Long typeId, Long labelId);
@Modifying
@Query("UPDATE course_type_label SET deleted_at = :deletedAt WHERE type_id = :typeId AND label_id = :labelId")
Mono<Integer> deleteByTypeIdAndLabelId(Long typeId, Long labelId, LocalDateTime deletedAt);
@Modifying
@Query("UPDATE course_type_label SET deleted_at = :deletedAt WHERE type_id = :typeId")
Mono<Integer> deleteByTypeId(Long typeId, LocalDateTime deletedAt);
@Modifying
@Query("DELETE FROM course_type_label WHERE type_id = :typeId AND label_id = :labelId")
Mono<Integer> physicalDeleteByTypeIdAndLabelId(Long typeId, Long labelId);
@Modifying
@Query("UPDATE course_type_label SET deleted_at = :deletedAt WHERE label_id = :labelId")
Mono<Integer> deleteByLabelId(Long labelId, LocalDateTime deletedAt);
}
@@ -1,16 +1,19 @@
package cn.novalon.gym.manage.groupcourse.dao; package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity; import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.repository.Modifying; import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query; import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository; import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Repository @Repository
public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long> { public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long> {
@@ -36,4 +39,195 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
@Modifying @Modifying
@Query("UPDATE group_course SET deleted_at = :deletedAt WHERE id = :id") @Query("UPDATE group_course SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt); Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
@Modifying
@Query("UPDATE group_course SET deleted_at = NULL, status = '1', updated_at = :updatedAt WHERE id = :id AND deleted_at IS NOT NULL")
Mono<Integer> restoreCourse(Long id, LocalDateTime updatedAt);
@Modifying
@Query("UPDATE group_course SET status = '2', updated_at = :updatedAt WHERE status = '0' AND end_time <= NOW() AND deleted_at IS NULL")
Mono<Integer> completeExpiredCourses(LocalDateTime updatedAt);
Flux<GroupCourseEntity> findByCourseTypeAndDeletedAtIsNull(Long courseType);
/**
* 多条件查询团课(使用 DatabaseClient 构建动态 SQL
*/
default Flux<GroupCourseEntity> searchGroupCourses(DatabaseClient databaseClient, GroupCourseQueryDto query) {
StringBuilder sql = new StringBuilder("SELECT * FROM group_course WHERE deleted_at IS NULL");
List<String> conditions = new ArrayList<>();
// 默认不查询可预约团课(status = '0' 且未过期)
conditions.add("status = '0'");
conditions.add("end_time > NOW()");
// 1. 团课名称模糊查询
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
conditions.add("course_name ILIKE :courseName");
}
// 2. 基于团课类型查询
if (query.getCourseType() != null) {
conditions.add("course_type = :courseType");
}
// 3. 基于日期时间段查询
if (query.getStartDate() != null) {
conditions.add("start_time >= :startDate");
}
if (query.getEndDate() != null) {
conditions.add("start_time <= :endDate");
}
// 4. 基于早晨/下午/夜晚时间段查询
if (query.getTimePeriod() != null && !query.getTimePeriod().isEmpty()) {
switch (query.getTimePeriod().toLowerCase()) {
case "morning":
conditions.add("EXTRACT(HOUR FROM start_time) >= 6 AND EXTRACT(HOUR FROM start_time) < 12");
break;
case "afternoon":
conditions.add("EXTRACT(HOUR FROM start_time) >= 12 AND EXTRACT(HOUR FROM start_time) < 18");
break;
case "evening":
conditions.add("EXTRACT(HOUR FROM start_time) >= 18 AND EXTRACT(HOUR FROM start_time) < 24");
break;
default:
break;
}
}
sql.append(" AND ").append(String.join(" AND ", conditions));
// 5. 价格排序 / 6. 剩余名额最多排序
boolean hasPriceSort = query.getPriceSort() != null && !query.getPriceSort().isEmpty();
boolean hasRemainingMost = query.getRemainingMost() != null && query.getRemainingMost();
if (hasPriceSort || hasRemainingMost) {
sql.append(" ORDER BY");
List<String> orderClauses = new ArrayList<>();
if (hasRemainingMost) {
orderClauses.add(" (max_members - current_members) DESC");
}
if (hasPriceSort) {
if ("asc".equalsIgnoreCase(query.getPriceSort())) {
orderClauses.add(" stored_value_amount ASC");
} else if ("desc".equalsIgnoreCase(query.getPriceSort())) {
orderClauses.add(" stored_value_amount DESC");
}
}
sql.append(String.join(",", orderClauses));
} else {
sql.append(" ORDER BY start_time ASC");
}
// 分页
int page = query.getPage() != null ? query.getPage() : 0;
int size = query.getSize() != null ? query.getSize() : 10;
if (size < 1) size = 10;
if (size > 100) size = 100;
int offset = page * size;
sql.append(" LIMIT :limit OFFSET :offset");
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
spec = spec.bind("courseName", "%" + query.getCourseName() + "%");
}
if (query.getCourseType() != null) {
spec = spec.bind("courseType", query.getCourseType());
}
if (query.getStartDate() != null) {
spec = spec.bind("startDate", query.getStartDate());
}
if (query.getEndDate() != null) {
spec = spec.bind("endDate", query.getEndDate());
}
spec = spec.bind("limit", size);
spec = spec.bind("offset", offset);
return spec.map((row, meta) -> {
GroupCourseEntity entity = new GroupCourseEntity();
entity.setId(row.get("id", Long.class));
entity.setCourseName(row.get("course_name", String.class));
entity.setCoachId(row.get("coach_id", Long.class));
entity.setCourseType(row.get("course_type", Long.class));
entity.setStartTime(row.get("start_time", LocalDateTime.class));
entity.setEndTime(row.get("end_time", LocalDateTime.class));
entity.setMaxMembers(row.get("max_members", Integer.class));
entity.setCurrentMembers(row.get("current_members", Integer.class));
String statusStr = row.get("status", String.class);
entity.setStatus(statusStr != null ? Long.parseLong(statusStr) : null);
entity.setLocation(row.get("location", String.class));
entity.setCoverImage(row.get("cover_image", String.class));
entity.setDescription(row.get("description", String.class));
entity.setStoredValueAmount(row.get("stored_value_amount", java.math.BigDecimal.class));
entity.setCreateBy(row.get("create_by", String.class));
entity.setUpdateBy(row.get("update_by", String.class));
entity.setCreatedAt(row.get("created_at", LocalDateTime.class));
entity.setUpdatedAt(row.get("updated_at", LocalDateTime.class));
entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class));
return entity;
}).all();
}
/**
* 多条件查询团课总数
*/
default Mono<Long> countSearchGroupCourses(DatabaseClient databaseClient, GroupCourseQueryDto query) {
StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM group_course WHERE deleted_at IS NULL");
List<String> conditions = new ArrayList<>();
conditions.add("status = '0'");
conditions.add("end_time > NOW()");
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
conditions.add("course_name ILIKE :courseName");
}
if (query.getCourseType() != null) {
conditions.add("course_type = :courseType");
}
if (query.getStartDate() != null) {
conditions.add("start_time >= :startDate");
}
if (query.getEndDate() != null) {
conditions.add("start_time <= :endDate");
}
if (query.getTimePeriod() != null && !query.getTimePeriod().isEmpty()) {
switch (query.getTimePeriod().toLowerCase()) {
case "morning":
conditions.add("EXTRACT(HOUR FROM start_time) >= 6 AND EXTRACT(HOUR FROM start_time) < 12");
break;
case "afternoon":
conditions.add("EXTRACT(HOUR FROM start_time) >= 12 AND EXTRACT(HOUR FROM start_time) < 18");
break;
case "evening":
conditions.add("EXTRACT(HOUR FROM start_time) >= 18 AND EXTRACT(HOUR FROM start_time) < 24");
break;
default:
break;
}
}
sql.append(" AND ").append(String.join(" AND ", conditions));
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
spec = spec.bind("courseName", "%" + query.getCourseName() + "%");
}
if (query.getCourseType() != null) {
spec = spec.bind("courseType", query.getCourseType());
}
if (query.getStartDate() != null) {
spec = spec.bind("startDate", query.getStartDate());
}
if (query.getEndDate() != null) {
spec = spec.bind("endDate", query.getEndDate());
}
return spec.map((row, meta) -> row.get(0, Long.class)).one();
}
} }
@@ -0,0 +1,42 @@
package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseRecommendEntity;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
public interface GroupCourseRecommendDao extends R2dbcRepository<GroupCourseRecommendEntity, Long> {
Mono<GroupCourseRecommendEntity> findByIdAndDeletedAtIsNull(Long id);
Flux<GroupCourseRecommendEntity> findAllByDeletedAtIsNull();
Flux<GroupCourseRecommendEntity> findAllByDeletedAtIsNull(Sort sort);
Flux<GroupCourseRecommendEntity> findByCourseIdAndDeletedAtIsNull(Long courseId);
Mono<GroupCourseRecommendEntity> findByCourseIdAndDeletedAtIsNullAndIsActiveTrue(Long courseId);
Flux<GroupCourseRecommendEntity> findByIsActiveTrueAndDeletedAtIsNull();
Flux<GroupCourseRecommendEntity> findByIsActiveTrueAndDeletedAtIsNull(Sort sort);
@Modifying
@Query("UPDATE group_course_recommend SET is_active = :isActive, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateActiveStatus(Long id, Boolean isActive, LocalDateTime updatedAt);
@Modifying
@Query("UPDATE group_course_recommend SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
@Modifying
@Query("UPDATE group_course_recommend SET deleted_at = :deletedAt WHERE course_id = :courseId")
Mono<Integer> softDeleteByCourseId(Long courseId, LocalDateTime deletedAt);
}
@@ -0,0 +1,36 @@
package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseTypeEntity;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
public interface GroupCourseTypeDao extends R2dbcRepository<GroupCourseTypeEntity, Long> {
Mono<GroupCourseTypeEntity> findByIdIsAndDeletedAtIsNull(Long id);
Flux<GroupCourseTypeEntity> findAllByDeletedAtIsNull();
Flux<GroupCourseTypeEntity> findAllByDeletedAtIsNull(Sort sort);
Flux<GroupCourseTypeEntity> findByTypeNameContainingAndDeletedAtIsNull(String typeName);
Flux<GroupCourseTypeEntity> findByCategoryAndDeletedAtIsNull(String category);
Mono<GroupCourseTypeEntity> findByTypeNameAndDeletedAtIsNull(String typeName);
@Modifying
@Query("UPDATE group_course_type SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
@Modifying
@Query("UPDATE group_course_type SET type_name = :typeName, base_difficulty = :baseDifficulty, description = :description, category = :category, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateFields(Long id, String typeName, Integer baseDifficulty, String description, String category, LocalDateTime updatedAt);
}
@@ -0,0 +1,43 @@
package cn.novalon.gym.manage.groupcourse.domain;
import cn.novalon.gym.manage.sys.core.domain.BaseDomain;
import io.swagger.v3.oas.annotations.media.Schema;
public class CourseLabel extends BaseDomain {
//标签名称
@Schema(description = "标签名称", example = "适合新手")
private String labelName;
//标签颜色(十六进制)
@Schema(description = "标签颜色(十六进制)", example = "#52c41a")
private String color;
//标签描述
@Schema(description = "标签描述", example = "适合健身初学者")
private String description;
public String getLabelName() {
return labelName;
}
public void setLabelName(String labelName) {
this.labelName = labelName;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
@@ -52,14 +52,14 @@ public class GroupCourse extends BaseDomain{
@Schema(description = "课程描述", example = "从入门到入土") @Schema(description = "课程描述", example = "从入门到入土")
private String description; private String description;
//点卡额度(消耗次数)
@Schema(description = "点卡额度(消耗次数)", example = "1")
private Integer pointCardAmount;
//储值卡额度(消耗金额) //储值卡额度(消耗金额)
@Schema(description = "储值卡额度(消耗金额)", example = "50.00") @Schema(description = "储值卡额度(消耗金额)", example = "50.00")
private java.math.BigDecimal storedValueAmount; private java.math.BigDecimal storedValueAmount;
//二维码路径
@Schema(description = "二维码路径", example = "D:\\Games\\exmp\\image\\abc123_20260618120000.png")
private String qrCodePath;
public String getCourseName() { public String getCourseName() {
return courseName; return courseName;
} }
@@ -148,14 +148,6 @@ public class GroupCourse extends BaseDomain{
this.description = description; this.description = description;
} }
public Integer getPointCardAmount() {
return pointCardAmount;
}
public void setPointCardAmount(Integer pointCardAmount) {
this.pointCardAmount = pointCardAmount;
}
public java.math.BigDecimal getStoredValueAmount() { public java.math.BigDecimal getStoredValueAmount() {
return storedValueAmount; return storedValueAmount;
} }
@@ -163,4 +155,12 @@ public class GroupCourse extends BaseDomain{
public void setStoredValueAmount(java.math.BigDecimal storedValueAmount) { public void setStoredValueAmount(java.math.BigDecimal storedValueAmount) {
this.storedValueAmount = storedValueAmount; this.storedValueAmount = storedValueAmount;
} }
public String getQrCodePath() {
return qrCodePath;
}
public void setQrCodePath(String qrCodePath) {
this.qrCodePath = qrCodePath;
}
} }
@@ -0,0 +1,254 @@
package cn.novalon.gym.manage.groupcourse.domain;
import cn.novalon.gym.manage.sys.core.domain.BaseDomain;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 团课完整信息领域模型
* 包含团课基础信息、关联的类型信息以及类型的标签信息
*/
public class GroupCourseDetail extends BaseDomain {
// ===== 团课基础信息 =====
@Schema(description = "课程名称", example = "瑜伽入门")
private String courseName;
@Schema(description = "教练ID", example = "1")
private Long coachId;
@Schema(description = "课程类型ID", example = "1")
private Long courseType;
@Schema(description = "开始时间", example = "2026-06-02T09:00:00")
private LocalDateTime startTime;
@Schema(description = "结束时间", example = "2026-06-02T10:00:00")
private LocalDateTime endTime;
@Schema(description = "最大参与人数", example = "20")
private Integer maxMembers;
@Schema(description = "当前参与人数", example = "15")
private Integer currentMembers;
@Schema(description = "课程状态", example = "0")
private Long status;
@Schema(description = "上课地点", example = "健身房A区")
private String location;
@Schema(description = "封面图URL", example = "https://example.com/yoga.jpg")
private String coverImage;
@Schema(description = "课程描述", example = "适合初学者的瑜伽课程")
private String description;
@Schema(description = "储值卡额度(消耗金额)", example = "50.00")
private BigDecimal storedValueAmount;
@Schema(description = "二维码路径", example = "D:\\Games\\exmp\\image\\abc123_20260618120000.png")
private String qrCodePath;
// ===== 关联的类型信息 =====
@Schema(description = "类型信息")
private GroupCourseType typeInfo;
// ===== 快捷访问属性(从类型信息派生)=====
@Schema(description = "类型名称", example = "瑜伽入门")
private String typeName;
@Schema(description = "类型分类", example = "柔韧与平衡类")
private String typeCategory;
@Schema(description = "基础难度", example = "2")
private Integer baseDifficulty;
@Schema(description = "难度等级描述", example = "初级")
private String difficultyLevel;
@Schema(description = "综合难度系数", example = "2")
private Integer calculatedDifficulty;
// ===== 标签信息(从类型标签派生)=====
@Schema(description = "标签列表")
private List<CourseLabel> labels;
// ===== Getters and Setters =====
public String getCourseName() {
return courseName;
}
public void setCourseName(String courseName) {
this.courseName = courseName;
}
public Long getCoachId() {
return coachId;
}
public void setCoachId(Long coachId) {
this.coachId = coachId;
}
public Long getCourseType() {
return courseType;
}
public void setCourseType(Long courseType) {
this.courseType = courseType;
}
public LocalDateTime getStartTime() {
return startTime;
}
public void setStartTime(LocalDateTime startTime) {
this.startTime = startTime;
}
public LocalDateTime getEndTime() {
return endTime;
}
public void setEndTime(LocalDateTime endTime) {
this.endTime = endTime;
}
public Integer getMaxMembers() {
return maxMembers;
}
public void setMaxMembers(Integer maxMembers) {
this.maxMembers = maxMembers;
}
public Integer getCurrentMembers() {
return currentMembers;
}
public void setCurrentMembers(Integer currentMembers) {
this.currentMembers = currentMembers;
}
public Long getStatus() {
return status;
}
public void setStatus(Long status) {
this.status = status;
}
public String getLocation() {
return location;
}
public void setLocation(String location) {
this.location = location;
}
public String getCoverImage() {
return coverImage;
}
public void setCoverImage(String coverImage) {
this.coverImage = coverImage;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public BigDecimal getStoredValueAmount() {
return storedValueAmount;
}
public void setStoredValueAmount(BigDecimal storedValueAmount) {
this.storedValueAmount = storedValueAmount;
}
public String getQrCodePath() {
return qrCodePath;
}
public void setQrCodePath(String qrCodePath) {
this.qrCodePath = qrCodePath;
}
public GroupCourseType getTypeInfo() {
return typeInfo;
}
public void setTypeInfo(GroupCourseType typeInfo) {
this.typeInfo = typeInfo;
// 同步派生属性
if (typeInfo != null) {
this.typeName = typeInfo.getTypeName();
this.typeCategory = typeInfo.getCategory();
this.baseDifficulty = typeInfo.getBaseDifficulty();
this.difficultyLevel = typeInfo.getDifficultyLevel();
this.calculatedDifficulty = typeInfo.getCalculatedDifficulty();
this.labels = typeInfo.getLabels();
}
}
public String getTypeName() {
return typeName;
}
public void setTypeName(String typeName) {
this.typeName = typeName;
}
public String getTypeCategory() {
return typeCategory;
}
public void setTypeCategory(String typeCategory) {
this.typeCategory = typeCategory;
}
public Integer getBaseDifficulty() {
return baseDifficulty;
}
public void setBaseDifficulty(Integer baseDifficulty) {
this.baseDifficulty = baseDifficulty;
}
public String getDifficultyLevel() {
return difficultyLevel;
}
public void setDifficultyLevel(String difficultyLevel) {
this.difficultyLevel = difficultyLevel;
}
public Integer getCalculatedDifficulty() {
return calculatedDifficulty;
}
public void setCalculatedDifficulty(Integer calculatedDifficulty) {
this.calculatedDifficulty = calculatedDifficulty;
}
public List<CourseLabel> getLabels() {
return labels;
}
public void setLabels(List<CourseLabel> labels) {
this.labels = labels;
}
}
@@ -0,0 +1,84 @@
package cn.novalon.gym.manage.groupcourse.domain;
import cn.novalon.gym.manage.sys.core.domain.BaseDomain;
import io.swagger.v3.oas.annotations.media.Schema;
public class GroupCourseRecommend extends BaseDomain {
@Schema(description = "团课ID", example = "1")
private Long courseId;
@Schema(description = "推荐标题", example = "本周热门课程")
private String recommendTitle;
@Schema(description = "推荐内容", example = "这是一门非常棒的课程,快来参加吧!")
private String recommendContent;
@Schema(description = "推荐理由", example = "教练专业,课程内容丰富")
private String recommendReason;
@Schema(description = "优先级(数字越大优先级越高)", example = "10")
private Integer priority;
@Schema(description = "是否启用", example = "true")
private Boolean isActive;
@Schema(description = "团课信息")
private GroupCourse groupCourse;
public Long getCourseId() {
return courseId;
}
public void setCourseId(Long courseId) {
this.courseId = courseId;
}
public String getRecommendTitle() {
return recommendTitle;
}
public void setRecommendTitle(String recommendTitle) {
this.recommendTitle = recommendTitle;
}
public String getRecommendContent() {
return recommendContent;
}
public void setRecommendContent(String recommendContent) {
this.recommendContent = recommendContent;
}
public String getRecommendReason() {
return recommendReason;
}
public void setRecommendReason(String recommendReason) {
this.recommendReason = recommendReason;
}
public Integer getPriority() {
return priority;
}
public void setPriority(Integer priority) {
this.priority = priority;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public GroupCourse getGroupCourse() {
return groupCourse;
}
public void setGroupCourse(GroupCourse groupCourse) {
this.groupCourse = groupCourse;
}
}
@@ -0,0 +1,113 @@
package cn.novalon.gym.manage.groupcourse.domain;
import cn.novalon.gym.manage.sys.core.domain.BaseDomain;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.ArrayList;
import java.util.List;
public class GroupCourseType extends BaseDomain {
//类型名称
@Schema(description = "类型名称", example = "瑜伽入门")
private String typeName;
//基础难度(1-10
@Schema(description = "基础难度(1-10", example = "2")
private Integer baseDifficulty;
//类型描述
@Schema(description = "类型描述", example = "适合初学者的瑜伽课程")
private String description;
//分类(如:有氧、力量、柔韧等)
@Schema(description = "分类", example = "柔韧与平衡类")
private String category;
//标签列表
@Schema(description = "标签列表")
private List<CourseLabel> labels = new ArrayList<>();
/**
* 计算综合难度系数
*
* 当前实现仅返回基础难度,为后续扩展预留空间。
* 未来可扩展的影响因素包括:
* 1. 课程时长系数(时长越长难度越高)
* 2. 教练难度调整系数(教练可根据实际情况微调)
* 3. 会员等级适配系数(根据会员等级动态调整显示难度)
* 4. 课程强度系数(高强度课程难度加成)
*
* @return 综合难度系数(1-10
*/
@Schema(description = "综合难度系数(预留扩展字段)", example = "2")
public Integer getCalculatedDifficulty() {
// TODO: 预留扩展点 - 未来可在此处添加更多难度计算逻辑
// 例如:return calculateDynamicDifficulty(baseDifficulty, additionalFactors...);
return this.baseDifficulty != null ? this.baseDifficulty : 1;
}
/**
* 获取难度等级描述
* 将数字难度转换为友好的文字描述
*
* @return 难度等级描述
*/
@Schema(description = "难度等级描述", example = "初级")
public String getDifficultyLevel() {
if (baseDifficulty == null) {
return "未知";
}
if (baseDifficulty <= 2) {
return "初级";
} else if (baseDifficulty <= 4) {
return "中级";
} else if (baseDifficulty <= 6) {
return "中高级";
} else if (baseDifficulty <= 8) {
return "高级";
} else {
return "专家级";
}
}
public String getTypeName() {
return typeName;
}
public void setTypeName(String typeName) {
this.typeName = typeName;
}
public Integer getBaseDifficulty() {
return baseDifficulty;
}
public void setBaseDifficulty(Integer baseDifficulty) {
this.baseDifficulty = baseDifficulty;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public List<CourseLabel> getLabels() {
return labels;
}
public void setLabels(List<CourseLabel> labels) {
this.labels = labels;
}
}
@@ -0,0 +1,116 @@
package cn.novalon.gym.manage.groupcourse.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
/**
* 团课多条件查询请求DTO
*
* @author 张翔
* @date 2026-06-14
*/
@Schema(description = "团课多条件查询参数")
public class GroupCourseQueryDto {
@Schema(description = "课程名称(模糊查询)", example = "瑜伽")
private String courseName;
@Schema(description = "课程类型ID", example = "1")
private Long courseType;
@Schema(description = "查询开始日期", example = "2026-06-01T00:00:00")
private LocalDateTime startDate;
@Schema(description = "查询结束日期", example = "2026-06-30T23:59:59")
private LocalDateTime endDate;
@Schema(description = "时间段:morning-早晨(6:00-12:00), afternoon-下午(12:00-18:00), evening-夜晚(18:00-24:00)", example = "morning")
private String timePeriod;
@Schema(description = "价格排序:asc-从低到高, desc-从高到低", example = "asc")
private String priceSort;
@Schema(description = "按剩余名额最多排序", example = "true")
private Boolean remainingMost;
@Schema(description = "页码", example = "0")
private Integer page = 0;
@Schema(description = "每页大小", example = "10")
private Integer size = 10;
// ===== Getters and Setters =====
public String getCourseName() {
return courseName;
}
public void setCourseName(String courseName) {
this.courseName = courseName;
}
public Long getCourseType() {
return courseType;
}
public void setCourseType(Long courseType) {
this.courseType = courseType;
}
public LocalDateTime getStartDate() {
return startDate;
}
public void setStartDate(LocalDateTime startDate) {
this.startDate = startDate;
}
public LocalDateTime getEndDate() {
return endDate;
}
public void setEndDate(LocalDateTime endDate) {
this.endDate = endDate;
}
public String getTimePeriod() {
return timePeriod;
}
public void setTimePeriod(String timePeriod) {
this.timePeriod = timePeriod;
}
public String getPriceSort() {
return priceSort;
}
public void setPriceSort(String priceSort) {
this.priceSort = priceSort;
}
public Boolean getRemainingMost() {
return remainingMost;
}
public void setRemainingMost(Boolean remainingMost) {
this.remainingMost = remainingMost;
}
public Integer getPage() {
return page;
}
public void setPage(Integer page) {
this.page = page;
}
public Integer getSize() {
return size;
}
public void setSize(Integer size) {
this.size = size;
}
}
@@ -0,0 +1,45 @@
package cn.novalon.gym.manage.groupcourse.entity;
import cn.novalon.gym.manage.db.entity.BaseEntity;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
@Table("course_label")
public class CourseLabelEntity extends BaseEntity {
//标签名称
@Column("label_name")
private String labelName;
//标签颜色(十六进制)
@Column("color")
private String color;
//标签描述
@Column("description")
private String description;
public String getLabelName() {
return labelName;
}
public void setLabelName(String labelName) {
this.labelName = labelName;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
@@ -0,0 +1,33 @@
package cn.novalon.gym.manage.groupcourse.entity;
import cn.novalon.gym.manage.db.entity.BaseEntity;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
@Table("course_type_label")
public class CourseTypeLabelEntity extends BaseEntity {
//团课类型ID
@Column("type_id")
private Long typeId;
//标签ID
@Column("label_id")
private Long labelId;
public Long getTypeId() {
return typeId;
}
public void setTypeId(Long typeId) {
this.typeId = typeId;
}
public Long getLabelId() {
return labelId;
}
public void setLabelId(Long labelId) {
this.labelId = labelId;
}
}
@@ -54,14 +54,14 @@ public class GroupCourseEntity extends BaseEntity {
@Column("description") @Column("description")
private String description; private String description;
//点卡额度(消耗次数)
@Column("point_card_amount")
private Integer pointCardAmount;
//储值卡额度(消耗金额) //储值卡额度(消耗金额)
@Column("stored_value_amount") @Column("stored_value_amount")
private java.math.BigDecimal storedValueAmount; private java.math.BigDecimal storedValueAmount;
//二维码路径
@Column("qr_code_path")
private String qrCodePath;
public String getCourseName() { public String getCourseName() {
return courseName; return courseName;
} }
@@ -150,14 +150,6 @@ public class GroupCourseEntity extends BaseEntity {
this.description = description; this.description = description;
} }
public Integer getPointCardAmount() {
return pointCardAmount;
}
public void setPointCardAmount(Integer pointCardAmount) {
this.pointCardAmount = pointCardAmount;
}
public java.math.BigDecimal getStoredValueAmount() { public java.math.BigDecimal getStoredValueAmount() {
return storedValueAmount; return storedValueAmount;
} }
@@ -165,4 +157,12 @@ public class GroupCourseEntity extends BaseEntity {
public void setStoredValueAmount(java.math.BigDecimal storedValueAmount) { public void setStoredValueAmount(java.math.BigDecimal storedValueAmount) {
this.storedValueAmount = storedValueAmount; this.storedValueAmount = storedValueAmount;
} }
public String getQrCodePath() {
return qrCodePath;
}
public void setQrCodePath(String qrCodePath) {
this.qrCodePath = qrCodePath;
}
} }
@@ -0,0 +1,77 @@
package cn.novalon.gym.manage.groupcourse.entity;
import cn.novalon.gym.manage.db.entity.BaseEntity;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
@Table("group_course_recommend")
public class GroupCourseRecommendEntity extends BaseEntity {
@Column("course_id")
private Long courseId;
@Column("recommend_title")
private String recommendTitle;
@Column("recommend_content")
private String recommendContent;
@Column("recommend_reason")
private String recommendReason;
@Column("priority")
private Integer priority;
@Column("is_active")
private Boolean isActive;
public Long getCourseId() {
return courseId;
}
public void setCourseId(Long courseId) {
this.courseId = courseId;
}
public String getRecommendTitle() {
return recommendTitle;
}
public void setRecommendTitle(String recommendTitle) {
this.recommendTitle = recommendTitle;
}
public String getRecommendContent() {
return recommendContent;
}
public void setRecommendContent(String recommendContent) {
this.recommendContent = recommendContent;
}
public String getRecommendReason() {
return recommendReason;
}
public void setRecommendReason(String recommendReason) {
this.recommendReason = recommendReason;
}
public Integer getPriority() {
return priority;
}
public void setPriority(Integer priority) {
this.priority = priority;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}
@@ -0,0 +1,57 @@
package cn.novalon.gym.manage.groupcourse.entity;
import cn.novalon.gym.manage.db.entity.BaseEntity;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
@Table("group_course_type")
public class GroupCourseTypeEntity extends BaseEntity {
//类型名称
@Column("type_name")
private String typeName;
//基础难度(1-10
@Column("base_difficulty")
private Integer baseDifficulty;
//类型描述
@Column("description")
private String description;
//分类(如:有氧、力量、柔韧等)
@Column("category")
private String category;
public String getTypeName() {
return typeName;
}
public void setTypeName(String typeName) {
this.typeName = typeName;
}
public Integer getBaseDifficulty() {
return baseDifficulty;
}
public void setBaseDifficulty(Integer baseDifficulty) {
this.baseDifficulty = baseDifficulty;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
}
@@ -109,8 +109,8 @@ public class BookingSagaHandler {
switch (cardType) { switch (cardType) {
case COUNT_CARD: case COUNT_CARD:
// 次卡扣除次 // 次数卡不再支持团课预约
return deductCardUsage(recordId, 1, 0.0); return Mono.error(new RuntimeException("团课预约仅支持储值卡支付"));
case STORED_VALUE_CARD: case STORED_VALUE_CARD:
// 储值卡扣除金额 // 储值卡扣除金额
return deductCardUsage(recordId, 0, DEFAULT_GROUP_COURSE_PRICE); return deductCardUsage(recordId, 0, DEFAULT_GROUP_COURSE_PRICE);
@@ -168,7 +168,8 @@ public class BookingSagaHandler {
switch (cardType) { switch (cardType) {
case COUNT_CARD: case COUNT_CARD:
return restoreCardUsage(recordId, 1, 0.0); // 次数卡不再支持团课预约,无需恢复
return Mono.empty();
case STORED_VALUE_CARD: case STORED_VALUE_CARD:
return restoreCardUsage(recordId, 0, DEFAULT_GROUP_COURSE_PRICE); return restoreCardUsage(recordId, 0, DEFAULT_GROUP_COURSE_PRICE);
case TIME_CARD: case TIME_CARD:
@@ -0,0 +1,120 @@
package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.util.OSSUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.http.codec.multipart.Part;
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.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 通用文件上传处理器
*/
@Slf4j
@Component
@Tag(name = "文件上传", description = "通用文件上传到阿里云OSS")
public class CommonUploadHandler {
@Operation(summary = "上传图片文件", description = "上传图片到阿里云OSS,返回ossKey和预签名URL")
public Mono<ServerResponse> uploadImage(ServerRequest request) {
return request.multipartData()
.flatMap(multiValueMap -> {
List<Part> fileParts = multiValueMap.get("file");
if (fileParts == null || fileParts.isEmpty()) {
return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "未选择文件"));
}
Part part = fileParts.get(0);
if (!(part instanceof FilePart filePart)) {
return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "文件格式不正确"));
}
String originalFilename = filePart.filename();
String ext = "";
int dotIndex = originalFilename.lastIndexOf('.');
if (dotIndex > 0) {
ext = originalFilename.substring(dotIndex);
}
String newFileName = UUID.randomUUID().toString().replace("-", "") + ext;
Path tempFile = null;
try {
tempFile = Files.createTempFile("upload-", newFileName);
} catch (Exception e) {
return Mono.error(new RuntimeException("创建临时文件失败", e));
}
Path finalTempFile = tempFile;
return filePart.transferTo(finalTempFile)
.then(Mono.defer(() -> {
try {
String ossKey;
try (InputStream inputStream = Files.newInputStream(finalTempFile)) {
ossKey = OSSUtil.uploadCoverToOSS(inputStream, newFileName);
}
String presignedUrl = OSSUtil.generatePresignedUrl(ossKey);
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of(
"code", 200,
"message", "上传成功",
"data", Map.of(
"ossKey", ossKey,
"presignedUrl", presignedUrl,
"fileName", originalFilename
)
));
} catch (Exception e) {
return ServerResponse.status(500)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 500, "message", "上传失败: " + e.getMessage()));
} finally {
try {
Files.deleteIfExists(finalTempFile);
} catch (Exception ignored) {
}
}
}));
});
}
@Operation(summary = "获取OSS预签名URL", description = "根据ossKey生成临时访问URL,有效期5分钟")
public Mono<ServerResponse> presignUrl(ServerRequest request) {
String ossKey = request.queryParam("key").orElse(null);
if (ossKey == null || ossKey.isBlank()) {
return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "参数 key 不能为空"));
}
try {
String presignedUrl = OSSUtil.generatePresignedUrl(ossKey);
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of(
"code", 200,
"data", Map.of("presignedUrl", presignedUrl)
));
} catch (Exception e) {
return ServerResponse.status(500)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 500, "message", "生成预签名URL失败: " + e.getMessage()));
}
}
}
@@ -0,0 +1,224 @@
package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import cn.novalon.gym.manage.groupcourse.service.ICourseLabelService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
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.List;
import java.util.Map;
@Component
@Tag(name = "团课标签管理", description = "团课标签相关操作")
public class CourseLabelHandler {
private final ICourseLabelService courseLabelService;
public CourseLabelHandler(ICourseLabelService courseLabelService) {
this.courseLabelService = courseLabelService;
}
@Operation(summary = "获取所有标签", description = "获取系统中所有标签列表")
public Mono<ServerResponse> getAllLabels(ServerRequest request) {
return ServerResponse.ok()
.body(courseLabelService.findAll(), CourseLabel.class);
}
@Operation(summary = "根据ID获取标签", description = "根据ID获取标签详情")
public Mono<ServerResponse> getLabelById(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return courseLabelService.findById(id)
.flatMap(label -> ServerResponse.ok().bodyValue(label))
.switchIfEmpty(ServerResponse.notFound().build());
}
@Operation(summary = "搜索标签", description = "根据关键词搜索标签")
public Mono<ServerResponse> searchLabels(ServerRequest request) {
String keyword = request.queryParam("keyword").orElse("");
return ServerResponse.ok()
.body(courseLabelService.findByKeyword(keyword), CourseLabel.class);
}
@Operation(summary = "创建标签", description = "创建新的标签")
public Mono<ServerResponse> createLabel(ServerRequest request) {
return request.bodyToMono(CourseLabel.class)
.flatMap(courseLabel -> {
if (courseLabel.getLabelName() == null || courseLabel.getLabelName().isEmpty()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "标签名称不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
if (courseLabel.getLabelName().length() > 50) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "标签名称不能超过50个字符");
return ServerResponse.badRequest().bodyValue(error);
}
if (courseLabel.getColor() == null || courseLabel.getColor().isEmpty()) {
courseLabel.setColor("#1890ff");
}
return courseLabelService.create(courseLabel)
.flatMap(label -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "标签创建成功");
response.put("data", label);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "更新标签", description = "更新指定标签信息")
public Mono<ServerResponse> updateLabel(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(CourseLabel.class)
.flatMap(courseLabel -> {
if (courseLabel.getLabelName() != null && courseLabel.getLabelName().length() > 50) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "标签名称不能超过50个字符");
return ServerResponse.badRequest().bodyValue(error);
}
return courseLabelService.update(id, courseLabel)
.flatMap(label -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "标签更新成功");
response.put("data", label);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "删除标签", description = "删除指定标签(软删除)")
public Mono<ServerResponse> deleteLabel(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return courseLabelService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "标签删除成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
@Operation(summary = "获取类型的标签", description = "获取指定团课类型的所有标签")
public Mono<ServerResponse> getLabelsByTypeId(ServerRequest request) {
Long typeId = Long.valueOf(request.pathVariable("typeId"));
return courseLabelService.findByTypeId(typeId)
.collectList()
.flatMap(list -> ServerResponse.ok().bodyValue(list));
}
@Operation(summary = "为类型添加标签", description = "为指定团课类型添加标签")
public Mono<ServerResponse> addLabelsToType(ServerRequest request) {
Long typeId = Long.valueOf(request.pathVariable("typeId"));
return request.bodyToMono(Map.class)
.flatMap(body -> {
Object labelIdsObj = body.get("labelIds");
if (!(labelIdsObj instanceof List)) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "labelIds不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
List<?> rawList = (List<?>) labelIdsObj;
if (rawList.isEmpty()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "labelIds不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
List<Long> labelIds = rawList.stream()
.map(id -> Long.valueOf(String.valueOf(id)))
.collect(java.util.stream.Collectors.toList());
return courseLabelService.addLabelsToType(typeId, labelIds)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "标签添加成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "从类型移除标签", description = "从指定团课类型移除标签")
public Mono<ServerResponse> removeLabelFromType(ServerRequest request) {
Long typeId = Long.valueOf(request.pathVariable("typeId"));
Long labelId = Long.valueOf(request.pathVariable("labelId"));
return courseLabelService.removeLabelFromType(typeId, labelId)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "标签移除成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
@Operation(summary = "清空类型标签", description = "清空指定团课类型的所有标签")
public Mono<ServerResponse> clearLabelsFromType(ServerRequest request) {
Long typeId = Long.valueOf(request.pathVariable("typeId"));
return courseLabelService.clearLabelsFromType(typeId)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "标签清空成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
}
@@ -4,11 +4,16 @@ package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.common.dto.PageRequest; import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Validator; import jakarta.validation.Validator;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
@@ -24,15 +29,21 @@ public class GroupCourseHandler {
private final Validator validator; private final Validator validator;
private final RedisUtil redisUtil; private final RedisUtil redisUtil;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public GroupCourseHandler(IGroupCourseService groupCourseService, public GroupCourseHandler(IGroupCourseService groupCourseService,
Validator validator, Validator validator,
RedisUtil redisUtil, RedisUtil redisUtil,
ObjectMapper objectMapper){ ObjectMapper objectMapper,
ISysUserService sysUserService,
AuthUtil authUtil){
this.groupCourseService = groupCourseService; this.groupCourseService = groupCourseService;
this.validator = validator; this.validator = validator;
this.redisUtil = redisUtil; this.redisUtil = redisUtil;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
} }
@Operation(summary = "获取所有团课", description = "获取系统中所有团课列表") @Operation(summary = "获取所有团课", description = "获取系统中所有团课列表")
@@ -76,6 +87,14 @@ public class GroupCourseHandler {
.switchIfEmpty(ServerResponse.notFound().build()); .switchIfEmpty(ServerResponse.notFound().build());
} }
@Operation(summary = "根据ID获取团课完整信息", description = "根据ID获取团课完整信息,包括团课基础信息、类型信息和标签信息")
public Mono<ServerResponse> getGroupCourseDetailById(ServerRequest request){
Long id = Long.valueOf(request.pathVariable("id"));
return groupCourseService.findDetailById(id)
.flatMap(detail -> ServerResponse.ok().bodyValue(detail))
.switchIfEmpty(ServerResponse.notFound().build());
}
@Operation(summary = "创建团课", description = "创建新的团课") @Operation(summary = "创建团课", description = "创建新的团课")
public Mono<ServerResponse> createGroupCourse(ServerRequest request) { public Mono<ServerResponse> createGroupCourse(ServerRequest request) {
return request.bodyToMono(GroupCourse.class) return request.bodyToMono(GroupCourse.class)
@@ -104,25 +123,43 @@ public class GroupCourseHandler {
}); });
} }
@Operation(summary = "更新团课", description = "更新指定团课信息") @Operation(summary = "更新团课", description = "更新指定团课信息,需验证管理员密码")
public Mono<ServerResponse> updateGroupCourse(ServerRequest request) { public Mono<ServerResponse> updateGroupCourse(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
return request.bodyToMono(GroupCourse.class) if (adminPassword == null || adminPassword.isBlank()) {
.flatMap(groupCourse -> { Map<String, Object> error = new HashMap<>();
return groupCourseService.update(id, groupCourse) error.put("success", false);
.flatMap(course -> { error.put("message", "管理员密码不能为空");
Map<String, Object> response = new HashMap<>(); return ServerResponse.badRequest().bodyValue(error);
response.put("success", true); }
response.put("message", "团课更新成功");
response.put("data", course); return sysUserService.verifyPassword(adminId, adminPassword)
return ServerResponse.ok().bodyValue(response); .flatMap(valid -> {
}) if (!valid) {
.onErrorResume(error -> { Map<String, Object> error = new HashMap<>();
Map<String, Object> response = new HashMap<>(); error.put("success", false);
response.put("success", false); error.put("message", "管理员密码错误");
response.put("message", error.getMessage()); return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
return ServerResponse.badRequest().bodyValue(response); }
return request.bodyToMono(GroupCourse.class)
.flatMap(groupCourse -> {
return groupCourseService.update(id, groupCourse)
.flatMap(course -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课更新成功");
response.put("data", course);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}); });
}); });
} }
@@ -149,7 +186,7 @@ public class GroupCourseHandler {
@Operation(summary = "团课签到", description = "会员签到参加团课") @Operation(summary = "团课签到", description = "会员签到参加团课")
public Mono<ServerResponse> signIn(ServerRequest request) { public Mono<ServerResponse> signIn(ServerRequest request) {
Long courseId = Long.valueOf(request.pathVariable("courseId")); Long memberId = Long.valueOf(request.pathVariable("memberId"));
return request.bodyToMono(Map.class) return request.bodyToMono(Map.class)
.flatMap(body -> { .flatMap(body -> {
@@ -160,15 +197,15 @@ public class GroupCourseHandler {
return ServerResponse.badRequest().bodyValue(response); return ServerResponse.badRequest().bodyValue(response);
} }
Object memberIdObj = body.get("memberId"); Object courseIdObj = body.get("courseId");
if (memberIdObj == null) { if (courseIdObj == null) {
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
response.put("success", false); response.put("success", false);
response.put("message", "memberId不能为空"); response.put("message", "courseId不能为空");
return ServerResponse.badRequest().bodyValue(response); return ServerResponse.badRequest().bodyValue(response);
} }
Long memberId = ((Number) memberIdObj).longValue(); Long courseId = ((Number) courseIdObj).longValue();
return groupCourseService.signIn(courseId, memberId) return groupCourseService.signIn(courseId, memberId)
.flatMap(course -> { .flatMap(course -> {
@@ -187,22 +224,99 @@ public class GroupCourseHandler {
}); });
} }
@Operation(summary = "删除团课", description = "删除指定团课(软删除)") @Operation(summary = "删除团课", description = "删除指定团课(软删除),需验证管理员密码")
public Mono<ServerResponse> deleteGroupCourse(ServerRequest request) { public Mono<ServerResponse> deleteGroupCourse(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
return groupCourseService.delete(id) if (adminPassword == null || adminPassword.isBlank()) {
.then(Mono.defer(() -> { Map<String, Object> error = new HashMap<>();
Map<String, Object> response = new HashMap<>(); error.put("success", false);
response.put("success", true); error.put("message", "管理员密码不能为空");
response.put("message", "团课删除成功"); return ServerResponse.badRequest().bodyValue(error);
return ServerResponse.ok().bodyValue(response); }
}))
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return groupCourseService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课删除成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "恢复已删除团课", description = "将已删除的团课恢复为已取消状态,需验证管理员密码")
public Mono<ServerResponse> restoreGroupCourse(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return groupCourseService.restore(id)
.flatMap(course -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课恢复成功");
response.put("data", course);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "多条件查询团课", description = "支持团课名称模糊查询、类型筛选、日期范围、时间段、价格排序、剩余名额排序等多条件组合查询")
public Mono<ServerResponse> searchGroupCourses(ServerRequest request) {
return request.bodyToMono(GroupCourseQueryDto.class)
.flatMap(query -> {
return groupCourseService.searchGroupCourses(query)
.flatMap(response -> {
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("message", "查询成功");
result.put("data", response);
return ServerResponse.ok().bodyValue(result);
});
})
.onErrorResume(error -> { .onErrorResume(error -> {
Map<String, Object> response = new HashMap<>(); Map<String, Object> errorResponse = new HashMap<>();
response.put("success", false); errorResponse.put("success", false);
response.put("message", error.getMessage()); errorResponse.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response); return ServerResponse.badRequest().bodyValue(errorResponse);
}); });
} }
@@ -0,0 +1,225 @@
package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
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;
@Component
@Tag(name = "团课推荐管理", description = "团课推荐相关操作")
public class GroupCourseRecommendHandler {
private final IGroupCourseRecommendService recommendService;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public GroupCourseRecommendHandler(IGroupCourseRecommendService recommendService,
ISysUserService sysUserService,
AuthUtil authUtil) {
this.recommendService = recommendService;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
}
@Operation(summary = "获取所有团课推荐", description = "获取系统中所有团课推荐列表,支持按优先级排序")
public Mono<ServerResponse> getAllRecommendations(ServerRequest request) {
String sortBy = request.queryParam("sortBy").orElse("priority");
String sortOrder = request.queryParam("sortOrder").orElse("desc");
return ServerResponse.ok()
.body(recommendService.findAll(sortBy, sortOrder), GroupCourseRecommend.class);
}
@Operation(summary = "获取所有启用的团课推荐", description = "获取系统中所有已启用的团课推荐列表(按优先级排序)")
public Mono<ServerResponse> getAllActiveRecommendations(ServerRequest request) {
return ServerResponse.ok()
.body(recommendService.findAllActive(), GroupCourseRecommend.class);
}
@Operation(summary = "根据ID获取团课推荐", description = "根据ID获取团课推荐详情")
public Mono<ServerResponse> getRecommendationById(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return recommendService.findById(id)
.flatMap(recommend -> ServerResponse.ok().bodyValue(recommend))
.switchIfEmpty(ServerResponse.notFound().build());
}
@Operation(summary = "根据团课ID获取推荐", description = "根据团课ID获取该团课的推荐信息")
public Mono<ServerResponse> getRecommendationsByCourseId(ServerRequest request) {
Long courseId = Long.valueOf(request.pathVariable("courseId"));
return ServerResponse.ok()
.body(recommendService.findByCourseId(courseId), GroupCourseRecommend.class);
}
@Operation(summary = "创建团课推荐", description = "创建新的团课推荐")
public Mono<ServerResponse> createRecommendation(ServerRequest request) {
return request.bodyToMono(GroupCourseRecommend.class)
.flatMap(recommend -> {
if (recommend.getCourseId() == null) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "团课ID不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return recommendService.create(recommend)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课推荐创建成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "更新团课推荐", description = "更新指定团课推荐信息,需验证管理员密码")
public Mono<ServerResponse> updateRecommendation(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return request.bodyToMono(GroupCourseRecommend.class)
.flatMap(recommend -> recommendService.update(id, recommend)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课推荐更新成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
}));
});
}
@Operation(summary = "删除团课推荐", description = "删除指定团课推荐(软删除),需验证管理员密码")
public Mono<ServerResponse> deleteRecommendation(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return recommendService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课推荐删除成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "启用团课推荐", description = "启用指定团课推荐")
public Mono<ServerResponse> enableRecommendation(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return recommendService.enable(id)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课推荐启用成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
@Operation(summary = "禁用团课推荐", description = "禁用指定团课推荐,需验证管理员密码")
public Mono<ServerResponse> disableRecommendation(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return recommendService.disable(id)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课推荐禁用成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
}
@@ -0,0 +1,182 @@
package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
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;
@Component
@Tag(name = "团课类型管理", description = "团课类型及难度相关操作")
public class GroupCourseTypeHandler {
private final IGroupCourseTypeService groupCourseTypeService;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public GroupCourseTypeHandler(IGroupCourseTypeService groupCourseTypeService,
ISysUserService sysUserService,
AuthUtil authUtil) {
this.groupCourseTypeService = groupCourseTypeService;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
}
@Operation(summary = "获取所有团课类型", description = "获取系统中所有团课类型列表")
public Mono<ServerResponse> getAllGroupCourseTypes(ServerRequest request) {
boolean includeDeleted = Boolean.valueOf(request.queryParam("includeDeleted").orElse("false"));
return ServerResponse.ok()
.body(groupCourseTypeService.findAll(includeDeleted), GroupCourseType.class);
}
@Operation(summary = "根据ID获取团课类型", description = "根据ID获取团课类型详情")
public Mono<ServerResponse> getGroupCourseTypeById(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return groupCourseTypeService.findById(id)
.flatMap(type -> ServerResponse.ok().bodyValue(type))
.switchIfEmpty(ServerResponse.notFound().build());
}
@Operation(summary = "根据关键词搜索团课类型", description = "根据类型名称关键词搜索团课类型")
public Mono<ServerResponse> searchGroupCourseTypes(ServerRequest request) {
String keyword = request.queryParam("keyword").orElse("");
return ServerResponse.ok()
.body(groupCourseTypeService.findByKeyword(keyword), GroupCourseType.class);
}
@Operation(summary = "根据分类获取团课类型", description = "根据分类获取团课类型列表")
public Mono<ServerResponse> getGroupCourseTypesByCategory(ServerRequest request) {
String category = request.pathVariable("category");
String keyword = request.queryParam("keyword").orElse("");
return ServerResponse.ok()
.body(groupCourseTypeService.findByCategoryAndKeyword(category, keyword), GroupCourseType.class);
}
@Operation(summary = "获取所有分类", description = "获取所有团课类型分类(去重)")
public Mono<ServerResponse> getCategories(ServerRequest request) {
return groupCourseTypeService.findCategories()
.collectList()
.flatMap(list -> ServerResponse.ok().bodyValue(list));
}
@Operation(summary = "创建团课类型", description = "创建新的团课类型")
public Mono<ServerResponse> createGroupCourseType(ServerRequest request) {
return request.bodyToMono(GroupCourseType.class)
.flatMap(groupCourseType -> {
if (groupCourseType.getTypeName() == null || groupCourseType.getTypeName().isEmpty()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "类型名称不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
// 默认基础难度为1
if (groupCourseType.getBaseDifficulty() == null) {
groupCourseType.setBaseDifficulty(1);
}
return groupCourseTypeService.create(groupCourseType)
.flatMap(type -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课类型创建成功");
response.put("data", type);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "更新团课类型", description = "更新指定团课类型信息,需验证管理员密码")
public Mono<ServerResponse> updateGroupCourseType(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return request.bodyToMono(GroupCourseType.class)
.flatMap(groupCourseType -> {
groupCourseType.setId(id);
return groupCourseTypeService.update(id, groupCourseType)
.flatMap(type -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课类型更新成功");
response.put("data", type);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
});
}
@Operation(summary = "删除团课类型", description = "删除指定团课类型(软删除),需验证管理员密码,且该类型不能被任何团课引用")
public Mono<ServerResponse> deleteGroupCourseType(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return groupCourseTypeService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课类型删除成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
}
@@ -0,0 +1,89 @@
package cn.novalon.gym.manage.groupcourse.initializer;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* 项目启动时补全缺失的团课二维码
* 遍历所有未删除的团课,对qrCodePath为空的课程生成二维码并上传至阿里云OSS
*/
@Component
public class QrCodeInitializer implements CommandLineRunner {
private static final Logger logger = LoggerFactory.getLogger(QrCodeInitializer.class);
private final IGroupCourseRepository groupCourseRepository;
private final ObjectMapper objectMapper;
public QrCodeInitializer(IGroupCourseRepository groupCourseRepository,
ObjectMapper objectMapper) {
this.groupCourseRepository = groupCourseRepository;
this.objectMapper = objectMapper;
}
@Override
public void run(String... args) {
logger.info("===== 开始检查团课二维码缺失情况 =====");
groupCourseRepository.findByDeletedAtIsNull()
.filter(course -> course.getQrCodePath() == null || course.getQrCodePath().isEmpty())
.flatMap(course -> {
try {
logger.info("发现缺失二维码的团课 - id={}, name={}", course.getId(), course.getCourseName());
// 生成二维码内容:团课基础信息JSON
Map<String, Object> qrCodeContent = new HashMap<>();
qrCodeContent.put("id", course.getId());
qrCodeContent.put("courseName", course.getCourseName());
qrCodeContent.put("coachId", course.getCoachId());
qrCodeContent.put("courseType", course.getCourseType());
qrCodeContent.put("startTime", course.getStartTime() != null ? course.getStartTime().toString() : null);
qrCodeContent.put("endTime", course.getEndTime() != null ? course.getEndTime().toString() : null);
qrCodeContent.put("maxMembers", course.getMaxMembers());
qrCodeContent.put("location", course.getLocation());
qrCodeContent.put("description", course.getDescription());
String jsonContent = objectMapper.writeValueAsString(qrCodeContent);
// 生成二维码并上传到阿里云OSS
String uuid = UUID.randomUUID().toString().replace("-", "");
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
String fileName = "qr_" + uuid + "_" + timestamp + ".png";
String ossUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent, fileName);
course.setQrCodePath(ossUrl);
// 更新数据库
return groupCourseRepository.update(course)
.doOnSuccess(updated -> logger.info("团课二维码补全成功 - id={}, name={}, ossUrl={}",
updated.getId(), updated.getCourseName(), ossUrl))
.doOnError(error -> logger.error("团课二维码补全失败(更新DB) - id={}, name={}, error: {}",
course.getId(), course.getCourseName(), error.getMessage()));
} catch (Exception e) {
logger.error("团课二维码补全失败(生成) - id={}, name={}, error: {}",
course.getId(), course.getCourseName(), e.getMessage(), e);
return Mono.empty();
}
})
.collectList()
.doOnSuccess(list -> logger.info("===== 团课二维码检查完毕,共补全 {} 个缺失二维码 =====", list.size()))
.onErrorResume(error -> {
logger.error("团课二维码初始化检查异常: {}", error.getMessage(), error);
return Mono.empty();
})
.subscribe();
}
}
@@ -0,0 +1,32 @@
package cn.novalon.gym.manage.groupcourse.repository;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
public interface ICourseLabelRepository {
Mono<CourseLabel> findById(Long id);
Flux<CourseLabel> findAll();
Flux<CourseLabel> findByKeyword(String keyword);
Mono<CourseLabel> findByLabelName(String labelName);
Mono<CourseLabel> save(CourseLabel courseLabel);
Mono<CourseLabel> update(CourseLabel courseLabel);
Mono<Void> deleteById(Long id);
Flux<CourseLabel> findByTypeId(Long typeId);
Mono<Void> addLabelsToType(Long typeId, List<Long> labelIds);
Mono<Void> removeLabelFromType(Long typeId, Long labelId);
Mono<Void> clearLabelsFromType(Long typeId);
}
@@ -0,0 +1,30 @@
package cn.novalon.gym.manage.groupcourse.repository;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface IGroupCourseRecommendRepository {
Mono<GroupCourseRecommend> findById(Long id);
Flux<GroupCourseRecommend> findAll();
Flux<GroupCourseRecommend> findAll(String sortBy, String sortOrder);
Flux<GroupCourseRecommend> findAllActive();
Flux<GroupCourseRecommend> findByCourseId(Long courseId);
Mono<GroupCourseRecommend> findActiveByCourseId(Long courseId);
Mono<GroupCourseRecommend> save(GroupCourseRecommend recommend);
Mono<GroupCourseRecommend> update(GroupCourseRecommend recommend);
Mono<Void> deleteById(Long id);
Mono<Void> deleteByCourseId(Long courseId);
Mono<GroupCourseRecommend> updateActiveStatus(Long id, Boolean isActive);
}
@@ -4,6 +4,7 @@ package cn.novalon.gym.manage.groupcourse.repository;
import cn.novalon.gym.manage.common.dto.PageRequest; import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse; import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -26,5 +27,11 @@ public interface IGroupCourseRepository {
Mono<Void> deleteById(Long id); Mono<Void> deleteById(Long id);
Mono<GroupCourse> restoreById(Long id);
Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta); Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta);
Flux<GroupCourse> findByCourseType(Long courseType);
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
} }
@@ -0,0 +1,28 @@
package cn.novalon.gym.manage.groupcourse.repository;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface IGroupCourseTypeRepository {
Mono<GroupCourseType> findById(Long id);
Flux<GroupCourseType> findAll();
Flux<GroupCourseType> findAll(boolean includeDeleted);
Flux<GroupCourseType> findByKeyword(String keyword);
Flux<GroupCourseType> findByCategory(String category);
Flux<GroupCourseType> findByCategoryAndKeyword(String category, String keyword);
Mono<GroupCourseType> findByTypeName(String typeName);
Mono<GroupCourseType> save(GroupCourseType groupCourseType);
Mono<GroupCourseType> update(GroupCourseType groupCourseType);
Mono<Void> deleteById(Long id);
}
@@ -0,0 +1,161 @@
package cn.novalon.gym.manage.groupcourse.repository.impl;
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
import cn.novalon.gym.manage.groupcourse.dao.CourseLabelDao;
import cn.novalon.gym.manage.groupcourse.dao.CourseTypeLabelDao;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import cn.novalon.gym.manage.groupcourse.entity.CourseLabelEntity;
import cn.novalon.gym.manage.groupcourse.entity.CourseTypeLabelEntity;
import cn.novalon.gym.manage.groupcourse.repository.ICourseLabelRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.List;
@Repository
@Transactional
public class CourseLabelRepository implements ICourseLabelRepository {
private static final Logger logger = LoggerFactory.getLogger(CourseLabelRepository.class);
private final CourseLabelDao courseLabelDao;
private final CourseTypeLabelDao courseTypeLabelDao;
private final GroupCourseConverter converter;
public CourseLabelRepository(CourseLabelDao courseLabelDao, CourseTypeLabelDao courseTypeLabelDao,
GroupCourseConverter converter) {
this.courseLabelDao = courseLabelDao;
this.courseTypeLabelDao = courseTypeLabelDao;
this.converter = converter;
}
@Override
public Mono<CourseLabel> findById(Long id) {
return courseLabelDao.findByIdIsAndDeletedAtIsNull(id)
.map(this::toCourseLabel);
}
@Override
public Flux<CourseLabel> findAll() {
return courseLabelDao.findAllByDeletedAtIsNull()
.map(this::toCourseLabel);
}
@Override
public Flux<CourseLabel> findByKeyword(String keyword) {
if (keyword == null || keyword.isEmpty()) {
return findAll();
}
return courseLabelDao.findByLabelNameContainingAndDeletedAtIsNull(keyword)
.map(this::toCourseLabel);
}
@Override
public Mono<CourseLabel> findByLabelName(String labelName) {
return courseLabelDao.findByLabelNameAndDeletedAtIsNull(labelName)
.map(this::toCourseLabel);
}
@Override
public Mono<CourseLabel> save(CourseLabel courseLabel) {
CourseLabelEntity entity = toCourseLabelEntity(courseLabel);
return courseLabelDao.save(entity)
.map(this::toCourseLabel);
}
@Override
public Mono<CourseLabel> update(CourseLabel courseLabel) {
return courseLabelDao.findByIdIsAndDeletedAtIsNull(courseLabel.getId())
.switchIfEmpty(Mono.error(new RuntimeException("标签不存在")))
.flatMap(existing -> {
existing.markNotNew();
if (courseLabel.getLabelName() != null) {
existing.setLabelName(courseLabel.getLabelName());
}
if (courseLabel.getColor() != null) {
existing.setColor(courseLabel.getColor());
}
if (courseLabel.getDescription() != null) {
existing.setDescription(courseLabel.getDescription());
}
existing.setUpdatedAt(LocalDateTime.now());
return courseLabelDao.save(existing);
})
.map(this::toCourseLabel);
}
@Override
public Mono<Void> deleteById(Long id) {
return courseLabelDao.softDelete(id, LocalDateTime.now())
.then(courseTypeLabelDao.deleteByLabelId(id, LocalDateTime.now()))
.then();
}
@Override
public Flux<CourseLabel> findByTypeId(Long typeId) {
return courseTypeLabelDao.findByTypeIdAndDeletedAtIsNull(typeId)
.flatMap(typeLabel -> courseLabelDao.findByIdIsAndDeletedAtIsNull(typeLabel.getLabelId()))
.map(this::toCourseLabel);
}
@Override
public Mono<Void> addLabelsToType(Long typeId, List<Long> labelIds) {
return Flux.fromIterable(labelIds)
.flatMap(labelId -> {
return courseTypeLabelDao.physicalDeleteByTypeIdAndLabelId(typeId, labelId)
.then(Mono.defer(() -> {
CourseTypeLabelEntity entity = new CourseTypeLabelEntity();
entity.setTypeId(typeId);
entity.setLabelId(labelId);
return courseTypeLabelDao.save(entity).then(Mono.empty());
}));
})
.then();
}
@Override
public Mono<Void> removeLabelFromType(Long typeId, Long labelId) {
return courseTypeLabelDao.deleteByTypeIdAndLabelId(typeId, labelId, LocalDateTime.now())
.then();
}
@Override
public Mono<Void> clearLabelsFromType(Long typeId) {
return courseTypeLabelDao.deleteByTypeId(typeId, LocalDateTime.now())
.then();
}
private CourseLabel toCourseLabel(CourseLabelEntity entity) {
if (entity == null) {
return null;
}
CourseLabel label = new CourseLabel();
label.setId(entity.getId());
label.setLabelName(entity.getLabelName());
label.setColor(entity.getColor());
label.setDescription(entity.getDescription());
label.setCreatedAt(entity.getCreatedAt());
label.setUpdatedAt(entity.getUpdatedAt());
return label;
}
private CourseLabelEntity toCourseLabelEntity(CourseLabel domain) {
if (domain == null) {
return null;
}
CourseLabelEntity entity = new CourseLabelEntity();
entity.setId(domain.getId());
entity.setLabelName(domain.getLabelName());
entity.setColor(domain.getColor());
entity.setDescription(domain.getDescription());
if (domain.getId() != null) {
entity.markNotNew();
}
return entity;
}
}
@@ -0,0 +1,136 @@
package cn.novalon.gym.manage.groupcourse.repository.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseRecommendDao;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseRecommendEntity;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRecommendRepository;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
public class GroupCourseRecommendRepository implements IGroupCourseRecommendRepository {
private final GroupCourseRecommendDao recommendDao;
private final R2dbcEntityTemplate r2dbcEntityTemplate;
public GroupCourseRecommendRepository(GroupCourseRecommendDao recommendDao,
R2dbcEntityTemplate r2dbcEntityTemplate) {
this.recommendDao = recommendDao;
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
}
@Override
public Mono<GroupCourseRecommend> findById(Long id) {
return recommendDao.findByIdAndDeletedAtIsNull(id)
.map(this::toDomain);
}
@Override
public Flux<GroupCourseRecommend> findAll() {
return recommendDao.findAllByDeletedAtIsNull()
.map(this::toDomain);
}
@Override
public Flux<GroupCourseRecommend> findAll(String sortBy, String sortOrder) {
Sort.Direction direction = "asc".equalsIgnoreCase(sortOrder)
? Sort.Direction.ASC
: Sort.Direction.DESC;
Sort sort = Sort.by(direction, sortBy);
return recommendDao.findAllByDeletedAtIsNull(sort)
.map(this::toDomain);
}
@Override
public Flux<GroupCourseRecommend> findAllActive() {
return recommendDao.findByIsActiveTrueAndDeletedAtIsNull(Sort.by(Sort.Direction.DESC, "priority"))
.map(this::toDomain);
}
@Override
public Flux<GroupCourseRecommend> findByCourseId(Long courseId) {
return recommendDao.findByCourseIdAndDeletedAtIsNull(courseId)
.map(this::toDomain);
}
@Override
public Mono<GroupCourseRecommend> findActiveByCourseId(Long courseId) {
return recommendDao.findByCourseIdAndDeletedAtIsNullAndIsActiveTrue(courseId)
.map(this::toDomain);
}
@Override
public Mono<GroupCourseRecommend> save(GroupCourseRecommend recommend) {
GroupCourseRecommendEntity entity = toEntity(recommend);
entity.setCreatedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now());
if (entity.getPriority() == null) {
entity.setPriority(0);
}
if (entity.getIsActive() == null) {
entity.setIsActive(true);
}
return recommendDao.save(entity)
.map(this::toDomain);
}
@Override
public Mono<GroupCourseRecommend> update(GroupCourseRecommend recommend) {
GroupCourseRecommendEntity entity = toEntity(recommend);
entity.setUpdatedAt(LocalDateTime.now());
return r2dbcEntityTemplate.update(entity)
.then(findById(recommend.getId()));
}
@Override
public Mono<Void> deleteById(Long id) {
return recommendDao.softDelete(id, LocalDateTime.now())
.then();
}
@Override
public Mono<Void> deleteByCourseId(Long courseId) {
return recommendDao.softDeleteByCourseId(courseId, LocalDateTime.now())
.then();
}
@Override
public Mono<GroupCourseRecommend> updateActiveStatus(Long id, Boolean isActive) {
return recommendDao.updateActiveStatus(id, isActive, LocalDateTime.now())
.flatMap(updated -> {
if (updated > 0) {
return findById(id);
}
return Mono.empty();
});
}
private GroupCourseRecommend toDomain(GroupCourseRecommendEntity entity) {
if (entity == null) {
return null;
}
GroupCourseRecommend recommend = new GroupCourseRecommend();
BeanUtil.copyProperties(entity, recommend);
return recommend;
}
private GroupCourseRecommendEntity toEntity(GroupCourseRecommend domain) {
if (domain == null) {
return null;
}
GroupCourseRecommendEntity entity = new GroupCourseRecommendEntity();
BeanUtil.copyProperties(domain, entity);
if (domain.getId() != null) {
entity.markNotNew();
}
return entity;
}
}
@@ -6,6 +6,7 @@ import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter; import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao; import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity; import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
@@ -168,6 +169,17 @@ public class GroupCourseRepository implements IGroupCourseRepository {
.then(); .then();
} }
@Override
public Mono<GroupCourse> restoreById(Long id) {
return groupCourseDao.restoreCourse(id, LocalDateTime.now())
.flatMap(updated -> {
if (updated > 0) {
return findByIdAndDeletedAtIsNull(id);
}
return Mono.error(new RuntimeException("团课恢复失败,可能该课程未被删除"));
});
}
@Override @Override
public Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta) { public Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta) {
return groupCourseDao.updateCurrentMembers(id, delta, LocalDateTime.now()) return groupCourseDao.updateCurrentMembers(id, delta, LocalDateTime.now())
@@ -178,4 +190,33 @@ public class GroupCourseRepository implements IGroupCourseRepository {
return Mono.empty(); return Mono.empty();
}); });
} }
@Override
public Flux<GroupCourse> findByCourseType(Long courseType) {
return groupCourseDao.findByCourseTypeAndDeletedAtIsNull(courseType)
.map(groupCourseConverter::toDomain);
}
@Override
public Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query) {
return groupCourseDao.countSearchGroupCourses(r2dbcEntityTemplate.getDatabaseClient(), query)
.flatMap(total -> {
if (total == 0) {
return Mono.just(new PageResponse<>(
List.of(), 0, 0L,
query.getPage() != null ? query.getPage() : 0,
query.getSize() != null ? query.getSize() : 10));
}
return groupCourseDao.searchGroupCourses(r2dbcEntityTemplate.getDatabaseClient(), query)
.map(groupCourseConverter::toDomain)
.collectList()
.map(courseList -> {
int size = query.getSize() != null ? query.getSize() : 10;
int totalPages = (int) Math.ceil((double) total / size);
return new PageResponse<>(
courseList, totalPages, total,
query.getPage() != null ? query.getPage() : 0, size);
});
});
}
} }
@@ -0,0 +1,131 @@
package cn.novalon.gym.manage.groupcourse.repository.impl;
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseTypeDao;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseTypeEntity;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
@Transactional
public class GroupCourseTypeRepository implements IGroupCourseTypeRepository {
private static final Logger logger = LoggerFactory.getLogger(GroupCourseTypeRepository.class);
private final GroupCourseTypeDao groupCourseTypeDao;
private final GroupCourseConverter converter;
public GroupCourseTypeRepository(GroupCourseTypeDao groupCourseTypeDao, GroupCourseConverter converter) {
this.groupCourseTypeDao = groupCourseTypeDao;
this.converter = converter;
}
@Override
public Mono<GroupCourseType> findById(Long id) {
return groupCourseTypeDao.findByIdIsAndDeletedAtIsNull(id)
.map(converter::toGroupCourseType);
}
@Override
public Flux<GroupCourseType> findAll() {
return groupCourseTypeDao.findAll()
.map(converter::toGroupCourseType);
}
@Override
public Flux<GroupCourseType> findAll(boolean includeDeleted) {
if (includeDeleted) {
return groupCourseTypeDao.findAll()
.map(converter::toGroupCourseType);
} else {
return groupCourseTypeDao.findAllByDeletedAtIsNull()
.map(converter::toGroupCourseType);
}
}
@Override
public Flux<GroupCourseType> findByKeyword(String keyword) {
if (keyword == null || keyword.isEmpty()) {
return findAll(false);
}
return groupCourseTypeDao.findByTypeNameContainingAndDeletedAtIsNull(keyword)
.map(converter::toGroupCourseType);
}
@Override
public Flux<GroupCourseType> findByCategory(String category) {
if (category == null || category.isEmpty()) {
return findAll(false);
}
return groupCourseTypeDao.findByCategoryAndDeletedAtIsNull(category)
.map(converter::toGroupCourseType);
}
@Override
public Flux<GroupCourseType> findByCategoryAndKeyword(String category, String keyword) {
Flux<GroupCourseType> result;
if (category != null && !category.isEmpty()) {
result = findByCategory(category);
} else {
result = findAll(false);
}
if (keyword != null && !keyword.isEmpty()) {
result = result.filter(type -> type.getTypeName() != null &&
type.getTypeName().toLowerCase().contains(keyword.toLowerCase()));
}
return result;
}
@Override
public Mono<GroupCourseType> findByTypeName(String typeName) {
return groupCourseTypeDao.findByTypeNameAndDeletedAtIsNull(typeName)
.map(converter::toGroupCourseType);
}
@Override
public Mono<GroupCourseType> save(GroupCourseType groupCourseType) {
GroupCourseTypeEntity entity = converter.toGroupCourseTypeEntity(groupCourseType);
return groupCourseTypeDao.save(entity)
.map(converter::toGroupCourseType);
}
@Override
public Mono<GroupCourseType> update(GroupCourseType groupCourseType) {
return groupCourseTypeDao.findByIdIsAndDeletedAtIsNull(groupCourseType.getId())
.switchIfEmpty(Mono.error(new RuntimeException("团课类型不存在")))
.flatMap(existing -> {
String typeName = groupCourseType.getTypeName() != null
? groupCourseType.getTypeName() : existing.getTypeName();
Integer baseDifficulty = groupCourseType.getBaseDifficulty() != null
? groupCourseType.getBaseDifficulty() : existing.getBaseDifficulty();
String description = groupCourseType.getDescription() != null
? groupCourseType.getDescription() : existing.getDescription();
String category = groupCourseType.getCategory() != null
? groupCourseType.getCategory() : existing.getCategory();
LocalDateTime now = LocalDateTime.now();
return groupCourseTypeDao.updateFields(
groupCourseType.getId(), typeName, baseDifficulty,
description, category, now)
.then(groupCourseTypeDao.findByIdIsAndDeletedAtIsNull(groupCourseType.getId()));
})
.map(converter::toGroupCourseType);
}
@Override
public Mono<Void> deleteById(Long id) {
return groupCourseTypeDao.softDelete(id, LocalDateTime.now())
.then();
}
}
@@ -0,0 +1,46 @@
package cn.novalon.gym.manage.groupcourse.scheduler;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* 团课过期状态自动更新定时任务
*
* 功能:定期检查已过期的团课(end_time <= NOW()),自动将 status 从 0 更新为 2(已结束)
*
* @date 2026-06-24
*/
@Component
public class GroupCourseExpireScheduler {
private static final Logger logger = LoggerFactory.getLogger(GroupCourseExpireScheduler.class);
private final GroupCourseDao groupCourseDao;
public GroupCourseExpireScheduler(GroupCourseDao groupCourseDao) {
this.groupCourseDao = groupCourseDao;
}
/**
* 每分钟检查一次,将已过期但状态仍为 0 的团课标记为已结束(status = 2)
*/
@Scheduled(fixedRate = 60000)
public void completeExpiredCourses() {
logger.debug("定时任务开始检查已过期团课,更新状态为已结束");
groupCourseDao.completeExpiredCourses(LocalDateTime.now())
.subscribe(
count -> {
if (count > 0) {
logger.info("定时任务完成,更新了 {} 条过期团课状态为已结束", count);
}
},
error -> logger.error("过期团课状态更新定时任务执行失败:{}", error.getMessage(), error)
);
}
}
@@ -0,0 +1,30 @@
package cn.novalon.gym.manage.groupcourse.service;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
public interface ICourseLabelService {
Mono<CourseLabel> findById(Long id);
Flux<CourseLabel> findAll();
Flux<CourseLabel> findByKeyword(String keyword);
Mono<CourseLabel> create(CourseLabel courseLabel);
Mono<CourseLabel> update(Long id, CourseLabel courseLabel);
Mono<Void> delete(Long id);
Flux<CourseLabel> findByTypeId(Long typeId);
Mono<Void> addLabelsToType(Long typeId, List<Long> labelIds);
Mono<Void> removeLabelFromType(Long typeId, Long labelId);
Mono<Void> clearLabelsFromType(Long typeId);
}
@@ -0,0 +1,28 @@
package cn.novalon.gym.manage.groupcourse.service;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface IGroupCourseRecommendService {
Mono<GroupCourseRecommend> findById(Long id);
Flux<GroupCourseRecommend> findAll();
Flux<GroupCourseRecommend> findAll(String sortBy, String sortOrder);
Flux<GroupCourseRecommend> findAllActive();
Flux<GroupCourseRecommend> findByCourseId(Long courseId);
Mono<GroupCourseRecommend> create(GroupCourseRecommend recommend);
Mono<GroupCourseRecommend> update(Long id, GroupCourseRecommend recommend);
Mono<Void> delete(Long id);
Mono<GroupCourseRecommend> enable(Long id);
Mono<GroupCourseRecommend> disable(Long id);
}
@@ -4,11 +4,14 @@ package cn.novalon.gym.manage.groupcourse.service;
import cn.novalon.gym.manage.common.dto.PageRequest; import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse; import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
public interface IGroupCourseService { public interface IGroupCourseService {
Mono<GroupCourse> findById(Long id); Mono<GroupCourse> findById(Long id);
Mono<GroupCourseDetail> findDetailById(Long id);
Flux<GroupCourse> findAll(); Flux<GroupCourse> findAll();
Flux<GroupCourse> findAll(boolean includeDeleted); Flux<GroupCourse> findAll(boolean includeDeleted);
@@ -23,4 +26,8 @@ public interface IGroupCourseService {
Mono<GroupCourse> signIn(Long courseId, Long memberId); Mono<GroupCourse> signIn(Long courseId, Long memberId);
Mono<Void> delete(Long id); Mono<Void> delete(Long id);
Mono<GroupCourse> restore(Long id);
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
} }
@@ -0,0 +1,32 @@
package cn.novalon.gym.manage.groupcourse.service;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface IGroupCourseTypeService {
Mono<GroupCourseType> findById(Long id);
Flux<GroupCourseType> findAll();
Flux<GroupCourseType> findAll(boolean includeDeleted);
Flux<GroupCourseType> findByKeyword(String keyword);
Flux<GroupCourseType> findByCategory(String category);
Flux<GroupCourseType> findByCategoryAndKeyword(String category, String keyword);
Mono<GroupCourseType> create(GroupCourseType groupCourseType);
Mono<GroupCourseType> update(Long id, GroupCourseType groupCourseType);
Mono<Void> delete(Long id);
/**
* 获取分类列表(去重)
* @return 分类名称列表
*/
Flux<String> findCategories();
}
@@ -0,0 +1,112 @@
package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.repository.ICourseLabelRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.service.ICourseLabelService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
@Service
public class CourseLabelService implements ICourseLabelService {
private static final Logger logger = LoggerFactory.getLogger(CourseLabelService.class);
private static final String CACHE_KEY_DETAIL_PREFIX = "group_course:detail:";
private final ICourseLabelRepository courseLabelRepository;
private final IGroupCourseRepository groupCourseRepository;
private final RedisUtil redisUtil;
public CourseLabelService(ICourseLabelRepository courseLabelRepository,
IGroupCourseRepository groupCourseRepository,
RedisUtil redisUtil) {
this.courseLabelRepository = courseLabelRepository;
this.groupCourseRepository = groupCourseRepository;
this.redisUtil = redisUtil;
}
private Mono<Void> invalidateGroupCourseDetailCache(Long typeId) {
return groupCourseRepository.findByCourseType(typeId)
.flatMap(course -> {
String cacheKey = CACHE_KEY_DETAIL_PREFIX + course.getId();
return redisUtil.delete(cacheKey)
.doOnSuccess(deleted -> logger.debug("清除团课详情缓存 - courseId={}", course.getId()));
})
.then();
}
@Override
public Mono<CourseLabel> findById(Long id) {
return courseLabelRepository.findById(id);
}
@Override
public Flux<CourseLabel> findAll() {
return courseLabelRepository.findAll();
}
@Override
public Flux<CourseLabel> findByKeyword(String keyword) {
return courseLabelRepository.findByKeyword(keyword);
}
@Override
public Mono<CourseLabel> create(CourseLabel courseLabel) {
return courseLabelRepository.findByLabelName(courseLabel.getLabelName())
.flatMap(existing -> Mono.<CourseLabel>error(new RuntimeException("标签名称已存在")))
.switchIfEmpty(courseLabelRepository.save(courseLabel))
.doOnSuccess(label -> logger.info("标签创建成功 - id={}, name={}", label.getId(), label.getLabelName()))
.doOnError(error -> logger.error("标签创建失败 - error: {}", error.getMessage()));
}
@Override
public Mono<CourseLabel> update(Long id, CourseLabel courseLabel) {
courseLabel.setId(id);
return courseLabelRepository.update(courseLabel)
.doOnSuccess(label -> logger.info("标签更新成功 - id={}", id))
.doOnError(error -> logger.error("标签更新失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<Void> delete(Long id) {
return courseLabelRepository.deleteById(id)
.doOnSuccess(v -> logger.info("标签删除成功 - id={}", id))
.doOnError(error -> logger.error("标签删除失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Flux<CourseLabel> findByTypeId(Long typeId) {
return courseLabelRepository.findByTypeId(typeId);
}
@Override
public Mono<Void> addLabelsToType(Long typeId, List<Long> labelIds) {
return courseLabelRepository.addLabelsToType(typeId, labelIds)
.then(invalidateGroupCourseDetailCache(typeId))
.doOnSuccess(v -> logger.info("标签添加到类型成功 - typeId={}, labelIds={}", typeId, labelIds))
.doOnError(error -> logger.error("标签添加到类型失败 - typeId={}, error: {}", typeId, error.getMessage()));
}
@Override
public Mono<Void> removeLabelFromType(Long typeId, Long labelId) {
return courseLabelRepository.removeLabelFromType(typeId, labelId)
.then(invalidateGroupCourseDetailCache(typeId))
.doOnSuccess(v -> logger.info("从类型移除标签成功 - typeId={}, labelId={}", typeId, labelId))
.doOnError(error -> logger.error("从类型移除标签失败 - typeId={}, labelId={}, error: {}", typeId, labelId, error.getMessage()));
}
@Override
public Mono<Void> clearLabelsFromType(Long typeId) {
return courseLabelRepository.clearLabelsFromType(typeId)
.then(invalidateGroupCourseDetailCache(typeId))
.doOnSuccess(v -> logger.info("清空类型标签成功 - typeId={}", typeId))
.doOnError(error -> logger.error("清空类型标签失败 - typeId={}, error: {}", typeId, error.getMessage()));
}
}
@@ -0,0 +1,142 @@
package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRecommendRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Service
public class GroupCourseRecommendService implements IGroupCourseRecommendService {
private static final Logger logger = LoggerFactory.getLogger(GroupCourseRecommendService.class);
private final IGroupCourseRecommendRepository recommendRepository;
private final IGroupCourseRepository groupCourseRepository;
public GroupCourseRecommendService(IGroupCourseRecommendRepository recommendRepository,
IGroupCourseRepository groupCourseRepository) {
this.recommendRepository = recommendRepository;
this.groupCourseRepository = groupCourseRepository;
}
@Override
public Mono<GroupCourseRecommend> findById(Long id) {
return recommendRepository.findById(id)
.flatMap(this::fillGroupCourseInfo);
}
@Override
public Flux<GroupCourseRecommend> findAll() {
return recommendRepository.findAll()
.flatMap(this::fillGroupCourseInfo);
}
@Override
public Flux<GroupCourseRecommend> findAll(String sortBy, String sortOrder) {
return recommendRepository.findAll(sortBy, sortOrder)
.flatMap(this::fillGroupCourseInfo);
}
@Override
public Flux<GroupCourseRecommend> findAllActive() {
return recommendRepository.findAllActive()
.flatMap(this::fillGroupCourseInfo);
}
@Override
public Flux<GroupCourseRecommend> findByCourseId(Long courseId) {
return recommendRepository.findByCourseId(courseId)
.flatMap(this::fillGroupCourseInfo);
}
@Override
public Mono<GroupCourseRecommend> create(GroupCourseRecommend recommend) {
if (recommend.getCourseId() == null) {
return Mono.error(new RuntimeException("团课ID不能为空"));
}
return groupCourseRepository.findByIdAndDeletedAtIsNull(recommend.getCourseId())
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
.flatMap(course -> {
return recommendRepository.save(recommend)
.doOnSuccess(r -> logger.info("团课推荐创建成功 - id={}, courseId={}", r.getId(), r.getCourseId()))
.doOnError(error -> logger.error("团课推荐创建失败 - error: {}", error.getMessage()));
});
}
@Override
public Mono<GroupCourseRecommend> update(Long id, GroupCourseRecommend recommend) {
return recommendRepository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("团课推荐不存在")))
.flatMap(existing -> {
if (recommend.getRecommendTitle() != null) {
existing.setRecommendTitle(recommend.getRecommendTitle());
}
if (recommend.getRecommendContent() != null) {
existing.setRecommendContent(recommend.getRecommendContent());
}
if (recommend.getRecommendReason() != null) {
existing.setRecommendReason(recommend.getRecommendReason());
}
if (recommend.getPriority() != null) {
existing.setPriority(recommend.getPriority());
}
if (recommend.getIsActive() != null) {
existing.setIsActive(recommend.getIsActive());
}
if (recommend.getCourseId() != null) {
existing.setCourseId(recommend.getCourseId());
}
return recommendRepository.update(existing);
})
.doOnSuccess(r -> logger.info("团课推荐更新成功 - id={}", id))
.doOnError(error -> logger.error("团课推荐更新失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<Void> delete(Long id) {
return recommendRepository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("团课推荐不存在")))
.flatMap(recommend -> {
return recommendRepository.deleteById(id)
.doOnSuccess(v -> logger.info("团课推荐删除成功 - id={}", id))
.doOnError(error -> logger.error("团课推荐删除失败 - id={}, error: {}", id, error.getMessage()));
});
}
@Override
public Mono<GroupCourseRecommend> enable(Long id) {
return recommendRepository.updateActiveStatus(id, true)
.switchIfEmpty(Mono.error(new RuntimeException("团课推荐不存在")))
.doOnSuccess(r -> logger.info("团课推荐启用成功 - id={}", id))
.doOnError(error -> logger.error("团课推荐启用失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<GroupCourseRecommend> disable(Long id) {
return recommendRepository.updateActiveStatus(id, false)
.switchIfEmpty(Mono.error(new RuntimeException("团课推荐不存在")))
.doOnSuccess(r -> logger.info("团课推荐禁用成功 - id={}", id))
.doOnError(error -> logger.error("团课推荐禁用失败 - id={}, error: {}", id, error.getMessage()));
}
private Mono<GroupCourseRecommend> fillGroupCourseInfo(GroupCourseRecommend recommend) {
if (recommend.getCourseId() == null) {
return Mono.just(recommend);
}
return groupCourseRepository.findByIdAndDeletedAtIsNull(recommend.getCourseId())
.map(course -> {
recommend.setGroupCourse(course);
return recommend;
})
.defaultIfEmpty(recommend);
}
}
@@ -4,14 +4,21 @@ package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.common.dto.PageRequest; import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse; import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.common.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.enums.CourseEvent; import cn.novalon.gym.manage.groupcourse.enums.CourseEvent;
import cn.novalon.gym.manage.groupcourse.enums.CourseStatus; import cn.novalon.gym.manage.groupcourse.enums.CourseStatus;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseStateMachine; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseStateMachine;
import cn.novalon.gym.manage.groupcourse.repository.ICourseLabelRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil;
import cn.novalon.gym.manage.member.entity.MemberCard; import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord; import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.enums.MemberCardType; import cn.novalon.gym.manage.member.enums.MemberCardType;
@@ -21,11 +28,14 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@Service @Service
public class GroupCourseService implements IGroupCourseService { public class GroupCourseService implements IGroupCourseService {
@@ -33,32 +43,129 @@ public class GroupCourseService implements IGroupCourseService {
private final IGroupCourseRepository groupCourseRepository; private final IGroupCourseRepository groupCourseRepository;
private final IGroupCourseBookingRepository bookingRepository; private final IGroupCourseBookingRepository bookingRepository;
private final IGroupCourseTypeRepository groupCourseTypeRepository;
private final ICourseLabelRepository courseLabelRepository;
private final IMemberCardRecordService memberCardRecordService; private final IMemberCardRecordService memberCardRecordService;
private final MemberCardRepository memberCardRepository; private final MemberCardRepository memberCardRepository;
private final RedisUtil redisUtil; private final RedisUtil redisUtil;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final GroupCourseStateMachine stateMachine; private final GroupCourseStateMachine stateMachine;
private final DatabaseClient databaseClient;
private static final String CACHE_KEY_PREFIX = "group_course:page:"; private static final String CACHE_KEY_PREFIX = "group_course:page:";
private static final String CACHE_KEY_ID_PREFIX = "group_course:id:"; private static final String CACHE_KEY_ID_PREFIX = "group_course:id:";
private static final String CACHE_KEY_DETAIL_PREFIX = "group_course:detail:";
private static final long CACHE_EXPIRE_SECONDS = 300; private static final long CACHE_EXPIRE_SECONDS = 300;
private static final double DEFAULT_GROUP_COURSE_PRICE = 50.0; private static final double DEFAULT_GROUP_COURSE_PRICE = 50.0;
public GroupCourseService(IGroupCourseRepository groupCourseRepository, public GroupCourseService(IGroupCourseRepository groupCourseRepository,
IGroupCourseBookingRepository bookingRepository, IGroupCourseBookingRepository bookingRepository,
IGroupCourseTypeRepository groupCourseTypeRepository,
ICourseLabelRepository courseLabelRepository,
IMemberCardRecordService memberCardRecordService, IMemberCardRecordService memberCardRecordService,
MemberCardRepository memberCardRepository, MemberCardRepository memberCardRepository,
RedisUtil redisUtil, RedisUtil redisUtil,
ObjectMapper objectMapper, ObjectMapper objectMapper,
GroupCourseStateMachine stateMachine){ GroupCourseStateMachine stateMachine,
DatabaseClient databaseClient){
this.groupCourseRepository = groupCourseRepository; this.groupCourseRepository = groupCourseRepository;
this.bookingRepository = bookingRepository; this.bookingRepository = bookingRepository;
this.groupCourseTypeRepository = groupCourseTypeRepository;
this.courseLabelRepository = courseLabelRepository;
this.memberCardRecordService = memberCardRecordService; this.memberCardRecordService = memberCardRecordService;
this.memberCardRepository = memberCardRepository; this.memberCardRepository = memberCardRepository;
this.redisUtil = redisUtil; this.redisUtil = redisUtil;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.stateMachine = stateMachine; this.stateMachine = stateMachine;
this.databaseClient = databaseClient;
}
@Override
public Mono<GroupCourseDetail> findDetailById(Long id) {
String cacheKey = CACHE_KEY_DETAIL_PREFIX + id;
return redisUtil.get(cacheKey, String.class)
.flatMap(cachedJson -> {
if (cachedJson != null) {
try {
GroupCourseDetail detail = objectMapper.readValue(cachedJson, GroupCourseDetail.class);
logger.info("缓存命中 - findDetailById: id={}", id);
return Mono.just(detail);
} catch (JsonProcessingException e) {
logger.warn("缓存解析失败,删除缓存 - id: {}, error: {}", id, e.getMessage());
return redisUtil.delete(cacheKey).then(Mono.empty());
}
}
return Mono.empty();
})
.switchIfEmpty(
groupCourseRepository.findByIdAndDeletedAtIsNull(id)
.flatMap(course -> {
// 查询类型信息
Long courseTypeId = course.getCourseType();
if (courseTypeId == null) {
// 没有类型,直接构建详情
return Mono.just(buildDetail(course, null));
}
// 有类型,查询类型信息
return groupCourseTypeRepository.findById(courseTypeId)
.flatMap(type -> {
// 查询标签
return courseLabelRepository.findByTypeId(type.getId())
.collectList()
.map(labels -> {
type.setLabels(labels);
return buildDetail(course, type);
});
})
.switchIfEmpty(Mono.just(buildDetail(course, null)));
})
.flatMap(detail -> {
try {
String jsonData = objectMapper.writeValueAsString(detail);
return redisUtil.setWithExpire(cacheKey, jsonData, CACHE_EXPIRE_SECONDS)
.thenReturn(detail)
.doOnSuccess(d -> logger.debug("缓存已设置 - findDetailById: id={}", id));
} catch (JsonProcessingException e) {
logger.error("缓存设置失败 - id: {}, error: {}", id, e.getMessage());
return Mono.just(detail);
}
})
.doOnSubscribe(sub -> logger.debug("缓存未命中,查询数据库 - findDetailById: id={}", id))
);
}
/**
* 构建团课完整信息对象
*/
private GroupCourseDetail buildDetail(GroupCourse course, GroupCourseType type) {
GroupCourseDetail detail = new GroupCourseDetail();
detail.setId(course.getId());
detail.setCourseName(course.getCourseName());
detail.setCoachId(course.getCoachId());
detail.setCourseType(course.getCourseType());
detail.setStartTime(course.getStartTime());
detail.setEndTime(course.getEndTime());
detail.setMaxMembers(course.getMaxMembers());
detail.setCurrentMembers(course.getCurrentMembers());
detail.setStatus(course.getStatus());
detail.setLocation(course.getLocation());
detail.setCoverImage(course.getCoverImage());
detail.setDescription(course.getDescription());
detail.setStoredValueAmount(course.getStoredValueAmount());
detail.setQrCodePath(course.getQrCodePath());
detail.setCreatedAt(course.getCreatedAt());
detail.setUpdatedAt(course.getUpdatedAt());
// 设置类型信息
if (type != null) {
detail.setTypeInfo(type);
}
return detail;
} }
@Override @Override
@@ -163,7 +270,38 @@ public class GroupCourseService implements IGroupCourseService {
@Override @Override
public Mono<GroupCourse> create(GroupCourse groupCourse) { public Mono<GroupCourse> create(GroupCourse groupCourse) {
return groupCourseRepository.save(groupCourse) return groupCourseRepository.save(groupCourse)
.doOnSuccess(course -> logger.info("团课创建成功 - id={}, name={}", course.getId(), course.getCourseName())) .flatMap(course -> {
try {
// 生成二维码内容:团课基础信息JSON
Map<String, Object> qrCodeContent = new HashMap<>();
qrCodeContent.put("id", course.getId());
qrCodeContent.put("courseName", course.getCourseName());
qrCodeContent.put("coachId", course.getCoachId());
qrCodeContent.put("courseType", course.getCourseType());
qrCodeContent.put("startTime", course.getStartTime());
qrCodeContent.put("endTime", course.getEndTime());
qrCodeContent.put("maxMembers", course.getMaxMembers());
qrCodeContent.put("location", course.getLocation());
qrCodeContent.put("description", course.getDescription());
qrCodeContent.put("createdAt", course.getCreatedAt());
String jsonContent = objectMapper.writeValueAsString(qrCodeContent);
// 生成二维码并上传到阿里云OSS
String qrCodeUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent);
course.setQrCodePath(qrCodeUrl);
logger.info("团课二维码上传到OSS成功 - id={}, qrCodeUrl={}", course.getId(), qrCodeUrl);
// 更新团课信息,保存二维码路径
return groupCourseRepository.update(course)
.doOnSuccess(updatedCourse -> logger.info("团课创建成功 - id={}, name={}", updatedCourse.getId(), updatedCourse.getCourseName()));
} catch (Exception e) {
logger.error("团课二维码生成失败 - id={}, error: {}", course.getId(), e.getMessage(), e);
// 即使二维码生成失败,也返回成功创建的团课
return Mono.just(course);
}
})
.flatMap(course -> clearCache().thenReturn(course)) .flatMap(course -> clearCache().thenReturn(course))
.doOnError(error -> logger.error("团课创建失败 - error: {}", error.getMessage())); .doOnError(error -> logger.error("团课创建失败 - error: {}", error.getMessage()));
} }
@@ -203,12 +341,12 @@ public class GroupCourseService implements IGroupCourseService {
if (groupCourse.getDescription() != null) { if (groupCourse.getDescription() != null) {
existing.setDescription(groupCourse.getDescription()); existing.setDescription(groupCourse.getDescription());
} }
if (groupCourse.getPointCardAmount() != null) {
existing.setPointCardAmount(groupCourse.getPointCardAmount());
}
if (groupCourse.getStoredValueAmount() != null) { if (groupCourse.getStoredValueAmount() != null) {
existing.setStoredValueAmount(groupCourse.getStoredValueAmount()); existing.setStoredValueAmount(groupCourse.getStoredValueAmount());
} }
if (groupCourse.getQrCodePath() != null) {
existing.setQrCodePath(groupCourse.getQrCodePath());
}
return groupCourseRepository.update(existing); return groupCourseRepository.update(existing);
}) })
.doOnSuccess(course -> logger.info("团课更新成功 - id={}", id)) .doOnSuccess(course -> logger.info("团课更新成功 - id={}", id))
@@ -258,12 +396,11 @@ public class GroupCourseService implements IGroupCourseService {
return groupCourseRepository.findByIdAndDeletedAtIsNull(courseId) return groupCourseRepository.findByIdAndDeletedAtIsNull(courseId)
.switchIfEmpty(Mono.error(new RuntimeException("课程不存在"))) .switchIfEmpty(Mono.error(new RuntimeException("课程不存在")))
.flatMap(course -> { .flatMap(course -> {
Integer pointCardAmount = course.getPointCardAmount() != null ? course.getPointCardAmount() : 1;
Double storedValueAmount = course.getStoredValueAmount() != null ? Double storedValueAmount = course.getStoredValueAmount() != null ?
course.getStoredValueAmount().doubleValue() : DEFAULT_GROUP_COURSE_PRICE; course.getStoredValueAmount().doubleValue() : DEFAULT_GROUP_COURSE_PRICE;
logger.info("课程权益信息: courseId={}, pointCardAmount={}, storedValueAmount={}", logger.info("课程权益信息: courseId={}, storedValueAmount={}",
courseId, pointCardAmount, storedValueAmount); courseId, storedValueAmount);
return bookingRepository.findByCourseId(courseId) return bookingRepository.findByCourseId(courseId)
.filter(booking -> "0".equals(booking.getStatus())) // 只处理已预约状态的记录 .filter(booking -> "0".equals(booking.getStatus())) // 只处理已预约状态的记录
@@ -274,8 +411,8 @@ public class GroupCourseService implements IGroupCourseService {
return Mono.empty(); return Mono.empty();
} }
// 根据会员卡类型返还权益 // 根据会员卡类型返还权益(仅储值卡返还金额)
return refundCardUsage(booking.getId(), memberCardRecordId, pointCardAmount, storedValueAmount) return refundCardUsage(booking.getId(), memberCardRecordId, storedValueAmount)
.doOnSuccess(unused -> logger.info("会员权益返还成功: bookingId={}, memberId={}", .doOnSuccess(unused -> logger.info("会员权益返还成功: bookingId={}, memberId={}",
booking.getId(), booking.getMemberId())) booking.getId(), booking.getMemberId()))
.doOnError(error -> logger.error("会员权益返还失败: bookingId={}, memberId={}, error={}", .doOnError(error -> logger.error("会员权益返还失败: bookingId={}, memberId={}, error={}",
@@ -291,11 +428,10 @@ public class GroupCourseService implements IGroupCourseService {
* *
* @param bookingId 预约ID * @param bookingId 预约ID
* @param recordId 会员卡记录ID * @param recordId 会员卡记录ID
* @param pointCardAmount 次卡返还次数
* @param storedValueAmount 储值卡返还金额 * @param storedValueAmount 储值卡返还金额
* @return 空Mono * @return 空Mono
*/ */
private Mono<Void> refundCardUsage(Long bookingId, Long recordId, Integer pointCardAmount, Double storedValueAmount) { private Mono<Void> refundCardUsage(Long bookingId, Long recordId, Double storedValueAmount) {
return memberCardRecordService.findById(recordId) return memberCardRecordService.findById(recordId)
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在"))) .switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
.flatMap(record -> { .flatMap(record -> {
@@ -306,10 +442,9 @@ public class GroupCourseService implements IGroupCourseService {
Mono<Void> refundOperation; Mono<Void> refundOperation;
switch (cardType) { switch (cardType) {
case COUNT_CARD: case COUNT_CARD:
// 次卡返还次数(基于课程设置的pointCardAmount // 次卡不再支持团课预约,无需返还
logger.debug("返还次卡次数: recordId={}, amount={}", recordId, pointCardAmount); logger.debug("次卡不返还权益: bookingId={}", bookingId);
refundOperation = memberCardRecordService.renewCard(recordId, pointCardAmount, 0.0, record.getExpireTime()) refundOperation = Mono.empty();
.then(Mono.empty());
break; break;
case STORED_VALUE_CARD: case STORED_VALUE_CARD:
// 储值卡返还金额(基于课程设置的storedValueAmount // 储值卡返还金额(基于课程设置的storedValueAmount
@@ -341,26 +476,56 @@ public class GroupCourseService implements IGroupCourseService {
@Override @Override
public Mono<GroupCourse> signIn(Long courseId, Long memberId) { public Mono<GroupCourse> signIn(Long courseId, Long memberId) {
return groupCourseRepository.findByIdAndDeletedAtIsNull(courseId) return groupCourseRepository.findByIdAndDeletedAtIsNull(courseId)
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在"))) .switchIfEmpty(Mono.error(new RuntimeException("团课不存在或已删除")))
.flatMap(course -> { .flatMap(course -> {
if (course.getStatus() != 0L) { // 校验1:团课已取消
return Mono.error(new RuntimeException("课程状态不允许签到")); if (course.getStatus().equals(CourseStatus.CANCELLED.getValue())) {
return Mono.error(new RuntimeException("团课已取消,无法签到"));
} }
// 校验2:团课已结束
if (course.getStatus().equals(CourseStatus.ENDED.getValue())) {
return Mono.error(new RuntimeException("团课已结束,无法签到"));
}
// 校验3:签到时间校验(开课前2小时内才能签到)
LocalDateTime now = LocalDateTime.now();
LocalDateTime signInStart = course.getStartTime().minusHours(2);
if (now.isBefore(signInStart)) {
return Mono.error(new RuntimeException("未到签到时间,请在开课前2小时内签到"));
}
if (now.isAfter(course.getEndTime())) {
return Mono.error(new RuntimeException("团课已结束,无法签到"));
}
// 校验4:课程已满员
if (course.getCurrentMembers() >= course.getMaxMembers()) { if (course.getCurrentMembers() >= course.getMaxMembers()) {
return Mono.error(new RuntimeException("课程已满员")); return Mono.error(new RuntimeException("课程已满员,无法签到"));
} }
// 检查会员是否已预约此课程 // 校验5:用户今日是否已到店签到(直接查询sign_in_record表)
return bookingRepository.findValidBooking(courseId, memberId) LocalDateTime todayStart = LocalDateTime.now().toLocalDate().atStartOfDay();
.switchIfEmpty(Mono.error(new RuntimeException("会员未预约此课程"))) LocalDateTime todayEnd = todayStart.plusDays(1);
.flatMap(booking -> { return databaseClient.sql("SELECT sign_in_status FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false ORDER BY sign_in_time DESC LIMIT 1")
// 更新课程当前人数 .bind("memberId", memberId)
return groupCourseRepository.updateCurrentMembers(courseId, 1) .bind("startTime", todayStart)
.flatMap(updatedCourse -> { .bind("endTime", todayEnd)
// 更新预约状态为已出席 .map(row -> row.get("sign_in_status", String.class))
return bookingRepository.updateStatus(booking.getId(), "2") .one()
.thenReturn(updatedCourse); .switchIfEmpty(Mono.error(new RuntimeException("请先完成到店签到")))
.flatMap(status -> {
if (!"SUCCESS".equals(status)) {
return Mono.error(new RuntimeException("到店签到未成功,请重新签到"));
}
// 校验6:用户已预约此课程(有效预约,状态为0-已预约)
return bookingRepository.findValidBooking(courseId, memberId)
.switchIfEmpty(Mono.error(new RuntimeException("您未预约此课程,无法签到")))
.flatMap(booking -> {
return groupCourseRepository.updateCurrentMembers(courseId, 1)
.flatMap(updatedCourse -> {
return bookingRepository.updateStatus(booking.getId(), "2")
.thenReturn(updatedCourse);
});
}); });
}); });
}) })
@@ -371,14 +536,14 @@ public class GroupCourseService implements IGroupCourseService {
@Override @Override
public Mono<Void> delete(Long id) { public Mono<Void> delete(Long id) {
// 先查询课程状态,只有已取消的课程才能删除 // 已取消或已结束的课程才能删除
return groupCourseRepository.findByIdAndDeletedAtIsNull(id) return groupCourseRepository.findByIdAndDeletedAtIsNull(id)
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在"))) .switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
.flatMap(course -> { .flatMap(course -> {
// 检查课程状态是否为已取消(状态码1) Long status = course.getStatus();
if (course.getStatus() == null || !course.getStatus().equals(CourseStatus.CANCELLED.getValue())) { if (status == null || (!status.equals(CourseStatus.CANCELLED.getValue()) && !status.equals(CourseStatus.ENDED.getValue()))) {
return Mono.error(new RuntimeException("只有已取消的课程才能删除,当前状态: " + return Mono.error(new RuntimeException("只有已取消或已结束的课程才能删除,当前状态: " +
(course.getStatus() != null ? course.getStatus() : "未知"))); (status != null ? status : "未知")));
} }
// 删除课程 // 删除课程
@@ -389,8 +554,29 @@ public class GroupCourseService implements IGroupCourseService {
}); });
} }
@Override
public Mono<GroupCourse> restore(Long id) {
return groupCourseRepository.restoreById(id)
.doOnSuccess(course -> logger.info("团课恢复成功 - id={}", id))
.flatMap(course -> clearCache().thenReturn(course))
.doOnError(error -> logger.error("团课恢复失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query) {
logger.info("多条件查询团课 - courseName={}, courseType={}, startDate={}, endDate={}, timePeriod={}, priceSort={}, remainingMost={}",
query.getCourseName(), query.getCourseType(), query.getStartDate(), query.getEndDate(),
query.getTimePeriod(), query.getPriceSort(), query.getRemainingMost());
return groupCourseRepository.searchGroupCourses(query)
.doOnSuccess(result -> logger.info("多条件查询结果 - total={}, page={}, size={}",
result.getTotalElements(), result.getCurrentPage(), result.getPageSize()))
.doOnError(error -> logger.error("多条件查询失败 - error: {}", error.getMessage()));
}
private Mono<Void> clearCache() { private Mono<Void> clearCache() {
return redisUtil.deleteByPattern(CACHE_KEY_PREFIX + "*") return redisUtil.deleteByPattern(CACHE_KEY_PREFIX + "*")
.then(redisUtil.deleteByPattern(CACHE_KEY_ID_PREFIX + "*")).then(); .then(redisUtil.deleteByPattern(CACHE_KEY_ID_PREFIX + "*"))
.then(redisUtil.deleteByPattern(CACHE_KEY_DETAIL_PREFIX + "*")).then();
} }
} }
@@ -0,0 +1,94 @@
package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Service
public class GroupCourseTypeService implements IGroupCourseTypeService {
private static final Logger logger = LoggerFactory.getLogger(GroupCourseTypeService.class);
private final IGroupCourseTypeRepository groupCourseTypeRepository;
private final IGroupCourseRepository groupCourseRepository;
public GroupCourseTypeService(IGroupCourseTypeRepository groupCourseTypeRepository,
IGroupCourseRepository groupCourseRepository) {
this.groupCourseTypeRepository = groupCourseTypeRepository;
this.groupCourseRepository = groupCourseRepository;
}
@Override
public Mono<GroupCourseType> findById(Long id) {
return groupCourseTypeRepository.findById(id);
}
@Override
public Flux<GroupCourseType> findAll() {
return groupCourseTypeRepository.findAll(false);
}
@Override
public Flux<GroupCourseType> findAll(boolean includeDeleted) {
return groupCourseTypeRepository.findAll(includeDeleted);
}
@Override
public Flux<GroupCourseType> findByKeyword(String keyword) {
return groupCourseTypeRepository.findByKeyword(keyword);
}
@Override
public Flux<GroupCourseType> findByCategory(String category) {
return groupCourseTypeRepository.findByCategory(category);
}
@Override
public Flux<GroupCourseType> findByCategoryAndKeyword(String category, String keyword) {
return groupCourseTypeRepository.findByCategoryAndKeyword(category, keyword);
}
@Override
public Mono<GroupCourseType> create(GroupCourseType groupCourseType) {
return groupCourseTypeRepository.findByTypeName(groupCourseType.getTypeName())
.flatMap(existing -> Mono.<GroupCourseType>error(new RuntimeException("团课类型名称已存在")))
.switchIfEmpty(groupCourseTypeRepository.save(groupCourseType))
.doOnSuccess(type -> logger.info("团课类型创建成功 - id={}, name={}", type.getId(), type.getTypeName()))
.doOnError(error -> logger.error("团课类型创建失败 - error: {}", error.getMessage()));
}
@Override
public Mono<GroupCourseType> update(Long id, GroupCourseType groupCourseType) {
return groupCourseTypeRepository.update(groupCourseType)
.doOnSuccess(type -> logger.info("团课类型更新成功 - id={}", id))
.doOnError(error -> logger.error("团课类型更新失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<Void> delete(Long id) {
return groupCourseRepository.findByCourseType(id)
.hasElements()
.flatMap(hasCourses -> {
if (hasCourses) {
return Mono.<Void>error(new RuntimeException("该类型下存在关联团课,无法删除"));
}
return groupCourseTypeRepository.deleteById(id);
})
.doOnSuccess(v -> logger.info("团课类型删除成功 - id={}", id))
.doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Flux<String> findCategories() {
return groupCourseTypeRepository.findAll(false)
.map(GroupCourseType::getCategory)
.filter(category -> category != null && !category.isEmpty())
.distinct();
}
}
@@ -0,0 +1,174 @@
package cn.novalon.gym.manage.groupcourse.util;
import com.aliyun.oss.HttpMethod;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.GeneratePresignedUrlRequest;
import com.aliyun.oss.model.PutObjectRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.InputStream;
import java.net.URL;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
/**
* 阿里云OSS工具类
*/
public class OSSUtil {
private static final Logger logger = LoggerFactory.getLogger(OSSUtil.class);
// OSS配置信息
private static final String ENDPOINT = "oss-cn-beijing.aliyuncs.com";
private static final String ACCESS_KEY_ID = "LTAI5t9wHCiH68Xjxg64Xx4Y";
private static final String ACCESS_KEY_SECRET = "isAfz1IFGAnV13LOIrVg19aPhY8aRq";
private static final String BUCKET_NAME = "ycc-filesaver";
// OSS访问地址前缀
private static final String OSS_URL_PREFIX = "https://" + BUCKET_NAME + "." + ENDPOINT + "/";
// 文件存储目录
private static final String QRCODE_DIR = "qrcode/";
private static final String COVER_DIR = "cover/";
// 预签名URL有效期(秒)
private static final long PRESIGN_EXPIRE_SECONDS = 300;
/**
* 上传文件到阿里云OSS(文件默认继承Bucket权限,不设置ACL)
*
* @param localFilePath 本地文件路径
* @param fileName 文件名(不含路径)
* @return OSS object key(不含域名前缀)
*/
public static String uploadToOSS(String localFilePath, String fileName) {
OSS ossClient = null;
try {
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
String datePath = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String ossFilePath = QRCODE_DIR + datePath + "/" + fileName;
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath));
ossClient.putObject(putObjectRequest);
logger.info("文件上传到OSS成功: localPath={}, ossKey={}", localFilePath, ossFilePath);
return ossFilePath;
} catch (Exception e) {
logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e);
throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e);
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
/**
* 上传文件到阿里云OSS(自定义存储路径,文件默认继承Bucket权限)
*
* @param localFilePath 本地文件路径
* @param ossDirectory OSS存储目录
* @param fileName 文件名(不含路径)
* @return OSS object key(不含域名前缀)
*/
public static String uploadToOSS(String localFilePath, String ossDirectory, String fileName) {
OSS ossClient = null;
try {
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
String ossFilePath = ossDirectory + fileName;
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath));
ossClient.putObject(putObjectRequest);
logger.info("文件上传到OSS成功: localPath={}, ossKey={}", localFilePath, ossFilePath);
return ossFilePath;
} catch (Exception e) {
logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e);
throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e);
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
/**
* 上传封面图到阿里云OSS(使用InputStream,文件默认继承Bucket权限)
*
* @param inputStream 文件输入流
* @param fileName 文件名(不含路径)
* @return OSS object key(不含域名前缀)
*/
public static String uploadCoverToOSS(InputStream inputStream, String fileName) {
OSS ossClient = null;
try {
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
String datePath = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String ossFilePath = COVER_DIR + datePath + "/" + fileName;
ossClient.putObject(BUCKET_NAME, ossFilePath, inputStream);
logger.info("封面上传到OSS成功: fileName={}, ossKey={}", fileName, ossFilePath);
return ossFilePath;
} catch (Exception e) {
logger.error("封面上传到OSS失败 - fileName: {}, error: {}", fileName, e.getMessage(), e);
throw new RuntimeException("封面上传到OSS失败: " + e.getMessage(), e);
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
/**
* 生成OSS对象预签名URL(临时访问链接)
*
* @param ossKey OSS对象Key(不含域名前缀)
* @return 预签名URL
*/
public static String generatePresignedUrl(String ossKey) {
return generatePresignedUrl(ossKey, PRESIGN_EXPIRE_SECONDS);
}
/**
* 生成OSS对象预签名URL(可指定有效期)
*
* @param ossKey OSS对象Key(不含域名前缀)
* @param expireSeconds 有效期(秒)
* @return 预签名URL
*/
public static String generatePresignedUrl(String ossKey, long expireSeconds) {
OSS ossClient = null;
try {
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
Date expiration = new Date(System.currentTimeMillis() + expireSeconds * 1000);
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(BUCKET_NAME, ossKey, HttpMethod.GET);
request.setExpiration(expiration);
URL signedUrl = ossClient.generatePresignedUrl(request);
return signedUrl.toString();
} catch (Exception e) {
logger.error("生成预签名URL失败 - ossKey: {}, error: {}", ossKey, e.getMessage(), e);
throw new RuntimeException("生成预签名URL失败: " + e.getMessage(), e);
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
/**
* 根据OSS Key拼接公开访问URL(仅当Bucket为公共读时有效)
*/
public static String getPublicUrl(String ossKey) {
return OSS_URL_PREFIX + ossKey;
}
}
@@ -0,0 +1,156 @@
package cn.novalon.gym.manage.groupcourse.util;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* 二维码生成工具类
*/
public class QRCodeUtil {
private static final Logger logger = LoggerFactory.getLogger(QRCodeUtil.class);
// 二维码默认保存路径(本地临时路径)
private static final String DEFAULT_SAVE_PATH = "D:\\Games\\exmp\\image";
// 二维码尺寸
private static final int QR_CODE_WIDTH = 300;
private static final int QR_CODE_HEIGHT = 300;
/**
* 生成二维码并保存到指定路径
*
* @param content 二维码内容
* @param savePath 保存路径
* @param fileName 文件名(不含扩展名)
* @return 生成的二维码文件完整路径
*/
public static String generateQRCode(String content, String savePath, String fileName) {
try {
Path directory = Paths.get(savePath);
if (!Files.exists(directory)) {
Files.createDirectories(directory);
logger.info("创建二维码保存目录: {}", savePath);
}
Map<EncodeHintType, Object> hints = new HashMap<>();
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
hints.put(EncodeHintType.MARGIN, 1);
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, QR_CODE_WIDTH, QR_CODE_HEIGHT, hints);
String filePath = Paths.get(savePath, fileName + ".png").toString();
Path path = Paths.get(filePath);
MatrixToImageWriter.writeToPath(bitMatrix, "PNG", path);
logger.info("二维码生成成功: {}", filePath);
return filePath;
} catch (WriterException e) {
logger.error("二维码生成失败 - WriterException: {}", e.getMessage(), e);
throw new RuntimeException("二维码生成失败: " + e.getMessage(), e);
} catch (IOException e) {
logger.error("二维码保存失败 - IOException: {}", e.getMessage(), e);
throw new RuntimeException("二维码保存失败: " + e.getMessage(), e);
}
}
/**
* 生成二维码并保存到默认路径
* 文件名格式: UUID + 创建时间
*
* @param content 二维码内容
* @return 生成的二维码文件完整路径
*/
public static String generateQRCode(String content) {
String uuid = UUID.randomUUID().toString().replace("-", "");
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
String fileName = uuid + "_" + timestamp;
return generateQRCode(content, DEFAULT_SAVE_PATH, fileName);
}
/**
* 生成二维码并保存到默认路径,使用自定义文件名
*
* @param content 二维码内容
* @param fileName 文件名(不含扩展名)
* @return 生成的二维码文件完整路径
*/
public static String generateQRCodeWithFileName(String content, String fileName) {
return generateQRCode(content, DEFAULT_SAVE_PATH, fileName);
}
/**
* 生成二维码并上传到阿里云OSS
* 文件名格式: UUID + 创建时间
*
* @param content 二维码内容
* @return 阿里云OSS访问地址
*/
public static String generateQRCodeAndUploadToOSS(String content) {
String uuid = UUID.randomUUID().toString().replace("-", "");
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
String fileName = uuid + "_" + timestamp + ".png";
return generateQRCodeAndUploadToOSS(content, fileName);
}
/**
* 生成二维码并上传到阿里云OSS
*
* @param content 二维码内容
* @param fileName 文件名(含扩展名)
* @return 阿里云OSS访问地址
*/
public static String generateQRCodeAndUploadToOSS(String content, String fileName) {
try {
Path tempDir = Files.createTempDirectory("qrcode_temp");
String tempFilePath = tempDir.resolve(fileName).toString();
Map<EncodeHintType, Object> hints = new HashMap<>();
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
hints.put(EncodeHintType.MARGIN, 1);
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, QR_CODE_WIDTH, QR_CODE_HEIGHT, hints);
MatrixToImageWriter.writeToPath(bitMatrix, "PNG", Paths.get(tempFilePath));
logger.info("二维码临时文件生成成功: {}", tempFilePath);
String ossUrl = OSSUtil.uploadToOSS(tempFilePath, fileName);
Files.deleteIfExists(Paths.get(tempFilePath));
Files.deleteIfExists(tempDir);
logger.info("临时文件已删除: {}", tempFilePath);
return ossUrl;
} catch (WriterException e) {
logger.error("二维码生成失败 - WriterException: {}", e.getMessage(), e);
throw new RuntimeException("二维码生成失败: " + e.getMessage(), e);
} catch (IOException e) {
logger.error("二维码处理失败 - IOException: {}", e.getMessage(), e);
throw new RuntimeException("二维码处理失败: " + e.getMessage(), e);
}
}
}
@@ -1,13 +0,0 @@
package cn.novalon.gym.manage.groupcourse;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class GymGroupCourseApplicationTests {
@Test
void contextLoads() {
}
}
@@ -0,0 +1,71 @@
package cn.novalon.gym.manage.groupcourse.util;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
/**
* QRCodeUtil测试类
*/
class QRCodeUtilTest {
@TempDir
Path tempDir;
@Test
void testGenerateQRCode() {
String content = "测试二维码内容";
String qrCodePath = QRCodeUtil.generateQRCode(content);
assertNotNull(qrCodePath, "二维码路径不应为空");
assertTrue(qrCodePath.endsWith(".png"), "二维码文件应为PNG格式");
assertTrue(qrCodePath.contains("D:\\Games\\exmp\\image"), "二维码应保存到指定路径");
System.out.println("生成的二维码路径: " + qrCodePath);
}
@Test
void testGenerateQRCodeWithCustomPath() {
String content = "自定义路径测试";
String customPath = tempDir.toString();
String fileName = "test_qrcode";
String qrCodePath = QRCodeUtil.generateQRCode(content, customPath, fileName);
assertNotNull(qrCodePath, "二维码路径不应为空");
assertTrue(qrCodePath.endsWith(".png"), "二维码文件应为PNG格式");
assertTrue(qrCodePath.contains(fileName), "二维码文件名应包含指定名称");
System.out.println("生成的二维码路径: " + qrCodePath);
}
@Test
void testGenerateQRCodeWithJsonContent() {
String jsonContent = "{\"id\":1,\"courseName\":\"瑜伽课\",\"coachId\":100,\"startTime\":\"2026-06-18T10:00:00\"}";
String qrCodePath = QRCodeUtil.generateQRCode(jsonContent);
assertNotNull(qrCodePath, "二维码路径不应为空");
assertTrue(qrCodePath.endsWith(".png"), "二维码文件应为PNG格式");
System.out.println("JSON内容二维码路径: " + qrCodePath);
}
@Test
void testGenerateQRCodeAndUploadToOSS() {
String jsonContent = "{\"id\":1,\"courseName\":\"瑜伽课\",\"coachId\":100,\"startTime\":\"2026-06-18T10:00:00\"}";
String ossUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent);
assertNotNull(ossUrl, "OSS访问地址不应为空");
assertTrue(ossUrl.startsWith("https://"), "OSS访问地址应为HTTPS");
assertTrue(ossUrl.contains("ycc-filesaver.oss-cn-beijing.aliyuncs.com"), "OSS访问地址应包含正确的域名");
assertTrue(ossUrl.contains("/qrcode/"), "OSS访问地址应包含qrcode目录");
assertTrue(ossUrl.endsWith(".png"), "OSS访问地址应为PNG格式");
System.out.println("上传到OSS的二维码地址: " + ossUrl);
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<FindBugsFilter>
<Match>
<Class name="~.*\.entity\..*" />
</Match>
<Match>
<Class name="~.*\.dto\..*" />
</Match>
<Match>
<Class name="~.*\.converter\..*" />
</Match>
</FindBugsFilter>
@@ -0,0 +1,59 @@
package cn.novalon.gym.manage.member.dto;
import cn.novalon.gym.manage.member.enums.GenderEnum;
import lombok.Data;
import java.time.LocalDate;
/**
* 管理员编辑会员信息DTO(含密码验证)
*
* @author system
* @date 2026-06-24
*/
@Data
public class AdminEditMemberDto {
/**
* 管理员密码(必填,用于验证身份)
*/
private String adminPassword;
/**
* 昵称
*/
private String nickname;
/**
* 性别
*/
private GenderEnum gender;
/**
* 生日
*/
private LocalDate birthday;
/**
* 地址
*/
private String address;
/**
* 头像
*/
private String avatar;
/**
* 转换为 UpdateMemberInfoDto
*/
public UpdateMemberInfoDto toUpdateMemberInfoDto() {
UpdateMemberInfoDto dto = new UpdateMemberInfoDto();
dto.setNickname(nickname);
dto.setGender(gender);
dto.setBirthday(birthday);
dto.setAddress(address);
dto.setAvatar(avatar);
return dto;
}
}
@@ -2,16 +2,21 @@ package cn.novalon.gym.manage.member.handler;
import cn.novalon.gym.manage.member.entity.MemberCard; import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.service.IMemberCardService; import cn.novalon.gym.manage.member.service.IMemberCardService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.util.Map;
/** /**
* 会员卡管理处理器 * 会员卡管理处理器
* *
@@ -24,9 +29,13 @@ import reactor.core.publisher.Mono;
public class MemberCardHandler { public class MemberCardHandler {
private final IMemberCardService memberCardService; private final IMemberCardService memberCardService;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public MemberCardHandler(IMemberCardService memberCardService) { public MemberCardHandler(IMemberCardService memberCardService, ISysUserService sysUserService, AuthUtil authUtil) {
this.memberCardService = memberCardService; this.memberCardService = memberCardService;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
} }
@Operation(summary = "根据ID查询会员卡类型", description = "查询指定ID的会员卡类型详情") @Operation(summary = "根据ID查询会员卡类型", description = "查询指定ID的会员卡类型详情")
@@ -60,27 +69,72 @@ public class MemberCardHandler {
.flatMap(card -> ServerResponse.status(HttpStatus.CREATED).bodyValue(card)); .flatMap(card -> ServerResponse.status(HttpStatus.CREATED).bodyValue(card));
} }
@Operation(summary = "更新会员卡类型", description = "更新会员卡类型信息") @Operation(summary = "更新会员卡类型", description = "更新会员卡类型信息,需验证管理员密码")
public Mono<ServerResponse> updateMemberCard(ServerRequest request) { public Mono<ServerResponse> updateMemberCard(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(MemberCard.class) String adminPassword = request.queryParam("adminPassword").orElse(null);
.flatMap(card -> {
card.setMemberCardId(id); if (adminPassword == null || adminPassword.isBlank()) {
return memberCardService.save(card); return ServerResponse.badRequest()
}) .contentType(MediaType.APPLICATION_JSON)
.flatMap(updated -> ServerResponse.ok().bodyValue(updated)) .bodyValue(Map.of("code", 400, "message", "管理员密码不能为空"))
.switchIfEmpty(ServerResponse.notFound().build()); .flatMap(resp -> Mono.just(resp));
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
return ServerResponse.status(HttpStatus.FORBIDDEN)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 403, "message", "管理员密码错误"))
.flatMap(resp -> Mono.just(resp));
}
return request.bodyToMono(MemberCard.class)
.flatMap(body -> memberCardService.findByMemberCardIdAndDeletedAtIsNull(id)
.flatMap(existing -> {
existing.setMemberCardName(body.getMemberCardName());
existing.setMemberCardType(body.getMemberCardType());
existing.setMemberCardPrice(body.getMemberCardPrice());
existing.setMemberCardValidityDays(body.getMemberCardValidityDays());
existing.setMemberCardTotalTimes(body.getMemberCardTotalTimes());
existing.setMemberCardAmount(body.getMemberCardAmount());
existing.setMemberCardStatus(body.getMemberCardStatus());
return memberCardService.save(existing);
})
.flatMap(updated -> ServerResponse.ok().bodyValue(updated))
.switchIfEmpty(ServerResponse.notFound().build()));
});
} }
@Operation(summary = "删除会员卡类型", description = "逻辑删除会员卡类型") @Operation(summary = "删除会员卡类型", description = "逻辑删除会员卡类型,需验证管理员密码")
public Mono<ServerResponse> deleteMemberCard(ServerRequest request) { public Mono<ServerResponse> deleteMemberCard(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
return memberCardService.logicalDelete(id) String adminPassword = request.queryParam("adminPassword").orElse(null);
.flatMap(rows -> {
if (rows > 0) { if (adminPassword == null || adminPassword.isBlank()) {
return ServerResponse.noContent().build(); return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "管理员密码不能为空"))
.flatMap(resp -> Mono.just(resp));
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
return ServerResponse.status(HttpStatus.FORBIDDEN)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 403, "message", "管理员密码错误"))
.flatMap(resp -> Mono.just(resp));
} }
return ServerResponse.notFound().build(); return memberCardService.logicalDelete(id)
.flatMap(rows -> {
if (rows > 0) {
return ServerResponse.noContent().build();
}
return ServerResponse.notFound().build();
});
}); });
} }
@@ -32,6 +32,7 @@ public class MemberCardRecordHandler {
@Operation(summary = "购买会员卡", description = "支持时长卡、次卡、储值卡,自动设置到期提醒") @Operation(summary = "购买会员卡", description = "支持时长卡、次卡、储值卡,自动设置到期提醒")
public Mono<ServerResponse> purchaseCard(ServerRequest request) { public Mono<ServerResponse> purchaseCard(ServerRequest request) {
return request.bodyToMono(PurchaseRequest.class) return request.bodyToMono(PurchaseRequest.class)
.flatMap(body -> memberCardService.purchaseCard( .flatMap(body -> memberCardService.purchaseCard(
body.getMemberId(), body.getMemberId(),
@@ -1,6 +1,7 @@
package cn.novalon.gym.manage.member.handler; package cn.novalon.gym.manage.member.handler;
import cn.novalon.gym.manage.member.config.WechatProperties; import cn.novalon.gym.manage.common.exception.NotFoundException;
import cn.novalon.gym.manage.member.dto.AdminEditMemberDto;
import cn.novalon.gym.manage.member.dto.AdminUpdatePhoneDto; import cn.novalon.gym.manage.member.dto.AdminUpdatePhoneDto;
import cn.novalon.gym.manage.member.dto.SearchMemberDto; import cn.novalon.gym.manage.member.dto.SearchMemberDto;
import cn.novalon.gym.manage.member.dto.UpdateMemberInfoDto; import cn.novalon.gym.manage.member.dto.UpdateMemberInfoDto;
@@ -9,19 +10,22 @@ import cn.novalon.gym.manage.member.service.WechatAuthService;
import cn.novalon.gym.manage.member.service.WechatOfficialService; import cn.novalon.gym.manage.member.service.WechatOfficialService;
import cn.novalon.gym.manage.member.util.AesUtil; import cn.novalon.gym.manage.member.util.AesUtil;
import cn.novalon.gym.manage.member.util.WechatPhoneUtil; import cn.novalon.gym.manage.member.util.WechatPhoneUtil;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil; import cn.novalon.gym.manage.sys.util.AuthUtil;
import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.math.NumberUtils; import org.apache.commons.lang3.math.NumberUtils;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.util.Map;
/** /**
* 会员信息处理器 * 会员信息处理器
* *
@@ -39,6 +43,7 @@ public class MemberHandler {
private final WechatAuthService wechatAuthService; private final WechatAuthService wechatAuthService;
private final WechatOfficialService wechatOfficialService; private final WechatOfficialService wechatOfficialService;
private final AuthUtil authUtil; private final AuthUtil authUtil;
private final ISysUserService sysUserService;
@Operation(summary = "获取会员信息", description = "根据当前登录用户获取会员基本信息") @Operation(summary = "获取会员信息", description = "根据当前登录用户获取会员基本信息")
public Mono<ServerResponse> getMemberInfo(ServerRequest request) { public Mono<ServerResponse> getMemberInfo(ServerRequest request) {
@@ -50,7 +55,11 @@ public class MemberHandler {
return memberService.getMemberInfo(memberId) return memberService.getMemberInfo(memberId)
.flatMap(info -> ServerResponse.ok() .flatMap(info -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.bodyValue(info)); .bodyValue(info))
.onErrorResume(NotFoundException.class, e ->
ServerResponse.status(HttpStatus.NOT_FOUND)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(java.util.Map.of("code", 404, "message", e.getMessage())));
} }
@Operation(summary = "更新会员信息", description = "更新会员昵称、性别、生日、头像、地址等信息") @Operation(summary = "更新会员信息", description = "更新会员昵称、性别、生日、头像、地址等信息")
@@ -159,7 +168,7 @@ public class MemberHandler {
}); });
} }
@Operation(summary = "管理员编辑会员信息", description = "后台管理员编辑会员信息") @Operation(summary = "管理员编辑会员信息", description = "后台管理员编辑会员信息,需验证管理员密码")
public Mono<ServerResponse> adminUpdateMemberInfo(ServerRequest request) { public Mono<ServerResponse> adminUpdateMemberInfo(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request); Long adminId = authUtil.getMemberIdOrThrow(request);
@@ -168,14 +177,30 @@ public class MemberHandler {
long memberId = NumberUtils.toLong(memberIdStr, 0L); long memberId = NumberUtils.toLong(memberIdStr, 0L);
if(memberId <= 0L) throw new IllegalArgumentException("会员ID格式错误"); if(memberId <= 0L) throw new IllegalArgumentException("会员ID格式错误");
// TODO: 补充签到记录
log.info("前台编辑会员信息, adminId: {}, memberId: {}", adminId, memberId); log.info("前台编辑会员信息, adminId: {}, memberId: {}", adminId, memberId);
return request.bodyToMono(UpdateMemberInfoDto.class) return request.bodyToMono(AdminEditMemberDto.class)
.flatMap(updateDto -> memberService.adminUpdateMemberInfo(memberId, updateDto)) .flatMap(dto -> {
.flatMap(detail -> ServerResponse.ok() if (dto.getAdminPassword() == null || dto.getAdminPassword().isBlank()) {
.contentType(MediaType.APPLICATION_JSON) return ServerResponse.badRequest()
.bodyValue(detail)); .contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "管理员密码不能为空"))
.flatMap(resp -> Mono.<ServerResponse>just(resp));
}
return sysUserService.verifyPassword(adminId, dto.getAdminPassword())
.flatMap(valid -> {
if (!valid) {
return ServerResponse.status(HttpStatus.FORBIDDEN)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 403, "message", "管理员密码错误"))
.flatMap(resp -> Mono.<ServerResponse>just(resp));
}
return memberService.adminUpdateMemberInfo(memberId, dto.toUpdateMemberInfoDto())
.flatMap(result -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(result));
});
});
} }
@Operation(summary = "搜索会员列表", description = "后台管理员按关键词搜索会员,支持性别筛选和分页") @Operation(summary = "搜索会员列表", description = "后台管理员按关键词搜索会员,支持性别筛选和分页")
@@ -1,5 +1,6 @@
package cn.novalon.gym.manage.member.service.impl; 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.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord; import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.entity.MemberCardTransaction; 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.repository.MemberCardRepository;
import cn.novalon.gym.manage.member.service.IMemberCardService; import cn.novalon.gym.manage.member.service.IMemberCardService;
import cn.novalon.gym.manage.member.service.IMemberCardTransactionService; import cn.novalon.gym.manage.member.service.IMemberCardTransactionService;
import cn.novalon.gym.manage.common.util.RedisUtil;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -65,21 +65,37 @@ public class MemberServiceImpl implements MemberService {
public Mono<MemberInfoVO> getMemberInfo(Long memberId) { public Mono<MemberInfoVO> getMemberInfo(Long memberId) {
String cacheKey = MEMBER_INFO_CACHE_PREFIX + memberId; String cacheKey = MEMBER_INFO_CACHE_PREFIX + memberId;
// 先查缓存
return redisUtil.get(cacheKey, MemberInfoVO.class) return redisUtil.get(cacheKey, MemberInfoVO.class)
.flatMap(cached -> { .flatMap(cached -> {
if (cached != null) { if (cached != null) {
log.debug("从缓存获取会员信息, memberId: {}", memberId); log.debug("从缓存获取会员信息, memberId: {}", memberId);
return Mono.just(cached); return Mono.just(cached);
} }
return memberRepository.findById(memberId) // 缓存没有查数据库
.map(this::buildMemberInfoResponse) return queryFromDatabaseAndCache(memberId, cacheKey);
.flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS) })
.then(Mono.just(vo))) .switchIfEmpty(Mono.defer(() -> {
.switchIfEmpty(Mono.error(() -> { // 缓存返回null查数据库
log.error("会员不存在: memberId={}", memberId); return queryFromDatabaseAndCache(memberId, cacheKey);
throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在"); }));
})); }
});
/**
* 从数据库查询并更新缓存
*/
private Mono<MemberInfoVO> queryFromDatabaseAndCache(Long memberId, String cacheKey) {
return memberRepository.findById(memberId)
.map(this::buildMemberInfoResponse)
.flatMap(vo -> {
// 查询到数据后更新缓存
return redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS)
.then(Mono.just(vo));
})
.switchIfEmpty(Mono.error(() -> {
log.error("会员不存在: memberId={}", memberId);
throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
}));
} }
@Override @Override
@@ -226,45 +242,58 @@ public class MemberServiceImpl implements MemberService {
log.debug("从缓存获取会员详情, memberId: {}", memberId); log.debug("从缓存获取会员详情, memberId: {}", memberId);
return Mono.just(cached); return Mono.just(cached);
} }
return memberRepository.findById(memberId) // 缓存反序列化异常查数据库
.zipWith( return queryMemberDetailFromDb(memberId, cacheKey);
memberRepository.findCardRecordsWithCardInfoByMemberId(memberId) })
.collectList(), .switchIfEmpty(Mono.defer(() -> {
(baseInfo, cardList) -> { // 缓存不存在查数据库
MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class); return queryMemberDetailFromDb(memberId, cacheKey);
}));
}
GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender()); private Mono<MemberDetailVO> queryMemberDetailFromDb(Long memberId, String cacheKey) {
memberDetailVO.setGenderDesc(genderEnum.getDesc()); return memberRepository.findById(memberId)
.zipWith(
memberRepository.findCardRecordsWithCardInfoByMemberId(memberId)
.collectList(),
(baseInfo, cardList) -> {
MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class);
List<MemberCardInfoVO> enrichedCards = cardList.stream() GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender());
.peek(vo -> { memberDetailVO.setGenderDesc(genderEnum.getDesc());
if (vo.getMemberCardType() != null) {
try {
MemberCardType cardType = MemberCardType.valueOf(vo.getMemberCardType());
vo.setMemberCardTypeDesc(cardType.getDesc());
} catch (IllegalArgumentException e) {
vo.setMemberCardTypeDesc(vo.getMemberCardType());
}
}
if (vo.getMemberCardStatus() != null) {
vo.setMemberCardStatusDesc(vo.getMemberCardStatus() == 1 ? "上架" : "下架");
}
})
.collect(Collectors.toList());
memberDetailVO.setMemberCards(enrichedCards);
long activeCount = enrichedCards.stream() List<MemberCardInfoVO> enrichedCards = cardList.stream()
.filter(card -> card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1) .peek(vo -> {
.count(); if (vo.getMemberCardType() != null) {
memberDetailVO.setActiveCardCount((int) activeCount); try {
memberDetailVO.setInactiveCardCount(enrichedCards.size() - (int) activeCount); MemberCardType cardType = MemberCardType.valueOf(vo.getMemberCardType());
vo.setMemberCardTypeDesc(cardType.getDesc());
} catch (IllegalArgumentException e) {
vo.setMemberCardTypeDesc(vo.getMemberCardType());
}
}
if (vo.getMemberCardStatus() != null) {
vo.setMemberCardStatusDesc(vo.getMemberCardStatus() == 1 ? "上架" : "下架");
}
})
.collect(Collectors.toList());
memberDetailVO.setMemberCards(enrichedCards);
return memberDetailVO; long activeCount = enrichedCards.stream()
} .filter(card -> card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1)
) .count();
.flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS) memberDetailVO.setActiveCardCount((int) activeCount);
.then(Mono.just(vo))); memberDetailVO.setInactiveCardCount(enrichedCards.size() - (int) activeCount);
});
return memberDetailVO;
}
)
.flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS)
.then(Mono.just(vo)))
.switchIfEmpty(Mono.error(() -> {
log.error("会员不存在: memberId={}", memberId);
return new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
}));
} }
@@ -1,16 +0,0 @@
package cn.novalon.gym.manage.member;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
/**
* 会员模块测试类
*/
@SpringBootTest
public class MemberModuleTest {
@Test
void contextLoads() {
// 测试Spring上下文是否能正常加载
}
}
+62
View File
@@ -0,0 +1,62 @@
<?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>
@@ -0,0 +1,32 @@
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()
);
}
}
@@ -0,0 +1,57 @@
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;
}
@@ -0,0 +1,52 @@
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();
}
}
@@ -0,0 +1,54 @@
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;
}
@@ -0,0 +1,54 @@
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;
}
@@ -0,0 +1,140 @@
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()));
});
}
}
@@ -0,0 +1,59 @@
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);
}
@@ -0,0 +1,445 @@
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 "支付宝接口调用失败,请稍后重试";
}
}
+5
View File
@@ -0,0 +1,5 @@
公钥:MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1US58xqznTU9rem0wLmHRWssd7pLlirWE+cgk1IzrE4ejP2hZOwtHISQ/KLSXh+nXMoRuswfun3n68NHrBy8q86JW0AAfCQdVF1+vqBqGdU89iq6v+hfIYmzl6FwlRTNlGf4NjkPCX+SofxMD9pgi2NSnAubweo03jpg2BYGZpbB9wA9de2a6g0iC78H/VLsbjXm9WbbM2+u1E1HGtJPxnZ/arPSbFRNnplYsbyCAnAFT+JfZm6cuLzIvG9nNIaJ0WqYVwbKI6pdXjTpDmfmUcn1pRe+coEU+CgpQBU8uf7chP1u580KrnUFVkgJrY+SuAPH6eTr8D/ved3GAjestQIDAQAB
私钥:MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDVRLnzGrOdNT2t6bTAuYdFayx3ukuWKtYT5yCTUjOsTh6M/aFk7C0chJD8otJeH6dcyhG6zB+6fefrw0esHLyrzolbQAB8JB1UXX6+oGoZ1Tz2Krq/6F8hibOXoXCVFM2UZ/g2OQ8Jf5Kh/EwP2mCLY1KcC5vB6jTeOmDYFgZmlsH3AD117ZrqDSILvwf9UuxuNeb1Ztszb67UTUca0k/Gdn9qs9JsVE2emVixvIICcAVP4l9mbpy4vMi8b2c0honRaphXBsojql1eNOkOZ+ZRyfWlF75ygRT4KClAFTy5/tyE/W7nzQqudQVWSAmtj5K4A8fp5OvwP+953cYCN6y1AgMBAAECggEADiwB+1Cj7Odz0NG97Cyn+4nyq4YarcDs9ued94w32NRcHVxhVDaJjOKWS+N5/T03PuhCo1obJaZmOfmBEsPaGcSgxgqLvbQcqtWHrZ01T8Amy+js/gGwCKeA4qucsptuSdVa3ieOg0AR+2ermkYVsk2IWMxArnMkzjus4c8c38Y3xJu+Nhd7HfKokH6TUluh2cwHqZLUJuDNkKLCuEgDJqNMyFGC99tLqGldMLuM2g7RVSNDsmzxXZC2vliLfsrwRF8TAZK0Fx5rgLj5WRnGFt3BfcVznDimHOOoBGyvl+1rFkpeb6iKcfKV9IcZN7iZN2/yunKtouqip4FVht8OAQKBgQD3YjR2rU7aWbLu+54c6YTYI1P+h6K8OLqWm0YgXZPFawxK46rEYWEk8noCI6SpQcNKxmqQ61ysv61yTVBL7bwHSF2mSSkfmzPjBS8aO4NVNCTaxU/S84ky/ROXJwXVXHGaVerXEnZGbVlJfHKM7ui80R9hPdGjn0cqEUgdAxiCNQKBgQDcslV096ok0o4iLuwzYmaM1Mtd9ZWy1fh+IG//0oocKNXA3W3Z0mKUyqEnheD3wRsKmiCVEWY+mD1YuRzDupFtlyNv7eet1dmbumXp+icZGiCHPTDEMHpNdpjWhD0x1juMnsrVyf7DKuL0gtT5I+MIdAVAQ3n4sGtdS6n/RyfQgQKBgQCarTOidJBWJDmmBXuCFlxyi/xLrGELEOlRm7qIKBpqGJmyBZHTghOsd1PkHIcrMSw7h74/6HmkpaB8VGz9XKLxZsTvYNUupusVajteSpG+Z4EXrMFRY+aIFAb4vnR+SHPbjRbt7OQ4+swWD5LMHeE1lgfp1C5NAR0M54tQYIwldQKBgBawtxyJghK7GyXkkSBPU5/TGP2WDEgQu7Wr/BffO5fdkux7V5n3iW8mzh7UIucWEYOriQIKgeqZmGUQ0yZEfkM4MMqHOTSkMfVS2ruNnot0JjgBUIw7N7fTI6+adPg0wHaatNv6IplKRX9CHEdpKyRpWUjcJyQsbz1uVBIM7MkBAoGBAJYDO/uvsYLjlRGJSOiY0u0Nc6aubtdwIx5MB9toLCSj0ATHqB8SYf1KnnGOMxUgjpLZsMnkbEyC8+oHslFNkb/uUdoitpxKgQ/sPwh+GkO+Z38obSXJ8FiRoMA6I7zCH3qgsMN3kmF3Y4njhYqGR0g8o95yNqV3yuVTOcLSzpyx
汇付公钥:MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxPEF/yDN4/kavunWrR6hl5wFq9vVyCsNsQMYQg1r607YfVmB6oqgkM/ytH8N85LU5bF45CQ12TyMJWdb/ltpy1yZN26olcknVP1IznvPZlXJUCGJndNVJnHXr1HgSphwVvPyOcrn9B1/phdnbO2+96ChFKRjz5fjnatMaRteapMFBvw4qcoumLtWdTLH85scng4SWMBfuj0Q9LdmB377KKkJl3x3fSgjUw4SquS2fUv6tqGQwyzCKRg2k5KRIb/HgaqdyMt7/SjMX5L1QpFNKbYlg7vTpf3jdh4JefvAk+/kiQO34GeWOzJpSNUWs/GxJUg9eWcCqCsM7aHRXhBTWQIDAQAB
sys_id6666000208197943
product_idXLSISV
+914 -120
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -48,11 +48,21 @@
<artifactId>gym-checkIn</artifactId> <artifactId>gym-checkIn</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-payment</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>cn.novalon.gym.manage</groupId> <groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-dataCount</artifactId> <artifactId>gym-dataCount</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-auth</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
@@ -10,14 +10,13 @@ import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDeta
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.data.elasticsearch.repository.config.EnableReactiveElasticsearchRepositories; import org.springframework.data.elasticsearch.repository.config.EnableReactiveElasticsearchRepositories;
import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories; import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilter;
import java.util.List; import java.util.List;
@SpringBootApplication(scanBasePackages = "cn.novalon.gym.manage", exclude = { @SpringBootApplication(scanBasePackages = "cn.novalon.gym.manage", exclude = {
ReactiveUserDetailsServiceAutoConfiguration.class }) ReactiveUserDetailsServiceAutoConfiguration.class })
@EnableScheduling //@EnableScheduling
@EnableR2dbcRepositories(basePackages = { @EnableR2dbcRepositories(basePackages = {
"cn.novalon.gym.manage.db.dao", "cn.novalon.gym.manage.db.dao",
"cn.novalon.gym.manage.sys.audit.repository" , "cn.novalon.gym.manage.sys.audit.repository" ,
@@ -4,8 +4,13 @@ package cn.novalon.gym.manage.app.config;
import cn.novalon.gym.manage.checkIn.handler.CheckInHandler; import cn.novalon.gym.manage.checkIn.handler.CheckInHandler;
import cn.novalon.gym.manage.datacount.handler.DataStatisticsHandler; import cn.novalon.gym.manage.datacount.handler.DataStatisticsHandler;
import cn.novalon.gym.manage.file.handler.SysFileHandler; 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.GroupCourseBookingHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseRecommendHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseTypeHandler;
import cn.novalon.gym.manage.groupcourse.handler.CourseLabelHandler;
import cn.novalon.gym.manage.groupcourse.handler.CommonUploadHandler;
import cn.novalon.gym.manage.member.handler.MemberCardHandler; import cn.novalon.gym.manage.member.handler.MemberCardHandler;
import cn.novalon.gym.manage.member.handler.MemberCardRecordHandler; import cn.novalon.gym.manage.member.handler.MemberCardRecordHandler;
import cn.novalon.gym.manage.member.handler.MemberCardTransactionHandler; import cn.novalon.gym.manage.member.handler.MemberCardTransactionHandler;
@@ -13,6 +18,7 @@ import cn.novalon.gym.manage.member.handler.MemberHandler;
import cn.novalon.gym.manage.member.handler.WechatAuthHandler; import cn.novalon.gym.manage.member.handler.WechatAuthHandler;
import cn.novalon.gym.manage.notify.handler.SysNoticeHandler; import cn.novalon.gym.manage.notify.handler.SysNoticeHandler;
import cn.novalon.gym.manage.notify.handler.SysUserMessageHandler; import cn.novalon.gym.manage.notify.handler.SysUserMessageHandler;
import cn.novalon.gym.manage.payment.handler.PaymentHandler;
import cn.novalon.gym.manage.sys.handler.auth.PasswordDiagnosticHandler; import cn.novalon.gym.manage.sys.handler.auth.PasswordDiagnosticHandler;
import cn.novalon.gym.manage.sys.handler.auth.SysAuthHandler; import cn.novalon.gym.manage.sys.handler.auth.SysAuthHandler;
import cn.novalon.gym.manage.sys.handler.config.SysConfigHandler; import cn.novalon.gym.manage.sys.handler.config.SysConfigHandler;
@@ -69,8 +75,14 @@ public class SystemRouter {
MemberCardTransactionHandler memberCardTransactionHandler, MemberCardTransactionHandler memberCardTransactionHandler,
GroupCourseHandler groupCourseHandler, GroupCourseHandler groupCourseHandler,
GroupCourseBookingHandler groupCourseBookingHandler, GroupCourseBookingHandler groupCourseBookingHandler,
GroupCourseRecommendHandler groupCourseRecommendHandler,
GroupCourseTypeHandler groupCourseTypeHandler,
CourseLabelHandler courseLabelHandler,
CheckInHandler checkInHandler, CheckInHandler checkInHandler,
DataStatisticsHandler dataStatisticsHandler) { DataStatisticsHandler dataStatisticsHandler,
PhoneAuthHandler phoneAuthHandler,
PaymentHandler paymentHandler,
CommonUploadHandler commonUploadHandler) {
return route() return route()
// ========== 诊断路由 ========== // ========== 诊断路由 ==========
@@ -158,6 +170,7 @@ public class SystemRouter {
.POST("/api/auth/login", authHandler::login) .POST("/api/auth/login", authHandler::login)
.POST("/api/auth/register", authHandler::register) .POST("/api/auth/register", authHandler::register)
.POST("/api/auth/logout", authHandler::logout) .POST("/api/auth/logout", authHandler::logout)
.GET("/api/auth/me", authHandler::me)
// ========== 统计路由 ========== // ========== 统计路由 ==========
.GET("/api/stats/overview", statsHandler::getOverview) .GET("/api/stats/overview", statsHandler::getOverview)
@@ -186,10 +199,13 @@ public class SystemRouter {
// ========== 消息路由 ========== // ========== 消息路由 ==========
.GET("/api/messages/user/{userId}", messageHandler::getMessagesByUser) .GET("/api/messages/user/{userId}", messageHandler::getMessagesByUser)
.GET("/api/messages/user/{userId}/page", messageHandler::getMessagesByUserPage)
.GET("/api/messages/user/{userId}/unread", messageHandler::getUnreadCount) .GET("/api/messages/user/{userId}/unread", messageHandler::getUnreadCount)
.GET("/api/messages/user/{userId}/unread/list", messageHandler::getUnreadList) .GET("/api/messages/user/{userId}/unread/list", messageHandler::getUnreadList)
.GET("/api/messages/user/{userId}/unread/page", messageHandler::getUnreadMessagesPage)
.POST("/api/messages", messageHandler::createMessage) .POST("/api/messages", messageHandler::createMessage)
.PUT("/api/messages/{id}/read", messageHandler::markAsRead) .PUT("/api/messages/{id}/read", messageHandler::markAsRead)
.PUT("/api/messages/user/{userId}/read", messageHandler::markAllAsRead)
.DELETE("/api/messages/{id}", messageHandler::deleteMessage) .DELETE("/api/messages/{id}", messageHandler::deleteMessage)
// ========== 文件路由 ========== // ========== 文件路由 ==========
@@ -202,6 +218,10 @@ public class SystemRouter {
.GET("/api/files/preview/{fileName}", fileHandler::previewFileByName) .GET("/api/files/preview/{fileName}", fileHandler::previewFileByName)
.DELETE("/api/files/{id}", fileHandler::deleteFile) .DELETE("/api/files/{id}", fileHandler::deleteFile)
// ===== 通用文件上传OSS=====
.POST("/api/upload/image", commonUploadHandler::uploadImage)
.GET("/api/upload/presign", commonUploadHandler::presignUrl)
// ========== 权限路由 ========== // ========== 权限路由 ==========
.GET("/api/permissions", permissionHandler::getAllPermissions) .GET("/api/permissions", permissionHandler::getAllPermissions)
.GET("/api/permissions/{id}", permissionHandler::getPermissionById) .GET("/api/permissions/{id}", permissionHandler::getPermissionById)
@@ -212,6 +232,11 @@ public class SystemRouter {
.PUT("/api/permissions/{id}", permissionHandler::updatePermission) .PUT("/api/permissions/{id}", permissionHandler::updatePermission)
.DELETE("/api/permissions/{id}", permissionHandler::deletePermission) .DELETE("/api/permissions/{id}", permissionHandler::deletePermission)
// ========== 手机号认证路由 ==========
.POST("/api/auth/phone/one-click-login", phoneAuthHandler::oneClickLogin)
.POST("/api/auth/phone/send-code", phoneAuthHandler::sendSmsCode)
.POST("/api/auth/phone/code-login", phoneAuthHandler::codeLogin)
// ========== 会员模块路由 - 微信认证 ========== // ========== 会员模块路由 - 微信认证 ==========
.POST("/api/member/auth/miniapp/login", wechatAuthHandler::miniappLogin) .POST("/api/member/auth/miniapp/login", wechatAuthHandler::miniappLogin)
.GET("/api/member/auth/mp/callback", wechatAuthHandler::verifyMpSignature) .GET("/api/member/auth/mp/callback", wechatAuthHandler::verifyMpSignature)
@@ -237,8 +262,11 @@ public class SystemRouter {
// ===== 会员卡类型管理 ===== // ===== 会员卡类型管理 =====
.GET("/api/member-cards/active", memberCardHandler::getActiveCards) .GET("/api/member-cards/active", memberCardHandler::getActiveCards)
.GET("/api/member-cards", memberCardHandler::listMemberCards)
.GET("/api/member-cards/{memberCardId}", memberCardHandler::getMemberCardById) .GET("/api/member-cards/{memberCardId}", memberCardHandler::getMemberCardById)
.POST("/api/member-cards", memberCardHandler::createMemberCard) .POST("/api/member-cards", memberCardHandler::createMemberCard)
.PUT("/api/member-cards/{id}", memberCardHandler::updateMemberCard)
.DELETE("/api/member-cards/{id}", memberCardHandler::deleteMemberCard)
// ===== 会员卡记录管理核心业务===== // ===== 会员卡记录管理核心业务=====
.POST("/api/member-card-records/purchase", memberCardRecordHandler::purchaseCard) .POST("/api/member-card-records/purchase", memberCardRecordHandler::purchaseCard)
@@ -265,12 +293,28 @@ public class SystemRouter {
// ===== 团课课程管理 ===== // ===== 团课课程管理 =====
.GET("/api/groupCourse/list", groupCourseHandler::getAllGroupCourse) .GET("/api/groupCourse/list", groupCourseHandler::getAllGroupCourse)
.POST("/api/groupCourse/page", groupCourseHandler::getGroupCoursesByPage) .POST("/api/groupCourse/page", groupCourseHandler::getGroupCoursesByPage)
.GET("/api/groupCourse/{id}", groupCourseHandler::getGroupCourseById)
.POST("/api/groupCourse", groupCourseHandler::createGroupCourse) // ===== 团课类型管理 =====
.PUT("/api/groupCourse/{id}", groupCourseHandler::updateGroupCourse) .GET("/api/groupCourse/types", groupCourseTypeHandler::getAllGroupCourseTypes)
.DELETE("/api/groupCourse/{id}", groupCourseHandler::deleteGroupCourse) .GET("/api/groupCourse/types/search", groupCourseTypeHandler::searchGroupCourseTypes)
.POST("/api/groupCourse/{id}/cancel", groupCourseHandler::cancelGroupCourse) .GET("/api/groupCourse/types/categories", groupCourseTypeHandler::getCategories)
.POST("/api/groupCourse/{courseId}/signin", groupCourseHandler::signIn) .GET("/api/groupCourse/types/category/{category}", groupCourseTypeHandler::getGroupCourseTypesByCategory)
.GET("/api/groupCourse/types/{id}", groupCourseTypeHandler::getGroupCourseTypeById)
.POST("/api/groupCourse/types", groupCourseTypeHandler::createGroupCourseType)
.PUT("/api/groupCourse/types/{id}", groupCourseTypeHandler::updateGroupCourseType)
.DELETE("/api/groupCourse/types/{id}", groupCourseTypeHandler::deleteGroupCourseType)
// ===== 团课标签管理 =====
.GET("/api/groupCourse/labels", courseLabelHandler::getAllLabels)
.GET("/api/groupCourse/labels/search", courseLabelHandler::searchLabels)
.GET("/api/groupCourse/labels/{id}", courseLabelHandler::getLabelById)
.GET("/api/groupCourse/types/{typeId}/labels", courseLabelHandler::getLabelsByTypeId)
.POST("/api/groupCourse/labels", courseLabelHandler::createLabel)
.PUT("/api/groupCourse/labels/{id}", courseLabelHandler::updateLabel)
.DELETE("/api/groupCourse/labels/{id}", courseLabelHandler::deleteLabel)
.POST("/api/groupCourse/types/{typeId}/labels", courseLabelHandler::addLabelsToType)
.DELETE("/api/groupCourse/types/{typeId}/labels/{labelId}", courseLabelHandler::removeLabelFromType)
.DELETE("/api/groupCourse/types/{typeId}/labels", courseLabelHandler::clearLabelsFromType)
// ===== 团课预约管理 ===== // ===== 团课预约管理 =====
.POST("/api/groupCourse/book", groupCourseBookingHandler::bookCourse) .POST("/api/groupCourse/book", groupCourseBookingHandler::bookCourse)
@@ -279,12 +323,35 @@ public class SystemRouter {
.GET("/api/groupCourse/bookings/course/{courseId}", groupCourseBookingHandler::getBookingsByCourseId) .GET("/api/groupCourse/bookings/course/{courseId}", groupCourseBookingHandler::getBookingsByCourseId)
.GET("/api/groupCourse/bookings/{bookingId}", groupCourseBookingHandler::getBookingById) .GET("/api/groupCourse/bookings/{bookingId}", groupCourseBookingHandler::getBookingById)
// ===== 团课推荐管理 =====
.GET("/api/groupCourse/recommend/list", groupCourseRecommendHandler::getAllRecommendations)
.GET("/api/groupCourse/recommend/active", groupCourseRecommendHandler::getAllActiveRecommendations)
.GET("/api/groupCourse/recommend/{id}", groupCourseRecommendHandler::getRecommendationById)
.GET("/api/groupCourse/recommend/course/{courseId}", groupCourseRecommendHandler::getRecommendationsByCourseId)
.POST("/api/groupCourse/recommend", groupCourseRecommendHandler::createRecommendation)
.PUT("/api/groupCourse/recommend/{id}", groupCourseRecommendHandler::updateRecommendation)
.DELETE("/api/groupCourse/recommend/{id}", groupCourseRecommendHandler::deleteRecommendation)
.POST("/api/groupCourse/recommend/{id}/enable", groupCourseRecommendHandler::enableRecommendation)
.POST("/api/groupCourse/recommend/{id}/disable", groupCourseRecommendHandler::disableRecommendation)
// ===== 团课课程管理需要放在具体路由之后=====
.GET("/api/groupCourse/{id}", groupCourseHandler::getGroupCourseById)
.GET("/api/groupCourse/{id}/detail", groupCourseHandler::getGroupCourseDetailById)
.POST("/api/groupCourse", groupCourseHandler::createGroupCourse)
.PUT("/api/groupCourse/{id}", groupCourseHandler::updateGroupCourse)
.DELETE("/api/groupCourse/{id}", groupCourseHandler::deleteGroupCourse)
.POST("/api/groupCourse/{id}/cancel", groupCourseHandler::cancelGroupCourse)
.POST("/api/groupCourse/{id}/restore", groupCourseHandler::restoreGroupCourse)
.POST("/api/groupCourse/signin/{memberId}", groupCourseHandler::signIn)
.POST("/api/groupCourse/search", groupCourseHandler::searchGroupCourses)
// ========= 签到模块路由 ========== // ========= 签到模块路由 ==========
// ===== 签到核心功能 ===== // ===== 签到核心功能 =====
.POST("/api/checkIn", checkInHandler::checkIn) .POST("/api/checkIn", checkInHandler::checkIn)
.GET("/api/checkIn/qrcode", checkInHandler::getQRCode) .GET("/api/checkIn/qrcode", checkInHandler::getQRCode)
// ===== 签到记录管理 ===== // ===== 签到记录管理 =====
.GET("/api/checkIn/records/export", checkInHandler::exportSignInRecords)
.GET("/api/checkIn/records", checkInHandler::getSignInRecords) .GET("/api/checkIn/records", checkInHandler::getSignInRecords)
.GET("/api/checkIn/records/{id}", checkInHandler::getSignInRecordById) .GET("/api/checkIn/records/{id}", checkInHandler::getSignInRecordById)
@@ -292,8 +359,9 @@ public class SystemRouter {
.GET("/api/checkIn/statistics", checkInHandler::getSignInStatistics) .GET("/api/checkIn/statistics", checkInHandler::getSignInStatistics)
.GET("/api/checkIn/daily-stats", checkInHandler::getDailySignInStats) .GET("/api/checkIn/daily-stats", checkInHandler::getDailySignInStats)
// ===== 签到数据导出 ===== // ===== 管理员签到记录全员 =====
.GET("/api/checkIn/records/export", checkInHandler::exportSignInRecords) .GET("/api/checkIn/admin/records", checkInHandler::getAllSignInRecords)
.GET("/api/checkIn/admin/statistics", checkInHandler::getAllSignInStatistics)
// ======================================== // ========================================
// ========== 数据统计模块路由 ============ // ========== 数据统计模块路由 ============
@@ -306,6 +374,20 @@ public class SystemRouter {
.GET("/api/datacount/signin", dataStatisticsHandler::getSignInStatistics) .GET("/api/datacount/signin", dataStatisticsHandler::getSignInStatistics)
.GET("/api/datacount/history", dataStatisticsHandler::queryHistoricalStatistics) .GET("/api/datacount/history", dataStatisticsHandler::queryHistoricalStatistics)
.GET("/api/datacount/export", dataStatisticsHandler::exportStatistics) .GET("/api/datacount/export", dataStatisticsHandler::exportStatistics)
// ========================================
// ========== 支付模块路由 =================
// ========================================
// ===== 支付宝App支付 =====
.POST("/api/payment/create", paymentHandler::createPayment)
.GET("/api/payment/{orderId}", paymentHandler::getPaymentStatus)
.POST("/api/payment/{orderId}/refund", paymentHandler::refundPayment)
.POST("/api/payment/notify", paymentHandler::alipayNotify)
// ===== 支付宝扫码支付 =====
.POST("/api/payment/qrcode/create", paymentHandler::createQrCodePayment)
.build(); .build();
} }
} }
@@ -15,10 +15,10 @@ spring:
url: jdbc:postgresql://localhost:55432/manage_system url: jdbc:postgresql://localhost:55432/manage_system
user: novalon user: novalon
password: novalon123 password: novalon123
enabled: true enabled: false
locations: classpath:db/migration locations: classpath:db/migration
baseline-on-migrate: true baseline-on-migrate: true
validate-on-migrate: true validate-on-migrate: false
jwt: jwt:

Some files were not shown because too many files have changed in this diff Show More