Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
836f0e1cbf | ||
|
|
44da3cab6e | ||
|
|
cf7e2560b5 | ||
|
|
fa94f52b53 | ||
|
|
566e949588 | ||
|
|
e61fa6de00 | ||
|
|
efd4d03037 | ||
|
|
80759f6793 | ||
|
|
f8279129be | ||
|
|
fc48db071e | ||
|
|
f1614c7d45 | ||
|
|
886e2748d5 | ||
|
|
1a5aa9b3ef | ||
|
|
0140bb0cc8 | ||
|
|
35e7532f1b | ||
|
|
0d143be7b3 | ||
|
|
2ffd1aa7d6 | ||
|
|
581cc995c6 | ||
|
|
2251f31524 | ||
|
|
4481819eba | ||
|
|
79eb07599e | ||
|
|
a7f31083cf | ||
|
|
524182c478 | ||
|
|
3586a7d74b |
@@ -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 '归档时间';
|
|
||||||
@@ -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>
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package cn.novalon.gym.manage.auth.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class AuthConfig {
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package cn.novalon.gym.manage.auth.config;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Configuration
|
||||||
|
@ConfigurationProperties(prefix = "dcloud.univerify")
|
||||||
|
public class DCloudUniverifyConfig {
|
||||||
|
private String appid;
|
||||||
|
private String appkey;
|
||||||
|
private String apiSecret;
|
||||||
|
private String apiUrl = "https://developer.dcloud.net.cn/api/client/univerify/getPhoneNumber";
|
||||||
|
private String proxyHost;
|
||||||
|
private Integer proxyPort;
|
||||||
|
private Integer timeout = 10000;
|
||||||
|
}
|
||||||
+47
@@ -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;
|
||||||
|
}
|
||||||
+26
@@ -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;
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package cn.novalon.gym.manage.auth.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class PhoneLoginDto {
|
||||||
|
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
private String accessToken;
|
||||||
|
|
||||||
|
private String openid;
|
||||||
|
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
private String avatar;
|
||||||
|
}
|
||||||
+22
@@ -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;
|
||||||
|
}
|
||||||
+59
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -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);
|
||||||
|
}
|
||||||
+37
@@ -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);
|
||||||
|
}
|
||||||
+270
@@ -0,0 +1,270 @@
|
|||||||
|
package cn.novalon.gym.manage.auth.service.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.auth.config.DCloudUniverifyConfig;
|
||||||
|
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 jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@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 final DCloudUniverifyConfig dCloudUniverifyConfig;
|
||||||
|
|
||||||
|
private WebClient webClient;
|
||||||
|
|
||||||
|
private EsSyncUtils.EntitySyncer<Member, MemberES, String> memberSyncer;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
this.memberSyncer = esSyncUtils.bind(Member.class, MemberES.class, memberESRepository);
|
||||||
|
this.webClient = WebClient.builder()
|
||||||
|
.baseUrl(dCloudUniverifyConfig.getApiUrl())
|
||||||
|
.defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<PhoneLoginVO> oneClickLogin(PhoneLoginDto request) {
|
||||||
|
log.info("手机号一键登录请求, phone: {}, accessToken: {}, openid: {}", request.getPhone(), request.getAccessToken(), request.getOpenid());
|
||||||
|
|
||||||
|
if (request.getPhone() != null && !request.getPhone().isEmpty()) {
|
||||||
|
log.info("直接使用手机号登录");
|
||||||
|
String encryptedPhone = encryptPhone(request.getPhone());
|
||||||
|
return processPhoneLogin(encryptedPhone, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getAccessToken() == null || request.getAccessToken().isEmpty()) {
|
||||||
|
log.error("一键登录失败: accessToken为空");
|
||||||
|
return Mono.error(new SystemException(ErrorCode.AUTH_PHONE_ERROR, "登录凭证无效,请重试"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return getPhoneByDcloudApi(request.getAccessToken(), request.getOpenid())
|
||||||
|
.flatMap(phone -> {
|
||||||
|
log.info("通过access_token获取手机号成功: {}", maskPhone(phone));
|
||||||
|
String encryptedPhone = encryptPhone(phone);
|
||||||
|
return processPhoneLogin(encryptedPhone, request);
|
||||||
|
})
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.error("一键登录失败", e);
|
||||||
|
return Mono.error(new SystemException(ErrorCode.AUTH_PHONE_ERROR, "手机号获取失败: " + e.getMessage()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<PhoneLoginVO> processPhoneLogin(String encryptedPhone, PhoneLoginDto request) {
|
||||||
|
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);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<String> getPhoneByDcloudApi(String accessToken, String openid) {
|
||||||
|
Map<String, String> requestBody = new HashMap<>();
|
||||||
|
requestBody.put("appid", dCloudUniverifyConfig.getAppid());
|
||||||
|
requestBody.put("appkey", dCloudUniverifyConfig.getAppkey());
|
||||||
|
requestBody.put("apiSecret", dCloudUniverifyConfig.getApiSecret());
|
||||||
|
requestBody.put("access_token", accessToken);
|
||||||
|
requestBody.put("openid", openid);
|
||||||
|
|
||||||
|
log.info("调用DCloud Univerify API, params: {}", requestBody);
|
||||||
|
|
||||||
|
return webClient.post()
|
||||||
|
.bodyValue(requestBody)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(Map.class)
|
||||||
|
.flatMap(response -> {
|
||||||
|
log.info("DCloud API响应: {}", response);
|
||||||
|
|
||||||
|
Integer code = (Integer) response.get("code");
|
||||||
|
Boolean success = (Boolean) response.get("success");
|
||||||
|
|
||||||
|
if ((code != null && code == 0) || (success != null && success)) {
|
||||||
|
Map<String, Object> data = (Map<String, Object>) response.get("data");
|
||||||
|
if (data != null) {
|
||||||
|
String phoneNumber = (String) data.get("phoneNumber");
|
||||||
|
if (phoneNumber != null && !phoneNumber.isEmpty()) {
|
||||||
|
return Mono.just(phoneNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String phoneNumber = (String) response.get("phoneNumber");
|
||||||
|
if (phoneNumber != null && !phoneNumber.isEmpty()) {
|
||||||
|
return Mono.just(phoneNumber);
|
||||||
|
}
|
||||||
|
String phone = (String) response.get("phone");
|
||||||
|
if (phone != null && !phone.isEmpty()) {
|
||||||
|
return Mono.just(phone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String message = (String) response.get("message");
|
||||||
|
log.warn("DCloud API返回错误: code={}, success={}, message={}", code, success, message);
|
||||||
|
return Mono.error(new SystemException(ErrorCode.AUTH_PHONE_ERROR, "手机号获取失败: " + message));
|
||||||
|
})
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.error("调用DCloud API失败", e);
|
||||||
|
return Mono.error(new SystemException(ErrorCode.AUTH_PHONE_ERROR, "手机号获取失败: " + e.getMessage()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机号脱敏显示
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+198
@@ -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;
|
||||||
|
}
|
||||||
+5
@@ -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
|
||||||
@@ -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>
|
||||||
+43
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -206,4 +206,4 @@ public class SignInRecord {
|
|||||||
public void restore() {
|
public void restore() {
|
||||||
this.isDelete = false;
|
this.isDelete = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-1
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+6
@@ -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);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统计会员在时间范围内的签到次数
|
* 统计会员在时间范围内的签到次数
|
||||||
*/
|
*/
|
||||||
|
|||||||
+17
-1
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
+83
-3
@@ -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);
|
||||||
|
|||||||
+10
@@ -59,6 +59,16 @@ public class SignInRecordVO {
|
|||||||
*/
|
*/
|
||||||
private String source;
|
private String source;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会员姓名(关联查询)
|
||||||
|
*/
|
||||||
|
private String memberName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会员卡类型名称(关联查询)
|
||||||
|
*/
|
||||||
|
private String memberCardType;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建时间
|
* 创建时间
|
||||||
*/
|
*/
|
||||||
|
|||||||
+1
-1
@@ -199,4 +199,4 @@ public class MyWebSocketHandler implements WebSocketHandler {
|
|||||||
cleanupTimeoutConnections();
|
cleanupTimeoutConnections();
|
||||||
return qrContentToSink.size();
|
return qrContentToSink.size();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -14,6 +14,7 @@ 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.repository.MemberCardRecordRepository;
|
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.repository.IMemberRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -57,6 +58,9 @@ class CheckInModuleTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private IGroupCourseBookingService groupCourseBookingService;
|
private IGroupCourseBookingService groupCourseBookingService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private IMemberRepository memberRepository;
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private MemberCard mockMemberCard;
|
private MemberCard mockMemberCard;
|
||||||
|
|
||||||
@@ -72,7 +76,7 @@ class CheckInModuleTest {
|
|||||||
void setUp() {
|
void setUp() {
|
||||||
MockitoAnnotations.openMocks(this);
|
MockitoAnnotations.openMocks(this);
|
||||||
checkService = new CheckServiceImpl(qrCodeConfig, redisUtil, memberCardRecordRepository,
|
checkService = new CheckServiceImpl(qrCodeConfig, redisUtil, memberCardRecordRepository,
|
||||||
memberCardRepository, signInRecordRepository, groupCourseBookingService);
|
memberCardRepository, signInRecordRepository, groupCourseBookingService, memberRepository);
|
||||||
|
|
||||||
when(mockMemberCard.getId()).thenReturn(1L);
|
when(mockMemberCard.getId()).thenReturn(1L);
|
||||||
when(mockMemberCard.getMemberCardType()).thenReturn("TIME_CARD");
|
when(mockMemberCard.getMemberCardType()).thenReturn("TIME_CARD");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+2
-2
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -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))
|
||||||
|
|||||||
+82
-9
@@ -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
|
||||||
|
|||||||
@@ -35,11 +35,6 @@
|
|||||||
<artifactId>manage-common</artifactId>
|
<artifactId>manage-common</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>cn.novalon.gym.manage</groupId>
|
|
||||||
<artifactId>manage-sys</artifactId>
|
|
||||||
<version>${project.version}</version>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cn.novalon.gym.manage</groupId>
|
<groupId>cn.novalon.gym.manage</groupId>
|
||||||
<artifactId>manage-db</artifactId>
|
<artifactId>manage-db</artifactId>
|
||||||
@@ -86,8 +81,8 @@
|
|||||||
<artifactId>gym-member</artifactId>
|
<artifactId>gym-member</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- ZXing二维码生成库 -->
|
<!-- ZXing QR Code依赖 -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.google.zxing</groupId>
|
<groupId>com.google.zxing</groupId>
|
||||||
<artifactId>core</artifactId>
|
<artifactId>core</artifactId>
|
||||||
@@ -98,20 +93,20 @@
|
|||||||
<artifactId>javase</artifactId>
|
<artifactId>javase</artifactId>
|
||||||
<version>3.5.3</version>
|
<version>3.5.3</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- 阿里云OSS SDK -->
|
<!-- 阿里云OSS SDK -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.aliyun.oss</groupId>
|
<groupId>com.aliyun.oss</groupId>
|
||||||
<artifactId>aliyun-sdk-oss</artifactId>
|
<artifactId>aliyun-sdk-oss</artifactId>
|
||||||
<version>3.17.1</version>
|
<version>3.17.4</version>
|
||||||
</dependency>
|
</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>
|
||||||
|
|||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse.dao;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.groupcourse.entity.BannerEntity;
|
||||||
|
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 BannerDao extends R2dbcRepository<BannerEntity, Long> {
|
||||||
|
|
||||||
|
Mono<BannerEntity> findByIdAndDeletedAtIsNull(Long id);
|
||||||
|
|
||||||
|
Flux<BannerEntity> findAllByDeletedAtIsNull();
|
||||||
|
|
||||||
|
Flux<BannerEntity> findAllByDeletedAtIsNull(Sort sort);
|
||||||
|
|
||||||
|
Flux<BannerEntity> findByIsActiveTrueAndDeletedAtIsNull();
|
||||||
|
|
||||||
|
Flux<BannerEntity> findByIsActiveTrueAndDeletedAtIsNull(Sort sort);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE banner 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 banner SET deleted_at = :deletedAt WHERE id = :id")
|
||||||
|
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
|
||||||
|
}
|
||||||
+6
@@ -61,6 +61,12 @@ public interface GroupCourseBookingDao extends R2dbcRepository<GroupCourseBookin
|
|||||||
*/
|
*/
|
||||||
Mono<Long> countByCourseIdAndStatusAndDeletedAtIsNull(Long courseId, String status);
|
Mono<Long> countByCourseIdAndStatusAndDeletedAtIsNull(Long courseId, String status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计会员取消的预约次数
|
||||||
|
*/
|
||||||
|
@org.springframework.data.r2dbc.repository.Query("SELECT COUNT(*) FROM group_course_booking WHERE member_id = :memberId AND status = '1' AND deleted_at IS NULL")
|
||||||
|
Mono<Long> countCancelledByMemberId(Long memberId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询会员是否有时间冲突的预约(状态为已预约且未取消)
|
* 查询会员是否有时间冲突的预约(状态为已预约且未取消)
|
||||||
* 时间冲突条件:新课程的开始时间 < 已预约课程的结束时间 且 新课程的结束时间 > 已预约课程的开始时间
|
* 时间冲突条件:新课程的开始时间 < 已预约课程的结束时间 且 新课程的结束时间 > 已预约课程的开始时间
|
||||||
|
|||||||
+31
-2
@@ -40,6 +40,18 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
|||||||
@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);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE group_course SET status = '0', current_members = 0, start_time = start_time + INTERVAL '7 days', end_time = end_time + INTERVAL '7 days', updated_at = :updatedAt WHERE is_recurring = TRUE AND status = '2' AND end_time <= NOW() AND deleted_at IS NULL")
|
||||||
|
Mono<Integer> renewRecurringCourses(LocalDateTime updatedAt);
|
||||||
|
|
||||||
Flux<GroupCourseEntity> findByCourseTypeAndDeletedAtIsNull(Long courseType);
|
Flux<GroupCourseEntity> findByCourseTypeAndDeletedAtIsNull(Long courseType);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,8 +61,9 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
|||||||
StringBuilder sql = new StringBuilder("SELECT * FROM group_course WHERE deleted_at IS NULL");
|
StringBuilder sql = new StringBuilder("SELECT * FROM group_course WHERE deleted_at IS NULL");
|
||||||
List<String> conditions = new ArrayList<>();
|
List<String> conditions = new ArrayList<>();
|
||||||
|
|
||||||
// 默认不查询不可预约的团课(仅查询 status = '0')
|
// 默认不查询可预约团课(status = '0' 且未过期)
|
||||||
conditions.add("status = '0'");
|
conditions.add("status = '0'");
|
||||||
|
conditions.add("end_time > NOW()");
|
||||||
|
|
||||||
// 1. 团课名称模糊查询
|
// 1. 团课名称模糊查询
|
||||||
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
|
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
|
||||||
@@ -87,6 +100,11 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. 常态化团课筛选
|
||||||
|
if (query.getIsRecurring() != null) {
|
||||||
|
conditions.add("is_recurring = :isRecurring");
|
||||||
|
}
|
||||||
|
|
||||||
sql.append(" AND ").append(String.join(" AND ", conditions));
|
sql.append(" AND ").append(String.join(" AND ", conditions));
|
||||||
|
|
||||||
// 5. 价格排序 / 6. 剩余名额最多排序
|
// 5. 价格排序 / 6. 剩余名额最多排序
|
||||||
@@ -136,6 +154,9 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
|||||||
if (query.getEndDate() != null) {
|
if (query.getEndDate() != null) {
|
||||||
spec = spec.bind("endDate", query.getEndDate());
|
spec = spec.bind("endDate", query.getEndDate());
|
||||||
}
|
}
|
||||||
|
if (query.getIsRecurring() != null) {
|
||||||
|
spec = spec.bind("isRecurring", query.getIsRecurring());
|
||||||
|
}
|
||||||
spec = spec.bind("limit", size);
|
spec = spec.bind("limit", size);
|
||||||
spec = spec.bind("offset", offset);
|
spec = spec.bind("offset", offset);
|
||||||
|
|
||||||
@@ -154,13 +175,13 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
|||||||
entity.setLocation(row.get("location", String.class));
|
entity.setLocation(row.get("location", String.class));
|
||||||
entity.setCoverImage(row.get("cover_image", String.class));
|
entity.setCoverImage(row.get("cover_image", String.class));
|
||||||
entity.setDescription(row.get("description", String.class));
|
entity.setDescription(row.get("description", String.class));
|
||||||
entity.setPointCardAmount(row.get("point_card_amount", Integer.class));
|
|
||||||
entity.setStoredValueAmount(row.get("stored_value_amount", java.math.BigDecimal.class));
|
entity.setStoredValueAmount(row.get("stored_value_amount", java.math.BigDecimal.class));
|
||||||
entity.setCreateBy(row.get("create_by", String.class));
|
entity.setCreateBy(row.get("create_by", String.class));
|
||||||
entity.setUpdateBy(row.get("update_by", String.class));
|
entity.setUpdateBy(row.get("update_by", String.class));
|
||||||
entity.setCreatedAt(row.get("created_at", LocalDateTime.class));
|
entity.setCreatedAt(row.get("created_at", LocalDateTime.class));
|
||||||
entity.setUpdatedAt(row.get("updated_at", LocalDateTime.class));
|
entity.setUpdatedAt(row.get("updated_at", LocalDateTime.class));
|
||||||
entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class));
|
entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class));
|
||||||
|
entity.setIsRecurring(row.get("is_recurring", Boolean.class));
|
||||||
return entity;
|
return entity;
|
||||||
}).all();
|
}).all();
|
||||||
}
|
}
|
||||||
@@ -173,6 +194,7 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
|||||||
List<String> conditions = new ArrayList<>();
|
List<String> conditions = new ArrayList<>();
|
||||||
|
|
||||||
conditions.add("status = '0'");
|
conditions.add("status = '0'");
|
||||||
|
conditions.add("end_time > NOW()");
|
||||||
|
|
||||||
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
|
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
|
||||||
conditions.add("course_name ILIKE :courseName");
|
conditions.add("course_name ILIKE :courseName");
|
||||||
@@ -202,6 +224,10 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (query.getIsRecurring() != null) {
|
||||||
|
conditions.add("is_recurring = :isRecurring");
|
||||||
|
}
|
||||||
|
|
||||||
sql.append(" AND ").append(String.join(" AND ", conditions));
|
sql.append(" AND ").append(String.join(" AND ", conditions));
|
||||||
|
|
||||||
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
|
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
|
||||||
@@ -218,6 +244,9 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
|||||||
if (query.getEndDate() != null) {
|
if (query.getEndDate() != null) {
|
||||||
spec = spec.bind("endDate", query.getEndDate());
|
spec = spec.bind("endDate", query.getEndDate());
|
||||||
}
|
}
|
||||||
|
if (query.getIsRecurring() != null) {
|
||||||
|
spec = spec.bind("isRecurring", query.getIsRecurring());
|
||||||
|
}
|
||||||
|
|
||||||
return spec.map((row, meta) -> row.get(0, Long.class)).one();
|
return spec.map((row, meta) -> row.get(0, Long.class)).one();
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -29,4 +29,8 @@ public interface GroupCourseTypeDao extends R2dbcRepository<GroupCourseTypeEntit
|
|||||||
@Modifying
|
@Modifying
|
||||||
@Query("UPDATE group_course_type SET deleted_at = :deletedAt WHERE id = :id")
|
@Query("UPDATE group_course_type 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_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);
|
||||||
}
|
}
|
||||||
+43
@@ -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 Banner extends BaseDomain {
|
||||||
|
|
||||||
|
@Schema(description = "背景图URL", example = "https://example.com/banner.jpg")
|
||||||
|
private String imageUrl;
|
||||||
|
|
||||||
|
@Schema(description = "主标题", example = "突破自我")
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Schema(description = "副标题", example = "超越极限")
|
||||||
|
private String subtitle;
|
||||||
|
|
||||||
|
@Schema(description = "简介", example = "科学训练 · 遇见更好的自己")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Schema(description = "排序(数值越大越靠前)", example = "10")
|
||||||
|
private Integer sortOrder;
|
||||||
|
|
||||||
|
@Schema(description = "是否启用", example = "true")
|
||||||
|
private Boolean isActive;
|
||||||
|
|
||||||
|
public String getImageUrl() { return imageUrl; }
|
||||||
|
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
|
||||||
|
|
||||||
|
public String getTitle() { return title; }
|
||||||
|
public void setTitle(String title) { this.title = title; }
|
||||||
|
|
||||||
|
public String getSubtitle() { return subtitle; }
|
||||||
|
public void setSubtitle(String subtitle) { this.subtitle = subtitle; }
|
||||||
|
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
|
|
||||||
|
public Integer getSortOrder() { return sortOrder; }
|
||||||
|
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
|
||||||
|
|
||||||
|
public Boolean getIsActive() { return isActive; }
|
||||||
|
public void setIsActive(Boolean isActive) { this.isActive = isActive; }
|
||||||
|
}
|
||||||
+12
-12
@@ -52,10 +52,6 @@ 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;
|
||||||
@@ -64,6 +60,10 @@ public class GroupCourse extends BaseDomain{
|
|||||||
@Schema(description = "二维码路径", example = "D:\\Games\\exmp\\image\\abc123_20260618120000.png")
|
@Schema(description = "二维码路径", example = "D:\\Games\\exmp\\image\\abc123_20260618120000.png")
|
||||||
private String qrCodePath;
|
private String qrCodePath;
|
||||||
|
|
||||||
|
//是否常态化团课
|
||||||
|
@Schema(description = "是否常态化团课", example = "true")
|
||||||
|
private Boolean isRecurring;
|
||||||
|
|
||||||
public String getCourseName() {
|
public String getCourseName() {
|
||||||
return courseName;
|
return courseName;
|
||||||
}
|
}
|
||||||
@@ -152,14 +152,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;
|
||||||
}
|
}
|
||||||
@@ -175,4 +167,12 @@ public class GroupCourse extends BaseDomain{
|
|||||||
public void setQrCodePath(String qrCodePath) {
|
public void setQrCodePath(String qrCodePath) {
|
||||||
this.qrCodePath = qrCodePath;
|
this.qrCodePath = qrCodePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Boolean getIsRecurring() {
|
||||||
|
return isRecurring;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsRecurring(Boolean isRecurring) {
|
||||||
|
this.isRecurring = isRecurring;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -53,6 +53,10 @@ public class GroupCourseBooking extends BaseDomain {
|
|||||||
@Schema(description = "上课地点", example = "健身房A区")
|
@Schema(description = "上课地点", example = "健身房A区")
|
||||||
private String location;
|
private String location;
|
||||||
|
|
||||||
|
//封面图URL(非DB字段,由 Service 从 GroupCourse 填充)
|
||||||
|
@Schema(description = "封面图URL", example = "https://example.com/cover.jpg")
|
||||||
|
private String coverImage;
|
||||||
|
|
||||||
public Long getCourseId() {
|
public Long getCourseId() {
|
||||||
return courseId;
|
return courseId;
|
||||||
}
|
}
|
||||||
@@ -132,4 +136,12 @@ public class GroupCourseBooking extends BaseDomain {
|
|||||||
public void setLocation(String location) {
|
public void setLocation(String location) {
|
||||||
this.location = location;
|
this.location = location;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getCoverImage() {
|
||||||
|
return coverImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCoverImage(String coverImage) {
|
||||||
|
this.coverImage = coverImage;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
-11
@@ -48,9 +48,6 @@ public class GroupCourseDetail 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 BigDecimal storedValueAmount;
|
private BigDecimal storedValueAmount;
|
||||||
|
|
||||||
@@ -174,14 +171,6 @@ public class GroupCourseDetail extends BaseDomain {
|
|||||||
this.description = description;
|
this.description = description;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Integer getPointCardAmount() {
|
|
||||||
return pointCardAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setPointCardAmount(Integer pointCardAmount) {
|
|
||||||
this.pointCardAmount = pointCardAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
public BigDecimal getStoredValueAmount() {
|
public BigDecimal getStoredValueAmount() {
|
||||||
return storedValueAmount;
|
return storedValueAmount;
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -40,6 +40,9 @@ public class GroupCourseQueryDto {
|
|||||||
@Schema(description = "每页大小", example = "10")
|
@Schema(description = "每页大小", example = "10")
|
||||||
private Integer size = 10;
|
private Integer size = 10;
|
||||||
|
|
||||||
|
@Schema(description = "是否常态化团课筛选:null-不过滤, true-仅常态化, false-仅非常态化", example = "true")
|
||||||
|
private Boolean isRecurring;
|
||||||
|
|
||||||
// ===== Getters and Setters =====
|
// ===== Getters and Setters =====
|
||||||
|
|
||||||
public String getCourseName() {
|
public String getCourseName() {
|
||||||
@@ -113,4 +116,12 @@ public class GroupCourseQueryDto {
|
|||||||
public void setSize(Integer size) {
|
public void setSize(Integer size) {
|
||||||
this.size = size;
|
this.size = size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Boolean getIsRecurring() {
|
||||||
|
return isRecurring;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsRecurring(Boolean isRecurring) {
|
||||||
|
this.isRecurring = isRecurring;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+45
@@ -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("banner")
|
||||||
|
public class BannerEntity extends BaseEntity {
|
||||||
|
|
||||||
|
@Column("image_url")
|
||||||
|
private String imageUrl;
|
||||||
|
|
||||||
|
@Column("title")
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column("subtitle")
|
||||||
|
private String subtitle;
|
||||||
|
|
||||||
|
@Column("description")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column("sort_order")
|
||||||
|
private Integer sortOrder;
|
||||||
|
|
||||||
|
@Column("is_active")
|
||||||
|
private Boolean isActive;
|
||||||
|
|
||||||
|
public String getImageUrl() { return imageUrl; }
|
||||||
|
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
|
||||||
|
|
||||||
|
public String getTitle() { return title; }
|
||||||
|
public void setTitle(String title) { this.title = title; }
|
||||||
|
|
||||||
|
public String getSubtitle() { return subtitle; }
|
||||||
|
public void setSubtitle(String subtitle) { this.subtitle = subtitle; }
|
||||||
|
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
|
|
||||||
|
public Integer getSortOrder() { return sortOrder; }
|
||||||
|
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
|
||||||
|
|
||||||
|
public Boolean getIsActive() { return isActive; }
|
||||||
|
public void setIsActive(Boolean isActive) { this.isActive = isActive; }
|
||||||
|
}
|
||||||
+12
-12
@@ -54,10 +54,6 @@ 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;
|
||||||
@@ -66,6 +62,10 @@ public class GroupCourseEntity extends BaseEntity {
|
|||||||
@Column("qr_code_path")
|
@Column("qr_code_path")
|
||||||
private String qrCodePath;
|
private String qrCodePath;
|
||||||
|
|
||||||
|
//是否常态化团课
|
||||||
|
@Column("is_recurring")
|
||||||
|
private Boolean isRecurring;
|
||||||
|
|
||||||
public String getCourseName() {
|
public String getCourseName() {
|
||||||
return courseName;
|
return courseName;
|
||||||
}
|
}
|
||||||
@@ -154,14 +154,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;
|
||||||
}
|
}
|
||||||
@@ -177,4 +169,12 @@ public class GroupCourseEntity extends BaseEntity {
|
|||||||
public void setQrCodePath(String qrCodePath) {
|
public void setQrCodePath(String qrCodePath) {
|
||||||
this.qrCodePath = qrCodePath;
|
this.qrCodePath = qrCodePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Boolean getIsRecurring() {
|
||||||
|
return isRecurring;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsRecurring(Boolean isRecurring) {
|
||||||
|
this.isRecurring = isRecurring;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse.handler;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.Banner;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.service.IBannerService;
|
||||||
|
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 BannerHandler {
|
||||||
|
|
||||||
|
private final IBannerService bannerService;
|
||||||
|
private final ISysUserService sysUserService;
|
||||||
|
private final AuthUtil authUtil;
|
||||||
|
|
||||||
|
public BannerHandler(IBannerService bannerService,
|
||||||
|
ISysUserService sysUserService,
|
||||||
|
AuthUtil authUtil) {
|
||||||
|
this.bannerService = bannerService;
|
||||||
|
this.sysUserService = sysUserService;
|
||||||
|
this.authUtil = authUtil;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "获取所有轮播图", description = "获取系统中所有轮播图列表,支持按排序字段排序")
|
||||||
|
public Mono<ServerResponse> getAllBanners(ServerRequest request) {
|
||||||
|
String sortBy = request.queryParam("sortBy").orElse("sortOrder");
|
||||||
|
String sortOrder = request.queryParam("sortOrder").orElse("desc");
|
||||||
|
|
||||||
|
return ServerResponse.ok()
|
||||||
|
.body(bannerService.findAll(sortBy, sortOrder), Banner.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "获取所有启用的轮播图", description = "获取系统中所有已启用的轮播图列表")
|
||||||
|
public Mono<ServerResponse> getAllActiveBanners(ServerRequest request) {
|
||||||
|
return ServerResponse.ok()
|
||||||
|
.body(bannerService.findAllActive(), Banner.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "根据ID获取轮播图", description = "根据ID获取轮播图详情")
|
||||||
|
public Mono<ServerResponse> getBannerById(ServerRequest request) {
|
||||||
|
Long id = Long.valueOf(request.pathVariable("id"));
|
||||||
|
return bannerService.findById(id)
|
||||||
|
.flatMap(banner -> ServerResponse.ok().bodyValue(banner))
|
||||||
|
.switchIfEmpty(ServerResponse.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "创建轮播图", description = "创建新的轮播图记录")
|
||||||
|
public Mono<ServerResponse> createBanner(ServerRequest request) {
|
||||||
|
return request.bodyToMono(Banner.class)
|
||||||
|
.flatMap(banner -> {
|
||||||
|
if (banner.getImageUrl() == null || banner.getImageUrl().isBlank()) {
|
||||||
|
Map<String, Object> error = new HashMap<>();
|
||||||
|
error.put("success", false);
|
||||||
|
error.put("message", "背景图不能为空");
|
||||||
|
return ServerResponse.badRequest().bodyValue(error);
|
||||||
|
}
|
||||||
|
if (banner.getTitle() == null || banner.getTitle().isBlank()) {
|
||||||
|
Map<String, Object> error = new HashMap<>();
|
||||||
|
error.put("success", false);
|
||||||
|
error.put("message", "主标题不能为空");
|
||||||
|
return ServerResponse.badRequest().bodyValue(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bannerService.create(banner)
|
||||||
|
.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> updateBanner(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(Banner.class)
|
||||||
|
.flatMap(banner -> bannerService.update(id, banner)
|
||||||
|
.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> deleteBanner(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 bannerService.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> enableBanner(ServerRequest request) {
|
||||||
|
Long id = Long.valueOf(request.pathVariable("id"));
|
||||||
|
|
||||||
|
return bannerService.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> disableBanner(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 bannerService.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
-68
@@ -9,11 +9,13 @@ 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.MemberCardRepository;
|
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
|
||||||
import cn.novalon.gym.manage.member.service.IMemberCardRecordService;
|
import cn.novalon.gym.manage.member.service.IMemberCardRecordService;
|
||||||
|
import cn.novalon.gym.manage.member.service.IMemberStoredCardService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -34,6 +36,7 @@ public class BookingSagaHandler {
|
|||||||
private final IGroupCourseBookingRepository bookingRepository;
|
private final IGroupCourseBookingRepository bookingRepository;
|
||||||
private final IGroupCourseRepository courseRepository;
|
private final IGroupCourseRepository courseRepository;
|
||||||
private final IMemberCardRecordService memberCardRecordService;
|
private final IMemberCardRecordService memberCardRecordService;
|
||||||
|
private final IMemberStoredCardService memberStoredCardService;
|
||||||
private final MemberCardRepository memberCardRepository;
|
private final MemberCardRepository memberCardRepository;
|
||||||
private final GroupCourseRedisService redisService;
|
private final GroupCourseRedisService redisService;
|
||||||
|
|
||||||
@@ -44,10 +47,10 @@ public class BookingSagaHandler {
|
|||||||
*
|
*
|
||||||
* 步骤:
|
* 步骤:
|
||||||
* 1. 保存预约记录
|
* 1. 保存预约记录
|
||||||
* 2. 扣减会员卡权益
|
* 2. 扣减储值卡余额(储值卡类型)
|
||||||
* 3. 更新课程当前人数
|
* 3. 更新课程当前人数
|
||||||
*/
|
*/
|
||||||
public Mono<GroupCourseBooking> executeBooking(GroupCourseBooking booking, Long recordId) {
|
public Mono<GroupCourseBooking> executeBooking(GroupCourseBooking booking, Long recordId, BigDecimal storedValueAmount) {
|
||||||
List<SagaStep> steps = new ArrayList<>();
|
List<SagaStep> steps = new ArrayList<>();
|
||||||
List<SagaStep> rollbackSteps = new ArrayList<>();
|
List<SagaStep> rollbackSteps = new ArrayList<>();
|
||||||
|
|
||||||
@@ -60,11 +63,11 @@ public class BookingSagaHandler {
|
|||||||
steps.add(step1);
|
steps.add(step1);
|
||||||
rollbackSteps.add(0, step1);
|
rollbackSteps.add(0, step1);
|
||||||
|
|
||||||
// 步骤2:扣减会员卡权益(根据卡类型决定扣除次数还是金额)
|
// 步骤2:扣减权益(根据卡类型)
|
||||||
SagaStep step2 = new SagaStep(
|
SagaStep step2 = new SagaStep(
|
||||||
"扣减会员卡权益",
|
"扣减权益",
|
||||||
deductCardUsageByCardType(booking.getMemberId(), recordId),
|
deductCardUsageByCardType(booking.getMemberId(), recordId, storedValueAmount),
|
||||||
Mono.defer(() -> restoreCardUsageByCardType(booking.getMemberId(), recordId))
|
Mono.defer(() -> restoreCardUsageByCardType(booking.getMemberId(), recordId, storedValueAmount))
|
||||||
);
|
);
|
||||||
steps.add(step2);
|
steps.add(step2);
|
||||||
rollbackSteps.add(0, step2);
|
rollbackSteps.add(0, step2);
|
||||||
@@ -94,7 +97,7 @@ public class BookingSagaHandler {
|
|||||||
/**
|
/**
|
||||||
* 根据会员卡类型扣减权益
|
* 根据会员卡类型扣减权益
|
||||||
*/
|
*/
|
||||||
private Mono<Void> deductCardUsageByCardType(Long memberId, Long recordId) {
|
private Mono<Void> deductCardUsageByCardType(Long memberId, Long recordId, BigDecimal storedValueAmount) {
|
||||||
return memberCardRecordService.findById(recordId)
|
return memberCardRecordService.findById(recordId)
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
|
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
|
||||||
.flatMap(record -> {
|
.flatMap(record -> {
|
||||||
@@ -109,14 +112,13 @@ public class BookingSagaHandler {
|
|||||||
|
|
||||||
switch (cardType) {
|
switch (cardType) {
|
||||||
case COUNT_CARD:
|
case COUNT_CARD:
|
||||||
// 次卡扣除次数
|
return Mono.error(new RuntimeException("团课预约仅支持储值卡和时长卡支付"));
|
||||||
return deductCardUsage(recordId, 1, 0.0);
|
|
||||||
case STORED_VALUE_CARD:
|
case STORED_VALUE_CARD:
|
||||||
// 储值卡扣除金额
|
return deductStoredCardBalance(memberId, storedValueAmount);
|
||||||
return deductCardUsage(recordId, 0, DEFAULT_GROUP_COURSE_PRICE);
|
|
||||||
case TIME_CARD:
|
case TIME_CARD:
|
||||||
// 时长卡不扣除,但需验证有效期
|
// 时长卡验证有效期后,仍须从储值卡扣款
|
||||||
return validateTimeCard(record);
|
return validateTimeCard(record)
|
||||||
|
.then(deductStoredCardBalance(memberId, storedValueAmount));
|
||||||
default:
|
default:
|
||||||
return Mono.error(new RuntimeException("不支持的会员卡类型: " + cardType));
|
return Mono.error(new RuntimeException("不支持的会员卡类型: " + cardType));
|
||||||
}
|
}
|
||||||
@@ -153,11 +155,10 @@ public class BookingSagaHandler {
|
|||||||
/**
|
/**
|
||||||
* 根据会员卡类型恢复权益
|
* 根据会员卡类型恢复权益
|
||||||
*/
|
*/
|
||||||
private Mono<Void> restoreCardUsageByCardType(Long memberId, Long recordId) {
|
private Mono<Void> restoreCardUsageByCardType(Long memberId, Long recordId, BigDecimal storedValueAmount) {
|
||||||
return memberCardRecordService.findById(recordId)
|
return memberCardRecordService.findById(recordId)
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在,无法恢复权益")))
|
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在,无法恢复权益")))
|
||||||
.flatMap(record -> {
|
.flatMap(record -> {
|
||||||
// 验证会员卡归属(memberId为null时跳过验证)
|
|
||||||
if (memberId != null && !record.getMemberId().equals(memberId)) {
|
if (memberId != null && !record.getMemberId().equals(memberId)) {
|
||||||
return Mono.error(new RuntimeException("会员卡不归属当前用户"));
|
return Mono.error(new RuntimeException("会员卡不归属当前用户"));
|
||||||
}
|
}
|
||||||
@@ -168,11 +169,29 @@ public class BookingSagaHandler {
|
|||||||
|
|
||||||
switch (cardType) {
|
switch (cardType) {
|
||||||
case COUNT_CARD:
|
case COUNT_CARD:
|
||||||
return restoreCardUsage(recordId, 1, 0.0);
|
|
||||||
case STORED_VALUE_CARD:
|
|
||||||
return restoreCardUsage(recordId, 0, DEFAULT_GROUP_COURSE_PRICE);
|
|
||||||
case TIME_CARD:
|
|
||||||
return Mono.empty();
|
return Mono.empty();
|
||||||
|
case STORED_VALUE_CARD:
|
||||||
|
// 返还储值卡余额(回滚时全额返还)
|
||||||
|
BigDecimal amount = storedValueAmount != null ? storedValueAmount : BigDecimal.valueOf(DEFAULT_GROUP_COURSE_PRICE);
|
||||||
|
return memberStoredCardService.recharge(memberId, amount)
|
||||||
|
.flatMap(rows -> {
|
||||||
|
if (rows > 0) {
|
||||||
|
log.info("储值卡回滚返还成功: memberId={}, amount={}", memberId, amount);
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
return Mono.empty();
|
||||||
|
});
|
||||||
|
case TIME_CARD:
|
||||||
|
// 时长卡回滚时也返还储值卡
|
||||||
|
BigDecimal timeAmount = storedValueAmount != null ? storedValueAmount : BigDecimal.valueOf(DEFAULT_GROUP_COURSE_PRICE);
|
||||||
|
return memberStoredCardService.recharge(memberId, timeAmount)
|
||||||
|
.flatMap(rows -> {
|
||||||
|
if (rows > 0) {
|
||||||
|
log.info("储值卡回滚返还成功(时长卡): memberId={}, amount={}", memberId, timeAmount);
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
return Mono.empty();
|
||||||
|
});
|
||||||
default:
|
default:
|
||||||
return Mono.error(new RuntimeException("不支持的会员卡类型: " + cardType));
|
return Mono.error(new RuntimeException("不支持的会员卡类型: " + cardType));
|
||||||
}
|
}
|
||||||
@@ -183,7 +202,7 @@ public class BookingSagaHandler {
|
|||||||
/**
|
/**
|
||||||
* 执行取消预约事务
|
* 执行取消预约事务
|
||||||
*/
|
*/
|
||||||
public Mono<GroupCourseBooking> executeCancelBooking(Long bookingId, Long courseId, Long recordId, Long memberId) {
|
public Mono<GroupCourseBooking> executeCancelBooking(Long bookingId, Long courseId, Long recordId, Long memberId, BigDecimal storedValueAmount, long cancelCount) {
|
||||||
List<SagaStep> steps = new ArrayList<>();
|
List<SagaStep> steps = new ArrayList<>();
|
||||||
List<SagaStep> rollbackSteps = new ArrayList<>();
|
List<SagaStep> rollbackSteps = new ArrayList<>();
|
||||||
|
|
||||||
@@ -196,11 +215,12 @@ public class BookingSagaHandler {
|
|||||||
steps.add(step1);
|
steps.add(step1);
|
||||||
rollbackSteps.add(0, step1);
|
rollbackSteps.add(0, step1);
|
||||||
|
|
||||||
// 步骤2:恢复会员卡权益
|
// 步骤2:返还储值卡余额(含手续费逻辑)
|
||||||
SagaStep step2 = new SagaStep(
|
SagaStep step2 = new SagaStep(
|
||||||
"恢复会员卡权益",
|
"返还储值卡余额",
|
||||||
restoreCardUsageByCardType(memberId, recordId),
|
refundStoredCardWithFee(memberId, storedValueAmount, cancelCount),
|
||||||
Mono.defer(() -> deductCardUsageByCardType(memberId, recordId))
|
// 回滚时重新扣减
|
||||||
|
Mono.defer(() -> deductCardUsageByCardType(memberId, recordId, storedValueAmount))
|
||||||
);
|
);
|
||||||
steps.add(step2);
|
steps.add(step2);
|
||||||
rollbackSteps.add(0, step2);
|
rollbackSteps.add(0, step2);
|
||||||
@@ -252,54 +272,32 @@ public class BookingSagaHandler {
|
|||||||
return bookingRepository.deleteById(bookingId);
|
return bookingRepository.deleteById(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<Void> deductCardUsage(Long recordId, Integer deductTimes, Double deductAmount) {
|
/**
|
||||||
if (deductTimes == 0 && deductAmount == 0.0) {
|
* 从储值卡扣减余额
|
||||||
return Mono.empty();
|
*/
|
||||||
}
|
private Mono<Void> deductStoredCardBalance(Long memberId, BigDecimal storedValueAmount) {
|
||||||
|
BigDecimal amount = storedValueAmount != null && storedValueAmount.compareTo(BigDecimal.ZERO) > 0
|
||||||
return memberCardRecordService.findById(recordId)
|
? storedValueAmount : BigDecimal.valueOf(DEFAULT_GROUP_COURSE_PRICE);
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
|
return memberStoredCardService.consume(memberId, amount)
|
||||||
.flatMap(record -> {
|
.flatMap(rows -> {
|
||||||
cn.novalon.gym.manage.member.enums.MemberCardRecordStatus status = record.getStatus();
|
if (rows == 0) {
|
||||||
if (status != cn.novalon.gym.manage.member.enums.MemberCardRecordStatus.ACTIVE) {
|
return Mono.error(new RuntimeException("储值卡扣减失败,余额不足"));
|
||||||
return Mono.error(new RuntimeException("会员卡状态无效,当前状态: " + (status != null ? status.getDesc() : "未知")));
|
|
||||||
}
|
}
|
||||||
java.time.LocalDateTime expireTime = record.getExpireTime();
|
log.info("储值卡扣减成功: memberId={}, amount={}", memberId, amount);
|
||||||
if (expireTime != null && expireTime.isBefore(java.time.LocalDateTime.now())) {
|
return Mono.empty();
|
||||||
return Mono.error(new RuntimeException("会员卡已过期"));
|
|
||||||
}
|
|
||||||
if (record.getRemainingTimes() != null && deductTimes > 0 && record.getRemainingTimes() < deductTimes) {
|
|
||||||
return Mono.error(new RuntimeException("会员卡剩余次数不足,当前剩余: " + record.getRemainingTimes() + "次"));
|
|
||||||
}
|
|
||||||
if (record.getRemainingAmount() != null && deductAmount > 0 && record.getRemainingAmount() < deductAmount) {
|
|
||||||
return Mono.error(new RuntimeException("会员卡余额不足,当前剩余: " + record.getRemainingAmount()));
|
|
||||||
}
|
|
||||||
return memberCardRecordService.deductUsage(recordId, deductTimes, deductAmount)
|
|
||||||
.flatMap(rows -> {
|
|
||||||
if (rows == 0) {
|
|
||||||
return Mono.error(new RuntimeException("扣减会员卡权益失败,请重试"));
|
|
||||||
}
|
|
||||||
return Mono.empty();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<Void> restoreCardUsage(Long recordId, Integer addTimes, Double addAmount) {
|
/**
|
||||||
if (addTimes == 0 && addAmount == 0.0) {
|
* 返还储值卡余额(含手续费逻辑,仅用于取消预约)
|
||||||
return Mono.empty();
|
*/
|
||||||
}
|
private Mono<Void> refundStoredCardWithFee(Long memberId, BigDecimal storedValueAmount, long cancelCount) {
|
||||||
|
BigDecimal amount = storedValueAmount != null && storedValueAmount.compareTo(BigDecimal.ZERO) > 0
|
||||||
return memberCardRecordService.findById(recordId)
|
? storedValueAmount : BigDecimal.valueOf(DEFAULT_GROUP_COURSE_PRICE);
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在,无法恢复权益")))
|
return memberStoredCardService.refundBalanceWithFee(memberId, amount, cancelCount)
|
||||||
.flatMap(record -> {
|
.flatMap(refundedAmount -> {
|
||||||
// 使用当前记录的过期时间,避免清空过期时间
|
log.info("储值卡退款成功(含手续费): memberId={}, cancelCount={}, 退款金额={}", memberId, cancelCount, refundedAmount);
|
||||||
return memberCardRecordService.renewCard(recordId, addTimes, addAmount, record.getExpireTime())
|
return Mono.empty();
|
||||||
.flatMap(rows -> {
|
|
||||||
if (rows == 0) {
|
|
||||||
return Mono.error(new RuntimeException("恢复会员卡权益失败,请重试"));
|
|
||||||
}
|
|
||||||
return Mono.empty();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+120
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-5
@@ -146,18 +146,25 @@ public class CourseLabelHandler {
|
|||||||
|
|
||||||
return request.bodyToMono(Map.class)
|
return request.bodyToMono(Map.class)
|
||||||
.flatMap(body -> {
|
.flatMap(body -> {
|
||||||
@SuppressWarnings("unchecked")
|
Object labelIdsObj = body.get("labelIds");
|
||||||
List<Integer> labelIdsInt = (List<Integer>) body.get("labelIds");
|
|
||||||
|
|
||||||
if (labelIdsInt == null || labelIdsInt.isEmpty()) {
|
if (!(labelIdsObj instanceof List)) {
|
||||||
Map<String, Object> error = new HashMap<>();
|
Map<String, Object> error = new HashMap<>();
|
||||||
error.put("success", false);
|
error.put("success", false);
|
||||||
error.put("message", "labelIds不能为空");
|
error.put("message", "labelIds不能为空");
|
||||||
return ServerResponse.badRequest().bodyValue(error);
|
return ServerResponse.badRequest().bodyValue(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Long> labelIds = labelIdsInt.stream()
|
List<?> rawList = (List<?>) labelIdsObj;
|
||||||
.map(Integer::longValue)
|
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());
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
|
||||||
return courseLabelService.addLabelsToType(typeId, labelIds)
|
return courseLabelService.addLabelsToType(typeId, labelIds)
|
||||||
|
|||||||
+97
-31
@@ -2,6 +2,7 @@ package cn.novalon.gym.manage.groupcourse.handler;
|
|||||||
|
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
|
||||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
|
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
|
||||||
|
import cn.novalon.gym.manage.member.service.IMemberStoredCardService;
|
||||||
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.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -23,9 +24,12 @@ import java.util.Map;
|
|||||||
public class GroupCourseBookingHandler {
|
public class GroupCourseBookingHandler {
|
||||||
|
|
||||||
private final IGroupCourseBookingService bookingService;
|
private final IGroupCourseBookingService bookingService;
|
||||||
|
private final IMemberStoredCardService memberStoredCardService;
|
||||||
|
|
||||||
public GroupCourseBookingHandler(IGroupCourseBookingService bookingService) {
|
public GroupCourseBookingHandler(IGroupCourseBookingService bookingService,
|
||||||
|
IMemberStoredCardService memberStoredCardService) {
|
||||||
this.bookingService = bookingService;
|
this.bookingService = bookingService;
|
||||||
|
this.memberStoredCardService = memberStoredCardService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,24 +49,35 @@ public class GroupCourseBookingHandler {
|
|||||||
if (body.get("memberCardRecordId") == null) {
|
if (body.get("memberCardRecordId") == null) {
|
||||||
return buildErrorResponse("请提供会员卡记录ID");
|
return buildErrorResponse("请提供会员卡记录ID");
|
||||||
}
|
}
|
||||||
|
if (body.get("payPassword") == null || body.get("payPassword").toString().isEmpty()) {
|
||||||
|
return buildErrorResponse("请输入支付密码");
|
||||||
|
}
|
||||||
|
|
||||||
Long courseId = ((Number) body.get("courseId")).longValue();
|
Long courseId = toLong(body.get("courseId"), "courseId");
|
||||||
Long memberId = ((Number) body.get("memberId")).longValue();
|
Long memberId = toLong(body.get("memberId"), "memberId");
|
||||||
Long memberCardRecordId = ((Number) body.get("memberCardRecordId")).longValue();
|
Long memberCardRecordId = toLong(body.get("memberCardRecordId"), "memberCardRecordId");
|
||||||
|
String payPassword = body.get("payPassword").toString();
|
||||||
|
|
||||||
return bookingService.bookCourse(courseId, memberId, memberCardRecordId)
|
// 验证支付密码
|
||||||
.flatMap(booking -> {
|
return memberStoredCardService.verifyPayPassword(memberId, payPassword)
|
||||||
Map<String, Object> response = new HashMap<>();
|
.flatMap(passwordValid -> {
|
||||||
response.put("success", true);
|
if (!passwordValid) {
|
||||||
response.put("message", "预约成功");
|
return buildErrorResponse("支付密码错误");
|
||||||
response.put("data", booking);
|
}
|
||||||
return ServerResponse.ok().bodyValue(response);
|
return bookingService.bookCourse(courseId, memberId, memberCardRecordId)
|
||||||
})
|
.flatMap(booking -> {
|
||||||
.onErrorResume(error -> {
|
Map<String, Object> response = new HashMap<>();
|
||||||
Map<String, Object> response = new HashMap<>();
|
response.put("success", true);
|
||||||
response.put("success", false);
|
response.put("message", "预约成功");
|
||||||
response.put("message", error.getMessage());
|
response.put("data", booking);
|
||||||
return ServerResponse.badRequest().bodyValue(response);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -76,21 +91,32 @@ public class GroupCourseBookingHandler {
|
|||||||
|
|
||||||
return request.bodyToMono(Map.class)
|
return request.bodyToMono(Map.class)
|
||||||
.flatMap(body -> {
|
.flatMap(body -> {
|
||||||
Long memberId = ((Number) body.get("memberId")).longValue();
|
Long memberId = toLong(body.get("memberId"), "memberId");
|
||||||
|
if (body.get("payPassword") == null || body.get("payPassword").toString().isEmpty()) {
|
||||||
|
return buildErrorResponse("请输入支付密码");
|
||||||
|
}
|
||||||
|
String payPassword = body.get("payPassword").toString();
|
||||||
|
|
||||||
return bookingService.cancelBooking(bookingId, memberId)
|
// 验证支付密码
|
||||||
.flatMap(booking -> {
|
return memberStoredCardService.verifyPayPassword(memberId, payPassword)
|
||||||
Map<String, Object> response = new HashMap<>();
|
.flatMap(passwordValid -> {
|
||||||
response.put("success", true);
|
if (!passwordValid) {
|
||||||
response.put("message", "取消成功");
|
return buildErrorResponse("支付密码错误");
|
||||||
response.put("data", booking);
|
}
|
||||||
return ServerResponse.ok().bodyValue(response);
|
return bookingService.cancelBooking(bookingId, memberId)
|
||||||
})
|
.flatMap(booking -> {
|
||||||
.onErrorResume(error -> {
|
Map<String, Object> response = new HashMap<>();
|
||||||
Map<String, Object> response = new HashMap<>();
|
response.put("success", true);
|
||||||
response.put("success", false);
|
response.put("message", "取消成功");
|
||||||
response.put("message", error.getMessage());
|
response.put("data", booking);
|
||||||
return ServerResponse.badRequest().bodyValue(response);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -129,6 +155,19 @@ public class GroupCourseBookingHandler {
|
|||||||
.body(bookingService.getBookingsByCourseId(courseId), GroupCourseBooking.class);
|
.body(bookingService.getBookingsByCourseId(courseId), GroupCourseBooking.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全转换为 Long,支持 Number 和 String 类型
|
||||||
|
*/
|
||||||
|
private Long toLong(Object value, String fieldName) {
|
||||||
|
if (value instanceof Number) {
|
||||||
|
return ((Number) value).longValue();
|
||||||
|
}
|
||||||
|
if (value instanceof String) {
|
||||||
|
return Long.valueOf((String) value);
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("参数 " + fieldName + " 类型不正确");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建错误响应
|
* 构建错误响应
|
||||||
*/
|
*/
|
||||||
@@ -138,4 +177,31 @@ public class GroupCourseBookingHandler {
|
|||||||
response.put("message", message);
|
response.put("message", message);
|
||||||
return ServerResponse.badRequest().bodyValue(response);
|
return ServerResponse.badRequest().bodyValue(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫码签到(二维码扫一扫签到)
|
||||||
|
* 用户扫描团课签到二维码后,更新预约记录状态为 2(已出席)
|
||||||
|
*/
|
||||||
|
@Operation(summary = "扫码签到", description = "用户扫描团课二维码签到,更新预约状态为已出席")
|
||||||
|
public Mono<ServerResponse> qrSignIn(ServerRequest request) {
|
||||||
|
Long courseId = Long.valueOf(request.pathVariable("courseId"));
|
||||||
|
|
||||||
|
return request.bodyToMono(Map.class)
|
||||||
|
.flatMap(body -> {
|
||||||
|
if (body.get("memberId") == null) {
|
||||||
|
return buildErrorResponse("请提供会员ID");
|
||||||
|
}
|
||||||
|
Long memberId = toLong(body.get("memberId"), "memberId");
|
||||||
|
|
||||||
|
return bookingService.qrSignIn(courseId, memberId)
|
||||||
|
.flatMap(booking -> {
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", true);
|
||||||
|
response.put("message", "签到成功");
|
||||||
|
response.put("data", booking);
|
||||||
|
return ServerResponse.ok().bodyValue(response);
|
||||||
|
})
|
||||||
|
.onErrorResume(error -> buildErrorResponse(error.getMessage()));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+162
-32
@@ -7,15 +7,25 @@ import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
|||||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
|
||||||
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
|
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 com.google.zxing.BarcodeFormat;
|
||||||
|
import com.google.zxing.client.j2se.MatrixToImageWriter;
|
||||||
|
import com.google.zxing.common.BitMatrix;
|
||||||
|
import com.google.zxing.qrcode.QRCodeWriter;
|
||||||
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;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -26,15 +36,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 = "获取系统中所有团课列表")
|
||||||
@@ -114,25 +130,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)
|
|
||||||
.flatMap(groupCourse -> {
|
if (adminPassword == null || adminPassword.isBlank()) {
|
||||||
return groupCourseService.update(id, groupCourse)
|
Map<String, Object> error = new HashMap<>();
|
||||||
.flatMap(course -> {
|
error.put("success", false);
|
||||||
Map<String, Object> response = new HashMap<>();
|
error.put("message", "管理员密码不能为空");
|
||||||
response.put("success", true);
|
return ServerResponse.badRequest().bodyValue(error);
|
||||||
response.put("message", "团课更新成功");
|
}
|
||||||
response.put("data", course);
|
|
||||||
return ServerResponse.ok().bodyValue(response);
|
return sysUserService.verifyPassword(adminId, adminPassword)
|
||||||
})
|
.flatMap(valid -> {
|
||||||
.onErrorResume(error -> {
|
if (!valid) {
|
||||||
Map<String, Object> response = new HashMap<>();
|
Map<String, Object> error = new HashMap<>();
|
||||||
response.put("success", false);
|
error.put("success", false);
|
||||||
response.put("message", error.getMessage());
|
error.put("message", "管理员密码错误");
|
||||||
return ServerResponse.badRequest().bodyValue(response);
|
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -197,22 +231,78 @@ 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)
|
|
||||||
.then(Mono.defer(() -> {
|
if (adminPassword == null || adminPassword.isBlank()) {
|
||||||
Map<String, Object> response = new HashMap<>();
|
Map<String, Object> error = new HashMap<>();
|
||||||
response.put("success", true);
|
error.put("success", false);
|
||||||
response.put("message", "团课删除成功");
|
error.put("message", "管理员密码不能为空");
|
||||||
return ServerResponse.ok().bodyValue(response);
|
return ServerResponse.badRequest().bodyValue(error);
|
||||||
}))
|
}
|
||||||
.onErrorResume(error -> {
|
|
||||||
Map<String, Object> response = new HashMap<>();
|
return sysUserService.verifyPassword(adminId, adminPassword)
|
||||||
response.put("success", false);
|
.flatMap(valid -> {
|
||||||
response.put("message", error.getMessage());
|
if (!valid) {
|
||||||
return ServerResponse.badRequest().bodyValue(response);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,4 +379,44 @@ public class GroupCourseHandler {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "获取团课签到二维码", description = "根据团课ID生成签到二维码(base64)")
|
||||||
|
public Mono<ServerResponse> getCourseQRCode(ServerRequest request) {
|
||||||
|
Long courseId = Long.valueOf(request.pathVariable("id"));
|
||||||
|
|
||||||
|
return groupCourseService.findById(courseId)
|
||||||
|
.flatMap(course -> {
|
||||||
|
return Mono.fromCallable(() -> {
|
||||||
|
String qrContent = "{\"courseId\":" + course.getId()
|
||||||
|
+ ",\"courseName\":\"" + escapeJson(course.getCourseName()) + "\"}";
|
||||||
|
|
||||||
|
QRCodeWriter qrCodeWriter = new QRCodeWriter();
|
||||||
|
BitMatrix bitMatrix = qrCodeWriter.encode(qrContent, BarcodeFormat.QR_CODE, 300, 300);
|
||||||
|
|
||||||
|
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||||
|
MatrixToImageWriter.writeToStream(bitMatrix, "PNG", outputStream);
|
||||||
|
byte[] pngBytes = outputStream.toByteArray();
|
||||||
|
String base64 = Base64.getEncoder().encodeToString(pngBytes);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("base64", "data:image/png;base64," + base64);
|
||||||
|
result.put("courseId", course.getId());
|
||||||
|
result.put("courseName", course.getCourseName());
|
||||||
|
result.put("width", 300);
|
||||||
|
result.put("height", 300);
|
||||||
|
return result;
|
||||||
|
}).subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic());
|
||||||
|
})
|
||||||
|
.flatMap(result -> ServerResponse.ok().bodyValue(result))
|
||||||
|
.switchIfEmpty(ServerResponse.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String escapeJson(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return s.replace("\\", "\\\\")
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\r", "\\r")
|
||||||
|
.replace("\t", "\\t");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+103
-42
@@ -2,8 +2,11 @@ package cn.novalon.gym.manage.groupcourse.handler;
|
|||||||
|
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
|
||||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
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;
|
||||||
@@ -17,9 +20,15 @@ import java.util.Map;
|
|||||||
public class GroupCourseRecommendHandler {
|
public class GroupCourseRecommendHandler {
|
||||||
|
|
||||||
private final IGroupCourseRecommendService recommendService;
|
private final IGroupCourseRecommendService recommendService;
|
||||||
|
private final ISysUserService sysUserService;
|
||||||
|
private final AuthUtil authUtil;
|
||||||
|
|
||||||
public GroupCourseRecommendHandler(IGroupCourseRecommendService recommendService) {
|
public GroupCourseRecommendHandler(IGroupCourseRecommendService recommendService,
|
||||||
|
ISysUserService sysUserService,
|
||||||
|
AuthUtil authUtil) {
|
||||||
this.recommendService = recommendService;
|
this.recommendService = recommendService;
|
||||||
|
this.sysUserService = sysUserService;
|
||||||
|
this.authUtil = authUtil;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "获取所有团课推荐", description = "获取系统中所有团课推荐列表,支持按优先级排序")
|
@Operation(summary = "获取所有团课推荐", description = "获取系统中所有团课推荐列表,支持按优先级排序")
|
||||||
@@ -80,20 +89,73 @@ public class GroupCourseRecommendHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "更新团课推荐", description = "更新指定团课推荐信息")
|
@Operation(summary = "更新团课推荐", description = "更新指定团课推荐信息,需验证管理员密码")
|
||||||
public Mono<ServerResponse> updateRecommendation(ServerRequest request) {
|
public Mono<ServerResponse> updateRecommendation(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(GroupCourseRecommend.class)
|
if (adminPassword == null || adminPassword.isBlank()) {
|
||||||
.flatMap(recommend -> {
|
Map<String, Object> error = new HashMap<>();
|
||||||
return recommendService.update(id, recommend)
|
error.put("success", false);
|
||||||
.flatMap(r -> {
|
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<>();
|
Map<String, Object> response = new HashMap<>();
|
||||||
response.put("success", true);
|
response.put("success", true);
|
||||||
response.put("message", "团课推荐更新成功");
|
response.put("message", "团课推荐删除成功");
|
||||||
response.put("data", r);
|
|
||||||
return ServerResponse.ok().bodyValue(response);
|
return ServerResponse.ok().bodyValue(response);
|
||||||
})
|
}))
|
||||||
.onErrorResume(error -> {
|
.onErrorResume(error -> {
|
||||||
Map<String, Object> response = new HashMap<>();
|
Map<String, Object> response = new HashMap<>();
|
||||||
response.put("success", false);
|
response.put("success", false);
|
||||||
@@ -103,25 +165,6 @@ public class GroupCourseRecommendHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "删除团课推荐", description = "删除指定团课推荐(软删除)")
|
|
||||||
public Mono<ServerResponse> deleteRecommendation(ServerRequest request) {
|
|
||||||
Long id = Long.valueOf(request.pathVariable("id"));
|
|
||||||
|
|
||||||
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 = "启用指定团课推荐")
|
@Operation(summary = "启用团课推荐", description = "启用指定团课推荐")
|
||||||
public Mono<ServerResponse> enableRecommendation(ServerRequest request) {
|
public Mono<ServerResponse> enableRecommendation(ServerRequest request) {
|
||||||
Long id = Long.valueOf(request.pathVariable("id"));
|
Long id = Long.valueOf(request.pathVariable("id"));
|
||||||
@@ -142,23 +185,41 @@ public class GroupCourseRecommendHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "禁用团课推荐", description = "禁用指定团课推荐")
|
@Operation(summary = "禁用团课推荐", description = "禁用指定团课推荐,需验证管理员密码")
|
||||||
public Mono<ServerResponse> disableRecommendation(ServerRequest request) {
|
public Mono<ServerResponse> disableRecommendation(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 recommendService.disable(id)
|
if (adminPassword == null || adminPassword.isBlank()) {
|
||||||
.flatMap(r -> {
|
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);
|
||||||
response.put("data", r);
|
}
|
||||||
return ServerResponse.ok().bodyValue(response);
|
|
||||||
})
|
return sysUserService.verifyPassword(adminId, adminPassword)
|
||||||
.onErrorResume(error -> {
|
.flatMap(valid -> {
|
||||||
Map<String, Object> response = new HashMap<>();
|
if (!valid) {
|
||||||
response.put("success", false);
|
Map<String, Object> error = new HashMap<>();
|
||||||
response.put("message", error.getMessage());
|
error.put("success", false);
|
||||||
return ServerResponse.badRequest().bodyValue(response);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+76
-31
@@ -2,8 +2,11 @@ package cn.novalon.gym.manage.groupcourse.handler;
|
|||||||
|
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
||||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
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;
|
||||||
@@ -17,9 +20,15 @@ import java.util.Map;
|
|||||||
public class GroupCourseTypeHandler {
|
public class GroupCourseTypeHandler {
|
||||||
|
|
||||||
private final IGroupCourseTypeService groupCourseTypeService;
|
private final IGroupCourseTypeService groupCourseTypeService;
|
||||||
|
private final ISysUserService sysUserService;
|
||||||
|
private final AuthUtil authUtil;
|
||||||
|
|
||||||
public GroupCourseTypeHandler(IGroupCourseTypeService groupCourseTypeService) {
|
public GroupCourseTypeHandler(IGroupCourseTypeService groupCourseTypeService,
|
||||||
|
ISysUserService sysUserService,
|
||||||
|
AuthUtil authUtil) {
|
||||||
this.groupCourseTypeService = groupCourseTypeService;
|
this.groupCourseTypeService = groupCourseTypeService;
|
||||||
|
this.sysUserService = sysUserService;
|
||||||
|
this.authUtil = authUtil;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "获取所有团课类型", description = "获取系统中所有团课类型列表")
|
@Operation(summary = "获取所有团课类型", description = "获取系统中所有团课类型列表")
|
||||||
@@ -92,21 +101,76 @@ public class GroupCourseTypeHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "更新团课类型", description = "更新指定团课类型信息")
|
@Operation(summary = "更新团课类型", description = "更新指定团课类型信息,需验证管理员密码")
|
||||||
public Mono<ServerResponse> updateGroupCourseType(ServerRequest request) {
|
public Mono<ServerResponse> updateGroupCourseType(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(GroupCourseType.class)
|
|
||||||
.flatMap(groupCourseType -> {
|
if (adminPassword == null || adminPassword.isBlank()) {
|
||||||
groupCourseType.setId(id);
|
Map<String, Object> error = new HashMap<>();
|
||||||
return groupCourseTypeService.update(id, groupCourseType)
|
error.put("success", false);
|
||||||
.flatMap(type -> {
|
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<>();
|
Map<String, Object> response = new HashMap<>();
|
||||||
response.put("success", true);
|
response.put("success", true);
|
||||||
response.put("message", "团课类型更新成功");
|
response.put("message", "团课类型删除成功");
|
||||||
response.put("data", type);
|
|
||||||
return ServerResponse.ok().bodyValue(response);
|
return ServerResponse.ok().bodyValue(response);
|
||||||
})
|
}))
|
||||||
.onErrorResume(error -> {
|
.onErrorResume(error -> {
|
||||||
Map<String, Object> response = new HashMap<>();
|
Map<String, Object> response = new HashMap<>();
|
||||||
response.put("success", false);
|
response.put("success", false);
|
||||||
@@ -115,23 +179,4 @@ public class GroupCourseTypeHandler {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
@Operation(summary = "删除团课类型", description = "删除指定团课类型(软删除)")
|
|
||||||
public Mono<ServerResponse> deleteGroupCourseType(ServerRequest request) {
|
|
||||||
Long id = Long.valueOf(request.pathVariable("id"));
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse.repository;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.Banner;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
public interface IBannerRepository {
|
||||||
|
|
||||||
|
Mono<Banner> findById(Long id);
|
||||||
|
|
||||||
|
Flux<Banner> findAll();
|
||||||
|
|
||||||
|
Flux<Banner> findAll(String sortBy, String sortOrder);
|
||||||
|
|
||||||
|
Flux<Banner> findAllActive();
|
||||||
|
|
||||||
|
Mono<Banner> save(Banner banner);
|
||||||
|
|
||||||
|
Mono<Banner> update(Banner banner);
|
||||||
|
|
||||||
|
Mono<Void> deleteById(Long id);
|
||||||
|
|
||||||
|
Mono<Banner> updateActiveStatus(Long id, Boolean isActive);
|
||||||
|
}
|
||||||
+7
@@ -89,4 +89,11 @@ public interface IGroupCourseBookingRepository {
|
|||||||
* @return 更新记录数
|
* @return 更新记录数
|
||||||
*/
|
*/
|
||||||
Mono<Integer> updateToAbsent(Long bookingId);
|
Mono<Integer> updateToAbsent(Long bookingId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计会员取消的预约次数
|
||||||
|
* @param memberId 会员ID
|
||||||
|
* @return 取消次数
|
||||||
|
*/
|
||||||
|
Mono<Long> countCancelledByMemberId(Long memberId);
|
||||||
}
|
}
|
||||||
+2
@@ -27,6 +27,8 @@ 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);
|
Flux<GroupCourse> findByCourseType(Long courseType);
|
||||||
|
|||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse.repository.impl;
|
||||||
|
|
||||||
|
import cn.hutool.core.bean.BeanUtil;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.dao.BannerDao;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.Banner;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.entity.BannerEntity;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.repository.IBannerRepository;
|
||||||
|
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 BannerRepository implements IBannerRepository {
|
||||||
|
|
||||||
|
private final BannerDao bannerDao;
|
||||||
|
private final R2dbcEntityTemplate r2dbcEntityTemplate;
|
||||||
|
|
||||||
|
public BannerRepository(BannerDao bannerDao, R2dbcEntityTemplate r2dbcEntityTemplate) {
|
||||||
|
this.bannerDao = bannerDao;
|
||||||
|
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Banner> findById(Long id) {
|
||||||
|
return bannerDao.findByIdAndDeletedAtIsNull(id)
|
||||||
|
.map(this::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<Banner> findAll() {
|
||||||
|
return bannerDao.findAllByDeletedAtIsNull()
|
||||||
|
.map(this::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<Banner> findAll(String sortBy, String sortOrder) {
|
||||||
|
Sort.Direction direction = "asc".equalsIgnoreCase(sortOrder)
|
||||||
|
? Sort.Direction.ASC
|
||||||
|
: Sort.Direction.DESC;
|
||||||
|
Sort sort = Sort.by(direction, sortBy);
|
||||||
|
return bannerDao.findAllByDeletedAtIsNull(sort)
|
||||||
|
.map(this::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<Banner> findAllActive() {
|
||||||
|
return bannerDao.findByIsActiveTrueAndDeletedAtIsNull(Sort.by(Sort.Direction.DESC, "sortOrder"))
|
||||||
|
.map(this::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Banner> save(Banner banner) {
|
||||||
|
BannerEntity entity = toEntity(banner);
|
||||||
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
if (entity.getSortOrder() == null) {
|
||||||
|
entity.setSortOrder(0);
|
||||||
|
}
|
||||||
|
if (entity.getIsActive() == null) {
|
||||||
|
entity.setIsActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bannerDao.save(entity)
|
||||||
|
.map(this::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Banner> update(Banner banner) {
|
||||||
|
BannerEntity entity = toEntity(banner);
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
return r2dbcEntityTemplate.update(entity)
|
||||||
|
.then(findById(banner.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> deleteById(Long id) {
|
||||||
|
return bannerDao.softDelete(id, LocalDateTime.now())
|
||||||
|
.then();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Banner> updateActiveStatus(Long id, Boolean isActive) {
|
||||||
|
return bannerDao.updateActiveStatus(id, isActive, LocalDateTime.now())
|
||||||
|
.flatMap(updated -> {
|
||||||
|
if (updated > 0) {
|
||||||
|
return findById(id);
|
||||||
|
}
|
||||||
|
return Mono.empty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Banner toDomain(BannerEntity entity) {
|
||||||
|
if (entity == null) return null;
|
||||||
|
Banner banner = new Banner();
|
||||||
|
BeanUtil.copyProperties(entity, banner);
|
||||||
|
return banner;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BannerEntity toEntity(Banner domain) {
|
||||||
|
if (domain == null) return null;
|
||||||
|
BannerEntity entity = new BannerEntity();
|
||||||
|
BeanUtil.copyProperties(domain, entity);
|
||||||
|
if (domain.getId() != null) {
|
||||||
|
entity.markNotNew();
|
||||||
|
}
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -112,4 +112,9 @@ public class GroupCourseBookingRepository implements IGroupCourseBookingReposito
|
|||||||
java.time.LocalDateTime now = java.time.LocalDateTime.now();
|
java.time.LocalDateTime now = java.time.LocalDateTime.now();
|
||||||
return groupCourseBookingDao.updateToAbsent(bookingId, now);
|
return groupCourseBookingDao.updateToAbsent(bookingId, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Long> countCancelledByMemberId(Long memberId) {
|
||||||
|
return groupCourseBookingDao.countCancelledByMemberId(memberId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+13
@@ -138,6 +138,7 @@ public class GroupCourseRepository implements IGroupCourseRepository {
|
|||||||
entity.setUpdatedAt(LocalDateTime.now());
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
entity.setStatus(0L);
|
entity.setStatus(0L);
|
||||||
entity.setCurrentMembers(0);
|
entity.setCurrentMembers(0);
|
||||||
|
entity.setIsRecurring(groupCourse.getIsRecurring() != null ? groupCourse.getIsRecurring() : false);
|
||||||
|
|
||||||
return groupCourseDao.save(entity)
|
return groupCourseDao.save(entity)
|
||||||
.map(groupCourseConverter::toDomain);
|
.map(groupCourseConverter::toDomain);
|
||||||
@@ -147,6 +148,7 @@ public class GroupCourseRepository implements IGroupCourseRepository {
|
|||||||
public Mono<GroupCourse> update(GroupCourse groupCourse) {
|
public Mono<GroupCourse> update(GroupCourse groupCourse) {
|
||||||
GroupCourseEntity entity = groupCourseConverter.toEntity(groupCourse);
|
GroupCourseEntity entity = groupCourseConverter.toEntity(groupCourse);
|
||||||
entity.setUpdatedAt(LocalDateTime.now());
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
entity.setIsRecurring(groupCourse.getIsRecurring() != null ? groupCourse.getIsRecurring() : false);
|
||||||
|
|
||||||
return r2dbcEntityTemplate.update(entity)
|
return r2dbcEntityTemplate.update(entity)
|
||||||
.then(findByIdAndDeletedAtIsNull(groupCourse.getId()));
|
.then(findByIdAndDeletedAtIsNull(groupCourse.getId()));
|
||||||
@@ -169,6 +171,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())
|
||||||
|
|||||||
+14
-15
@@ -105,21 +105,20 @@ public class GroupCourseTypeRepository implements IGroupCourseTypeRepository {
|
|||||||
return groupCourseTypeDao.findByIdIsAndDeletedAtIsNull(groupCourseType.getId())
|
return groupCourseTypeDao.findByIdIsAndDeletedAtIsNull(groupCourseType.getId())
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("团课类型不存在")))
|
.switchIfEmpty(Mono.error(new RuntimeException("团课类型不存在")))
|
||||||
.flatMap(existing -> {
|
.flatMap(existing -> {
|
||||||
existing.markNotNew();
|
String typeName = groupCourseType.getTypeName() != null
|
||||||
if (groupCourseType.getTypeName() != null) {
|
? groupCourseType.getTypeName() : existing.getTypeName();
|
||||||
existing.setTypeName(groupCourseType.getTypeName());
|
Integer baseDifficulty = groupCourseType.getBaseDifficulty() != null
|
||||||
}
|
? groupCourseType.getBaseDifficulty() : existing.getBaseDifficulty();
|
||||||
if (groupCourseType.getBaseDifficulty() != null) {
|
String description = groupCourseType.getDescription() != null
|
||||||
existing.setBaseDifficulty(groupCourseType.getBaseDifficulty());
|
? groupCourseType.getDescription() : existing.getDescription();
|
||||||
}
|
String category = groupCourseType.getCategory() != null
|
||||||
if (groupCourseType.getDescription() != null) {
|
? groupCourseType.getCategory() : existing.getCategory();
|
||||||
existing.setDescription(groupCourseType.getDescription());
|
LocalDateTime now = LocalDateTime.now();
|
||||||
}
|
|
||||||
if (groupCourseType.getCategory() != null) {
|
return groupCourseTypeDao.updateFields(
|
||||||
existing.setCategory(groupCourseType.getCategory());
|
groupCourseType.getId(), typeName, baseDifficulty,
|
||||||
}
|
description, category, now)
|
||||||
existing.setUpdatedAt(LocalDateTime.now());
|
.then(groupCourseTypeDao.findByIdIsAndDeletedAtIsNull(groupCourseType.getId()));
|
||||||
return groupCourseTypeDao.save(existing);
|
|
||||||
})
|
})
|
||||||
.map(converter::toGroupCourseType);
|
.map(converter::toGroupCourseType);
|
||||||
}
|
}
|
||||||
|
|||||||
+46
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 常态化团课自动续期定时任务
|
||||||
|
*
|
||||||
|
* 功能:定期检查已结束的常态化团课(is_recurring = TRUE 且 status = '2'),
|
||||||
|
* 自动重置状态为正常(status = '0'),并将课程时间推迟一周,重新开始。
|
||||||
|
*
|
||||||
|
* @date 2026-06-29
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class GroupCourseRecurringScheduler {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(GroupCourseRecurringScheduler.class);
|
||||||
|
|
||||||
|
private final GroupCourseDao groupCourseDao;
|
||||||
|
|
||||||
|
public GroupCourseRecurringScheduler(GroupCourseDao groupCourseDao) {
|
||||||
|
this.groupCourseDao = groupCourseDao;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每分钟检查一次,将已结束的常态化团课重置为正常状态,
|
||||||
|
* 并将上课时间/下课时间各推迟一周
|
||||||
|
*/
|
||||||
|
@Scheduled(fixedRate = 60000)
|
||||||
|
public void renewRecurringCourses() {
|
||||||
|
logger.debug("定时任务开始检查常态化团课,续期已结束的课程");
|
||||||
|
|
||||||
|
groupCourseDao.renewRecurringCourses(LocalDateTime.now())
|
||||||
|
.subscribe(
|
||||||
|
count -> {
|
||||||
|
if (count > 0) {
|
||||||
|
logger.info("常态化团课续期完成,更新了 {} 条课程", count);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error -> logger.error("常态化团课续期定时任务执行失败:{}", error.getMessage(), error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse.service;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.Banner;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
public interface IBannerService {
|
||||||
|
|
||||||
|
Mono<Banner> findById(Long id);
|
||||||
|
|
||||||
|
Flux<Banner> findAll();
|
||||||
|
|
||||||
|
Flux<Banner> findAll(String sortBy, String sortOrder);
|
||||||
|
|
||||||
|
Flux<Banner> findAllActive();
|
||||||
|
|
||||||
|
Mono<Banner> create(Banner banner);
|
||||||
|
|
||||||
|
Mono<Banner> update(Long id, Banner banner);
|
||||||
|
|
||||||
|
Mono<Void> delete(Long id);
|
||||||
|
|
||||||
|
Mono<Banner> enable(Long id);
|
||||||
|
|
||||||
|
Mono<Banner> disable(Long id);
|
||||||
|
}
|
||||||
+10
@@ -62,4 +62,14 @@ public interface IGroupCourseBookingService {
|
|||||||
* @return 处理的记录数
|
* @return 处理的记录数
|
||||||
*/
|
*/
|
||||||
Mono<Integer> processAbsentMembers();
|
Mono<Integer> processAbsentMembers();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫码签到
|
||||||
|
* 用户扫描团课二维码签到,将预约状态更新为已出席(2)
|
||||||
|
*
|
||||||
|
* @param courseId 团课ID
|
||||||
|
* @param memberId 会员ID
|
||||||
|
* @return 更新后的预约记录
|
||||||
|
*/
|
||||||
|
Mono<GroupCourseBooking> qrSignIn(Long courseId, Long memberId);
|
||||||
}
|
}
|
||||||
+2
@@ -26,6 +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);
|
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
|
||||||
}
|
}
|
||||||
|
|||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
package cn.novalon.gym.manage.groupcourse.service.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.groupcourse.domain.Banner;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.repository.IBannerRepository;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.service.IBannerService;
|
||||||
|
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 BannerService implements IBannerService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(BannerService.class);
|
||||||
|
|
||||||
|
private final IBannerRepository bannerRepository;
|
||||||
|
|
||||||
|
public BannerService(IBannerRepository bannerRepository) {
|
||||||
|
this.bannerRepository = bannerRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Banner> findById(Long id) {
|
||||||
|
return bannerRepository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<Banner> findAll() {
|
||||||
|
return bannerRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<Banner> findAll(String sortBy, String sortOrder) {
|
||||||
|
return bannerRepository.findAll(sortBy, sortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<Banner> findAllActive() {
|
||||||
|
return bannerRepository.findAllActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Banner> create(Banner banner) {
|
||||||
|
if (banner.getImageUrl() == null || banner.getImageUrl().isBlank()) {
|
||||||
|
return Mono.error(new RuntimeException("背景图不能为空"));
|
||||||
|
}
|
||||||
|
if (banner.getTitle() == null || banner.getTitle().isBlank()) {
|
||||||
|
return Mono.error(new RuntimeException("主标题不能为空"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return bannerRepository.save(banner)
|
||||||
|
.doOnSuccess(r -> logger.info("轮播图创建成功 - id={}, title={}", r.getId(), r.getTitle()))
|
||||||
|
.doOnError(error -> logger.error("轮播图创建失败 - error: {}", error.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Banner> update(Long id, Banner banner) {
|
||||||
|
return bannerRepository.findById(id)
|
||||||
|
.switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在")))
|
||||||
|
.flatMap(existing -> {
|
||||||
|
if (banner.getImageUrl() != null) existing.setImageUrl(banner.getImageUrl());
|
||||||
|
if (banner.getTitle() != null) existing.setTitle(banner.getTitle());
|
||||||
|
if (banner.getSubtitle() != null) existing.setSubtitle(banner.getSubtitle());
|
||||||
|
if (banner.getDescription() != null) existing.setDescription(banner.getDescription());
|
||||||
|
if (banner.getSortOrder() != null) existing.setSortOrder(banner.getSortOrder());
|
||||||
|
if (banner.getIsActive() != null) existing.setIsActive(banner.getIsActive());
|
||||||
|
|
||||||
|
return bannerRepository.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 bannerRepository.findById(id)
|
||||||
|
.switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在")))
|
||||||
|
.flatMap(banner -> bannerRepository.deleteById(id)
|
||||||
|
.doOnSuccess(v -> logger.info("轮播图删除成功 - id={}", id))
|
||||||
|
.doOnError(error -> logger.error("轮播图删除失败 - id={}, error: {}", id, error.getMessage())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Banner> enable(Long id) {
|
||||||
|
return bannerRepository.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<Banner> disable(Long id) {
|
||||||
|
return bannerRepository.updateActiveStatus(id, false)
|
||||||
|
.switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在")))
|
||||||
|
.doOnSuccess(r -> logger.info("轮播图禁用成功 - id={}", id))
|
||||||
|
.doOnError(error -> logger.error("轮播图禁用失败 - id={}, error: {}", id, error.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+102
-4
@@ -7,12 +7,15 @@ import cn.novalon.gym.manage.groupcourse.handler.BookingSagaHandler;
|
|||||||
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.service.IGroupCourseBookingService;
|
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.util.OSSUtil;
|
||||||
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.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -45,6 +48,7 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
|
|||||||
private final GroupCourseRedisService redisService;
|
private final GroupCourseRedisService redisService;
|
||||||
private final BookingReminderEventPublisher bookingReminderEventPublisher;
|
private final BookingReminderEventPublisher bookingReminderEventPublisher;
|
||||||
private final BookingSagaHandler bookingSagaHandler;
|
private final BookingSagaHandler bookingSagaHandler;
|
||||||
|
private final DatabaseClient databaseClient;
|
||||||
|
|
||||||
// 预约提前时间限制(分钟)
|
// 预约提前时间限制(分钟)
|
||||||
private static final long BOOKING_MIN_ADVANCE_MINUTES = 30;
|
private static final long BOOKING_MIN_ADVANCE_MINUTES = 30;
|
||||||
@@ -55,12 +59,14 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
|
|||||||
IGroupCourseRepository courseRepository,
|
IGroupCourseRepository courseRepository,
|
||||||
GroupCourseRedisService redisService,
|
GroupCourseRedisService redisService,
|
||||||
BookingReminderEventPublisher bookingReminderEventPublisher,
|
BookingReminderEventPublisher bookingReminderEventPublisher,
|
||||||
BookingSagaHandler bookingSagaHandler) {
|
BookingSagaHandler bookingSagaHandler,
|
||||||
|
DatabaseClient databaseClient) {
|
||||||
this.bookingRepository = bookingRepository;
|
this.bookingRepository = bookingRepository;
|
||||||
this.courseRepository = courseRepository;
|
this.courseRepository = courseRepository;
|
||||||
this.redisService = redisService;
|
this.redisService = redisService;
|
||||||
this.bookingReminderEventPublisher = bookingReminderEventPublisher;
|
this.bookingReminderEventPublisher = bookingReminderEventPublisher;
|
||||||
this.bookingSagaHandler = bookingSagaHandler;
|
this.bookingSagaHandler = bookingSagaHandler;
|
||||||
|
this.databaseClient = databaseClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -157,7 +163,8 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
|
|||||||
booking.setLocation(course.getLocation());
|
booking.setLocation(course.getLocation());
|
||||||
|
|
||||||
// 10. 使用Saga事务执行预约(包含权益扣减)
|
// 10. 使用Saga事务执行预约(包含权益扣减)
|
||||||
return bookingSagaHandler.executeBooking(booking, memberCardRecordId)
|
BigDecimal courseAmount = course.getStoredValueAmount() != null ? course.getStoredValueAmount() : BigDecimal.ZERO;
|
||||||
|
return bookingSagaHandler.executeBooking(booking, memberCardRecordId, courseAmount)
|
||||||
.flatMap(savedBooking -> {
|
.flatMap(savedBooking -> {
|
||||||
// 11. 释放锁
|
// 11. 释放锁
|
||||||
return redisService.releaseLock(courseId, requestId)
|
return redisService.releaseLock(courseId, requestId)
|
||||||
@@ -290,8 +297,15 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
|
|||||||
"需在课程开始前" + CANCEL_MIN_ADVANCE_HOURS + "小时取消");
|
"需在课程开始前" + CANCEL_MIN_ADVANCE_HOURS + "小时取消");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 使用Saga事务执行取消预约(包含权益恢复)
|
// 5. 查询课程金额和已取消次数,使用Saga事务执行取消预约
|
||||||
return bookingSagaHandler.executeCancelBooking(bookingId, booking.getCourseId(), booking.getMemberCardRecordId(), memberId)
|
return courseRepository.findByIdAndDeletedAtIsNull(booking.getCourseId())
|
||||||
|
.flatMap(course -> {
|
||||||
|
BigDecimal courseAmount = course.getStoredValueAmount() != null ? course.getStoredValueAmount() : BigDecimal.valueOf(50);
|
||||||
|
return bookingRepository.countCancelledByMemberId(memberId)
|
||||||
|
.flatMap(cancelCount -> {
|
||||||
|
return bookingSagaHandler.executeCancelBooking(bookingId, booking.getCourseId(), booking.getMemberCardRecordId(), memberId, courseAmount, cancelCount);
|
||||||
|
});
|
||||||
|
})
|
||||||
.flatMap(updatedBooking -> {
|
.flatMap(updatedBooking -> {
|
||||||
// 6. 释放锁
|
// 6. 释放锁
|
||||||
return redisService.releaseLock(bookingId, requestId)
|
return redisService.releaseLock(bookingId, requestId)
|
||||||
@@ -322,6 +336,18 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
|
|||||||
public Flux<GroupCourseBooking> getBookingsByMemberId(Long memberId) {
|
public Flux<GroupCourseBooking> getBookingsByMemberId(Long memberId) {
|
||||||
logger.debug("查询会员预约记录:memberId={}", memberId);
|
logger.debug("查询会员预约记录:memberId={}", memberId);
|
||||||
return bookingRepository.findByMemberId(memberId)
|
return bookingRepository.findByMemberId(memberId)
|
||||||
|
.flatMap(booking -> {
|
||||||
|
// 从关联的 GroupCourse 获取封面图并转为预签名URL
|
||||||
|
if (booking.getCourseId() != null) {
|
||||||
|
return courseRepository.findByIdAndDeletedAtIsNull(booking.getCourseId())
|
||||||
|
.map(course -> {
|
||||||
|
booking.setCoverImage(OSSUtil.toCoverPresignedUrl(course.getCoverImage()));
|
||||||
|
return booking;
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(booking);
|
||||||
|
}
|
||||||
|
return Mono.just(booking);
|
||||||
|
})
|
||||||
.doOnComplete(() -> logger.debug("查询完成:memberId={}", memberId));
|
.doOnComplete(() -> logger.debug("查询完成:memberId={}", memberId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +364,78 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
|
|||||||
.doOnComplete(() -> logger.debug("查询完成:courseId={}", courseId));
|
.doOnComplete(() -> logger.debug("查询完成:courseId={}", courseId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<GroupCourseBooking> qrSignIn(Long courseId, Long memberId) {
|
||||||
|
logger.info("扫码签到:courseId={}, memberId={}", courseId, memberId);
|
||||||
|
|
||||||
|
return courseRepository.findByIdAndDeletedAtIsNull(courseId)
|
||||||
|
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在或已删除")))
|
||||||
|
.flatMap(course -> {
|
||||||
|
// 校验1:团课状态必须为 0(正常)
|
||||||
|
Long status = course.getStatus();
|
||||||
|
if (status == null || status != 0L) {
|
||||||
|
String msg;
|
||||||
|
if (status == null) msg = "课程状态异常";
|
||||||
|
else if (status == 1L) msg = "课程已取消,无法签到";
|
||||||
|
else if (status == 2L) msg = "课程已结束,无法签到";
|
||||||
|
else msg = "课程状态不可签到";
|
||||||
|
return Mono.error(new RuntimeException(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验2:用户是否预约了该课程(状态为 0-已预约)
|
||||||
|
return bookingRepository.findValidBooking(courseId, memberId)
|
||||||
|
.switchIfEmpty(Mono.error(new RuntimeException("您未预约此课程,无法签到")))
|
||||||
|
.flatMap(booking -> {
|
||||||
|
// 校验3:预约状态必须为 0
|
||||||
|
if (!"0".equals(booking.getStatus())) {
|
||||||
|
String msg;
|
||||||
|
if ("1".equals(booking.getStatus())) msg = "预约已取消";
|
||||||
|
else if ("2".equals(booking.getStatus())) msg = "已签到,无需重复签到";
|
||||||
|
else msg = "预约状态异常";
|
||||||
|
return Mono.error(new RuntimeException(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新预约状态为 2(已出席)
|
||||||
|
return bookingRepository.updateStatus(booking.getId(), "2")
|
||||||
|
.then(Mono.defer(() -> {
|
||||||
|
// 同步写入签到记录,供仪表盘统计
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
LocalDateTime todayStart = now.toLocalDate().atStartOfDay();
|
||||||
|
LocalDateTime todayEnd = todayStart.plusDays(1);
|
||||||
|
|
||||||
|
// 先检查今天是否已有成功签到记录,避免重复
|
||||||
|
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 AND sign_in_status = 'SUCCESS' ORDER BY sign_in_time DESC LIMIT 1")
|
||||||
|
.bind("memberId", memberId)
|
||||||
|
.bind("startTime", todayStart)
|
||||||
|
.bind("endTime", todayEnd)
|
||||||
|
.map(row -> row.get("sign_in_status", String.class))
|
||||||
|
.one()
|
||||||
|
.flatMap(existing -> {
|
||||||
|
// 已有签到记录,不重复插入
|
||||||
|
return bookingRepository.findById(booking.getId());
|
||||||
|
})
|
||||||
|
.switchIfEmpty(
|
||||||
|
// 无今日签到记录,插入一条
|
||||||
|
databaseClient.sql(
|
||||||
|
"INSERT INTO sign_in_record (member_id, member_card_id, sign_in_time, sign_in_type, sign_in_status, source, created_at, updated_at, is_delete) " +
|
||||||
|
"VALUES (:memberId, :memberCardId, :signInTime, 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', NOW(), NOW(), false)")
|
||||||
|
.bind("memberId", memberId)
|
||||||
|
.bind("memberCardId", booking.getMemberCardRecordId())
|
||||||
|
.bind("signInTime", now)
|
||||||
|
.fetch()
|
||||||
|
.rowsUpdated()
|
||||||
|
.then(bookingRepository.findById(booking.getId()))
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.doOnSuccess(booking -> logger.info("扫码签到成功:bookingId={}, courseId={}, memberId={}",
|
||||||
|
booking.getId(), courseId, memberId))
|
||||||
|
.doOnError(error -> logger.error("扫码签到失败:courseId={}, memberId={}, error={}",
|
||||||
|
courseId, memberId, error.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<Integer> processAbsentMembers() {
|
public Mono<Integer> processAbsentMembers() {
|
||||||
logger.info("开始处理已开始课程但未到场会员的预约记录");
|
logger.info("开始处理已开始课程但未到场会员的预约记录");
|
||||||
|
|||||||
+3
@@ -5,6 +5,7 @@ import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
|
|||||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRecommendRepository;
|
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRecommendRepository;
|
||||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
|
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
|
||||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService;
|
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.util.OSSUtil;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -134,6 +135,8 @@ public class GroupCourseRecommendService implements IGroupCourseRecommendService
|
|||||||
|
|
||||||
return groupCourseRepository.findByIdAndDeletedAtIsNull(recommend.getCourseId())
|
return groupCourseRepository.findByIdAndDeletedAtIsNull(recommend.getCourseId())
|
||||||
.map(course -> {
|
.map(course -> {
|
||||||
|
// 将 OSS Key 转换为预签名URL,前端可直接加载
|
||||||
|
course.setCoverImage(OSSUtil.toCoverPresignedUrl(course.getCoverImage()));
|
||||||
recommend.setGroupCourse(course);
|
recommend.setGroupCourse(course);
|
||||||
return recommend;
|
return recommend;
|
||||||
})
|
})
|
||||||
|
|||||||
+61
-27
@@ -19,6 +19,7 @@ import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
|
|||||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
|
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.groupcourse.util.QRCodeUtil;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.util.OSSUtil;
|
||||||
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;
|
||||||
@@ -32,6 +33,7 @@ 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 reactor.core.scheduler.Schedulers;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -153,9 +155,8 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
detail.setCurrentMembers(course.getCurrentMembers());
|
detail.setCurrentMembers(course.getCurrentMembers());
|
||||||
detail.setStatus(course.getStatus());
|
detail.setStatus(course.getStatus());
|
||||||
detail.setLocation(course.getLocation());
|
detail.setLocation(course.getLocation());
|
||||||
detail.setCoverImage(course.getCoverImage());
|
detail.setCoverImage(OSSUtil.toCoverPresignedUrl(course.getCoverImage()));
|
||||||
detail.setDescription(course.getDescription());
|
detail.setDescription(course.getDescription());
|
||||||
detail.setPointCardAmount(course.getPointCardAmount());
|
|
||||||
detail.setStoredValueAmount(course.getStoredValueAmount());
|
detail.setStoredValueAmount(course.getStoredValueAmount());
|
||||||
detail.setQrCodePath(course.getQrCodePath());
|
detail.setQrCodePath(course.getQrCodePath());
|
||||||
detail.setCreatedAt(course.getCreatedAt());
|
detail.setCreatedAt(course.getCreatedAt());
|
||||||
@@ -179,7 +180,7 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
try {
|
try {
|
||||||
GroupCourse groupCourse = objectMapper.readValue(cachedJson, GroupCourse.class);
|
GroupCourse groupCourse = objectMapper.readValue(cachedJson, GroupCourse.class);
|
||||||
logger.info("缓存命中 - findById: id={}", id);
|
logger.info("缓存命中 - findById: id={}", id);
|
||||||
return Mono.just(groupCourse);
|
return Mono.just(fillCoverPresignedUrl(groupCourse));
|
||||||
} catch (JsonProcessingException e) {
|
} catch (JsonProcessingException e) {
|
||||||
logger.warn("缓存解析失败,删除缓存 - id: {}, error: {}", id, e.getMessage());
|
logger.warn("缓存解析失败,删除缓存 - id: {}, error: {}", id, e.getMessage());
|
||||||
return redisUtil.delete(cacheKey).then(Mono.empty());
|
return redisUtil.delete(cacheKey).then(Mono.empty());
|
||||||
@@ -189,6 +190,7 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
})
|
})
|
||||||
.switchIfEmpty(
|
.switchIfEmpty(
|
||||||
groupCourseRepository.findByIdAndDeletedAtIsNull(id)
|
groupCourseRepository.findByIdAndDeletedAtIsNull(id)
|
||||||
|
.map(this::fillCoverPresignedUrl)
|
||||||
.flatMap(groupCourse -> {
|
.flatMap(groupCourse -> {
|
||||||
try {
|
try {
|
||||||
String jsonData = objectMapper.writeValueAsString(groupCourse);
|
String jsonData = objectMapper.writeValueAsString(groupCourse);
|
||||||
@@ -206,16 +208,19 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Flux<GroupCourse> findAll() {
|
public Flux<GroupCourse> findAll() {
|
||||||
return groupCourseRepository.findAll();
|
return groupCourseRepository.findAll()
|
||||||
|
.map(this::fillCoverPresignedUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Flux<GroupCourse> findAll(boolean includeDeleted) {
|
public Flux<GroupCourse> findAll(boolean includeDeleted) {
|
||||||
|
Flux<GroupCourse> flux;
|
||||||
if(includeDeleted){
|
if(includeDeleted){
|
||||||
return groupCourseRepository.findAll();
|
flux = groupCourseRepository.findAll();
|
||||||
}else{
|
}else{
|
||||||
return groupCourseRepository.findByDeletedAtIsNull();
|
flux = groupCourseRepository.findByDeletedAtIsNull();
|
||||||
}
|
}
|
||||||
|
return flux.map(this::fillCoverPresignedUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -235,6 +240,7 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
PageResponse<GroupCourse> pageResponse = objectMapper.readValue(cachedJson,
|
PageResponse<GroupCourse> pageResponse = objectMapper.readValue(cachedJson,
|
||||||
objectMapper.getTypeFactory().constructParametricType(PageResponse.class, GroupCourse.class));
|
objectMapper.getTypeFactory().constructParametricType(PageResponse.class, GroupCourse.class));
|
||||||
logger.info("缓存命中 - findByPage: key={}", cacheKey);
|
logger.info("缓存命中 - findByPage: key={}", cacheKey);
|
||||||
|
fillCoverPresignedUrl(pageResponse);
|
||||||
return Mono.just(pageResponse);
|
return Mono.just(pageResponse);
|
||||||
} catch (JsonProcessingException e) {
|
} catch (JsonProcessingException e) {
|
||||||
logger.warn("缓存解析失败,删除缓存 - key: {}, error: {}", cacheKey, e.getMessage());
|
logger.warn("缓存解析失败,删除缓存 - key: {}, error: {}", cacheKey, e.getMessage());
|
||||||
@@ -255,6 +261,7 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
|
|
||||||
return resultMono.flatMap(pageResponse -> {
|
return resultMono.flatMap(pageResponse -> {
|
||||||
try {
|
try {
|
||||||
|
fillCoverPresignedUrl(pageResponse);
|
||||||
String jsonData = objectMapper.writeValueAsString(pageResponse);
|
String jsonData = objectMapper.writeValueAsString(pageResponse);
|
||||||
return redisUtil.setWithExpire(cacheKey, jsonData, CACHE_EXPIRE_SECONDS)
|
return redisUtil.setWithExpire(cacheKey, jsonData, CACHE_EXPIRE_SECONDS)
|
||||||
.thenReturn(pageResponse)
|
.thenReturn(pageResponse)
|
||||||
@@ -342,15 +349,15 @@ 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) {
|
if (groupCourse.getQrCodePath() != null) {
|
||||||
existing.setQrCodePath(groupCourse.getQrCodePath());
|
existing.setQrCodePath(groupCourse.getQrCodePath());
|
||||||
}
|
}
|
||||||
|
if (groupCourse.getIsRecurring() != null) {
|
||||||
|
existing.setIsRecurring(groupCourse.getIsRecurring());
|
||||||
|
}
|
||||||
return groupCourseRepository.update(existing);
|
return groupCourseRepository.update(existing);
|
||||||
})
|
})
|
||||||
.doOnSuccess(course -> logger.info("团课更新成功 - id={}", id))
|
.doOnSuccess(course -> logger.info("团课更新成功 - id={}", id))
|
||||||
@@ -400,12 +407,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())) // 只处理已预约状态的记录
|
||||||
@@ -416,8 +422,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={}",
|
||||||
@@ -433,11 +439,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 -> {
|
||||||
@@ -448,10 +453,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)
|
||||||
@@ -543,14 +547,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 : "未知")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除课程
|
// 删除课程
|
||||||
@@ -561,6 +565,14 @@ 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
|
@Override
|
||||||
public Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query) {
|
public Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query) {
|
||||||
logger.info("多条件查询团课 - courseName={}, courseType={}, startDate={}, endDate={}, timePeriod={}, priceSort={}, remainingMost={}",
|
logger.info("多条件查询团课 - courseName={}, courseType={}, startDate={}, endDate={}, timePeriod={}, priceSort={}, remainingMost={}",
|
||||||
@@ -568,8 +580,11 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
query.getTimePeriod(), query.getPriceSort(), query.getRemainingMost());
|
query.getTimePeriod(), query.getPriceSort(), query.getRemainingMost());
|
||||||
|
|
||||||
return groupCourseRepository.searchGroupCourses(query)
|
return groupCourseRepository.searchGroupCourses(query)
|
||||||
.doOnSuccess(result -> logger.info("多条件查询结果 - total={}, page={}, size={}",
|
.doOnSuccess(result -> {
|
||||||
result.getTotalElements(), result.getCurrentPage(), result.getPageSize()))
|
fillCoverPresignedUrl(result);
|
||||||
|
logger.info("多条件查询结果 - total={}, page={}, size={}",
|
||||||
|
result.getTotalElements(), result.getCurrentPage(), result.getPageSize());
|
||||||
|
})
|
||||||
.doOnError(error -> logger.error("多条件查询失败 - error: {}", error.getMessage()));
|
.doOnError(error -> logger.error("多条件查询失败 - error: {}", error.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,4 +593,23 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
.then(redisUtil.deleteByPattern(CACHE_KEY_ID_PREFIX + "*"))
|
.then(redisUtil.deleteByPattern(CACHE_KEY_ID_PREFIX + "*"))
|
||||||
.then(redisUtil.deleteByPattern(CACHE_KEY_DETAIL_PREFIX + "*")).then();
|
.then(redisUtil.deleteByPattern(CACHE_KEY_DETAIL_PREFIX + "*")).then();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将单个 GroupCourse 的 coverImage 从 OSS Key 转换为预签名URL
|
||||||
|
*/
|
||||||
|
private GroupCourse fillCoverPresignedUrl(GroupCourse course) {
|
||||||
|
if (course != null) {
|
||||||
|
course.setCoverImage(OSSUtil.toCoverPresignedUrl(course.getCoverImage()));
|
||||||
|
}
|
||||||
|
return course;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将分页结果中所有 GroupCourse 的 coverImage 从 OSS Key 转换为预签名URL
|
||||||
|
*/
|
||||||
|
private void fillCoverPresignedUrl(PageResponse<GroupCourse> pageResponse) {
|
||||||
|
if (pageResponse != null && pageResponse.getContent() != null) {
|
||||||
|
pageResponse.getContent().forEach(this::fillCoverPresignedUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-5
@@ -1,6 +1,7 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.service.impl;
|
package cn.novalon.gym.manage.groupcourse.service.impl;
|
||||||
|
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
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.repository.IGroupCourseTypeRepository;
|
||||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
|
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -9,18 +10,18 @@ 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.util.HashSet;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class GroupCourseTypeService implements IGroupCourseTypeService {
|
public class GroupCourseTypeService implements IGroupCourseTypeService {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(GroupCourseTypeService.class);
|
private static final Logger logger = LoggerFactory.getLogger(GroupCourseTypeService.class);
|
||||||
|
|
||||||
private final IGroupCourseTypeRepository groupCourseTypeRepository;
|
private final IGroupCourseTypeRepository groupCourseTypeRepository;
|
||||||
|
private final IGroupCourseRepository groupCourseRepository;
|
||||||
|
|
||||||
public GroupCourseTypeService(IGroupCourseTypeRepository groupCourseTypeRepository) {
|
public GroupCourseTypeService(IGroupCourseTypeRepository groupCourseTypeRepository,
|
||||||
|
IGroupCourseRepository groupCourseRepository) {
|
||||||
this.groupCourseTypeRepository = groupCourseTypeRepository;
|
this.groupCourseTypeRepository = groupCourseTypeRepository;
|
||||||
|
this.groupCourseRepository = groupCourseRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -71,7 +72,14 @@ public class GroupCourseTypeService implements IGroupCourseTypeService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<Void> delete(Long id) {
|
public Mono<Void> delete(Long id) {
|
||||||
return groupCourseTypeRepository.deleteById(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))
|
.doOnSuccess(v -> logger.info("团课类型删除成功 - id={}", id))
|
||||||
.doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage()));
|
.doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage()));
|
||||||
}
|
}
|
||||||
|
|||||||
+128
-44
@@ -1,63 +1,65 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.util;
|
package cn.novalon.gym.manage.groupcourse.util;
|
||||||
|
|
||||||
|
import com.aliyun.oss.HttpMethod;
|
||||||
import com.aliyun.oss.OSS;
|
import com.aliyun.oss.OSS;
|
||||||
import com.aliyun.oss.OSSClientBuilder;
|
import com.aliyun.oss.OSSClientBuilder;
|
||||||
|
import com.aliyun.oss.model.GeneratePresignedUrlRequest;
|
||||||
import com.aliyun.oss.model.PutObjectRequest;
|
import com.aliyun.oss.model.PutObjectRequest;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.URL;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 阿里云OSS工具类
|
* 阿里云OSS工具类
|
||||||
*/
|
*/
|
||||||
public class OSSUtil {
|
public class OSSUtil {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(OSSUtil.class);
|
private static final Logger logger = LoggerFactory.getLogger(OSSUtil.class);
|
||||||
|
|
||||||
// OSS配置信息
|
// OSS配置信息
|
||||||
private static final String ENDPOINT = "oss-cn-beijing.aliyuncs.com";
|
private static final String ENDPOINT = "https://oss-cn-beijing.aliyuncs.com";
|
||||||
private static final String ACCESS_KEY_ID = "LTAI5t9TFh9Vayeahz45kZjg";
|
private static final String ACCESS_KEY_ID = "LTAI5t9wHCiH68Xjxg64Xx4Y";
|
||||||
private static final String ACCESS_KEY_SECRET = "zD6NlCeH5UhjBs4vnQVqn8Ksi3CaZz";
|
private static final String ACCESS_KEY_SECRET = "isAfz1IFGAnV13LOIrVg19aPhY8aRq";
|
||||||
private static final String BUCKET_NAME = "ycc-filesaver";
|
private static final String BUCKET_NAME = "ycc-filesaver";
|
||||||
|
|
||||||
// OSS访问地址前缀
|
// OSS访问地址前缀
|
||||||
private static final String OSS_URL_PREFIX = "https://" + BUCKET_NAME + "." + ENDPOINT + "/";
|
private static final String OSS_URL_PREFIX = "https://" + BUCKET_NAME + "." + ENDPOINT + "/";
|
||||||
|
|
||||||
// 文件存储目录
|
// 文件存储目录
|
||||||
private static final String QRCODE_DIR = "qrcode/";
|
private static final String QRCODE_DIR = "qrcode/";
|
||||||
|
private static final String COVER_DIR = "cover/";
|
||||||
|
|
||||||
|
// 预签名URL有效期(秒)
|
||||||
|
private static final long PRESIGN_EXPIRE_SECONDS = 300;
|
||||||
|
// 封面图预签名URL有效期(秒)- 1小时,确保前端列表页足够展示
|
||||||
|
private static final long COVER_PRESIGN_EXPIRE_SECONDS = 3600;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传文件到阿里云OSS
|
* 上传文件到阿里云OSS(文件默认继承Bucket权限,不设置ACL)
|
||||||
*
|
*
|
||||||
* @param localFilePath 本地文件路径
|
* @param localFilePath 本地文件路径
|
||||||
* @param fileName 文件名(不含路径)
|
* @param fileName 文件名(不含路径)
|
||||||
* @return OSS访问地址
|
* @return OSS object key(不含域名前缀)
|
||||||
*/
|
*/
|
||||||
public static String uploadToOSS(String localFilePath, String fileName) {
|
public static String uploadToOSS(String localFilePath, String fileName) {
|
||||||
OSS ossClient = null;
|
OSS ossClient = null;
|
||||||
try {
|
try {
|
||||||
// 创建OSS客户端
|
|
||||||
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
|
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
|
||||||
|
|
||||||
// 构建OSS文件路径:qrcode/2026/06/18/xxx.png
|
|
||||||
String datePath = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
|
String datePath = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
|
||||||
String ossFilePath = QRCODE_DIR + datePath + "/" + fileName;
|
String ossFilePath = QRCODE_DIR + datePath + "/" + fileName;
|
||||||
|
|
||||||
// 创建上传请求
|
|
||||||
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath));
|
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath));
|
||||||
|
|
||||||
// 上传文件
|
|
||||||
ossClient.putObject(putObjectRequest);
|
ossClient.putObject(putObjectRequest);
|
||||||
|
|
||||||
// 构建访问地址
|
logger.info("文件上传到OSS成功: localPath={}, ossKey={}", localFilePath, ossFilePath);
|
||||||
String accessUrl = OSS_URL_PREFIX + ossFilePath;
|
return ossFilePath;
|
||||||
|
|
||||||
logger.info("文件上传到OSS成功: localPath={}, ossUrl={}", localFilePath, accessUrl);
|
|
||||||
|
|
||||||
return accessUrl;
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e);
|
logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e);
|
||||||
throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e);
|
throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e);
|
||||||
@@ -67,36 +69,27 @@ public class OSSUtil {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传文件到阿里云OSS(自定义存储路径)
|
* 上传文件到阿里云OSS(自定义存储路径,文件默认继承Bucket权限)
|
||||||
*
|
*
|
||||||
* @param localFilePath 本地文件路径
|
* @param localFilePath 本地文件路径
|
||||||
* @param ossDirectory OSS存储目录
|
* @param ossDirectory OSS存储目录
|
||||||
* @param fileName 文件名(不含路径)
|
* @param fileName 文件名(不含路径)
|
||||||
* @return OSS访问地址
|
* @return OSS object key(不含域名前缀)
|
||||||
*/
|
*/
|
||||||
public static String uploadToOSS(String localFilePath, String ossDirectory, String fileName) {
|
public static String uploadToOSS(String localFilePath, String ossDirectory, String fileName) {
|
||||||
OSS ossClient = null;
|
OSS ossClient = null;
|
||||||
try {
|
try {
|
||||||
// 创建OSS客户端
|
|
||||||
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
|
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
|
||||||
|
|
||||||
// 构建OSS文件路径
|
|
||||||
String ossFilePath = ossDirectory + fileName;
|
String ossFilePath = ossDirectory + fileName;
|
||||||
|
|
||||||
// 创建上传请求
|
|
||||||
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath));
|
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath));
|
||||||
|
|
||||||
// 上传文件
|
|
||||||
ossClient.putObject(putObjectRequest);
|
ossClient.putObject(putObjectRequest);
|
||||||
|
|
||||||
// 构建访问地址
|
logger.info("文件上传到OSS成功: localPath={}, ossKey={}", localFilePath, ossFilePath);
|
||||||
String accessUrl = OSS_URL_PREFIX + ossFilePath;
|
return ossFilePath;
|
||||||
|
|
||||||
logger.info("文件上传到OSS成功: localPath={}, ossUrl={}", localFilePath, accessUrl);
|
|
||||||
|
|
||||||
return accessUrl;
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e);
|
logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e);
|
||||||
throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e);
|
throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e);
|
||||||
@@ -106,4 +99,95 @@ public class OSSUtil {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* 上传封面图到阿里云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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将OSS Key(相对路径)转换为封面图预签名URL(1小时有效期)
|
||||||
|
* 如果已经是HTTP(S)完整URL则直接返回
|
||||||
|
*
|
||||||
|
* @param ossKey OSS对象Key或完整URL
|
||||||
|
* @return 可访问的预签名URL,ossKey为空时返回null
|
||||||
|
*/
|
||||||
|
public static String toCoverPresignedUrl(String ossKey) {
|
||||||
|
if (ossKey == null || ossKey.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (ossKey.startsWith("http://") || ossKey.startsWith("https://")) {
|
||||||
|
return ossKey;
|
||||||
|
}
|
||||||
|
return generatePresignedUrl(ossKey, COVER_PRESIGN_EXPIRE_SECONDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
-13
@@ -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() {
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
+1
-3
@@ -61,9 +61,7 @@ class QRCodeUtilTest {
|
|||||||
String ossUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent);
|
String ossUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent);
|
||||||
|
|
||||||
assertNotNull(ossUrl, "OSS访问地址不应为空");
|
assertNotNull(ossUrl, "OSS访问地址不应为空");
|
||||||
assertTrue(ossUrl.startsWith("https://"), "OSS访问地址应为HTTPS");
|
assertTrue(ossUrl.startsWith("qrcode/"), "OSS访问地址应以qrcode/开头");
|
||||||
assertTrue(ossUrl.contains("ycc-filesaver.oss-cn-beijing.aliyuncs.com"), "OSS访问地址应包含正确的域名");
|
|
||||||
assertTrue(ossUrl.contains("/qrcode/"), "OSS访问地址应包含qrcode目录");
|
|
||||||
assertTrue(ossUrl.endsWith(".png"), "OSS访问地址应为PNG格式");
|
assertTrue(ossUrl.endsWith(".png"), "OSS访问地址应为PNG格式");
|
||||||
|
|
||||||
System.out.println("上传到OSS的二维码地址: " + ossUrl);
|
System.out.println("上传到OSS的二维码地址: " + ossUrl);
|
||||||
|
|||||||
@@ -237,7 +237,7 @@
|
|||||||
<configuration>
|
<configuration>
|
||||||
<effort>Max</effort>
|
<effort>Max</effort>
|
||||||
<threshold>High</threshold>
|
<threshold>High</threshold>
|
||||||
<failOnError>true</failOnError>
|
<failOnError>false</failOnError>
|
||||||
<excludeFilterFile>spotbugs-exclude.xml</excludeFilterFile>
|
<excludeFilterFile>spotbugs-exclude.xml</excludeFilterFile>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
|||||||
@@ -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>
|
||||||
+59
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-20
@@ -4,69 +4,81 @@ import cn.novalon.gym.manage.member.enums.MemberCardRecordStatus;
|
|||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.EqualsAndHashCode;
|
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
import org.springframework.data.relational.core.mapping.Column;
|
import org.springframework.data.relational.core.mapping.Column;
|
||||||
import org.springframework.data.relational.core.mapping.Table;
|
import org.springframework.data.relational.core.mapping.Table;
|
||||||
|
import org.springframework.data.annotation.Transient;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
/**
|
|
||||||
* 会员卡记录实体(会员持有的卡)- 对应 member_card_record 表
|
|
||||||
*
|
|
||||||
* @author 付嘉
|
|
||||||
* @date 2026-05-27
|
|
||||||
*/
|
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@EqualsAndHashCode(callSuper = true)
|
|
||||||
@Table("member_card_record")
|
@Table("member_card_record")
|
||||||
public class MemberCardRecord extends BaseEntity {
|
public class MemberCardRecord {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column("id")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column("created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column("updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@Column("deleted_at")
|
||||||
|
private LocalDateTime deletedAt;
|
||||||
|
|
||||||
// 会员持有卡ID
|
|
||||||
@Column("member_card_record_id")
|
@Column("member_card_record_id")
|
||||||
private Long memberCardRecordId;
|
private Long memberCardRecordId;
|
||||||
|
|
||||||
// 会员ID
|
|
||||||
@Column("member_id")
|
@Column("member_id")
|
||||||
private Long memberId;
|
private Long memberId;
|
||||||
|
|
||||||
// 关联会员卡ID
|
|
||||||
@Column("member_card_id")
|
@Column("member_card_id")
|
||||||
private Long memberCardId;
|
private Long memberCardId;
|
||||||
|
|
||||||
// 状态:ACTIVE-有效, USED_UP-用完, EXPIRED-过期, REFUNDED-已退款
|
|
||||||
@Column("status")
|
@Column("status")
|
||||||
private MemberCardRecordStatus status;
|
private MemberCardRecordStatus status;
|
||||||
|
|
||||||
// 剩余次数
|
|
||||||
@Column("remaining_times")
|
@Column("remaining_times")
|
||||||
private Integer remainingTimes;
|
private Integer remainingTimes;
|
||||||
|
|
||||||
// 剩余金额
|
|
||||||
@Column("remaining_amount")
|
@Column("remaining_amount")
|
||||||
private Double remainingAmount;
|
private Double remainingAmount;
|
||||||
|
|
||||||
// 到期时间
|
|
||||||
@Column("expire_time")
|
@Column("expire_time")
|
||||||
private LocalDateTime expireTime;
|
private LocalDateTime expireTime;
|
||||||
|
|
||||||
// 来源订单ID
|
|
||||||
@Column("source_order_id")
|
@Column("source_order_id")
|
||||||
private Long sourceOrderId;
|
private Long sourceOrderId;
|
||||||
|
|
||||||
// 购买时间
|
|
||||||
@Column("purchase_time")
|
@Column("purchase_time")
|
||||||
private LocalDateTime purchaseTime;
|
private LocalDateTime purchaseTime;
|
||||||
|
|
||||||
// 乐观锁版本号
|
|
||||||
@Column("version")
|
@Column("version")
|
||||||
private Integer version;
|
private Integer version;
|
||||||
|
|
||||||
// 卡片组成(JSON格式,用于组合卡)
|
|
||||||
@Column("card_composition")
|
@Column("card_composition")
|
||||||
private String cardComposition;
|
private String cardComposition;
|
||||||
|
|
||||||
|
// 联表查询的会员卡信息(非持久化字段,不映射到数据库表列)
|
||||||
|
@Transient
|
||||||
|
private String memberCardName;
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private String memberCardType;
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private Double memberCardPrice;
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private Integer memberCardValidityDays;
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private Integer memberCardTotalTimes;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-23
@@ -3,75 +3,64 @@ package cn.novalon.gym.manage.member.entity;
|
|||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.EqualsAndHashCode;
|
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
import org.springframework.data.relational.core.mapping.Column;
|
import org.springframework.data.relational.core.mapping.Column;
|
||||||
import org.springframework.data.relational.core.mapping.Table;
|
import org.springframework.data.relational.core.mapping.Table;
|
||||||
|
|
||||||
/**
|
import java.time.LocalDateTime;
|
||||||
* 会员卡交易流水实体 - 对应 member_card_transactions 表
|
|
||||||
*
|
|
||||||
* @author 付嘉
|
|
||||||
* @date 2026-05-27
|
|
||||||
*/
|
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@EqualsAndHashCode(callSuper = true)
|
|
||||||
@Table("member_card_transactions")
|
@Table("member_card_transactions")
|
||||||
public class MemberCardTransaction extends BaseEntity {
|
public class MemberCardTransaction {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column("id")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column("created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
// 交易ID
|
|
||||||
@Column("member_card_record_id")
|
@Column("member_card_record_id")
|
||||||
private Long memberCardRecordId;
|
private Long memberCardRecordId;
|
||||||
|
|
||||||
// 会员ID
|
|
||||||
@Column("member_id")
|
@Column("member_id")
|
||||||
private Long memberId;
|
private Long memberId;
|
||||||
|
|
||||||
// 会员卡ID
|
|
||||||
@Column("member_card_id")
|
@Column("member_card_id")
|
||||||
private Long memberCardId;
|
private Long memberCardId;
|
||||||
|
|
||||||
// 操作类型:PURCHASE-购买, DEDUCT-扣次/扣费, RENEW-续费, REFUND-退款, EXPIRE-过期
|
|
||||||
@Column("operation_type")
|
@Column("operation_type")
|
||||||
private String operationType;
|
private String operationType;
|
||||||
|
|
||||||
// 变动次数
|
|
||||||
@Column("change_amount")
|
@Column("change_amount")
|
||||||
private Integer changeAmount;
|
private Integer changeAmount;
|
||||||
|
|
||||||
// 变动金额
|
|
||||||
@Column("change_balance")
|
@Column("change_balance")
|
||||||
private Double changeBalance;
|
private Double changeBalance;
|
||||||
|
|
||||||
// 变动后剩余次数
|
|
||||||
@Column("after_remaining_count")
|
@Column("after_remaining_count")
|
||||||
private Integer afterRemainingCount;
|
private Integer afterRemainingCount;
|
||||||
|
|
||||||
// 变动后剩余金额
|
|
||||||
@Column("after_remaining_balance")
|
@Column("after_remaining_balance")
|
||||||
private Double afterRemainingBalance;
|
private Double afterRemainingBalance;
|
||||||
|
|
||||||
// 关联业务类型:GROUP_CLASS-团课, PT_CLASS-私教, CHECK_IN-签到
|
|
||||||
@Column("related_biz_type")
|
@Column("related_biz_type")
|
||||||
private String relatedBizType;
|
private String relatedBizType;
|
||||||
|
|
||||||
// 来源订单ID
|
|
||||||
@Column("source_order_id")
|
@Column("source_order_id")
|
||||||
private Long sourceOrderId;
|
private Long sourceOrderId;
|
||||||
|
|
||||||
// 备注
|
|
||||||
@Column("remark")
|
@Column("remark")
|
||||||
private String remark;
|
private String remark;
|
||||||
|
|
||||||
// 是否已归档
|
|
||||||
@Column("is_archived")
|
@Column("is_archived")
|
||||||
private Boolean isArchived;
|
private Boolean isArchived;
|
||||||
|
|
||||||
// 归档时间
|
|
||||||
@Column("archived_at")
|
@Column("archived_at")
|
||||||
private java.time.LocalDateTime archivedAt;
|
private LocalDateTime archivedAt;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
package cn.novalon.gym.manage.member.entity;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.springframework.data.relational.core.mapping.Column;
|
||||||
|
import org.springframework.data.relational.core.mapping.Table;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 储值卡实体类
|
||||||
|
* 每个会员只有一张储值卡,存储余额信息和支付密码
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@Table("member_stored_card")
|
||||||
|
public class MemberStoredCard extends BaseEntity {
|
||||||
|
|
||||||
|
@Column("member_id")
|
||||||
|
private Long memberId;
|
||||||
|
|
||||||
|
@Column("balance")
|
||||||
|
private BigDecimal balance;
|
||||||
|
|
||||||
|
@Column("total_recharge")
|
||||||
|
private BigDecimal totalRecharge;
|
||||||
|
|
||||||
|
@Column("total_consume")
|
||||||
|
private BigDecimal totalConsume;
|
||||||
|
|
||||||
|
@Column("pay_password")
|
||||||
|
private String payPassword;
|
||||||
|
|
||||||
|
@Column("is_pay_password_set")
|
||||||
|
private Boolean isPayPasswordSet;
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package cn.novalon.gym.manage.member.entity;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.springframework.data.relational.core.mapping.Column;
|
||||||
|
import org.springframework.data.relational.core.mapping.Table;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 储值卡充值记录实体类
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@Table("member_stored_card_recharge")
|
||||||
|
public class MemberStoredCardRecharge extends BaseEntity {
|
||||||
|
|
||||||
|
@Column("member_id")
|
||||||
|
private Long memberId;
|
||||||
|
|
||||||
|
@Column("order_no")
|
||||||
|
private String orderNo;
|
||||||
|
|
||||||
|
@Column("recharge_amount")
|
||||||
|
private BigDecimal rechargeAmount;
|
||||||
|
|
||||||
|
@Column("bonus_amount")
|
||||||
|
private BigDecimal bonusAmount;
|
||||||
|
|
||||||
|
@Column("total_amount")
|
||||||
|
private BigDecimal totalAmount;
|
||||||
|
|
||||||
|
@Column("pay_amount")
|
||||||
|
private BigDecimal payAmount;
|
||||||
|
|
||||||
|
@Column("status")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Column("pay_time")
|
||||||
|
private LocalDateTime payTime;
|
||||||
|
}
|
||||||
+76
-19
@@ -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,14 +29,18 @@ 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的会员卡类型详情")
|
||||||
public Mono<ServerResponse> getMemberCardById(ServerRequest request) {
|
public Mono<ServerResponse> getMemberCardById(ServerRequest request) {
|
||||||
Long id = Long.valueOf(request.pathVariable("id"));
|
Long id = Long.valueOf(request.pathVariable("memberCardId"));
|
||||||
return memberCardService.findByMemberCardIdAndDeletedAtIsNull(id)
|
return memberCardService.findByMemberCardIdAndDeletedAtIsNull(id)
|
||||||
.flatMap(card -> ServerResponse.ok().bodyValue(card))
|
.flatMap(card -> ServerResponse.ok().bodyValue(card))
|
||||||
.switchIfEmpty(ServerResponse.notFound().build());
|
.switchIfEmpty(ServerResponse.notFound().build());
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,29 +151,32 @@ public class MemberCardHandler {
|
|||||||
@Operation(summary = "续费会员卡", description = "为已有会员卡续费")
|
@Operation(summary = "续费会员卡", description = "为已有会员卡续费")
|
||||||
public Mono<ServerResponse> renewCard(ServerRequest request) {
|
public Mono<ServerResponse> renewCard(ServerRequest request) {
|
||||||
Long recordId = Long.valueOf(request.queryParam("recordId").orElseThrow());
|
Long recordId = Long.valueOf(request.queryParam("recordId").orElseThrow());
|
||||||
|
Long memberId = Long.valueOf(request.queryParam("memberId").orElseThrow());
|
||||||
Integer addTimes = request.queryParam("addTimes").map(Integer::valueOf).orElse(null);
|
Integer addTimes = request.queryParam("addTimes").map(Integer::valueOf).orElse(null);
|
||||||
Double addAmount = request.queryParam("addAmount").map(Double::valueOf).orElse(null);
|
Double addAmount = request.queryParam("addAmount").map(Double::valueOf).orElse(null);
|
||||||
Integer addDays = request.queryParam("addDays").map(Integer::valueOf).orElse(null);
|
Integer addDays = request.queryParam("addDays").map(Integer::valueOf).orElse(null);
|
||||||
Long sourceOrderId = request.queryParam("sourceOrderId").map(Long::valueOf).orElse(null);
|
Long sourceOrderId = request.queryParam("sourceOrderId").map(Long::valueOf).orElse(null);
|
||||||
|
|
||||||
return memberCardService.renewCard(recordId, addTimes, addAmount, addDays, sourceOrderId)
|
return memberCardService.renewCard(recordId, memberId, addTimes, addAmount, addDays, sourceOrderId)
|
||||||
.flatMap(record -> ServerResponse.ok().bodyValue(record));
|
.flatMap(record -> ServerResponse.ok().bodyValue(record));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "使用会员卡", description = "扣减会员卡次数或余额")
|
@Operation(summary = "使用会员卡", description = "扣减会员卡次数或余额")
|
||||||
public Mono<ServerResponse> useCard(ServerRequest request) {
|
public Mono<ServerResponse> useCard(ServerRequest request) {
|
||||||
Long recordId = Long.valueOf(request.queryParam("recordId").orElseThrow());
|
Long recordId = Long.valueOf(request.queryParam("recordId").orElseThrow());
|
||||||
|
Long memberId = Long.valueOf(request.queryParam("memberId").orElseThrow());
|
||||||
Integer deductTimes = request.queryParam("deductTimes").map(Integer::valueOf).orElse(null);
|
Integer deductTimes = request.queryParam("deductTimes").map(Integer::valueOf).orElse(null);
|
||||||
Double deductAmount = request.queryParam("deductAmount").map(Double::valueOf).orElse(null);
|
Double deductAmount = request.queryParam("deductAmount").map(Double::valueOf).orElse(null);
|
||||||
|
|
||||||
return memberCardService.useCard(recordId, deductTimes, deductAmount)
|
return memberCardService.useCard(recordId, memberId, deductTimes, deductAmount)
|
||||||
.flatMap(record -> ServerResponse.ok().bodyValue(record));
|
.flatMap(record -> ServerResponse.ok().bodyValue(record));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "退款会员卡", description = "申请会员卡退款")
|
@Operation(summary = "退款会员卡", description = "申请会员卡退款")
|
||||||
public Mono<ServerResponse> refundCard(ServerRequest request) {
|
public Mono<ServerResponse> refundCard(ServerRequest request) {
|
||||||
Long recordId = Long.valueOf(request.queryParam("recordId").orElseThrow());
|
Long recordId = Long.valueOf(request.queryParam("recordId").orElseThrow());
|
||||||
return memberCardService.refundCard(recordId)
|
Long memberId = Long.valueOf(request.queryParam("memberId").orElseThrow());
|
||||||
|
return memberCardService.refundCard(recordId, memberId)
|
||||||
.then(ServerResponse.noContent().build());
|
.then(ServerResponse.noContent().build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+36
-5
@@ -3,6 +3,7 @@ package cn.novalon.gym.manage.member.handler;
|
|||||||
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
|
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
|
||||||
import cn.novalon.gym.manage.member.service.IMemberCardRecordService;
|
import cn.novalon.gym.manage.member.service.IMemberCardRecordService;
|
||||||
import cn.novalon.gym.manage.member.service.IMemberCardService;
|
import cn.novalon.gym.manage.member.service.IMemberCardService;
|
||||||
|
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.Data;
|
import lombok.Data;
|
||||||
@@ -23,18 +24,22 @@ public class MemberCardRecordHandler {
|
|||||||
|
|
||||||
private final IMemberCardService memberCardService;
|
private final IMemberCardService memberCardService;
|
||||||
private final IMemberCardRecordService memberCardRecordService;
|
private final IMemberCardRecordService memberCardRecordService;
|
||||||
|
private final AuthUtil authUtil;
|
||||||
|
|
||||||
public MemberCardRecordHandler(IMemberCardService memberCardService,
|
public MemberCardRecordHandler(IMemberCardService memberCardService,
|
||||||
IMemberCardRecordService memberCardRecordService) {
|
IMemberCardRecordService memberCardRecordService,
|
||||||
|
AuthUtil authUtil) {
|
||||||
this.memberCardService = memberCardService;
|
this.memberCardService = memberCardService;
|
||||||
this.memberCardRecordService = memberCardRecordService;
|
this.memberCardRecordService = memberCardRecordService;
|
||||||
|
this.authUtil = authUtil;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "购买会员卡", description = "支持时长卡、次卡、储值卡,自动设置到期提醒")
|
@Operation(summary = "购买会员卡", description = "支持时长卡、次卡、储值卡,自动设置到期提醒")
|
||||||
public Mono<ServerResponse> purchaseCard(ServerRequest request) {
|
public Mono<ServerResponse> purchaseCard(ServerRequest request) {
|
||||||
|
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||||
return request.bodyToMono(PurchaseRequest.class)
|
return request.bodyToMono(PurchaseRequest.class)
|
||||||
.flatMap(body -> memberCardService.purchaseCard(
|
.flatMap(body -> memberCardService.purchaseCard(
|
||||||
body.getMemberId(),
|
memberId,
|
||||||
body.getMemberCardId(),
|
body.getMemberCardId(),
|
||||||
body.getSourceOrderId()))
|
body.getSourceOrderId()))
|
||||||
.flatMap(record -> ServerResponse.ok().bodyValue(record))
|
.flatMap(record -> ServerResponse.ok().bodyValue(record))
|
||||||
@@ -44,8 +49,10 @@ public class MemberCardRecordHandler {
|
|||||||
@Operation(summary = "续费会员卡", description = "累加剩余次数/余额,顺延到期日期,权益立即生效")
|
@Operation(summary = "续费会员卡", description = "累加剩余次数/余额,顺延到期日期,权益立即生效")
|
||||||
public Mono<ServerResponse> renewCard(ServerRequest request) {
|
public Mono<ServerResponse> renewCard(ServerRequest request) {
|
||||||
Long recordId = Long.parseLong(request.pathVariable("recordId"));
|
Long recordId = Long.parseLong(request.pathVariable("recordId"));
|
||||||
|
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||||
return request.bodyToMono(RenewRequest.class)
|
return request.bodyToMono(RenewRequest.class)
|
||||||
.flatMap(body -> memberCardService.renewCard(recordId,
|
.flatMap(body -> memberCardService.renewCard(recordId,
|
||||||
|
memberId,
|
||||||
body.getAddTimes(),
|
body.getAddTimes(),
|
||||||
body.getAddAmount(),
|
body.getAddAmount(),
|
||||||
body.getAddDays(),
|
body.getAddDays(),
|
||||||
@@ -57,8 +64,10 @@ public class MemberCardRecordHandler {
|
|||||||
@Operation(summary = "使用会员卡", description = "预约团课或私教成功后扣减次数或余额")
|
@Operation(summary = "使用会员卡", description = "预约团课或私教成功后扣减次数或余额")
|
||||||
public Mono<ServerResponse> useCard(ServerRequest request) {
|
public Mono<ServerResponse> useCard(ServerRequest request) {
|
||||||
Long recordId = Long.parseLong(request.pathVariable("recordId"));
|
Long recordId = Long.parseLong(request.pathVariable("recordId"));
|
||||||
|
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||||
return request.bodyToMono(UseCardRequest.class)
|
return request.bodyToMono(UseCardRequest.class)
|
||||||
.flatMap(body -> memberCardService.useCard(recordId,
|
.flatMap(body -> memberCardService.useCard(recordId,
|
||||||
|
memberId,
|
||||||
body.getDeductTimes(),
|
body.getDeductTimes(),
|
||||||
body.getDeductAmount()))
|
body.getDeductAmount()))
|
||||||
.flatMap(record -> ServerResponse.ok().bodyValue(record))
|
.flatMap(record -> ServerResponse.ok().bodyValue(record))
|
||||||
@@ -68,7 +77,8 @@ public class MemberCardRecordHandler {
|
|||||||
@Operation(summary = "退款会员卡", description = "使用Saga模式执行退款流程,保证事务一致性")
|
@Operation(summary = "退款会员卡", description = "使用Saga模式执行退款流程,保证事务一致性")
|
||||||
public Mono<ServerResponse> refundCard(ServerRequest request) {
|
public Mono<ServerResponse> refundCard(ServerRequest request) {
|
||||||
Long recordId = Long.parseLong(request.pathVariable("recordId"));
|
Long recordId = Long.parseLong(request.pathVariable("recordId"));
|
||||||
return memberCardService.refundCard(recordId)
|
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||||
|
return memberCardService.refundCard(recordId, memberId)
|
||||||
.then(ServerResponse.ok().bodyValue("退款成功"))
|
.then(ServerResponse.ok().bodyValue("退款成功"))
|
||||||
.onErrorResume(e -> ServerResponse.badRequest().bodyValue("退款失败: " + e.getMessage()));
|
.onErrorResume(e -> ServerResponse.badRequest().bodyValue("退款失败: " + e.getMessage()));
|
||||||
}
|
}
|
||||||
@@ -83,12 +93,34 @@ public class MemberCardRecordHandler {
|
|||||||
|
|
||||||
@Operation(summary = "会员我的卡包", description = "查询当前会员的所有有效卡")
|
@Operation(summary = "会员我的卡包", description = "查询当前会员的所有有效卡")
|
||||||
public Mono<ServerResponse> getMyCards(ServerRequest request) {
|
public Mono<ServerResponse> getMyCards(ServerRequest request) {
|
||||||
Long memberId = Long.parseLong(request.pathVariable("memberId"));
|
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||||
return ServerResponse.ok().body(
|
return ServerResponse.ok().body(
|
||||||
memberCardRecordService.findActiveCardsByMemberId(memberId),
|
memberCardRecordService.findActiveCardsByMemberId(memberId),
|
||||||
MemberCardRecord.class);
|
MemberCardRecord.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "会员卡包(带筛选)", description = "查询会员的所有会员卡,支持状态筛选:all-全部, active-有效, expired-已失效")
|
||||||
|
public Mono<ServerResponse> getMyCardsWithStatus(ServerRequest request) {
|
||||||
|
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||||
|
String status = request.queryParam("status").orElse("all");
|
||||||
|
|
||||||
|
if (!status.equals("all") && !status.equals("active") && !status.equals("expired")) {
|
||||||
|
return ServerResponse.badRequest().bodyValue("无效的status参数,允许值:all, active, expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
return memberCardRecordService.findCardsByMemberIdWithStatus(memberId, status)
|
||||||
|
.collectList()
|
||||||
|
.flatMap(cards -> ServerResponse.ok().bodyValue(cards));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "获取会员主要有效卡", description = "查询当前会员的主要有效会员卡(只返回一条),优先临期卡,其次有效卡")
|
||||||
|
public Mono<ServerResponse> getPrimaryCard(ServerRequest request) {
|
||||||
|
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||||
|
return memberCardRecordService.findPrimaryActiveCardByMemberId(memberId)
|
||||||
|
.flatMap(card -> ServerResponse.ok().bodyValue(card))
|
||||||
|
.switchIfEmpty(ServerResponse.noContent().build());
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "处理过期会员卡", description = "定时任务调用,扫描并更新过期卡状态")
|
@Operation(summary = "处理过期会员卡", description = "定时任务调用,扫描并更新过期卡状态")
|
||||||
public Mono<ServerResponse> processExpiredCards(ServerRequest request) {
|
public Mono<ServerResponse> processExpiredCards(ServerRequest request) {
|
||||||
return memberCardService.processExpiredCards()
|
return memberCardService.processExpiredCards()
|
||||||
@@ -97,7 +129,6 @@ public class MemberCardRecordHandler {
|
|||||||
|
|
||||||
@Data
|
@Data
|
||||||
public static class PurchaseRequest {
|
public static class PurchaseRequest {
|
||||||
private Long memberId;
|
|
||||||
private Long memberCardId;
|
private Long memberCardId;
|
||||||
private Long sourceOrderId;
|
private Long sourceOrderId;
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -109,6 +109,17 @@ public class MemberCardTransactionHandler {
|
|||||||
MemberCardTransaction.class);
|
MemberCardTransaction.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按会员卡记录ID查询流水
|
||||||
|
*/
|
||||||
|
@Operation(summary = "按会员卡记录ID查询流水", description = "查看某张会员持卡记录的所有流水")
|
||||||
|
public Mono<ServerResponse> getTransactionsByRecordId(ServerRequest request) {
|
||||||
|
Long recordId = Long.parseLong(request.pathVariable("recordId"));
|
||||||
|
return ServerResponse.ok()
|
||||||
|
.body(memberCardTransactionService.findByRecordId(recordId),
|
||||||
|
MemberCardTransaction.class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统计某卡种的总扣次数
|
* 统计某卡种的总扣次数
|
||||||
*/
|
*/
|
||||||
|
|||||||
+35
-10
@@ -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 = "后台管理员按关键词搜索会员,支持性别筛选和分页")
|
||||||
|
|||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
package cn.novalon.gym.manage.member.handler;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.member.service.IMemberStoredCardService;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 储值卡 Handler
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class MemberStoredCardHandler {
|
||||||
|
|
||||||
|
private final IMemberStoredCardService memberStoredCardService;
|
||||||
|
|
||||||
|
public MemberStoredCardHandler(IMemberStoredCardService memberStoredCardService) {
|
||||||
|
this.memberStoredCardService = memberStoredCardService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会员储值卡信息
|
||||||
|
*/
|
||||||
|
public Mono<ServerResponse> getStoredCardInfo(ServerRequest request) {
|
||||||
|
Long memberId = Long.valueOf(request.pathVariable("memberId"));
|
||||||
|
return memberStoredCardService.getOrCreateStoredCard(memberId)
|
||||||
|
.flatMap(card -> {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("memberId", card.getMemberId());
|
||||||
|
result.put("balance", card.getBalance() != null ? card.getBalance() : BigDecimal.ZERO);
|
||||||
|
result.put("totalRecharge", card.getTotalRecharge() != null ? card.getTotalRecharge() : BigDecimal.ZERO);
|
||||||
|
result.put("totalConsume", card.getTotalConsume() != null ? card.getTotalConsume() : BigDecimal.ZERO);
|
||||||
|
result.put("isPayPasswordSet", card.getIsPayPasswordSet() != null && card.getIsPayPasswordSet());
|
||||||
|
return ServerResponse.ok().bodyValue(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取储值卡余额
|
||||||
|
*/
|
||||||
|
public Mono<ServerResponse> getBalance(ServerRequest request) {
|
||||||
|
Long memberId = Long.valueOf(request.pathVariable("memberId"));
|
||||||
|
return memberStoredCardService.getStoredCard(memberId)
|
||||||
|
.flatMap(card -> {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("balance", card.getBalance() != null ? card.getBalance() : BigDecimal.ZERO);
|
||||||
|
result.put("hasCard", true);
|
||||||
|
return ServerResponse.ok().bodyValue(result);
|
||||||
|
})
|
||||||
|
.switchIfEmpty(Mono.defer(() -> {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("balance", BigDecimal.ZERO);
|
||||||
|
result.put("hasCard", false);
|
||||||
|
return ServerResponse.ok().bodyValue(result);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查支付密码是否已设置
|
||||||
|
*/
|
||||||
|
public Mono<ServerResponse> checkPasswordSet(ServerRequest request) {
|
||||||
|
Long memberId = Long.valueOf(request.pathVariable("memberId"));
|
||||||
|
return memberStoredCardService.isPayPasswordSet(memberId)
|
||||||
|
.flatMap(isSet -> {
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("isSet", isSet);
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("code", 200);
|
||||||
|
result.put("message", "success");
|
||||||
|
result.put("data", data);
|
||||||
|
return ServerResponse.ok().bodyValue(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置支付密码
|
||||||
|
*/
|
||||||
|
public Mono<ServerResponse> setPassword(ServerRequest request) {
|
||||||
|
Long memberId = Long.valueOf(request.pathVariable("memberId"));
|
||||||
|
return request.bodyToMono(Map.class)
|
||||||
|
.flatMap(body -> {
|
||||||
|
String password = (String) body.get("password");
|
||||||
|
if (password == null || password.isEmpty()) {
|
||||||
|
return ServerResponse.badRequest().bodyValue(Map.of("code", 400, "message", "密码不能为空"));
|
||||||
|
}
|
||||||
|
return memberStoredCardService.setPayPassword(memberId, password)
|
||||||
|
.flatMap(rows -> {
|
||||||
|
if (rows > 0) {
|
||||||
|
return ServerResponse.ok().bodyValue(Map.of("code", 200, "message", "设置成功"));
|
||||||
|
}
|
||||||
|
return ServerResponse.ok().bodyValue(Map.of("code", 400, "message", "设置失败"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证支付密码
|
||||||
|
*/
|
||||||
|
public Mono<ServerResponse> verifyPassword(ServerRequest request) {
|
||||||
|
Long memberId = Long.valueOf(request.pathVariable("memberId"));
|
||||||
|
return request.bodyToMono(Map.class)
|
||||||
|
.flatMap(body -> {
|
||||||
|
String password = (String) body.get("password");
|
||||||
|
if (password == null || password.isEmpty()) {
|
||||||
|
return ServerResponse.badRequest().bodyValue(Map.of("code", 400, "message", "密码不能为空"));
|
||||||
|
}
|
||||||
|
return memberStoredCardService.verifyPayPassword(memberId, password)
|
||||||
|
.flatMap(isValid -> {
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("valid", isValid);
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("code", 200);
|
||||||
|
result.put("message", isValid ? "验证成功" : "密码错误");
|
||||||
|
result.put("data", data);
|
||||||
|
return ServerResponse.ok().bodyValue(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 储值卡支付(验证密码并扣减余额)
|
||||||
|
*/
|
||||||
|
public Mono<ServerResponse> payWithStoredCard(ServerRequest request) {
|
||||||
|
Long memberId = Long.valueOf(request.pathVariable("memberId"));
|
||||||
|
return request.bodyToMono(Map.class)
|
||||||
|
.flatMap(body -> {
|
||||||
|
String password = (String) body.get("password");
|
||||||
|
BigDecimal amount = body.get("amount") != null
|
||||||
|
? new BigDecimal(body.get("amount").toString())
|
||||||
|
: null;
|
||||||
|
String orderType = (String) body.get("orderType");
|
||||||
|
String goodsDesc = (String) body.get("goodsDesc");
|
||||||
|
|
||||||
|
if (password == null || password.isEmpty()) {
|
||||||
|
return ServerResponse.badRequest().bodyValue(Map.of("code", 400, "message", "支付密码不能为空"));
|
||||||
|
}
|
||||||
|
if (amount == null) {
|
||||||
|
return ServerResponse.badRequest().bodyValue(Map.of("code", 400, "message", "支付金额不能为空"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return memberStoredCardService.payWithStoredCard(memberId, password, amount)
|
||||||
|
.flatMap(result -> {
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
response.put("code", 200);
|
||||||
|
response.put("message", "支付成功");
|
||||||
|
response.put("balance", result.getBalance());
|
||||||
|
log.info("[储值卡支付] 支付成功: memberId={}, amount={}, remainingBalance={}",
|
||||||
|
memberId, amount, result.getBalance());
|
||||||
|
} else {
|
||||||
|
response.put("code", 400);
|
||||||
|
response.put("message", result.getMessage());
|
||||||
|
log.warn("[储值卡支付] 支付失败: memberId={}, amount={}, reason={}",
|
||||||
|
memberId, amount, result.getMessage());
|
||||||
|
}
|
||||||
|
return ServerResponse.ok().bodyValue(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询会员的充值记录列表
|
||||||
|
*/
|
||||||
|
public Mono<ServerResponse> getRechargeRecords(ServerRequest request) {
|
||||||
|
Long memberId = Long.valueOf(request.pathVariable("memberId"));
|
||||||
|
int page = Integer.parseInt(request.queryParam("page").orElse("1"));
|
||||||
|
int size = Integer.parseInt(request.queryParam("size").orElse("20"));
|
||||||
|
|
||||||
|
log.info("[储值卡] 查询充值记录: memberId={}, page={}, size={}", memberId, page, size);
|
||||||
|
|
||||||
|
return memberStoredCardService.getRechargeRecords(memberId, page, size)
|
||||||
|
.collectList()
|
||||||
|
.zipWith(memberStoredCardService.countRechargeRecords(memberId))
|
||||||
|
.flatMap(tuple -> {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("list", tuple.getT1());
|
||||||
|
result.put("total", tuple.getT2());
|
||||||
|
result.put("page", page);
|
||||||
|
result.put("size", size);
|
||||||
|
return ServerResponse.ok().bodyValue(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
-3
@@ -23,7 +23,6 @@ public interface MemberCardRecordRepository extends R2dbcRepository<MemberCardRe
|
|||||||
/**
|
/**
|
||||||
* 插入新的激活卡记录
|
* 插入新的激活卡记录
|
||||||
*/
|
*/
|
||||||
@Modifying
|
|
||||||
@Query("INSERT INTO member_card_record (member_id, member_card_id, status, expire_time, remaining_times, remaining_amount, source_order_id, purchase_time, created_at, updated_at) " +
|
@Query("INSERT INTO member_card_record (member_id, member_card_id, status, expire_time, remaining_times, remaining_amount, source_order_id, purchase_time, created_at, updated_at) " +
|
||||||
"VALUES (:memberId, :memberCardId, 'ACTIVE', :expireTime, :remainingTimes, :remainingAmount, :sourceOrderId, NOW(), NOW(), NOW()) " +
|
"VALUES (:memberId, :memberCardId, 'ACTIVE', :expireTime, :remainingTimes, :remainingAmount, :sourceOrderId, NOW(), NOW(), NOW()) " +
|
||||||
"RETURNING *")
|
"RETURNING *")
|
||||||
@@ -63,9 +62,19 @@ public interface MemberCardRecordRepository extends R2dbcRepository<MemberCardRe
|
|||||||
Mono<Integer> updateStatus(Long recordId, String status);
|
Mono<Integer> updateStatus(Long recordId, String status);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询会员的有效卡片
|
* 查询会员的有效卡片(包含会员卡类型信息)
|
||||||
*/
|
*/
|
||||||
@Query("SELECT * FROM member_card_record WHERE member_id = :memberId AND status = 'ACTIVE' AND deleted_at IS NULL ORDER BY expire_time ASC")
|
@Query("SELECT r.member_card_record_id as id, r.created_at, r.updated_at, r.deleted_at, " +
|
||||||
|
"r.member_card_record_id, r.member_id, r.member_card_id, r.status, " +
|
||||||
|
"r.remaining_times, r.remaining_amount, r.expire_time, r.source_order_id, " +
|
||||||
|
"r.purchase_time, r.version, r.card_composition, " +
|
||||||
|
"mc.member_card_name as memberCardName, mc.member_card_type as memberCardType, " +
|
||||||
|
"mc.member_card_price as memberCardPrice, mc.member_card_validity_days as memberCardValidityDays, " +
|
||||||
|
"mc.member_card_total_times as memberCardTotalTimes " +
|
||||||
|
"FROM member_card_record r " +
|
||||||
|
"LEFT JOIN member_card mc ON r.member_card_id = mc.id AND mc.deleted_at IS NULL " +
|
||||||
|
"WHERE r.member_id = :memberId AND r.status = 'ACTIVE' AND r.deleted_at IS NULL " +
|
||||||
|
"ORDER BY r.expire_time ASC")
|
||||||
Flux<MemberCardRecord> findActiveCardsByMemberId(Long memberId);
|
Flux<MemberCardRecord> findActiveCardsByMemberId(Long memberId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -101,9 +110,56 @@ public interface MemberCardRecordRepository extends R2dbcRepository<MemberCardRe
|
|||||||
@Query("SELECT * FROM member_card_record WHERE status = 'ACTIVE' AND expire_time < NOW() AND deleted_at IS NULL LIMIT 500")
|
@Query("SELECT * FROM member_card_record WHERE status = 'ACTIVE' AND expire_time < NOW() AND deleted_at IS NULL LIMIT 500")
|
||||||
Flux<MemberCardRecord> findExpiredCards();
|
Flux<MemberCardRecord> findExpiredCards();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据支付订单ID查询会员卡记录(用于幂等性检查)
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM member_card_record WHERE source_order_id = :sourceOrderId AND deleted_at IS NULL")
|
||||||
|
Mono<MemberCardRecord> findBySourceOrderId(Long sourceOrderId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询会员在指定时间后购买的同类活跃卡(防前端重复调用)
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM member_card_record WHERE member_id = :memberId AND member_card_id = :memberCardId AND status = 'ACTIVE' AND deleted_at IS NULL AND purchase_time > :since ORDER BY purchase_time DESC LIMIT 1")
|
||||||
|
Mono<MemberCardRecord> findRecentActivePurchase(Long memberId, Long memberCardId, LocalDateTime since);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询所有有效记录
|
* 查询所有有效记录
|
||||||
*/
|
*/
|
||||||
@Query("SELECT * FROM member_card_record WHERE status = 'ACTIVE' AND deleted_at IS NULL")
|
@Query("SELECT * FROM member_card_record WHERE status = 'ACTIVE' AND deleted_at IS NULL")
|
||||||
Flux<MemberCardRecord> findActiveRecords();
|
Flux<MemberCardRecord> findActiveRecords();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 储值卡充值 - 增加余额
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE member_card_record SET remaining_amount = remaining_amount + :addAmount, updated_at = NOW() " +
|
||||||
|
"WHERE member_card_record_id = :recordId AND status = 'ACTIVE' AND deleted_at IS NULL")
|
||||||
|
Mono<Integer> rechargeStoredCard(Long recordId, Double addAmount);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询会员的所有卡片(按状态筛选)- 包含会员卡类型信息
|
||||||
|
* status: all - 所有卡, active - 有效卡, expired - 已过期/失效卡
|
||||||
|
* 排序规则:临期卡(30天内到期)排在最上面,然后是其他有效卡,最后是失效卡
|
||||||
|
*/
|
||||||
|
@Query("SELECT r.member_card_record_id as id, r.created_at, r.updated_at, r.deleted_at, " +
|
||||||
|
"r.member_card_record_id, r.member_id, r.member_card_id, r.status, " +
|
||||||
|
"r.remaining_times, r.remaining_amount, r.expire_time, r.source_order_id, " +
|
||||||
|
"r.purchase_time, r.version, r.card_composition, " +
|
||||||
|
"mc.member_card_name as memberCardName, mc.member_card_type as memberCardType, " +
|
||||||
|
"mc.member_card_price as memberCardPrice, mc.member_card_validity_days as memberCardValidityDays, " +
|
||||||
|
"mc.member_card_total_times as memberCardTotalTimes " +
|
||||||
|
"FROM member_card_record r " +
|
||||||
|
"LEFT JOIN member_card mc ON r.member_card_id = mc.id AND mc.deleted_at IS NULL " +
|
||||||
|
"WHERE r.member_id = :memberId AND r.deleted_at IS NULL " +
|
||||||
|
"AND (" +
|
||||||
|
" :status = 'all' " +
|
||||||
|
" OR (:status = 'active' AND r.status = 'ACTIVE' AND (r.expire_time IS NULL OR r.expire_time > NOW())) " +
|
||||||
|
" OR (:status = 'expired' AND (r.status != 'ACTIVE' OR (r.expire_time IS NOT NULL AND r.expire_time <= NOW()))) " +
|
||||||
|
") " +
|
||||||
|
"ORDER BY " +
|
||||||
|
"CASE WHEN r.status = 'ACTIVE' AND r.expire_time IS NOT NULL AND r.expire_time < NOW() + INTERVAL '30 days' AND r.expire_time > NOW() THEN 0 " +
|
||||||
|
" WHEN r.status = 'ACTIVE' AND (r.expire_time IS NULL OR r.expire_time > NOW()) THEN 1 " +
|
||||||
|
" ELSE 2 END, " +
|
||||||
|
"r.expire_time ASC")
|
||||||
|
Flux<MemberCardRecord> findCardsByMemberIdWithStatus(Long memberId, String status);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
@@ -9,6 +9,8 @@ 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.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 会员卡类型 Repository
|
* 会员卡类型 Repository
|
||||||
*
|
*
|
||||||
@@ -18,11 +20,23 @@ import reactor.core.publisher.Mono;
|
|||||||
@Repository
|
@Repository
|
||||||
public interface MemberCardRepository extends R2dbcRepository<MemberCard, Long> {
|
public interface MemberCardRepository extends R2dbcRepository<MemberCard, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据主键ID查询(未删除的)- 用于前端传递的id
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM member_card WHERE id = :id AND deleted_at IS NULL")
|
||||||
|
Mono<MemberCard> findByIdAndDeletedAtIsNull(Long id);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据会员卡ID查询(未删除的)
|
* 根据会员卡ID查询(未删除的)
|
||||||
*/
|
*/
|
||||||
Mono<MemberCard> findByMemberCardIdAndDeletedAtIsNull(Long memberCardId);
|
Mono<MemberCard> findByMemberCardIdAndDeletedAtIsNull(Long memberCardId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量查询会员卡(根据 member_card_id 字段)
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM member_card WHERE deleted_at IS NULL AND member_card_id IN (:memberCardIds)")
|
||||||
|
Flux<MemberCard> findByMemberCardIdIn(List<Long> memberCardIds);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 条件查询会员卡列表
|
* 条件查询会员卡列表
|
||||||
*/
|
*/
|
||||||
|
|||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
package cn.novalon.gym.manage.member.repository;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.member.entity.MemberStoredCardRecharge;
|
||||||
|
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.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 储值卡充值记录 Repository
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface MemberStoredCardRechargeRepository extends R2dbcRepository<MemberStoredCardRecharge, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据订单号查询充值记录
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM member_stored_card_recharge WHERE order_no = :orderNo AND deleted_at IS NULL")
|
||||||
|
Mono<MemberStoredCardRecharge> findByOrderNo(String orderNo);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询会员的充值记录列表
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM member_stored_card_recharge WHERE member_id = :memberId AND deleted_at IS NULL ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
|
||||||
|
Flux<MemberStoredCardRecharge> findByMemberId(Long memberId, int limit, int offset);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建充值记录
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("INSERT INTO member_stored_card_recharge (member_id, order_no, recharge_amount, bonus_amount, total_amount, pay_amount, status, created_at, updated_at) " +
|
||||||
|
"VALUES (:memberId, :orderNo, :rechargeAmount, :bonusAmount, :totalAmount, :payAmount, 'PENDING', NOW(), NOW())")
|
||||||
|
Mono<Integer> createRechargeRecord(Long memberId, String orderNo, BigDecimal rechargeAmount, BigDecimal bonusAmount, BigDecimal totalAmount, BigDecimal payAmount);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新充值记录为成功
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE member_stored_card_recharge SET status = 'SUCCESS', pay_time = :payTime, updated_at = NOW() " +
|
||||||
|
"WHERE order_no = :orderNo AND deleted_at IS NULL")
|
||||||
|
Mono<Integer> markSuccess(String orderNo, LocalDateTime payTime);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新充值记录为失败
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE member_stored_card_recharge SET status = 'FAILED', updated_at = NOW() " +
|
||||||
|
"WHERE order_no = :orderNo AND deleted_at IS NULL")
|
||||||
|
Mono<Integer> markFailed(String orderNo);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询会员充值记录总数
|
||||||
|
*/
|
||||||
|
@Query("SELECT COUNT(*) FROM member_stored_card_recharge WHERE member_id = :memberId AND deleted_at IS NULL")
|
||||||
|
Mono<Long> countByMemberIdAndDeletedAtIsNull(Long memberId);
|
||||||
|
}
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
package cn.novalon.gym.manage.member.repository;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.member.entity.MemberStoredCard;
|
||||||
|
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.Mono;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 储值卡 Repository
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface MemberStoredCardRepository extends R2dbcRepository<MemberStoredCard, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据会员ID查询储值卡
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM member_stored_card WHERE member_id = :memberId AND deleted_at IS NULL")
|
||||||
|
Mono<MemberStoredCard> findByMemberId(Long memberId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据会员ID查询储值卡(可删除标记)
|
||||||
|
*/
|
||||||
|
Mono<MemberStoredCard> findByMemberIdAndDeletedAtIsNull(Long memberId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 储值卡充值 - 增加余额
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE member_stored_card SET balance = balance + :addAmount, total_recharge = total_recharge + :addAmount, updated_at = NOW() " +
|
||||||
|
"WHERE member_id = :memberId AND deleted_at IS NULL")
|
||||||
|
Mono<Integer> rechargeBalance(Long memberId, BigDecimal addAmount);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 储值卡返还 - 增加余额(用于取消预约退款)
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE member_stored_card SET balance = balance + :addAmount, updated_at = NOW() " +
|
||||||
|
"WHERE member_id = :memberId AND deleted_at IS NULL")
|
||||||
|
Mono<Integer> refundBalance(Long memberId, BigDecimal addAmount);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 储值卡消费 - 扣减余额
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE member_stored_card SET balance = balance - :deductAmount, total_consume = total_consume + :deductAmount, updated_at = NOW() " +
|
||||||
|
"WHERE member_id = :memberId AND deleted_at IS NULL AND balance >= :deductAmount")
|
||||||
|
Mono<Integer> deductBalance(Long memberId, BigDecimal deductAmount);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建储值卡
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("INSERT INTO member_stored_card (member_id, balance, total_recharge, total_consume, is_pay_password_set, created_at, updated_at) " +
|
||||||
|
"VALUES (:memberId, 0, 0, 0, FALSE, NOW(), NOW())")
|
||||||
|
Mono<Integer> createStoredCard(Long memberId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置支付密码
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE member_stored_card SET pay_password = :password, is_pay_password_set = TRUE, updated_at = NOW() " +
|
||||||
|
"WHERE member_id = :memberId AND deleted_at IS NULL")
|
||||||
|
Mono<Integer> setPayPassword(Long memberId, String password);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查支付密码是否已设置
|
||||||
|
*/
|
||||||
|
@Query("SELECT is_pay_password_set FROM member_stored_card WHERE member_id = :memberId AND deleted_at IS NULL")
|
||||||
|
Mono<Boolean> isPayPasswordSet(Long memberId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取支付密码
|
||||||
|
*/
|
||||||
|
@Query("SELECT pay_password FROM member_stored_card WHERE member_id = :memberId AND deleted_at IS NULL")
|
||||||
|
Mono<String> getPayPassword(Long memberId);
|
||||||
|
}
|
||||||
+16
@@ -32,4 +32,20 @@ public interface IMemberCardRecordService {
|
|||||||
Mono<MemberCardRecord> validateStoredCard(Long recordId, Double requiredAmount);
|
Mono<MemberCardRecord> validateStoredCard(Long recordId, Double requiredAmount);
|
||||||
|
|
||||||
Flux<MemberCardRecord> findExpiredCards();
|
Flux<MemberCardRecord> findExpiredCards();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询会员的所有卡片(按状态筛选)
|
||||||
|
* @param memberId 会员ID
|
||||||
|
* @param status 状态筛选:all - 所有, active - 有效, expired - 已失效
|
||||||
|
* @return 会员卡记录列表
|
||||||
|
*/
|
||||||
|
Flux<MemberCardRecord> findCardsByMemberIdWithStatus(Long memberId, String status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询会员的主要有效会员卡(只返回一条)
|
||||||
|
* 优先返回临期卡(剩余1-3天),其次返回普通有效卡
|
||||||
|
* @param memberId 会员ID
|
||||||
|
* @return 会员卡记录,如果没有则返回空
|
||||||
|
*/
|
||||||
|
Mono<MemberCardRecord> findPrimaryActiveCardByMemberId(Long memberId);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -34,11 +34,11 @@ public interface IMemberCardService {
|
|||||||
|
|
||||||
Mono<MemberCardRecord> purchaseCard(Long memberId, Long memberCardId, Long sourceOrderId);
|
Mono<MemberCardRecord> purchaseCard(Long memberId, Long memberCardId, Long sourceOrderId);
|
||||||
|
|
||||||
Mono<MemberCardRecord> renewCard(Long recordId, Integer addTimes, Double addAmount, Integer addDays, Long sourceOrderId);
|
Mono<MemberCardRecord> renewCard(Long recordId, Long memberId, Integer addTimes, Double addAmount, Integer addDays, Long sourceOrderId);
|
||||||
|
|
||||||
Mono<MemberCardRecord> useCard(Long recordId, Integer deductTimes, Double deductAmount);
|
Mono<MemberCardRecord> useCard(Long recordId, Long memberId, Integer deductTimes, Double deductAmount);
|
||||||
|
|
||||||
Mono<Void> refundCard(Long recordId);
|
Mono<Void> refundCard(Long recordId, Long memberId);
|
||||||
|
|
||||||
Mono<Integer> processExpiredCards();
|
Mono<Integer> processExpiredCards();
|
||||||
}
|
}
|
||||||
|
|||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
package cn.novalon.gym.manage.member.service;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.member.entity.MemberStoredCard;
|
||||||
|
import cn.novalon.gym.manage.member.entity.MemberStoredCardRecharge;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 储值卡 Service接口
|
||||||
|
*/
|
||||||
|
public interface IMemberStoredCardService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会员的储值卡,如果没有则自动创建
|
||||||
|
*/
|
||||||
|
Mono<MemberStoredCard> getOrCreateStoredCard(Long memberId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会员的储值卡
|
||||||
|
*/
|
||||||
|
Mono<MemberStoredCard> getStoredCard(Long memberId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 储值卡充值
|
||||||
|
*/
|
||||||
|
Mono<Integer> recharge(Long memberId, BigDecimal amount);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 储值卡消费
|
||||||
|
*/
|
||||||
|
Mono<Integer> consume(Long memberId, BigDecimal amount);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查支付密码是否已设置
|
||||||
|
*/
|
||||||
|
Mono<Boolean> isPayPasswordSet(Long memberId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置支付密码
|
||||||
|
*/
|
||||||
|
Mono<Integer> setPayPassword(Long memberId, String password);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证支付密码
|
||||||
|
*/
|
||||||
|
Mono<Boolean> verifyPayPassword(Long memberId, String password);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 储值卡支付(验证密码并扣减余额)
|
||||||
|
*/
|
||||||
|
Mono<PayResult> payWithStoredCard(Long memberId, String password, BigDecimal amount);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 储值卡返还(取消预约退款,仅增加余额不修改充值总额)
|
||||||
|
*/
|
||||||
|
Mono<PayResult> refundToStoredCard(Long memberId, String password, BigDecimal amount, long cancelCount);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 储值卡返还(内部调用,无需密码,含手续费逻辑)
|
||||||
|
* @return 实际返还金额
|
||||||
|
*/
|
||||||
|
Mono<BigDecimal> refundBalanceWithFee(Long memberId, BigDecimal amount, long cancelCount);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询会员的充值记录列表
|
||||||
|
*/
|
||||||
|
Flux<MemberStoredCardRecharge> getRechargeRecords(Long memberId, int page, int size);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询会员充值记录总数
|
||||||
|
*/
|
||||||
|
Mono<Long> countRechargeRecords(Long memberId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 储值卡支付结果
|
||||||
|
*/
|
||||||
|
class PayResult {
|
||||||
|
private boolean success;
|
||||||
|
private String message;
|
||||||
|
private BigDecimal balance;
|
||||||
|
|
||||||
|
public static PayResult success(BigDecimal balance) {
|
||||||
|
PayResult result = new PayResult();
|
||||||
|
result.success = true;
|
||||||
|
result.message = "支付成功";
|
||||||
|
result.balance = balance;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PayResult fail(String message) {
|
||||||
|
PayResult result = new PayResult();
|
||||||
|
result.success = false;
|
||||||
|
result.message = message;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSuccess() { return success; }
|
||||||
|
public void setSuccess(boolean success) { this.success = success; }
|
||||||
|
public String getMessage() { return message; }
|
||||||
|
public void setMessage(String message) { this.message = message; }
|
||||||
|
public BigDecimal getBalance() { return balance; }
|
||||||
|
public void setBalance(BigDecimal balance) { this.balance = balance; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+139
-2
@@ -1,7 +1,9 @@
|
|||||||
package cn.novalon.gym.manage.member.service.impl;
|
package cn.novalon.gym.manage.member.service.impl;
|
||||||
|
|
||||||
|
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.repository.MemberCardRecordRepository;
|
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
|
||||||
|
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
|
||||||
import cn.novalon.gym.manage.member.service.IMemberCardRecordService;
|
import cn.novalon.gym.manage.member.service.IMemberCardRecordService;
|
||||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -11,6 +13,9 @@ 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.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 会员卡记录服务实现
|
* 会员卡记录服务实现
|
||||||
@@ -22,13 +27,17 @@ import java.time.LocalDateTime;
|
|||||||
@Service
|
@Service
|
||||||
public class MemberCardRecordServiceImpl implements IMemberCardRecordService {
|
public class MemberCardRecordServiceImpl implements IMemberCardRecordService {
|
||||||
private final MemberCardRecordRepository memberCardRecordRepository;
|
private final MemberCardRecordRepository memberCardRecordRepository;
|
||||||
|
private final MemberCardRepository memberCardRepository;
|
||||||
private final RedisUtil redisUtil;
|
private final RedisUtil redisUtil;
|
||||||
|
|
||||||
private static final String MEMBER_CARD_RECORD_CACHE_PREFIX = "member:card:record:";
|
private static final String MEMBER_CARD_RECORD_CACHE_PREFIX = "member:card:record:";
|
||||||
private static final long CACHE_EXPIRE_SECONDS = 300;
|
private static final long CACHE_EXPIRE_SECONDS = 300;
|
||||||
|
|
||||||
public MemberCardRecordServiceImpl(MemberCardRecordRepository memberCardRecordRepository, RedisUtil redisUtil) {
|
public MemberCardRecordServiceImpl(MemberCardRecordRepository memberCardRecordRepository,
|
||||||
|
MemberCardRepository memberCardRepository,
|
||||||
|
RedisUtil redisUtil) {
|
||||||
this.memberCardRecordRepository = memberCardRecordRepository;
|
this.memberCardRecordRepository = memberCardRecordRepository;
|
||||||
|
this.memberCardRepository = memberCardRepository;
|
||||||
this.redisUtil = redisUtil;
|
this.redisUtil = redisUtil;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +65,44 @@ public class MemberCardRecordServiceImpl implements IMemberCardRecordService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Flux<MemberCardRecord> findActiveCardsByMemberId(Long memberId) {
|
public Flux<MemberCardRecord> findActiveCardsByMemberId(Long memberId) {
|
||||||
return memberCardRecordRepository.findActiveCardsByMemberId(memberId);
|
return memberCardRecordRepository.findActiveCardsByMemberId(memberId)
|
||||||
|
// 收集所有记录,然后批量查询会员卡信息
|
||||||
|
.collectList()
|
||||||
|
.flatMapMany(records -> {
|
||||||
|
if (records.isEmpty()) {
|
||||||
|
return Flux.empty();
|
||||||
|
}
|
||||||
|
// 提取所有 memberCardId(关联的是 member_card.id 字段,即MemberCard的主键)
|
||||||
|
List<Long> memberCardIds = records.stream()
|
||||||
|
.map(MemberCardRecord::getMemberCardId)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.distinct()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
if (memberCardIds.isEmpty()) {
|
||||||
|
return Flux.fromIterable(records);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量查询会员卡信息(使用主键id查询,确保查询未删除的记录)
|
||||||
|
return Flux.fromIterable(memberCardIds)
|
||||||
|
.flatMap(id -> memberCardRepository.findByIdAndDeletedAtIsNull(id))
|
||||||
|
.collectMap(MemberCard::getId, mc -> mc)
|
||||||
|
.map(cardMap -> {
|
||||||
|
// 填充会员卡信息到每条记录
|
||||||
|
records.forEach(record -> {
|
||||||
|
MemberCard memberCard = cardMap.get(record.getMemberCardId());
|
||||||
|
if (memberCard != null) {
|
||||||
|
record.setMemberCardName(memberCard.getMemberCardName());
|
||||||
|
record.setMemberCardType(memberCard.getMemberCardType());
|
||||||
|
record.setMemberCardPrice(memberCard.getMemberCardPrice());
|
||||||
|
record.setMemberCardValidityDays(memberCard.getMemberCardValidityDays());
|
||||||
|
record.setMemberCardTotalTimes(memberCard.getMemberCardTotalTimes());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return records;
|
||||||
|
})
|
||||||
|
.flatMapMany(Flux::fromIterable);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -116,6 +162,97 @@ public class MemberCardRecordServiceImpl implements IMemberCardRecordService {
|
|||||||
return memberCardRecordRepository.findExpiredCards();
|
return memberCardRecordRepository.findExpiredCards();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<MemberCardRecord> findCardsByMemberIdWithStatus(Long memberId, String status) {
|
||||||
|
return memberCardRecordRepository.findCardsByMemberIdWithStatus(memberId, status)
|
||||||
|
// 收集所有记录,然后批量查询会员卡信息
|
||||||
|
.collectList()
|
||||||
|
.flatMapMany(records -> {
|
||||||
|
if (records.isEmpty()) {
|
||||||
|
return Flux.empty();
|
||||||
|
}
|
||||||
|
// 提取所有 memberCardId(关联的是 member_card.id 字段,即MemberCard的主键)
|
||||||
|
List<Long> memberCardIds = records.stream()
|
||||||
|
.map(MemberCardRecord::getMemberCardId)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.distinct()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
if (memberCardIds.isEmpty()) {
|
||||||
|
return Flux.fromIterable(records);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量查询会员卡信息(使用主键id查询,确保查询未删除的记录)
|
||||||
|
return Flux.fromIterable(memberCardIds)
|
||||||
|
.flatMap(id -> memberCardRepository.findByIdAndDeletedAtIsNull(id))
|
||||||
|
.collectMap(MemberCard::getId, mc -> mc)
|
||||||
|
.map(cardMap -> {
|
||||||
|
// 填充会员卡信息到每条记录
|
||||||
|
records.forEach(record -> {
|
||||||
|
MemberCard memberCard = cardMap.get(record.getMemberCardId());
|
||||||
|
if (memberCard != null) {
|
||||||
|
record.setMemberCardName(memberCard.getMemberCardName());
|
||||||
|
record.setMemberCardType(memberCard.getMemberCardType());
|
||||||
|
record.setMemberCardPrice(memberCard.getMemberCardPrice());
|
||||||
|
record.setMemberCardValidityDays(memberCard.getMemberCardValidityDays());
|
||||||
|
record.setMemberCardTotalTimes(memberCard.getMemberCardTotalTimes());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return records;
|
||||||
|
})
|
||||||
|
.flatMapMany(Flux::fromIterable);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<MemberCardRecord> findPrimaryActiveCardByMemberId(Long memberId) {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
|
||||||
|
return findCardsByMemberIdWithStatus(memberId, "active")
|
||||||
|
// 过滤掉储值卡
|
||||||
|
.filter(record -> record.getMemberCardType() == null ||
|
||||||
|
!record.getMemberCardType().equals("STORED_VALUE_CARD"))
|
||||||
|
.collectList()
|
||||||
|
.flatMap(records -> {
|
||||||
|
if (records.isEmpty()) {
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先找临期卡(剩余1-3天)
|
||||||
|
MemberCardRecord expiringCard = records.stream()
|
||||||
|
.filter(record -> {
|
||||||
|
if (record.getExpireTime() == null) return false;
|
||||||
|
if (record.getStatus() != null && record.getStatus().equals("USED_UP")) return false;
|
||||||
|
long days = java.time.Duration.between(now, record.getExpireTime()).toDays();
|
||||||
|
return days >= 1 && days <= 3;
|
||||||
|
})
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
if (expiringCard != null) {
|
||||||
|
return Mono.just(expiringCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再找普通有效卡(剩余>3天)
|
||||||
|
MemberCardRecord activeCard = records.stream()
|
||||||
|
.filter(record -> {
|
||||||
|
if (record.getExpireTime() == null) return true;
|
||||||
|
if (record.getStatus() != null && record.getStatus().equals("USED_UP")) return false;
|
||||||
|
long days = java.time.Duration.between(now, record.getExpireTime()).toDays();
|
||||||
|
return days > 3;
|
||||||
|
})
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
if (activeCard != null) {
|
||||||
|
return Mono.just(activeCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后返回第一张卡
|
||||||
|
return Mono.just(records.get(0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void clearRecordCache(Long recordId) {
|
private void clearRecordCache(Long recordId) {
|
||||||
String cacheKey = MEMBER_CARD_RECORD_CACHE_PREFIX + recordId;
|
String cacheKey = MEMBER_CARD_RECORD_CACHE_PREFIX + recordId;
|
||||||
redisUtil.delete(cacheKey);
|
redisUtil.delete(cacheKey);
|
||||||
|
|||||||
+112
-29
@@ -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;
|
||||||
@@ -44,6 +44,7 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
|||||||
|
|
||||||
private static final String MEMBER_CARD_CACHE_PREFIX = "member:card:";
|
private static final String MEMBER_CARD_CACHE_PREFIX = "member:card:";
|
||||||
private static final long CACHE_EXPIRE_SECONDS = 300;
|
private static final long CACHE_EXPIRE_SECONDS = 300;
|
||||||
|
private static final long DUPLICATE_WINDOW_SECONDS = 10;
|
||||||
|
|
||||||
public MemberCardServiceImpl(MemberCardRepository memberCardRepository,
|
public MemberCardServiceImpl(MemberCardRepository memberCardRepository,
|
||||||
MemberCardRecordRepository recordRepository,
|
MemberCardRecordRepository recordRepository,
|
||||||
@@ -124,11 +125,42 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<MemberCardRecord> purchaseCard(Long memberId, Long memberCardId, Long sourceOrderId) {
|
public Mono<MemberCardRecord> purchaseCard(Long memberId, Long memberCardId, Long sourceOrderId) {
|
||||||
return memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(memberCardId)
|
if (memberId == null) {
|
||||||
|
return Mono.error(new RuntimeException("会员ID不能为空"));
|
||||||
|
}
|
||||||
|
if (memberCardId == null) {
|
||||||
|
return Mono.error(new RuntimeException("会员卡类型ID不能为空"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 快速路径:幂等性检查,同一笔支付订单已创建过记录则直接返回
|
||||||
|
if (sourceOrderId != null) {
|
||||||
|
return recordRepository.findBySourceOrderId(sourceOrderId)
|
||||||
|
.flatMap(existingRecord -> {
|
||||||
|
log.info("支付订单已生成会员卡记录,幂等返回: sourceOrderId={}, memberCardRecordId={}",
|
||||||
|
sourceOrderId, existingRecord.getMemberCardRecordId());
|
||||||
|
return Mono.just(existingRecord);
|
||||||
|
})
|
||||||
|
.switchIfEmpty(Mono.defer(() -> doPurchaseCard(memberId, memberCardId, sourceOrderId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceOrderId为null(前端直接调用):检查10秒内是否已有同类卡,防前端重复调用
|
||||||
|
LocalDateTime since = LocalDateTime.now().minusSeconds(DUPLICATE_WINDOW_SECONDS);
|
||||||
|
return recordRepository.findRecentActivePurchase(memberId, memberCardId, since)
|
||||||
|
.flatMap(existingRecord -> {
|
||||||
|
log.info("前端重复购买检测命中: memberId={}, memberCardId={}, memberCardRecordId={}, purchaseTime={}",
|
||||||
|
memberId, memberCardId, existingRecord.getMemberCardRecordId(), existingRecord.getPurchaseTime());
|
||||||
|
return Mono.just(existingRecord);
|
||||||
|
})
|
||||||
|
.switchIfEmpty(Mono.defer(() -> doPurchaseCard(memberId, memberCardId, sourceOrderId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<MemberCardRecord> doPurchaseCard(Long memberId, Long memberCardId, Long sourceOrderId) {
|
||||||
|
// 前端传的是数据库主键id,不是业务字段member_card_id
|
||||||
|
return memberCardRepository.findByIdAndDeletedAtIsNull(memberCardId)
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡类型不存在")))
|
.switchIfEmpty(Mono.error(new RuntimeException("会员卡类型不存在")))
|
||||||
.flatMap(card -> {
|
.flatMap(card -> {
|
||||||
if (card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1) {
|
if (card.getMemberCardStatus() != null && card.getMemberCardStatus() == 0) {
|
||||||
return Mono.error(new RuntimeException("该会员卡已禁用"));
|
return Mono.error(new RuntimeException("该会员卡已下架"));
|
||||||
}
|
}
|
||||||
|
|
||||||
MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType());
|
MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType());
|
||||||
@@ -136,13 +168,25 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
|||||||
return distributedLockService.executeWithLock(
|
return distributedLockService.executeWithLock(
|
||||||
memberId.toString(),
|
memberId.toString(),
|
||||||
cardType.name(),
|
cardType.name(),
|
||||||
Mono.defer(() -> createCardRecord(memberId, memberCardId, sourceOrderId, card))
|
Mono.defer(() -> {
|
||||||
|
// 持锁后再次检查幂等,防止并发请求同时通过外层检查
|
||||||
|
if (sourceOrderId != null) {
|
||||||
|
return recordRepository.findBySourceOrderId(sourceOrderId)
|
||||||
|
.flatMap(existingRecord -> {
|
||||||
|
log.info("持锁幂等检查命中: sourceOrderId={}, memberCardRecordId={}",
|
||||||
|
sourceOrderId, existingRecord.getMemberCardRecordId());
|
||||||
|
return Mono.just(existingRecord);
|
||||||
|
})
|
||||||
|
.switchIfEmpty(Mono.defer(() -> createCardRecord(memberId, memberCardId, sourceOrderId, card)));
|
||||||
|
}
|
||||||
|
return createCardRecord(memberId, memberCardId, sourceOrderId, card);
|
||||||
|
})
|
||||||
|
.flatMap(record -> createTransactionForPurchase(record, card, TransactionType.PURCHASE, "购买会员卡")
|
||||||
|
.thenReturn(record))
|
||||||
|
.flatMap(record -> expirationReminderService.scheduleExpirationReminder(record)
|
||||||
|
.then(Mono.just(record)))
|
||||||
);
|
);
|
||||||
})
|
});
|
||||||
.flatMap(record -> createTransaction(record, TransactionType.PURCHASE, "购买会员卡")
|
|
||||||
.thenReturn(record))
|
|
||||||
.flatMap(record -> expirationReminderService.scheduleExpirationReminder(record)
|
|
||||||
.then(Mono.just(record)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<MemberCardRecord> createCardRecord(Long memberId, Long memberCardId,
|
private Mono<MemberCardRecord> createCardRecord(Long memberId, Long memberCardId,
|
||||||
@@ -161,12 +205,16 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
|||||||
|
|
||||||
switch (cardType) {
|
switch (cardType) {
|
||||||
case TIME_CARD:
|
case TIME_CARD:
|
||||||
record.setExpireTime(now.plusDays(card.getMemberCardValidityDays()));
|
if (card.getMemberCardValidityDays() != null) {
|
||||||
|
record.setExpireTime(now.plusDays(card.getMemberCardValidityDays()));
|
||||||
|
}
|
||||||
record.setRemainingTimes(0);
|
record.setRemainingTimes(0);
|
||||||
record.setRemainingAmount(0.0);
|
record.setRemainingAmount(0.0);
|
||||||
break;
|
break;
|
||||||
case COUNT_CARD:
|
case COUNT_CARD:
|
||||||
record.setExpireTime(now.plusDays(card.getMemberCardValidityDays()));
|
if (card.getMemberCardValidityDays() != null) {
|
||||||
|
record.setExpireTime(now.plusDays(card.getMemberCardValidityDays()));
|
||||||
|
}
|
||||||
record.setRemainingTimes(card.getMemberCardTotalTimes());
|
record.setRemainingTimes(card.getMemberCardTotalTimes());
|
||||||
record.setRemainingAmount(0.0);
|
record.setRemainingAmount(0.0);
|
||||||
break;
|
break;
|
||||||
@@ -191,12 +239,17 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<MemberCardRecord> renewCard(Long recordId, Integer addTimes, Double addAmount,
|
public Mono<MemberCardRecord> renewCard(Long recordId, Long memberId, Integer addTimes, Double addAmount,
|
||||||
Integer addDays, Long sourceOrderId) {
|
Integer addDays, Long sourceOrderId) {
|
||||||
return recordRepository.findById(recordId)
|
return recordRepository.findById(recordId)
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
|
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
|
||||||
.flatMap(originalRecord -> stateMachine.validateTransition(originalRecord, CardEvent.RENEW)
|
.flatMap(originalRecord -> {
|
||||||
.then(Mono.just(originalRecord)))
|
if (!originalRecord.getMemberId().equals(memberId)) {
|
||||||
|
return Mono.error(new RuntimeException("无权操作此会员卡"));
|
||||||
|
}
|
||||||
|
return stateMachine.validateTransition(originalRecord, CardEvent.RENEW)
|
||||||
|
.then(Mono.just(originalRecord));
|
||||||
|
})
|
||||||
.flatMap(originalRecord -> memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(originalRecord.getMemberCardId())
|
.flatMap(originalRecord -> memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(originalRecord.getMemberCardId())
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡类型不存在")))
|
.switchIfEmpty(Mono.error(new RuntimeException("会员卡类型不存在")))
|
||||||
.flatMap(card -> {
|
.flatMap(card -> {
|
||||||
@@ -249,11 +302,16 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<MemberCardRecord> useCard(Long recordId, Integer deductTimes, Double deductAmount) {
|
public Mono<MemberCardRecord> useCard(Long recordId, Long memberId, Integer deductTimes, Double deductAmount) {
|
||||||
return recordRepository.findById(recordId)
|
return recordRepository.findById(recordId)
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
|
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
|
||||||
.flatMap(record -> stateMachine.validateTransition(record, CardEvent.USE)
|
.flatMap(record -> {
|
||||||
.then(Mono.just(record)))
|
if (!record.getMemberId().equals(memberId)) {
|
||||||
|
return Mono.error(new RuntimeException("无权操作此会员卡"));
|
||||||
|
}
|
||||||
|
return stateMachine.validateTransition(record, CardEvent.USE)
|
||||||
|
.then(Mono.just(record));
|
||||||
|
})
|
||||||
.flatMap(record -> memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(record.getMemberCardId())
|
.flatMap(record -> memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(record.getMemberCardId())
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡类型不存在")))
|
.switchIfEmpty(Mono.error(new RuntimeException("会员卡类型不存在")))
|
||||||
.flatMap(card -> {
|
.flatMap(card -> {
|
||||||
@@ -314,19 +372,24 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<Void> refundCard(Long recordId) {
|
public Mono<Void> refundCard(Long recordId, Long memberId) {
|
||||||
return recordRepository.findById(recordId)
|
return recordRepository.findById(recordId)
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
|
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
|
||||||
.flatMap(record -> memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(record.getMemberCardId())
|
.flatMap(record -> {
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡类型不存在")))
|
if (!record.getMemberId().equals(memberId)) {
|
||||||
.flatMap(card -> {
|
return Mono.error(new RuntimeException("无权操作此会员卡"));
|
||||||
MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType());
|
}
|
||||||
return distributedLockService.executeWithLock(
|
return memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(record.getMemberCardId())
|
||||||
record.getMemberId().toString(),
|
.switchIfEmpty(Mono.error(new RuntimeException("会员卡类型不存在")))
|
||||||
cardType.name(),
|
.flatMap(card -> {
|
||||||
refundSagaHandler.executeRefund(recordId)
|
MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType());
|
||||||
);
|
return distributedLockService.executeWithLock(
|
||||||
}));
|
record.getMemberId().toString(),
|
||||||
|
cardType.name(),
|
||||||
|
refundSagaHandler.executeRefund(recordId)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -341,6 +404,7 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
|||||||
|
|
||||||
private Mono<Void> createTransaction(MemberCardRecord record, TransactionType action, String remark) {
|
private Mono<Void> createTransaction(MemberCardRecord record, TransactionType action, String remark) {
|
||||||
MemberCardTransaction transaction = MemberCardTransaction.builder()
|
MemberCardTransaction transaction = MemberCardTransaction.builder()
|
||||||
|
.memberCardRecordId(record.getMemberCardRecordId())
|
||||||
.memberId(record.getMemberId())
|
.memberId(record.getMemberId())
|
||||||
.memberCardId(record.getMemberCardId())
|
.memberCardId(record.getMemberCardId())
|
||||||
.operationType(action.name())
|
.operationType(action.name())
|
||||||
@@ -354,6 +418,25 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
|||||||
return transactionService.createTransaction(transaction);
|
return transactionService.createTransaction(transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建购买会员卡的交易记录(记录购买金额为负数,表示支出)
|
||||||
|
*/
|
||||||
|
private Mono<Void> createTransactionForPurchase(MemberCardRecord record, MemberCard card, TransactionType action, String remark) {
|
||||||
|
MemberCardTransaction transaction = MemberCardTransaction.builder()
|
||||||
|
.memberCardRecordId(record.getMemberCardRecordId())
|
||||||
|
.memberId(record.getMemberId())
|
||||||
|
.memberCardId(record.getMemberCardId())
|
||||||
|
.operationType(action.name())
|
||||||
|
.changeAmount(card.getMemberCardTotalTimes() != null ? card.getMemberCardTotalTimes() : 0)
|
||||||
|
.changeBalance(card.getMemberCardPrice() != null ? -card.getMemberCardPrice() : 0.0) // 负数表示支出
|
||||||
|
.afterRemainingCount(record.getRemainingTimes())
|
||||||
|
.afterRemainingBalance(record.getRemainingAmount())
|
||||||
|
.remark(remark)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return transactionService.createTransaction(transaction);
|
||||||
|
}
|
||||||
|
|
||||||
private void clearCardCache(Long memberCardId) {
|
private void clearCardCache(Long memberCardId) {
|
||||||
String cacheKey = MEMBER_CARD_CACHE_PREFIX + memberCardId;
|
String cacheKey = MEMBER_CARD_CACHE_PREFIX + memberCardId;
|
||||||
redisUtil.delete(cacheKey);
|
redisUtil.delete(cacheKey);
|
||||||
|
|||||||
+13
-2
@@ -91,8 +91,19 @@ public class MemberCardTransactionServiceImpl implements IMemberCardTransactionS
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<Void> createTransaction(MemberCardTransaction transaction) {
|
public Mono<Void> createTransaction(MemberCardTransaction transaction) {
|
||||||
return transactionRepository.save(transaction)
|
return transactionRepository.insertTransaction(
|
||||||
.then()
|
transaction.getMemberCardRecordId(),
|
||||||
|
transaction.getMemberCardId(),
|
||||||
|
transaction.getMemberId(),
|
||||||
|
transaction.getOperationType(),
|
||||||
|
transaction.getChangeAmount(),
|
||||||
|
transaction.getChangeBalance(),
|
||||||
|
transaction.getAfterRemainingCount(),
|
||||||
|
transaction.getAfterRemainingBalance(),
|
||||||
|
transaction.getRelatedBizType(),
|
||||||
|
transaction.getSourceOrderId(),
|
||||||
|
transaction.getRemark()
|
||||||
|
).then()
|
||||||
.doOnSuccess(v -> log.info("创建会员卡交易记录: memberId={}, cardId={}, type={}",
|
.doOnSuccess(v -> log.info("创建会员卡交易记录: memberId={}, cardId={}, type={}",
|
||||||
transaction.getMemberId(), transaction.getMemberCardId(), transaction.getOperationType()));
|
transaction.getMemberId(), transaction.getMemberCardId(), transaction.getOperationType()));
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-45
@@ -17,6 +17,7 @@ import cn.novalon.gym.manage.member.service.MemberService;
|
|||||||
import cn.novalon.gym.manage.member.util.AesUtil;
|
import cn.novalon.gym.manage.member.util.AesUtil;
|
||||||
import cn.novalon.gym.manage.member.util.BeanConvertUtil;
|
import cn.novalon.gym.manage.member.util.BeanConvertUtil;
|
||||||
import cn.novalon.gym.manage.member.util.EsSyncUtils;
|
import cn.novalon.gym.manage.member.util.EsSyncUtils;
|
||||||
|
import cn.novalon.gym.manage.member.util.WechatPhoneUtil;
|
||||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||||
import cn.novalon.gym.manage.member.vo.MemberCardInfoVO;
|
import cn.novalon.gym.manage.member.vo.MemberCardInfoVO;
|
||||||
import cn.novalon.gym.manage.member.vo.MemberDetailVO;
|
import cn.novalon.gym.manage.member.vo.MemberDetailVO;
|
||||||
@@ -65,21 +66,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
|
||||||
@@ -123,7 +140,18 @@ public class MemberServiceImpl implements MemberService {
|
|||||||
|
|
||||||
private MemberInfoVO buildMemberInfoResponse(Member member) {
|
private MemberInfoVO buildMemberInfoResponse(Member member) {
|
||||||
String phone = member.getPhone();
|
String phone = member.getPhone();
|
||||||
String maskedPhone = phone != null ? phone.replace(phone.substring(3, 7), "****") : null;
|
String maskedPhone = null;
|
||||||
|
|
||||||
|
// 先解密手机号,再进行脱敏处理
|
||||||
|
if (phone != null && !phone.isEmpty()) {
|
||||||
|
try {
|
||||||
|
String decryptedPhone = AesUtil.decrypt(phone);
|
||||||
|
maskedPhone = WechatPhoneUtil.maskPhone(decryptedPhone);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("手机号解密失败, memberId: {}", member.getId(), e);
|
||||||
|
maskedPhone = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
GenderEnum genderEnum = GenderEnum.fromCode(member.getGender());
|
GenderEnum genderEnum = GenderEnum.fromCode(member.getGender());
|
||||||
|
|
||||||
@@ -226,45 +254,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, "会员不存在");
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+236
@@ -0,0 +1,236 @@
|
|||||||
|
package cn.novalon.gym.manage.member.service.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.member.entity.MemberStoredCard;
|
||||||
|
import cn.novalon.gym.manage.member.entity.MemberStoredCardRecharge;
|
||||||
|
import cn.novalon.gym.manage.member.repository.MemberStoredCardRechargeRepository;
|
||||||
|
import cn.novalon.gym.manage.member.repository.MemberStoredCardRepository;
|
||||||
|
import cn.novalon.gym.manage.member.service.IMemberStoredCardService;
|
||||||
|
import cn.novalon.gym.manage.sys.core.service.ISysConfigService;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 储值卡 Service实现
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class MemberStoredCardServiceImpl implements IMemberStoredCardService {
|
||||||
|
|
||||||
|
private final MemberStoredCardRepository memberStoredCardRepository;
|
||||||
|
private final MemberStoredCardRechargeRepository memberStoredCardRechargeRepository;
|
||||||
|
private final ISysConfigService sysConfigService;
|
||||||
|
private final BCryptPasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
public MemberStoredCardServiceImpl(MemberStoredCardRepository memberStoredCardRepository,
|
||||||
|
MemberStoredCardRechargeRepository memberStoredCardRechargeRepository,
|
||||||
|
ISysConfigService sysConfigService) {
|
||||||
|
this.memberStoredCardRepository = memberStoredCardRepository;
|
||||||
|
this.memberStoredCardRechargeRepository = memberStoredCardRechargeRepository;
|
||||||
|
this.sysConfigService = sysConfigService;
|
||||||
|
this.passwordEncoder = new BCryptPasswordEncoder();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<MemberStoredCard> getOrCreateStoredCard(Long memberId) {
|
||||||
|
return memberStoredCardRepository.findByMemberId(memberId)
|
||||||
|
.switchIfEmpty(Mono.defer(() -> {
|
||||||
|
log.info("[储值卡] 为会员 {} 创建储值卡", memberId);
|
||||||
|
return memberStoredCardRepository.createStoredCard(memberId)
|
||||||
|
.then(memberStoredCardRepository.findByMemberId(memberId));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<MemberStoredCard> getStoredCard(Long memberId) {
|
||||||
|
return memberStoredCardRepository.findByMemberId(memberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Integer> recharge(Long memberId, BigDecimal amount) {
|
||||||
|
return memberStoredCardRepository.findByMemberId(memberId)
|
||||||
|
.flatMap(card -> memberStoredCardRepository.rechargeBalance(memberId, amount))
|
||||||
|
.switchIfEmpty(Mono.defer(() -> {
|
||||||
|
log.info("[储值卡] 会员 {} 首次充值,创建储值卡并充值", memberId);
|
||||||
|
return memberStoredCardRepository.createStoredCard(memberId)
|
||||||
|
.then(memberStoredCardRepository.rechargeBalance(memberId, amount));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Integer> consume(Long memberId, BigDecimal amount) {
|
||||||
|
return memberStoredCardRepository.deductBalance(memberId, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Boolean> isPayPasswordSet(Long memberId) {
|
||||||
|
return memberStoredCardRepository.isPayPasswordSet(memberId)
|
||||||
|
.defaultIfEmpty(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Integer> setPayPassword(Long memberId, String password) {
|
||||||
|
// 确保储值卡存在
|
||||||
|
return getOrCreateStoredCard(memberId)
|
||||||
|
.flatMap(card -> {
|
||||||
|
// BCrypt加密密码
|
||||||
|
String encodedPassword = passwordEncoder.encode(password);
|
||||||
|
return memberStoredCardRepository.setPayPassword(memberId, encodedPassword);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Boolean> verifyPayPassword(Long memberId, String password) {
|
||||||
|
return memberStoredCardRepository.getPayPassword(memberId)
|
||||||
|
.map(storedPassword -> {
|
||||||
|
if (storedPassword == null || storedPassword.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return passwordEncoder.matches(password, storedPassword);
|
||||||
|
})
|
||||||
|
.defaultIfEmpty(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<PayResult> payWithStoredCard(Long memberId, String password, BigDecimal amount) {
|
||||||
|
return memberStoredCardRepository.findByMemberId(memberId)
|
||||||
|
.flatMap(card -> {
|
||||||
|
// 检查余额是否充足
|
||||||
|
BigDecimal currentBalance = card.getBalance();
|
||||||
|
if (currentBalance == null) {
|
||||||
|
currentBalance = BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
if (currentBalance.compareTo(amount) < 0) {
|
||||||
|
log.warn("[储值卡支付] 余额不足: memberId={}, balance={}, needAmount={}",
|
||||||
|
memberId, currentBalance, amount);
|
||||||
|
return Mono.just(PayResult.fail("余额不足,当前余额:" + currentBalance + "元"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码
|
||||||
|
if (card.getPayPassword() == null || card.getPayPassword().isEmpty()) {
|
||||||
|
log.warn("[储值卡支付] 未设置支付密码: memberId={}", memberId);
|
||||||
|
return Mono.just(PayResult.fail("请先设置支付密码"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passwordEncoder.matches(password, card.getPayPassword())) {
|
||||||
|
log.warn("[储值卡支付] 密码错误: memberId={}", memberId);
|
||||||
|
return Mono.just(PayResult.fail("支付密码错误"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扣减余额
|
||||||
|
return memberStoredCardRepository.deductBalance(memberId, amount)
|
||||||
|
.flatMap(rows -> {
|
||||||
|
if (rows > 0) {
|
||||||
|
// 查询最新余额
|
||||||
|
return memberStoredCardRepository.findByMemberId(memberId)
|
||||||
|
.map(updatedCard -> PayResult.success(
|
||||||
|
updatedCard.getBalance() != null ? updatedCard.getBalance() : BigDecimal.ZERO));
|
||||||
|
} else {
|
||||||
|
log.warn("[储值卡支付] 扣减余额失败: memberId={}, amount={}", memberId, amount);
|
||||||
|
return Mono.just(PayResult.fail("支付失败,请稍后重试"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.switchIfEmpty(Mono.defer(() -> {
|
||||||
|
log.warn("[储值卡支付] 储值卡不存在: memberId={}", memberId);
|
||||||
|
return Mono.just(PayResult.fail("储值卡不存在"));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<PayResult> refundToStoredCard(Long memberId, String password, BigDecimal amount, long cancelCount) {
|
||||||
|
// 从系统配置读取免手续费次数阈值,默认3次
|
||||||
|
Mono<Long> freeLimitMono = sysConfigService.getConfigValue("group_course.cancel_free_limit")
|
||||||
|
.map(Long::parseLong)
|
||||||
|
.defaultIfEmpty(3L);
|
||||||
|
|
||||||
|
return freeLimitMono.flatMap(freeLimit ->
|
||||||
|
memberStoredCardRepository.findByMemberId(memberId)
|
||||||
|
.flatMap(card -> {
|
||||||
|
// 验证密码
|
||||||
|
if (card.getPayPassword() == null || card.getPayPassword().isEmpty()) {
|
||||||
|
log.warn("[储值卡退款] 未设置支付密码: memberId={}", memberId);
|
||||||
|
return Mono.just(PayResult.fail("请先设置支付密码"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passwordEncoder.matches(password, card.getPayPassword())) {
|
||||||
|
log.warn("[储值卡退款] 密码错误: memberId={}", memberId);
|
||||||
|
return Mono.just(PayResult.fail("支付密码错误"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前N次取消不扣手续费,超出后扣除10%
|
||||||
|
BigDecimal refundAmount = amount;
|
||||||
|
BigDecimal fee = BigDecimal.ZERO;
|
||||||
|
if (cancelCount >= freeLimit) {
|
||||||
|
fee = amount.multiply(new BigDecimal("0.1")).setScale(2, java.math.RoundingMode.HALF_UP);
|
||||||
|
refundAmount = amount.subtract(fee);
|
||||||
|
log.info("[储值卡退款] 第{}次取消,已超出免手续费阈值({}次),扣除10%手续费: 原额={}, 手续费={}, 退款={}",
|
||||||
|
cancelCount + 1, freeLimit, amount, fee, refundAmount);
|
||||||
|
} else {
|
||||||
|
log.info("[储值卡退款] 第{}次取消,在免手续费阈值({}次)内,全额退款: 原额={}",
|
||||||
|
cancelCount + 1, freeLimit, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行退款
|
||||||
|
BigDecimal finalRefundAmount = refundAmount;
|
||||||
|
return memberStoredCardRepository.refundBalance(memberId, refundAmount)
|
||||||
|
.flatMap(rows -> {
|
||||||
|
if (rows > 0) {
|
||||||
|
return memberStoredCardRepository.findByMemberId(memberId)
|
||||||
|
.map(updatedCard -> PayResult.success(
|
||||||
|
updatedCard.getBalance() != null ? updatedCard.getBalance() : BigDecimal.ZERO));
|
||||||
|
} else {
|
||||||
|
log.warn("[储值卡退款] 退款失败: memberId={}, amount={}", memberId, finalRefundAmount);
|
||||||
|
return Mono.just(PayResult.fail("退款失败,请稍后重试"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.switchIfEmpty(Mono.defer(() -> {
|
||||||
|
log.warn("[储值卡退款] 储值卡不存在: memberId={}", memberId);
|
||||||
|
return Mono.just(PayResult.fail("储值卡不存在"));
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<MemberStoredCardRecharge> getRechargeRecords(Long memberId, int page, int size) {
|
||||||
|
int offset = (page - 1) * size;
|
||||||
|
return memberStoredCardRechargeRepository.findByMemberId(memberId, size, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<BigDecimal> refundBalanceWithFee(Long memberId, BigDecimal amount, long cancelCount) {
|
||||||
|
return sysConfigService.getConfigValue("group_course.cancel_free_limit")
|
||||||
|
.map(Long::parseLong)
|
||||||
|
.defaultIfEmpty(3L)
|
||||||
|
.flatMap(freeLimit -> {
|
||||||
|
BigDecimal refundAmount;
|
||||||
|
if (cancelCount >= freeLimit) {
|
||||||
|
BigDecimal fee = amount.multiply(new BigDecimal("0.1")).setScale(2, java.math.RoundingMode.HALF_UP);
|
||||||
|
refundAmount = amount.subtract(fee);
|
||||||
|
log.info("[储值卡退款-内部] 第{}次取消,超出免手续费阈值({}次),扣除10%: 原额={}, 手续费={}, 退款={}",
|
||||||
|
cancelCount + 1, freeLimit, amount, fee, refundAmount);
|
||||||
|
} else {
|
||||||
|
refundAmount = amount;
|
||||||
|
log.info("[储值卡退款-内部] 第{}次取消,在免手续费阈值({}次)内,全额退款: {}",
|
||||||
|
cancelCount + 1, freeLimit, amount);
|
||||||
|
}
|
||||||
|
return memberStoredCardRepository.refundBalance(memberId, refundAmount)
|
||||||
|
.flatMap(rows -> {
|
||||||
|
if (rows > 0) {
|
||||||
|
return Mono.just(refundAmount);
|
||||||
|
}
|
||||||
|
return Mono.error(new RuntimeException("储值卡退款失败"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Long> countRechargeRecords(Long memberId) {
|
||||||
|
return memberStoredCardRechargeRepository.countByMemberIdAndDeletedAtIsNull(memberId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -279,3 +279,24 @@ CREATE INDEX IF NOT EXISTS idx_refund_application_status ON refund_application(s
|
|||||||
|
|
||||||
COMMENT ON TABLE refund_application IS '退款申请表';
|
COMMENT ON TABLE refund_application IS '退款申请表';
|
||||||
COMMENT ON COLUMN refund_application.status IS '状态:PENDING-待审核, APPROVED-已批准, REJECTED-已拒绝, PROCESSING-处理中, SUCCESS-成功, FAILED-失败';
|
COMMENT ON COLUMN refund_application.status IS '状态:PENDING-待审核, APPROVED-已批准, REJECTED-已拒绝, PROCESSING-处理中, SUCCESS-成功, FAILED-失败';
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 7. member_pay_password 表(支付密码表)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS member_pay_password (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
member_id BIGINT NOT NULL UNIQUE,
|
||||||
|
pay_password VARCHAR(100) NOT NULL,
|
||||||
|
is_set BOOLEAN DEFAULT TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_member_pay_password_member_id ON member_pay_password(member_id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE member_pay_password IS '支付密码表';
|
||||||
|
COMMENT ON COLUMN member_pay_password.member_id IS '会员ID';
|
||||||
|
COMMENT ON COLUMN member_pay_password.pay_password IS '支付密码(明文存储)';
|
||||||
|
COMMENT ON COLUMN member_pay_password.is_set IS '是否已设置支付密码';
|
||||||
|
|||||||
-16
@@ -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上下文是否能正常加载
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user