Author SHA1 Message Date
liwentao 53d1ce6fb2 更新会员端个人页面中团课签到记录显示 2026-07-20 17:27:22 +08:00
liwentao 4a4697c816 新增教练端,实现业务闭环:会员登录注册→查询团课→预约团课→扫码签到→教练端开课→记录实际开课时间→教练端结课→记录实际结课时间→后台查询数据 2026-07-20 17:21:28 +08:00
liwentao df0e68469b 新增教练管理,优化预约人数计数方式 2026-07-19 19:02:10 +08:00
liwentao 97a5eff678 完善业务闭环,实现对轮播图的管理,优化数据库表文件 2026-07-18 16:21:40 +08:00
liwentao feaf014b4a 补充缺失的文件 2026-07-17 16:37:59 +08:00
liwentao ff899f491e 实现用户登录→查询团课→预约团课→扫码签到→后台查看数据闭环 2026-07-17 16:36:25 +08:00
liwentao 922c5850b5 补充缺失的文件 2026-07-17 15:57:05 +08:00
liwentao 2e7c2ced43 实现大部分页面前后端互通 2026-07-17 15:48:36 +08:00
liwentao a9ccdab421 实现部分页面前后端相通 2026-07-16 17:29:27 +08:00
liwentao 7e45ecd144 重写会员端,初步完善首页 2026-07-15 17:26:07 +08:00
liwentao cacc8997ec 移除流程中的支付相关和会员卡相关 2026-07-14 18:49:15 +08:00
liwentao 593f6f13e7 新增数据统计页面 2026-07-10 13:56:16 +08:00
liwentao 8ee326b43a 修补缺失文件 2026-07-09 15:34:50 +08:00
liwentao 8fb4e714d8 新增会员卡管理 2026-07-09 15:33:05 +08:00
liwentao a8e69e0850 新增会员管理 2026-07-09 14:45:45 +08:00
liwentao f5b5724e92 完善团课相关管理 2026-07-08 17:02:04 +08:00
liwentao 1e41f31271 更新后台管理系统,实现团课管理基础显示 2026-07-06 16:44:27 +08:00
liwentao efd4d03037 修复多处问题 2026-06-29 02:56:13 +08:00
liwentao 80759f6793 修复一次支付发放两张会员卡的问题 2026-06-28 17:56:31 +08:00
liwentao f8279129be 修复编译时错误 2026-06-28 16:42:24 +08:00
future fc48db071e 完成一键登录和支付功能 2026-06-28 14:47:42 +08:00
future 35e7532f1b 解决bom字符 2026-06-25 01:22:33 +08:00
future 0d143be7b3 与dev重组登录 2026-06-25 01:12:42 +08:00
future 2ffd1aa7d6 修复项目构建 2026-06-24 23:08:53 +08:00
liwentao 581cc995c6 修复后端bug,更新测试数据 2026-06-24 07:27:44 +08:00
liwentao 2251f31524 新增支付宝连接沙箱模拟支付(暂不可用 2026-06-24 04:59:32 +08:00
futureandfujia 4481819eba 修复丢失文件 2026-06-24 00:46:47 +08:00
futureandfujia 79eb07599e feat: 添加签到模块和数据库表结构
- 新增gym-checkIn签到模块
- 创建sign_in_record签到记录表(V13迁移脚本)
- 添加签到相关路由配置
- 更新应用配置以支持签到模块
- 添加Redis密钥常量
2026-06-24 00:46:47 +08:00
futureandfujia a7f31083cf 签到模块 2026-06-24 00:46:47 +08:00
futureandfujia 524182c478 修复项目构建 2026-06-24 00:46:47 +08:00
future 3586a7d74b 登录 2026-06-23 22:47:35 +08:00
liwentao 8da58a8f51 新增功能:团课签到前验证是否到店签到 2026-06-18 10:48:23 +08:00
liwentao 0b2146f237 新增团课扫码签到功能,文档已更新 2026-06-18 10:11:47 +08:00
liwentao 7cc9a68144 新增团课推荐功能 2026-06-15 16:43:12 +08:00
liwentao b5c8a087dd 新增团课多条件查询 2026-06-15 14:09:19 +08:00
liwentao 7a94145819 新增团课类型,类型标签,以及相关功能 2026-06-11 13:57:46 +08:00
399 changed files with 38878 additions and 1535 deletions
+202
View File
@@ -0,0 +1,202 @@
import java.util.Base64;
import java.math.BigInteger;
import java.io.ByteArrayInputStream;
public class KeyAnalyzer {
public static void main(String[] args) {
String privateKeyBase64 = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCcxAwrhZF+izz1gxQKOr4jnC05obIBHbl0DzHOcd4CaaDV7Kv1NRJKi33JhdDsW4JGgu16e5Rtzq1VzU5VWz5EKgGL46maOFwCkngJUTP/LC3JVf/wtJmYCm8xNE8C7GNNIqKzYEvUOEfXqagpVvVrGsQ9FpzFp2rP9hBmHY3yizVyPX/uT9S+af5TRxiaItj3SSJGgloaEMrnKOpb/EH7JwPSS0liAyT/NxPfOyyZHc22AvaAIOE5y+0PMUIKPuIdfpOrej3LVpO1Arc2hSgmdB+YIPSiBVYPXa6AuAmil9mpbtSikQJ7Uu7lX4JyTW4QxQ06rPFKnFWVKkzivAElAgMBAAECggEAJd0AJ37iTlMpDQ90xqe7hvRQxAu256gbQ9nrqLY97g0/KIw6WEZSPakFX6gvdvb/NzKmUyAIEKGLoh6tXdZk6qfOqc/6BeK47nIcBfwT9/zerjNUVvn34w4aHyNINieMMHQ+Id8PUZmqWH+Euz9ilVTosuyEPwUZulLvUQqwXzU5VnwVghURbUhDd+ecBJACWgemRun6d5241PQXNYAdH1k7cETd8GfIi3qclhhJrxi7tu5tq4YGCXQIoz7HCLim7GIvT0M+FRgSw2EOrHnAQNFeQ/vQbP71ttLoTxehL6Se9dfWrV5OI+Y/T7vR2F84Qt0iNbaxyJGir7siKDFIwQKBgQDXDzinx3/TasplM78pR/0CtuuKr1Ch02LOPrTosJ1qf1OohxQThowhOTxMsBlgYSKu9s1QRffUUXEqYXxd2B6lzDKfggwO6U2XxIcxWeNow0xoFfqcXYSg7Ga2sCr9uhdwxIdFQNF7SNBpT8ht4fJrRX6mWY1nHybpyTDQ4xoQNQKBgQC6m+yiOoi5JD3zVSSJq/iq5DJPA5B4aoP+t5u9lp2Q7iVO0QI5ilBlEKGE4VOU0glnXlDTfuqEYooMY85ekl4WGb3AOT0PhLL2i+gO2nlWBzf4HPzB/hibjfyPyniRM03cHkG3HXucL7Sne6FwERcfjEqjUd2cdP1l89PNrq4rMQKBgQCMmjABSWYh8/y1I5rEQ4OAJdVjC3GdC1Xa35ZpVybjvLEWSpHunhW5lvD8dllw8LC7UTI0XDpGPqTM/4VO2YBYB2PFc0Gs8g0/v0ZgFpOeJ6kpl80MM/wFNemFYTIKRoMSv/psZY9PmfBgGcBBTuquBXZjDcNr+yr2yAm5V/DvTQKBgHqRi94KoF8q5N39IKCkqhJlDH5FkxDktYoKw2rFkPzuzuZz9gghRyj6wXxsG9/2DWMt2dzw0czehFoa/CO188KEadPmRKr6uCmkP2nyKhxNZX+8WnB5G2Sg4DD6BjMpBYz8+qDx5ozx8LDJTYI0V4HLPgMD9JGdbgsXGhlREOkhAoGALr6IQOXnviWNAhCdc7rrsaMLMPbLZ1wqzWtQUG1JxDobbpzEP4CW/mvW5pMn58mSBg5qbXhyDI4fFP0CPb98QIz2tGnIzYyFzdKmF5Z1N7X1OF9O+tsSqASoBZzTqB4fr/o4mz0s9JCeriBR2LWjsbsDU13DTLsfQpWbtOnIy70=";
System.out.println("========== 密钥分析开始 ==========");
System.out.println("密钥长度: " + privateKeyBase64.length());
byte[] keyBytes = Base64.getDecoder().decode(privateKeyBase64);
System.out.println("解码后长度: " + keyBytes.length + " bytes");
// 打印前30字节
System.out.print("前30字节(hex): ");
for (int i = 0; i < 30 && i < keyBytes.length; i++) {
System.out.printf("%02X ", keyBytes[i]);
}
System.out.println();
// 解析ASN.1结构
try {
parseASN1(keyBytes);
} catch (Exception e) {
System.out.println("解析失败: " + e.getMessage());
e.printStackTrace();
}
}
static void parseASN1(byte[] keyBytes) throws Exception {
ByteArrayInputStream bis = new ByteArrayInputStream(keyBytes);
// 读取SEQUENCE
int tag = bis.read();
System.out.println("第一个tag: 0x" + Integer.toHexString(tag));
if (tag == 0x30) {
System.out.println("这是SEQUENCE");
// 读取长度
int len = readASN1Length(bis);
System.out.println("SEQUENCE长度: " + len);
// 检查下一个tag
int nextTag = bis.read();
System.out.println("下一个tag: 0x" + Integer.toHexString(nextTag));
if (nextTag == 0x02) {
// INTEGER
int intLen = readASN1Length(bis);
byte[] versionBytes = new byte[intLen];
bis.read(versionBytes);
int version = new BigInteger(versionBytes).intValue();
System.out.println("版本INTEGER值: " + version);
// 检查这是PKCS#8还是PKCS#1
// PKCS#8: version=0后是SEQUENCE(算法标识符)
// PKCS#1: version=0后是INTEGER(modulus)
int afterVersionTag = bis.read();
System.out.println("版本后的tag: 0x" + Integer.toHexString(afterVersionTag));
if (afterVersionTag == 0x30) {
System.out.println(">>> 这是PKCS#8格式 (version后是SEQUENCE)");
// 解析PKCS#8
// AlgorithmIdentifier: SEQUENCE { OID, NULL }
int algLen = readASN1Length(bis);
System.out.println("AlgorithmIdentifier长度: " + algLen);
// 跳过AlgorithmIdentifier内容
byte[] algBytes = new byte[algLen];
bis.read(algBytes);
// 打印OID
System.out.print("OID bytes: ");
for (int i = 0; i < algBytes.length; i++) {
System.out.printf("%02X ", algBytes[i]);
}
System.out.println();
// 下一个应该是OCTET STRING (包含私钥)
int octetTag = bis.read();
System.out.println("下一个tag: 0x" + Integer.toHexString(octetTag));
if (octetTag == 0x04) {
System.out.println("这是OCTET STRING (包含私钥数据)");
int octetLen = readASN1Length(bis);
System.out.println("OCTET STRING长度: " + octetLen);
// OCTET STRING内容是PKCS#1私钥
byte[] pkcs1Bytes = new byte[octetLen];
bis.read(pkcs1Bytes);
System.out.print("PKCS#1私钥前20字节: ");
for (int i = 0; i < 20; i++) {
System.out.printf("%02X ", pkcs1Bytes[i]);
}
System.out.println();
// 解析PKCS#1私钥
ByteArrayInputStream pkcs1Stream = new ByteArrayInputStream(pkcs1Bytes);
int pkcs1Tag = pkcs1Stream.read();
System.out.println("PKCS#1第一个tag: 0x" + Integer.toHexString(pkcs1Tag));
if (pkcs1Tag == 0x30) {
int pkcs1Len = readASN1Length(pkcs1Stream);
System.out.println("PKCS#1 SEQUENCE长度: " + pkcs1Len);
// version
int vTag = pkcs1Stream.read();
int vLen = readASN1Length(pkcs1Stream);
byte[] vBytes = new byte[vLen];
pkcs1Stream.read(vBytes);
System.out.println("PKCS#1版本: " + new BigInteger(vBytes));
// 解析私钥参数
parsePKCS1(pkcs1Stream);
}
}
} else if (afterVersionTag == 0x02) {
System.out.println(">>> 这是PKCS#1格式 (version后是INTEGER)");
// 继续解析PKCS#1
parsePKCS1(bis);
} else {
System.out.println(">>> 未知格式 (tag: 0x" + Integer.toHexString(afterVersionTag) + ")");
}
}
}
}
static void parsePKCS1(ByteArrayInputStream bis) throws Exception {
System.out.println("\n========== 解析PKCS#1私钥参数 ==========");
// modulus
BigInteger modulus = readASN1Integer(bis);
System.out.println("modulus长度: " + modulus.bitLength() + " bits");
// publicExponent
BigInteger publicExponent = readASN1Integer(bis);
System.out.println("publicExponent: " + publicExponent);
// privateExponent
BigInteger privateExponent = readASN1Integer(bis);
System.out.println("privateExponent长度: " + privateExponent.bitLength() + " bits");
// prime1
BigInteger prime1 = readASN1Integer(bis);
System.out.println("prime1长度: " + prime1.bitLength() + " bits");
// prime2
BigInteger prime2 = readASN1Integer(bis);
System.out.println("prime2长度: " + prime2.bitLength() + " bits");
// 验证 prime1 * prime2 == modulus
BigInteger calculatedModulus = prime1.multiply(prime2);
boolean valid = calculatedModulus.equals(modulus);
System.out.println("\n验证 prime1 * prime2 == modulus: " + valid);
if (!valid) {
System.out.println(">>> 密钥数学关系不正确!这是无效的RSA私钥");
System.out.println("计算得到的modulus长度: " + calculatedModulus.bitLength() + " bits");
}
// exponent1
BigInteger exponent1 = readASN1Integer(bis);
System.out.println("exponent1长度: " + exponent1.bitLength() + " bits");
// exponent2
BigInteger exponent2 = readASN1Integer(bis);
System.out.println("exponent2长度: " + exponent2.bitLength() + " bits");
// coefficient
BigInteger coefficient = readASN1Integer(bis);
System.out.println("coefficient长度: " + coefficient.bitLength() + " bits");
System.out.println("\n========== 解析完成 ==========");
}
static int readASN1Length(ByteArrayInputStream bis) throws Exception {
int firstByte = bis.read();
if ((firstByte & 0x80) == 0) {
return firstByte;
}
int numBytes = firstByte & 0x7F;
int length = 0;
for (int i = 0; i < numBytes; i++) {
length = (length << 8) | (bis.read() & 0xFF);
}
return length;
}
static BigInteger readASN1Integer(ByteArrayInputStream bis) throws Exception {
int tag = bis.read();
if (tag != 0x02) throw new Exception("期望INTEGER tag: 0x02, 实际: 0x" + Integer.toHexString(tag));
int len = readASN1Length(bis);
byte[] data = new byte[len];
bis.read(data);
return new BigInteger(1, data);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,530 @@
# 团课推荐模块 API 文档
> **文档版本**: v1.0
> **创建日期**: 2026-06-15
> **作者**: 张翔
> **状态**: 正式发布
---
## 📋 目录
1. [概述](#概述)
2. [基础路径](#基础路径)
3. [团课推荐管理接口](#团课推荐管理接口)
- [获取所有团课推荐](#获取所有团课推荐)
- [获取所有启用的团课推荐](#获取所有启用的团课推荐)
- [根据ID获取团课推荐](#根据ID获取团课推荐)
- [根据团课ID获取推荐](#根据团课ID获取推荐)
- [创建团课推荐](#创建团课推荐)
- [更新团课推荐](#更新团课推荐)
- [删除团课推荐](#删除团课推荐)
- [启用团课推荐](#启用团课推荐)
- [禁用团课推荐](#禁用团课推荐)
4. [数据模型](#数据模型)
- [GroupCourseRecommend(团课推荐)](#GroupCourseRecommend团课推荐)
5. [状态码说明](#状态码说明)
6. [业务规则](#业务规则)
---
## 概述
团课推荐模块提供团课推荐信息的创建、编辑、查询、删除和状态管理功能。推荐信息包含团课ID、推荐标题、推荐内容、推荐理由、优先级等必要信息,支持按优先级排序展示。
## 基础路径
所有接口的基础路径为: `http://{host}:{port}/api/groupCourse/recommend`
---
## 团课推荐管理接口
### 获取所有团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/recommend/list` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**请求参数**:
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| sortBy | string | 否 | priority | 排序字段(支持:priority、createdAt、updatedAt |
| sortOrder | string | 否 | desc | 排序方式(asc-升序,desc-降序) |
**成功响应** (200 OK):
```json
[
{
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
"recommendReason": "教练专业,课程内容丰富",
"priority": 10,
"isActive": true,
"groupCourse": {
"id": 1,
"courseName": "瑜伽入门",
"coachId": 1,
"courseType": 1,
"startTime": "2026-06-15T09:00:00",
"endTime": "2026-06-15T10:00:00",
"maxMembers": 20,
"currentMembers": 15,
"status": 0,
"location": "健身房A区",
"coverImage": "https://example.com/yoga.jpg",
"description": "适合初学者的瑜伽课程"
},
"createdAt": "2026-06-15T10:00:00",
"updatedAt": "2026-06-15T10:00:00"
}
]
```
---
### 获取所有启用的团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/recommend/active` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**功能说明**: 获取系统中所有已启用的团课推荐列表,按优先级从高到低排序。
**成功响应** (200 OK):
```json
[
{
"id": 2,
"courseId": 3,
"recommendTitle": "新学员推荐",
"recommendContent": "专为新学员设计的入门课程,轻松上手",
"recommendReason": "零基础友好,教练耐心指导",
"priority": 20,
"isActive": true,
"groupCourse": {
"id": 3,
"courseName": "基础有氧",
"coachId": 2,
"courseType": 2,
"startTime": "2026-06-16T18:00:00",
"endTime": "2026-06-16T19:00:00",
"maxMembers": 30,
"currentMembers": 8,
"status": 0,
"location": "健身房B区",
"coverImage": "https://example.com/aerobic.jpg",
"description": "适合所有健身水平的有氧课程"
},
"createdAt": "2026-06-15T11:00:00",
"updatedAt": "2026-06-15T11:00:00"
}
]
```
---
### 根据ID获取团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/recommend/{id}` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课推荐ID |
**成功响应** (200 OK):
```json
{
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
"recommendReason": "教练专业,课程内容丰富",
"priority": 10,
"isActive": true,
"groupCourse": {
"id": 1,
"courseName": "瑜伽入门",
"coachId": 1,
"courseType": 1,
"startTime": "2026-06-15T09:00:00",
"endTime": "2026-06-15T10:00:00",
"maxMembers": 20,
"currentMembers": 15,
"status": 0,
"location": "健身房A区",
"coverImage": "https://example.com/yoga.jpg",
"description": "适合初学者的瑜伽课程"
},
"createdAt": "2026-06-15T10:00:00",
"updatedAt": "2026-06-15T10:00:00"
}
```
**失败响应** (404 Not Found):
```json
{}
```
---
### 根据团课ID获取推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | GET |
| **接口路径** | `/api/groupCourse/recommend/course/{courseId}` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| courseId | Long | 是 | 团课ID |
**成功响应** (200 OK):
```json
[
{
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
"recommendReason": "教练专业,课程内容丰富",
"priority": 10,
"isActive": true,
"groupCourse": {
"id": 1,
"courseName": "瑜伽入门",
"coachId": 1,
"courseType": 1,
"startTime": "2026-06-15T09:00:00",
"endTime": "2026-06-15T10:00:00",
"maxMembers": 20,
"currentMembers": 15,
"status": 0,
"location": "健身房A区"
},
"createdAt": "2026-06-15T10:00:00",
"updatedAt": "2026-06-15T10:00:00"
}
]
```
---
### 创建团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | POST |
| **接口路径** | `/api/groupCourse/recommend` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**请求体**:
```json
{
"courseId": 1,
"recommendTitle": "本周热门课程",
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
"recommendReason": "教练专业,课程内容丰富",
"priority": 10,
"isActive": true
}
```
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| courseId | Long | **是** | - | 团课ID(必须是有效的团课) |
| recommendTitle | String | 否 | - | 推荐标题 |
| recommendContent | String | 否 | - | 推荐内容 |
| recommendReason | String | 否 | - | 推荐理由 |
| priority | Integer | 否 | 0 | 优先级(数字越大优先级越高) |
| isActive | Boolean | 否 | true | 是否启用 |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课推荐创建成功",
"data": {
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"recommendContent": "这是一门非常棒的课程,快来参加吧!",
"recommendReason": "教练专业,课程内容丰富",
"priority": 10,
"isActive": true,
"createdAt": "2026-06-15T10:00:00",
"updatedAt": "2026-06-15T10:00:00"
}
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "团课ID不能为空"
}
```
---
### 更新团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | PUT |
| **接口路径** | `/api/groupCourse/recommend/{id}` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课推荐ID |
**请求体**:
```json
{
"recommendTitle": "本周热门课程(更新)",
"recommendContent": "更新后的推荐内容",
"recommendReason": "更新后的推荐理由",
"priority": 15,
"isActive": true
}
```
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| courseId | Long | 否 | 团课ID |
| recommendTitle | String | 否 | 推荐标题 |
| recommendContent | String | 否 | 推荐内容 |
| recommendReason | String | 否 | 推荐理由 |
| priority | Integer | 否 | 优先级 |
| isActive | Boolean | 否 | 是否启用 |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课推荐更新成功",
"data": {
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程(更新)",
"recommendContent": "更新后的推荐内容",
"recommendReason": "更新后的推荐理由",
"priority": 15,
"isActive": true,
"updatedAt": "2026-06-15T12:00:00"
}
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "团课推荐不存在"
}
```
---
### 删除团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | DELETE |
| **接口路径** | `/api/groupCourse/recommend/{id}` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课推荐ID |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课推荐删除成功"
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "团课推荐不存在"
}
```
---
### 启用团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | POST |
| **接口路径** | `/api/groupCourse/recommend/{id}/enable` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课推荐ID |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课推荐启用成功",
"data": {
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"isActive": true
}
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "团课推荐不存在"
}
```
---
### 禁用团课推荐
| 属性 | 值 |
|------|-----|
| **HTTP方法** | POST |
| **接口路径** | `/api/groupCourse/recommend/{id}/disable` |
| **所属文件** | `GroupCourseRecommendHandler.java` |
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Long | 是 | 团课推荐ID |
**成功响应** (200 OK):
```json
{
"success": true,
"message": "团课推荐禁用成功",
"data": {
"id": 1,
"courseId": 1,
"recommendTitle": "本周热门课程",
"isActive": false
}
}
```
**失败响应** (400 Bad Request):
```json
{
"success": false,
"message": "团课推荐不存在"
}
```
---
## 数据模型
### GroupCourseRecommend(团课推荐)
| 字段名 | 类型 | 说明 |
|--------|------|------|
| id | Long | 主键ID |
| courseId | Long | 团课ID(关联group_course.id |
| recommendTitle | String | 推荐标题 |
| recommendContent | String | 推荐内容 |
| recommendReason | String | 推荐理由 |
| priority | Integer | 优先级(数字越大优先级越高),默认0 |
| isActive | Boolean | 是否启用,默认true |
| groupCourse | GroupCourse | 关联的团课信息(查询时自动填充) |
| createdBy | String | 创建人 |
| updatedBy | String | 更新人 |
| createdAt | LocalDateTime | 创建时间 |
| updatedAt | LocalDateTime | 更新时间 |
| deletedAt | LocalDateTime | 删除时间(软删除) |
---
## 状态码说明
### 推荐状态
| 状态值 | 含义 |
|--------|------|
| true | 启用 |
| false | 禁用 |
---
## 业务规则
### 团课推荐管理
1. **创建推荐**:团课ID为必填项,且必须是有效的团课
2. **优先级排序**:获取启用的推荐列表时,按优先级从高到低排序
3. **删除推荐**:采用软删除机制,数据保留可恢复
4. **状态管理**:支持启用/禁用推荐状态,禁用的推荐不会在推荐列表中显示
---
## 附录:错误响应格式
所有接口的错误响应统一格式:
```json
{
"success": false,
"message": "错误描述信息"
}
```
---
*文档结束*
+87
View File
@@ -0,0 +1,87 @@
一、基础有氧与热身(难度 1-3
主要是低冲击、低技巧,用于建立运动基础。
慢走/椭圆机轻松模式:1(几乎无难度,适合所有人)
固定自行车(低阻力):2(注意座椅高度调节即可)
跑步机慢跑:3(需要基本协调性,膝盖有压力)
跳绳(连续基础跳):3(需要手脚配合,心肺要求明显)
二、固定器械训练(难度 2-5
轨迹固定,主要考验力量和耐力,技巧要求低。
坐姿腿屈伸/腿弯举:2(很容易找到发力感)
坐姿推胸机:3(需注意肩胛后收,避免耸肩)
高位下拉(坐姿):3(需控制不要过度后仰)
史密斯机深蹲:4(轨迹固定,但需保持核心稳定)
蝴蝶机夹胸:3(易用肘关节代偿,需锁定肩关节)
三、自重基础动作(难度 3-7
需要一定的力量-体重比和身体控制能力。
平板支撑:3(耐力考验,技巧低)
跪姿俯卧撑:3(上肢力量较弱者首选)
标准俯卧撑:5(需核心收紧,身体成直线)
引体向上(弹力带辅助):6(背部和手臂力量要求高)
标准引体向上:8(力量-体重比极高,多数男性无法完成1次)
徒手深蹲:3(注意膝盖方向与背部直立)
单腿深蹲(手枪蹲):8(需要极高下肢力量、柔韧性和平衡)
四、自由重量杠铃/哑铃(难度 5-9)
技巧风险最高,需要神经系统协调和长期动作打磨。
哑铃二头弯举:4(容易晃动借力,但较安全)
哑铃侧平举:5(极易用斜方肌代偿,真正练到三角肌中束很难)
杠铃卧推:7(肩关节压力大,起桥、沉肩、稳定手腕均有技巧,有压伤风险)
杠铃深蹲(颈后):8(全身协调性、核心抗压、杠位放置、呼吸模式,学习曲线陡峭)
传统硬拉:9(风险极高,需要精确的脊柱中立、髋铰链、背阔肌收紧,错误时伤腰)
高翻/抓举(奥运举重):10(需要爆发力、柔韧、精准衔接,非数月训练不能掌握)
五、高强度与爆发力(难度 6-10
对心肺、神经系统和恢复能力要求极高。
波比跳(标准版):6(连续做时心肺压力极大)
冲刺跑(短跑):7(对腘绳肌和脚踝爆发力要求高)
跳箱(合理高度):6(需要落地缓冲技巧)
负重雪橇推:6(主要考验腿部耐力和意志力)
双力臂(引体向上后翻腕上杠):9(需要爆发引体 + 极高相对力量)
六、柔韧与平衡类(难度 3-8
考验本体感觉和关节活动度。
静态拉伸(坐姿体前屈):2(无风险,但需要坚持)
瑜伽下犬式:3(常见,但需背部与手臂对齐)
单腿罗马尼亚硬拉(徒手):6(极考验平衡和髋稳定)
全深蹲(脚跟贴地,亚洲蹲):5(踝关节灵活度限制多数人)
竖叉/横叉:8(需要数月甚至数年拉伸)
+129
View File
@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-manage-api</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>gym-auth</artifactId>
<packaging>jar</packaging>
<name>Gym Auth</name>
<description>Phone Authentication Module - Phone Number Login Services</description>
<dependencies>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-db</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-sys</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-member</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-commons</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.25</version>
</dependency>
<!-- 阿里云号码认证服务(新版SDK -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dypnsapi20170525</artifactId>
<version>1.0.6</version>
</dependency>
<!-- 阿里云一键登录服务(旧版SDK,保留兼容) -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-dypnsapi</artifactId>
<version>1.2.12</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.6.0</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
<version>2.0.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<executions>
<execution>
<id>default-jar</id>
<phase>package</phase>
<goals>
<goal>jar</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>21</source>
<target>21</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,7 @@
package cn.novalon.gym.manage.auth.config;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AuthConfig {
}
@@ -0,0 +1,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;
}
@@ -0,0 +1,47 @@
package cn.novalon.gym.manage.auth.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* 阿里云短信配置属性类
*
* @author auto-generated
* @date 2026-06-20
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "alibaba.cloud.sms")
public class SmsProperties {
/**
* 访问密钥ID
*/
private String accessKeyId;
/**
* 访问密钥密钥
*/
private String accessKeySecret;
/**
* 短信签名名称
*/
private String signName;
/**
* 短信模板CODE
*/
private String templateCode;
/**
* 短信验证码长度(默认6位)
*/
private int codeLength = 6;
/**
* 短信验证码有效期(秒,默认300秒=5分钟)
*/
private long codeExpireSeconds = 300;
}
@@ -0,0 +1,26 @@
package cn.novalon.gym.manage.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 手机号验证码登录请求DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PhoneCodeLoginDto {
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
@NotBlank(message = "验证码不能为空")
@Pattern(regexp = "^\\d{4,6}$", message = "验证码格式不正确")
private String code;
}
@@ -0,0 +1,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;
}
@@ -0,0 +1,22 @@
package cn.novalon.gym.manage.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 发送短信验证码请求DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SendCodeRequest {
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
}
@@ -0,0 +1,25 @@
package cn.novalon.gym.manage.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 短信验证码登录请求DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SmsLoginDto {
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
@NotBlank(message = "验证码不能为空")
private String code;
}
@@ -0,0 +1,59 @@
package cn.novalon.gym.manage.auth.handler;
import cn.novalon.gym.manage.auth.dto.PhoneCodeLoginDto;
import cn.novalon.gym.manage.auth.dto.PhoneLoginDto;
import cn.novalon.gym.manage.auth.dto.SendCodeRequest;
import cn.novalon.gym.manage.auth.service.PhoneAuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.util.Map;
@Slf4j
@Component
@RequiredArgsConstructor
@Tag(name = "手机号认证", description = "手机号一键登录与验证码登录")
public class PhoneAuthHandler {
private final PhoneAuthService phoneAuthService;
@Operation(summary = "手机号一键登录", description = "使用uniapp官方运营商认证,直接手机号登录或注册")
public Mono<ServerResponse> oneClickLogin(ServerRequest request) {
log.info("收到手机号一键登录请求");
return request.bodyToMono(PhoneLoginDto.class)
.flatMap(phoneAuthService::oneClickLogin)
.flatMap(response -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(response));
}
@Operation(summary = "发送短信验证码", description = "使用阿里云发送短信验证码")
public Mono<ServerResponse> sendSmsCode(ServerRequest request) {
log.info("收到发送短信验证码请求");
return request.bodyToMono(SendCodeRequest.class)
.flatMap(req -> phoneAuthService.sendSmsCode(req.getPhone()))
.flatMap(success -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("success", success, "message", success ? "验证码发送成功" : "验证码发送失败")));
}
@Operation(summary = "手机号验证码登录", description = "使用阿里云短信验证码登录或注册")
public Mono<ServerResponse> codeLogin(ServerRequest request) {
log.info("收到手机号验证码登录请求");
return request.bodyToMono(PhoneCodeLoginDto.class)
.flatMap(phoneAuthService::codeLogin)
.flatMap(response -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(response));
}
}
@@ -0,0 +1,37 @@
package cn.novalon.gym.manage.auth.service;
import cn.novalon.gym.manage.auth.dto.PhoneLoginDto;
import cn.novalon.gym.manage.auth.dto.PhoneCodeLoginDto;
import cn.novalon.gym.manage.auth.vo.PhoneLoginVO;
import reactor.core.publisher.Mono;
/**
* 手机号认证服务接口
*/
public interface PhoneAuthService {
/**
* 手机号一键登录(uniapp官方运营商认证)
* 已注册则直接登录,未注册则自动注册后登录
*
* @param request 登录请求
* @return 登录响应
*/
Mono<PhoneLoginVO> oneClickLogin(PhoneLoginDto request);
/**
* 发送短信验证码(阿里云)
*
* @param phone 手机号
* @return 是否发送成功
*/
Mono<Boolean> sendSmsCode(String phone);
/**
* 手机号验证码登录(阿里云)
*
* @param request 登录请求
* @return 登录响应
*/
Mono<PhoneLoginVO> codeLogin(PhoneCodeLoginDto request);
}
@@ -0,0 +1,37 @@
package cn.novalon.gym.manage.auth.service;
import reactor.core.publisher.Mono;
/**
* 短信服务接口
*
* @author auto-generated
* @date 2026-06-20
*/
public interface SmsService {
/**
* 发送短信验证码
*
* @param phone 手机号
* @return 发送结果
*/
Mono<Boolean> sendVerificationCode(String phone);
/**
* 验证短信验证码
*
* @param phone 手机号
* @param code 验证码
* @return 验证结果
*/
Mono<Boolean> verifyCode(String phone, String code);
/**
* 获取验证码(用于测试或特殊场景)
*
* @param phone 手机号
* @return 验证码
*/
Mono<String> getVerificationCode(String phone);
}
@@ -0,0 +1,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;
}
}
}
@@ -0,0 +1,198 @@
package cn.novalon.gym.manage.auth.service.impl;
import cn.novalon.gym.manage.auth.config.SmsProperties;
import cn.novalon.gym.manage.auth.service.SmsService;
import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
import cn.novalon.gym.manage.common.util.RedisUtil;
import com.aliyuncs.CommonRequest;
import com.aliyuncs.CommonResponse;
import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.exceptions.ClientException;
import com.aliyuncs.http.MethodType;
import com.aliyuncs.profile.DefaultProfile;
import com.aliyuncs.profile.IClientProfile;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
@Slf4j
@Service
@RequiredArgsConstructor
public class SmsServiceImpl implements SmsService {
private static final long SEND_INTERVAL_SECONDS = 60;
private static final long CODE_EXPIRE_SECONDS = 300;
private final SmsProperties smsProperties;
private final RedisUtil redisUtil;
private final ObjectMapper objectMapper;
@Override
public Mono<Boolean> sendVerificationCode(String phone) {
log.info("发送短信验证码, phone: {}", phone);
String rateLimitKey = RedisKeyConstants.SMS_CODE + phone + ":rate_limit";
return redisUtil.get(rateLimitKey, Long.class)
.defaultIfEmpty(0L)
.flatMap(lastSendTime -> {
long currentTime = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);
if (currentTime - lastSendTime < SEND_INTERVAL_SECONDS) {
long remainingSeconds = SEND_INTERVAL_SECONDS - (currentTime - lastSendTime);
log.warn("发送频率限制, phone: {}, 剩余时间: {}秒", phone, remainingSeconds);
return Mono.just(false);
}
return Mono.fromCallable(() -> {
try {
IAcsClient client = createClient();
CommonRequest request = new CommonRequest();
request.setSysMethod(MethodType.POST);
request.setSysDomain("dypnsapi.aliyuncs.com");
request.setSysVersion("2017-05-25");
request.setSysAction("SendSmsVerifyCode");
request.putQueryParameter("PhoneNumber", phone);
request.putQueryParameter("SignName", smsProperties.getSignName());
request.putQueryParameter("TemplateCode", smsProperties.getTemplateCode());
request.putQueryParameter("TemplateParam", "{\"code\":\"##code##\",\"min\":\"5\"}");
request.putQueryParameter("ReturnVerifyCode", "true");
log.info("阿里云号码认证请求参数 - signName: {}, templateCode: {}, templateParam: {}",
smsProperties.getSignName(),
smsProperties.getTemplateCode(),
"{\"code\":\"##code##\",\"min\":\"5\"}");
CommonResponse response = client.getCommonResponse(request);
String responseData = response.getData();
log.info("阿里云号码认证原始响应: {}", responseData);
JsonNode jsonNode = objectMapper.readTree(responseData);
JsonNode codeNode = jsonNode.get("Code");
if (codeNode == null) {
log.error("阿里云响应中找不到Code字段, 原始响应: {}", responseData);
return false;
}
String code = codeNode.asText();
if ("OK".equals(code)) {
JsonNode requestIdNode = jsonNode.get("RequestId");
String requestId = requestIdNode != null ? requestIdNode.asText() : "unknown";
log.info("短信验证码发送成功, phone: {}, requestId: {}", phone, requestId);
// 提取验证码并存入Redis
JsonNode modelNode = jsonNode.get("Model");
if (modelNode != null) {
JsonNode verifyCodeNode = modelNode.get("VerifyCode");
if (verifyCodeNode != null) {
String verifyCode = verifyCodeNode.asText();
String smsCodeKey = RedisKeyConstants.SMS_CODE + phone;
redisUtil.setWithExpire(smsCodeKey, verifyCode, CODE_EXPIRE_SECONDS).subscribe();
log.info("验证码已存入Redis, phone: {}, key: {}, expire: {}秒", phone, smsCodeKey, CODE_EXPIRE_SECONDS);
} else {
log.warn("响应中未找到Model.VerifyCode字段, 原始响应: {}", responseData);
}
} else {
log.warn("响应中未找到Model字段, 原始响应: {}", responseData);
}
redisUtil.setWithExpire(rateLimitKey, currentTime, SEND_INTERVAL_SECONDS).subscribe();
return true;
}
JsonNode messageNode = jsonNode.get("Message");
String message = messageNode != null ? messageNode.asText() : "unknown";
log.error("短信验证码发送失败, phone: {}, code: {}, message: {}", phone, code, message);
return false;
} catch (Exception e) {
log.error("发送短信验证码异常, phone: {}, 异常信息: {}", phone, e.getMessage(), e);
return false;
}
});
});
}
@Override
public Mono<Boolean> verifyCode(String phone, String code) {
log.info("验证短信验证码, phone: {}", phone);
return Mono.fromCallable(() -> {
try {
IAcsClient client = createClient();
CommonRequest request = new CommonRequest();
request.setSysMethod(MethodType.POST);
request.setSysDomain("dypnsapi.aliyuncs.com");
request.setSysVersion("2017-05-25");
request.setSysAction("CheckSmsVerifyCode");
request.putQueryParameter("PhoneNumber", phone);
request.putQueryParameter("VerifyCode", code);
log.info("阿里云号码认证核验参数 - phone: {}, code: {}", phone, code);
CommonResponse response = client.getCommonResponse(request);
String responseData = response.getData();
log.info("阿里云号码认证核验原始响应: {}", responseData);
JsonNode jsonNode = objectMapper.readTree(responseData);
JsonNode codeNode = jsonNode.get("Code");
if (codeNode == null) {
log.error("阿里云核验响应中找不到Code字段, 原始响应: {}", responseData);
return false;
}
String responseCode = codeNode.asText();
JsonNode modelNode = jsonNode.get("Model");
JsonNode verifyResultNode = modelNode != null ? modelNode.get("VerifyResult") : null;
boolean verifyResult = verifyResultNode != null && "PASS".equals(verifyResultNode.asText());
if ("OK".equals(responseCode) && verifyResult) {
log.info("验证码验证成功, phone: {}", phone);
return true;
}
JsonNode messageNode = jsonNode.get("Message");
String message = messageNode != null ? messageNode.asText() : "unknown";
log.warn("验证码验证失败, phone: {}, code: {}, message: {}, result: {}",
phone, responseCode, message, verifyResult);
return false;
} catch (Exception e) {
log.error("验证短信验证码异常, phone: {}, 异常信息: {}", phone, e.getMessage(), e);
return false;
}
});
}
@Override
public Mono<String> getVerificationCode(String phone) {
try {
String cacheKey = RedisKeyConstants.SMS_CODE + phone;
return redisUtil.get(cacheKey, String.class);
} catch (Exception e) {
log.error("获取验证码异常, phone: {}", phone, e);
return Mono.empty();
}
}
private IAcsClient createClient() throws ClientException {
IClientProfile profile = DefaultProfile.getProfile(
"cn-hangzhou",
smsProperties.getAccessKeyId(),
smsProperties.getAccessKeySecret());
DefaultProfile.addEndpoint("cn-hangzhou", "cn-hangzhou", "Dypnsapi", "dypnsapi.aliyuncs.com");
return new DefaultAcsClient(profile);
}
}
@@ -0,0 +1,36 @@
package cn.novalon.gym.manage.auth.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 手机号一键登录响应VO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PhoneLoginVO {
private Long memberId;
private String memberNo;
private String phone;
private String accessToken;
private String refreshToken;
private Integer expiresIn;
private Boolean isNewUser;
private Boolean needCompleteInfo;
private String nickname;
private String avatar;
}
@@ -0,0 +1,5 @@
cn.novalon.gym.manage.auth.handler.PhoneAuthHandler
cn.novalon.gym.manage.auth.service.impl.PhoneAuthServiceImpl
cn.novalon.gym.manage.auth.service.impl.SmsServiceImpl
cn.novalon.gym.manage.auth.config.AuthConfig
cn.novalon.gym.manage.auth.config.SmsProperties
+5
View File
@@ -118,6 +118,11 @@
<artifactId>hutool-all</artifactId> <artifactId>hutool-all</artifactId>
<version>5.8.25</version> <version>5.8.25</version>
</dependency> </dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency> <dependency>
<groupId>com.google.zxing</groupId> <groupId>com.google.zxing</groupId>
<artifactId>core</artifactId> <artifactId>core</artifactId>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<FindBugsFilter>
<Match>
<Class name="~.*\.entity\..*" />
</Match>
<Match>
<Class name="~.*\.dto\..*" />
</Match>
<Match>
<Class name="~.*\.converter\..*" />
</Match>
</FindBugsFilter>
@@ -0,0 +1,43 @@
package cn.novalon.gym.manage.checkIn.config;
import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.HandlerMapping;
import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping;
import org.springframework.web.reactive.socket.WebSocketHandler;
import org.springframework.web.reactive.socket.server.support.WebSocketHandlerAdapter;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class WebSocketConfig {
@Autowired
private MyWebSocketHandler myWebSocketHandler;
/**
* 注册 WebSocket 路由映射
* 路径对应前端连接的 ws://xxx/webSocket/checkIn
*/
@Bean
public HandlerMapping webSocketMapping() {
Map<String, WebSocketHandler> map = new HashMap<>();
map.put("/webSocket/checkIn", myWebSocketHandler);
SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping();
mapping.setUrlMap(map);
mapping.setOrder(10); // 设置优先级
return mapping;
}
/**
* 注册 WebSocket 处理器适配器(必须)
*/
@Bean
public WebSocketHandlerAdapter handlerAdapter() {
return new WebSocketHandlerAdapter();
}
}
@@ -2,6 +2,8 @@ package cn.novalon.gym.manage.checkIn.handler;
import cn.novalon.gym.manage.checkIn.service.impl.CheckServiceImpl; import cn.novalon.gym.manage.checkIn.service.impl.CheckServiceImpl;
import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler; import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
import cn.novalon.gym.manage.checkIn.vo.SignInRecordVO;
import cn.novalon.gym.manage.checkIn.vo.SignInStatsVO;
import cn.novalon.gym.manage.sys.util.AuthUtil; import cn.novalon.gym.manage.sys.util.AuthUtil;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -12,6 +14,7 @@ import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
@@ -36,6 +39,7 @@ public class CheckInHandler {
public Mono<ServerResponse> checkIn(ServerRequest request) { public Mono<ServerResponse> checkIn(ServerRequest request) {
Long memberId = authUtil.getMemberIdOrThrow(request); Long memberId = authUtil.getMemberIdOrThrow(request);
return request.bodyToMono(Map.class) return request.bodyToMono(Map.class)
.flatMap(body -> { .flatMap(body -> {
String qrContent = (String) body.get("qrContent"); String qrContent = (String) body.get("qrContent");
@@ -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;
@@ -19,8 +21,6 @@ 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.MemberCardRecordRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRepository; import cn.novalon.gym.manage.member.repository.MemberCardRepository;
import cn.hutool.extra.qrcode.QrCodeUtil;
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;
@@ -52,19 +52,13 @@ public class CheckServiceImpl implements ICheckInService {
@Override @Override
public Mono<QRCodeVo> getQRCode(Long memberId) { public Mono<QRCodeVo> getQRCode(Long memberId) {
log.info("开始查询会员信息, memberId: {}", memberId); log.info("开始生成会员签到二维码, memberId: {}", memberId);
return findValidMemberCard(memberId)
.flatMap(cardRecord -> {
log.info("会员信息查询完成, memberCardRecordId: {}", cardRecord.getMemberCardRecordId());
log.info("开始生成二维码");
String qrContent = QRRedisKey.generateQrcodeContent(); String qrContent = QRRedisKey.generateQrcodeContent();
Map<String, Object> redisMap = new HashMap<>(); Map<String, Object> redisMap = new HashMap<>();
redisMap.put("qrContent", qrContent); redisMap.put("qrContent", qrContent);
redisMap.put("isUsed", false); redisMap.put("isUsed", false);
redisMap.put("memberId", memberId); redisMap.put("memberId", memberId);
redisMap.put("memberCardRecordId", cardRecord.getMemberCardRecordId());
return redisUtil.setWithExpire( return redisUtil.setWithExpire(
RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(), RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(),
@@ -76,8 +70,6 @@ public class CheckServiceImpl implements ICheckInService {
BeanUtil.copyProperties(qrCodeConfig, QrConfig.class), "png"); BeanUtil.copyProperties(qrCodeConfig, QrConfig.class), "png");
return new QRCodeVo(qrCodeBase64, false, qrContent, qrCodeConfig.getWidth(), qrCodeConfig.getHeight(), LocalDate.now()); return new QRCodeVo(qrCodeBase64, false, qrContent, qrCodeConfig.getWidth(), qrCodeConfig.getHeight(), LocalDate.now());
})); }));
})
.switchIfEmpty(Mono.error(new RuntimeException("该会员没有可用的会员卡")));
} }
@Override @Override
@@ -113,7 +105,8 @@ public class CheckServiceImpl implements ICheckInService {
} }
log.info("二维码匹配成功,memberId: {}", memberId); log.info("二维码匹配成功,memberId: {}", memberId);
Long memberCardRecordId = ((Number) map.get("memberCardRecordId")).longValue(); Long memberCardRecordId = map.get("memberCardRecordId") != null
? ((Number) map.get("memberCardRecordId")).longValue() : null;
return processCheckIn(memberId, memberCardRecordId, map, qrContent); return processCheckIn(memberId, memberCardRecordId, map, qrContent);
} else { } else {
@@ -145,54 +138,23 @@ public class CheckServiceImpl implements ICheckInService {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
// 发送实时进度通知 // 发送实时进度通知
MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_CARD", "正在验证会员卡..."); MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_BOOKING", "正在检查预约信息...");
return memberCardRecordRepository.findById(memberCardRecordId)
.switchIfEmpty(Mono.defer(() -> {
MyWebSocketHandler.sendFailure(qrContent, "会员卡记录不存在");
return Mono.error(new RuntimeException("会员卡记录不存在"));
}))
.flatMap(cardRecord -> {
if (!"ACTIVE".equals(cardRecord.getStatus().name())) {
MyWebSocketHandler.sendFailure(qrContent, "会员卡状态不正确");
return Mono.error(new RuntimeException("会员卡状态不正确"));
}
if (cardRecord.getExpireTime() != null && cardRecord.getExpireTime().isBefore(now)) {
MyWebSocketHandler.sendFailure(qrContent, "会员卡已过期");
return Mono.error(new RuntimeException("会员卡已过期"));
}
// 发送实时进度通知
MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_BOOKING", "会员卡验证通过,正在检查预约信息...");
// 检查是否有需要签到的团课预约 // 检查是否有需要签到的团课预约
return validateBooking(memberId, now) return validateBooking(memberId, now)
.then(memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(cardRecord.getMemberCardId()) .then(Mono.defer(() -> {
.switchIfEmpty(Mono.defer(() -> {
MyWebSocketHandler.sendFailure(qrContent, "会员卡类型不存在");
return Mono.error(new RuntimeException("会员卡类型不存在"));
}))
.flatMap(card -> {
// 发送实时进度通知
MyWebSocketHandler.sendProgress(qrContent, "DEDUCT_USAGE", "正在扣减会员卡次数...");
return deductCardUsage(cardRecord, card)
.flatMap(updatedRecord -> {
redisMap.put("isUsed", true); redisMap.put("isUsed", true);
redisMap.put("checkInTime", now.format(DATE_FORMATTER)); redisMap.put("checkInTime", now.format(DATE_FORMATTER));
return saveSignInRecord(memberId, cardRecord.getMemberCardRecordId(), card.getMemberCardId()) return saveSignInRecord(memberId, null, null)
.then(redisUtil.set(RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(), redisMap)) .then(redisUtil.set(RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(), redisMap))
.then(Mono.defer(() -> { .then(Mono.defer(() -> {
String successMsg = buildSuccessResponse(now); String successMsg = buildSuccessResponse(now);
MyWebSocketHandler.sendSuccess(qrContent, memberId, now.format(DATE_FORMATTER)); MyWebSocketHandler.sendSuccess(qrContent, memberId, now.format(DATE_FORMATTER));
log.info("签到成功, memberId: {}, cardRecordId: {}", memberId, memberCardRecordId); log.info("签到成功, memberId: {}", memberId);
return Mono.just(successMsg); return Mono.just(successMsg);
})); }));
});
})); }));
});
} }
/** /**
+62
View File
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-manage-api</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>gym-coach</artifactId>
<packaging>jar</packaging>
<name>Gym Coach</name>
<description>Coach Management Module - Course Start/End, Violation Tracking</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-groupCourse</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</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.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,15 @@
package cn.novalon.gym.manage.coach.dao;
import cn.novalon.gym.manage.coach.entity.CoachViolationEntity;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
/**
* 教练违规记录 DAO
*
* @author 张翔
* @date 2026-07-20
*/
@Repository
public interface CoachViolationDao extends R2dbcRepository<CoachViolationEntity, Long> {
}
@@ -0,0 +1,61 @@
package cn.novalon.gym.manage.coach.entity;
import cn.novalon.gym.manage.db.entity.BaseEntity;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
/**
* 教练违规记录实体类 - 对应 coach_violation 表
*
* @author 张翔
* @date 2026-07-20
*/
@Table("coach_violation")
public class CoachViolationEntity extends BaseEntity {
@Column("coach_id")
private Long coachId;
@Column("course_id")
private Long courseId;
@Column("violation_time")
private LocalDateTime violationTime;
@Column("violation_reason")
private String violationReason;
public Long getCoachId() {
return coachId;
}
public void setCoachId(Long coachId) {
this.coachId = coachId;
}
public Long getCourseId() {
return courseId;
}
public void setCourseId(Long courseId) {
this.courseId = courseId;
}
public LocalDateTime getViolationTime() {
return violationTime;
}
public void setViolationTime(LocalDateTime violationTime) {
this.violationTime = violationTime;
}
public String getViolationReason() {
return violationReason;
}
public void setViolationReason(String violationReason) {
this.violationReason = violationReason;
}
}
@@ -0,0 +1,42 @@
package cn.novalon.gym.manage.coach.enums;
/**
* 团课预约状态枚举
*
* @author 张翔
* @date 2026-07-20
*/
public enum BookingStatus {
BOOKED("0", "已预约"),
CANCELLED("1", "已取消"),
ATTENDED("2", "已出席"),
ABSENT("3", "缺席"),
COACH_ABSENT("4", "教练缺席"),
LATE("5", "迟到");
private final String value;
private final String desc;
BookingStatus(String value, String desc) {
this.value = value;
this.desc = desc;
}
public String getValue() {
return value;
}
public String getDesc() {
return desc;
}
public static BookingStatus fromValue(String value) {
for (BookingStatus status : values()) {
if (status.value.equals(value)) {
return status;
}
}
return BOOKED;
}
}
@@ -0,0 +1,30 @@
package cn.novalon.gym.manage.coach.enums;
/**
* 教练违规原因枚举
*
* @author 张翔
* @date 2026-07-20
*/
public enum ViolationReason {
COACH_LATE("COACH_LATE", "教练迟到"),
COACH_ABSENT("COACH_ABSENT", "教练缺席"),
NOT_MANUAL_END("NOT_MANUAL_END", "教练未手动结课");
private final String value;
private final String desc;
ViolationReason(String value, String desc) {
this.value = value;
this.desc = desc;
}
public String getValue() {
return value;
}
public String getDesc() {
return desc;
}
}
@@ -0,0 +1,68 @@
package cn.novalon.gym.manage.coach.handler;
import cn.novalon.gym.manage.coach.service.CoachCourseService;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
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;
/**
* 教练开课/结课处理器
*
* @author 张翔
* @date 2026-07-20
*/
@Component
@Tag(name = "教练开课结课", description = "教练开课与结课操作")
public class CoachCourseHandler {
private static final Logger logger = LoggerFactory.getLogger(CoachCourseHandler.class);
private final CoachCourseService coachCourseService;
private final AuthUtil authUtil;
public CoachCourseHandler(CoachCourseService coachCourseService, AuthUtil authUtil) {
this.coachCourseService = coachCourseService;
this.authUtil = authUtil;
}
@Operation(summary = "教练开课", description = "教练手动开课,记录实际开课时间,若超时则判定迟到")
public Mono<ServerResponse> startCourse(ServerRequest request) {
Long courseId = Long.valueOf(request.pathVariable("courseId"));
Long coachId = authUtil.getMemberIdOrThrow(request);
return coachCourseService.startCourse(courseId, coachId)
.flatMap(result -> ServerResponse.ok().bodyValue(Map.of(
"message", "开课成功",
"status", result.getStatus(),
"actualStartTime", result.getActualStartTime() != null ? result.getActualStartTime().toString() : ""
)))
.onErrorResume(e -> {
logger.error("开课失败: {}", e.getMessage());
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
});
}
@Operation(summary = "教练结课", description = "教练手动结课,记录实际结课时间")
public Mono<ServerResponse> endCourse(ServerRequest request) {
Long courseId = Long.valueOf(request.pathVariable("courseId"));
Long coachId = authUtil.getMemberIdOrThrow(request);
return coachCourseService.endCourse(courseId, coachId)
.flatMap(result -> ServerResponse.ok().bodyValue(Map.of(
"message", "结课成功",
"status", result.getStatus(),
"actualEndTime", result.getActualEndTime() != null ? result.getActualEndTime().toString() : ""
)))
.onErrorResume(e -> {
logger.error("结课失败: {}", e.getMessage());
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
});
}
}
@@ -0,0 +1,118 @@
package cn.novalon.gym.manage.coach.handler;
import cn.novalon.gym.manage.coach.service.CoachCourseService;
import cn.novalon.gym.manage.sys.core.domain.SysUser;
import cn.novalon.gym.manage.sys.dto.request.CoachCreateRequest;
import cn.novalon.gym.manage.sys.dto.request.CoachUpdateRequest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Validator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.List;
import java.util.Map;
/**
* 教练管理处理器(从 manage-app 迁移至 gym-coach 模块)
*
* @author 张翔
* @date 2026-07-19
*/
@Component
@Tag(name = "教练管理", description = "教练相关操作")
public class CoachHandler {
private static final Logger logger = LoggerFactory.getLogger(CoachHandler.class);
private final CoachCourseService coachCourseService;
private final Validator validator;
public CoachHandler(CoachCourseService coachCourseService, Validator validator) {
this.coachCourseService = coachCourseService;
this.validator = validator;
}
@Operation(summary = "获取所有教练", description = "获取系统中所有教练列表")
public Mono<ServerResponse> getAllCoaches(ServerRequest request) {
return ServerResponse.ok()
.body(coachCourseService.getAllCoaches(), SysUser.class);
}
@Operation(summary = "创建教练", description = "创建新教练(创建用户并分配教练角色)")
public Mono<ServerResponse> createCoach(ServerRequest request) {
return request.bodyToMono(CoachCreateRequest.class)
.flatMap(req -> {
var violations = validator.validate(req);
if (!violations.isEmpty()) {
Map<String, String> errors = new HashMap<>();
violations.forEach(v -> errors.put(v.getPropertyPath().toString(), v.getMessage()));
return ServerResponse.badRequest().bodyValue(errors);
}
return coachCourseService.createCoach(
req.getUsername(), req.getPassword(),
req.getNickname(), req.getEmail(), req.getPhone())
.flatMap(user -> ServerResponse.status(HttpStatus.CREATED).bodyValue(user))
.onErrorResume(e -> {
logger.error("创建教练失败", e);
return ServerResponse.badRequest()
.bodyValue(Map.of("error", e.getMessage()));
});
});
}
@Operation(summary = "更新教练信息", description = "更新教练基本信息(昵称、邮箱、手机号)")
public Mono<ServerResponse> updateCoach(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(CoachUpdateRequest.class)
.flatMap(req -> coachCourseService.updateCoach(id, req.getNickname(), req.getEmail(), req.getPhone())
.flatMap(user -> ServerResponse.ok().bodyValue(user))
.switchIfEmpty(ServerResponse.notFound().build())
.onErrorResume(e -> {
logger.error("更新教练失败", e);
return ServerResponse.badRequest()
.bodyValue(Map.of("error", e.getMessage()));
}));
}
@Operation(summary = "禁用教练", description = "禁用教练账号,自动取消其所有非进行中的团课")
public Mono<ServerResponse> disableCoach(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return coachCourseService.disableCoach(id)
.then(ServerResponse.ok().bodyValue(Map.of("message", "教练已禁用")))
.onErrorResume(e -> {
logger.error("禁用教练失败: {}", e.getMessage());
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
});
}
@Operation(summary = "获取教练的团课", description = "获取指定教练教授的所有团课")
public Mono<ServerResponse> getCoachCourses(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return coachCourseService.getCoachCourses(id)
.collectList()
.flatMap(courses -> ServerResponse.ok().bodyValue(courses))
.switchIfEmpty(ServerResponse.ok().bodyValue(List.of()));
}
@Operation(summary = "获取教练违规次数统计", description = "获取所有教练的违规次数")
public Mono<ServerResponse> getViolationCounts(ServerRequest request) {
return coachCourseService.getViolationCounts()
.collectList()
.flatMap(counts -> ServerResponse.ok().bodyValue(counts));
}
@Operation(summary = "获取教练违规记录", description = "获取指定教练的违规记录")
public Mono<ServerResponse> getCoachViolations(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return coachCourseService.getCoachViolations(id)
.collectList()
.flatMap(violations -> ServerResponse.ok().bodyValue(violations))
.switchIfEmpty(ServerResponse.ok().bodyValue(List.of()));
}
}
@@ -0,0 +1,178 @@
package cn.novalon.gym.manage.coach.scheduler;
import cn.novalon.gym.manage.coach.enums.ViolationReason;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseBookingDao;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import cn.novalon.gym.manage.groupcourse.enums.CourseStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.time.LocalDateTime;
/**
* 教练课程定时调度器
*
* 功能:
* 1. 检查未手动开课的课程,超时标记为教练缺席(5)并记录违规
* 2. 检查未手动结课的课程,超时标记为自动结束(6)并记录违规
*
* @author 张翔
* @date 2026-07-20
*/
@Component
public class CoachCourseScheduler {
private static final Logger logger = LoggerFactory.getLogger(CoachCourseScheduler.class);
private static final long ONE_HOUR_MINUTES = 60;
private static final long END_GRACE_MINUTES = 10;
private final GroupCourseDao groupCourseDao;
private final GroupCourseBookingDao groupCourseBookingDao;
private final DatabaseClient databaseClient;
private final RedisUtil redisUtil;
public CoachCourseScheduler(GroupCourseDao groupCourseDao,
GroupCourseBookingDao groupCourseBookingDao,
DatabaseClient databaseClient,
RedisUtil redisUtil) {
this.groupCourseDao = groupCourseDao;
this.groupCourseBookingDao = groupCourseBookingDao;
this.databaseClient = databaseClient;
this.redisUtil = redisUtil;
}
/**
* 每分钟检查一次:
* - status=0(NORMAL) 的课程是否已过开课缺席阈值
* - status=3(IN_PROGRESS) 或 status=7(COACH_LATE) 的课程是否已过结课宽限期
*/
@Scheduled(fixedRate = 60000)
public void checkAndProcessCourses() {
logger.debug("教练课程调度器开始检查");
LocalDateTime now = LocalDateTime.now();
// 1. 检查未开课的课程
processAbsentCourses(now)
.subscribe(
count -> {
if (count > 0) {
logger.info("教练课程调度器:处理了 {} 门教练缺席课程", count);
invalidateCache();
}
},
error -> logger.error("教练课程调度器(缺席检查)执行失败:{}", error.getMessage(), error)
);
// 2. 检查未结课的课程
processAutoEndCourses(now)
.subscribe(
count -> {
if (count > 0) {
logger.info("教练课程调度器:处理了 {} 门自动结束课程", count);
invalidateCache();
}
},
error -> logger.error("教练课程调度器(自动结课)执行失败:{}", error.getMessage(), error)
);
}
/**
* 处理缺席课程:status=0 且已过开课缺席阈值
*/
private Mono<Long> processAbsentCourses(LocalDateTime now) {
return groupCourseDao.findByStatusAndStartTimeBefore(databaseClient, "0", now)
.filter(course -> isAbsentThresholdExceeded(course, now))
.flatMap(course -> markAsCoachAbsent(course, now))
.count();
}
/**
* 处理自动结课:status IN ('3','7') 且 end_time + 10分钟 已过
*/
private Mono<Long> processAutoEndCourses(LocalDateTime now) {
LocalDateTime endThreshold = now.minusMinutes(END_GRACE_MINUTES);
return groupCourseDao.findByStatusInAndEndTimeBefore(databaseClient,
new String[]{String.valueOf(CourseStatus.IN_PROGRESS.getValue()),
String.valueOf(CourseStatus.COACH_LATE.getValue())},
endThreshold)
.flatMap(course -> markAsAutoEnded(course, now))
.count();
}
/**
* 判断课程是否已过缺席阈值
*/
private boolean isAbsentThresholdExceeded(GroupCourseEntity course, LocalDateTime now) {
long courseDurationMinutes = Duration.between(course.getStartTime(), course.getEndTime()).toMinutes();
long minutesSinceStart = Duration.between(course.getStartTime(), now).toMinutes();
if (courseDurationMinutes >= ONE_HOUR_MINUTES) {
return minutesSinceStart > 30;
} else {
long thresholdB = Math.max(1, (long) (courseDurationMinutes * 0.25));
return minutesSinceStart > thresholdB;
}
}
/**
* 标记课程为教练缺席(5),更新预约记录为教练缺席(4),记录违规
*/
private Mono<GroupCourseEntity> markAsCoachAbsent(GroupCourseEntity course, LocalDateTime now) {
logger.info("课程 {} 教练缺席,标记为 COACH_ABSENT", course.getId());
return insertViolation(course.getCoachId(), course.getId(), now, ViolationReason.COACH_ABSENT)
.then(groupCourseDao.updateToCoachAbsent(course.getId(), now, now))
.then(groupCourseBookingDao.updateStatusByCourseId(course.getId(), "0", "4"))
.thenReturn(course);
}
/**
* 标记课程为自动结束(6),记录违规
*/
private Mono<GroupCourseEntity> markAsAutoEnded(GroupCourseEntity course, LocalDateTime now) {
logger.info("课程 {} 未手动结课,标记为 AUTO_ENDED", course.getId());
return insertViolation(course.getCoachId(), course.getId(), now, ViolationReason.NOT_MANUAL_END)
.then(groupCourseDao.updateToAutoEnded(course.getId(), now, now))
.thenReturn(course);
}
/**
* 插入违规记录(使用 DatabaseClient 直连)
*/
private Mono<Void> insertViolation(Long coachId, Long courseId, LocalDateTime violationTime,
ViolationReason reason) {
return databaseClient.sql("""
INSERT INTO coach_violation (coach_id, course_id, violation_time, violation_reason, created_at, updated_at)
VALUES (:coachId, :courseId, :violationTime, :reason, :now, :now)
""")
.bind("coachId", coachId)
.bind("courseId", courseId)
.bind("violationTime", violationTime)
.bind("reason", reason.getValue())
.bind("now", LocalDateTime.now())
.then();
}
/**
* 清除统计缓存和团课缓存 —— 调度器触发时,如有课程状态变更则必须及时失效
*/
private void invalidateCache() {
redisUtil.deleteByPattern("datacount:statistics:*").subscribe(
deleted -> logger.debug("调度器清除统计缓存,已删除 {} 条", deleted),
error -> logger.warn("调度器清除统计缓存失败: {}", error.getMessage())
);
redisUtil.deleteByPattern("group_course:*").subscribe(
deleted -> logger.debug("调度器清除团课缓存,已删除 {} 条", deleted),
error -> logger.warn("调度器清除团课缓存失败: {}", error.getMessage())
);
}
}
@@ -0,0 +1,357 @@
package cn.novalon.gym.manage.coach.service;
import cn.novalon.gym.manage.coach.dao.CoachViolationDao;
import cn.novalon.gym.manage.coach.entity.CoachViolationEntity;
import cn.novalon.gym.manage.coach.enums.ViolationReason;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.common.util.StatusConstants;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseBookingDao;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import cn.novalon.gym.manage.groupcourse.enums.CourseStatus;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.sys.core.domain.SysRole;
import cn.novalon.gym.manage.sys.core.domain.SysUser;
import cn.novalon.gym.manage.sys.core.domain.UserRole;
import cn.novalon.gym.manage.sys.core.repository.ISysRoleRepository;
import cn.novalon.gym.manage.sys.core.repository.ISysUserRepository;
import cn.novalon.gym.manage.sys.core.repository.IUserRoleRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Sort;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 教练课程服务(含教练管理 + 开课/结课逻辑 + 违规记录)
*
* @author 张翔
* @date 2026-07-20
*/
@Service
public class CoachCourseService {
private static final Logger logger = LoggerFactory.getLogger(CoachCourseService.class);
private static final String COACH_ROLE_NAME = "教练";
private static final long ONE_HOUR_MINUTES = 60;
private final ISysUserRepository userRepository;
private final ISysRoleRepository roleRepository;
private final IUserRoleRepository userRoleRepository;
private final IGroupCourseRepository groupCourseRepository;
private final IGroupCourseBookingRepository bookingRepository;
private final GroupCourseDao groupCourseDao;
private final GroupCourseBookingDao groupCourseBookingDao;
private final CoachViolationDao violationDao;
private final DatabaseClient databaseClient;
private final PasswordEncoder passwordEncoder;
private final RedisUtil redisUtil;
public CoachCourseService(ISysUserRepository userRepository,
ISysRoleRepository roleRepository,
IUserRoleRepository userRoleRepository,
IGroupCourseRepository groupCourseRepository,
IGroupCourseBookingRepository bookingRepository,
GroupCourseDao groupCourseDao,
GroupCourseBookingDao groupCourseBookingDao,
CoachViolationDao violationDao,
DatabaseClient databaseClient,
PasswordEncoder passwordEncoder,
RedisUtil redisUtil) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.userRoleRepository = userRoleRepository;
this.groupCourseRepository = groupCourseRepository;
this.bookingRepository = bookingRepository;
this.groupCourseDao = groupCourseDao;
this.groupCourseBookingDao = groupCourseBookingDao;
this.violationDao = violationDao;
this.databaseClient = databaseClient;
this.passwordEncoder = passwordEncoder;
this.redisUtil = redisUtil;
}
// ==================== 教练管理(从原 CoachService 迁移) ====================
public Flux<SysUser> getAllCoaches() {
return getCoachRoleId()
.flatMapMany(roleId -> userRoleRepository.findByRoleId(roleId)
.map(UserRole::getUserId)
.collectList()
.flatMapMany(userIds -> {
if (userIds.isEmpty()) {
return Flux.empty();
}
return Flux.fromIterable(userIds)
.flatMap(userRepository::findById)
.filter(user -> user.getDeletedAt() == null);
}));
}
public Mono<SysUser> createCoach(String username, String password, String nickname, String email, String phone) {
return getCoachRoleId().flatMap(coachRoleId -> {
SysUser user = new SysUser();
user.generateId();
user.setUsername(username);
user.setPassword(passwordEncoder.encode(password));
user.setNickname(nickname);
user.setEmail(email);
user.setPhone(phone);
user.setStatus(StatusConstants.ENABLED);
return userRepository.save(user)
.flatMap(saved -> {
UserRole userRole = new UserRole();
userRole.setUserId(saved.getId());
userRole.setRoleId(coachRoleId);
return userRoleRepository.save(userRole).thenReturn(saved);
});
});
}
public Mono<SysUser> updateCoach(Long id, String nickname, String email, String phone) {
return userRepository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("教练不存在")))
.flatMap(user -> {
if (nickname != null) user.setNickname(nickname);
if (email != null) user.setEmail(email);
if (phone != null) user.setPhone(phone);
user.setUpdatedAt(LocalDateTime.now());
return userRepository.update(user);
});
}
@Transactional(transactionManager = "connectionFactoryTransactionManager")
public Mono<Void> disableCoach(Long id) {
return userRepository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("教练不存在")))
.flatMap(user ->
groupCourseRepository.countByCoachIdAndStatus(id, 3L)
.flatMap(inProgressCount -> {
if (inProgressCount > 0) {
return Mono.error(new RuntimeException(
"该教练有 " + inProgressCount + " 门正在进行中的团课,无法禁用"));
}
return groupCourseRepository.cancelCoursesByCoachIdExceptStatus(id, 3L)
.then();
})
.then(Mono.defer(() -> {
user.setStatus(StatusConstants.DISABLED);
user.setUpdatedAt(LocalDateTime.now());
return userRepository.update(user).then();
}))
)
.then(invalidateStatisticsCache());
}
public Flux<GroupCourse> getCoachCourses(Long coachId) {
return groupCourseRepository.findByCoachId(coachId, Sort.by(Sort.Direction.DESC, "startTime"))
.flatMap(course ->
bookingRepository.countValidBookings(course.getId())
.map(count -> {
course.setCurrentMembers(count.intValue());
return course;
})
.defaultIfEmpty(course)
);
}
public Mono<Long> getCoachRoleId() {
return roleRepository.findByRoleName(COACH_ROLE_NAME)
.map(SysRole::getId)
.switchIfEmpty(Mono.error(new RuntimeException("教练角色未找到,请先执行数据库迁移")));
}
// ==================== 开课逻辑 ====================
/**
* 教练手动开课
* 判定逻辑:
* - 长课时(>=1h): 10分钟内正常,10~30分钟迟到,>30分钟拒绝
* - 短课时(<1h): 10%时长内正常,10%~25%迟到,>25%拒绝
*/
public Mono<GroupCourseEntity> startCourse(Long courseId, Long coachId) {
return groupCourseDao.findByIdIsAndDeletedAtIsNull(courseId)
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
.flatMap(course -> {
// 验证教练身份
if (!course.getCoachId().equals(coachId)) {
return Mono.error(new RuntimeException("您不是该课程的教练,无权开课"));
}
// 验证课程状态:只有 NORMAL(0) 可以开课
if (!CourseStatus.NORMAL.getValue().equals(course.getStatus())) {
return Mono.error(new RuntimeException("当前课程状态不允许开课,当前状态: " + course.getStatus()));
}
LocalDateTime now = LocalDateTime.now();
long courseDurationMinutes = Duration.between(course.getStartTime(), course.getEndTime()).toMinutes();
if (courseDurationMinutes >= ONE_HOUR_MINUTES) {
return handleLongCourseStart(course, now);
} else {
return handleShortCourseStart(course, now, courseDurationMinutes);
}
});
}
private Mono<GroupCourseEntity> handleLongCourseStart(GroupCourseEntity course, LocalDateTime now) {
long minutesSinceStart = Duration.between(course.getStartTime(), now).toMinutes();
if (minutesSinceStart < 0) {
return Mono.error(new RuntimeException("课程尚未到开课时间"));
}
if (minutesSinceStart <= 10) {
// 正常开课
return doStartCourse(course, now, CourseStatus.IN_PROGRESS, null);
}
if (minutesSinceStart <= 30) {
// 教练迟到
return recordViolation(course.getCoachId(), course.getId(), now, ViolationReason.COACH_LATE)
.then(doStartCourse(course, now, CourseStatus.COACH_LATE, ViolationReason.COACH_LATE));
}
// >30分钟,拒绝(调度器应已标记为缺席)
return Mono.error(new RuntimeException("已超过开课时间30分钟,无法开课"));
}
private Mono<GroupCourseEntity> handleShortCourseStart(GroupCourseEntity course, LocalDateTime now,
long courseDurationMinutes) {
long minutesSinceStart = Duration.between(course.getStartTime(), now).toMinutes();
long thresholdA = Math.max(1, (long) (courseDurationMinutes * 0.10));
long thresholdB = Math.max(1, (long) (courseDurationMinutes * 0.25));
if (minutesSinceStart < 0) {
return Mono.error(new RuntimeException("课程尚未到开课时间"));
}
if (minutesSinceStart <= thresholdA) {
// 正常开课
return doStartCourse(course, now, CourseStatus.IN_PROGRESS, null);
}
if (minutesSinceStart <= thresholdB) {
// 教练迟到
return recordViolation(course.getCoachId(), course.getId(), now, ViolationReason.COACH_LATE)
.then(doStartCourse(course, now, CourseStatus.COACH_LATE, ViolationReason.COACH_LATE));
}
// >thresholdB,拒绝
return Mono.error(new RuntimeException("已超过开课时间,无法开课"));
}
private Mono<GroupCourseEntity> doStartCourse(GroupCourseEntity course, LocalDateTime now,
CourseStatus newStatus, ViolationReason violationReason) {
course.setStatus(newStatus.getValue());
course.setActualStartTime(now);
course.setUpdatedAt(now);
return groupCourseDao.updateStartInfo(course.getId(), String.valueOf(newStatus.getValue()), now, now)
.then(groupCourseDao.findByIdIsAndDeletedAtIsNull(course.getId()))
.flatMap(entity -> invalidateStatisticsCache().thenReturn(entity));
}
// ==================== 结课逻辑 ====================
/**
* 教练手动结课
* 可在 IN_PROGRESS(3) 或 COACH_LATE(7) 状态下结课
* 必须在标注结课时间 + 10分钟内
*/
public Mono<GroupCourseEntity> endCourse(Long courseId, Long coachId) {
return groupCourseDao.findByIdIsAndDeletedAtIsNull(courseId)
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
.flatMap(course -> {
// 验证教练身份
if (!course.getCoachId().equals(coachId)) {
return Mono.error(new RuntimeException("您不是该课程的教练,无权结课"));
}
// 验证状态:IN_PROGRESS(3) 或 COACH_LATE(7)
Long status = course.getStatus();
if (!CourseStatus.IN_PROGRESS.getValue().equals(status)
&& !CourseStatus.COACH_LATE.getValue().equals(status)) {
return Mono.error(new RuntimeException("当前课程状态不允许结课,当前状态: " + status));
}
LocalDateTime now = LocalDateTime.now();
long minutesAfterEnd = Duration.between(course.getEndTime(), now).toMinutes();
if (minutesAfterEnd > 10) {
return Mono.error(new RuntimeException("已超过结课时间10分钟,请等待系统自动结课"));
}
course.setStatus(CourseStatus.ENDED.getValue());
course.setActualEndTime(now);
course.setUpdatedAt(now);
return groupCourseDao.updateEndInfo(course.getId(), String.valueOf(CourseStatus.ENDED.getValue()), now, now)
.then(groupCourseDao.findByIdIsAndDeletedAtIsNull(course.getId()))
.flatMap(entity -> invalidateStatisticsCache().thenReturn(entity));
});
}
// ==================== 违规记录 ====================
/**
* 记录教练违规行为(使用 DatabaseClient 直连,避免 R2DBC Entity 映射问题)
*/
public Mono<Void> recordViolation(Long coachId, Long courseId, LocalDateTime violationTime,
ViolationReason reason) {
LocalDateTime now = LocalDateTime.now();
return databaseClient.sql("""
INSERT INTO coach_violation (coach_id, course_id, violation_time, violation_reason, created_at, updated_at)
VALUES (:coachId, :courseId, :violationTime, :reason, :now, :now)
""")
.bind("coachId", coachId)
.bind("courseId", courseId)
.bind("violationTime", violationTime)
.bind("reason", reason.getValue())
.bind("now", now)
.then();
}
// ==================== 违规查询 ====================
/**
* 获取所有教练的违规次数统计
*/
public Flux<Map<String, Object>> getViolationCounts() {
return databaseClient.sql("""
SELECT coach_id, COUNT(*) AS count
FROM coach_violation
WHERE deleted_at IS NULL
GROUP BY coach_id
""")
.fetch()
.all();
}
/**
* 获取指定教练的违规记录
*/
public Flux<Map<String, Object>> getCoachViolations(Long coachId) {
return databaseClient.sql("""
SELECT v.*, gc.course_name
FROM coach_violation v
LEFT JOIN group_course gc ON v.course_id = gc.id AND gc.deleted_at IS NULL
WHERE v.coach_id = :coachId AND v.deleted_at IS NULL
ORDER BY v.violation_time DESC
""")
.bind("coachId", coachId)
.fetch()
.all();
}
/**
* 清除统计缓存和团课缓存 —— 课程状态变更后调用,返回 Mono 确保链式执行
*/
private Mono<Void> invalidateStatisticsCache() {
return redisUtil.deleteByPattern("datacount:statistics:*")
.then(redisUtil.deleteByPattern("group_course:*"))
.doOnNext(count -> {})
.then();
}
}
+20 -3
View File
@@ -67,7 +67,12 @@
<dependency> <dependency>
<groupId>org.apache.poi</groupId> <groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId> <artifactId>poi-ooxml</artifactId>
<version>5.2.5</version> </dependency>
<!-- Fix commons-compress version for POI compatibility -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
</dependency> </dependency>
<!-- Gym Modules --> <!-- Gym Modules -->
@@ -100,8 +105,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>
@@ -6,6 +6,7 @@ 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.Map;
/** /**
* 数据统计 DAO - 使用 DatabaseClient 执行跨表聚合查询 * 数据统计 DAO - 使用 DatabaseClient 执行跨表聚合查询
@@ -180,4 +181,68 @@ public class DataStatisticsDao {
this.count = count; this.count = count;
} }
} }
// ========== 教练相关统计 ==========
/** 统计教练总数(角色为"教练"的用户) */
public Mono<Long> countTotalCoaches() {
return databaseClient.sql("""
SELECT COUNT(*) FROM sys_user u
INNER JOIN user_role ur ON u.id = ur.user_id
INNER JOIN sys_role sr ON ur.role_id = sr.id
WHERE sr.role_name = '教练' AND u.deleted_at IS NULL
""")
.map(row -> row.get(0, Long.class))
.one();
}
/** 统计违规总数(时间范围) */
public Mono<Long> countTotalViolations(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("""
SELECT COUNT(*) FROM coach_violation
WHERE violation_time >= :startTime AND violation_time < :endTime AND deleted_at IS NULL
""")
.bind("startTime", startTime)
.bind("endTime", endTime)
.map(row -> row.get(0, Long.class))
.one();
}
/** 按违规类型统计次数 */
public Flux<Map<String, Object>> countViolationsByReason(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("""
SELECT violation_reason, COUNT(*) AS count
FROM coach_violation
WHERE violation_time >= :startTime AND violation_time < :endTime AND deleted_at IS NULL
GROUP BY violation_reason
""")
.bind("startTime", startTime)
.bind("endTime", endTime)
.fetch()
.all();
}
/** 统计有违规记录的教练数 */
public Mono<Long> countViolatedCoaches(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("""
SELECT COUNT(DISTINCT coach_id) FROM coach_violation
WHERE violation_time >= :startTime AND violation_time < :endTime AND deleted_at IS NULL
""")
.bind("startTime", startTime)
.bind("endTime", endTime)
.map(row -> row.get(0, Long.class))
.one();
}
/** 统计区间内开课的团课数 */
public Mono<Long> countCourses(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("""
SELECT COUNT(*) FROM group_course
WHERE start_time >= :startTime AND start_time < :endTime AND deleted_at IS NULL
""")
.bind("startTime", startTime)
.bind("endTime", endTime)
.map(row -> row.get(0, Long.class))
.one();
}
} }
@@ -0,0 +1,40 @@
package cn.novalon.gym.manage.datacount.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 教练统计数据
*
* @author 张翔
* @date 2026-07-20
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CoachStatistics {
/** 教练总数 */
private Long totalCoaches;
/** 违规总数 */
private Long totalViolations;
/** 迟到次数 */
private Long lateCount;
/** 缺席次数 */
private Long absentCount;
/** 未手动结课次数 */
private Long notManualEndCount;
/** 有违规记录的教练数 */
private Long violatedCoaches;
/** 统计区间内开课的团课数 */
private Long totalCourses;
}
@@ -59,6 +59,12 @@ public class DataStatistics {
public static final String WEEK = "WEEK"; public static final String WEEK = "WEEK";
/** 月统计 */ /** 月统计 */
public static final String MONTH = "MONTH"; public static final String MONTH = "MONTH";
/** 近30天 */
public static final String LAST_30_DAYS = "LAST_30_DAYS";
/** 近90天 */
public static final String LAST_90_DAYS = "LAST_90_DAYS";
/** 今年 */
public static final String YEAR = "YEAR";
private PeriodType() {} private PeriodType() {}
} }
@@ -37,6 +37,11 @@ public class StatisticsSummary {
*/ */
private SignInStatistics signInStatistics; private SignInStatistics signInStatistics;
/**
* 教练统计数据
*/
private CoachStatistics coachStatistics;
/** /**
* 统计数据生成时间 * 统计数据生成时间
*/ */
@@ -171,18 +171,47 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
return count != null ? count : 0L; return count != null ? count : 0L;
} }
private Mono<CoachStatistics> getCoachStatistics(StatisticsQuery query) {
LocalDateTime startTime = getStartTime(query);
LocalDateTime endTime = getEndTime(query);
Mono<Long> totalCoachesMono = dataStatisticsDao.countTotalCoaches();
Mono<Long> totalViolationsMono = dataStatisticsDao.countTotalViolations(startTime, endTime);
Mono<Long> violatedCoachesMono = dataStatisticsDao.countViolatedCoaches(startTime, endTime);
Mono<Long> totalCoursesMono = dataStatisticsDao.countCourses(startTime, endTime);
Mono<Map<String, Long>> violationByReasonMono = dataStatisticsDao.countViolationsByReason(startTime, endTime)
.collectMap(row -> (String) row.get("violation_reason"),
row -> ((Number) row.get("count")).longValue());
return Mono.zip(totalCoachesMono, totalViolationsMono, violatedCoachesMono, totalCoursesMono, violationByReasonMono)
.map(tuple -> {
Map<String, Long> reasonMap = tuple.getT5();
return CoachStatistics.builder()
.totalCoaches(tuple.getT1())
.totalViolations(tuple.getT2())
.lateCount(reasonMap.getOrDefault("COACH_LATE", 0L))
.absentCount(reasonMap.getOrDefault("COACH_ABSENT", 0L))
.notManualEndCount(reasonMap.getOrDefault("NOT_MANUAL_END", 0L))
.violatedCoaches(tuple.getT3())
.totalCourses(tuple.getT4())
.build();
});
}
@Override @Override
public Mono<StatisticsSummary> getStatisticsSummary(StatisticsQuery query) { public Mono<StatisticsSummary> getStatisticsSummary(StatisticsQuery query) {
Mono<MemberStatistics> memberStatsMono = getMemberStatistics(query); Mono<MemberStatistics> memberStatsMono = getMemberStatistics(query);
Mono<BookingStatistics> bookingStatsMono = getBookingStatistics(query); Mono<BookingStatistics> bookingStatsMono = getBookingStatistics(query);
Mono<SignInStatistics> signInStatsMono = getSignInStatistics(query); Mono<SignInStatistics> signInStatsMono = getSignInStatistics(query);
Mono<CoachStatistics> coachStatsMono = getCoachStatistics(query);
return Mono.zip(memberStatsMono, bookingStatsMono, signInStatsMono) return Mono.zip(memberStatsMono, bookingStatsMono, signInStatsMono, coachStatsMono)
.map(tuple -> StatisticsSummary.builder() .map(tuple -> StatisticsSummary.builder()
.statDate(LocalDateTime.now().toLocalDate().toString()) .statDate(LocalDateTime.now().toLocalDate().toString())
.memberStatistics(tuple.getT1()) .memberStatistics(tuple.getT1())
.bookingStatistics(tuple.getT2()) .bookingStatistics(tuple.getT2())
.signInStatistics(tuple.getT3()) .signInStatistics(tuple.getT3())
.coachStatistics(tuple.getT4())
.generatedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)) .generatedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
.build()); .build());
} }
@@ -458,13 +487,16 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
String periodType = query.getPeriodType(); String periodType = query.getPeriodType();
if (DataStatistics.PeriodType.WEEK.equals(periodType)) { if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
// 周统计:本周一
return today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay(); return today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay();
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) { } else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
// 月统计:本月第一天
return today.withDayOfMonth(1).atStartOfDay(); return today.withDayOfMonth(1).atStartOfDay();
} else if (DataStatistics.PeriodType.LAST_30_DAYS.equals(periodType)) {
return today.minusDays(29).atStartOfDay();
} else if (DataStatistics.PeriodType.LAST_90_DAYS.equals(periodType)) {
return today.minusDays(89).atStartOfDay();
} else if (DataStatistics.PeriodType.YEAR.equals(periodType)) {
return today.withDayOfYear(1).atStartOfDay();
} else { } else {
// 日统计:当天零点
return today.atStartOfDay(); return today.atStartOfDay();
} }
} }
@@ -478,13 +510,14 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
String periodType = query.getPeriodType(); String periodType = query.getPeriodType();
if (DataStatistics.PeriodType.WEEK.equals(periodType)) { if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
// 周统计:本周日 23:59:59
return today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).atTime(23, 59, 59); return today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) { } else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
// 月统计:本月最后一天 23:59:59
return today.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59); return today.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.LAST_30_DAYS.equals(periodType)
|| DataStatistics.PeriodType.LAST_90_DAYS.equals(periodType)
|| DataStatistics.PeriodType.YEAR.equals(periodType)) {
return LocalDateTime.now();
} else { } else {
// 日统计:当前时间
return LocalDateTime.now(); return LocalDateTime.now();
} }
} }
@@ -503,6 +536,15 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) { } else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
startTime = date.withDayOfMonth(1).atStartOfDay(); startTime = date.withDayOfMonth(1).atStartOfDay();
endTime = date.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59); endTime = date.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.LAST_30_DAYS.equals(periodType)) {
startTime = date.minusDays(29).atStartOfDay();
endTime = date.atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.LAST_90_DAYS.equals(periodType)) {
startTime = date.minusDays(89).atStartOfDay();
endTime = date.atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.YEAR.equals(periodType)) {
startTime = date.withDayOfYear(1).atStartOfDay();
endTime = date.atTime(23, 59, 59);
} else { } else {
startTime = date.atStartOfDay(); startTime = date.atStartOfDay();
endTime = date.plusDays(1).atStartOfDay(); endTime = date.plusDays(1).atStartOfDay();
+30 -2
View File
@@ -81,13 +81,41 @@
<artifactId>gym-member</artifactId> <artifactId>gym-member</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<!-- ZXing QR Code依赖 -->
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.5.3</version>
</dependency>
<!-- 阿里云OSS SDK -->
<!--
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>
-->
<!-- 文件管理模块(统一文件存储) -->
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-file</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies> </dependencies>
<build> <build>
<plugins> <plugins>
<plugin> <plugin>
<groupId>org.springframework.boot</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
</plugin> </plugin>
</plugins> </plugins>
</build> </build>
@@ -4,8 +4,10 @@ package cn.novalon.gym.manage.groupcourse.converter;
import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.bean.BeanUtil;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseBookingEntity; import cn.novalon.gym.manage.groupcourse.entity.GroupCourseBookingEntity;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity; import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseTypeEntity;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -124,4 +126,33 @@ public class GroupCourseConverter {
.map(this::toBookingEntity) .map(this::toBookingEntity)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
/**
* 将团课类型实体转换为领域模型
*/
public GroupCourseType toGroupCourseType(GroupCourseTypeEntity entity){
if(entity == null){
return null;
}
GroupCourseType groupCourseType = new GroupCourseType();
BeanUtil.copyProperties(entity, groupCourseType);
log.debug("转换团课类型实体到领域模型:typeId={}", entity.getId());
return groupCourseType;
}
/**
* 将团课类型领域模型转换为实体
*/
public GroupCourseTypeEntity toGroupCourseTypeEntity(GroupCourseType domain){
if(domain == null){
return null;
}
GroupCourseTypeEntity entity = new GroupCourseTypeEntity();
BeanUtil.copyProperties(domain, entity);
if (domain.getId() != null) {
entity.markNotNew();
}
log.debug("转换团课类型领域模型到实体:typeId={}", domain.getId());
return entity;
}
} }
@@ -0,0 +1,27 @@
package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.groupcourse.entity.CourseLabelEntity;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
public interface CourseLabelDao extends R2dbcRepository<CourseLabelEntity, Long> {
Mono<CourseLabelEntity> findByIdIsAndDeletedAtIsNull(Long id);
Flux<CourseLabelEntity> findAllByDeletedAtIsNull();
Flux<CourseLabelEntity> findByLabelNameContainingAndDeletedAtIsNull(String labelName);
Mono<CourseLabelEntity> findByLabelNameAndDeletedAtIsNull(String labelName);
@Modifying
@Query("UPDATE course_label SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
}
@@ -0,0 +1,38 @@
package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.groupcourse.entity.CourseTypeLabelEntity;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface CourseTypeLabelDao extends R2dbcRepository<CourseTypeLabelEntity, Long> {
Flux<CourseTypeLabelEntity> findByTypeIdAndDeletedAtIsNull(Long typeId);
Flux<CourseTypeLabelEntity> findByLabelIdAndDeletedAtIsNull(Long labelId);
Mono<CourseTypeLabelEntity> findByTypeIdAndLabelIdAndDeletedAtIsNull(Long typeId, Long labelId);
@Modifying
@Query("UPDATE course_type_label SET deleted_at = :deletedAt WHERE type_id = :typeId AND label_id = :labelId")
Mono<Integer> deleteByTypeIdAndLabelId(Long typeId, Long labelId, LocalDateTime deletedAt);
@Modifying
@Query("UPDATE course_type_label SET deleted_at = :deletedAt WHERE type_id = :typeId")
Mono<Integer> deleteByTypeId(Long typeId, LocalDateTime deletedAt);
@Modifying
@Query("DELETE FROM course_type_label WHERE type_id = :typeId AND label_id = :labelId")
Mono<Integer> physicalDeleteByTypeIdAndLabelId(Long typeId, Long labelId);
@Modifying
@Query("UPDATE course_type_label SET deleted_at = :deletedAt WHERE label_id = :labelId")
Mono<Integer> deleteByLabelId(Long labelId, LocalDateTime deletedAt);
}
@@ -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);
/** /**
* 查询会员是否有时间冲突的预约(状态为已预约且未取消) * 查询会员是否有时间冲突的预约(状态为已预约且未取消)
* 时间冲突条件:新课程的开始时间 < 已预约课程的结束时间 且 新课程的结束时间 > 已预约课程的开始时间 * 时间冲突条件:新课程的开始时间 < 已预约课程的结束时间 且 新课程的结束时间 > 已预约课程的开始时间
@@ -108,4 +114,11 @@ public interface GroupCourseBookingDao extends R2dbcRepository<GroupCourseBookin
@org.springframework.data.r2dbc.repository.Modifying @org.springframework.data.r2dbc.repository.Modifying
@org.springframework.data.r2dbc.repository.Query("UPDATE group_course_booking SET status = '3', updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL") @org.springframework.data.r2dbc.repository.Query("UPDATE group_course_booking SET status = '3', updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateToAbsent(Long id, java.time.LocalDateTime updatedAt); Mono<Integer> updateToAbsent(Long id, java.time.LocalDateTime updatedAt);
/**
* 批量更新某课程的预约状态(教练缺席场景)
*/
@org.springframework.data.r2dbc.repository.Modifying
@org.springframework.data.r2dbc.repository.Query("UPDATE group_course_booking SET status = :newStatus, updated_at = NOW() WHERE course_id = :courseId AND status = :oldStatus AND deleted_at IS NULL")
Mono<Integer> updateStatusByCourseId(Long courseId, String oldStatus, String newStatus);
} }
@@ -1,16 +1,20 @@
package cn.novalon.gym.manage.groupcourse.dao; package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity; import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.repository.Modifying; import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query; import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository; import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Repository @Repository
public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long> { public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long> {
@@ -29,11 +33,345 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
@Query("UPDATE group_course SET status = '1', updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL") @Query("UPDATE group_course SET status = '1', updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> cancelCourse(Long id, LocalDateTime updatedAt); Mono<Integer> cancelCourse(Long id, LocalDateTime updatedAt);
@Modifying
@Query("UPDATE group_course SET current_members = current_members + :delta, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateCurrentMembers(Long id, Integer delta, LocalDateTime updatedAt);
@Modifying @Modifying
@Query("UPDATE group_course SET deleted_at = :deletedAt WHERE id = :id") @Query("UPDATE group_course SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt); Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
// ---------- 教练相关 SQL 方法 ----------
@Modifying
@Query("UPDATE group_course SET status = :status, actual_start_time = :actualStartTime, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateStartInfo(Long id, String status, LocalDateTime actualStartTime, LocalDateTime updatedAt);
@Modifying
@Query("UPDATE group_course SET status = :status, actual_end_time = :actualEndTime, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateEndInfo(Long id, String status, LocalDateTime actualEndTime, LocalDateTime updatedAt);
@Modifying
@Query("UPDATE group_course SET status = '5', actual_end_time = :actualEndTime, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateToCoachAbsent(Long id, LocalDateTime actualEndTime, LocalDateTime updatedAt);
@Modifying
@Query("UPDATE group_course SET status = '6', actual_end_time = :actualEndTime, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateToAutoEnded(Long id, LocalDateTime actualEndTime, LocalDateTime updatedAt);
/**
* 查询指定状态且开始时间早于指定时间的课程(用于缺席检查)
*/
default Flux<GroupCourseEntity> findByStatusAndStartTimeBefore(DatabaseClient databaseClient, String status, LocalDateTime time) {
return databaseClient.sql("SELECT * FROM group_course WHERE status = :status AND start_time < :time AND deleted_at IS NULL")
.bind("status", status)
.bind("time", time)
.map((row, meta) -> mapRowToEntity(row))
.all();
}
/**
* 查询指定状态集合且结束时间早于指定时间的课程(用于自动结课检查)
*/
default Flux<GroupCourseEntity> findByStatusInAndEndTimeBefore(DatabaseClient databaseClient, String[] statuses, LocalDateTime time) {
return databaseClient.sql("SELECT * FROM group_course WHERE status = ANY(:statuses::varchar[]) AND end_time < :time AND deleted_at IS NULL")
.bind("statuses", statuses)
.bind("time", time)
.map((row, meta) -> mapRowToEntity(row))
.all();
}
Flux<GroupCourseEntity> findByCourseTypeAndDeletedAtIsNull(Long courseType);
// ==================== 教练相关查询 ====================
Flux<GroupCourseEntity> findByCoachIdAndDeletedAtIsNull(Long coachId);
Flux<GroupCourseEntity> findByCoachIdAndDeletedAtIsNull(Long coachId, Sort sort);
@Query("SELECT COUNT(*) FROM group_course WHERE coach_id = :coachId AND status = :status AND deleted_at IS NULL")
Mono<Long> countByCoachIdAndStatus(Long coachId, String status);
@Modifying
@Query("UPDATE group_course SET status = '1', updated_at = :updatedAt WHERE coach_id = :coachId AND status != :excludeStatus AND deleted_at IS NULL")
Mono<Integer> cancelCoursesByCoachIdExceptStatus(Long coachId, String excludeStatus, LocalDateTime updatedAt);
/**
* 多条件查询团课(使用 DatabaseClient 构建动态 SQL
*/
default Flux<GroupCourseEntity> searchGroupCourses(DatabaseClient databaseClient, GroupCourseQueryDto query) {
StringBuilder sql = new StringBuilder(
"SELECT gc.*, COALESCE(b.cnt, 0) AS current_members " +
"FROM group_course gc " +
"LEFT JOIN (SELECT course_id, COUNT(*) AS cnt FROM group_course_booking WHERE status = '0' GROUP BY course_id) b " +
"ON gc.id = b.course_id " +
"WHERE gc.deleted_at IS NULL");
List<String> conditions = new ArrayList<>();
// 默认不查询可预约团课(status = '0' 且未过期)
conditions.add("gc.status = '0'");
conditions.add("gc.end_time > NOW()");
// 1. 团课名称模糊查询
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
conditions.add("gc.course_name ILIKE :courseName");
}
// 2. 基于团课类型查询
if (query.getCourseType() != null) {
conditions.add("gc.course_type = :courseType");
}
// 3. 基于日期时间段查询
if (query.getStartDate() != null) {
conditions.add("gc.start_time >= :startDate");
}
if (query.getEndDate() != null) {
conditions.add("gc.start_time <= :endDate");
}
// 4. 基于早晨/下午/夜晚时间段查询
if (query.getTimePeriod() != null && !query.getTimePeriod().isEmpty()) {
switch (query.getTimePeriod().toLowerCase()) {
case "morning":
conditions.add("EXTRACT(HOUR FROM gc.start_time) >= 6 AND EXTRACT(HOUR FROM gc.start_time) < 12");
break;
case "afternoon":
conditions.add("EXTRACT(HOUR FROM gc.start_time) >= 12 AND EXTRACT(HOUR FROM gc.start_time) < 18");
break;
case "evening":
conditions.add("EXTRACT(HOUR FROM gc.start_time) >= 18 AND EXTRACT(HOUR FROM gc.start_time) < 24");
break;
default:
break;
}
}
sql.append(" AND ").append(String.join(" AND ", conditions));
// 5. 价格排序 / 6. 剩余名额最多排序
boolean hasPriceSort = query.getPriceSort() != null && !query.getPriceSort().isEmpty();
boolean hasRemainingMost = query.getRemainingMost() != null && query.getRemainingMost();
if (hasPriceSort || hasRemainingMost) {
sql.append(" ORDER BY");
List<String> orderClauses = new ArrayList<>();
if (hasRemainingMost) {
orderClauses.add(" (gc.max_members - COALESCE(b.cnt, 0)) DESC");
}
if (hasPriceSort) {
if ("asc".equalsIgnoreCase(query.getPriceSort())) {
orderClauses.add(" gc.stored_value_amount ASC");
} else if ("desc".equalsIgnoreCase(query.getPriceSort())) {
orderClauses.add(" gc.stored_value_amount DESC");
}
}
sql.append(String.join(",", orderClauses));
} else {
sql.append(" ORDER BY gc.start_time ASC");
}
// 分页
int page = query.getPage() != null ? query.getPage() : 0;
int size = query.getSize() != null ? query.getSize() : 10;
if (size < 1) size = 10;
if (size > 100) size = 100;
int offset = page * size;
sql.append(" LIMIT :limit OFFSET :offset");
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
spec = spec.bind("courseName", "%" + query.getCourseName() + "%");
}
if (query.getCourseType() != null) {
spec = spec.bind("courseType", query.getCourseType());
}
if (query.getStartDate() != null) {
spec = spec.bind("startDate", query.getStartDate());
}
if (query.getEndDate() != null) {
spec = spec.bind("endDate", query.getEndDate());
}
spec = spec.bind("limit", size);
spec = spec.bind("offset", offset);
return spec.map((row, meta) -> mapRowToEntity(row)).all();
}
/**
* 多条件查询团课总数
*/
default Mono<Long> countSearchGroupCourses(DatabaseClient databaseClient, GroupCourseQueryDto query) {
StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM group_course WHERE deleted_at IS NULL");
List<String> conditions = new ArrayList<>();
conditions.add("status = '0'");
conditions.add("end_time > NOW()");
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
conditions.add("course_name ILIKE :courseName");
}
if (query.getCourseType() != null) {
conditions.add("course_type = :courseType");
}
if (query.getStartDate() != null) {
conditions.add("start_time >= :startDate");
}
if (query.getEndDate() != null) {
conditions.add("start_time <= :endDate");
}
if (query.getTimePeriod() != null && !query.getTimePeriod().isEmpty()) {
switch (query.getTimePeriod().toLowerCase()) {
case "morning":
conditions.add("EXTRACT(HOUR FROM start_time) >= 6 AND EXTRACT(HOUR FROM start_time) < 12");
break;
case "afternoon":
conditions.add("EXTRACT(HOUR FROM start_time) >= 12 AND EXTRACT(HOUR FROM start_time) < 18");
break;
case "evening":
conditions.add("EXTRACT(HOUR FROM start_time) >= 18 AND EXTRACT(HOUR FROM start_time) < 24");
break;
default:
break;
}
}
sql.append(" AND ").append(String.join(" AND ", conditions));
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
spec = spec.bind("courseName", "%" + query.getCourseName() + "%");
}
if (query.getCourseType() != null) {
spec = spec.bind("courseType", query.getCourseType());
}
if (query.getStartDate() != null) {
spec = spec.bind("startDate", query.getStartDate());
}
if (query.getEndDate() != null) {
spec = spec.bind("endDate", query.getEndDate());
}
return spec.map((row, meta) -> row.get(0, Long.class)).one();
}
/**
* 分页查询团课(支持 keyword 和 status 过滤)
*/
default Flux<GroupCourseEntity> findByPageFiltered(DatabaseClient databaseClient, PageRequest pageRequest) {
StringBuilder sql = new StringBuilder(
"SELECT gc.*, COALESCE(b.cnt, 0) AS current_members " +
"FROM group_course gc " +
"LEFT JOIN (SELECT course_id, COUNT(*) AS cnt FROM group_course_booking WHERE status = '0' GROUP BY course_id) b " +
"ON gc.id = b.course_id " +
"WHERE gc.deleted_at IS NULL");
List<String> conditions = new ArrayList<>();
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
conditions.add("gc.course_name ILIKE :keyword");
}
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
conditions.add("gc.status = :status");
}
if (!conditions.isEmpty()) {
sql.append(" AND ").append(String.join(" AND ", conditions));
}
String sort = pageRequest.getSort() != null ? pageRequest.getSort() : "id";
String order = "desc".equalsIgnoreCase(pageRequest.getOrder()) ? "DESC" : "ASC";
sql.append(" ORDER BY gc.").append(sort).append(" ").append(order);
int size = pageRequest.getSize();
if (size < 1) size = 10;
if (size > 100) size = 100;
int offset = pageRequest.getPage() * size;
sql.append(" LIMIT :limit OFFSET :offset");
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
spec = spec.bind("keyword", "%" + pageRequest.getKeyword() + "%");
}
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
spec = spec.bind("status", pageRequest.getStatus());
}
spec = spec.bind("limit", size);
spec = spec.bind("offset", offset);
return spec.map((row, meta) -> mapRowToEntity(row)).all();
}
/**
* 分页查询团课总数(支持 keyword 和 status 过滤)
*/
default Mono<Long> countByPageFiltered(DatabaseClient databaseClient, PageRequest pageRequest) {
StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM group_course WHERE deleted_at IS NULL");
List<String> conditions = new ArrayList<>();
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
conditions.add("course_name ILIKE :keyword");
}
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
conditions.add("status = :status");
}
if (!conditions.isEmpty()) {
sql.append(" AND ").append(String.join(" AND ", conditions));
}
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
spec = spec.bind("keyword", "%" + pageRequest.getKeyword() + "%");
}
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
spec = spec.bind("status", pageRequest.getStatus());
}
return spec.map((row, meta) -> row.get(0, Long.class)).one();
}
/**
* 统计单门课程的有效预约人数(status='0'
*/
default Mono<Long> countValidBookings(DatabaseClient databaseClient, Long courseId) {
return databaseClient.sql("SELECT COUNT(*) FROM group_course_booking WHERE course_id = :id AND status = '0'")
.bind("id", courseId)
.map((row, meta) -> row.get(0, Long.class))
.one()
.defaultIfEmpty(0L);
}
/**
* 将数据库行映射为 GroupCourseEntity
*/
private GroupCourseEntity mapRowToEntity(io.r2dbc.spi.Row row) {
GroupCourseEntity entity = new GroupCourseEntity();
entity.setId(row.get("id", Long.class));
entity.setCourseName(row.get("course_name", String.class));
entity.setCoachId(row.get("coach_id", Long.class));
entity.setCourseType(row.get("course_type", Long.class));
entity.setStartTime(row.get("start_time", LocalDateTime.class));
entity.setEndTime(row.get("end_time", LocalDateTime.class));
entity.setMaxMembers(row.get("max_members", Integer.class));
Integer cm = row.get("current_members", Integer.class);
entity.setCurrentMembers(cm != null ? cm : 0);
String statusStr = row.get("status", String.class);
entity.setStatus(statusStr != null ? Long.parseLong(statusStr) : null);
entity.setLocation(row.get("location", String.class));
entity.setCoverImage(row.get("cover_image", String.class));
entity.setDescription(row.get("description", String.class));
entity.setStoredValueAmount(row.get("stored_value_amount", java.math.BigDecimal.class));
entity.setActualStartTime(row.get("actual_start_time", LocalDateTime.class));
entity.setActualEndTime(row.get("actual_end_time", LocalDateTime.class));
entity.setQrCodePath(row.get("qr_code_path", String.class));
entity.setCreateBy(row.get("create_by", String.class));
entity.setUpdateBy(row.get("update_by", String.class));
entity.setCreatedAt(row.get("created_at", LocalDateTime.class));
entity.setUpdatedAt(row.get("updated_at", LocalDateTime.class));
entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class));
return entity;
}
} }
@@ -0,0 +1,42 @@
package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseRecommendEntity;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
public interface GroupCourseRecommendDao extends R2dbcRepository<GroupCourseRecommendEntity, Long> {
Mono<GroupCourseRecommendEntity> findByIdAndDeletedAtIsNull(Long id);
Flux<GroupCourseRecommendEntity> findAllByDeletedAtIsNull();
Flux<GroupCourseRecommendEntity> findAllByDeletedAtIsNull(Sort sort);
Flux<GroupCourseRecommendEntity> findByCourseIdAndDeletedAtIsNull(Long courseId);
Mono<GroupCourseRecommendEntity> findByCourseIdAndDeletedAtIsNullAndIsActiveTrue(Long courseId);
Flux<GroupCourseRecommendEntity> findByIsActiveTrueAndDeletedAtIsNull();
Flux<GroupCourseRecommendEntity> findByIsActiveTrueAndDeletedAtIsNull(Sort sort);
@Modifying
@Query("UPDATE group_course_recommend SET is_active = :isActive, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateActiveStatus(Long id, Boolean isActive, LocalDateTime updatedAt);
@Modifying
@Query("UPDATE group_course_recommend SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
@Modifying
@Query("UPDATE group_course_recommend SET deleted_at = :deletedAt WHERE course_id = :courseId")
Mono<Integer> softDeleteByCourseId(Long courseId, LocalDateTime deletedAt);
}
@@ -0,0 +1,32 @@
package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseTypeEntity;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
public interface GroupCourseTypeDao extends R2dbcRepository<GroupCourseTypeEntity, Long> {
Mono<GroupCourseTypeEntity> findByIdIsAndDeletedAtIsNull(Long id);
Flux<GroupCourseTypeEntity> findAllByDeletedAtIsNull();
Flux<GroupCourseTypeEntity> findAllByDeletedAtIsNull(Sort sort);
Flux<GroupCourseTypeEntity> findByTypeNameContainingAndDeletedAtIsNull(String typeName);
Flux<GroupCourseTypeEntity> findByCategoryAndDeletedAtIsNull(String category);
Mono<GroupCourseTypeEntity> findByTypeNameAndDeletedAtIsNull(String typeName);
@Modifying
@Query("UPDATE group_course_type SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
}
@@ -0,0 +1,43 @@
package cn.novalon.gym.manage.groupcourse.domain;
import cn.novalon.gym.manage.sys.core.domain.BaseDomain;
import io.swagger.v3.oas.annotations.media.Schema;
public class CourseLabel extends BaseDomain {
//标签名称
@Schema(description = "标签名称", example = "适合新手")
private String labelName;
//标签颜色(十六进制)
@Schema(description = "标签颜色(十六进制)", example = "#52c41a")
private String color;
//标签描述
@Schema(description = "标签描述", example = "适合健身初学者")
private String description;
public String getLabelName() {
return labelName;
}
public void setLabelName(String labelName) {
this.labelName = labelName;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
@@ -40,6 +40,14 @@ public class GroupCourse extends BaseDomain{
@Schema(description = "课程状态", example = "0") @Schema(description = "课程状态", example = "0")
private Long status; private Long status;
//实际开课时间
@Schema(description = "实际开课时间")
private LocalDateTime actualStartTime;
//实际结课时间
@Schema(description = "实际结课时间")
private LocalDateTime actualEndTime;
//上课地点 //上课地点
@Schema(description = "上课地点", example = "龙泉驿区幸福路") @Schema(description = "上课地点", example = "龙泉驿区幸福路")
private String location; private String location;
@@ -52,14 +60,14 @@ public class GroupCourse extends BaseDomain{
@Schema(description = "课程描述", example = "从入门到入土") @Schema(description = "课程描述", example = "从入门到入土")
private String description; private String description;
//点卡额度(消耗次数)
@Schema(description = "点卡额度(消耗次数)", example = "1")
private Integer pointCardAmount;
//储值卡额度(消耗金额) //储值卡额度(消耗金额)
@Schema(description = "储值卡额度(消耗金额)", example = "50.00") @Schema(description = "储值卡额度(消耗金额)", example = "50.00")
private java.math.BigDecimal storedValueAmount; private java.math.BigDecimal storedValueAmount;
//二维码路径
@Schema(description = "二维码路径", example = "D:\\Games\\exmp\\image\\abc123_20260618120000.png")
private String qrCodePath;
public String getCourseName() { public String getCourseName() {
return courseName; return courseName;
} }
@@ -124,6 +132,22 @@ public class GroupCourse extends BaseDomain{
this.status = status; this.status = status;
} }
public LocalDateTime getActualStartTime() {
return actualStartTime;
}
public void setActualStartTime(LocalDateTime actualStartTime) {
this.actualStartTime = actualStartTime;
}
public LocalDateTime getActualEndTime() {
return actualEndTime;
}
public void setActualEndTime(LocalDateTime actualEndTime) {
this.actualEndTime = actualEndTime;
}
public String getLocation() { public String getLocation() {
return location; return location;
} }
@@ -148,14 +172,6 @@ public class GroupCourse extends BaseDomain{
this.description = description; this.description = description;
} }
public Integer getPointCardAmount() {
return pointCardAmount;
}
public void setPointCardAmount(Integer pointCardAmount) {
this.pointCardAmount = pointCardAmount;
}
public java.math.BigDecimal getStoredValueAmount() { public java.math.BigDecimal getStoredValueAmount() {
return storedValueAmount; return storedValueAmount;
} }
@@ -163,4 +179,12 @@ public class GroupCourse extends BaseDomain{
public void setStoredValueAmount(java.math.BigDecimal storedValueAmount) { public void setStoredValueAmount(java.math.BigDecimal storedValueAmount) {
this.storedValueAmount = storedValueAmount; this.storedValueAmount = storedValueAmount;
} }
public String getQrCodePath() {
return qrCodePath;
}
public void setQrCodePath(String qrCodePath) {
this.qrCodePath = qrCodePath;
}
} }
@@ -0,0 +1,265 @@
package cn.novalon.gym.manage.groupcourse.domain;
import cn.novalon.gym.manage.sys.core.domain.BaseDomain;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 团课完整信息领域模型
* 包含团课基础信息、关联的类型信息以及类型的标签信息
*/
public class GroupCourseDetail extends BaseDomain {
// ===== 团课基础信息 =====
@Schema(description = "课程名称", example = "瑜伽入门")
private String courseName;
@Schema(description = "教练ID", example = "1")
private Long coachId;
@Schema(description = "教练名称", example = "张教练")
private String coachName;
@Schema(description = "课程类型ID", example = "1")
private Long courseType;
@Schema(description = "开始时间", example = "2026-06-02T09:00:00")
private LocalDateTime startTime;
@Schema(description = "结束时间", example = "2026-06-02T10:00:00")
private LocalDateTime endTime;
@Schema(description = "最大参与人数", example = "20")
private Integer maxMembers;
@Schema(description = "当前参与人数", example = "15")
private Integer currentMembers;
@Schema(description = "课程状态", example = "0")
private Long status;
@Schema(description = "上课地点", example = "健身房A区")
private String location;
@Schema(description = "封面图URL", example = "https://example.com/yoga.jpg")
private String coverImage;
@Schema(description = "课程描述", example = "适合初学者的瑜伽课程")
private String description;
@Schema(description = "储值卡额度(消耗金额)", example = "50.00")
private BigDecimal storedValueAmount;
@Schema(description = "二维码路径", example = "D:\\Games\\exmp\\image\\abc123_20260618120000.png")
private String qrCodePath;
// ===== 关联的类型信息 =====
@Schema(description = "类型信息")
private GroupCourseType typeInfo;
// ===== 快捷访问属性(从类型信息派生)=====
@Schema(description = "类型名称", example = "瑜伽入门")
private String typeName;
@Schema(description = "类型分类", example = "柔韧与平衡类")
private String typeCategory;
@Schema(description = "基础难度", example = "2")
private Integer baseDifficulty;
@Schema(description = "难度等级描述", example = "初级")
private String difficultyLevel;
@Schema(description = "综合难度系数", example = "2")
private Integer calculatedDifficulty;
// ===== 标签信息(从类型标签派生)=====
@Schema(description = "标签列表")
private List<CourseLabel> labels;
// ===== Getters and Setters =====
public String getCourseName() {
return courseName;
}
public void setCourseName(String courseName) {
this.courseName = courseName;
}
public Long getCoachId() {
return coachId;
}
public void setCoachId(Long coachId) {
this.coachId = coachId;
}
public String getCoachName() {
return coachName;
}
public void setCoachName(String coachName) {
this.coachName = coachName;
}
public Long getCourseType() {
return courseType;
}
public void setCourseType(Long courseType) {
this.courseType = courseType;
}
public LocalDateTime getStartTime() {
return startTime;
}
public void setStartTime(LocalDateTime startTime) {
this.startTime = startTime;
}
public LocalDateTime getEndTime() {
return endTime;
}
public void setEndTime(LocalDateTime endTime) {
this.endTime = endTime;
}
public Integer getMaxMembers() {
return maxMembers;
}
public void setMaxMembers(Integer maxMembers) {
this.maxMembers = maxMembers;
}
public Integer getCurrentMembers() {
return currentMembers;
}
public void setCurrentMembers(Integer currentMembers) {
this.currentMembers = currentMembers;
}
public Long getStatus() {
return status;
}
public void setStatus(Long status) {
this.status = status;
}
public String getLocation() {
return location;
}
public void setLocation(String location) {
this.location = location;
}
public String getCoverImage() {
return coverImage;
}
public void setCoverImage(String coverImage) {
this.coverImage = coverImage;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public BigDecimal getStoredValueAmount() {
return storedValueAmount;
}
public void setStoredValueAmount(BigDecimal storedValueAmount) {
this.storedValueAmount = storedValueAmount;
}
public String getQrCodePath() {
return qrCodePath;
}
public void setQrCodePath(String qrCodePath) {
this.qrCodePath = qrCodePath;
}
public GroupCourseType getTypeInfo() {
return typeInfo;
}
public void setTypeInfo(GroupCourseType typeInfo) {
this.typeInfo = typeInfo;
// 同步派生属性
if (typeInfo != null) {
this.typeName = typeInfo.getTypeName();
this.typeCategory = typeInfo.getCategory();
this.baseDifficulty = typeInfo.getBaseDifficulty();
this.difficultyLevel = typeInfo.getDifficultyLevel();
this.calculatedDifficulty = typeInfo.getCalculatedDifficulty();
this.labels = typeInfo.getLabels();
}
}
public String getTypeName() {
return typeName;
}
public void setTypeName(String typeName) {
this.typeName = typeName;
}
public String getTypeCategory() {
return typeCategory;
}
public void setTypeCategory(String typeCategory) {
this.typeCategory = typeCategory;
}
public Integer getBaseDifficulty() {
return baseDifficulty;
}
public void setBaseDifficulty(Integer baseDifficulty) {
this.baseDifficulty = baseDifficulty;
}
public String getDifficultyLevel() {
return difficultyLevel;
}
public void setDifficultyLevel(String difficultyLevel) {
this.difficultyLevel = difficultyLevel;
}
public Integer getCalculatedDifficulty() {
return calculatedDifficulty;
}
public void setCalculatedDifficulty(Integer calculatedDifficulty) {
this.calculatedDifficulty = calculatedDifficulty;
}
public List<CourseLabel> getLabels() {
return labels;
}
public void setLabels(List<CourseLabel> labels) {
this.labels = labels;
}
}
@@ -0,0 +1,84 @@
package cn.novalon.gym.manage.groupcourse.domain;
import cn.novalon.gym.manage.sys.core.domain.BaseDomain;
import io.swagger.v3.oas.annotations.media.Schema;
public class GroupCourseRecommend extends BaseDomain {
@Schema(description = "团课ID", example = "1")
private Long courseId;
@Schema(description = "推荐标题", example = "本周热门课程")
private String recommendTitle;
@Schema(description = "推荐内容", example = "这是一门非常棒的课程,快来参加吧!")
private String recommendContent;
@Schema(description = "推荐理由", example = "教练专业,课程内容丰富")
private String recommendReason;
@Schema(description = "优先级(数字越大优先级越高)", example = "10")
private Integer priority;
@Schema(description = "是否启用", example = "true")
private Boolean isActive;
@Schema(description = "团课信息")
private GroupCourse groupCourse;
public Long getCourseId() {
return courseId;
}
public void setCourseId(Long courseId) {
this.courseId = courseId;
}
public String getRecommendTitle() {
return recommendTitle;
}
public void setRecommendTitle(String recommendTitle) {
this.recommendTitle = recommendTitle;
}
public String getRecommendContent() {
return recommendContent;
}
public void setRecommendContent(String recommendContent) {
this.recommendContent = recommendContent;
}
public String getRecommendReason() {
return recommendReason;
}
public void setRecommendReason(String recommendReason) {
this.recommendReason = recommendReason;
}
public Integer getPriority() {
return priority;
}
public void setPriority(Integer priority) {
this.priority = priority;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public GroupCourse getGroupCourse() {
return groupCourse;
}
public void setGroupCourse(GroupCourse groupCourse) {
this.groupCourse = groupCourse;
}
}
@@ -0,0 +1,113 @@
package cn.novalon.gym.manage.groupcourse.domain;
import cn.novalon.gym.manage.sys.core.domain.BaseDomain;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.ArrayList;
import java.util.List;
public class GroupCourseType extends BaseDomain {
//类型名称
@Schema(description = "类型名称", example = "瑜伽入门")
private String typeName;
//基础难度(1-10
@Schema(description = "基础难度(1-10", example = "2")
private Integer baseDifficulty;
//类型描述
@Schema(description = "类型描述", example = "适合初学者的瑜伽课程")
private String description;
//分类(如:有氧、力量、柔韧等)
@Schema(description = "分类", example = "柔韧与平衡类")
private String category;
//标签列表
@Schema(description = "标签列表")
private List<CourseLabel> labels = new ArrayList<>();
/**
* 计算综合难度系数
*
* 当前实现仅返回基础难度,为后续扩展预留空间。
* 未来可扩展的影响因素包括:
* 1. 课程时长系数(时长越长难度越高)
* 2. 教练难度调整系数(教练可根据实际情况微调)
* 3. 会员等级适配系数(根据会员等级动态调整显示难度)
* 4. 课程强度系数(高强度课程难度加成)
*
* @return 综合难度系数(1-10
*/
@Schema(description = "综合难度系数(预留扩展字段)", example = "2")
public Integer getCalculatedDifficulty() {
// TODO: 预留扩展点 - 未来可在此处添加更多难度计算逻辑
// 例如:return calculateDynamicDifficulty(baseDifficulty, additionalFactors...);
return this.baseDifficulty != null ? this.baseDifficulty : 1;
}
/**
* 获取难度等级描述
* 将数字难度转换为友好的文字描述
*
* @return 难度等级描述
*/
@Schema(description = "难度等级描述", example = "初级")
public String getDifficultyLevel() {
if (baseDifficulty == null) {
return "未知";
}
if (baseDifficulty <= 2) {
return "初级";
} else if (baseDifficulty <= 4) {
return "中级";
} else if (baseDifficulty <= 6) {
return "中高级";
} else if (baseDifficulty <= 8) {
return "高级";
} else {
return "专家级";
}
}
public String getTypeName() {
return typeName;
}
public void setTypeName(String typeName) {
this.typeName = typeName;
}
public Integer getBaseDifficulty() {
return baseDifficulty;
}
public void setBaseDifficulty(Integer baseDifficulty) {
this.baseDifficulty = baseDifficulty;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public List<CourseLabel> getLabels() {
return labels;
}
public void setLabels(List<CourseLabel> labels) {
this.labels = labels;
}
}
@@ -0,0 +1,116 @@
package cn.novalon.gym.manage.groupcourse.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
/**
* 团课多条件查询请求DTO
*
* @author 张翔
* @date 2026-06-14
*/
@Schema(description = "团课多条件查询参数")
public class GroupCourseQueryDto {
@Schema(description = "课程名称(模糊查询)", example = "瑜伽")
private String courseName;
@Schema(description = "课程类型ID", example = "1")
private Long courseType;
@Schema(description = "查询开始日期", example = "2026-06-01T00:00:00")
private LocalDateTime startDate;
@Schema(description = "查询结束日期", example = "2026-06-30T23:59:59")
private LocalDateTime endDate;
@Schema(description = "时间段:morning-早晨(6:00-12:00), afternoon-下午(12:00-18:00), evening-夜晚(18:00-24:00)", example = "morning")
private String timePeriod;
@Schema(description = "价格排序:asc-从低到高, desc-从高到低", example = "asc")
private String priceSort;
@Schema(description = "按剩余名额最多排序", example = "true")
private Boolean remainingMost;
@Schema(description = "页码", example = "0")
private Integer page = 0;
@Schema(description = "每页大小", example = "10")
private Integer size = 10;
// ===== Getters and Setters =====
public String getCourseName() {
return courseName;
}
public void setCourseName(String courseName) {
this.courseName = courseName;
}
public Long getCourseType() {
return courseType;
}
public void setCourseType(Long courseType) {
this.courseType = courseType;
}
public LocalDateTime getStartDate() {
return startDate;
}
public void setStartDate(LocalDateTime startDate) {
this.startDate = startDate;
}
public LocalDateTime getEndDate() {
return endDate;
}
public void setEndDate(LocalDateTime endDate) {
this.endDate = endDate;
}
public String getTimePeriod() {
return timePeriod;
}
public void setTimePeriod(String timePeriod) {
this.timePeriod = timePeriod;
}
public String getPriceSort() {
return priceSort;
}
public void setPriceSort(String priceSort) {
this.priceSort = priceSort;
}
public Boolean getRemainingMost() {
return remainingMost;
}
public void setRemainingMost(Boolean remainingMost) {
this.remainingMost = remainingMost;
}
public Integer getPage() {
return page;
}
public void setPage(Integer page) {
this.page = page;
}
public Integer getSize() {
return size;
}
public void setSize(Integer size) {
this.size = size;
}
}
@@ -0,0 +1,45 @@
package cn.novalon.gym.manage.groupcourse.entity;
import cn.novalon.gym.manage.db.entity.BaseEntity;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
@Table("course_label")
public class CourseLabelEntity extends BaseEntity {
//标签名称
@Column("label_name")
private String labelName;
//标签颜色(十六进制)
@Column("color")
private String color;
//标签描述
@Column("description")
private String description;
public String getLabelName() {
return labelName;
}
public void setLabelName(String labelName) {
this.labelName = labelName;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
@@ -0,0 +1,33 @@
package cn.novalon.gym.manage.groupcourse.entity;
import cn.novalon.gym.manage.db.entity.BaseEntity;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
@Table("course_type_label")
public class CourseTypeLabelEntity extends BaseEntity {
//团课类型ID
@Column("type_id")
private Long typeId;
//标签ID
@Column("label_id")
private Long labelId;
public Long getTypeId() {
return typeId;
}
public void setTypeId(Long typeId) {
this.typeId = typeId;
}
public Long getLabelId() {
return labelId;
}
public void setLabelId(Long labelId) {
this.labelId = labelId;
}
}
@@ -38,7 +38,15 @@ public class GroupCourseEntity extends BaseEntity {
@Column("current_members") @Column("current_members")
private Integer currentMembers; private Integer currentMembers;
//课程状态:0-正常,1-已取消,2-已结束 //实际开课时间
@Column("actual_start_time")
private LocalDateTime actualStartTime;
//实际结课时间
@Column("actual_end_time")
private LocalDateTime actualEndTime;
//课程状态:0-正常,1-已取消,2-已结束,3-进行中,5-教练缺席,6-自动结束,7-教练迟到
@Column("status") @Column("status")
private Long status; private Long status;
@@ -54,14 +62,14 @@ public class GroupCourseEntity extends BaseEntity {
@Column("description") @Column("description")
private String description; private String description;
//点卡额度(消耗次数)
@Column("point_card_amount")
private Integer pointCardAmount;
//储值卡额度(消耗金额) //储值卡额度(消耗金额)
@Column("stored_value_amount") @Column("stored_value_amount")
private java.math.BigDecimal storedValueAmount; private java.math.BigDecimal storedValueAmount;
//二维码路径
@Column("qr_code_path")
private String qrCodePath;
public String getCourseName() { public String getCourseName() {
return courseName; return courseName;
} }
@@ -150,14 +158,6 @@ public class GroupCourseEntity extends BaseEntity {
this.description = description; this.description = description;
} }
public Integer getPointCardAmount() {
return pointCardAmount;
}
public void setPointCardAmount(Integer pointCardAmount) {
this.pointCardAmount = pointCardAmount;
}
public java.math.BigDecimal getStoredValueAmount() { public java.math.BigDecimal getStoredValueAmount() {
return storedValueAmount; return storedValueAmount;
} }
@@ -165,4 +165,28 @@ public class GroupCourseEntity extends BaseEntity {
public void setStoredValueAmount(java.math.BigDecimal storedValueAmount) { public void setStoredValueAmount(java.math.BigDecimal storedValueAmount) {
this.storedValueAmount = storedValueAmount; this.storedValueAmount = storedValueAmount;
} }
public LocalDateTime getActualStartTime() {
return actualStartTime;
}
public void setActualStartTime(LocalDateTime actualStartTime) {
this.actualStartTime = actualStartTime;
}
public LocalDateTime getActualEndTime() {
return actualEndTime;
}
public void setActualEndTime(LocalDateTime actualEndTime) {
this.actualEndTime = actualEndTime;
}
public String getQrCodePath() {
return qrCodePath;
}
public void setQrCodePath(String qrCodePath) {
this.qrCodePath = qrCodePath;
}
} }
@@ -0,0 +1,77 @@
package cn.novalon.gym.manage.groupcourse.entity;
import cn.novalon.gym.manage.db.entity.BaseEntity;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
@Table("group_course_recommend")
public class GroupCourseRecommendEntity extends BaseEntity {
@Column("course_id")
private Long courseId;
@Column("recommend_title")
private String recommendTitle;
@Column("recommend_content")
private String recommendContent;
@Column("recommend_reason")
private String recommendReason;
@Column("priority")
private Integer priority;
@Column("is_active")
private Boolean isActive;
public Long getCourseId() {
return courseId;
}
public void setCourseId(Long courseId) {
this.courseId = courseId;
}
public String getRecommendTitle() {
return recommendTitle;
}
public void setRecommendTitle(String recommendTitle) {
this.recommendTitle = recommendTitle;
}
public String getRecommendContent() {
return recommendContent;
}
public void setRecommendContent(String recommendContent) {
this.recommendContent = recommendContent;
}
public String getRecommendReason() {
return recommendReason;
}
public void setRecommendReason(String recommendReason) {
this.recommendReason = recommendReason;
}
public Integer getPriority() {
return priority;
}
public void setPriority(Integer priority) {
this.priority = priority;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}
@@ -0,0 +1,57 @@
package cn.novalon.gym.manage.groupcourse.entity;
import cn.novalon.gym.manage.db.entity.BaseEntity;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
@Table("group_course_type")
public class GroupCourseTypeEntity extends BaseEntity {
//类型名称
@Column("type_name")
private String typeName;
//基础难度(1-10
@Column("base_difficulty")
private Integer baseDifficulty;
//类型描述
@Column("description")
private String description;
//分类(如:有氧、力量、柔韧等)
@Column("category")
private String category;
public String getTypeName() {
return typeName;
}
public void setTypeName(String typeName) {
this.typeName = typeName;
}
public Integer getBaseDifficulty() {
return baseDifficulty;
}
public void setBaseDifficulty(Integer baseDifficulty) {
this.baseDifficulty = baseDifficulty;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
}
@@ -11,7 +11,10 @@ public enum CourseStatus {
NORMAL(0L, "正常"), NORMAL(0L, "正常"),
CANCELLED(1L, "已取消"), CANCELLED(1L, "已取消"),
ENDED(2L, "已结束"), ENDED(2L, "已结束"),
IN_PROGRESS(3L, "进行中"); IN_PROGRESS(3L, "进行中"),
COACH_ABSENT(5L, "教练缺席"),
AUTO_ENDED(6L, "自动结束"),
COACH_LATE(7L, "教练迟到");
private final Long value; private final Long value;
private final String desc; private final String desc;
@@ -2,18 +2,18 @@ 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.repository.IGroupCourseBookingRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.service.impl.GroupCourseRedisService;
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.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;
@@ -32,10 +32,9 @@ import java.util.List;
public class BookingSagaHandler { public class BookingSagaHandler {
private final IGroupCourseBookingRepository bookingRepository; private final IGroupCourseBookingRepository bookingRepository;
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 static final double DEFAULT_GROUP_COURSE_PRICE = 50.0; private static final double DEFAULT_GROUP_COURSE_PRICE = 50.0;
@@ -44,10 +43,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,33 +59,15 @@ 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);
// 步骤3:更新课程当前人数
SagaStep step3 = new SagaStep(
"更新课程当前人数",
incrementCourseCurrentMembers(booking.getCourseId()),
Mono.defer(() -> decrementCourseCurrentMembers(booking.getCourseId()))
);
steps.add(step3);
rollbackSteps.add(0, step3);
// 步骤4:更新Redis预约计数
SagaStep step4 = new SagaStep(
"更新Redis预约计数",
incrementRedisBookingCount(booking.getCourseId()),
Mono.defer(() -> decrementRedisBookingCount(booking.getCourseId()))
);
steps.add(step4);
rollbackSteps.add(0, step4);
return executeSaga(steps, rollbackSteps) return executeSaga(steps, rollbackSteps)
.then(Mono.just(booking)); .then(Mono.just(booking));
} }
@@ -94,7 +75,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 +90,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 +133,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 +147,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 +180,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,33 +193,16 @@ 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);
// 步骤3:减少课程当前人数
SagaStep step3 = new SagaStep(
"减少课程当前人数",
decrementCourseCurrentMembers(courseId),
Mono.defer(() -> incrementCourseCurrentMembers(courseId))
);
steps.add(step3);
rollbackSteps.add(0, step3);
// 步骤4:更新Redis预约计数
SagaStep step4 = new SagaStep(
"更新Redis预约计数",
decrementRedisBookingCount(courseId),
Mono.defer(() -> incrementRedisBookingCount(courseId))
);
steps.add(step4);
rollbackSteps.add(0, step4);
return executeSaga(steps, rollbackSteps) return executeSaga(steps, rollbackSteps)
.then(bookingRepository.findById(bookingId)); .then(bookingRepository.findById(bookingId));
} }
@@ -252,71 +232,33 @@ 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 -> {
cn.novalon.gym.manage.member.enums.MemberCardRecordStatus status = record.getStatus();
if (status != cn.novalon.gym.manage.member.enums.MemberCardRecordStatus.ACTIVE) {
return Mono.error(new RuntimeException("会员卡状态无效,当前状态: " + (status != null ? status.getDesc() : "未知")));
}
java.time.LocalDateTime expireTime = record.getExpireTime();
if (expireTime != null && expireTime.isBefore(java.time.LocalDateTime.now())) {
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 -> { .flatMap(rows -> {
if (rows == 0) { if (rows == 0) {
return Mono.error(new RuntimeException("扣减会员卡权益失败,请重试")); return Mono.error(new RuntimeException("储值卡扣减失败,余额不足"));
} }
log.info("储值卡扣减成功: memberId={}, amount={}", memberId, amount);
return Mono.empty(); 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())
.flatMap(rows -> {
if (rows == 0) {
return Mono.error(new RuntimeException("恢复会员卡权益失败,请重试"));
}
return Mono.empty(); return Mono.empty();
}); });
});
}
private Mono<Void> incrementCourseCurrentMembers(Long courseId) {
return courseRepository.updateCurrentMembers(courseId, 1).then();
}
private Mono<Void> decrementCourseCurrentMembers(Long courseId) {
return courseRepository.updateCurrentMembers(courseId, -1).then();
}
private Mono<Void> incrementRedisBookingCount(Long courseId) {
return redisService.incrementBookingCount(courseId).then();
}
private Mono<Void> decrementRedisBookingCount(Long courseId) {
return redisService.decrementBookingCount(courseId).then();
} }
private Mono<Void> executeSaga(List<SagaStep> steps, List<SagaStep> rollbackSteps) { private Mono<Void> executeSaga(List<SagaStep> steps, List<SagaStep> rollbackSteps) {
@@ -0,0 +1,225 @@
package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import cn.novalon.gym.manage.groupcourse.service.ICourseLabelService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Component
@Tag(name = "团课标签管理", description = "团课标签相关操作")
public class CourseLabelHandler {
private final ICourseLabelService courseLabelService;
public CourseLabelHandler(ICourseLabelService courseLabelService) {
this.courseLabelService = courseLabelService;
}
@Operation(summary = "获取所有标签", description = "获取系统中所有标签列表")
public Mono<ServerResponse> getAllLabels(ServerRequest request) {
return ServerResponse.ok()
.body(courseLabelService.findAll(), CourseLabel.class);
}
@Operation(summary = "分页查询标签", description = "支持按关键词筛选并分页查询标签")
public Mono<ServerResponse> getLabelsByPage(ServerRequest request) {
return request.bodyToMono(PageRequest.class)
.flatMap(pageRequest -> courseLabelService.findByPage(pageRequest)
.flatMap(response -> ServerResponse.ok().bodyValue(response)));
}
@Operation(summary = "根据ID获取标签", description = "根据ID获取标签详情")
public Mono<ServerResponse> getLabelById(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return courseLabelService.findById(id)
.flatMap(label -> ServerResponse.ok().bodyValue(label))
.switchIfEmpty(ServerResponse.notFound().build());
}
@Operation(summary = "搜索标签", description = "根据关键词搜索标签")
public Mono<ServerResponse> searchLabels(ServerRequest request) {
String keyword = request.queryParam("keyword").orElse("");
return ServerResponse.ok()
.body(courseLabelService.findByKeyword(keyword), CourseLabel.class);
}
@Operation(summary = "创建标签", description = "创建新的标签")
public Mono<ServerResponse> createLabel(ServerRequest request) {
return request.bodyToMono(CourseLabel.class)
.flatMap(courseLabel -> {
if (courseLabel.getLabelName() == null || courseLabel.getLabelName().isEmpty()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "标签名称不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
if (courseLabel.getLabelName().length() > 50) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "标签名称不能超过50个字符");
return ServerResponse.badRequest().bodyValue(error);
}
if (courseLabel.getColor() == null || courseLabel.getColor().isEmpty()) {
courseLabel.setColor("#1890ff");
}
return courseLabelService.create(courseLabel)
.flatMap(label -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "标签创建成功");
response.put("data", label);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "更新标签", description = "更新指定标签信息")
public Mono<ServerResponse> updateLabel(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(CourseLabel.class)
.flatMap(courseLabel -> {
if (courseLabel.getLabelName() != null && courseLabel.getLabelName().length() > 50) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "标签名称不能超过50个字符");
return ServerResponse.badRequest().bodyValue(error);
}
return courseLabelService.update(id, courseLabel)
.flatMap(label -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "标签更新成功");
response.put("data", label);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "删除标签", description = "删除指定标签(软删除)")
public Mono<ServerResponse> deleteLabel(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return courseLabelService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "标签删除成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
@Operation(summary = "获取类型的标签", description = "获取指定团课类型的所有标签")
public Mono<ServerResponse> getLabelsByTypeId(ServerRequest request) {
Long typeId = Long.valueOf(request.pathVariable("typeId"));
return courseLabelService.findByTypeId(typeId)
.collectList()
.flatMap(list -> ServerResponse.ok().bodyValue(list));
}
@Operation(summary = "为类型添加标签", description = "为指定团课类型添加标签")
public Mono<ServerResponse> addLabelsToType(ServerRequest request) {
Long typeId = Long.valueOf(request.pathVariable("typeId"));
return request.bodyToMono(Map.class)
.flatMap(body -> {
@SuppressWarnings("unchecked")
List<?> rawIds = (List<?>) body.get("labelIds");
if (rawIds == null || rawIds.isEmpty()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "labelIds不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
List<Long> labelIds = rawIds.stream()
.map(id -> Long.valueOf(String.valueOf(id)))
.collect(java.util.stream.Collectors.toList());
return courseLabelService.addLabelsToType(typeId, labelIds)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "标签添加成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "从类型移除标签", description = "从指定团课类型移除标签")
public Mono<ServerResponse> removeLabelFromType(ServerRequest request) {
Long typeId = Long.valueOf(request.pathVariable("typeId"));
Long labelId = Long.valueOf(request.pathVariable("labelId"));
return courseLabelService.removeLabelFromType(typeId, labelId)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "标签移除成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
@Operation(summary = "清空类型标签", description = "清空指定团课类型的所有标签")
public Mono<ServerResponse> clearLabelsFromType(ServerRequest request) {
Long typeId = Long.valueOf(request.pathVariable("typeId"));
return courseLabelService.clearLabelsFromType(typeId)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "标签清空成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
}
@@ -42,15 +42,11 @@ public class GroupCourseBookingHandler {
if (body.get("memberId") == null) { if (body.get("memberId") == null) {
return buildErrorResponse("请提供会员ID"); return buildErrorResponse("请提供会员ID");
} }
if (body.get("memberCardRecordId") == null) {
return buildErrorResponse("请提供会员卡记录ID");
}
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();
return bookingService.bookCourse(courseId, memberId, memberCardRecordId) return bookingService.bookCourse(courseId, memberId)
.flatMap(booking -> { .flatMap(booking -> {
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
response.put("success", true); response.put("success", true);
@@ -76,7 +72,7 @@ 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");
return bookingService.cancelBooking(bookingId, memberId) return bookingService.cancelBooking(bookingId, memberId)
.flatMap(booking -> { .flatMap(booking -> {
@@ -129,6 +125,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 + " 类型不正确");
}
/** /**
* 构建错误响应 * 构建错误响应
*/ */
@@ -4,22 +4,30 @@ package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.common.dto.PageRequest; import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
import cn.novalon.gym.manage.groupcourse.vo.GroupCourseVO;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Validator; import jakarta.validation.Validator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.time.LocalDateTime;
@Component @Component
@Tag(name="团课管理",description = "团课相关操作") @Tag(name="团课管理",description = "团课相关操作")
public class GroupCourseHandler { public class GroupCourseHandler {
private static final Logger logger = LoggerFactory.getLogger(GroupCourseHandler.class);
private final IGroupCourseService groupCourseService; private final IGroupCourseService groupCourseService;
private final Validator validator; private final Validator validator;
private final RedisUtil redisUtil; private final RedisUtil redisUtil;
@@ -35,11 +43,12 @@ public class GroupCourseHandler {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@Operation(summary = "获取所有团课", description = "获取系统中所有团课列表") @Operation(summary = "获取所有团课", description = "获取系统中所有团课列表(含教练昵称)")
public Mono<ServerResponse> getAllGroupCourse(ServerRequest request){ public Mono<ServerResponse> getAllGroupCourse(ServerRequest request){
boolean includeDeleted = Boolean.valueOf(request.queryParam("includeDeleted").orElse("false")); boolean includeDeleted = Boolean.valueOf(request.queryParam("includeDeleted").orElse("false"));
return ServerResponse.ok() return groupCourseService.findAllAsVO(includeDeleted)
.body(groupCourseService.findAll(includeDeleted), GroupCourse.class); .collectList()
.flatMap(list -> ServerResponse.ok().bodyValue(list));
} }
@Operation(summary = "分页获取团课", description = "根据分页参数获取团课列表") @Operation(summary = "分页获取团课", description = "根据分页参数获取团课列表")
@@ -63,7 +72,7 @@ public class GroupCourseHandler {
pageRequest.setOrder("asc"); pageRequest.setOrder("asc");
} }
return groupCourseService.findByPage(pageRequest, includeDeleted) return groupCourseService.findByPageAsVO(pageRequest, includeDeleted)
.flatMap(response -> ServerResponse.ok().bodyValue(response)); .flatMap(response -> ServerResponse.ok().bodyValue(response));
}); });
} }
@@ -76,6 +85,14 @@ public class GroupCourseHandler {
.switchIfEmpty(ServerResponse.notFound().build()); .switchIfEmpty(ServerResponse.notFound().build());
} }
@Operation(summary = "根据ID获取团课完整信息", description = "根据ID获取团课完整信息,包括团课基础信息、类型信息和标签信息")
public Mono<ServerResponse> getGroupCourseDetailById(ServerRequest request){
Long id = Long.valueOf(request.pathVariable("id"));
return groupCourseService.findDetailById(id)
.flatMap(detail -> ServerResponse.ok().bodyValue(detail))
.switchIfEmpty(ServerResponse.notFound().build());
}
@Operation(summary = "创建团课", description = "创建新的团课") @Operation(summary = "创建团课", description = "创建新的团课")
public Mono<ServerResponse> createGroupCourse(ServerRequest request) { public Mono<ServerResponse> createGroupCourse(ServerRequest request) {
return request.bodyToMono(GroupCourse.class) return request.bodyToMono(GroupCourse.class)
@@ -149,7 +166,7 @@ public class GroupCourseHandler {
@Operation(summary = "团课签到", description = "会员签到参加团课") @Operation(summary = "团课签到", description = "会员签到参加团课")
public Mono<ServerResponse> signIn(ServerRequest request) { public Mono<ServerResponse> signIn(ServerRequest request) {
Long courseId = Long.valueOf(request.pathVariable("courseId")); Long memberId = Long.valueOf(request.pathVariable("memberId"));
return request.bodyToMono(Map.class) return request.bodyToMono(Map.class)
.flatMap(body -> { .flatMap(body -> {
@@ -160,15 +177,15 @@ public class GroupCourseHandler {
return ServerResponse.badRequest().bodyValue(response); return ServerResponse.badRequest().bodyValue(response);
} }
Object memberIdObj = body.get("memberId"); Object courseIdObj = body.get("courseId");
if (memberIdObj == null) { if (courseIdObj == null) {
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
response.put("success", false); response.put("success", false);
response.put("message", "memberId不能为空"); response.put("message", "courseId不能为空");
return ServerResponse.badRequest().bodyValue(response); return ServerResponse.badRequest().bodyValue(response);
} }
Long memberId = ((Number) memberIdObj).longValue(); Long courseId = ((Number) courseIdObj).longValue();
return groupCourseService.signIn(courseId, memberId) return groupCourseService.signIn(courseId, memberId)
.flatMap(course -> { .flatMap(course -> {
@@ -206,6 +223,27 @@ public class GroupCourseHandler {
}); });
} }
@Operation(summary = "多条件查询团课", description = "支持团课名称模糊查询、类型筛选、日期范围、时间段、价格排序、剩余名额排序等多条件组合查询")
public Mono<ServerResponse> searchGroupCourses(ServerRequest request) {
return request.bodyToMono(GroupCourseQueryDto.class)
.flatMap(query -> {
return groupCourseService.searchGroupCourses(query)
.flatMap(response -> {
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("message", "查询成功");
result.put("data", response);
return ServerResponse.ok().bodyValue(result);
});
})
.onErrorResume(error -> {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("success", false);
errorResponse.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(errorResponse);
});
}
@Operation(summary = "测试-根据Key获取Redis缓存", description = "测试接口:根据传入的key值获取Redis中缓存的数据") @Operation(summary = "测试-根据Key获取Redis缓存", description = "测试接口:根据传入的key值获取Redis中缓存的数据")
public Mono<ServerResponse> getCacheByKey(ServerRequest request) { public Mono<ServerResponse> getCacheByKey(ServerRequest request) {
return request.bodyToMono(Map.class) return request.bodyToMono(Map.class)
@@ -258,4 +296,50 @@ public class GroupCourseHandler {
}); });
}); });
} }
@Operation(summary = "检查教练时间冲突", description = "检查教练在指定时间段内是否有时间冲突的团课")
public Mono<ServerResponse> checkCoachConflict(ServerRequest request) {
return request.bodyToMono(Map.class)
.flatMap(body -> {
if (body == null) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "请求体不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
Object coachIdObj = body.get("coachId");
Object startTimeStr = body.get("startTime");
Object endTimeStr = body.get("endTime");
Object excludeCourseIdObj = body.get("excludeCourseId");
if (coachIdObj == null || startTimeStr == null || endTimeStr == null) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "coachId、startTime、endTime 不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
Long coachId = Long.valueOf(String.valueOf(coachIdObj));
LocalDateTime startTime = LocalDateTime.parse(String.valueOf(startTimeStr));
LocalDateTime endTime = LocalDateTime.parse(String.valueOf(endTimeStr));
Long excludeCourseId = excludeCourseIdObj != null ? Long.valueOf(String.valueOf(excludeCourseIdObj)) : null;
return groupCourseService.checkCoachConflict(coachId, startTime, endTime, excludeCourseId)
.flatMap(conflicts -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("hasConflict", !conflicts.isEmpty());
response.put("conflicts", conflicts);
return ServerResponse.ok().bodyValue(response);
});
})
.onErrorResume(error -> {
logger.warn("检查教练时间冲突失败: {}", error.getMessage());
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", "检查失败: " + error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
} }
@@ -0,0 +1,164 @@
package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.Map;
@Component
@Tag(name = "团课推荐管理", description = "团课推荐相关操作")
public class GroupCourseRecommendHandler {
private final IGroupCourseRecommendService recommendService;
public GroupCourseRecommendHandler(IGroupCourseRecommendService recommendService) {
this.recommendService = recommendService;
}
@Operation(summary = "获取所有团课推荐", description = "获取系统中所有团课推荐列表,支持按优先级排序")
public Mono<ServerResponse> getAllRecommendations(ServerRequest request) {
String sortBy = request.queryParam("sortBy").orElse("priority");
String sortOrder = request.queryParam("sortOrder").orElse("desc");
return ServerResponse.ok()
.body(recommendService.findAll(sortBy, sortOrder), GroupCourseRecommend.class);
}
@Operation(summary = "获取所有启用的团课推荐", description = "获取系统中所有已启用的团课推荐列表(按优先级排序)")
public Mono<ServerResponse> getAllActiveRecommendations(ServerRequest request) {
return ServerResponse.ok()
.body(recommendService.findAllActive(), GroupCourseRecommend.class);
}
@Operation(summary = "根据ID获取团课推荐", description = "根据ID获取团课推荐详情")
public Mono<ServerResponse> getRecommendationById(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return recommendService.findById(id)
.flatMap(recommend -> ServerResponse.ok().bodyValue(recommend))
.switchIfEmpty(ServerResponse.notFound().build());
}
@Operation(summary = "根据团课ID获取推荐", description = "根据团课ID获取该团课的推荐信息")
public Mono<ServerResponse> getRecommendationsByCourseId(ServerRequest request) {
Long courseId = Long.valueOf(request.pathVariable("courseId"));
return ServerResponse.ok()
.body(recommendService.findByCourseId(courseId), GroupCourseRecommend.class);
}
@Operation(summary = "创建团课推荐", description = "创建新的团课推荐")
public Mono<ServerResponse> createRecommendation(ServerRequest request) {
return request.bodyToMono(GroupCourseRecommend.class)
.flatMap(recommend -> {
if (recommend.getCourseId() == null) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "团课ID不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return recommendService.create(recommend)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课推荐创建成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "更新团课推荐", description = "更新指定团课推荐信息")
public Mono<ServerResponse> updateRecommendation(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(GroupCourseRecommend.class)
.flatMap(recommend -> {
return 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 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 = "启用指定团课推荐")
public Mono<ServerResponse> enableRecommendation(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return recommendService.enable(id)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课推荐启用成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
@Operation(summary = "禁用团课推荐", description = "禁用指定团课推荐")
public Mono<ServerResponse> disableRecommendation(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return recommendService.disable(id)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课推荐禁用成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
}
@@ -0,0 +1,146 @@
package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.Map;
@Component
@Tag(name = "团课类型管理", description = "团课类型及难度相关操作")
public class GroupCourseTypeHandler {
private final IGroupCourseTypeService groupCourseTypeService;
public GroupCourseTypeHandler(IGroupCourseTypeService groupCourseTypeService) {
this.groupCourseTypeService = groupCourseTypeService;
}
@Operation(summary = "获取所有团课类型", description = "获取系统中所有团课类型列表")
public Mono<ServerResponse> getAllGroupCourseTypes(ServerRequest request) {
boolean includeDeleted = Boolean.valueOf(request.queryParam("includeDeleted").orElse("false"));
return ServerResponse.ok()
.body(groupCourseTypeService.findAll(includeDeleted), GroupCourseType.class);
}
@Operation(summary = "根据ID获取团课类型", description = "根据ID获取团课类型详情")
public Mono<ServerResponse> getGroupCourseTypeById(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return groupCourseTypeService.findById(id)
.flatMap(type -> ServerResponse.ok().bodyValue(type))
.switchIfEmpty(ServerResponse.notFound().build());
}
@Operation(summary = "根据关键词搜索团课类型", description = "根据类型名称关键词搜索团课类型")
public Mono<ServerResponse> searchGroupCourseTypes(ServerRequest request) {
String keyword = request.queryParam("keyword").orElse("");
return ServerResponse.ok()
.body(groupCourseTypeService.findByKeyword(keyword), GroupCourseType.class);
}
@Operation(summary = "根据分类获取团课类型", description = "根据分类获取团课类型列表")
public Mono<ServerResponse> getGroupCourseTypesByCategory(ServerRequest request) {
String category = request.pathVariable("category");
String keyword = request.queryParam("keyword").orElse("");
return ServerResponse.ok()
.body(groupCourseTypeService.findByCategoryAndKeyword(category, keyword), GroupCourseType.class);
}
@Operation(summary = "获取所有分类", description = "获取所有团课类型分类(去重)")
public Mono<ServerResponse> getCategories(ServerRequest request) {
return groupCourseTypeService.findCategories()
.collectList()
.flatMap(list -> ServerResponse.ok().bodyValue(list));
}
@Operation(summary = "创建团课类型", description = "创建新的团课类型")
public Mono<ServerResponse> createGroupCourseType(ServerRequest request) {
return request.bodyToMono(GroupCourseType.class)
.flatMap(groupCourseType -> {
if (groupCourseType.getTypeName() == null || groupCourseType.getTypeName().isEmpty()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "类型名称不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
// 默认基础难度为1
if (groupCourseType.getBaseDifficulty() == null) {
groupCourseType.setBaseDifficulty(1);
}
return groupCourseTypeService.create(groupCourseType)
.flatMap(type -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课类型创建成功");
response.put("data", type);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "更新团课类型", description = "更新指定团课类型信息")
public Mono<ServerResponse> updateGroupCourseType(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
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 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);
});
}
@Operation(summary = "分页查询团课类型", description = "支持按关键词、分类筛选并分页查询团课类型")
public Mono<ServerResponse> getGroupCourseTypesByPage(ServerRequest request) {
return request.bodyToMono(PageRequest.class)
.flatMap(pageRequest -> groupCourseTypeService.findByPage(pageRequest)
.flatMap(response -> ServerResponse.ok().bodyValue(response))
);
}
}
@@ -0,0 +1,92 @@
package cn.novalon.gym.manage.groupcourse.initializer;
import cn.novalon.gym.manage.file.core.service.ISysFileService;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.Map;
/**
* 项目启动时补全缺失的团课二维码
* 遍历所有未删除的团课对qrCodePath为空的课程生成二维码并通过统一文件服务保存
*/
@Component
public class QrCodeInitializer implements CommandLineRunner {
private static final Logger logger = LoggerFactory.getLogger(QrCodeInitializer.class);
private final IGroupCourseRepository groupCourseRepository;
private final ObjectMapper objectMapper;
private final ISysFileService fileService;
public QrCodeInitializer(IGroupCourseRepository groupCourseRepository,
ObjectMapper objectMapper,
ISysFileService fileService) {
this.groupCourseRepository = groupCourseRepository;
this.objectMapper = objectMapper;
this.fileService = fileService;
}
@Override
public void run(String... args) {
logger.info("===== 开始检查团课二维码缺失情况 =====");
groupCourseRepository.findByDeletedAtIsNull()
.filter(course -> course.getQrCodePath() == null || course.getQrCodePath().isEmpty())
.flatMap(course -> {
try {
logger.info("发现缺失二维码的团课 - id={}, name={}", course.getId(), course.getCourseName());
// 生成二维码内容团课基础信息JSON
Map<String, Object> qrCodeContent = new HashMap<>();
qrCodeContent.put("id", course.getId());
qrCodeContent.put("courseName", course.getCourseName());
qrCodeContent.put("coachId", course.getCoachId());
qrCodeContent.put("courseType", course.getCourseType());
qrCodeContent.put("startTime", course.getStartTime() != null ? course.getStartTime().toString() : null);
qrCodeContent.put("endTime", course.getEndTime() != null ? course.getEndTime().toString() : null);
qrCodeContent.put("maxMembers", course.getMaxMembers());
qrCodeContent.put("location", course.getLocation());
qrCodeContent.put("description", course.getDescription());
String jsonContent = objectMapper.writeValueAsString(qrCodeContent);
// 生成二维码字节数组通过统一文件服务保存
return Mono.fromCallable(() -> QRCodeUtil.generateQrCodeBytes(jsonContent))
.flatMap(qrCodeBytes -> {
String fileName = "qrcode_" + course.getId() + ".png";
return fileService.saveBytes(qrCodeBytes, fileName, "image/png", "system");
})
.flatMap(sysFile -> {
String qrCodeUrl = "/api/files/" + sysFile.getId() + "/preview";
course.setQrCodePath(qrCodeUrl);
return groupCourseRepository.update(course)
.doOnSuccess(updated -> logger.info("团课二维码补全成功 - id={}, name={}, url={}",
updated.getId(), updated.getCourseName(), qrCodeUrl))
.doOnError(error -> logger.error("团课二维码补全失败(更新DB) - id={}, name={}, error: {}",
course.getId(), course.getCourseName(), error.getMessage()));
});
} catch (Exception e) {
logger.error("团课二维码补全失败(生成) - id={}, name={}, error: {}",
course.getId(), course.getCourseName(), e.getMessage(), e);
return Mono.empty();
}
})
.collectList()
.doOnSuccess(list -> logger.info("===== 团课二维码检查完毕,共补全 {} 个缺失二维码 =====", list.size()))
.onErrorResume(error -> {
logger.error("团课二维码初始化检查异常: {}", error.getMessage(), error);
return Mono.empty();
})
.subscribe();
}
}
@@ -0,0 +1,36 @@
package cn.novalon.gym.manage.groupcourse.repository;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
public interface ICourseLabelRepository {
Mono<CourseLabel> findById(Long id);
Flux<CourseLabel> findAll();
Flux<CourseLabel> findByKeyword(String keyword);
Mono<CourseLabel> findByLabelName(String labelName);
Mono<CourseLabel> save(CourseLabel courseLabel);
Mono<CourseLabel> update(CourseLabel courseLabel);
Mono<Void> deleteById(Long id);
Flux<CourseLabel> findByTypeId(Long typeId);
Mono<Void> addLabelsToType(Long typeId, List<Long> labelIds);
Mono<Void> removeLabelFromType(Long typeId, Long labelId);
Mono<Void> clearLabelsFromType(Long typeId);
Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest);
}
@@ -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);
} }
@@ -0,0 +1,30 @@
package cn.novalon.gym.manage.groupcourse.repository;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface IGroupCourseRecommendRepository {
Mono<GroupCourseRecommend> findById(Long id);
Flux<GroupCourseRecommend> findAll();
Flux<GroupCourseRecommend> findAll(String sortBy, String sortOrder);
Flux<GroupCourseRecommend> findAllActive();
Flux<GroupCourseRecommend> findByCourseId(Long courseId);
Mono<GroupCourseRecommend> findActiveByCourseId(Long courseId);
Mono<GroupCourseRecommend> save(GroupCourseRecommend recommend);
Mono<GroupCourseRecommend> update(GroupCourseRecommend recommend);
Mono<Void> deleteById(Long id);
Mono<Void> deleteByCourseId(Long courseId);
Mono<GroupCourseRecommend> updateActiveStatus(Long id, Boolean isActive);
}
@@ -4,6 +4,7 @@ package cn.novalon.gym.manage.groupcourse.repository;
import cn.novalon.gym.manage.common.dto.PageRequest; import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse; import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -26,5 +27,14 @@ public interface IGroupCourseRepository {
Mono<Void> deleteById(Long id); Mono<Void> deleteById(Long id);
Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta); Flux<GroupCourse> findByCourseType(Long courseType);
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
// ==================== 教练相关 ====================
Flux<GroupCourse> findByCoachId(Long coachId);
Flux<GroupCourse> findByCoachId(Long coachId, Sort sort);
Mono<Long> countByCoachIdAndStatus(Long coachId, Long status);
Mono<Integer> cancelCoursesByCoachIdExceptStatus(Long coachId, Long excludeStatus);
} }
@@ -0,0 +1,32 @@
package cn.novalon.gym.manage.groupcourse.repository;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface IGroupCourseTypeRepository {
Mono<GroupCourseType> findById(Long id);
Flux<GroupCourseType> findAll();
Flux<GroupCourseType> findAll(boolean includeDeleted);
Flux<GroupCourseType> findByKeyword(String keyword);
Flux<GroupCourseType> findByCategory(String category);
Flux<GroupCourseType> findByCategoryAndKeyword(String category, String keyword);
Mono<GroupCourseType> findByTypeName(String typeName);
Mono<GroupCourseType> save(GroupCourseType groupCourseType);
Mono<GroupCourseType> update(GroupCourseType groupCourseType);
Mono<Void> deleteById(Long id);
Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest);
}
@@ -0,0 +1,207 @@
package cn.novalon.gym.manage.groupcourse.repository.impl;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
import cn.novalon.gym.manage.groupcourse.dao.CourseLabelDao;
import cn.novalon.gym.manage.groupcourse.dao.CourseTypeLabelDao;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import cn.novalon.gym.manage.groupcourse.entity.CourseLabelEntity;
import cn.novalon.gym.manage.groupcourse.entity.CourseTypeLabelEntity;
import cn.novalon.gym.manage.groupcourse.repository.ICourseLabelRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
import org.springframework.data.relational.core.query.Criteria;
import org.springframework.data.relational.core.query.Query;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Repository
@Transactional
public class CourseLabelRepository implements ICourseLabelRepository {
private static final Logger logger = LoggerFactory.getLogger(CourseLabelRepository.class);
private final CourseLabelDao courseLabelDao;
private final CourseTypeLabelDao courseTypeLabelDao;
private final GroupCourseConverter converter;
private final R2dbcEntityTemplate r2dbcEntityTemplate;
public CourseLabelRepository(CourseLabelDao courseLabelDao, CourseTypeLabelDao courseTypeLabelDao,
GroupCourseConverter converter, R2dbcEntityTemplate r2dbcEntityTemplate) {
this.courseLabelDao = courseLabelDao;
this.courseTypeLabelDao = courseTypeLabelDao;
this.converter = converter;
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
}
@Override
public Mono<CourseLabel> findById(Long id) {
return courseLabelDao.findByIdIsAndDeletedAtIsNull(id)
.map(this::toCourseLabel);
}
@Override
public Flux<CourseLabel> findAll() {
return courseLabelDao.findAllByDeletedAtIsNull()
.map(this::toCourseLabel);
}
@Override
public Flux<CourseLabel> findByKeyword(String keyword) {
if (keyword == null || keyword.isEmpty()) {
return findAll();
}
return courseLabelDao.findByLabelNameContainingAndDeletedAtIsNull(keyword)
.map(this::toCourseLabel);
}
@Override
public Mono<CourseLabel> findByLabelName(String labelName) {
return courseLabelDao.findByLabelNameAndDeletedAtIsNull(labelName)
.map(this::toCourseLabel);
}
@Override
public Mono<CourseLabel> save(CourseLabel courseLabel) {
CourseLabelEntity entity = toCourseLabelEntity(courseLabel);
return courseLabelDao.save(entity)
.map(this::toCourseLabel);
}
@Override
public Mono<CourseLabel> update(CourseLabel courseLabel) {
return courseLabelDao.findByIdIsAndDeletedAtIsNull(courseLabel.getId())
.switchIfEmpty(Mono.error(new RuntimeException("标签不存在")))
.flatMap(existing -> {
existing.markNotNew();
if (courseLabel.getLabelName() != null) {
existing.setLabelName(courseLabel.getLabelName());
}
if (courseLabel.getColor() != null) {
existing.setColor(courseLabel.getColor());
}
if (courseLabel.getDescription() != null) {
existing.setDescription(courseLabel.getDescription());
}
existing.setUpdatedAt(LocalDateTime.now());
return courseLabelDao.save(existing);
})
.map(this::toCourseLabel);
}
@Override
public Mono<Void> deleteById(Long id) {
return courseLabelDao.softDelete(id, LocalDateTime.now())
.then(courseTypeLabelDao.deleteByLabelId(id, LocalDateTime.now()))
.then();
}
@Override
public Flux<CourseLabel> findByTypeId(Long typeId) {
return courseTypeLabelDao.findByTypeIdAndDeletedAtIsNull(typeId)
.flatMap(typeLabel -> courseLabelDao.findByIdIsAndDeletedAtIsNull(typeLabel.getLabelId()))
.map(this::toCourseLabel);
}
@Override
public Mono<Void> addLabelsToType(Long typeId, List<Long> labelIds) {
return Flux.fromIterable(labelIds)
.flatMap(labelId -> {
return courseTypeLabelDao.physicalDeleteByTypeIdAndLabelId(typeId, labelId)
.then(Mono.defer(() -> {
CourseTypeLabelEntity entity = new CourseTypeLabelEntity();
entity.setTypeId(typeId);
entity.setLabelId(labelId);
return courseTypeLabelDao.save(entity).then(Mono.empty());
}));
})
.then();
}
@Override
public Mono<Void> removeLabelFromType(Long typeId, Long labelId) {
return courseTypeLabelDao.deleteByTypeIdAndLabelId(typeId, labelId, LocalDateTime.now())
.then();
}
@Override
public Mono<Void> clearLabelsFromType(Long typeId) {
return courseTypeLabelDao.deleteByTypeId(typeId, LocalDateTime.now())
.then();
}
private CourseLabel toCourseLabel(CourseLabelEntity entity) {
if (entity == null) {
return null;
}
CourseLabel label = new CourseLabel();
label.setId(entity.getId());
label.setLabelName(entity.getLabelName());
label.setColor(entity.getColor());
label.setDescription(entity.getDescription());
label.setCreatedAt(entity.getCreatedAt());
label.setUpdatedAt(entity.getUpdatedAt());
return label;
}
private CourseLabelEntity toCourseLabelEntity(CourseLabel domain) {
if (domain == null) {
return null;
}
CourseLabelEntity entity = new CourseLabelEntity();
entity.setId(domain.getId());
entity.setLabelName(domain.getLabelName());
entity.setColor(domain.getColor());
entity.setDescription(domain.getDescription());
if (domain.getId() != null) {
entity.markNotNew();
}
return entity;
}
@Override
public Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest) {
int page = pageRequest.getPage();
int size = pageRequest.getSize();
String sort = pageRequest.getSort();
String order = pageRequest.getOrder();
String keyword = pageRequest.getKeyword();
Sort sortObj = Sort.unsorted();
if (sort != null && !sort.isEmpty()) {
sortObj = Sort.by(Sort.Direction.fromString(order), sort);
}
List<Criteria> criteriaList = new ArrayList<>();
criteriaList.add(Criteria.where("deleted_at").isNull());
if (keyword != null && !keyword.isEmpty()) {
criteriaList.add(Criteria.where("label_name").like("%" + keyword + "%").ignoreCase(true));
}
Criteria criteria = criteriaList.isEmpty() ? Criteria.empty() : Criteria.from(criteriaList);
Query query = Query.query(criteria).with(org.springframework.data.domain.PageRequest.of(page, size, sortObj));
return r2dbcEntityTemplate.select(CourseLabelEntity.class)
.matching(query)
.all()
.collectList()
.zipWith(r2dbcEntityTemplate.count(Query.query(criteria), CourseLabelEntity.class))
.map(tuple -> {
long total = tuple.getT2();
int totalPages = (int) Math.ceil((double) total / size);
List<CourseLabel> list = tuple.getT1().stream()
.map(this::toCourseLabel)
.toList();
return new PageResponse<>(list, totalPages, total, page, size);
});
}
}
@@ -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);
}
} }
@@ -0,0 +1,136 @@
package cn.novalon.gym.manage.groupcourse.repository.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseRecommendDao;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseRecommendEntity;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRecommendRepository;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
public class GroupCourseRecommendRepository implements IGroupCourseRecommendRepository {
private final GroupCourseRecommendDao recommendDao;
private final R2dbcEntityTemplate r2dbcEntityTemplate;
public GroupCourseRecommendRepository(GroupCourseRecommendDao recommendDao,
R2dbcEntityTemplate r2dbcEntityTemplate) {
this.recommendDao = recommendDao;
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
}
@Override
public Mono<GroupCourseRecommend> findById(Long id) {
return recommendDao.findByIdAndDeletedAtIsNull(id)
.map(this::toDomain);
}
@Override
public Flux<GroupCourseRecommend> findAll() {
return recommendDao.findAllByDeletedAtIsNull()
.map(this::toDomain);
}
@Override
public Flux<GroupCourseRecommend> findAll(String sortBy, String sortOrder) {
Sort.Direction direction = "asc".equalsIgnoreCase(sortOrder)
? Sort.Direction.ASC
: Sort.Direction.DESC;
Sort sort = Sort.by(direction, sortBy);
return recommendDao.findAllByDeletedAtIsNull(sort)
.map(this::toDomain);
}
@Override
public Flux<GroupCourseRecommend> findAllActive() {
return recommendDao.findByIsActiveTrueAndDeletedAtIsNull(Sort.by(Sort.Direction.DESC, "priority"))
.map(this::toDomain);
}
@Override
public Flux<GroupCourseRecommend> findByCourseId(Long courseId) {
return recommendDao.findByCourseIdAndDeletedAtIsNull(courseId)
.map(this::toDomain);
}
@Override
public Mono<GroupCourseRecommend> findActiveByCourseId(Long courseId) {
return recommendDao.findByCourseIdAndDeletedAtIsNullAndIsActiveTrue(courseId)
.map(this::toDomain);
}
@Override
public Mono<GroupCourseRecommend> save(GroupCourseRecommend recommend) {
GroupCourseRecommendEntity entity = toEntity(recommend);
entity.setCreatedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now());
if (entity.getPriority() == null) {
entity.setPriority(0);
}
if (entity.getIsActive() == null) {
entity.setIsActive(true);
}
return recommendDao.save(entity)
.map(this::toDomain);
}
@Override
public Mono<GroupCourseRecommend> update(GroupCourseRecommend recommend) {
GroupCourseRecommendEntity entity = toEntity(recommend);
entity.setUpdatedAt(LocalDateTime.now());
return r2dbcEntityTemplate.update(entity)
.then(findById(recommend.getId()));
}
@Override
public Mono<Void> deleteById(Long id) {
return recommendDao.softDelete(id, LocalDateTime.now())
.then();
}
@Override
public Mono<Void> deleteByCourseId(Long courseId) {
return recommendDao.softDeleteByCourseId(courseId, LocalDateTime.now())
.then();
}
@Override
public Mono<GroupCourseRecommend> updateActiveStatus(Long id, Boolean isActive) {
return recommendDao.updateActiveStatus(id, isActive, LocalDateTime.now())
.flatMap(updated -> {
if (updated > 0) {
return findById(id);
}
return Mono.empty();
});
}
private GroupCourseRecommend toDomain(GroupCourseRecommendEntity entity) {
if (entity == null) {
return null;
}
GroupCourseRecommend recommend = new GroupCourseRecommend();
BeanUtil.copyProperties(entity, recommend);
return recommend;
}
private GroupCourseRecommendEntity toEntity(GroupCourseRecommend domain) {
if (domain == null) {
return null;
}
GroupCourseRecommendEntity entity = new GroupCourseRecommendEntity();
BeanUtil.copyProperties(domain, entity);
if (domain.getId() != null) {
entity.markNotNew();
}
return entity;
}
}
@@ -6,6 +6,7 @@ import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter; import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao; import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity; import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
@@ -96,38 +97,20 @@ public class GroupCourseRepository implements IGroupCourseRepository {
public Mono<PageResponse<GroupCourse>> findByPageAndNotDeleted(PageRequest pageRequest) { public Mono<PageResponse<GroupCourse>> findByPageAndNotDeleted(PageRequest pageRequest) {
int page = pageRequest.getPage(); int page = pageRequest.getPage();
int size = pageRequest.getSize(); int size = pageRequest.getSize();
String sort = pageRequest.getSort();
String order = pageRequest.getOrder();
Sort sortObj = Sort.unsorted(); return groupCourseDao.countByPageFiltered(r2dbcEntityTemplate.getDatabaseClient(), pageRequest)
if (sort != null && !sort.isEmpty()) { .flatMap(total -> {
sortObj = Sort.by(Sort.Direction.fromString(order), sort); if (total == 0) {
return Mono.just(new PageResponse<>(List.of(), 0, 0L, page, size));
} }
return groupCourseDao.findByPageFiltered(r2dbcEntityTemplate.getDatabaseClient(), pageRequest)
org.springframework.data.domain.PageRequest pageable = org.springframework.data.domain.PageRequest.of(page, size, sortObj);
return groupCourseDao.findAllByDeletedAtIsNull(sortObj)
.collectList()
.zipWith(groupCourseDao.findAllByDeletedAtIsNull().count())
.map(tuple -> {
List<GroupCourseEntity> allEntities = tuple.getT1();
long total = tuple.getT2();
int fromIndex = page * size;
int toIndex = Math.min(fromIndex + size, allEntities.size());
List<GroupCourse> courseList;
if (fromIndex < allEntities.size()) {
courseList = allEntities.subList(fromIndex, toIndex).stream()
.map(groupCourseConverter::toDomain) .map(groupCourseConverter::toDomain)
.toList(); .collectList()
} else { .map(courseList -> {
courseList = List.of();
}
int totalPages = (int) Math.ceil((double) total / size); int totalPages = (int) Math.ceil((double) total / size);
return new PageResponse<>(courseList, totalPages, total, page, size); return new PageResponse<>(courseList, totalPages, total, page, size);
}); });
});
} }
@Override @Override
@@ -136,7 +119,6 @@ public class GroupCourseRepository implements IGroupCourseRepository {
entity.setCreatedAt(LocalDateTime.now()); entity.setCreatedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now()); entity.setUpdatedAt(LocalDateTime.now());
entity.setStatus(0L); entity.setStatus(0L);
entity.setCurrentMembers(0);
return groupCourseDao.save(entity) return groupCourseDao.save(entity)
.map(groupCourseConverter::toDomain); .map(groupCourseConverter::toDomain);
@@ -169,13 +151,55 @@ public class GroupCourseRepository implements IGroupCourseRepository {
} }
@Override @Override
public Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta) { public Flux<GroupCourse> findByCourseType(Long courseType) {
return groupCourseDao.updateCurrentMembers(id, delta, LocalDateTime.now()) return groupCourseDao.findByCourseTypeAndDeletedAtIsNull(courseType)
.flatMap(updated -> { .map(groupCourseConverter::toDomain);
if (updated > 0) {
return findByIdAndDeletedAtIsNull(id);
} }
return Mono.empty();
@Override
public Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query) {
return groupCourseDao.countSearchGroupCourses(r2dbcEntityTemplate.getDatabaseClient(), query)
.flatMap(total -> {
if (total == 0) {
return Mono.just(new PageResponse<>(
List.of(), 0, 0L,
query.getPage() != null ? query.getPage() : 0,
query.getSize() != null ? query.getSize() : 10));
}
return groupCourseDao.searchGroupCourses(r2dbcEntityTemplate.getDatabaseClient(), query)
.map(groupCourseConverter::toDomain)
.collectList()
.map(courseList -> {
int size = query.getSize() != null ? query.getSize() : 10;
int totalPages = (int) Math.ceil((double) total / size);
return new PageResponse<>(
courseList, totalPages, total,
query.getPage() != null ? query.getPage() : 0, size);
});
}); });
} }
// ==================== 教练相关 ====================
@Override
public Flux<GroupCourse> findByCoachId(Long coachId) {
return groupCourseDao.findByCoachIdAndDeletedAtIsNull(coachId)
.map(groupCourseConverter::toDomain);
}
@Override
public Flux<GroupCourse> findByCoachId(Long coachId, Sort sort) {
return groupCourseDao.findByCoachIdAndDeletedAtIsNull(coachId, sort)
.map(groupCourseConverter::toDomain);
}
@Override
public Mono<Long> countByCoachIdAndStatus(Long coachId, Long status) {
return groupCourseDao.countByCoachIdAndStatus(coachId, String.valueOf(status));
}
@Override
public Mono<Integer> cancelCoursesByCoachIdExceptStatus(Long coachId, Long excludeStatus) {
return groupCourseDao.cancelCoursesByCoachIdExceptStatus(coachId, String.valueOf(excludeStatus), LocalDateTime.now());
}
} }
@@ -0,0 +1,185 @@
package cn.novalon.gym.manage.groupcourse.repository.impl;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseTypeDao;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseTypeEntity;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
import org.springframework.data.relational.core.query.Criteria;
import org.springframework.data.relational.core.query.Query;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Repository
@Transactional
public class GroupCourseTypeRepository implements IGroupCourseTypeRepository {
private static final Logger logger = LoggerFactory.getLogger(GroupCourseTypeRepository.class);
private final GroupCourseTypeDao groupCourseTypeDao;
private final GroupCourseConverter converter;
private final R2dbcEntityTemplate r2dbcEntityTemplate;
public GroupCourseTypeRepository(GroupCourseTypeDao groupCourseTypeDao, GroupCourseConverter converter,
R2dbcEntityTemplate r2dbcEntityTemplate) {
this.groupCourseTypeDao = groupCourseTypeDao;
this.converter = converter;
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
}
@Override
public Mono<GroupCourseType> findById(Long id) {
return groupCourseTypeDao.findByIdIsAndDeletedAtIsNull(id)
.map(converter::toGroupCourseType);
}
@Override
public Flux<GroupCourseType> findAll() {
return groupCourseTypeDao.findAll()
.map(converter::toGroupCourseType);
}
@Override
public Flux<GroupCourseType> findAll(boolean includeDeleted) {
if (includeDeleted) {
return groupCourseTypeDao.findAll()
.map(converter::toGroupCourseType);
} else {
return groupCourseTypeDao.findAllByDeletedAtIsNull()
.map(converter::toGroupCourseType);
}
}
@Override
public Flux<GroupCourseType> findByKeyword(String keyword) {
if (keyword == null || keyword.isEmpty()) {
return findAll(false);
}
return groupCourseTypeDao.findByTypeNameContainingAndDeletedAtIsNull(keyword)
.map(converter::toGroupCourseType);
}
@Override
public Flux<GroupCourseType> findByCategory(String category) {
if (category == null || category.isEmpty()) {
return findAll(false);
}
return groupCourseTypeDao.findByCategoryAndDeletedAtIsNull(category)
.map(converter::toGroupCourseType);
}
@Override
public Flux<GroupCourseType> findByCategoryAndKeyword(String category, String keyword) {
Flux<GroupCourseType> result;
if (category != null && !category.isEmpty()) {
result = findByCategory(category);
} else {
result = findAll(false);
}
if (keyword != null && !keyword.isEmpty()) {
result = result.filter(type -> type.getTypeName() != null &&
type.getTypeName().toLowerCase().contains(keyword.toLowerCase()));
}
return result;
}
@Override
public Mono<GroupCourseType> findByTypeName(String typeName) {
return groupCourseTypeDao.findByTypeNameAndDeletedAtIsNull(typeName)
.map(converter::toGroupCourseType);
}
@Override
public Mono<GroupCourseType> save(GroupCourseType groupCourseType) {
GroupCourseTypeEntity entity = converter.toGroupCourseTypeEntity(groupCourseType);
return groupCourseTypeDao.save(entity)
.map(converter::toGroupCourseType);
}
@Override
public Mono<GroupCourseType> update(GroupCourseType groupCourseType) {
return groupCourseTypeDao.findByIdIsAndDeletedAtIsNull(groupCourseType.getId())
.switchIfEmpty(Mono.error(new RuntimeException("团课类型不存在")))
.flatMap(existing -> {
existing.markNotNew();
if (groupCourseType.getTypeName() != null) {
existing.setTypeName(groupCourseType.getTypeName());
}
if (groupCourseType.getBaseDifficulty() != null) {
existing.setBaseDifficulty(groupCourseType.getBaseDifficulty());
}
if (groupCourseType.getDescription() != null) {
existing.setDescription(groupCourseType.getDescription());
}
if (groupCourseType.getCategory() != null) {
existing.setCategory(groupCourseType.getCategory());
}
existing.setUpdatedAt(LocalDateTime.now());
return groupCourseTypeDao.save(existing);
})
.map(converter::toGroupCourseType);
}
@Override
public Mono<Void> deleteById(Long id) {
return groupCourseTypeDao.softDelete(id, LocalDateTime.now())
.then();
}
@Override
public Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest) {
int page = pageRequest.getPage();
int size = pageRequest.getSize();
String order = pageRequest.getOrder();
String sort = pageRequest.getSort();
String keyword = pageRequest.getKeyword();
String category = pageRequest.getCategory();
Sort sortObj = Sort.unsorted();
if (sort != null && !sort.isEmpty()) {
sortObj = Sort.by(Sort.Direction.fromString(order), sort);
}
// Build dynamic criteria
List<Criteria> criteriaList = new ArrayList<>();
criteriaList.add(Criteria.where("deleted_at").isNull());
if (keyword != null && !keyword.isEmpty()) {
criteriaList.add(Criteria.where("type_name").like("%" + keyword + "%").ignoreCase(true));
}
if (category != null && !category.isEmpty()) {
criteriaList.add(Criteria.where("category").is(category));
}
Criteria criteria = criteriaList.isEmpty() ? Criteria.empty() : Criteria.from(criteriaList);
Query query = Query.query(criteria).with(org.springframework.data.domain.PageRequest.of(page, size, sortObj));
return r2dbcEntityTemplate.select(GroupCourseTypeEntity.class)
.matching(query)
.all()
.collectList()
.zipWith(r2dbcEntityTemplate.count(Query.query(criteria), GroupCourseTypeEntity.class))
.map(tuple -> {
long total = tuple.getT2();
int totalPages = (int) Math.ceil((double) total / size);
List<GroupCourseType> list = tuple.getT1().stream()
.map(converter::toGroupCourseType)
.toList();
return new PageResponse<>(list, totalPages, total, page, size);
});
}
}
@@ -0,0 +1,34 @@
package cn.novalon.gym.manage.groupcourse.service;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
public interface ICourseLabelService {
Mono<CourseLabel> findById(Long id);
Flux<CourseLabel> findAll();
Flux<CourseLabel> findByKeyword(String keyword);
Mono<CourseLabel> create(CourseLabel courseLabel);
Mono<CourseLabel> update(Long id, CourseLabel courseLabel);
Mono<Void> delete(Long id);
Flux<CourseLabel> findByTypeId(Long typeId);
Mono<Void> addLabelsToType(Long typeId, List<Long> labelIds);
Mono<Void> removeLabelFromType(Long typeId, Long labelId);
Mono<Void> clearLabelsFromType(Long typeId);
Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest);
}
@@ -17,10 +17,9 @@ public interface IGroupCourseBookingService {
* *
* @param courseId 团课ID * @param courseId 团课ID
* @param memberId 会员ID * @param memberId 会员ID
* @param memberCardRecordId 会员卡记录ID
* @return 预约记录 * @return 预约记录
*/ */
Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId, Long memberCardRecordId); Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId);
/** /**
* 取消预约 * 取消预约
@@ -0,0 +1,28 @@
package cn.novalon.gym.manage.groupcourse.service;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface IGroupCourseRecommendService {
Mono<GroupCourseRecommend> findById(Long id);
Flux<GroupCourseRecommend> findAll();
Flux<GroupCourseRecommend> findAll(String sortBy, String sortOrder);
Flux<GroupCourseRecommend> findAllActive();
Flux<GroupCourseRecommend> findByCourseId(Long courseId);
Mono<GroupCourseRecommend> create(GroupCourseRecommend recommend);
Mono<GroupCourseRecommend> update(Long id, GroupCourseRecommend recommend);
Mono<Void> delete(Long id);
Mono<GroupCourseRecommend> enable(Long id);
Mono<GroupCourseRecommend> disable(Long id);
}
@@ -4,13 +4,22 @@ package cn.novalon.gym.manage.groupcourse.service;
import cn.novalon.gym.manage.common.dto.PageRequest; import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse; import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.vo.GroupCourseVO;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.List;
public interface IGroupCourseService { public interface IGroupCourseService {
Mono<GroupCourse> findById(Long id); Mono<GroupCourse> findById(Long id);
Mono<GroupCourseDetail> findDetailById(Long id);
Flux<GroupCourse> findAll(); Flux<GroupCourse> findAll();
Flux<GroupCourse> findAll(boolean includeDeleted); Flux<GroupCourse> findAll(boolean includeDeleted);
Flux<GroupCourseVO> findAllAsVO(boolean includeDeleted);
Mono<PageResponse<GroupCourseVO>> findByPageAsVO(PageRequest pageRequest, boolean includeDeleted);
Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted); Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted);
@@ -23,4 +32,16 @@ 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<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
/**
* 检查教练在指定时间段内是否有时间冲突的团课
* @param coachId 教练ID
* @param startTime 开始时间
* @param endTime 结束时间
* @param excludeCourseId 排除的课程ID编辑时排除自身
* @return 冲突的课程列表
*/
Mono<List<GroupCourse>> checkCoachConflict(Long coachId, LocalDateTime startTime, LocalDateTime endTime, Long excludeCourseId);
} }
@@ -0,0 +1,36 @@
package cn.novalon.gym.manage.groupcourse.service;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface IGroupCourseTypeService {
Mono<GroupCourseType> findById(Long id);
Flux<GroupCourseType> findAll();
Flux<GroupCourseType> findAll(boolean includeDeleted);
Flux<GroupCourseType> findByKeyword(String keyword);
Flux<GroupCourseType> findByCategory(String category);
Flux<GroupCourseType> findByCategoryAndKeyword(String category, String keyword);
Mono<GroupCourseType> create(GroupCourseType groupCourseType);
Mono<GroupCourseType> update(Long id, GroupCourseType groupCourseType);
Mono<Void> delete(Long id);
/**
* 获取分类列表去重
* @return 分类名称列表
*/
Flux<String> findCategories();
Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest);
}
@@ -0,0 +1,122 @@
package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.repository.ICourseLabelRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.service.ICourseLabelService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
@Service
public class CourseLabelService implements ICourseLabelService {
private static final Logger logger = LoggerFactory.getLogger(CourseLabelService.class);
private static final String CACHE_KEY_DETAIL_PREFIX = "group_course:detail:";
private final ICourseLabelRepository courseLabelRepository;
private final IGroupCourseRepository groupCourseRepository;
private final RedisUtil redisUtil;
public CourseLabelService(ICourseLabelRepository courseLabelRepository,
IGroupCourseRepository groupCourseRepository,
RedisUtil redisUtil) {
this.courseLabelRepository = courseLabelRepository;
this.groupCourseRepository = groupCourseRepository;
this.redisUtil = redisUtil;
}
private Mono<Void> invalidateGroupCourseDetailCache(Long typeId) {
return groupCourseRepository.findByCourseType(typeId)
.flatMap(course -> {
String cacheKey = CACHE_KEY_DETAIL_PREFIX + course.getId();
return redisUtil.delete(cacheKey)
.doOnSuccess(deleted -> logger.debug("清除团课详情缓存 - courseId={}", course.getId()));
})
.then();
}
@Override
public Mono<CourseLabel> findById(Long id) {
return courseLabelRepository.findById(id);
}
@Override
public Flux<CourseLabel> findAll() {
return courseLabelRepository.findAll();
}
@Override
public Flux<CourseLabel> findByKeyword(String keyword) {
return courseLabelRepository.findByKeyword(keyword);
}
@Override
public Mono<CourseLabel> create(CourseLabel courseLabel) {
return courseLabelRepository.findByLabelName(courseLabel.getLabelName())
.flatMap(existing -> Mono.<CourseLabel>error(new RuntimeException("标签名称已存在")))
.switchIfEmpty(courseLabelRepository.save(courseLabel))
.doOnSuccess(label -> logger.info("标签创建成功 - id={}, name={}", label.getId(), label.getLabelName()))
.doOnError(error -> logger.error("标签创建失败 - error: {}", error.getMessage()));
}
@Override
public Mono<CourseLabel> update(Long id, CourseLabel courseLabel) {
courseLabel.setId(id);
return courseLabelRepository.update(courseLabel)
.doOnSuccess(label -> logger.info("标签更新成功 - id={}", id))
.doOnError(error -> logger.error("标签更新失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<Void> delete(Long id) {
return courseLabelRepository.deleteById(id)
.doOnSuccess(v -> logger.info("标签删除成功 - id={}", id))
.doOnError(error -> logger.error("标签删除失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Flux<CourseLabel> findByTypeId(Long typeId) {
return courseLabelRepository.findByTypeId(typeId);
}
@Override
public Mono<Void> addLabelsToType(Long typeId, List<Long> labelIds) {
return courseLabelRepository.addLabelsToType(typeId, labelIds)
.then(invalidateGroupCourseDetailCache(typeId))
.doOnSuccess(v -> logger.info("标签添加到类型成功 - typeId={}, labelIds={}", typeId, labelIds))
.doOnError(error -> logger.error("标签添加到类型失败 - typeId={}, error: {}", typeId, error.getMessage()));
}
@Override
public Mono<Void> removeLabelFromType(Long typeId, Long labelId) {
return courseLabelRepository.removeLabelFromType(typeId, labelId)
.then(invalidateGroupCourseDetailCache(typeId))
.doOnSuccess(v -> logger.info("从类型移除标签成功 - typeId={}, labelId={}", typeId, labelId))
.doOnError(error -> logger.error("从类型移除标签失败 - typeId={}, labelId={}, error: {}", typeId, labelId, error.getMessage()));
}
@Override
public Mono<Void> clearLabelsFromType(Long typeId) {
return courseLabelRepository.clearLabelsFromType(typeId)
.then(invalidateGroupCourseDetailCache(typeId))
.doOnSuccess(v -> logger.info("清空类型标签成功 - typeId={}", typeId))
.doOnError(error -> logger.error("清空类型标签失败 - typeId={}, error: {}", typeId, error.getMessage()));
}
@Override
public Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest) {
return courseLabelRepository.findByPage(pageRequest)
.doOnSuccess(result -> logger.info("分页查询标签成功 - page={}, size={}, total={}",
pageRequest.getPage(), pageRequest.getSize(), result.getTotalElements()))
.doOnError(error -> logger.error("分页查询标签失败 - error: {}", error.getMessage()));
}
}
@@ -3,7 +3,6 @@ package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
import cn.novalon.gym.manage.groupcourse.event.BookingReminderEventPublisher; import cn.novalon.gym.manage.groupcourse.event.BookingReminderEventPublisher;
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;
@@ -25,7 +24,6 @@ import java.util.UUID;
* - 取消预约需在课程开始前至少2小时 * - 取消预约需在课程开始前至少2小时
* - 每节课最多20人 * - 每节课最多20人
* - 预约成功后发送提醒 * - 预约成功后发送提醒
* - 预约成功后扣减权益
* *
* 技术要点 * 技术要点
* - 使用Redis缓存团课信息 * - 使用Redis缓存团课信息
@@ -44,7 +42,6 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
private final IGroupCourseRepository courseRepository; private final IGroupCourseRepository courseRepository;
private final GroupCourseRedisService redisService; private final GroupCourseRedisService redisService;
private final BookingReminderEventPublisher bookingReminderEventPublisher; private final BookingReminderEventPublisher bookingReminderEventPublisher;
private final BookingSagaHandler bookingSagaHandler;
// 预约提前时间限制分钟 // 预约提前时间限制分钟
private static final long BOOKING_MIN_ADVANCE_MINUTES = 30; private static final long BOOKING_MIN_ADVANCE_MINUTES = 30;
@@ -54,18 +51,16 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
public GroupCourseBookingService(IGroupCourseBookingRepository bookingRepository, public GroupCourseBookingService(IGroupCourseBookingRepository bookingRepository,
IGroupCourseRepository courseRepository, IGroupCourseRepository courseRepository,
GroupCourseRedisService redisService, GroupCourseRedisService redisService,
BookingReminderEventPublisher bookingReminderEventPublisher, BookingReminderEventPublisher bookingReminderEventPublisher) {
BookingSagaHandler bookingSagaHandler) {
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;
} }
@Override @Override
public Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId, Long memberCardRecordId) { public Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId) {
logger.info("开始预约团课:courseId={}, memberId={}, memberCardRecordId={}", courseId, memberId, memberCardRecordId); logger.info("开始预约团课:courseId={}, memberId={}", courseId, memberId);
// 生成唯一请求ID用于分布式锁 // 生成唯一请求ID用于分布式锁
String requestId = UUID.randomUUID().toString(); String requestId = UUID.randomUUID().toString();
@@ -77,18 +72,26 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
return Mono.error(new RuntimeException("系统繁忙,请稍后重试")); return Mono.error(new RuntimeException("系统繁忙,请稍后重试"));
} }
// 2. 尝试从缓存获取课程信息如果缓存不存在则从数据库获取 // 2. 从缓存或数据库获取课程信息并用实时预约数覆盖currentMembers
return getCourseWithCache(courseId) return getCourseWithCache(courseId)
.flatMap(course ->
bookingRepository.countValidBookings(courseId)
.map(count -> {
course.setCurrentMembers(count.intValue());
return course;
})
.defaultIfEmpty(course)
)
.flatMap(course -> { .flatMap(course -> {
// 3. 验证课程状态 // 3. 验证课程状态
Long status = course.getStatus(); Long courseStatus = course.getStatus();
if (status == null || status != 0L) { if (courseStatus == null || courseStatus != 0L) {
String errorMessage; String errorMessage;
if (status == null) { if (courseStatus == null) {
errorMessage = "课程状态异常"; errorMessage = "课程状态异常";
} else if (status == 1L) { } else if (courseStatus == 1L) {
errorMessage = "课程已取消,无法预约"; errorMessage = "课程已取消,无法预约";
} else if (status == 2L) { } else if (courseStatus == 2L) {
errorMessage = "课程已结束,无法预约"; errorMessage = "课程已结束,无法预约";
} else { } else {
errorMessage = "课程状态不可预约"; errorMessage = "课程状态不可预约";
@@ -131,22 +134,15 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
// 7. 验证是否已预约 // 7. 验证是否已预约
return bookingRepository.findValidBooking(courseId, memberId) return bookingRepository.findValidBooking(courseId, memberId)
.flatMap(existingBooking -> { .flatMap(existingBooking ->
return releaseLockAndError(courseId, requestId, "您已预约该课程"); releaseLockAndError(courseId, requestId, "您已预约该课程")
}) )
.switchIfEmpty( .switchIfEmpty(
// 8. 使用Redis原子操作验证课程人数是否已满 // 8. 创建预约记录
validateAndIncrementBookingCount(courseId, course.getMaxMembers()) Mono.defer(() -> {
.flatMap(countValid -> {
if (countValid > course.getMaxMembers()) {
return releaseLockAndError(courseId, requestId, "课程已满");
}
// 9. 创建预约记录
GroupCourseBooking booking = new GroupCourseBooking(); GroupCourseBooking booking = new GroupCourseBooking();
booking.setCourseId(courseId); booking.setCourseId(courseId);
booking.setMemberId(memberId); booking.setMemberId(memberId);
booking.setMemberCardRecordId(memberCardRecordId);
booking.setBookingTime(LocalDateTime.now()); booking.setBookingTime(LocalDateTime.now());
booking.setStatus("0"); // 0-已预约 booking.setStatus("0"); // 0-已预约
@@ -156,12 +152,15 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
booking.setCourseEndTime(course.getEndTime()); booking.setCourseEndTime(course.getEndTime());
booking.setLocation(course.getLocation()); booking.setLocation(course.getLocation());
// 10. 使用Saga事务执行预约包含权益扣减 // 9. 保存预约记录
return bookingSagaHandler.executeBooking(booking, memberCardRecordId) return bookingRepository.save(booking)
.flatMap(savedBooking -> { .flatMap(saved -> {
// 11. 释放锁 if (saved.getId() == null) {
return Mono.error(new RuntimeException("保存预约记录失败"));
}
// 10. 释放锁
return redisService.releaseLock(courseId, requestId) return redisService.releaseLock(courseId, requestId)
.then(Mono.just(savedBooking)); .then(Mono.just(saved));
}) })
.doOnSuccess(savedBooking -> { .doOnSuccess(savedBooking -> {
logger.info("预约成功:bookingId={}, courseId={}, memberId={}", logger.info("预约成功:bookingId={}, courseId={}, memberId={}",
@@ -173,12 +172,6 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
savedBooking.getCourseName(), savedBooking.getCourseName(),
savedBooking.getCourseStartTime().toString() savedBooking.getCourseStartTime().toString()
); );
})
.doOnError(error -> {
// 回滚Redis计数
redisService.decrementBookingCount(courseId).subscribe();
logger.error("预约失败:courseId={}, memberId={}, error={}",
courseId, memberId, error.getMessage());
}); });
}) })
); );
@@ -208,29 +201,6 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
); );
} }
/**
* 验证并增加预约人数使用Redis原子操作
*/
private Mono<Integer> validateAndIncrementBookingCount(Long courseId, Integer maxMembers) {
// 先获取当前Redis中的预约计数
return redisService.getBookingCount(courseId)
.flatMap(currentCount -> {
// 如果Redis中计数为0可能是首次访问需要从数据库同步
if (currentCount == 0) {
// 从数据库查询实际预约人数
return bookingRepository.countValidBookings(courseId)
.flatMap(dbCount -> {
// 将数据库中的实际预约人数同步到Redis
return redisService.setBookingCount(courseId, dbCount.intValue())
.then(Mono.just(dbCount.intValue()));
});
}
return Mono.just(currentCount);
})
// 递增预约计数
.flatMap(count -> redisService.incrementBookingCount(courseId).map(Long::intValue));
}
/** /**
* 释放锁并返回错误 * 释放锁并返回错误
*/ */
@@ -290,12 +260,15 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
"需在课程开始前" + CANCEL_MIN_ADVANCE_HOURS + "小时取消"); "需在课程开始前" + CANCEL_MIN_ADVANCE_HOURS + "小时取消");
} }
// 5. 使用Saga事务执行取消预约包含权益恢复 // 5. 更新预约状态为已取消
return bookingSagaHandler.executeCancelBooking(bookingId, booking.getCourseId(), booking.getMemberCardRecordId(), memberId) return bookingRepository.updateStatus(bookingId, "1")
.flatMap(updatedBooking -> { .flatMap(rows -> {
if (rows == 0) {
return Mono.error(new RuntimeException("更新预约状态失败"));
}
// 6. 释放锁 // 6. 释放锁
return redisService.releaseLock(bookingId, requestId) return redisService.releaseLock(bookingId, requestId)
.then(Mono.just(updatedBooking)); .then(bookingRepository.findById(bookingId));
}) })
.doOnSuccess(updatedBooking -> { .doOnSuccess(updatedBooking -> {
logger.info("取消预约成功:bookingId={}, memberId={}", bookingId, memberId); logger.info("取消预约成功:bookingId={}, memberId={}", bookingId, memberId);
@@ -0,0 +1,142 @@
package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRecommendRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Service
public class GroupCourseRecommendService implements IGroupCourseRecommendService {
private static final Logger logger = LoggerFactory.getLogger(GroupCourseRecommendService.class);
private final IGroupCourseRecommendRepository recommendRepository;
private final IGroupCourseRepository groupCourseRepository;
public GroupCourseRecommendService(IGroupCourseRecommendRepository recommendRepository,
IGroupCourseRepository groupCourseRepository) {
this.recommendRepository = recommendRepository;
this.groupCourseRepository = groupCourseRepository;
}
@Override
public Mono<GroupCourseRecommend> findById(Long id) {
return recommendRepository.findById(id)
.flatMap(this::fillGroupCourseInfo);
}
@Override
public Flux<GroupCourseRecommend> findAll() {
return recommendRepository.findAll()
.flatMap(this::fillGroupCourseInfo);
}
@Override
public Flux<GroupCourseRecommend> findAll(String sortBy, String sortOrder) {
return recommendRepository.findAll(sortBy, sortOrder)
.flatMap(this::fillGroupCourseInfo);
}
@Override
public Flux<GroupCourseRecommend> findAllActive() {
return recommendRepository.findAllActive()
.flatMap(this::fillGroupCourseInfo);
}
@Override
public Flux<GroupCourseRecommend> findByCourseId(Long courseId) {
return recommendRepository.findByCourseId(courseId)
.flatMap(this::fillGroupCourseInfo);
}
@Override
public Mono<GroupCourseRecommend> create(GroupCourseRecommend recommend) {
if (recommend.getCourseId() == null) {
return Mono.error(new RuntimeException("团课ID不能为空"));
}
return groupCourseRepository.findByIdAndDeletedAtIsNull(recommend.getCourseId())
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
.flatMap(course -> {
return recommendRepository.save(recommend)
.doOnSuccess(r -> logger.info("团课推荐创建成功 - id={}, courseId={}", r.getId(), r.getCourseId()))
.doOnError(error -> logger.error("团课推荐创建失败 - error: {}", error.getMessage()));
});
}
@Override
public Mono<GroupCourseRecommend> update(Long id, GroupCourseRecommend recommend) {
return recommendRepository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("团课推荐不存在")))
.flatMap(existing -> {
if (recommend.getRecommendTitle() != null) {
existing.setRecommendTitle(recommend.getRecommendTitle());
}
if (recommend.getRecommendContent() != null) {
existing.setRecommendContent(recommend.getRecommendContent());
}
if (recommend.getRecommendReason() != null) {
existing.setRecommendReason(recommend.getRecommendReason());
}
if (recommend.getPriority() != null) {
existing.setPriority(recommend.getPriority());
}
if (recommend.getIsActive() != null) {
existing.setIsActive(recommend.getIsActive());
}
if (recommend.getCourseId() != null) {
existing.setCourseId(recommend.getCourseId());
}
return recommendRepository.update(existing);
})
.doOnSuccess(r -> logger.info("团课推荐更新成功 - id={}", id))
.doOnError(error -> logger.error("团课推荐更新失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<Void> delete(Long id) {
return recommendRepository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("团课推荐不存在")))
.flatMap(recommend -> {
return recommendRepository.deleteById(id)
.doOnSuccess(v -> logger.info("团课推荐删除成功 - id={}", id))
.doOnError(error -> logger.error("团课推荐删除失败 - id={}, error: {}", id, error.getMessage()));
});
}
@Override
public Mono<GroupCourseRecommend> enable(Long id) {
return recommendRepository.updateActiveStatus(id, true)
.switchIfEmpty(Mono.error(new RuntimeException("团课推荐不存在")))
.doOnSuccess(r -> logger.info("团课推荐启用成功 - id={}", id))
.doOnError(error -> logger.error("团课推荐启用失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<GroupCourseRecommend> disable(Long id) {
return recommendRepository.updateActiveStatus(id, false)
.switchIfEmpty(Mono.error(new RuntimeException("团课推荐不存在")))
.doOnSuccess(r -> logger.info("团课推荐禁用成功 - id={}", id))
.doOnError(error -> logger.error("团课推荐禁用失败 - id={}, error: {}", id, error.getMessage()));
}
private Mono<GroupCourseRecommend> fillGroupCourseInfo(GroupCourseRecommend recommend) {
if (recommend.getCourseId() == null) {
return Mono.just(recommend);
}
return groupCourseRepository.findByIdAndDeletedAtIsNull(recommend.getCourseId())
.map(course -> {
recommend.setGroupCourse(course);
return recommend;
})
.defaultIfEmpty(recommend);
}
}
@@ -149,46 +149,4 @@ public class GroupCourseRedisService {
}) })
.defaultIfEmpty(false); .defaultIfEmpty(false);
} }
/**
* 获取课程预约人数缓存
*/
public Mono<Integer> getBookingCount(Long courseId) {
String key = "booking_count:" + courseId;
return reactiveRedisTemplate.opsForValue()
.get(key)
.map(obj -> (Integer) obj)
.defaultIfEmpty(0);
}
/**
* 增加课程预约人数
*/
public Mono<Long> incrementBookingCount(Long courseId) {
String key = "booking_count:" + courseId;
return reactiveRedisTemplate.opsForValue()
.increment(key)
.doOnSuccess(count -> logger.debug("预约人数增加:courseId={}, count={}", courseId, count));
}
/**
* 减少课程预约人数
*/
public Mono<Long> decrementBookingCount(Long courseId) {
String key = "booking_count:" + courseId;
return reactiveRedisTemplate.opsForValue()
.decrement(key)
.doOnSuccess(count -> logger.debug("预约人数减少:courseId={}, count={}", courseId, count));
}
/**
* 设置课程预约人数用于从数据库同步
*/
public Mono<Void> setBookingCount(Long courseId, Integer count) {
String key = "booking_count:" + courseId;
return reactiveRedisTemplate.opsForValue()
.set(key, count)
.doOnSuccess(result -> logger.debug("预约人数已设置:courseId={}, count={}", courseId, count))
.then();
}
} }
@@ -4,28 +4,44 @@ package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.common.dto.PageRequest; import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse; import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.common.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.enums.CourseEvent; import cn.novalon.gym.manage.groupcourse.enums.CourseEvent;
import cn.novalon.gym.manage.groupcourse.enums.CourseStatus; import cn.novalon.gym.manage.groupcourse.enums.CourseStatus;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseStateMachine; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseStateMachine;
import cn.novalon.gym.manage.groupcourse.repository.ICourseLabelRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil;
import cn.novalon.gym.manage.groupcourse.vo.GroupCourseVO;
import cn.novalon.gym.manage.file.core.service.ISysFileService;
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.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.sys.core.domain.SysUser;
import cn.novalon.gym.manage.sys.core.repository.ISysUserRepository;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service @Service
public class GroupCourseService implements IGroupCourseService { public class GroupCourseService implements IGroupCourseService {
@@ -33,45 +49,198 @@ public class GroupCourseService implements IGroupCourseService {
private final IGroupCourseRepository groupCourseRepository; private final IGroupCourseRepository groupCourseRepository;
private final IGroupCourseBookingRepository bookingRepository; private final IGroupCourseBookingRepository bookingRepository;
private final IGroupCourseTypeRepository groupCourseTypeRepository;
private final ICourseLabelRepository courseLabelRepository;
private final IMemberCardRecordService memberCardRecordService; private final IMemberCardRecordService memberCardRecordService;
private final MemberCardRepository memberCardRepository; private final MemberCardRepository memberCardRepository;
private final RedisUtil redisUtil; private final RedisUtil redisUtil;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final GroupCourseStateMachine stateMachine; private final GroupCourseStateMachine stateMachine;
private final DatabaseClient databaseClient;
private final ISysFileService fileService;
private final ISysUserRepository sysUserRepository;
private static final String CACHE_KEY_PREFIX = "group_course:page:"; private static final String CACHE_KEY_PREFIX = "group_course:page:";
private static final String CACHE_KEY_ID_PREFIX = "group_course:id:"; private static final String CACHE_KEY_ID_PREFIX = "group_course:id:";
private static final String CACHE_KEY_DETAIL_PREFIX = "group_course:detail:";
private static final long CACHE_EXPIRE_SECONDS = 300; private static final long CACHE_EXPIRE_SECONDS = 300;
private static final double DEFAULT_GROUP_COURSE_PRICE = 50.0; private static final double DEFAULT_GROUP_COURSE_PRICE = 50.0;
public GroupCourseService(IGroupCourseRepository groupCourseRepository, public GroupCourseService(IGroupCourseRepository groupCourseRepository,
IGroupCourseBookingRepository bookingRepository, IGroupCourseBookingRepository bookingRepository,
IGroupCourseTypeRepository groupCourseTypeRepository,
ICourseLabelRepository courseLabelRepository,
IMemberCardRecordService memberCardRecordService, IMemberCardRecordService memberCardRecordService,
MemberCardRepository memberCardRepository, MemberCardRepository memberCardRepository,
RedisUtil redisUtil, RedisUtil redisUtil,
ObjectMapper objectMapper, ObjectMapper objectMapper,
GroupCourseStateMachine stateMachine){ GroupCourseStateMachine stateMachine,
DatabaseClient databaseClient,
ISysFileService fileService,
ISysUserRepository sysUserRepository){
this.groupCourseRepository = groupCourseRepository; this.groupCourseRepository = groupCourseRepository;
this.bookingRepository = bookingRepository; this.bookingRepository = bookingRepository;
this.groupCourseTypeRepository = groupCourseTypeRepository;
this.courseLabelRepository = courseLabelRepository;
this.memberCardRecordService = memberCardRecordService; this.memberCardRecordService = memberCardRecordService;
this.memberCardRepository = memberCardRepository; this.memberCardRepository = memberCardRepository;
this.redisUtil = redisUtil; this.redisUtil = redisUtil;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.stateMachine = stateMachine; this.stateMachine = stateMachine;
this.databaseClient = databaseClient;
this.fileService = fileService;
this.sysUserRepository = sysUserRepository;
}
@Override
public Mono<GroupCourseDetail> findDetailById(Long id) {
String cacheKey = CACHE_KEY_DETAIL_PREFIX + id;
Mono<String> cachedMono = redisUtil.get(cacheKey, String.class);
return cachedMono
.flatMap(cachedJson -> {
if (cachedJson != null && !cachedJson.isEmpty()) {
try {
GroupCourseDetail detail = objectMapper.readValue(cachedJson, GroupCourseDetail.class);
logger.info("缓存命中 - findDetailById: id={}", id);
return Mono.<GroupCourseDetail>just(detail);
} catch (JsonProcessingException e) {
logger.warn("缓存解析失败,删除缓存 - id: {}, error: {}", id, e.getMessage());
return redisUtil.delete(cacheKey).then(Mono.empty());
}
}
return Mono.empty();
})
.switchIfEmpty(
groupCourseRepository.findByIdAndDeletedAtIsNull(id)
.flatMap(course -> {
// 查询类型信息
Long courseTypeId = course.getCourseType();
if (courseTypeId == null) {
// 没有类型直接构建详情
return Mono.just(buildDetail(course, null));
}
// 有类型查询类型信息
return groupCourseTypeRepository.findById(courseTypeId)
.flatMap(type -> {
// 查询标签
return courseLabelRepository.findByTypeId(type.getId())
.collectList()
.map(labels -> {
type.setLabels(labels);
return buildDetail(course, type);
});
})
.switchIfEmpty(Mono.just(buildDetail(course, null)));
})
.flatMap(this::enrichCoachName)
.flatMap(this::enrichCurrentMembers)
.flatMap(detail -> {
try {
String jsonData = objectMapper.writeValueAsString(detail);
return redisUtil.setWithExpire(cacheKey, jsonData, CACHE_EXPIRE_SECONDS)
.thenReturn(detail)
.doOnSuccess(d -> logger.debug("缓存已设置 - findDetailById: id={}", id));
} catch (JsonProcessingException e) {
logger.error("缓存设置失败 - id: {}, error: {}", id, e.getMessage());
return Mono.just(detail);
}
})
.doOnSubscribe(sub -> logger.debug("缓存未命中,查询数据库 - findDetailById: id={}", id))
);
}
/**
* 构建团课完整信息对象
*/
private GroupCourseDetail buildDetail(GroupCourse course, GroupCourseType type) {
GroupCourseDetail detail = new GroupCourseDetail();
detail.setId(course.getId());
detail.setCourseName(course.getCourseName());
detail.setCoachId(course.getCoachId());
detail.setCourseType(course.getCourseType());
detail.setStartTime(course.getStartTime());
detail.setEndTime(course.getEndTime());
detail.setMaxMembers(course.getMaxMembers());
detail.setCurrentMembers(course.getCurrentMembers());
detail.setStatus(course.getStatus());
detail.setLocation(course.getLocation());
detail.setCoverImage(course.getCoverImage());
detail.setDescription(course.getDescription());
detail.setStoredValueAmount(course.getStoredValueAmount());
detail.setQrCodePath(course.getQrCodePath());
detail.setCreatedAt(course.getCreatedAt());
detail.setUpdatedAt(course.getUpdatedAt());
// 设置类型信息
if (type != null) {
detail.setTypeInfo(type);
}
return detail;
}
/**
* 为团课详情对象填充教练名称
*/
private Mono<GroupCourseDetail> enrichCoachName(GroupCourseDetail detail) {
Long coachId = detail.getCoachId();
if (coachId == null) {
return Mono.just(detail);
}
return sysUserRepository.findByIdIncludingDeleted(coachId)
.map(user -> {
String name = user.getNickname() != null ? user.getNickname() : user.getUsername();
detail.setCoachName(name);
logger.debug("enrichCoachName: courseId={}, coachId={}, coachName={}", detail.getId(), coachId, name);
return detail;
})
.doOnNext(d -> {}) // 避免空doOnNext告警
.defaultIfEmpty(detail)
.doOnDiscard(GroupCourseDetail.class, d ->
logger.warn("enrichCoachName: coach not found, coachId={}, courseId={}", coachId, detail.getId())
);
}
/**
* 为团课详情对象填充实时预约人数
*/
private Mono<GroupCourseDetail> enrichCurrentMembers(GroupCourseDetail detail) {
return bookingRepository.countValidBookings(detail.getId())
.map(count -> {
detail.setCurrentMembers(count.intValue());
return detail;
})
.defaultIfEmpty(detail);
}
/**
* 为团课对象填充实时预约人数
*/
private Mono<GroupCourse> enrichCurrentMembers(GroupCourse course) {
return bookingRepository.countValidBookings(course.getId())
.map(count -> {
course.setCurrentMembers(count.intValue());
return course;
})
.defaultIfEmpty(course);
} }
@Override @Override
public Mono<GroupCourse> findById(Long id) { public Mono<GroupCourse> findById(Long id) {
String cacheKey = CACHE_KEY_ID_PREFIX + id; String cacheKey = CACHE_KEY_ID_PREFIX + id;
return redisUtil.get(cacheKey, String.class) Mono<String> cachedMono = redisUtil.get(cacheKey, String.class);
return cachedMono
.flatMap(cachedJson -> { .flatMap(cachedJson -> {
if (cachedJson != null) { if (cachedJson != null && !cachedJson.isEmpty()) {
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.<GroupCourse>just(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());
@@ -93,7 +262,8 @@ public class GroupCourseService implements IGroupCourseService {
} }
}) })
.doOnSubscribe(sub -> logger.debug("缓存未命中,查询数据库 - findById: id={}", id)) .doOnSubscribe(sub -> logger.debug("缓存未命中,查询数据库 - findById: id={}", id))
); )
.flatMap(this::enrichCurrentMembers);
} }
@Override @Override
@@ -110,6 +280,47 @@ public class GroupCourseService implements IGroupCourseService {
} }
} }
@Override
public Flux<GroupCourseVO> findAllAsVO(boolean includeDeleted) {
return findAll(includeDeleted)
.flatMap(this::enrichCurrentMembers)
.collectList()
.flatMapMany(courses -> {
if (courses.isEmpty()) {
logger.info("findAllAsVO: no courses found");
return Flux.empty();
}
var coachIds = courses.stream()
.map(GroupCourse::getCoachId)
.filter(id -> id != null)
.distinct()
.toList();
logger.info("findAllAsVO: {} courses, {} unique coach IDs: {}", courses.size(), coachIds.size(), coachIds);
if (coachIds.isEmpty()) {
logger.info("findAllAsVO: no coach IDs, returning VOs with null coachName");
return Flux.fromIterable(courses.stream()
.map(c -> GroupCourseVO.from(c, null))
.toList());
}
return Flux.fromIterable(coachIds)
.flatMap(sysUserRepository::findByIdIncludingDeleted)
.doOnNext(u -> logger.info("findAllAsVO: found coach id={}, nickname={}, username={}", u.getId(), u.getNickname(), u.getUsername()))
.collectMap(SysUser::getId, u -> u.getNickname() != null ? u.getNickname() : u.getUsername())
.doOnNext(m -> logger.info("findAllAsVO: coachNameMap size={}, keys={}", m.size(), m.keySet()))
.flatMapMany(coachNameMap ->
Flux.fromIterable(courses.stream()
.map(c -> {
String name = coachNameMap.get(c.getCoachId());
logger.debug("findAllAsVO: courseId={}, coachId={}, coachName={}", c.getId(), c.getCoachId(), name);
return GroupCourseVO.from(c, name);
})
.toList()));
});
}
@Override @Override
public Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted) { public Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted) {
int page = pageRequest.getPage(); int page = pageRequest.getPage();
@@ -117,17 +328,19 @@ public class GroupCourseService implements IGroupCourseService {
String sort = pageRequest.getSort(); String sort = pageRequest.getSort();
String order = pageRequest.getOrder(); String order = pageRequest.getOrder();
String keyword = pageRequest.getKeyword() != null ? pageRequest.getKeyword() : ""; String keyword = pageRequest.getKeyword() != null ? pageRequest.getKeyword() : "";
String status = pageRequest.getStatus() != null ? pageRequest.getStatus() : "";
String cacheKey = CACHE_KEY_PREFIX + page + ":" + size + ":" + includeDeleted + ":" + sort + ":" + order + ":" + keyword; String cacheKey = CACHE_KEY_PREFIX + page + ":" + size + ":" + includeDeleted + ":" + sort + ":" + order + ":" + keyword + ":" + status;
return redisUtil.get(cacheKey, String.class) Mono<String> cachedMono = redisUtil.get(cacheKey, String.class);
return cachedMono
.flatMap(cachedJson -> { .flatMap(cachedJson -> {
if (cachedJson != null) { if (cachedJson != null && !cachedJson.isEmpty()) {
try { try {
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);
return Mono.just(pageResponse); return Mono.<PageResponse<GroupCourse>>just(pageResponse);
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
logger.warn("缓存解析失败,删除缓存 - key: {}, error: {}", cacheKey, e.getMessage()); logger.warn("缓存解析失败,删除缓存 - key: {}, error: {}", cacheKey, e.getMessage());
return redisUtil.delete(cacheKey).then(Mono.empty()); return redisUtil.delete(cacheKey).then(Mono.empty());
@@ -160,10 +373,114 @@ public class GroupCourseService implements IGroupCourseService {
); );
} }
@Override
public Mono<PageResponse<GroupCourseVO>> findByPageAsVO(PageRequest pageRequest, boolean includeDeleted) {
return findByPage(pageRequest, includeDeleted)
.flatMap(pageResponse -> {
List<GroupCourse> courses = pageResponse.getContent();
if (courses.isEmpty()) {
PageResponse<GroupCourseVO> voResponse = new PageResponse<>(
Collections.emptyList(),
pageResponse.getTotalPages(),
pageResponse.getTotalElements(),
pageResponse.getCurrentPage(),
pageResponse.getPageSize()
);
return Mono.just(voResponse);
}
// 用实时预约人数覆盖缓存/DB中的currentMembers
return Flux.fromIterable(courses)
.flatMap(this::enrichCurrentMembers)
.collectList()
.flatMap(enrichedCourses -> {
var coachIds = enrichedCourses.stream()
.map(GroupCourse::getCoachId)
.filter(id -> id != null)
.distinct()
.toList();
logger.info("findByPageAsVO: {} courses, {} unique coach IDs: {}", enrichedCourses.size(), coachIds.size(), coachIds);
if (coachIds.isEmpty()) {
List<GroupCourseVO> voList = enrichedCourses.stream()
.map(c -> GroupCourseVO.from(c, null))
.toList();
PageResponse<GroupCourseVO> voResponse = new PageResponse<>(
voList,
pageResponse.getTotalPages(),
pageResponse.getTotalElements(),
pageResponse.getCurrentPage(),
pageResponse.getPageSize()
);
return Mono.just(voResponse);
}
return Flux.fromIterable(coachIds)
.flatMap(sysUserRepository::findByIdIncludingDeleted)
.doOnNext(u -> logger.info("findByPageAsVO: found coach id={}, nickname={}", u.getId(), u.getNickname()))
.collectMap(SysUser::getId, u -> u.getNickname() != null ? u.getNickname() : u.getUsername())
.map(coachNameMap -> {
List<GroupCourseVO> voList = enrichedCourses.stream()
.map(c -> {
String name = coachNameMap.get(c.getCoachId());
logger.debug("findByPageAsVO: courseId={}, coachId={}, coachName={}", c.getId(), c.getCoachId(), name);
return GroupCourseVO.from(c, name);
})
.toList();
return new PageResponse<>(
voList,
pageResponse.getTotalPages(),
pageResponse.getTotalElements(),
pageResponse.getCurrentPage(),
pageResponse.getPageSize()
);
});
});
});
}
@Override @Override
public Mono<GroupCourse> create(GroupCourse groupCourse) { public Mono<GroupCourse> create(GroupCourse groupCourse) {
return groupCourseRepository.save(groupCourse) return groupCourseRepository.save(groupCourse)
.doOnSuccess(course -> logger.info("团课创建成功 - id={}, name={}", course.getId(), course.getCourseName())) .flatMap(course -> {
try {
// 生成二维码内容团课基础信息JSON
Map<String, Object> qrCodeContent = new HashMap<>();
qrCodeContent.put("id", course.getId());
qrCodeContent.put("courseName", course.getCourseName());
qrCodeContent.put("coachId", course.getCoachId());
qrCodeContent.put("courseType", course.getCourseType());
qrCodeContent.put("startTime", course.getStartTime());
qrCodeContent.put("endTime", course.getEndTime());
qrCodeContent.put("maxMembers", course.getMaxMembers());
qrCodeContent.put("location", course.getLocation());
qrCodeContent.put("description", course.getDescription());
qrCodeContent.put("createdAt", course.getCreatedAt());
String jsonContent = objectMapper.writeValueAsString(qrCodeContent);
// 生成二维码字节数组通过统一文件服务保存
return Mono.fromCallable(() -> QRCodeUtil.generateQrCodeBytes(jsonContent))
.flatMap(qrCodeBytes -> {
String fileName = "qrcode_" + course.getId() + ".png";
return fileService.saveBytes(qrCodeBytes, fileName, "image/png", "system");
})
.flatMap(sysFile -> {
String qrCodeUrl = "/api/files/" + sysFile.getId() + "/preview";
course.setQrCodePath(qrCodeUrl);
logger.info("团课二维码已保存 - id={}, fileId={}, url={}", course.getId(), sysFile.getId(), qrCodeUrl);
return groupCourseRepository.update(course)
.doOnSuccess(updatedCourse -> logger.info("团课创建成功 - id={}, name={}", updatedCourse.getId(), updatedCourse.getCourseName()));
});
} catch (Exception e) {
logger.error("团课二维码生成失败 - id={}, error: {}", course.getId(), e.getMessage(), e);
// 即使二维码生成失败也返回成功创建的团课
return Mono.just(course);
}
})
.flatMap(course -> clearCache().thenReturn(course)) .flatMap(course -> clearCache().thenReturn(course))
.doOnError(error -> logger.error("团课创建失败 - error: {}", error.getMessage())); .doOnError(error -> logger.error("团课创建失败 - error: {}", error.getMessage()));
} }
@@ -203,12 +520,12 @@ public class GroupCourseService implements IGroupCourseService {
if (groupCourse.getDescription() != null) { if (groupCourse.getDescription() != null) {
existing.setDescription(groupCourse.getDescription()); existing.setDescription(groupCourse.getDescription());
} }
if (groupCourse.getPointCardAmount() != null) {
existing.setPointCardAmount(groupCourse.getPointCardAmount());
}
if (groupCourse.getStoredValueAmount() != null) { if (groupCourse.getStoredValueAmount() != null) {
existing.setStoredValueAmount(groupCourse.getStoredValueAmount()); existing.setStoredValueAmount(groupCourse.getStoredValueAmount());
} }
if (groupCourse.getQrCodePath() != null) {
existing.setQrCodePath(groupCourse.getQrCodePath());
}
return groupCourseRepository.update(existing); return groupCourseRepository.update(existing);
}) })
.doOnSuccess(course -> logger.info("团课更新成功 - id={}", id)) .doOnSuccess(course -> logger.info("团课更新成功 - id={}", id))
@@ -258,12 +575,11 @@ public class GroupCourseService implements IGroupCourseService {
return groupCourseRepository.findByIdAndDeletedAtIsNull(courseId) return groupCourseRepository.findByIdAndDeletedAtIsNull(courseId)
.switchIfEmpty(Mono.error(new RuntimeException("课程不存在"))) .switchIfEmpty(Mono.error(new RuntimeException("课程不存在")))
.flatMap(course -> { .flatMap(course -> {
Integer pointCardAmount = course.getPointCardAmount() != null ? course.getPointCardAmount() : 1;
Double storedValueAmount = course.getStoredValueAmount() != null ? Double storedValueAmount = course.getStoredValueAmount() != null ?
course.getStoredValueAmount().doubleValue() : DEFAULT_GROUP_COURSE_PRICE; course.getStoredValueAmount().doubleValue() : DEFAULT_GROUP_COURSE_PRICE;
logger.info("课程权益信息: courseId={}, pointCardAmount={}, storedValueAmount={}", logger.info("课程权益信息: courseId={}, storedValueAmount={}",
courseId, pointCardAmount, storedValueAmount); courseId, storedValueAmount);
return bookingRepository.findByCourseId(courseId) return bookingRepository.findByCourseId(courseId)
.filter(booking -> "0".equals(booking.getStatus())) // 只处理已预约状态的记录 .filter(booking -> "0".equals(booking.getStatus())) // 只处理已预约状态的记录
@@ -274,8 +590,8 @@ public class GroupCourseService implements IGroupCourseService {
return Mono.empty(); return Mono.empty();
} }
// 根据会员卡类型返还权益 // 根据会员卡类型返还权益仅储值卡返还金额
return refundCardUsage(booking.getId(), memberCardRecordId, pointCardAmount, storedValueAmount) return refundCardUsage(booking.getId(), memberCardRecordId, storedValueAmount)
.doOnSuccess(unused -> logger.info("会员权益返还成功: bookingId={}, memberId={}", .doOnSuccess(unused -> logger.info("会员权益返还成功: bookingId={}, memberId={}",
booking.getId(), booking.getMemberId())) booking.getId(), booking.getMemberId()))
.doOnError(error -> logger.error("会员权益返还失败: bookingId={}, memberId={}, error={}", .doOnError(error -> logger.error("会员权益返还失败: bookingId={}, memberId={}, error={}",
@@ -291,11 +607,10 @@ public class GroupCourseService implements IGroupCourseService {
* *
* @param bookingId 预约ID * @param bookingId 预约ID
* @param recordId 会员卡记录ID * @param recordId 会员卡记录ID
* @param pointCardAmount 次卡返还次数
* @param storedValueAmount 储值卡返还金额 * @param storedValueAmount 储值卡返还金额
* @return 空Mono * @return 空Mono
*/ */
private Mono<Void> refundCardUsage(Long bookingId, Long recordId, Integer pointCardAmount, Double storedValueAmount) { private Mono<Void> refundCardUsage(Long bookingId, Long recordId, Double storedValueAmount) {
return memberCardRecordService.findById(recordId) return memberCardRecordService.findById(recordId)
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在"))) .switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
.flatMap(record -> { .flatMap(record -> {
@@ -306,10 +621,9 @@ public class GroupCourseService implements IGroupCourseService {
Mono<Void> refundOperation; Mono<Void> refundOperation;
switch (cardType) { switch (cardType) {
case COUNT_CARD: case COUNT_CARD:
// 次卡返还次数基于课程设置的pointCardAmount // 次卡不再支持团课预约无需返还
logger.debug("返还次卡次数: recordId={}, amount={}", recordId, pointCardAmount); logger.debug("次卡不返还权益: bookingId={}", bookingId);
refundOperation = memberCardRecordService.renewCard(recordId, pointCardAmount, 0.0, record.getExpireTime()) refundOperation = Mono.empty();
.then(Mono.empty());
break; break;
case STORED_VALUE_CARD: case STORED_VALUE_CARD:
// 储值卡返还金额基于课程设置的storedValueAmount // 储值卡返还金额基于课程设置的storedValueAmount
@@ -341,27 +655,49 @@ public class GroupCourseService implements IGroupCourseService {
@Override @Override
public Mono<GroupCourse> signIn(Long courseId, Long memberId) { public Mono<GroupCourse> signIn(Long courseId, Long memberId) {
return groupCourseRepository.findByIdAndDeletedAtIsNull(courseId) return groupCourseRepository.findByIdAndDeletedAtIsNull(courseId)
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在"))) .switchIfEmpty(Mono.error(new RuntimeException("团课不存在或已删除")))
.flatMap(course ->
// 用实时预约人数覆盖currentMembers
bookingRepository.countValidBookings(courseId)
.map(count -> {
course.setCurrentMembers(count.intValue());
return course;
})
.defaultIfEmpty(course)
)
.flatMap(course -> { .flatMap(course -> {
if (course.getStatus() != 0L) { // 校验1团课已取消
return Mono.error(new RuntimeException("课程状态不允许签到")); if (course.getStatus().equals(CourseStatus.CANCELLED.getValue())) {
return Mono.error(new RuntimeException("团课已取消,无法签到"));
} }
// 校验2团课已结束
if (course.getStatus().equals(CourseStatus.ENDED.getValue())) {
return Mono.error(new RuntimeException("团课已结束,无法签到"));
}
// 校验3签到时间校验开课前2小时内才能签到
LocalDateTime now = LocalDateTime.now();
LocalDateTime signInStart = course.getStartTime().minusHours(2);
if (now.isBefore(signInStart)) {
return Mono.error(new RuntimeException("未到签到时间,请在开课前2小时内签到"));
}
if (now.isAfter(course.getEndTime())) {
return Mono.error(new RuntimeException("团课已结束,无法签到"));
}
// 校验4课程已满员
if (course.getCurrentMembers() >= course.getMaxMembers()) { if (course.getCurrentMembers() >= course.getMaxMembers()) {
return Mono.error(new RuntimeException("课程已满员")); return Mono.error(new RuntimeException("课程已满员,无法签到"));
} }
// 检查会员是否已预约此课程 // 校验5用户已预约此课程有效预约状态为0-已预约
return bookingRepository.findValidBooking(courseId, memberId) return bookingRepository.findValidBooking(courseId, memberId)
.switchIfEmpty(Mono.error(new RuntimeException("会员未预约此课"))) .switchIfEmpty(Mono.error(new RuntimeException("未预约此")))
.flatMap(booking -> { .flatMap(booking -> {
// 更新课程当前人 // 更新预约状态为已出席2不再手动计
return groupCourseRepository.updateCurrentMembers(courseId, 1)
.flatMap(updatedCourse -> {
// 更新预约状态为已出席
return bookingRepository.updateStatus(booking.getId(), "2") return bookingRepository.updateStatus(booking.getId(), "2")
.thenReturn(updatedCourse); .thenReturn(course);
});
}); });
}) })
.doOnSuccess(course -> logger.info("团课签到成功 - courseId={}, memberId={}", courseId, memberId)) .doOnSuccess(course -> logger.info("团课签到成功 - courseId={}, memberId={}", courseId, memberId))
@@ -371,14 +707,15 @@ 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 // 检查课程状态是否为已取消状态码1或已结束状态码2
if (course.getStatus() == null || !course.getStatus().equals(CourseStatus.CANCELLED.getValue())) { Long status = course.getStatus();
return Mono.error(new RuntimeException("只有已取消的课程才能删除,当前状态: " + if (status == null || (!status.equals(CourseStatus.CANCELLED.getValue()) && !status.equals(CourseStatus.ENDED.getValue()))) {
(course.getStatus() != null ? course.getStatus() : "未知"))); return Mono.error(new RuntimeException("只有已取消或已结束的课程才能删除,当前状态: " +
(status != null ? status : "未知")));
} }
// 删除课程 // 删除课程
@@ -389,8 +726,52 @@ public class GroupCourseService implements IGroupCourseService {
}); });
} }
@Override
public Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query) {
logger.info("多条件查询团课 - courseName={}, courseType={}, startDate={}, endDate={}, timePeriod={}, priceSort={}, remainingMost={}",
query.getCourseName(), query.getCourseType(), query.getStartDate(), query.getEndDate(),
query.getTimePeriod(), query.getPriceSort(), query.getRemainingMost());
return groupCourseRepository.searchGroupCourses(query)
.doOnSuccess(result -> logger.info("多条件查询结果 - total={}, page={}, size={}",
result.getTotalElements(), result.getCurrentPage(), result.getPageSize()))
.doOnError(error -> logger.error("多条件查询失败 - error: {}", error.getMessage()));
}
private Mono<Void> clearCache() { private Mono<Void> clearCache() {
return redisUtil.deleteByPattern(CACHE_KEY_PREFIX + "*") return redisUtil.deleteByPattern(CACHE_KEY_PREFIX + "*")
.then(redisUtil.deleteByPattern(CACHE_KEY_ID_PREFIX + "*")).then(); .then(redisUtil.deleteByPattern(CACHE_KEY_ID_PREFIX + "*"))
.then(redisUtil.deleteByPattern(CACHE_KEY_DETAIL_PREFIX + "*"))
.then(redisUtil.deleteByPattern("datacount:statistics:*"))
.then();
}
@Override
public Mono<List<GroupCourse>> checkCoachConflict(Long coachId, LocalDateTime startTime, LocalDateTime endTime, Long excludeCourseId) {
if (coachId == null || startTime == null || endTime == null) {
return Mono.just(Collections.emptyList());
}
return groupCourseRepository.findByCoachId(coachId)
.filter(course -> {
// 排除已取消的课程
if (course.getStatus() != null && course.getStatus().equals(CourseStatus.CANCELLED.getValue())) {
return false;
}
// 排除自身编辑时
if (excludeCourseId != null && course.getId() != null && course.getId().equals(excludeCourseId)) {
return false;
}
// 检查时间是否重叠startTime < course.endTime AND course.startTime < endTime
if (course.getStartTime() == null || course.getEndTime() == null) {
return false;
}
return startTime.isBefore(course.getEndTime()) && course.getStartTime().isBefore(endTime);
})
.collectList()
.doOnNext(conflicts -> {
if (!conflicts.isEmpty()) {
logger.info("checkCoachConflict: coachId={}, conflicts={} courses", coachId, conflicts.size());
}
});
} }
} }
@@ -0,0 +1,105 @@
package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.common.dto.PageResponse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Service
public class GroupCourseTypeService implements IGroupCourseTypeService {
private static final Logger logger = LoggerFactory.getLogger(GroupCourseTypeService.class);
private final IGroupCourseTypeRepository groupCourseTypeRepository;
private final IGroupCourseRepository groupCourseRepository;
public GroupCourseTypeService(IGroupCourseTypeRepository groupCourseTypeRepository,
IGroupCourseRepository groupCourseRepository) {
this.groupCourseTypeRepository = groupCourseTypeRepository;
this.groupCourseRepository = groupCourseRepository;
}
@Override
public Mono<GroupCourseType> findById(Long id) {
return groupCourseTypeRepository.findById(id);
}
@Override
public Flux<GroupCourseType> findAll() {
return groupCourseTypeRepository.findAll(false);
}
@Override
public Flux<GroupCourseType> findAll(boolean includeDeleted) {
return groupCourseTypeRepository.findAll(includeDeleted);
}
@Override
public Flux<GroupCourseType> findByKeyword(String keyword) {
return groupCourseTypeRepository.findByKeyword(keyword);
}
@Override
public Flux<GroupCourseType> findByCategory(String category) {
return groupCourseTypeRepository.findByCategory(category);
}
@Override
public Flux<GroupCourseType> findByCategoryAndKeyword(String category, String keyword) {
return groupCourseTypeRepository.findByCategoryAndKeyword(category, keyword);
}
@Override
public Mono<GroupCourseType> create(GroupCourseType groupCourseType) {
return groupCourseTypeRepository.findByTypeName(groupCourseType.getTypeName())
.flatMap(existing -> Mono.<GroupCourseType>error(new RuntimeException("团课类型名称已存在")))
.switchIfEmpty(groupCourseTypeRepository.save(groupCourseType))
.doOnSuccess(type -> logger.info("团课类型创建成功 - id={}, name={}", type.getId(), type.getTypeName()))
.doOnError(error -> logger.error("团课类型创建失败 - error: {}", error.getMessage()));
}
@Override
public Mono<GroupCourseType> update(Long id, GroupCourseType groupCourseType) {
return groupCourseTypeRepository.update(groupCourseType)
.doOnSuccess(type -> logger.info("团课类型更新成功 - id={}", id))
.doOnError(error -> logger.error("团课类型更新失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<Void> delete(Long id) {
// 检查是否有团课依赖该类型
return groupCourseRepository.findByCourseType(id)
.hasElements()
.flatMap(hasDependents -> {
if (hasDependents) {
return Mono.<Void>error(new RuntimeException("该类型下存在团课,无法删除"));
}
return groupCourseTypeRepository.deleteById(id)
.doOnSuccess(v -> logger.info("团课类型删除成功 - id={}", id))
.doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage()));
});
}
@Override
public Flux<String> findCategories() {
return groupCourseTypeRepository.findAll(false)
.map(GroupCourseType::getCategory)
.filter(category -> category != null && !category.isEmpty())
.distinct();
}
@Override
public Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest) {
return groupCourseTypeRepository.findByPage(pageRequest)
.doOnSuccess(result -> logger.info("分页查询团课类型成功 - page={}, size={}, total={}",
pageRequest.getPage(), pageRequest.getSize(), result.getTotalElements()))
.doOnError(error -> logger.error("分页查询团课类型失败 - error: {}", error.getMessage()));
}
}
@@ -0,0 +1,65 @@
package cn.novalon.gym.manage.groupcourse.util;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
/**
* 二维码生成工具类
* 生成二维码的字节数组由调用方统一通过文件服务保存
*/
public class QRCodeUtil {
private static final Logger logger = LoggerFactory.getLogger(QRCodeUtil.class);
// 二维码尺寸
private static final int QR_CODE_WIDTH = 300;
private static final int QR_CODE_HEIGHT = 300;
/**
* 生成二维码图片的字节数组
*
* @param content 二维码内容
* @return PNG格式的二维码图片字节数组
*/
public static byte[] generateQrCodeBytes(String content) {
try {
Map<EncodeHintType, Object> hints = new HashMap<>();
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
hints.put(EncodeHintType.MARGIN, 1);
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, QR_CODE_WIDTH, QR_CODE_HEIGHT, hints);
BufferedImage image = MatrixToImageWriter.toBufferedImage(bitMatrix);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, "PNG", baos);
byte[] bytes = baos.toByteArray();
baos.close();
logger.info("二维码字节数组生成成功, size={} bytes", bytes.length);
return bytes;
} catch (WriterException e) {
logger.error("二维码生成失败 - WriterException: {}", e.getMessage(), e);
throw new RuntimeException("二维码生成失败: " + e.getMessage(), e);
} catch (IOException e) {
logger.error("二维码输出失败 - IOException: {}", e.getMessage(), e);
throw new RuntimeException("二维码输出失败: " + e.getMessage(), e);
}
}
}
@@ -0,0 +1,134 @@
package cn.novalon.gym.manage.groupcourse.vo;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 团课视图对象含教练昵称
*
* @author 张翔
* @date 2026-07-19
*/
public class GroupCourseVO {
private Long id;
private String courseName;
private Long coachId;
private String coachName;
private Long courseType;
private LocalDateTime startTime;
private LocalDateTime endTime;
private LocalDateTime actualStartTime;
private LocalDateTime actualEndTime;
private Integer maxMembers;
private Integer currentMembers;
private Long status;
private String location;
private String coverImage;
private String description;
private BigDecimal storedValueAmount;
private String qrCodePath;
private String createBy;
private String updateBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public GroupCourseVO() {}
/**
* 从领域对象和教练名称构建 VO
*/
public static GroupCourseVO from(GroupCourse course, String coachName) {
GroupCourseVO vo = new GroupCourseVO();
vo.setId(course.getId());
vo.setCourseName(course.getCourseName());
vo.setCoachId(course.getCoachId());
vo.setCoachName(coachName);
vo.setCourseType(course.getCourseType());
vo.setStartTime(course.getStartTime());
vo.setEndTime(course.getEndTime());
vo.setActualStartTime(course.getActualStartTime());
vo.setActualEndTime(course.getActualEndTime());
vo.setMaxMembers(course.getMaxMembers());
vo.setCurrentMembers(course.getCurrentMembers());
vo.setStatus(course.getStatus());
vo.setLocation(course.getLocation());
vo.setCoverImage(course.getCoverImage());
vo.setDescription(course.getDescription());
vo.setStoredValueAmount(course.getStoredValueAmount());
vo.setQrCodePath(course.getQrCodePath());
vo.setCreateBy(course.getCreateBy());
vo.setUpdateBy(course.getUpdateBy());
vo.setCreatedAt(course.getCreatedAt());
vo.setUpdatedAt(course.getUpdatedAt());
return vo;
}
// -- getters / setters --
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getCourseName() { return courseName; }
public void setCourseName(String courseName) { this.courseName = courseName; }
public Long getCoachId() { return coachId; }
public void setCoachId(Long coachId) { this.coachId = coachId; }
public String getCoachName() { return coachName; }
public void setCoachName(String coachName) { this.coachName = coachName; }
public Long getCourseType() { return courseType; }
public void setCourseType(Long courseType) { this.courseType = courseType; }
public LocalDateTime getStartTime() { return startTime; }
public void setStartTime(LocalDateTime startTime) { this.startTime = startTime; }
public LocalDateTime getEndTime() { return endTime; }
public void setEndTime(LocalDateTime endTime) { this.endTime = endTime; }
public LocalDateTime getActualStartTime() { return actualStartTime; }
public void setActualStartTime(LocalDateTime actualStartTime) { this.actualStartTime = actualStartTime; }
public LocalDateTime getActualEndTime() { return actualEndTime; }
public void setActualEndTime(LocalDateTime actualEndTime) { this.actualEndTime = actualEndTime; }
public Integer getMaxMembers() { return maxMembers; }
public void setMaxMembers(Integer maxMembers) { this.maxMembers = maxMembers; }
public Integer getCurrentMembers() { return currentMembers; }
public void setCurrentMembers(Integer currentMembers) { this.currentMembers = currentMembers; }
public Long getStatus() { return status; }
public void setStatus(Long status) { this.status = status; }
public String getLocation() { return location; }
public void setLocation(String location) { this.location = location; }
public String getCoverImage() { return coverImage; }
public void setCoverImage(String coverImage) { this.coverImage = coverImage; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public BigDecimal getStoredValueAmount() { return storedValueAmount; }
public void setStoredValueAmount(BigDecimal storedValueAmount) { this.storedValueAmount = storedValueAmount; }
public String getQrCodePath() { return qrCodePath; }
public void setQrCodePath(String qrCodePath) { this.qrCodePath = qrCodePath; }
public String getCreateBy() { return createBy; }
public void setCreateBy(String createBy) { this.createBy = createBy; }
public String getUpdateBy() { return updateBy; }
public void setUpdateBy(String updateBy) { this.updateBy = updateBy; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
@@ -1,13 +0,0 @@
package cn.novalon.gym.manage.groupcourse;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class GymGroupCourseApplicationTests {
@Test
void contextLoads() {
}
}
@@ -0,0 +1,49 @@
package cn.novalon.gym.manage.groupcourse.util;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* QRCodeUtil测试类
*/
class QRCodeUtilTest {
@Test
void testGenerateQrCodeBytes() {
String content = "测试二维码内容";
byte[] bytes = QRCodeUtil.generateQrCodeBytes(content);
assertNotNull(bytes, "二维码字节数组不应为空");
assertTrue(bytes.length > 0, "二维码字节数组应包含数据");
System.out.println("生成的二维码字节大小: " + bytes.length + " bytes");
}
@Test
void testGenerateQrCodeBytesWithJsonContent() {
String jsonContent = "{\"id\":1,\"courseName\":\"瑜伽课\",\"coachId\":100,\"startTime\":\"2026-07-14T10:00:00\"}";
byte[] bytes = QRCodeUtil.generateQrCodeBytes(jsonContent);
assertNotNull(bytes, "二维码字节数组不应为空");
assertTrue(bytes.length > 0, "二维码字节数组应包含数据");
System.out.println("JSON内容二维码字节大小: " + bytes.length + " bytes");
}
@Test
void testGenerateQrCodeBytesWithLongContent() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100; i++) {
sb.append("这是第").append(i).append("行测试数据\n");
}
byte[] bytes = QRCodeUtil.generateQrCodeBytes(sb.toString());
assertNotNull(bytes, "二维码字节数组不应为空");
assertTrue(bytes.length > 0, "长内容二维码应正常生成");
System.out.println("长内容二维码字节大小: " + bytes.length + " bytes");
}
}
+1 -1
View File
@@ -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>
@@ -16,6 +16,9 @@ import org.springframework.stereotype.Component;
@ConfigurationProperties(prefix = "wechat") @ConfigurationProperties(prefix = "wechat")
public class WechatProperties { public class WechatProperties {
// Mock模式true=使用模拟数据false=调用真实微信API
private Boolean mockEnabled;
// 小程序配置 // 小程序配置
private MiniApp miniapp = new MiniApp(); private MiniApp miniapp = new MiniApp();
@@ -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;
} }
@@ -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;
} }
@@ -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;
}
@@ -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;
}
@@ -31,7 +31,7 @@ public class MemberCardHandler {
@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());
@@ -63,13 +63,24 @@ public class MemberCardHandler {
@Operation(summary = "更新会员卡类型", description = "更新会员卡类型信息") @Operation(summary = "更新会员卡类型", description = "更新会员卡类型信息")
public Mono<ServerResponse> updateMemberCard(ServerRequest request) { public Mono<ServerResponse> updateMemberCard(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(MemberCard.class) return memberCardService.findByMemberCardIdAndDeletedAtIsNull(id)
.flatMap(card -> { .switchIfEmpty(Mono.error(new RuntimeException("会员卡不存在: " + id)))
card.setMemberCardId(id); .zipWith(request.bodyToMono(MemberCard.class))
return memberCardService.save(card); .flatMap(tuple -> {
MemberCard existing = tuple.getT1();
MemberCard body = tuple.getT2();
// 仅更新非 null 字段避免 null 覆盖已有值
if (body.getMemberCardName() != null) existing.setMemberCardName(body.getMemberCardName());
if (body.getMemberCardType() != null) existing.setMemberCardType(body.getMemberCardType());
if (body.getMemberCardPrice() != null) existing.setMemberCardPrice(body.getMemberCardPrice());
if (body.getMemberCardValidityDays() != null) existing.setMemberCardValidityDays(body.getMemberCardValidityDays());
if (body.getMemberCardTotalTimes() != null) existing.setMemberCardTotalTimes(body.getMemberCardTotalTimes());
if (body.getMemberCardAmount() != null) existing.setMemberCardAmount(body.getMemberCardAmount());
if (body.getMemberCardStatus() != null) existing.setMemberCardStatus(body.getMemberCardStatus());
if (body.getExtraConfig() != null) existing.setExtraConfig(body.getExtraConfig());
return memberCardService.save(existing);
}) })
.flatMap(updated -> ServerResponse.ok().bodyValue(updated)) .flatMap(updated -> ServerResponse.ok().bodyValue(updated));
.switchIfEmpty(ServerResponse.notFound().build());
} }
@Operation(summary = "删除会员卡类型", description = "逻辑删除会员卡类型") @Operation(summary = "删除会员卡类型", description = "逻辑删除会员卡类型")
@@ -97,29 +108,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());
} }
@@ -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;
} }

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