Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5ca726ded | ||
|
|
54a65b2a74 | ||
|
|
9446f66567 | ||
|
|
7465074eb7 | ||
|
|
e0e707edcb | ||
|
|
8ab528a74b | ||
|
|
de7a359ead | ||
|
|
fbb41911a2 | ||
|
|
223a427614 | ||
|
|
78c80c4b1d | ||
|
|
8958ffaa9e | ||
|
|
8af444b7ee | ||
|
|
bdcd3b2bf0 | ||
|
|
544ddeec5a | ||
|
|
2f070e6b44 | ||
|
|
de01a39848 | ||
|
|
03991319fd | ||
|
|
a8c7a4061e | ||
|
|
0d2ed2aba5 | ||
|
|
906512f294 | ||
|
|
a7f699a8b4 | ||
|
|
2df598c0d8 | ||
|
|
174e33053e | ||
|
|
29b73c1f67 |
@@ -0,0 +1,272 @@
|
||||
# V10 团课预约业务场景测试数据
|
||||
|
||||
## 概述
|
||||
|
||||
本文档描述了 V10 版本新增的团课预约功能测试数据,覆盖了7种典型的预约业务场景,用于验证系统在不同会员卡状态下的正确处理逻辑。
|
||||
|
||||
**当前日期**: 2026-06-02
|
||||
|
||||
---
|
||||
|
||||
## 一、测试场景总览
|
||||
|
||||
| 场景 | 会员 | 会员卡状态 | 预约课程 | 预期结果 |
|
||||
|-----|------|----------|---------|---------|
|
||||
| 1 | 用户A | 无会员卡 | 次数卡团课 | 提示无可用会员卡 |
|
||||
| 2 | 用户B | 时长卡已过期 | 次数卡团课 | 提示会员卡已过期 |
|
||||
| 3 | 会员C | 时长卡有效 | 次数卡团课 | 预约成功 |
|
||||
| 4 | 会员D | 次数卡次数=0 | 次数卡团课 | 提示剩余次数不足 |
|
||||
| 5 | 会员E | 次数卡次数=5 | 次数卡团课 | 预约成功 |
|
||||
| 6 | 会员F | 储值卡余额=30元 | 储值卡团课(需50元) | 提示余额不足 |
|
||||
| 7 | 会员G | 储值卡余额=200元 | 储值卡团课(需50元) | 预约成功 |
|
||||
|
||||
---
|
||||
|
||||
## 二、测试会员数据
|
||||
|
||||
### 2.1 会员列表
|
||||
|
||||
| 会员编号 | 昵称 | 手机号 | 性别 | 创建时间 |
|
||||
|---------|------|--------|------|---------|
|
||||
| MEM_TEST_A | 用户A_无卡新用户 | 13800001001 | 男 | 2026-06-02 10:00:00 |
|
||||
| MEM_TEST_B | 用户B_过期时长卡 | 13800001002 | 男 | 2026-05-01 10:00:00 |
|
||||
| MEM_TEST_C | 会员C_有效时长卡 | 13800001003 | 女 | 2026-05-01 10:00:00 |
|
||||
| MEM_TEST_D | 会员D_次数用尽 | 13800001004 | 男 | 2026-05-01 10:00:00 |
|
||||
| MEM_TEST_E | 会员E_次数充足 | 13800001005 | 女 | 2026-05-01 10:00:00 |
|
||||
| MEM_TEST_F | 会员F_储值卡余额不足 | 13800001006 | 男 | 2026-05-01 10:00:00 |
|
||||
| MEM_TEST_G | 会员G_储值卡余额充足 | 13800001007 | 女 | 2026-05-01 10:00:00 |
|
||||
|
||||
### 2.2 会员场景说明
|
||||
|
||||
#### 场景1: 用户A - 无会员卡新用户
|
||||
- **会员编号**: MEM_TEST_A
|
||||
- **状态**: 2026-06-02 刚注册,尚未购买任何会员卡
|
||||
- **测试目标**: 验证无会员卡用户申请团课预约时的错误提示
|
||||
|
||||
#### 场景2: 用户B - 时长卡已过期
|
||||
- **会员编号**: MEM_TEST_B
|
||||
- **状态**: 持有30天时长卡,于 2026-05-31 23:59:59 到期
|
||||
- **测试目标**: 验证会员卡过期后的预约行为
|
||||
|
||||
#### 场景3: 会员C - 时长卡有效
|
||||
- **会员编号**: MEM_TEST_C
|
||||
- **状态**: 持有30天时长卡,到期时间 2026-07-02
|
||||
- **测试目标**: 验证有效期内的时长卡可以正常预约次数卡团课
|
||||
|
||||
#### 场景4: 会员D - 次数卡耗尽
|
||||
- **会员编号**: MEM_TEST_D
|
||||
- **状态**: 持有10次卡,剩余次数 = 0
|
||||
- **测试目标**: 验证次数耗尽后无法预约团课
|
||||
|
||||
#### 场景5: 会员E - 次数卡充足
|
||||
- **会员编号**: MEM_TEST_E
|
||||
- **状态**: 持有10次卡,剩余次数 = 5
|
||||
- **测试目标**: 验证次数充足时正常预约
|
||||
|
||||
#### 场景6: 会员F - 储值卡余额不足
|
||||
- **会员编号**: MEM_TEST_F
|
||||
- **状态**: 持有储值卡,余额 = 30.00 元
|
||||
- **测试目标**: 验证预约储值卡团课(需50元)时提示余额不足
|
||||
|
||||
#### 场景7: 会员G - 储值卡余额充足
|
||||
- **会员编号**: MEM_TEST_G
|
||||
- **状态**: 持有储值卡,余额 = 200.00 元
|
||||
- **测试目标**: 验证余额充足时正常预约储值卡团课
|
||||
|
||||
---
|
||||
|
||||
## 三、团课课程数据
|
||||
|
||||
### 3.1 团课列表
|
||||
|
||||
| 课程名称 | 教练ID | 课程类型 | 开始时间 | 结束时间 | 最大人数 | 当前人数 | 状态 | 点卡额度 | 储值额度 |
|
||||
|---------|-------|---------|---------|---------|---------|---------|------|---------|---------|
|
||||
| 燃脂搏击_次数卡课程 | 102 | 2 | 2026-06-10 19:30:00 | 2026-06-10 20:30:00 | 20 | 0 | 正常 | 1 | 0.00 |
|
||||
| 高端普拉提_储值卡课程 | 103 | 1 | 2026-06-11 19:00:00 | 2026-06-11 20:00:00 | 15 | 0 | 正常 | 0 | 50.00 |
|
||||
|
||||
### 3.2 课程说明
|
||||
|
||||
**燃脂搏击_次数卡课程**
|
||||
- **收费类型**: 次数卡
|
||||
- **消耗点数**: 1次
|
||||
- **适用会员**: 时长卡、次数卡用户
|
||||
- **课程描述**: 高强度间歇训练,配合音乐快速燃脂
|
||||
|
||||
**高端普拉提_储值卡课程**
|
||||
- **收费类型**: 储值卡
|
||||
- **消耗金额**: 50.00 元
|
||||
- **适用会员**: 储值卡用户
|
||||
- **课程描述**: 精准训练核心肌群
|
||||
|
||||
---
|
||||
|
||||
## 四、会员卡类型数据
|
||||
|
||||
| 卡名称 | 卡类型 | 价格 | 有效天数 | 总次数 | 面额 | 状态 |
|
||||
|-------|-------|------|---------|-------|------|------|
|
||||
| 30天时长卡 | TIME_CARD | 299.00 | 30 | - | - | 上架 |
|
||||
| 10次卡 | COUNT_CARD | 499.00 | - | 10 | - | 上架 |
|
||||
| 储值卡500 | STORED_VALUE_CARD | 500.00 | - | - | 500.00 | 上架 |
|
||||
|
||||
---
|
||||
|
||||
## 五、会员卡记录数据
|
||||
|
||||
### 5.1 记录列表
|
||||
|
||||
| 会员编号 | 卡名称 | 状态 | 剩余次数 | 剩余金额 | 过期时间 | 购买时间 |
|
||||
|---------|-------|------|---------|---------|---------|---------|
|
||||
| MEM_TEST_B | 30天时长卡 | EXPIRED | 0 | 0.00 | 2026-05-31 | 2026-05-01 |
|
||||
| MEM_TEST_C | 30天时长卡 | ACTIVE | 0 | 0.00 | 2026-07-02 | 2026-06-02 |
|
||||
| MEM_TEST_D | 10次卡 | ACTIVE | 0 | 0.00 | 2026-12-31 | 2026-05-01 |
|
||||
| MEM_TEST_E | 10次卡 | ACTIVE | 5 | 0.00 | 2026-12-31 | 2026-05-01 |
|
||||
| MEM_TEST_F | 储值卡500 | ACTIVE | 0 | 30.00 | 2027-06-02 | 2026-05-01 |
|
||||
| MEM_TEST_G | 储值卡500 | ACTIVE | 0 | 200.00 | 2027-06-02 | 2026-05-01 |
|
||||
|
||||
### 5.2 详细说明
|
||||
|
||||
**用户B - 时长卡已过期**
|
||||
```
|
||||
会员ID: MEM_TEST_B
|
||||
卡名称: 30天时长卡
|
||||
状态: EXPIRED (已过期)
|
||||
有效期: 2026-05-01 至 2026-05-31
|
||||
剩余次数: 0
|
||||
剩余金额: 0.00
|
||||
```
|
||||
|
||||
**会员C - 时长卡有效**
|
||||
```
|
||||
会员ID: MEM_TEST_C
|
||||
卡名称: 30天时长卡
|
||||
状态: ACTIVE (有效)
|
||||
有效期: 2026-06-02 至 2026-07-02
|
||||
剩余次数: 0
|
||||
剩余金额: 0.00
|
||||
```
|
||||
|
||||
**会员D - 次数卡耗尽**
|
||||
```
|
||||
会员ID: MEM_TEST_D
|
||||
卡名称: 10次卡
|
||||
状态: ACTIVE (有效)
|
||||
有效期: 2026-05-01 至 2026-12-31
|
||||
剩余次数: 0 (已用尽)
|
||||
剩余金额: 0.00
|
||||
```
|
||||
|
||||
**会员E - 次数卡充足**
|
||||
```
|
||||
会员ID: MEM_TEST_E
|
||||
卡名称: 10次卡
|
||||
状态: ACTIVE (有效)
|
||||
有效期: 2026-05-01 至 2026-12-31
|
||||
剩余次数: 5
|
||||
剩余金额: 0.00
|
||||
```
|
||||
|
||||
**会员F - 储值卡余额不足**
|
||||
```
|
||||
会员ID: MEM_TEST_F
|
||||
卡名称: 储值卡500
|
||||
状态: ACTIVE (有效)
|
||||
有效期: 2026-05-01 至 2027-06-02
|
||||
剩余次数: 0
|
||||
剩余金额: 30.00 (不足50.00)
|
||||
```
|
||||
|
||||
**会员G - 储值卡余额充足**
|
||||
```
|
||||
会员ID: MEM_TEST_G
|
||||
卡名称: 储值卡500
|
||||
状态: ACTIVE (有效)
|
||||
有效期: 2026-05-01 至 2027-06-02
|
||||
剩余次数: 0
|
||||
剩余金额: 200.00 (充足)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、SQL数据插入顺序
|
||||
|
||||
```sql
|
||||
-- 1. 先插入会员用户 (A-G)
|
||||
INSERT INTO member_user (member_no, nickname, phone, gender, is_deleted, created_at, updated_at) VALUES
|
||||
('MEM_TEST_A', '用户A_无卡新用户', '13800001001', 1, false, '2026-06-02 10:00:00', '2026-06-02 10:00:00'),
|
||||
('MEM_TEST_B', '用户B_过期时长卡', '13800001002', 1, false, '2026-05-01 10:00:00', '2026-05-01 10:00:00'),
|
||||
('MEM_TEST_C', '会员C_有效时长卡', '13800001003', 2, false, '2026-05-01 10:00:00', '2026-05-01 10:00:00'),
|
||||
('MEM_TEST_D', '会员D_次数用尽', '13800001004', 1, false, '2026-05-01 10:00:00', '2026-05-01 10:00:00'),
|
||||
('MEM_TEST_E', '会员E_次数充足', '13800001005', 2, false, '2026-05-01 10:00:00', '2026-05-01 10:00:00'),
|
||||
('MEM_TEST_F', '会员F_储值卡余额不足', '13800001006', 1, false, '2026-05-01 10:00:00', '2026-05-01 10:00:00'),
|
||||
('MEM_TEST_G', '会员G_储值卡余额充足', '13800001007', 2, false, '2026-05-01 10:00:00', '2026-05-01 10:00:00');
|
||||
|
||||
-- 2. 插入团课课程
|
||||
INSERT INTO group_course (course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, description, point_card_amount, stored_value_amount, create_by, created_at, updated_at) VALUES
|
||||
('燃脂搏击_次数卡课程', 102, 2, '2026-06-10 19:30:00', '2026-06-10 20:30:00', 20, 0, '0', '综合训练区', '高强度间歇训练,配合音乐快速燃脂,消耗1次', 1, 0.00, 'admin', '2026-06-01 10:00:00', '2026-06-01 10:00:00');
|
||||
|
||||
INSERT INTO group_course (course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, description, point_card_amount, stored_value_amount, create_by, created_at, updated_at) VALUES
|
||||
('高端普拉提_储值卡课程', 103, 1, '2026-06-11 19:00:00', '2026-06-11 20:00:00', 15, 0, '0', '普拉提教室', '精准训练核心肌群,消耗储值50元', 0, 50.00, 'admin', '2026-06-01 10:00:00', '2026-06-01 10:00:00');
|
||||
|
||||
-- 3. 插入会员卡类型
|
||||
INSERT INTO member_card (member_card_name, member_card_type, member_card_price, member_card_validity_days, member_card_total_times, member_card_amount, member_card_status, extra_config, created_at, updated_at) VALUES
|
||||
('30天时长卡', 'TIME_CARD', 299.00, 30, NULL, NULL, 1, '{}', '2026-01-01 00:00:00', '2026-01-01 00:00:00'),
|
||||
('10次卡', 'COUNT_CARD', 499.00, NULL, 10, NULL, 1, '{}', '2026-01-01 00:00:00', '2026-01-01 00:00:00'),
|
||||
('储值卡500', 'STORED_VALUE_CARD', 500.00, NULL, NULL, 500.00, 1, '{}', '2026-01-01 00:00:00', '2026-01-01 00:00:00');
|
||||
|
||||
-- 4. 插入会员卡记录
|
||||
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
||||
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_B'), (SELECT member_card_id FROM member_card WHERE member_card_name = '30天时长卡'), 'EXPIRED', 0, 0.00, '2026-05-31 23:59:59', '2026-05-01 10:00:00', 0, '{}', '2026-05-01 10:00:00', '2026-06-02 10:00:00');
|
||||
|
||||
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
||||
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_C'), (SELECT member_card_id FROM member_card WHERE member_card_name = '30天时长卡'), 'ACTIVE', 0, 0.00, '2026-07-02 23:59:59', '2026-06-02 10:00:00', 0, '{}', '2026-06-02 10:00:00', '2026-06-02 10:00:00');
|
||||
|
||||
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
||||
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_D'), (SELECT member_card_id FROM member_card WHERE member_card_name = '10次卡'), 'ACTIVE', 0, 0.00, '2026-12-31 23:59:59', '2026-05-01 10:00:00', 0, '{}', '2026-05-01 10:00:00', '2026-06-02 10:00:00');
|
||||
|
||||
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
||||
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_E'), (SELECT member_card_id FROM member_card WHERE member_card_name = '10次卡'), 'ACTIVE', 5, 0.00, '2026-12-31 23:59:59', '2026-05-01 10:00:00', 0, '{}', '2026-05-01 10:00:00', '2026-06-02 10:00:00');
|
||||
|
||||
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
||||
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_F'), (SELECT member_card_id FROM member_card WHERE member_card_name = '储值卡500'), 'ACTIVE', 0, 30.00, '2027-06-02 23:59:59', '2026-05-01 10:00:00', 0, '{}', '2026-05-01 10:00:00', '2026-06-02 10:00:00');
|
||||
|
||||
INSERT INTO member_card_record (member_id, member_card_id, status, remaining_times, remaining_amount, expire_time, purchase_time, version, card_composition, created_at, updated_at) VALUES
|
||||
((SELECT id FROM member_user WHERE member_no = 'MEM_TEST_G'), (SELECT member_card_id FROM member_card WHERE member_card_name = '储值卡500'), 'ACTIVE', 0, 200.00, '2027-06-02 23:59:59', '2026-05-01 10:00:00', 0, '{}', '2026-05-01 10:00:00', '2026-06-02 10:00:00');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、预期测试结果
|
||||
|
||||
### 7.1 成功场景
|
||||
|
||||
| 场景 | 会员 | 操作 | 预期结果 |
|
||||
|-----|------|-----|---------|
|
||||
| 3 | 会员C | 预约次数卡团课 | 预约成功,消耗时长卡1次 |
|
||||
| 5 | 会员E | 预约次数卡团课 | 预约成功,次数减1 (5→4) |
|
||||
| 7 | 会员G | 预约储值卡团课 | 预约成功,余额减50 (200→150) |
|
||||
|
||||
### 7.2 失败场景
|
||||
|
||||
| 场景 | 会员 | 操作 | 预期错误码 | 预期提示信息 |
|
||||
|-----|------|-----|----------|------------|
|
||||
| 1 | 用户A | 预约次数卡团课 | NO_CARD | 无可用会员卡 |
|
||||
| 2 | 用户B | 预约次数卡团课 | CARD_EXPIRED | 会员卡已过期 |
|
||||
| 4 | 会员D | 预约次数卡团课 | INSUFFICIENT_TIMES | 剩余次数不足 |
|
||||
| 6 | 会员F | 预约储值卡团课 | INSUFFICIENT_BALANCE | 余额不足,当前余额30.00元,需50.00元 |
|
||||
|
||||
---
|
||||
|
||||
## 八、数据清理
|
||||
|
||||
测试完成后,可通过以下 SQL 清理测试数据:
|
||||
|
||||
```sql
|
||||
-- 删除测试会员(会级联删除相关会员卡记录)
|
||||
DELETE FROM member_user WHERE member_no LIKE 'MEM_TEST_%';
|
||||
|
||||
-- 删除测试团课
|
||||
DELETE FROM group_course WHERE course_name LIKE '%_次数卡课程' OR course_name LIKE '%_储值卡课程';
|
||||
|
||||
-- 删除测试会员卡类型
|
||||
DELETE FROM member_card WHERE member_card_name IN ('30天时长卡', '10次卡', '储值卡500');
|
||||
```
|
||||
@@ -0,0 +1,204 @@
|
||||
# V11 团课预约取消测试数据文档
|
||||
|
||||
## 文档说明
|
||||
|
||||
本文档描述了用于测试团课预约取消功能的测试数据,包含三种会员卡类型的取消预约场景。
|
||||
|
||||
## 测试场景概览
|
||||
|
||||
| 序号 | 场景描述 | 用户 | 会员卡类型 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| 1 | 使用时长卡预约后取消 | 张三 | 时长卡(30天) |
|
||||
| 2 | 使用次数卡预约后取消 | 李四 | 次数卡(10次) |
|
||||
| 3 | 使用储值卡预约后取消 | 王五 | 储值卡(500元) |
|
||||
|
||||
## 测试用户数据
|
||||
|
||||
### 1. 张三(时长卡用户)
|
||||
|
||||
| 字段 | 值 |
|
||||
| :--- | :--- |
|
||||
| 会员编号 | `MEM_TEST_ZHANG` |
|
||||
| 昵称 | 张三_时长卡用户 |
|
||||
| 手机号 | 13800002001 |
|
||||
| 性别 | 男(1) |
|
||||
|
||||
#### 会员卡信息
|
||||
|
||||
| 字段 | 值 |
|
||||
| :--- | :--- |
|
||||
| 卡类型 | 时长卡(TIME_CARD) |
|
||||
| 卡名称 | 30天时长卡 |
|
||||
| 状态 | ACTIVE(有效) |
|
||||
| 到期时间 | 2026-07-02 23:59:59 |
|
||||
| 购买时间 | 2026-06-02 10:00:00 |
|
||||
|
||||
---
|
||||
|
||||
### 2. 李四(次数卡用户)
|
||||
|
||||
| 字段 | 值 |
|
||||
| :--- | :--- |
|
||||
| 会员编号 | `MEM_TEST_LI` |
|
||||
| 昵称 | 李四_次数卡用户 |
|
||||
| 手机号 | 13800002002 |
|
||||
| 性别 | 男(1) |
|
||||
|
||||
#### 会员卡信息
|
||||
|
||||
| 字段 | 值 |
|
||||
| :--- | :--- |
|
||||
| 卡类型 | 次数卡(COUNT_CARD) |
|
||||
| 卡名称 | 10次卡 |
|
||||
| 状态 | ACTIVE(有效) |
|
||||
| 剩余次数 | 5次 |
|
||||
| 到期时间 | 2026-12-31 23:59:59 |
|
||||
|
||||
---
|
||||
|
||||
### 3. 王五(储值卡用户)
|
||||
|
||||
| 字段 | 值 |
|
||||
| :--- | :--- |
|
||||
| 会员编号 | `MEM_TEST_WANG` |
|
||||
| 昵称 | 王五_储值卡用户 |
|
||||
| 手机号 | 13800002003 |
|
||||
| 性别 | 女(2) |
|
||||
|
||||
#### 会员卡信息
|
||||
|
||||
| 字段 | 值 |
|
||||
| :--- | :--- |
|
||||
| 卡类型 | 储值卡(STORED_VALUE_CARD) |
|
||||
| 卡名称 | 储值卡500 |
|
||||
| 状态 | ACTIVE(有效) |
|
||||
| 剩余金额 | 200.00元 |
|
||||
| 到期时间 | 2027-06-02 23:59:59 |
|
||||
|
||||
---
|
||||
|
||||
## 测试课程数据
|
||||
|
||||
### 晚间瑜伽_取消测试
|
||||
|
||||
| 字段 | 值 |
|
||||
| :--- | :--- |
|
||||
| 课程名称 | 晚间瑜伽_取消测试 |
|
||||
| 教练ID | 101 |
|
||||
| 课程类型 | 1 |
|
||||
| 开始时间 | 2026-06-15 19:00:00 |
|
||||
| 结束时间 | 2026-06-15 20:00:00 |
|
||||
| 上课地点 | 瑜伽教室 |
|
||||
| 最大人数 | 20人 |
|
||||
| 当前人数 | 3人(三位测试用户) |
|
||||
| 状态 | 0(正常) |
|
||||
| 次数消耗 | 1次 |
|
||||
| 储值消耗 | 30.00元 |
|
||||
|
||||
---
|
||||
|
||||
## 预约记录数据
|
||||
|
||||
### 预约状态说明
|
||||
|
||||
| 状态码 | 含义 |
|
||||
| :--- | :--- |
|
||||
| `0` | 已预约(等待取消) |
|
||||
| `1` | 已取消 |
|
||||
| `2` | 已出席 |
|
||||
| `3` | 缺席 |
|
||||
|
||||
### 预约记录详情
|
||||
|
||||
| 用户 | 预约时间 | 状态 | 课程名称 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| 张三 | 2026-06-02 11:00:00 | 0(已预约) | 晚间瑜伽_取消测试 |
|
||||
| 李四 | 2026-06-02 11:30:00 | 0(已预约) | 晚间瑜伽_取消测试 |
|
||||
| 王五 | 2026-06-02 12:00:00 | 0(已预约) | 晚间瑜伽_取消测试 |
|
||||
|
||||
---
|
||||
|
||||
## 测试用例设计
|
||||
|
||||
### 场景1:时长卡用户取消预约
|
||||
|
||||
**前置条件:**
|
||||
- 张三持有有效时长卡
|
||||
- 张三已成功预约课程
|
||||
- 当前时间 < 课程开始时间
|
||||
|
||||
**测试步骤:**
|
||||
1. 调用取消预约接口,传入张三的预约记录ID
|
||||
2. 验证预约状态变为 `1`(已取消)
|
||||
3. 验证课程当前人数减1
|
||||
4. 验证时长卡状态保持不变(时长卡取消预约不影响卡状态)
|
||||
|
||||
**预期结果:**
|
||||
- 预约记录状态更新为已取消
|
||||
- 课程剩余名额增加1
|
||||
- 时长卡有效期不受影响
|
||||
|
||||
---
|
||||
|
||||
### 场景2:次数卡用户取消预约
|
||||
|
||||
**前置条件:**
|
||||
- 李四持有次数卡,剩余5次
|
||||
- 李四已成功预约课程(已扣除1次)
|
||||
- 当前时间 < 课程开始时间
|
||||
|
||||
**测试步骤:**
|
||||
1. 调用取消预约接口,传入李四的预约记录ID
|
||||
2. 验证预约状态变为 `1`(已取消)
|
||||
3. 验证课程当前人数减1
|
||||
4. 验证次数卡剩余次数恢复为5次(返还1次)
|
||||
|
||||
**预期结果:**
|
||||
- 预约记录状态更新为已取消
|
||||
- 课程剩余名额增加1
|
||||
- 次数卡返还1次,剩余次数恢复为5次
|
||||
|
||||
---
|
||||
|
||||
### 场景3:储值卡用户取消预约
|
||||
|
||||
**前置条件:**
|
||||
- 王五持有储值卡,余额200元
|
||||
- 王五已成功预约课程(已扣除30元)
|
||||
- 当前时间 < 课程开始时间
|
||||
|
||||
**测试步骤:**
|
||||
1. 调用取消预约接口,传入王五的预约记录ID
|
||||
2. 验证预约状态变为 `1`(已取消)
|
||||
3. 验证课程当前人数减1
|
||||
4. 验证储值卡余额恢复为200元(返还30元)
|
||||
|
||||
**预期结果:**
|
||||
- 预约记录状态更新为已取消
|
||||
- 课程剩余名额增加1
|
||||
- 储值卡返还30元,余额恢复为200元
|
||||
|
||||
---
|
||||
|
||||
## 数据文件位置
|
||||
|
||||
```
|
||||
manage-db/src/main/resources/db/migration/V11__Insert_Cancel_Booking_Test_Data.sql
|
||||
```
|
||||
|
||||
## 数据依赖
|
||||
|
||||
| 依赖文件 | 说明 |
|
||||
| :--- | :--- |
|
||||
| `V6__Create_GroupCourse_table.sql` | 团课相关表结构 |
|
||||
| `V8__Create_Member_And_MemberCard.sql` | 会员和会员卡表结构 |
|
||||
| `V9__Add_GroupCourse_Booking_Snapshot_Fields.sql` | 预约快照字段 |
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 测试数据中使用的日期均为未来日期,确保测试时课程尚未开始
|
||||
2. 会员卡状态均为 ACTIVE,确保可以正常预约
|
||||
3. 取消预约功能应在课程开始前24小时内允许操作(具体时间限制由业务逻辑决定)
|
||||
4. 取消预约后应记录操作日志和交易流水
|
||||
@@ -0,0 +1,400 @@
|
||||
# 团课预约场景测试数据文档
|
||||
|
||||
## 概述
|
||||
|
||||
本文档描述了团课预约功能的测试数据,覆盖了各种预约场景,用于验证系统在不同业务条件下的正确性和健壮性。
|
||||
|
||||
---
|
||||
|
||||
## 一、测试数据概览
|
||||
|
||||
| 数据类别 | 数量 | 说明 |
|
||||
|---------|------|------|
|
||||
| 测试会员 | 5个 | 覆盖不同会员卡状态 |
|
||||
| 会员卡类型 | 8种 | 次卡/时长卡/储值卡 |
|
||||
| 会员卡记录 | 5条 | 会员持有的实际卡 |
|
||||
| 团课预约记录 | 4条 | 不同状态的预约 |
|
||||
| 交易流水 | 27条 | 完整的消费记录 |
|
||||
|
||||
---
|
||||
|
||||
## 二、会员测试数据
|
||||
|
||||
### 2.1 会员列表
|
||||
|
||||
| 会员ID | 会员编号 | 昵称 | 手机号 | 性别 | 会员卡状态 |
|
||||
|--------|---------|------|--------|------|-----------|
|
||||
| 1001 | MEM202606001 | 新用户小张 | 13800138001 | 男 | 无会员卡 |
|
||||
| 1002 | MEM202606002 | 额度耗尽用户 | 13800138002 | 女 | 次卡已用完 |
|
||||
| 1003 | MEM202606003 | 额度充足用户 | 13800138003 | 男 | 次卡剩余8次 |
|
||||
| 1004 | MEM202606004 | 过期卡用户 | 13800138004 | 女 | 月卡已过期 |
|
||||
| 1005 | MEM202606005 | 多卡用户小李 | 13800138005 | 男 | 次卡+储值卡 |
|
||||
|
||||
### 2.2 会员详情
|
||||
|
||||
**会员1001 - 新用户小张**
|
||||
- 状态:新注册用户,尚未购买任何会员卡
|
||||
- 用途:测试无会员卡情况下的预约行为
|
||||
|
||||
**会员1002 - 额度耗尽用户**
|
||||
- 状态:持有10次团课卡,已全部用完
|
||||
- 用途:测试会员卡额度耗尽时的预约行为
|
||||
|
||||
**会员1003 - 额度充足用户**
|
||||
- 状态:持有20次团课卡,剩余8次
|
||||
- 用途:测试正常预约流程
|
||||
|
||||
**会员1004 - 过期卡用户**
|
||||
- 状态:持有月卡,已于2026-05-31过期
|
||||
- 用途:测试过期会员卡的预约行为
|
||||
|
||||
**会员1005 - 多卡用户小李**
|
||||
- 状态:持有50次团课卡(剩余45次) + 500元储值卡(剩余350元)
|
||||
- 用途:测试多会员卡用户选择支付方式的场景
|
||||
|
||||
---
|
||||
|
||||
## 三、会员卡类型数据
|
||||
|
||||
### 3.1 次卡类型
|
||||
|
||||
| 卡类型ID | 卡名称 | 类型 | 价格 | 有效天数 | 总次数 | 状态 |
|
||||
|---------|-------|------|------|---------|-------|------|
|
||||
| 101 | 10次团课卡 | COUNT_CARD | 299.00 | 90 | 10 | 上架 |
|
||||
| 102 | 20次团课卡 | COUNT_CARD | 499.00 | 180 | 20 | 上架 |
|
||||
| 103 | 50次团课卡 | COUNT_CARD | 999.00 | 365 | 50 | 上架 |
|
||||
|
||||
### 3.2 时长卡类型
|
||||
|
||||
| 卡类型ID | 卡名称 | 类型 | 价格 | 有效天数 | 状态 |
|
||||
|---------|-------|------|------|---------|------|
|
||||
| 201 | 月卡 | TIME_CARD | 399.00 | 30 | 上架 |
|
||||
| 202 | 季卡 | TIME_CARD | 899.00 | 90 | 上架 |
|
||||
| 203 | 年卡 | TIME_CARD | 2999.00 | 365 | 上架 |
|
||||
|
||||
### 3.3 储值卡类型
|
||||
|
||||
| 卡类型ID | 卡名称 | 类型 | 价格 | 面额 | 有效天数 | 状态 |
|
||||
|---------|-------|------|------|------|---------|------|
|
||||
| 301 | 500元储值卡 | STORED_VALUE_CARD | 500.00 | 500.00 | 365 | 上架 |
|
||||
| 302 | 1000元储值卡 | STORED_VALUE_CARD | 1000.00 | 1000.00 | 365 | 上架 |
|
||||
|
||||
---
|
||||
|
||||
## 四、会员卡记录数据(会员持有的卡)
|
||||
|
||||
| 记录ID | 会员ID | 卡类型ID | 状态 | 剩余次数 | 剩余金额 | 到期时间 | 购买时间 |
|
||||
|-------|--------|---------|------|---------|---------|---------|---------|
|
||||
| 2001 | 1002 | 101 | USED_UP | 0 | 0.00 | 2026-09-01 | 2026-06-01 |
|
||||
| 2002 | 1003 | 102 | ACTIVE | 8 | 0.00 | 2026-12-01 | 2026-06-01 |
|
||||
| 2003 | 1004 | 201 | EXPIRED | 0 | 0.00 | 2026-05-31 | 2026-05-01 |
|
||||
| 2004 | 1005 | 103 | ACTIVE | 45 | 0.00 | 2027-06-01 | 2026-06-01 |
|
||||
| 2005 | 1005 | 301 | ACTIVE | 0 | 350.00 | 2027-06-01 | 2026-05-15 |
|
||||
|
||||
---
|
||||
|
||||
## 五、团课预约记录数据
|
||||
|
||||
| 预约ID | 课程ID | 会员ID | 会员卡记录ID | 预约时间 | 状态 | 课程名称 |
|
||||
|-------|-------|--------|-------------|---------|------|---------|
|
||||
| 3001 | 1 | 1003 | 2002 | 2026-06-01 15:30 | 已预约(0) | 极速燃脂单车 |
|
||||
| 3002 | 2 | 1003 | 2002 | 2026-06-01 10:00 | 已取消(1) | 清晨流瑜伽 |
|
||||
| 3003 | 6 | 1003 | 2002 | 2026-05-28 19:00 | 已出席(2) | 蜜桃臀塑造 |
|
||||
| 3004 | 7 | 1002 | 2001 | 2026-05-30 10:00 | 缺席(3) | 午间冥想放松 |
|
||||
|
||||
### 状态说明
|
||||
|
||||
| 状态码 | 状态名称 | 说明 |
|
||||
|-------|---------|------|
|
||||
| 0 | 已预约 | 用户已成功预约,等待上课 |
|
||||
| 1 | 已取消 | 用户主动取消预约 |
|
||||
| 2 | 已出席 | 用户已按时参加课程 |
|
||||
| 3 | 缺席 | 用户预约后未出席 |
|
||||
|
||||
---
|
||||
|
||||
## 六、测试场景详解
|
||||
|
||||
### 场景1:新注册用户无会员卡预约
|
||||
|
||||
**测试目标**:验证系统对无会员卡用户的预约拦截
|
||||
|
||||
**测试数据**:
|
||||
- 用户ID:1001(新用户小张)
|
||||
- 目标课程:任意课程
|
||||
|
||||
**预期结果**:
|
||||
- 返回错误信息:"未绑定会员卡,请先购买会员卡"
|
||||
- HTTP状态码:400 Bad Request
|
||||
|
||||
---
|
||||
|
||||
### 场景2:会员卡额度耗尽预约
|
||||
|
||||
**测试目标**:验证系统对额度耗尽会员卡的预约拦截
|
||||
|
||||
**测试数据**:
|
||||
- 用户ID:1002(额度耗尽用户)
|
||||
- 会员卡记录ID:2001
|
||||
- 剩余次数:0
|
||||
|
||||
**预期结果**:
|
||||
- 返回错误信息:"会员卡余额不足"
|
||||
- HTTP状态码:400 Bad Request
|
||||
|
||||
---
|
||||
|
||||
### 场景3:会员卡额度充足预约
|
||||
|
||||
**测试目标**:验证正常预约流程
|
||||
|
||||
**测试数据**:
|
||||
- 用户ID:1003(额度充足用户)
|
||||
- 会员卡记录ID:2002
|
||||
- 剩余次数:8
|
||||
- 目标课程:课程ID=1(极速燃脂单车)
|
||||
|
||||
**预期结果**:
|
||||
- 预约成功
|
||||
- 会员卡剩余次数减1(变为7次)
|
||||
- 生成预约记录
|
||||
- HTTP状态码:200 OK
|
||||
|
||||
---
|
||||
|
||||
### 场景4:会员卡已过期预约
|
||||
|
||||
**测试目标**:验证系统对过期会员卡的预约拦截
|
||||
|
||||
**测试数据**:
|
||||
- 用户ID:1004(过期卡用户)
|
||||
- 会员卡记录ID:2003
|
||||
- 状态:EXPIRED
|
||||
- 到期时间:2026-05-31
|
||||
|
||||
**预期结果**:
|
||||
- 返回错误信息:"会员卡已过期"
|
||||
- HTTP状态码:400 Bad Request
|
||||
|
||||
---
|
||||
|
||||
### 场景5:课程已满员预约---------------
|
||||
|
||||
**测试目标**:验证系统对满员课程的预约拦截
|
||||
|
||||
**测试数据**:
|
||||
- 课程ID:3(燃脂搏击)
|
||||
- 当前人数:20/20
|
||||
|
||||
**预期结果**:
|
||||
- 返回错误信息:"课程已满员"
|
||||
- HTTP状态码:400 Bad Request
|
||||
|
||||
---
|
||||
|
||||
### 场景6:课程已取消预约
|
||||
|
||||
**测试目标**:验证系统对已取消课程的预约拦截
|
||||
|
||||
**测试数据**:
|
||||
- 课程ID:5(周末冥想修复)
|
||||
- 状态:1(已取消)
|
||||
|
||||
**预期结果**:
|
||||
- 返回错误信息:"课程已取消"
|
||||
- HTTP状态码:400 Bad Request
|
||||
|
||||
---
|
||||
|
||||
### 场景7:课程已结束预约
|
||||
|
||||
**测试目标**:验证系统对已结束课程的预约拦截
|
||||
|
||||
**测试数据**:
|
||||
- 课程ID:6(蜜桃臀塑造)或 7(午间冥想放松)
|
||||
- 状态:2(已结束)
|
||||
|
||||
**预期结果**:
|
||||
- 返回错误信息:"课程已结束"
|
||||
- HTTP状态码:400 Bad Request
|
||||
|
||||
---
|
||||
|
||||
### 场景8:超出预约时间预约
|
||||
|
||||
**测试目标**:验证系统对预约时间窗口的控制
|
||||
|
||||
**测试数据**:
|
||||
- 课程ID:4(哈他瑜伽)
|
||||
- 开始时间:2026-06-01 15:20:00
|
||||
- 测试时间:课程开始前30分钟内
|
||||
|
||||
**预期结果**:
|
||||
- 返回错误信息:"已过预约时间"
|
||||
- HTTP状态码:400 Bad Request
|
||||
|
||||
---
|
||||
|
||||
### 场景9:重复预约同一课程
|
||||
|
||||
**测试目标**:验证系统对重复预约的拦截
|
||||
|
||||
**测试数据**:
|
||||
- 用户ID:1003(额度充足用户)
|
||||
- 课程ID:1(极速燃脂单车)
|
||||
- 已有预约记录:booking_id=3001
|
||||
|
||||
**预期结果**:
|
||||
- 返回错误信息:"已预约该课程"
|
||||
- HTTP状态码:400 Bad Request
|
||||
|
||||
---
|
||||
|
||||
### 场景10:多会员卡用户选择支付方式
|
||||
|
||||
**测试目标**:验证多卡用户的支付方式选择功能
|
||||
|
||||
**测试数据**:
|
||||
- 用户ID:1005(多卡用户小李)
|
||||
- 可用卡1:次卡(记录ID=2004,剩余45次)
|
||||
- 可用卡2:储值卡(记录ID=2005,剩余350元)
|
||||
|
||||
**预期结果**:
|
||||
- 系统列出用户所有可用会员卡
|
||||
- 用户可选择使用次卡或储值卡支付
|
||||
- 根据选择的卡类型扣减相应额度
|
||||
|
||||
---
|
||||
|
||||
## 七、交易流水说明
|
||||
|
||||
### 7.1 会员1002(额度耗尽用户)消费记录
|
||||
|
||||
| 操作顺序 | 操作类型 | 变动次数 | 剩余次数 | 备注 |
|
||||
|---------|---------|---------|---------|------|
|
||||
| 1 | PURCHASE | +10 | 10 | 购买10次团课卡 |
|
||||
| 2-11 | DEDUCT | -1×10 | 0 | 10次课程消费 |
|
||||
|
||||
### 7.2 会员1003(额度充足用户)消费记录
|
||||
|
||||
| 操作顺序 | 操作类型 | 变动次数 | 剩余次数 | 备注 |
|
||||
|---------|---------|---------|---------|------|
|
||||
| 1 | PURCHASE | +20 | 20 | 购买20次团课卡 |
|
||||
| 2-12 | DEDUCT | -12 | 8 | 12次课程消费 |
|
||||
|
||||
### 7.3 会员1005(多卡用户)消费记录
|
||||
|
||||
| 会员卡记录ID | 操作类型 | 变动次数 | 变动金额 | 备注 |
|
||||
|-------------|---------|---------|---------|------|
|
||||
| 2004 | PURCHASE | +50 | 0 | 购买50次团课卡 |
|
||||
| 2004 | DEDUCT | -5 | 0 | 预约5节课程 |
|
||||
| 2005 | PURCHASE | 0 | +500 | 购买500元储值卡 |
|
||||
| 2005 | DEDUCT | 0 | -150 | 私教课消费150元 |
|
||||
|
||||
---
|
||||
|
||||
## 八、API测试示例
|
||||
|
||||
### 8.1 预约接口
|
||||
|
||||
```http
|
||||
POST /api/group-course/bookings
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"courseId": 1,
|
||||
"memberCardRecordId": 2002
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 取消预约接口
|
||||
|
||||
```http
|
||||
PUT /api/group-course/bookings/{bookingId}/cancel
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"reason": "临时有事无法参加"
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 查询用户预约列表
|
||||
|
||||
```http
|
||||
GET /api/group-course/bookings?memberId=1003
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、测试数据对应关系图
|
||||
|
||||
```
|
||||
会员表 (member_user)
|
||||
│
|
||||
├── 1001 ──→ (无会员卡)
|
||||
├── 1002 ──→ 会员卡记录 2001 (USED_UP)
|
||||
├── 1003 ──→ 会员卡记录 2002 (ACTIVE, 8次)
|
||||
├── 1004 ──→ 会员卡记录 2003 (EXPIRED)
|
||||
└── 1005 ──→ 会员卡记录 2004 (ACTIVE, 45次)
|
||||
└── 会员卡记录 2005 (ACTIVE, 350元)
|
||||
│
|
||||
↓
|
||||
预约记录表 (group_course_booking)
|
||||
│
|
||||
├── 3001: 已预约
|
||||
├── 3002: 已取消
|
||||
├── 3003: 已出席
|
||||
└── 3004: 缺席
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、数据初始化
|
||||
|
||||
测试数据通过 Flyway 迁移脚本 `V10__Insert_Booking_Test_Data.sql` 初始化,需在数据库初始化时执行。
|
||||
|
||||
**执行顺序**:
|
||||
1. V1__Create_all_tables.sql - 创建基础表结构
|
||||
2. V2__Insert_initial_data.sql - 插入初始数据
|
||||
3. V6__Create_GroupCourse_table.sql - 创建团课表
|
||||
4. V7__Insert_GroupCourse_test_data.sql - 插入团课测试数据
|
||||
5. V8__Create_Member_And_MemberCard.sql - 创建会员和会员卡表
|
||||
6. V9__Add_GroupCourse_Booking_Snapshot_Fields.sql - 添加冗余字段
|
||||
7. V10__Insert_Booking_Test_Data.sql - 插入预约测试数据(本文件)
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.1
|
||||
**创建时间**: 2026-06-02
|
||||
**更新时间**: 2026-06-02
|
||||
**适用场景**: 团课预约功能测试
|
||||
|
||||
---
|
||||
|
||||
## 十一、重要说明
|
||||
|
||||
### 11.1 字段说明
|
||||
|
||||
`member_card_record` 表包含两个ID字段:
|
||||
- `id` - 数据库主键(BIGSERIAL PRIMARY KEY)
|
||||
- `member_card_record_id` - 业务ID(BIGSERIAL),代码中使用此字段作为查询和更新条件
|
||||
|
||||
### 11.2 测试数据修复记录
|
||||
|
||||
**v1.1 修复内容**:
|
||||
- 修复了 `member_card_record` 表测试数据缺失 `member_card_record_id` 字段的问题
|
||||
- 原问题:测试数据只插入了 `id` 字段,导致 `member_card_record_id` 为 NULL
|
||||
- 影响:代码使用 `member_card_record_id` 作为WHERE条件时无法匹配到记录,导致扣减权益失败
|
||||
- 解决:在INSERT语句中添加 `member_card_record_id` 字段,值与 `id` 相同
|
||||
|
||||
**修复前**:
|
||||
```sql
|
||||
INSERT INTO member_card_record (id, member_id, member_card_id, ...) VALUES
|
||||
(2001, 1002, 101, ...);
|
||||
```
|
||||
|
||||
**修复后**:
|
||||
```sql
|
||||
INSERT INTO member_card_record (id, member_card_record_id, member_id, member_card_id, ...) VALUES
|
||||
(2001, 2001, 1002, 101, ...);
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,728 @@
|
||||
# 团课管理模块 API 文档
|
||||
|
||||
> **文档版本**: v1.0
|
||||
> **创建日期**: 2026-06-02
|
||||
> **作者**: 张翔
|
||||
> **状态**: 正式发布
|
||||
|
||||
---
|
||||
|
||||
## 📋 目录
|
||||
|
||||
1. [概述](#概述)
|
||||
2. [基础路径](#基础路径)
|
||||
3. [团课管理接口](#团课管理接口)
|
||||
- [获取所有团课](#获取所有团课)
|
||||
- [分页获取团课](#分页获取团课)
|
||||
- [根据ID获取团课详情](#根据ID获取团课详情)
|
||||
- [创建团课](#创建团课)
|
||||
- [更新团课](#更新团课)
|
||||
- [取消团课](#取消团课)
|
||||
- [团课签到](#团课签到)
|
||||
- [删除团课](#删除团课)
|
||||
4. [团课预约接口](#团课预约接口)
|
||||
- [预约团课](#预约团课)
|
||||
- [取消预约](#取消预约)
|
||||
- [查询会员预约记录](#查询会员预约记录)
|
||||
- [查询预约详情](#查询预约详情)
|
||||
- [查询课程预约记录](#查询课程预约记录)
|
||||
5. [数据模型](#数据模型)
|
||||
- [GroupCourse(团课)](#GroupCourse团课)
|
||||
- [GroupCourseBooking(团课预约)](#GroupCourseBooking团课预约)
|
||||
6. [状态码说明](#状态码说明)
|
||||
7. [业务规则](#业务规则)
|
||||
|
||||
---
|
||||
|
||||
## 概述
|
||||
|
||||
团课管理模块提供团课的创建、编辑、查询、取消和签到功能,以及团课预约相关操作。采用 Spring WebFlux 响应式编程,支持高并发场景。
|
||||
|
||||
## 基础路径
|
||||
|
||||
所有接口的基础路径为: `http://{host}:{port}/api/groupCourse`
|
||||
|
||||
---
|
||||
|
||||
## 团课管理接口
|
||||
|
||||
### 获取所有团课
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | GET |
|
||||
| **接口路径** | `/api/groupCourse/list` |
|
||||
| **所属文件** | `GroupCourseHandler.java` |
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| includeDeleted | boolean | 否 | false | 是否包含已删除的团课 |
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"courseName": "瑜伽入门",
|
||||
"coachId": 1,
|
||||
"courseType": 1,
|
||||
"startTime": "2026-06-02T09:00:00",
|
||||
"endTime": "2026-06-02T10:00:00",
|
||||
"maxMembers": 20,
|
||||
"currentMembers": 15,
|
||||
"status": 0,
|
||||
"location": "健身房A区",
|
||||
"coverImage": "https://example.com/yoga.jpg",
|
||||
"description": "适合初学者的瑜伽课程",
|
||||
"createdAt": "2026-06-01T10:00:00",
|
||||
"updatedAt": "2026-06-01T10:00:00"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 分页获取团课
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | POST |
|
||||
| **接口路径** | `/api/groupCourse/page` |
|
||||
| **所属文件** | `GroupCourseHandler.java` |
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"page": 0,
|
||||
"size": 10,
|
||||
"sort": "id",
|
||||
"order": "asc",
|
||||
"keyword": "瑜伽"
|
||||
}
|
||||
```
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| page | int | 否 | 0 | 页码,从0开始 |
|
||||
| size | int | 否 | 10 | 每页数量,最大100 |
|
||||
| sort | string | 否 | id | 排序字段 |
|
||||
| order | string | 否 | asc | 排序方式(asc/desc) |
|
||||
| keyword | string | 否 | - | 搜索关键词 |
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|--------|------|------|--------|------|
|
||||
| includeDeleted | boolean | 否 | false | 是否包含已删除的团课 |
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [...],
|
||||
"totalPages": 5,
|
||||
"totalElements": 45,
|
||||
"page": 0,
|
||||
"size": 10
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 根据ID获取团课详情
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | GET |
|
||||
| **接口路径** | `/api/groupCourse/{id}` |
|
||||
| **所属文件** | `GroupCourseHandler.java` |
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| id | Long | 是 | 团课ID |
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"courseName": "瑜伽入门",
|
||||
"coachId": 1,
|
||||
"courseType": 1,
|
||||
"startTime": "2026-06-02T09:00:00",
|
||||
"endTime": "2026-06-02T10:00:00",
|
||||
"maxMembers": 20,
|
||||
"currentMembers": 15,
|
||||
"status": 0,
|
||||
"location": "健身房A区",
|
||||
"coverImage": "https://example.com/yoga.jpg",
|
||||
"description": "适合初学者的瑜伽课程",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 50.00,
|
||||
"createdAt": "2026-06-01T10:00:00",
|
||||
"updatedAt": "2026-06-01T10:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
**失败响应** (404 Not Found):
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 创建团课
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | POST |
|
||||
| **接口路径** | `/api/groupCourse` |
|
||||
| **所属文件** | `GroupCourseHandler.java` |
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"courseName": "动感单车",
|
||||
"coachId": 2,
|
||||
"courseType": 2,
|
||||
"startTime": "2026-06-05T18:00:00",
|
||||
"endTime": "2026-06-05T19:00:00",
|
||||
"maxMembers": 25,
|
||||
"location": "健身房B区",
|
||||
"coverImage": "https://example.com/spinning.jpg",
|
||||
"description": "高强度有氧运动课程",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 50.00
|
||||
}
|
||||
```
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| courseName | String | **是** | 课程名称 |
|
||||
| coachId | Long | 否 | 教练ID |
|
||||
| courseType | Long | 否 | 课程类型 |
|
||||
| startTime | LocalDateTime | 否 | 开始时间 |
|
||||
| endTime | LocalDateTime | 否 | 结束时间 |
|
||||
| maxMembers | Integer | 否 | 最大参与人数,默认20 |
|
||||
| location | String | 否 | 上课地点 |
|
||||
| coverImage | String | 否 | 封面图URL |
|
||||
| description | String | 否 | 课程描述 |
|
||||
| pointCardAmount | Integer | 否 | 点卡额度(消耗次数),默认1 |
|
||||
| storedValueAmount | BigDecimal | 否 | 储值卡额度(消耗金额),默认0 |
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "团课创建成功",
|
||||
"data": {
|
||||
"id": 2,
|
||||
"courseName": "动感单车",
|
||||
"coachId": 2,
|
||||
"courseType": 2,
|
||||
"startTime": "2026-06-05T18:00:00",
|
||||
"endTime": "2026-06-05T19:00:00",
|
||||
"maxMembers": 25,
|
||||
"currentMembers": 0,
|
||||
"status": 0,
|
||||
"location": "健身房B区",
|
||||
"coverImage": "https://example.com/spinning.jpg",
|
||||
"description": "高强度有氧运动课程",
|
||||
"pointCardAmount": 1,
|
||||
"storedValueAmount": 50.00
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**失败响应** (400 Bad Request):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "课程名称不能为空"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 更新团课
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | PUT |
|
||||
| **接口路径** | `/api/groupCourse/{id}` |
|
||||
| **所属文件** | `GroupCourseHandler.java` |
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| id | Long | 是 | 团课ID |
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"courseName": "动感单车升级版",
|
||||
"coachId": 2,
|
||||
"maxMembers": 30,
|
||||
"description": "升级版高强度有氧运动课程",
|
||||
"pointCardAmount": 2,
|
||||
"storedValueAmount": 80.00
|
||||
}
|
||||
```
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| courseName | String | 否 | 课程名称 |
|
||||
| coachId | Long | 否 | 教练ID |
|
||||
| courseType | Long | 否 | 课程类型 |
|
||||
| startTime | LocalDateTime | 否 | 开始时间 |
|
||||
| endTime | LocalDateTime | 否 | 结束时间 |
|
||||
| maxMembers | Integer | 否 | 最大参与人数 |
|
||||
| location | String | 否 | 上课地点 |
|
||||
| coverImage | String | 否 | 封面图URL |
|
||||
| description | String | 否 | 课程描述 |
|
||||
| pointCardAmount | Integer | 否 | 点卡额度(消耗次数) |
|
||||
| storedValueAmount | BigDecimal | 否 | 储值卡额度(消耗金额) |
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "团课更新成功",
|
||||
"data": {
|
||||
"id": 2,
|
||||
"courseName": "动感单车升级版",
|
||||
"coachId": 2,
|
||||
"maxMembers": 30,
|
||||
"description": "升级版高强度有氧运动课程",
|
||||
"pointCardAmount": 2,
|
||||
"storedValueAmount": 80.00
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**失败响应** (400 Bad Request):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "团课不存在"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 取消团课
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | POST |
|
||||
| **接口路径** | `/api/groupCourse/{id}/cancel` |
|
||||
| **所属文件** | `GroupCourseHandler.java` |
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| id | Long | 是 | 团课ID |
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "团课取消成功",
|
||||
"data": {
|
||||
"id": 2,
|
||||
"status": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**失败响应** (400 Bad Request):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "课程取消需提前24小时"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 团课签到
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | POST |
|
||||
| **接口路径** | `/api/groupCourse/{courseId}/signin` |
|
||||
| **所属文件** | `GroupCourseHandler.java` |
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| courseId | Long | 是 | 团课ID |
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"memberId": 1001
|
||||
}
|
||||
```
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| memberId | Long | **是** | 会员ID |
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "签到成功",
|
||||
"data": {
|
||||
"id": 2,
|
||||
"currentMembers": 16
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**失败响应** (400 Bad Request):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "课程已满员"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 删除团课
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | DELETE |
|
||||
| **接口路径** | `/api/groupCourse/{id}` |
|
||||
| **所属文件** | `GroupCourseHandler.java` |
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| id | Long | 是 | 团课ID |
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "团课删除成功"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 团课预约接口
|
||||
|
||||
### 预约团课
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | POST |
|
||||
| **接口路径** | `/api/groupCourse/book` |
|
||||
| **所属文件** | `GroupCourseBookingHandler.java` |
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"courseId": 1,
|
||||
"memberId": 1001,
|
||||
"memberCardRecordId": 5001
|
||||
}
|
||||
```
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| courseId | Long | **是** | 团课ID |
|
||||
| memberId | Long | **是** | 会员ID |
|
||||
| memberCardRecordId | Long | **是** | 会员卡记录ID |
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "预约成功",
|
||||
"data": {
|
||||
"id": 100,
|
||||
"courseId": 1,
|
||||
"memberId": 1001,
|
||||
"memberCardRecordId": 5001,
|
||||
"bookingTime": "2026-06-02T08:00:00",
|
||||
"status": "0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 取消预约
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | POST |
|
||||
| **接口路径** | `/api/groupCourse/booking/{bookingId}/cancel` |
|
||||
| **所属文件** | `GroupCourseBookingHandler.java` |
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| bookingId | Long | 是 | 预约ID |
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"memberId": 1001
|
||||
}
|
||||
```
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| memberId | Long | **是** | 会员ID |
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "取消成功",
|
||||
"data": {
|
||||
"id": 100,
|
||||
"status": "1",
|
||||
"cancelTime": "2026-06-02T09:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 查询会员预约记录
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | GET |
|
||||
| **接口路径** | `/api/groupCourse/bookings/member/{memberId}` |
|
||||
| **所属文件** | `GroupCourseBookingHandler.java` |
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| memberId | Long | 是 | 会员ID |
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 100,
|
||||
"courseId": 1,
|
||||
"courseName": "瑜伽入门",
|
||||
"memberId": 1001,
|
||||
"memberCardRecordId": 5001,
|
||||
"bookingTime": "2026-06-02T08:00:00",
|
||||
"status": "0",
|
||||
"courseStartTime": "2026-06-02T09:00:00",
|
||||
"location": "健身房A区"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 查询预约详情
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | GET |
|
||||
| **接口路径** | `/api/groupCourse/bookings/{bookingId}` |
|
||||
| **所属文件** | `GroupCourseBookingHandler.java` |
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| bookingId | Long | 是 | 预约ID |
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 100,
|
||||
"courseId": 1,
|
||||
"courseName": "瑜伽入门",
|
||||
"memberId": 1001,
|
||||
"memberCardRecordId": 5001,
|
||||
"bookingTime": "2026-06-02T08:00:00",
|
||||
"status": "0",
|
||||
"courseStartTime": "2026-06-02T09:00:00",
|
||||
"courseEndTime": "2026-06-02T10:00:00",
|
||||
"location": "健身房A区"
|
||||
}
|
||||
```
|
||||
|
||||
**失败响应** (404 Not Found):
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 查询课程预约记录
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| **HTTP方法** | GET |
|
||||
| **接口路径** | `/api/groupCourse/bookings/course/{courseId}` |
|
||||
| **所属文件** | `GroupCourseBookingHandler.java` |
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| courseId | Long | 是 | 团课ID |
|
||||
|
||||
**成功响应** (200 OK):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 100,
|
||||
"courseId": 1,
|
||||
"courseName": "瑜伽入门",
|
||||
"memberId": 1001,
|
||||
"bookingTime": "2026-06-02T08:00:00",
|
||||
"status": "0"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据模型
|
||||
|
||||
### GroupCourse(团课)
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | Long | 主键ID |
|
||||
| courseName | String | 课程名称 |
|
||||
| coachId | Long | 教练ID |
|
||||
| courseType | Long | 课程类型 |
|
||||
| startTime | LocalDateTime | 开始时间 |
|
||||
| endTime | LocalDateTime | 结束时间 |
|
||||
| maxMembers | Integer | 最大参与人数 |
|
||||
| currentMembers | Integer | 当前参与人数 |
|
||||
| status | Long | 状态(0-正常,1-已取消,2-已结束) |
|
||||
| location | String | 上课地点 |
|
||||
| coverImage | String | 封面图URL |
|
||||
| description | String | 课程描述 |
|
||||
| pointCardAmount | Integer | 点卡额度(消耗次数),默认1 |
|
||||
| storedValueAmount | BigDecimal | 储值卡额度(消耗金额),默认0 |
|
||||
| createdBy | String | 创建人 |
|
||||
| updatedBy | String | 更新人 |
|
||||
| createdAt | LocalDateTime | 创建时间 |
|
||||
| updatedAt | LocalDateTime | 更新时间 |
|
||||
| deletedAt | LocalDateTime | 删除时间(软删除) |
|
||||
|
||||
### GroupCourseBooking(团课预约)
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | Long | 主键ID |
|
||||
| courseId | Long | 团课ID |
|
||||
| courseName | String | 团课名称 |
|
||||
| memberId | Long | 会员ID |
|
||||
| memberCardRecordId | Long | 会员卡记录ID |
|
||||
| bookingTime | LocalDateTime | 预约时间 |
|
||||
| status | String | 状态(0-已预约,1-已取消,2-已出席,3-缺席) |
|
||||
| cancelTime | LocalDateTime | 取消时间 |
|
||||
| courseStartTime | LocalDateTime | 课程开始时间 |
|
||||
| courseEndTime | LocalDateTime | 课程结束时间 |
|
||||
| location | String | 上课地点 |
|
||||
| createdBy | String | 创建人 |
|
||||
| updatedBy | String | 更新人 |
|
||||
| createdAt | LocalDateTime | 创建时间 |
|
||||
| updatedAt | LocalDateTime | 更新时间 |
|
||||
| deletedAt | LocalDateTime | 删除时间(软删除) |
|
||||
|
||||
---
|
||||
|
||||
## 状态码说明
|
||||
|
||||
### 团课状态
|
||||
|
||||
| 状态码 | 含义 |
|
||||
|--------|------|
|
||||
| 0 | 正常 |
|
||||
| 1 | 已取消 |
|
||||
| 2 | 已结束 |
|
||||
|
||||
### 预约状态
|
||||
|
||||
| 状态码 | 含义 |
|
||||
|--------|------|
|
||||
| 0 | 已预约 |
|
||||
| 1 | 已取消 |
|
||||
| 2 | 已出席 |
|
||||
| 3 | 缺席 |
|
||||
|
||||
---
|
||||
|
||||
## 业务规则
|
||||
|
||||
### 团课管理
|
||||
1. **创建团课**:课程名称为必填项
|
||||
2. **取消团课**:需提前24小时通知,否则拒绝操作
|
||||
3. **团课签到**:验证课程状态必须为正常,且未达最大人数限制
|
||||
4. **删除团课**:采用软删除机制,数据保留可恢复
|
||||
|
||||
### 团课预约
|
||||
1. **预约团课**:需验证会员卡有效性和课程名额
|
||||
2. **取消预约**:需在课程开始前至少2小时取消
|
||||
|
||||
---
|
||||
|
||||
## 附录:错误响应格式
|
||||
|
||||
所有接口的错误响应统一格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "错误描述信息"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*文档结束*
|
||||
@@ -0,0 +1,48 @@
|
||||
# Compiled class file
|
||||
*.class
|
||||
|
||||
# Log file
|
||||
*.log
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
|
||||
# Mobile Tools for Java (J2ME)
|
||||
.mtj.tmp/
|
||||
|
||||
# Package Files
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
|
||||
# Virtual machine crash logs
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
|
||||
# Maven
|
||||
target/
|
||||
pom.xml.tag
|
||||
pom.xml.releaseBackup
|
||||
pom.xml.versionsBackup
|
||||
pom.xml.next
|
||||
release.properties
|
||||
dependency-reduced-pom.xml
|
||||
buildNumber.properties
|
||||
.mvn/timing.properties
|
||||
.mvn/wrapper/maven-wrapper.jar
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
.settings/
|
||||
.classpath
|
||||
.project
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,252 @@
|
||||
<?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-checkIn</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>Gym CheckIn</name>
|
||||
<description>Check-In Management Module - Member Attendance 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>gym-member</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-aop</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>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.resilience4j</groupId>
|
||||
<artifactId>resilience4j-spring-boot3</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.resilience4j</groupId>
|
||||
<artifactId>resilience4j-reactor</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers</artifactId>
|
||||
<version>1.21.4</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>1.21.4</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>1.21.4</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.r2dbc</groupId>
|
||||
<artifactId>r2dbc-h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>r2dbc-postgresql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-all</artifactId>
|
||||
<version>5.8.25</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
<version>3.5.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 添加 ZXing JavaSE 扩展(用于生成图片) -->
|
||||
<dependency>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>javase</artifactId>
|
||||
<version>3.5.1</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</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.mapstruct</groupId>
|
||||
<artifactId>mapstruct-processor</artifactId>
|
||||
<version>1.5.5.Final</version>
|
||||
</path>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
</path>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok-mapstruct-binding</artifactId>
|
||||
<version>0.2.0</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>0.8.12</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>check</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>INSTRUCTION</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.60</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>com.github.spotbugs</groupId>
|
||||
<artifactId>spotbugs-maven-plugin</artifactId>
|
||||
<version>4.8.6.0</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.github.spotbugs</groupId>
|
||||
<artifactId>spotbugs</artifactId>
|
||||
<version>4.8.6</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>spotbugs-check</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<effort>Max</effort>
|
||||
<threshold>High</threshold>
|
||||
<failOnError>true</failOnError>
|
||||
<excludeFilterFile>spotbugs-exclude.xml</excludeFilterFile>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package cn.novalon.gym.manage.checkIn.config;
|
||||
|
||||
import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.reactive.HandlerMapping;
|
||||
import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping;
|
||||
import org.springframework.web.reactive.socket.WebSocketHandler;
|
||||
import org.springframework.web.reactive.socket.server.support.WebSocketHandlerAdapter;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Configuration
|
||||
public class CheckInWebSocketConfig {
|
||||
|
||||
@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();
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package cn.novalon.gym.manage.checkIn.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Data
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "qr.config")
|
||||
public class QRCodeConfig {
|
||||
|
||||
/**
|
||||
* 二维码宽度(像素)
|
||||
*/
|
||||
private Integer width = 300;
|
||||
|
||||
/**
|
||||
* 二维码高度(像素)
|
||||
*/
|
||||
private Integer height = 300;
|
||||
|
||||
/**
|
||||
* 白边宽度
|
||||
*/
|
||||
private Integer margin = 1;
|
||||
|
||||
/**
|
||||
* 容错率:L, M, Q, H
|
||||
*/
|
||||
private String errorCorrection = "M";
|
||||
|
||||
/**
|
||||
* 图片格式:png, jpg
|
||||
*/
|
||||
private String format = "png";
|
||||
|
||||
/**
|
||||
* 是否启用Logo
|
||||
*/
|
||||
private Boolean logoEnabled = false;
|
||||
|
||||
/**
|
||||
* Logo路径
|
||||
*/
|
||||
private String logoPath = "";
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package cn.novalon.gym.manage.checkIn.constant;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 打卡模块 Redis 键常量
|
||||
*
|
||||
* @author 付嘉
|
||||
* @date 2026-05-30
|
||||
*/
|
||||
public final class QRRedisKey {
|
||||
|
||||
private static final String SEPARATOR = ":";
|
||||
private static final String QRCODE_USER_DAILY = "qrcode:user:daily";
|
||||
private static final String QRCODE_CONTENT = "QR_";
|
||||
private QRRedisKey() {
|
||||
// 私有构造,防止实例化
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户当日二维码
|
||||
* 格式:qrcode:user:daily:{userId}:{date}
|
||||
* 示例:qrcode:user:daily:1001:2026-05-30
|
||||
*/
|
||||
public static String qrcodeUserDaily(Long userId, LocalDate date) {
|
||||
return QRCODE_USER_DAILY + SEPARATOR + userId + SEPARATOR + date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户当日二维码(今天)
|
||||
*/
|
||||
public static String qrcodeUserToday(Long userId) {
|
||||
return qrcodeUserDaily(userId, LocalDate.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成二维码内容(每个用户每次调用都不同)
|
||||
*/
|
||||
public static String generateQrcodeContent() {
|
||||
return QRCODE_CONTENT + UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package cn.novalon.gym.manage.checkIn.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class QRCodeDto {
|
||||
|
||||
private String qrContent;
|
||||
|
||||
private boolean isUsed;
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
package cn.novalon.gym.manage.checkIn.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.annotation.LastModifiedDate;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 会员到店签到记录实体
|
||||
*
|
||||
* @author 付嘉
|
||||
* @date 2026-06-08
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table("sign_in_record")
|
||||
public class SignInRecord {
|
||||
|
||||
/**
|
||||
* 自增主键
|
||||
*/
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 会员ID,关联member表
|
||||
*/
|
||||
@Column("member_id")
|
||||
private Long memberId;
|
||||
|
||||
/**
|
||||
* 签到时使用的会员卡ID
|
||||
*/
|
||||
@Column("member_card_id")
|
||||
private Long memberCardId;
|
||||
|
||||
/**
|
||||
* 签到入场时间
|
||||
*/
|
||||
@Column("sign_in_time")
|
||||
private LocalDateTime signInTime;
|
||||
|
||||
/**
|
||||
* 签到方式:QR_CODE-扫码签到,MANUAL-手动签到,FACE-人脸识别
|
||||
*/
|
||||
@Column("sign_in_type")
|
||||
private String signInType;
|
||||
|
||||
/**
|
||||
* 签到状态:SUCCESS-成功,FAILED-失败
|
||||
*/
|
||||
@Column("sign_in_status")
|
||||
private String signInStatus;
|
||||
|
||||
/**
|
||||
* JSONB格式,存储会员卡验证时的快照数据
|
||||
*/
|
||||
@Column("verification_details")
|
||||
private String verificationDetails;
|
||||
|
||||
/**
|
||||
* 失败时的具体原因文案
|
||||
*/
|
||||
@Column("fail_reason")
|
||||
private String failReason;
|
||||
|
||||
/**
|
||||
* 操作人ID(前台人员),自助签到时为NULL
|
||||
*/
|
||||
@Column("operator_id")
|
||||
private Long operatorId;
|
||||
|
||||
/**
|
||||
* 操作人姓名冗余
|
||||
*/
|
||||
@Column("operator_name")
|
||||
private String operatorName;
|
||||
|
||||
/**
|
||||
* 签到设备标识或型号
|
||||
*/
|
||||
@Column("device_info")
|
||||
private String deviceInfo;
|
||||
|
||||
/**
|
||||
* 客户端IP地址
|
||||
*/
|
||||
@Column("ip_address")
|
||||
private String ipAddress;
|
||||
|
||||
/**
|
||||
* 签到来源:MINI_PROGRAM-小程序扫码,PC_BACKEND-后台管理端
|
||||
*/
|
||||
@Column("source")
|
||||
private String source;
|
||||
|
||||
/**
|
||||
* 软删除标识:false-未删除,true-已删除
|
||||
*/
|
||||
@Column("is_delete")
|
||||
private Boolean isDelete;
|
||||
|
||||
/**
|
||||
* 记录创建时间
|
||||
*/
|
||||
@CreatedDate
|
||||
@Column("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 记录更新时间
|
||||
*/
|
||||
@LastModifiedDate
|
||||
@Column("updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
// ========== 常量定义 ==========
|
||||
|
||||
/**
|
||||
* 签到类型常量
|
||||
*/
|
||||
public static final class SignInType {
|
||||
/** 扫码签到 */
|
||||
public static final String QR_CODE = "QR_CODE";
|
||||
/** 手动签到 */
|
||||
public static final String MANUAL = "MANUAL";
|
||||
/** 人脸识别 */
|
||||
public static final String FACE = "FACE";
|
||||
|
||||
private SignInType() {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 签到状态常量
|
||||
*/
|
||||
public static final class SignInStatus {
|
||||
/** 成功 */
|
||||
public static final String SUCCESS = "SUCCESS";
|
||||
/** 失败 */
|
||||
public static final String FAILED = "FAILED";
|
||||
|
||||
private SignInStatus() {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 签到来源常量
|
||||
*/
|
||||
public static final class Source {
|
||||
/** 小程序扫码 */
|
||||
public static final String MINI_PROGRAM = "MINI_PROGRAM";
|
||||
/** 后台管理端手动签到 */
|
||||
public static final String PC_BACKEND = "PC_BACKEND";
|
||||
|
||||
private Source() {}
|
||||
}
|
||||
|
||||
// ========== 辅助方法 ==========
|
||||
|
||||
/**
|
||||
* 判断签到是否成功
|
||||
*/
|
||||
public boolean isSuccess() {
|
||||
return SignInStatus.SUCCESS.equals(this.signInStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断签到是否失败
|
||||
*/
|
||||
public boolean isFailed() {
|
||||
return SignInStatus.FAILED.equals(this.signInStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为扫码签到
|
||||
*/
|
||||
public boolean isQrCodeSign() {
|
||||
return SignInType.QR_CODE.equals(this.signInType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否已删除
|
||||
*/
|
||||
public boolean isDeleted() {
|
||||
return Boolean.TRUE.equals(this.isDelete);
|
||||
}
|
||||
|
||||
/**
|
||||
* 软删除
|
||||
*/
|
||||
public void softDelete() {
|
||||
this.isDelete = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复删除
|
||||
*/
|
||||
public void restore() {
|
||||
this.isDelete = false;
|
||||
}
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
package cn.novalon.gym.manage.checkIn.handler;
|
||||
|
||||
import cn.novalon.gym.manage.checkIn.service.impl.CheckServiceImpl;
|
||||
import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
|
||||
import cn.novalon.gym.manage.sys.util.AuthUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
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.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CheckInHandler {
|
||||
|
||||
private final AuthUtil authUtil;
|
||||
private final CheckServiceImpl checkService;
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
/**
|
||||
* 签到
|
||||
*
|
||||
* POST /api/checkIn
|
||||
*
|
||||
*/
|
||||
public Mono<ServerResponse> checkIn(ServerRequest request) {
|
||||
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
return request.bodyToMono(Map.class)
|
||||
.flatMap(body -> {
|
||||
String qrContent = (String) body.get("qrContent");
|
||||
log.info("收到签到请求, memberId: {}, qrContent: {}", memberId, qrContent);
|
||||
boolean messageToClient = MyWebSocketHandler.sendMessageToClient(qrContent, "正在进行签到");
|
||||
log.info("WebSocket 推送结果: {}", messageToClient);
|
||||
return checkService.checkIn(memberId, qrContent)
|
||||
.flatMap(result -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(result));
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.error("签到失败", e);
|
||||
return ServerResponse.status(HttpStatus.BAD_REQUEST)
|
||||
.bodyValue(Map.of("code", 400, "message", e.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取二维码
|
||||
*
|
||||
* GET /api/checkin/qrcode
|
||||
*
|
||||
*/
|
||||
public Mono<ServerResponse> getQRCode(ServerRequest request) {
|
||||
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
|
||||
log.info("收到用户{}获取二维码请求", memberId);
|
||||
|
||||
return checkService.getQRCode(memberId)
|
||||
.flatMap(qrCodeVo -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(qrCodeVo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询签到记录列表
|
||||
*
|
||||
* GET /api/checkIn/records
|
||||
*
|
||||
* @param request
|
||||
* @return
|
||||
*/
|
||||
public Mono<ServerResponse> getSignInRecords(ServerRequest request) {
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
|
||||
String startDateStr = request.queryParam("startDate").orElse(null);
|
||||
String endDateStr = request.queryParam("endDate").orElse(null);
|
||||
|
||||
LocalDate startDate = startDateStr != null ? LocalDate.parse(startDateStr, DATE_FORMATTER) : LocalDate.now().minusDays(30);
|
||||
LocalDate endDate = endDateStr != null ? LocalDate.parse(endDateStr, DATE_FORMATTER) : LocalDate.now();
|
||||
|
||||
log.info("查询签到记录, memberId: {}, startDate: {}, endDate: {}", memberId, startDate, endDate);
|
||||
|
||||
return checkService.getSignInRecords(memberId, startDate, endDate)
|
||||
.collectList()
|
||||
.flatMap(records -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(Map.of("code", 200, "message", "success", "data", records)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询单条签到记录
|
||||
*
|
||||
* GET /api/checkIn/records/{id}
|
||||
*
|
||||
* @param request
|
||||
* @return
|
||||
*/
|
||||
public Mono<ServerResponse> getSignInRecordById(ServerRequest request) {
|
||||
Long id = Long.parseLong(request.pathVariable("id"));
|
||||
|
||||
log.info("查询签到记录详情, id: {}", id);
|
||||
|
||||
return checkService.getSignInRecordById(id)
|
||||
.flatMap(record -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(Map.of("code", 200, "message", "success", "data", record)))
|
||||
.switchIfEmpty(ServerResponse.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取签到统计
|
||||
*
|
||||
* GET /api/checkIn/statistics
|
||||
*
|
||||
* @param request
|
||||
* @return
|
||||
*/
|
||||
public Mono<ServerResponse> getSignInStatistics(ServerRequest request) {
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
|
||||
String startDateStr = request.queryParam("startDate").orElse(null);
|
||||
String endDateStr = request.queryParam("endDate").orElse(null);
|
||||
|
||||
LocalDate startDate = startDateStr != null ? LocalDate.parse(startDateStr, DATE_FORMATTER) : LocalDate.now().minusDays(30);
|
||||
LocalDate endDate = endDateStr != null ? LocalDate.parse(endDateStr, DATE_FORMATTER) : LocalDate.now();
|
||||
|
||||
log.info("查询签到统计, memberId: {}, startDate: {}, endDate: {}", memberId, startDate, endDate);
|
||||
|
||||
return checkService.getSignInStats(memberId, startDate, endDate)
|
||||
.flatMap(stats -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(Map.of("code", 200, "message", "success", "data", stats)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出签到记录
|
||||
*
|
||||
* GET /api/checkIn/records/export
|
||||
*
|
||||
* @param request
|
||||
* @return
|
||||
*/
|
||||
public Mono<ServerResponse> exportSignInRecords(ServerRequest request) {
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
|
||||
String startDateStr = request.queryParam("startDate").orElse(null);
|
||||
String endDateStr = request.queryParam("endDate").orElse(null);
|
||||
|
||||
LocalDate startDate = startDateStr != null ? LocalDate.parse(startDateStr, DATE_FORMATTER) : LocalDate.now().minusDays(30);
|
||||
LocalDate endDate = endDateStr != null ? LocalDate.parse(endDateStr, DATE_FORMATTER) : LocalDate.now();
|
||||
|
||||
log.info("导出签到记录, memberId: {}, startDate: {}, endDate: {}", memberId, startDate, endDate);
|
||||
|
||||
String filename = "签到记录_" + startDateStr + "_" + endDateStr + ".csv";
|
||||
|
||||
return checkService.exportSignInRecords(memberId, startDate, endDate)
|
||||
.flatMap(bytes -> ServerResponse.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||
.contentType(MediaType.parseMediaType("text/csv; charset=UTF-8"))
|
||||
.bodyValue(bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取每日签到统计
|
||||
*
|
||||
* GET /api/checkIn/daily-stats
|
||||
*
|
||||
* @param request
|
||||
* @return
|
||||
*/
|
||||
public Mono<ServerResponse> getDailySignInStats(ServerRequest request) {
|
||||
String dateStr = request.queryParam("date").orElse(null);
|
||||
LocalDate date = dateStr != null ? LocalDate.parse(dateStr, DATE_FORMATTER) : LocalDate.now();
|
||||
|
||||
log.info("查询每日签到统计, date: {}", date);
|
||||
|
||||
return checkService.getDailySignInStats(date)
|
||||
.flatMap(stats -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(Map.of("code", 200, "message", "success", "data", stats)));
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package cn.novalon.gym.manage.checkIn.repository;
|
||||
|
||||
import cn.novalon.gym.manage.checkIn.entity.SignInRecord;
|
||||
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
|
||||
*
|
||||
* @author 付嘉
|
||||
* @date 2026-06-08
|
||||
*/
|
||||
@Repository
|
||||
public interface SignInRecordRepository extends R2dbcRepository<SignInRecord, Long> {
|
||||
|
||||
/**
|
||||
* 查询会员某天的签到记录
|
||||
*/
|
||||
@Query("SELECT * FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false")
|
||||
Mono<SignInRecord> findByMemberIdAndDate(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 查询会员的签到记录列表
|
||||
*/
|
||||
@Query("SELECT * FROM sign_in_record WHERE member_id = :memberId AND is_delete = false ORDER BY sign_in_time DESC")
|
||||
Flux<SignInRecord> findByMemberId(Long memberId);
|
||||
|
||||
/**
|
||||
* 统计会员某天的签到次数
|
||||
*/
|
||||
@Query("SELECT COUNT(*) FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false")
|
||||
Mono<Long> countByMemberIdAndDate(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 插入签到记录
|
||||
*/
|
||||
@Query("INSERT INTO sign_in_record (member_id, member_card_id, sign_in_time, sign_in_type, sign_in_status, verification_details, fail_reason, source, created_at, updated_at, is_delete) " +
|
||||
"VALUES (:memberId, :memberCardId, :signInTime, :signInType, :signInStatus, :verificationDetails, :failReason, :source, NOW(), NOW(), false)")
|
||||
Mono<Void> insertRecord(Long memberId, Long memberCardId, LocalDateTime signInTime,
|
||||
String signInType, String signInStatus, String verificationDetails,
|
||||
String failReason, String source);
|
||||
|
||||
/**
|
||||
* 根据会员ID和时间范围查询签到记录
|
||||
*/
|
||||
@Query("SELECT * FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false ORDER BY sign_in_time DESC")
|
||||
Flux<SignInRecord> findByMemberIdAndTimeRange(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 根据时间范围查询签到记录
|
||||
*/
|
||||
@Query("SELECT * FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false ORDER BY sign_in_time DESC")
|
||||
Flux<SignInRecord> findByTimeRange(LocalDateTime startTime, LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 统计会员在时间范围内的签到次数
|
||||
*/
|
||||
@Query("SELECT COUNT(*) FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false")
|
||||
Mono<Long> countByMemberIdAndTimeRange(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 统计时间范围内的签到次数
|
||||
*/
|
||||
@Query("SELECT COUNT(*) FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false")
|
||||
Mono<Long> countByTimeRange(LocalDateTime startTime, LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 统计会员在时间范围内的成功签到次数
|
||||
*/
|
||||
@Query("SELECT COUNT(*) FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time <= :endTime AND sign_in_status = 'SUCCESS' AND is_delete = false")
|
||||
Mono<Long> countSuccessByMemberIdAndTimeRange(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 统计时间范围内的成功签到次数
|
||||
*/
|
||||
@Query("SELECT COUNT(*) FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND sign_in_status = 'SUCCESS' AND is_delete = false")
|
||||
Mono<Long> countSuccessByTimeRange(LocalDateTime startTime, LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 统计时间范围内签到的独立会员数
|
||||
*/
|
||||
@Query("SELECT COUNT(DISTINCT member_id) FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false")
|
||||
Mono<Long> countDistinctMembersByTimeRange(LocalDateTime startTime, LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 获取会员在时间范围内的首次签到时间
|
||||
*/
|
||||
@Query("SELECT MIN(sign_in_time) FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false")
|
||||
Mono<LocalDateTime> getFirstSignInTime(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 获取会员在时间范围内的最后签到时间
|
||||
*/
|
||||
@Query("SELECT MAX(sign_in_time) FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false")
|
||||
Mono<LocalDateTime> getLastSignInTime(Long memberId, LocalDateTime startTime, LocalDateTime endTime);
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package cn.novalon.gym.manage.checkIn.service;
|
||||
|
||||
import cn.novalon.gym.manage.checkIn.vo.QRCodeVo;
|
||||
import cn.novalon.gym.manage.checkIn.vo.SignInRecordVO;
|
||||
import cn.novalon.gym.manage.checkIn.vo.SignInStatsVO;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 签到服务接口
|
||||
*
|
||||
* @author 付嘉
|
||||
* @date 2026-06-08
|
||||
*/
|
||||
public interface ICheckInService {
|
||||
|
||||
/**
|
||||
* 获取签到二维码
|
||||
*
|
||||
* @param memberId 会员ID
|
||||
* @return 二维码VO
|
||||
*/
|
||||
Mono<QRCodeVo> getQRCode(Long memberId);
|
||||
|
||||
/**
|
||||
* 扫码签到
|
||||
*
|
||||
* @param memberId 会员ID
|
||||
* @param qrContent 二维码内容
|
||||
* @return 签到结果JSON字符串
|
||||
*/
|
||||
Mono<String> checkIn(Long memberId, String qrContent);
|
||||
|
||||
/**
|
||||
* 查询会员签到记录列表
|
||||
*
|
||||
* @param memberId 会员ID
|
||||
* @param startTime 开始时间
|
||||
* @param endTime 结束时间
|
||||
* @return 签到记录列表
|
||||
*/
|
||||
Flux<SignInRecordVO> getSignInRecords(Long memberId, LocalDate startTime, LocalDate endTime);
|
||||
|
||||
/**
|
||||
* 根据ID查询签到记录
|
||||
*
|
||||
* @param id 签到记录ID
|
||||
* @return 签到记录VO
|
||||
*/
|
||||
Mono<SignInRecordVO> getSignInRecordById(Long id);
|
||||
|
||||
/**
|
||||
* 获取会员签到统计
|
||||
*
|
||||
* @param memberId 会员ID
|
||||
* @param startTime 开始时间
|
||||
* @param endTime 结束时间
|
||||
* @return 签到统计VO
|
||||
*/
|
||||
Mono<SignInStatsVO> getSignInStats(Long memberId, LocalDate startTime, LocalDate endTime);
|
||||
|
||||
/**
|
||||
* 导出会员签到记录
|
||||
*
|
||||
* @param memberId 会员ID
|
||||
* @param startTime 开始时间
|
||||
* @param endTime 结束时间
|
||||
* @return CSV格式的字节数组
|
||||
*/
|
||||
Mono<byte[]> exportSignInRecords(Long memberId, LocalDate startTime, LocalDate endTime);
|
||||
|
||||
/**
|
||||
* 获取每日签到统计
|
||||
*
|
||||
* @param date 日期
|
||||
* @return 签到统计VO
|
||||
*/
|
||||
Mono<SignInStatsVO> getDailySignInStats(LocalDate date);
|
||||
}
|
||||
+439
@@ -0,0 +1,439 @@
|
||||
package cn.novalon.gym.manage.checkIn.service.impl;
|
||||
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import cn.novalon.gym.manage.checkIn.config.QRCodeConfig;
|
||||
import cn.novalon.gym.manage.checkIn.constant.QRRedisKey;
|
||||
import cn.novalon.gym.manage.checkIn.entity.SignInRecord;
|
||||
import cn.novalon.gym.manage.checkIn.repository.SignInRecordRepository;
|
||||
import cn.novalon.gym.manage.checkIn.service.ICheckInService;
|
||||
import cn.novalon.gym.manage.checkIn.vo.QRCodeVo;
|
||||
import cn.novalon.gym.manage.checkIn.vo.SignInRecordVO;
|
||||
import cn.novalon.gym.manage.checkIn.vo.SignInStatsVO;
|
||||
import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
|
||||
import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
|
||||
import cn.novalon.gym.manage.member.entity.MemberCard;
|
||||
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
|
||||
import cn.novalon.gym.manage.member.enums.MemberCardType;
|
||||
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
|
||||
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
|
||||
import cn.hutool.extra.qrcode.QrCodeUtil;
|
||||
import cn.hutool.extra.qrcode.QrConfig;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CheckServiceImpl implements ICheckInService {
|
||||
|
||||
private final QRCodeConfig qrCodeConfig;
|
||||
private final RedisUtil redisUtil;
|
||||
private final MemberCardRecordRepository memberCardRecordRepository;
|
||||
private final MemberCardRepository memberCardRepository;
|
||||
private final SignInRecordRepository signInRecordRepository;
|
||||
private final IGroupCourseBookingService groupCourseBookingService;
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
@Override
|
||||
public Mono<QRCodeVo> getQRCode(Long memberId) {
|
||||
log.info("开始查询会员信息, memberId: {}", memberId);
|
||||
|
||||
return findValidMemberCard(memberId)
|
||||
.flatMap(cardRecord -> {
|
||||
log.info("会员信息查询完成, memberCardRecordId: {}", cardRecord.getMemberCardRecordId());
|
||||
|
||||
log.info("开始生成二维码");
|
||||
String qrContent = QRRedisKey.generateQrcodeContent();
|
||||
Map<String, Object> redisMap = new HashMap<>();
|
||||
redisMap.put("qrContent", qrContent);
|
||||
redisMap.put("isUsed", false);
|
||||
redisMap.put("memberId", memberId);
|
||||
redisMap.put("memberCardRecordId", cardRecord.getMemberCardRecordId());
|
||||
|
||||
return redisUtil.setWithExpire(
|
||||
RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(),
|
||||
redisMap,
|
||||
getSecondsUntilEndOfDay()
|
||||
)
|
||||
.then(Mono.fromSupplier(() -> {
|
||||
String qrCodeBase64 = QrCodeUtil.generateAsBase64(qrContent,
|
||||
BeanUtil.copyProperties(qrCodeConfig, QrConfig.class), "png");
|
||||
return new QRCodeVo(qrCodeBase64, false, qrContent, qrCodeConfig.getWidth(), qrCodeConfig.getHeight(), LocalDate.now());
|
||||
}));
|
||||
})
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("该会员没有可用的会员卡")));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<String> checkIn(Long memberId, String qrContent) {
|
||||
String key = RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now();
|
||||
|
||||
// 先检查当天是否已经签到(从数据库查询,作为重复签到的额外保障)
|
||||
return checkTodayAlreadySignedIn(memberId)
|
||||
.flatMap(existingRecord -> {
|
||||
String checkInTime = existingRecord.getSignInTime().format(DATE_FORMATTER);
|
||||
log.error("重复签到, memberId: {}", memberId);
|
||||
MyWebSocketHandler.sendFailure(qrContent, "您已经在" + checkInTime + "完成签到,请勿重复签到");
|
||||
return Mono.error(new RuntimeException("您已经在" + checkInTime + "完成签到,请勿重复签到"));
|
||||
})
|
||||
.then(Mono.defer(() -> redisUtil.get(key)))
|
||||
.flatMap(cachedObj -> {
|
||||
if (cachedObj != null) {
|
||||
Map<String, Object> map;
|
||||
if (cachedObj instanceof Map) {
|
||||
map = (Map<String, Object>) cachedObj;
|
||||
} else if (cachedObj instanceof String) {
|
||||
map = JSONUtil.parseObj((String) cachedObj);
|
||||
} else {
|
||||
MyWebSocketHandler.sendFailure(qrContent, "二维码数据格式错误");
|
||||
return Mono.error(new RuntimeException("二维码数据格式错误"));
|
||||
}
|
||||
if (map.get("qrContent").equals(qrContent)) {
|
||||
if ((boolean) map.get("isUsed")) {
|
||||
String checkInTime = String.valueOf(map.get("checkInTime"));
|
||||
log.error("重复签到(缓存), memberId: {}", memberId);
|
||||
MyWebSocketHandler.sendFailure(qrContent, "您已经在" + checkInTime + "完成签到,请勿重复签到");
|
||||
return Mono.error(new RuntimeException("您已经在" + checkInTime + "完成签到,请勿重复签到"));
|
||||
}
|
||||
log.info("二维码匹配成功,memberId: {}", memberId);
|
||||
|
||||
Long memberCardRecordId = ((Number) map.get("memberCardRecordId")).longValue();
|
||||
|
||||
return processCheckIn(memberId, memberCardRecordId, map, qrContent);
|
||||
} else {
|
||||
MyWebSocketHandler.sendFailure(qrContent, "二维码无效");
|
||||
return Mono.error(new RuntimeException("二维码无效"));
|
||||
}
|
||||
}
|
||||
MyWebSocketHandler.sendFailure(qrContent, "二维码已过期或不存在");
|
||||
return Mono.error(new RuntimeException("二维码已过期或不存在"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查会员当天是否已经签到
|
||||
* @param memberId 会员ID
|
||||
* @return 如果已签到返回签到记录,否则返回空
|
||||
*/
|
||||
private Mono<SignInRecord> checkTodayAlreadySignedIn(Long memberId) {
|
||||
LocalDateTime startOfDay = LocalDate.now().atStartOfDay();
|
||||
LocalDateTime endOfDay = LocalDate.now().atTime(LocalTime.MAX);
|
||||
|
||||
return signInRecordRepository.findByMemberIdAndDate(memberId, startOfDay, endOfDay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理签到逻辑
|
||||
*/
|
||||
private Mono<String> processCheckIn(Long memberId, Long memberCardRecordId, Map<String, Object> redisMap, String qrContent) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
// 发送实时进度通知
|
||||
MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_CARD", "正在验证会员卡...");
|
||||
|
||||
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)
|
||||
.then(memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(cardRecord.getMemberCardId())
|
||||
.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("checkInTime", now.format(DATE_FORMATTER));
|
||||
|
||||
return saveSignInRecord(memberId, cardRecord.getMemberCardRecordId(), card.getMemberCardId())
|
||||
.then(redisUtil.set(RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(), redisMap))
|
||||
.then(Mono.defer(() -> {
|
||||
String successMsg = buildSuccessResponse(now);
|
||||
MyWebSocketHandler.sendSuccess(qrContent, memberId, now.format(DATE_FORMATTER));
|
||||
log.info("签到成功, memberId: {}, cardRecordId: {}", memberId, memberCardRecordId);
|
||||
return Mono.just(successMsg);
|
||||
}));
|
||||
});
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证预约信息
|
||||
*/
|
||||
private Mono<Void> validateBooking(Long memberId, LocalDateTime now) {
|
||||
return groupCourseBookingService.getBookingsByMemberId(memberId)
|
||||
.filter(booking -> {
|
||||
String status = booking.getStatus();
|
||||
LocalDateTime startTime = booking.getCourseStartTime();
|
||||
return "0".equals(status) &&
|
||||
startTime != null &&
|
||||
startTime.toLocalDate().equals(now.toLocalDate()) &&
|
||||
!startTime.isBefore(now.minusMinutes(30));
|
||||
})
|
||||
.collectList()
|
||||
.flatMap(bookings -> {
|
||||
if (bookings.isEmpty()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
boolean hasValidBooking = bookings.stream()
|
||||
.anyMatch(b -> {
|
||||
LocalDateTime startTime = b.getCourseStartTime();
|
||||
return startTime != null &&
|
||||
!startTime.isBefore(now.minusMinutes(30)) &&
|
||||
!startTime.isAfter(now.plusMinutes(30));
|
||||
});
|
||||
if (hasValidBooking) {
|
||||
log.info("会员{}有有效的团课预约", memberId);
|
||||
} else {
|
||||
log.warn("会员{}有预约但不在签到时间范围内", memberId);
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 扣减会员卡使用次数/金额
|
||||
*/
|
||||
private Mono<MemberCardRecord> deductCardUsage(MemberCardRecord record, MemberCard card) {
|
||||
MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
switch (cardType) {
|
||||
case TIME_CARD:
|
||||
if (record.getExpireTime() != null && record.getExpireTime().isBefore(now)) {
|
||||
return Mono.error(new RuntimeException("时长卡已过期"));
|
||||
}
|
||||
return Mono.just(record);
|
||||
case COUNT_CARD:
|
||||
int currentTimes = record.getRemainingTimes() != null ? record.getRemainingTimes() : 0;
|
||||
if (currentTimes < 1) {
|
||||
return Mono.error(new RuntimeException("次卡剩余次数不足"));
|
||||
}
|
||||
record.setRemainingTimes(currentTimes - 1);
|
||||
if (record.getRemainingTimes() == 0) {
|
||||
record.setStatus(cn.novalon.gym.manage.member.enums.MemberCardRecordStatus.USED_UP);
|
||||
}
|
||||
return memberCardRecordRepository.save(record);
|
||||
case STORED_VALUE_CARD:
|
||||
double currentAmount = record.getRemainingAmount() != null ? record.getRemainingAmount() : 0.0;
|
||||
if (currentAmount < 0.01) {
|
||||
return Mono.error(new RuntimeException("储值卡余额不足"));
|
||||
}
|
||||
record.setRemainingAmount(Math.max(0, currentAmount - 1));
|
||||
if (record.getRemainingAmount() <= 0) {
|
||||
record.setStatus(cn.novalon.gym.manage.member.enums.MemberCardRecordStatus.USED_UP);
|
||||
}
|
||||
return memberCardRecordRepository.save(record);
|
||||
default:
|
||||
return Mono.error(new RuntimeException("不支持的会员卡类型"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存签到记录
|
||||
*/
|
||||
private Mono<Void> saveSignInRecord(Long memberId, Long memberCardRecordId, Long memberCardId) {
|
||||
SignInRecord record = SignInRecord.builder()
|
||||
.memberId(memberId)
|
||||
.memberCardId(memberCardId)
|
||||
.signInTime(LocalDateTime.now())
|
||||
.signInType(SignInRecord.SignInType.QR_CODE)
|
||||
.signInStatus(SignInRecord.SignInStatus.SUCCESS)
|
||||
.source(SignInRecord.Source.MINI_PROGRAM)
|
||||
.isDelete(false)
|
||||
.build();
|
||||
|
||||
return signInRecordRepository.save(record).then();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建成功响应
|
||||
*/
|
||||
private String buildSuccessResponse(LocalDateTime dateTime) {
|
||||
Map<String, Object> res = new HashMap<>();
|
||||
res.put("message", "签到成功");
|
||||
res.put("dateTime", dateTime.format(DATE_FORMATTER));
|
||||
return JSONUtil.toJsonStr(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找会员的有效会员卡(优先选择有效期最早到期的)
|
||||
*/
|
||||
private Mono<MemberCardRecord> findValidMemberCard(Long memberId) {
|
||||
return memberCardRecordRepository.findActiveCardsByMemberId(memberId)
|
||||
.filter(record -> {
|
||||
LocalDateTime expireTime = record.getExpireTime();
|
||||
return expireTime == null || expireTime.isAfter(LocalDateTime.now());
|
||||
})
|
||||
.sort((r1, r2) -> {
|
||||
LocalDateTime e1 = r1.getExpireTime();
|
||||
LocalDateTime e2 = r2.getExpireTime();
|
||||
if (e1 == null && e2 == null) return 0;
|
||||
if (e1 == null) return 1;
|
||||
if (e2 == null) return -1;
|
||||
return e1.compareTo(e2);
|
||||
})
|
||||
.next();
|
||||
}
|
||||
|
||||
// ==================== 签到记录管理功能 ====================
|
||||
|
||||
@Override
|
||||
public Flux<SignInRecordVO> getSignInRecords(Long memberId, LocalDate startTime, LocalDate endTime) {
|
||||
LocalDateTime start = startTime.atStartOfDay();
|
||||
LocalDateTime end = endTime.atTime(LocalTime.MAX);
|
||||
|
||||
return signInRecordRepository.findByMemberIdAndTimeRange(memberId, start, end)
|
||||
.map(this::convertToVO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SignInRecordVO> getSignInRecordById(Long id) {
|
||||
return signInRecordRepository.findById(id)
|
||||
.map(this::convertToVO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SignInStatsVO> getSignInStats(Long memberId, LocalDate startTime, LocalDate endTime) {
|
||||
LocalDateTime start = startTime.atStartOfDay();
|
||||
LocalDateTime end = endTime.atTime(LocalTime.MAX);
|
||||
|
||||
return Mono.zip(
|
||||
(Object[] results) -> {
|
||||
Long total = (Long) results[0];
|
||||
Long success = (Long) results[1];
|
||||
LocalDateTime first = (LocalDateTime) results[2];
|
||||
LocalDateTime last = (LocalDateTime) results[3];
|
||||
SignInStatsVO stats = new SignInStatsVO();
|
||||
stats.setTotalCount(total);
|
||||
stats.setSuccessCount(success);
|
||||
stats.setStartDate(startTime);
|
||||
stats.setEndDate(endTime);
|
||||
stats.setFirstSignInTime(first);
|
||||
stats.setLastSignInTime(last);
|
||||
stats.setSuccessRate(total > 0 ? (double) success / total * 100.0 : 0.0);
|
||||
return stats;
|
||||
},
|
||||
signInRecordRepository.countByMemberIdAndTimeRange(memberId, start, end),
|
||||
signInRecordRepository.countSuccessByMemberIdAndTimeRange(memberId, start, end),
|
||||
signInRecordRepository.getFirstSignInTime(memberId, start, end),
|
||||
signInRecordRepository.getLastSignInTime(memberId, start, end)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<byte[]> exportSignInRecords(Long memberId, LocalDate startTime, LocalDate endTime) {
|
||||
LocalDateTime start = startTime.atStartOfDay();
|
||||
LocalDateTime end = endTime.atTime(LocalTime.MAX);
|
||||
|
||||
return signInRecordRepository.findByMemberIdAndTimeRange(memberId, start, end)
|
||||
.map(record -> {
|
||||
String status = "SUCCESS".equals(record.getSignInStatus()) ? "成功" : "失败";
|
||||
String type = "QR_CODE".equals(record.getSignInType()) ? "扫码签到" :
|
||||
"MANUAL".equals(record.getSignInType()) ? "手动签到" : "人脸识别";
|
||||
return String.join(",",
|
||||
record.getId().toString(),
|
||||
record.getMemberId().toString(),
|
||||
record.getMemberCardId() != null ? record.getMemberCardId().toString() : "",
|
||||
record.getSignInTime() != null ? record.getSignInTime().format(DATE_FORMATTER) : "",
|
||||
type,
|
||||
status,
|
||||
record.getFailReason() != null ? record.getFailReason() : ""
|
||||
);
|
||||
})
|
||||
.collectList()
|
||||
.map(rows -> {
|
||||
List<String> csvLines = new java.util.ArrayList<>();
|
||||
csvLines.add("签到记录ID,会员ID,会员卡ID,签到时间,签到方式,签到状态,失败原因");
|
||||
csvLines.addAll(rows);
|
||||
return String.join("\n", csvLines).getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SignInStatsVO> getDailySignInStats(LocalDate date) {
|
||||
LocalDateTime start = date.atStartOfDay();
|
||||
LocalDateTime end = date.atTime(LocalTime.MAX);
|
||||
|
||||
return Mono.zip(
|
||||
(Object[] results) -> {
|
||||
Long total = (Long) results[0];
|
||||
Long success = (Long) results[1];
|
||||
Long members = (Long) results[2];
|
||||
SignInStatsVO stats = new SignInStatsVO();
|
||||
stats.setTotalCount(total);
|
||||
stats.setSuccessCount(success);
|
||||
stats.setStartDate(date);
|
||||
stats.setEndDate(date);
|
||||
stats.setUniqueMemberCount(members);
|
||||
stats.setSuccessRate(total > 0 ? (double) success / total * 100.0 : 0.0);
|
||||
return stats;
|
||||
},
|
||||
signInRecordRepository.countByTimeRange(start, end),
|
||||
signInRecordRepository.countSuccessByTimeRange(start, end),
|
||||
signInRecordRepository.countDistinctMembersByTimeRange(start, end)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换实体到VO
|
||||
*/
|
||||
private SignInRecordVO convertToVO(SignInRecord record) {
|
||||
SignInRecordVO vo = new SignInRecordVO();
|
||||
vo.setId(record.getId());
|
||||
vo.setMemberId(record.getMemberId());
|
||||
vo.setMemberCardId(record.getMemberCardId());
|
||||
vo.setSignInTime(record.getSignInTime());
|
||||
vo.setSignInType(record.getSignInType());
|
||||
vo.setSignInStatus(record.getSignInStatus());
|
||||
vo.setFailReason(record.getFailReason());
|
||||
vo.setSource(record.getSource());
|
||||
vo.setCreatedAt(record.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private long getSecondsUntilEndOfDay() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59);
|
||||
if (now.isAfter(endOfDay)) return 1;
|
||||
return ChronoUnit.SECONDS.between(now, endOfDay);
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package cn.novalon.gym.manage.checkIn.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class QRCodeVo {
|
||||
|
||||
private String qrCodeBase64;
|
||||
|
||||
private boolean isUsed;
|
||||
|
||||
private String qrContent;
|
||||
|
||||
private Integer width;
|
||||
|
||||
private Integer height;
|
||||
|
||||
private LocalDate createTime;
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package cn.novalon.gym.manage.checkIn.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 签到记录VO
|
||||
*
|
||||
* @author 付嘉
|
||||
* @date 2026-06-08
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SignInRecordVO {
|
||||
|
||||
/**
|
||||
* 签到记录ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 会员ID
|
||||
*/
|
||||
private Long memberId;
|
||||
|
||||
/**
|
||||
* 会员卡ID
|
||||
*/
|
||||
private Long memberCardId;
|
||||
|
||||
/**
|
||||
* 签到时间
|
||||
*/
|
||||
private LocalDateTime signInTime;
|
||||
|
||||
/**
|
||||
* 签到类型:QR_CODE-扫码签到,MANUAL-手动签到,FACE-人脸识别
|
||||
*/
|
||||
private String signInType;
|
||||
|
||||
/**
|
||||
* 签到状态:SUCCESS-成功,FAILED-失败
|
||||
*/
|
||||
private String signInStatus;
|
||||
|
||||
/**
|
||||
* 失败原因
|
||||
*/
|
||||
private String failReason;
|
||||
|
||||
/**
|
||||
* 签到来源:MINI_PROGRAM-小程序扫码,PC_BACKEND-后台管理端
|
||||
*/
|
||||
private String source;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package cn.novalon.gym.manage.checkIn.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 签到统计VO
|
||||
*
|
||||
* @author 付嘉
|
||||
* @date 2026-06-08
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SignInStatsVO {
|
||||
|
||||
/**
|
||||
* 统计开始日期
|
||||
*/
|
||||
private LocalDate startDate;
|
||||
|
||||
/**
|
||||
* 统计结束日期
|
||||
*/
|
||||
private LocalDate endDate;
|
||||
|
||||
/**
|
||||
* 总签到次数
|
||||
*/
|
||||
private Long totalCount;
|
||||
|
||||
/**
|
||||
* 成功签到次数
|
||||
*/
|
||||
private Long successCount;
|
||||
|
||||
/**
|
||||
* 成功率(百分比)
|
||||
*/
|
||||
private Double successRate;
|
||||
|
||||
/**
|
||||
* 独立会员数
|
||||
*/
|
||||
private Long uniqueMemberCount;
|
||||
|
||||
/**
|
||||
* 首次签到时间
|
||||
*/
|
||||
private LocalDateTime firstSignInTime;
|
||||
|
||||
/**
|
||||
* 最后签到时间
|
||||
*/
|
||||
private LocalDateTime lastSignInTime;
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
package cn.novalon.gym.manage.checkIn.websocket;
|
||||
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import cn.novalon.gym.manage.checkIn.dto.QRCodeDto;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.socket.WebSocketHandler;
|
||||
import org.springframework.web.reactive.socket.WebSocketSession;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* WebSocket 处理类,用于实时签到反馈
|
||||
*
|
||||
* 技术要点:
|
||||
* - 使用 Sinks 实现响应式消息推送
|
||||
* - 使用 ConcurrentHashMap 管理 qrContent 与 sink 的映射
|
||||
* - 支持实时推送签到进度和结果
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class MyWebSocketHandler implements WebSocketHandler {
|
||||
|
||||
/**
|
||||
* qrContent -> Sink 映射,用于根据二维码内容找到对应的客户端连接
|
||||
*/
|
||||
private static final Map<String, Sinks.Many<String>> qrContentToSink = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 连接创建时间映射,用于超时清理
|
||||
*/
|
||||
private static final Map<String, LocalDateTime> qrContentToCreateTime = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 超时时间(秒),超过此时间未使用的连接将被清理
|
||||
*/
|
||||
private static final long TIMEOUT_SECONDS = 300;
|
||||
|
||||
@Override
|
||||
public Mono<Void> handle(WebSocketSession session) {
|
||||
String sessionId = session.getId();
|
||||
log.info("WebSocket 连接建立,sessionId: {}", sessionId);
|
||||
|
||||
// 创建 sink,用于向客户端发送消息
|
||||
Sinks.Many<String> sink = Sinks.many().unicast().onBackpressureBuffer();
|
||||
|
||||
// 订阅接收客户端消息(异步处理)
|
||||
session.receive()
|
||||
.doOnNext(message -> {
|
||||
String payload = message.getPayloadAsText();
|
||||
log.debug("收到消息:sessionId={}, payload={}", sessionId, payload);
|
||||
|
||||
try {
|
||||
QRCodeDto qrCodeDto = JSONUtil.toBean(payload, QRCodeDto.class);
|
||||
String qrContent = qrCodeDto.getQrContent();
|
||||
|
||||
if (qrContent != null && !qrContent.isEmpty()) {
|
||||
// 绑定 qrContent 和 sink
|
||||
qrContentToSink.put(qrContent, sink);
|
||||
qrContentToCreateTime.put(qrContent, LocalDateTime.now());
|
||||
log.info("绑定成功: qrContent={}, sessionId={}", qrContent, sessionId);
|
||||
|
||||
// 发送连接成功消息
|
||||
sink.tryEmitNext(buildMessage("CONNECTED", "签到监听已建立,请扫描二维码"));
|
||||
} else {
|
||||
sink.tryEmitNext(buildMessage("ERROR", "二维码内容为空"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("解析消息失败,sessionId={}", sessionId, e);
|
||||
sink.tryEmitNext(buildMessage("ERROR", "消息格式错误: " + e.getMessage()));
|
||||
}
|
||||
})
|
||||
.doOnError(e -> {
|
||||
log.error("接收消息出错,sessionId={}", sessionId, e);
|
||||
})
|
||||
.subscribe(); // 必须订阅,否则不会执行
|
||||
|
||||
// 发送流给客户端
|
||||
return session.send(sink.asFlux().map(session::textMessage))
|
||||
.doFinally(signal -> {
|
||||
// 连接关闭时清理映射
|
||||
qrContentToSink.entrySet().removeIf(entry -> entry.getValue() == sink);
|
||||
qrContentToCreateTime.entrySet().removeIf(entry -> {
|
||||
Sinks.Many<String> s = qrContentToSink.get(entry.getKey());
|
||||
return s == null || s == sink;
|
||||
});
|
||||
log.info("WebSocket 连接关闭,sessionId={}", sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 向客户端发送消息
|
||||
*
|
||||
* @param qrContent 二维码内容
|
||||
* @param message 消息内容
|
||||
* @return 是否发送成功
|
||||
*/
|
||||
public static boolean sendMessageToClient(String qrContent, String message) {
|
||||
// 先清理超时连接
|
||||
cleanupTimeoutConnections();
|
||||
|
||||
Sinks.Many<String> sink = qrContentToSink.get(qrContent);
|
||||
if (sink == null) {
|
||||
log.warn("未找到绑定的连接,qrContent: {}", qrContent);
|
||||
return false;
|
||||
}
|
||||
|
||||
Sinks.EmitResult result = sink.tryEmitNext(message);
|
||||
if (result.isSuccess()) {
|
||||
log.info("主动推送成功,qrContent: {}, message: {}", qrContent, message);
|
||||
return true;
|
||||
} else {
|
||||
log.warn("推送失败,qrContent: {}, result: {}", qrContent, result);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送签到进度消息
|
||||
*
|
||||
* @param qrContent 二维码内容
|
||||
* @param step 进度步骤
|
||||
* @param message 进度消息
|
||||
*/
|
||||
public static void sendProgress(String qrContent, String step, String message) {
|
||||
String progressMessage = buildMessage("PROGRESS", message);
|
||||
sendMessageToClient(qrContent, progressMessage);
|
||||
log.debug("发送进度消息: qrContent={}, step={}, message={}", qrContent, step, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送签到成功消息
|
||||
*
|
||||
* @param qrContent 二维码内容
|
||||
* @param memberId 会员ID
|
||||
* @param signInTime 签到时间
|
||||
*/
|
||||
public static void sendSuccess(String qrContent, Long memberId, String signInTime) {
|
||||
String successMessage = buildMessage("SUCCESS", "签到成功!欢迎光临\n会员ID: " + memberId + "\n签到时间: " + signInTime);
|
||||
sendMessageToClient(qrContent, successMessage);
|
||||
log.info("发送成功消息: qrContent={}, memberId={}", qrContent, memberId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送签到失败消息
|
||||
*
|
||||
* @param qrContent 二维码内容
|
||||
* @param reason 失败原因
|
||||
*/
|
||||
public static void sendFailure(String qrContent, String reason) {
|
||||
String failureMessage = buildMessage("FAILURE", "签到失败:" + reason);
|
||||
sendMessageToClient(qrContent, failureMessage);
|
||||
log.warn("发送失败消息: qrContent={}, reason={}", qrContent, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建标准消息格式
|
||||
*
|
||||
* @param type 消息类型
|
||||
* @param content 消息内容
|
||||
* @return 格式化后的消息字符串
|
||||
*/
|
||||
private static String buildMessage(String type, String content) {
|
||||
return JSONUtil.toJsonStr(Map.of(
|
||||
"type", type,
|
||||
"content", content,
|
||||
"timestamp", System.currentTimeMillis()
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理超时连接
|
||||
*/
|
||||
private static void cleanupTimeoutConnections() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
qrContentToCreateTime.entrySet().removeIf(entry -> {
|
||||
LocalDateTime createTime = entry.getValue();
|
||||
long secondsDiff = java.time.Duration.between(createTime, now).getSeconds();
|
||||
if (secondsDiff > TIMEOUT_SECONDS) {
|
||||
String qrContent = entry.getKey();
|
||||
qrContentToSink.remove(qrContent);
|
||||
log.debug("清理超时连接: qrContent={}, 超时时间={}秒", qrContent, secondsDiff);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前连接数
|
||||
*
|
||||
* @return 连接数
|
||||
*/
|
||||
public static int getConnectionCount() {
|
||||
cleanupTimeoutConnections();
|
||||
return qrContentToSink.size();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# 二维码配置
|
||||
qr:
|
||||
config:
|
||||
width: 300 # 二维码宽度(像素)
|
||||
height: 300 # 二维码高度(像素)
|
||||
margin: 1 # 白边宽度(像素)
|
||||
format: png # 图片格式:png / jpg
|
||||
error-correction: L #容错率:L, M, Q, H,如果启用Logo(logo-enabled: true),必须设置为 H
|
||||
logo-enabled: false # 是否启用Logo(启用时error-correction必须为H)
|
||||
# logo-path: static/logo.png # Logo图片路径(支持相对路径或绝对路径)
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
package cn.novalon.gym.manage.checkin;
|
||||
|
||||
import cn.novalon.gym.manage.checkIn.config.QRCodeConfig;
|
||||
import cn.novalon.gym.manage.checkIn.entity.SignInRecord;
|
||||
import cn.novalon.gym.manage.checkIn.repository.SignInRecordRepository;
|
||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
|
||||
import cn.novalon.gym.manage.checkIn.service.impl.CheckServiceImpl;
|
||||
import cn.novalon.gym.manage.checkIn.vo.QRCodeVo;
|
||||
import cn.novalon.gym.manage.checkIn.vo.SignInRecordVO;
|
||||
import cn.novalon.gym.manage.checkIn.vo.SignInStatsVO;
|
||||
import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import cn.novalon.gym.manage.member.entity.MemberCard;
|
||||
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
|
||||
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
|
||||
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 签到模块接口测试类
|
||||
* 测试模块三(gym-checkIn)的所有接口
|
||||
*/
|
||||
class CheckInModuleTest {
|
||||
|
||||
@Mock
|
||||
private QRCodeConfig qrCodeConfig;
|
||||
|
||||
@Mock
|
||||
private RedisUtil redisUtil;
|
||||
|
||||
@Mock
|
||||
private MemberCardRecordRepository memberCardRecordRepository;
|
||||
|
||||
@Mock
|
||||
private MemberCardRepository memberCardRepository;
|
||||
|
||||
@Mock
|
||||
private SignInRecordRepository signInRecordRepository;
|
||||
|
||||
@Mock
|
||||
private IGroupCourseBookingService groupCourseBookingService;
|
||||
|
||||
@Mock
|
||||
private MemberCard mockMemberCard;
|
||||
|
||||
@Mock
|
||||
private SignInRecord mockSignInRecord;
|
||||
|
||||
@Mock
|
||||
private MemberCardRecord mockMemberCardRecord;
|
||||
|
||||
private CheckServiceImpl checkService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
checkService = new CheckServiceImpl(qrCodeConfig, redisUtil, memberCardRecordRepository,
|
||||
memberCardRepository, signInRecordRepository, groupCourseBookingService);
|
||||
|
||||
when(mockMemberCard.getId()).thenReturn(1L);
|
||||
when(mockMemberCard.getMemberCardType()).thenReturn("TIME_CARD");
|
||||
|
||||
when(mockSignInRecord.getId()).thenReturn(1L);
|
||||
when(mockSignInRecord.getMemberId()).thenReturn(1L);
|
||||
when(mockSignInRecord.getMemberCardId()).thenReturn(1L);
|
||||
when(mockSignInRecord.getSignInTime()).thenReturn(LocalDateTime.now());
|
||||
when(mockSignInRecord.getSignInType()).thenReturn("QR_CODE");
|
||||
when(mockSignInRecord.getSignInStatus()).thenReturn("SUCCESS");
|
||||
when(mockSignInRecord.getSource()).thenReturn("MINI_PROGRAM");
|
||||
|
||||
when(mockMemberCardRecord.getMemberCardRecordId()).thenReturn(1L);
|
||||
when(mockMemberCardRecord.getMemberCardId()).thenReturn(1L);
|
||||
when(mockMemberCardRecord.getRemainingTimes()).thenReturn(10);
|
||||
when(mockMemberCardRecord.getRemainingAmount()).thenReturn(100.0);
|
||||
when(mockMemberCardRecord.getExpireTime()).thenReturn(LocalDateTime.now().plusDays(30));
|
||||
when(mockMemberCardRecord.getStatus()).thenReturn(cn.novalon.gym.manage.member.enums.MemberCardRecordStatus.ACTIVE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试1: 获取二维码 - getQRCode")
|
||||
void testGetQRCode() {
|
||||
when(memberCardRecordRepository.findActiveCardsByMemberId(1L))
|
||||
.thenReturn(Flux.just(mockMemberCardRecord));
|
||||
when(redisUtil.setWithExpire(any(String.class), any(Map.class), any(Long.class)))
|
||||
.thenReturn(Mono.just(true));
|
||||
|
||||
Mono<QRCodeVo> result = checkService.getQRCode(1L);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(qrCodeVo -> {
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(qrCodeVo);
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(qrCodeVo.getQrContent());
|
||||
return true;
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试2: 签到 - checkIn")
|
||||
void testCheckIn() {
|
||||
Long memberId = 1L;
|
||||
Map<String, Object> qrData = new HashMap<>();
|
||||
qrData.put("qrContent", "test-qr-content");
|
||||
qrData.put("memberId", memberId);
|
||||
qrData.put("memberCardRecordId", 1L);
|
||||
qrData.put("isUsed", false);
|
||||
qrData.put("expireTime", System.currentTimeMillis() + 3600000);
|
||||
|
||||
String key = RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now();
|
||||
|
||||
when(redisUtil.get(eq(key))).thenReturn(Mono.just(qrData));
|
||||
when(memberCardRecordRepository.findById(1L)).thenReturn(Mono.just(mockMemberCardRecord));
|
||||
when(memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(1L)).thenReturn(Mono.just(mockMemberCard));
|
||||
when(signInRecordRepository.save(any(SignInRecord.class))).thenReturn(Mono.just(mockSignInRecord));
|
||||
when(redisUtil.set(any(String.class), any(Map.class))).thenReturn(Mono.just(true));
|
||||
when(groupCourseBookingService.getBookingsByMemberId(memberId)).thenReturn(Flux.empty());
|
||||
when(signInRecordRepository.findByMemberIdAndDate(eq(memberId), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
Mono<String> result = checkService.checkIn(memberId, "test-qr-content");
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(response -> response.contains("签到成功"))
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试3: 查询签到记录列表 - getSignInRecords")
|
||||
void testGetSignInRecords() {
|
||||
when(signInRecordRepository.findByMemberIdAndTimeRange(
|
||||
eq(1L), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Flux.just(mockSignInRecord));
|
||||
|
||||
Flux<SignInRecordVO> result = checkService.getSignInRecords(1L,
|
||||
LocalDate.now().minusDays(30), LocalDate.now());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextCount(1)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试4: 查询单条签到记录 - getSignInRecordById")
|
||||
void testGetSignInRecordById() {
|
||||
when(signInRecordRepository.findById(1L))
|
||||
.thenReturn(Mono.just(mockSignInRecord));
|
||||
|
||||
Mono<SignInRecordVO> result = checkService.getSignInRecordById(1L);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(vo -> vo.getId() == 1L)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试5: 查询签到记录 - 记录不存在")
|
||||
void testGetSignInRecordById_NotFound() {
|
||||
when(signInRecordRepository.findById(999L))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
Mono<SignInRecordVO> result = checkService.getSignInRecordById(999L);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试6: 获取签到统计 - getSignInStats")
|
||||
void testGetSignInStats() {
|
||||
when(signInRecordRepository.countByMemberIdAndTimeRange(
|
||||
eq(1L), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(10L));
|
||||
when(signInRecordRepository.countSuccessByMemberIdAndTimeRange(
|
||||
eq(1L), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(8L));
|
||||
when(signInRecordRepository.getFirstSignInTime(
|
||||
eq(1L), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(LocalDateTime.now().minusDays(29)));
|
||||
when(signInRecordRepository.getLastSignInTime(
|
||||
eq(1L), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(LocalDateTime.now()));
|
||||
|
||||
Mono<SignInStatsVO> result = checkService.getSignInStats(1L,
|
||||
LocalDate.now().minusDays(30), LocalDate.now());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(stats -> {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(10L, stats.getTotalCount());
|
||||
org.junit.jupiter.api.Assertions.assertEquals(8L, stats.getSuccessCount());
|
||||
return true;
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试7: 获取每日签到统计 - getDailySignInStats")
|
||||
void testGetDailySignInStats() {
|
||||
when(signInRecordRepository.countByTimeRange(any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(50L));
|
||||
when(signInRecordRepository.countSuccessByTimeRange(
|
||||
any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(45L));
|
||||
when(signInRecordRepository.countDistinctMembersByTimeRange(
|
||||
any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.just(30L));
|
||||
|
||||
Mono<SignInStatsVO> result = checkService.getDailySignInStats(LocalDate.now());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(stats -> {
|
||||
org.junit.jupiter.api.Assertions.assertEquals(50L, stats.getTotalCount());
|
||||
org.junit.jupiter.api.Assertions.assertEquals(45L, stats.getSuccessCount());
|
||||
org.junit.jupiter.api.Assertions.assertEquals(30L, stats.getUniqueMemberCount());
|
||||
return true;
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试8: 导出签到记录 - exportSignInRecords")
|
||||
void testExportSignInRecords() {
|
||||
when(signInRecordRepository.findByMemberIdAndTimeRange(
|
||||
eq(1L), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Flux.just(mockSignInRecord));
|
||||
|
||||
Mono<byte[]> result = checkService.exportSignInRecords(1L,
|
||||
LocalDate.now().minusDays(7), LocalDate.now());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(bytes -> bytes.length > 0)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试9: 签到失败 - 二维码无效")
|
||||
void testCheckIn_QRCodeInvalid() {
|
||||
Long memberId = 1L;
|
||||
String key = RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now();
|
||||
when(redisUtil.get(eq(key))).thenReturn(Mono.just(new HashMap<>()));
|
||||
when(signInRecordRepository.findByMemberIdAndDate(eq(memberId), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
Mono<String> result = checkService.checkIn(memberId, "invalid-qr");
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectError(RuntimeException.class)
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("测试10: 签到失败 - 二维码不存在")
|
||||
void testCheckIn_QRCodeNotFound() {
|
||||
Long memberId = 1L;
|
||||
String key = RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now();
|
||||
when(redisUtil.get(eq(key))).thenReturn(Mono.empty());
|
||||
when(signInRecordRepository.findByMemberIdAndDate(eq(memberId), any(LocalDateTime.class), any(LocalDateTime.class)))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
Mono<String> result = checkService.checkIn(memberId, "not-exist");
|
||||
|
||||
StepVerifier.create(result)
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# Test Configuration
|
||||
@@ -0,0 +1,3 @@
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
|
||||
@@ -0,0 +1,108 @@
|
||||
<?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 https://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>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-dataCount</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>gym-dataCount</name>
|
||||
<description>Data Statistics Module</description>
|
||||
<url/>
|
||||
<licenses>
|
||||
<license/>
|
||||
</licenses>
|
||||
<developers>
|
||||
<developer/>
|
||||
</developers>
|
||||
<scm>
|
||||
<connection/>
|
||||
<developerConnection/>
|
||||
<tag/>
|
||||
<url/>
|
||||
</scm>
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
</properties>
|
||||
<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>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.swagger.core.v3</groupId>
|
||||
<artifactId>swagger-annotations-jakarta</artifactId>
|
||||
<version>2.2.43</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Redis依赖-->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 会员模块依赖 -->
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-member</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 团课模块依赖 -->
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-groupCourse</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 签到模块依赖 -->
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-checkIn</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package cn.novalon.gym.manage.datacount;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.data.r2dbc.repository.config.EnableR2dbcRepositories;
|
||||
|
||||
@SpringBootApplication(scanBasePackages = "cn.novalon.gym.manage", exclude = {
|
||||
ReactiveUserDetailsServiceAutoConfiguration.class })
|
||||
@EnableR2dbcRepositories(basePackages = {
|
||||
"cn.novalon.gym.manage.db.dao",
|
||||
"cn.novalon.gym.manage.member.repository",
|
||||
"cn.novalon.gym.manage.groupcourse.dao",
|
||||
"cn.novalon.gym.manage.datacount.dao"
|
||||
})
|
||||
@ComponentScan(basePackages = "cn.novalon.gym.manage")
|
||||
public class GymDataCountApplication {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GymDataCountApplication.class);
|
||||
|
||||
public static void main(String[] args) {
|
||||
logger.info("数据统计模块启动中...");
|
||||
SpringApplication.run(GymDataCountApplication.class, args);
|
||||
logger.info("数据统计模块启动完成");
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package cn.novalon.gym.manage.datacount.config;
|
||||
|
||||
import cn.novalon.gym.manage.datacount.handler.DataCountHandler;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
|
||||
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
|
||||
|
||||
@Configuration
|
||||
public class DataCountRouter {
|
||||
|
||||
@Bean
|
||||
public RouterFunction<ServerResponse> dataCountRoutes(DataCountHandler dataCountHandler) {
|
||||
return route()
|
||||
// ========== 会员数据统计路由 ==========
|
||||
.GET("/api/stats/member/date/{date}", dataCountHandler::getMemberStatisticsByDate)
|
||||
.GET("/api/stats/member/range", dataCountHandler::getMemberStatisticsByDateRange)
|
||||
.GET("/api/stats/member/summary", dataCountHandler::getMemberStatisticsSummary)
|
||||
|
||||
// ========== 预约数据统计路由 ==========
|
||||
.GET("/api/stats/booking/date/{date}", dataCountHandler::getBookingStatisticsByDate)
|
||||
.GET("/api/stats/booking/range", dataCountHandler::getBookingStatisticsByDateRange)
|
||||
.GET("/api/stats/booking/summary", dataCountHandler::getBookingStatisticsSummary)
|
||||
|
||||
// ========== 签到数据统计路由 ==========
|
||||
.GET("/api/stats/signin/date/{date}", dataCountHandler::getSignInStatisticsByDate)
|
||||
.GET("/api/stats/signin/range", dataCountHandler::getSignInStatisticsByDateRange)
|
||||
.GET("/api/stats/signin/summary", dataCountHandler::getSignInStatisticsSummary)
|
||||
|
||||
// ========== 数据导出路由 ==========
|
||||
.GET("/api/stats/export/member", dataCountHandler::exportMemberStatistics)
|
||||
.GET("/api/stats/export/booking", dataCountHandler::exportBookingStatistics)
|
||||
.GET("/api/stats/export/signin", dataCountHandler::exportSignInStatistics)
|
||||
.GET("/api/stats/export/all", dataCountHandler::exportAllStatistics)
|
||||
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
package cn.novalon.gym.manage.datacount.dao;
|
||||
|
||||
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.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 统计数据访问层
|
||||
* 使用 R2dbcEntityTemplate 执行原生SQL查询
|
||||
*
|
||||
* @author 数据统计模块
|
||||
* @date 2026-06-06
|
||||
*/
|
||||
@Repository
|
||||
public class StatisticsDao {
|
||||
|
||||
private final R2dbcEntityTemplate r2dbcEntityTemplate;
|
||||
|
||||
public StatisticsDao(R2dbcEntityTemplate r2dbcEntityTemplate) {
|
||||
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
|
||||
}
|
||||
|
||||
// ========== 会员数据统计 ==========
|
||||
|
||||
public Mono<Integer> countTotalMembers() {
|
||||
String sql = "SELECT COUNT(*) FROM member_user WHERE is_deleted = FALSE";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.map(row -> row.get(0, Integer.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
public Mono<Integer> countNewMembersByDate(LocalDate date) {
|
||||
String sql = "SELECT COUNT(*) FROM member_user WHERE is_deleted = FALSE AND DATE(created_at) = $1";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.bind(0, date)
|
||||
.map(row -> row.get(0, Integer.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
public Mono<Integer> countActiveMembersByDate(LocalDate date) {
|
||||
String sql = "SELECT COUNT(DISTINCT m.id) FROM member_user m LEFT JOIN member_card_record r ON m.id = r.member_id WHERE m.is_deleted = FALSE AND r.deleted_at IS NULL AND DATE(r.created_at) = $1";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.bind(0, date)
|
||||
.map(row -> row.get(0, Integer.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
public Mono<Integer> countMaleMembers() {
|
||||
String sql = "SELECT COUNT(*) FROM member_user WHERE is_deleted = FALSE AND gender = 1";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.map(row -> row.get(0, Integer.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
public Mono<Integer> countFemaleMembers() {
|
||||
String sql = "SELECT COUNT(*) FROM member_user WHERE is_deleted = FALSE AND gender = 2";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.map(row -> row.get(0, Integer.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
public Mono<Integer> countCardPurchaseByDate(LocalDate date) {
|
||||
String sql = "SELECT COUNT(DISTINCT r.member_id) FROM member_card_record r WHERE r.deleted_at IS NULL AND DATE(r.created_at) = $1";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.bind(0, date)
|
||||
.map(row -> row.get(0, Integer.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
public Mono<Double> calculateAverageAge() {
|
||||
String sql = "SELECT AVG(EXTRACT(YEAR FROM AGE(CURRENT_DATE, birthday))) FROM member_user WHERE is_deleted = FALSE AND birthday IS NOT NULL";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.map(row -> row.get(0, Double.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
public Mono<Integer> countActiveMembersByTimeRange(LocalDateTime startTime, LocalDateTime endTime) {
|
||||
String sql = "SELECT COUNT(*) FROM member_user WHERE is_deleted = FALSE AND last_login_at >= $1 AND last_login_at < $2";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.bind(0, startTime)
|
||||
.bind(1, endTime)
|
||||
.map(row -> row.get(0, Integer.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
// ========== 预约数据统计 ==========
|
||||
|
||||
public Mono<Integer> countTotalBookingsByDate(LocalDate date) {
|
||||
String sql = "SELECT COUNT(*) FROM group_course_booking WHERE deleted_at IS NULL AND DATE(created_at) = $1";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.bind(0, date)
|
||||
.map(row -> row.get(0, Integer.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
public Mono<Integer> countCancelledBookingsByDate(LocalDate date) {
|
||||
String sql = "SELECT COUNT(*) FROM group_course_booking WHERE deleted_at IS NULL AND status = '1' AND DATE(cancel_time) = $1";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.bind(0, date)
|
||||
.map(row -> row.get(0, Integer.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
public Mono<Integer> countAttendedBookingsByDate(LocalDate date) {
|
||||
String sql = "SELECT COUNT(*) FROM group_course_booking WHERE deleted_at IS NULL AND status = '2' AND DATE(created_at) = $1";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.bind(0, date)
|
||||
.map(row -> row.get(0, Integer.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
public Mono<Integer> countTotalCoursesByDate(LocalDate date) {
|
||||
String sql = "SELECT COUNT(*) FROM group_course WHERE deleted_at IS NULL AND DATE(start_time) = $1";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.bind(0, date)
|
||||
.map(row -> row.get(0, Integer.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
public Mono<Integer> countFullCoursesByDate(LocalDate date) {
|
||||
String sql = "SELECT COUNT(*) FROM group_course WHERE deleted_at IS NULL AND current_members = max_members AND DATE(start_time) = $1";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.bind(0, date)
|
||||
.map(row -> row.get(0, Integer.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
public Mono<Object[]> findMostPopularCourseByDate(LocalDate date) {
|
||||
String sql = "SELECT c.course_name, COUNT(b.id) as booking_count FROM group_course_booking b LEFT JOIN group_course c ON b.course_id = c.id WHERE b.deleted_at IS NULL AND c.deleted_at IS NULL AND DATE(b.created_at) = $1 GROUP BY c.id, c.course_name ORDER BY booking_count DESC LIMIT 1";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.bind(0, date)
|
||||
.map(row -> new Object[]{row.get(0, String.class), row.get(1, Integer.class)})
|
||||
.one();
|
||||
}
|
||||
|
||||
// ========== 签到数据统计 ==========
|
||||
|
||||
public Mono<Integer> countTotalSignInsByDate(LocalDate date) {
|
||||
String sql = "SELECT COUNT(*) FROM sign_in_record WHERE is_delete = FALSE AND DATE(sign_in_time) = $1";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.bind(0, date)
|
||||
.map(row -> row.get(0, Integer.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
public Mono<Integer> countSuccessSignInsByDate(LocalDate date) {
|
||||
String sql = "SELECT COUNT(*) FROM sign_in_record WHERE is_delete = FALSE AND sign_in_status = 'SUCCESS' AND DATE(sign_in_time) = $1";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.bind(0, date)
|
||||
.map(row -> row.get(0, Integer.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
public Mono<Integer> countFailedSignInsByDate(LocalDate date) {
|
||||
String sql = "SELECT COUNT(*) FROM sign_in_record WHERE is_delete = FALSE AND sign_in_status = 'FAILED' AND DATE(sign_in_time) = $1";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.bind(0, date)
|
||||
.map(row -> row.get(0, Integer.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
public Mono<Integer> countUniqueMembersByDate(LocalDate date) {
|
||||
String sql = "SELECT COUNT(DISTINCT member_id) FROM sign_in_record WHERE is_delete = FALSE AND DATE(sign_in_time) = $1";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.bind(0, date)
|
||||
.map(row -> row.get(0, Integer.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
public Mono<Integer> countQrCodeSignInsByDate(LocalDate date) {
|
||||
String sql = "SELECT COUNT(*) FROM sign_in_record WHERE is_delete = FALSE AND sign_in_type = 'QR_CODE' AND DATE(sign_in_time) = $1";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.bind(0, date)
|
||||
.map(row -> row.get(0, Integer.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
public Mono<Integer> countManualSignInsByDate(LocalDate date) {
|
||||
String sql = "SELECT COUNT(*) FROM sign_in_record WHERE is_delete = FALSE AND sign_in_type = 'MANUAL' AND DATE(sign_in_time) = $1";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.bind(0, date)
|
||||
.map(row -> row.get(0, Integer.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
public Mono<Integer> countFaceSignInsByDate(LocalDate date) {
|
||||
String sql = "SELECT COUNT(*) FROM sign_in_record WHERE is_delete = FALSE AND sign_in_type = 'FACE' AND DATE(sign_in_time) = $1";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.bind(0, date)
|
||||
.map(row -> row.get(0, Integer.class))
|
||||
.one();
|
||||
}
|
||||
|
||||
public Flux<Object[]> findSignInStatisticsByDateRange(LocalDate startDate, LocalDate endDate) {
|
||||
String sql = "SELECT DATE(s.sign_in_time) as date, " +
|
||||
"COUNT(*) as total_sign_ins, " +
|
||||
"SUM(CASE WHEN s.sign_in_status = 'SUCCESS' THEN 1 ELSE 0 END) as success_sign_ins, " +
|
||||
"SUM(CASE WHEN s.sign_in_status = 'FAILED' THEN 1 ELSE 0 END) as failed_sign_ins, " +
|
||||
"COUNT(DISTINCT s.member_id) as unique_members, " +
|
||||
"SUM(CASE WHEN s.sign_in_type = 'QR_CODE' THEN 1 ELSE 0 END) as qr_code_sign_ins, " +
|
||||
"SUM(CASE WHEN s.sign_in_type = 'MANUAL' THEN 1 ELSE 0 END) as manual_sign_ins, " +
|
||||
"SUM(CASE WHEN s.sign_in_type = 'FACE' THEN 1 ELSE 0 END) as face_sign_ins " +
|
||||
"FROM sign_in_record s " +
|
||||
"WHERE s.is_delete = FALSE AND DATE(s.sign_in_time) BETWEEN $1 AND $2 " +
|
||||
"GROUP BY DATE(s.sign_in_time) " +
|
||||
"ORDER BY date";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.bind(0, startDate)
|
||||
.bind(1, endDate)
|
||||
.map(row -> new Object[]{
|
||||
row.get(0, LocalDate.class),
|
||||
row.get(1, Integer.class),
|
||||
row.get(2, Integer.class),
|
||||
row.get(3, Integer.class),
|
||||
row.get(4, Integer.class),
|
||||
row.get(5, Integer.class),
|
||||
row.get(6, Integer.class),
|
||||
row.get(7, Integer.class)
|
||||
})
|
||||
.all();
|
||||
}
|
||||
|
||||
// ========== 日期范围查询 ==========
|
||||
|
||||
public Flux<Object[]> findMemberStatisticsByDateRange(LocalDate startDate, LocalDate endDate) {
|
||||
String sql = "SELECT DATE(m.created_at) as date, COUNT(*) as new_members FROM member_user m WHERE m.is_deleted = FALSE AND DATE(m.created_at) BETWEEN $1 AND $2 GROUP BY DATE(m.created_at) ORDER BY date";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.bind(0, startDate)
|
||||
.bind(1, endDate)
|
||||
.map(row -> new Object[]{row.get(0, LocalDate.class), row.get(1, Integer.class)})
|
||||
.all();
|
||||
}
|
||||
|
||||
public Flux<Object[]> findBookingStatisticsByDateRange(LocalDate startDate, LocalDate endDate) {
|
||||
String sql = "SELECT DATE(b.created_at) as date, COUNT(*) as total_bookings, SUM(CASE WHEN b.status = '1' THEN 1 ELSE 0 END) as cancelled_bookings, SUM(CASE WHEN b.status = '2' THEN 1 ELSE 0 END) as attended_bookings FROM group_course_booking b WHERE b.deleted_at IS NULL AND DATE(b.created_at) BETWEEN $1 AND $2 GROUP BY DATE(b.created_at) ORDER BY date";
|
||||
return r2dbcEntityTemplate.getDatabaseClient()
|
||||
.sql(sql)
|
||||
.bind(0, startDate)
|
||||
.bind(1, endDate)
|
||||
.map(row -> new Object[]{row.get(0, LocalDate.class), row.get(1, Integer.class), row.get(2, Integer.class), row.get(3, Integer.class)})
|
||||
.all();
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package cn.novalon.gym.manage.datacount.domain;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Schema(description = "预约数据统计")
|
||||
public class BookingStatistics {
|
||||
|
||||
@Schema(description = "统计日期", example = "2026-01-01")
|
||||
private LocalDate date;
|
||||
|
||||
@Schema(description = "预约总数", example = "100")
|
||||
private Integer totalBookings;
|
||||
|
||||
@Schema(description = "取消预约数", example = "10")
|
||||
private Integer cancelledBookings;
|
||||
|
||||
@Schema(description = "实际到场数", example = "85")
|
||||
private Integer attendedBookings;
|
||||
|
||||
@Schema(description = "预约成功率", example = "0.85")
|
||||
private Double bookingSuccessRate;
|
||||
|
||||
@Schema(description = "取消率", example = "0.10")
|
||||
private Double cancellationRate;
|
||||
|
||||
@Schema(description = "到场率", example = "0.85")
|
||||
private Double attendanceRate;
|
||||
|
||||
@Schema(description = "热门课程名称", example = "动感单车")
|
||||
private String popularCourseName;
|
||||
|
||||
@Schema(description = "热门课程预约数", example = "25")
|
||||
private Integer popularCourseCount;
|
||||
|
||||
@Schema(description = "团课总数", example = "20")
|
||||
private Integer totalCourses;
|
||||
|
||||
@Schema(description = "满员课程数", example = "5")
|
||||
private Integer fullCourses;
|
||||
|
||||
@Schema(description = "开始日期(用于汇总统计)")
|
||||
private LocalDate startDate;
|
||||
|
||||
@Schema(description = "结束日期(用于汇总统计)")
|
||||
private LocalDate endDate;
|
||||
|
||||
public BookingStatistics() {
|
||||
}
|
||||
|
||||
public BookingStatistics(LocalDate date) {
|
||||
this.date = date;
|
||||
this.totalBookings = 0;
|
||||
this.cancelledBookings = 0;
|
||||
this.attendedBookings = 0;
|
||||
this.bookingSuccessRate = 0.0;
|
||||
this.cancellationRate = 0.0;
|
||||
this.attendanceRate = 0.0;
|
||||
this.popularCourseName = "";
|
||||
this.popularCourseCount = 0;
|
||||
this.totalCourses = 0;
|
||||
this.fullCourses = 0;
|
||||
}
|
||||
|
||||
public LocalDate getDate() {
|
||||
return date;
|
||||
}
|
||||
|
||||
public void setDate(LocalDate date) {
|
||||
this.date = date;
|
||||
}
|
||||
|
||||
public Integer getTotalBookings() {
|
||||
return totalBookings;
|
||||
}
|
||||
|
||||
public void setTotalBookings(Integer totalBookings) {
|
||||
this.totalBookings = totalBookings;
|
||||
}
|
||||
|
||||
public Integer getCancelledBookings() {
|
||||
return cancelledBookings;
|
||||
}
|
||||
|
||||
public void setCancelledBookings(Integer cancelledBookings) {
|
||||
this.cancelledBookings = cancelledBookings;
|
||||
}
|
||||
|
||||
public Integer getAttendedBookings() {
|
||||
return attendedBookings;
|
||||
}
|
||||
|
||||
public void setAttendedBookings(Integer attendedBookings) {
|
||||
this.attendedBookings = attendedBookings;
|
||||
}
|
||||
|
||||
public Double getBookingSuccessRate() {
|
||||
return bookingSuccessRate;
|
||||
}
|
||||
|
||||
public void setBookingSuccessRate(Double bookingSuccessRate) {
|
||||
this.bookingSuccessRate = bookingSuccessRate;
|
||||
}
|
||||
|
||||
public Double getCancellationRate() {
|
||||
return cancellationRate;
|
||||
}
|
||||
|
||||
public void setCancellationRate(Double cancellationRate) {
|
||||
this.cancellationRate = cancellationRate;
|
||||
}
|
||||
|
||||
public Double getAttendanceRate() {
|
||||
return attendanceRate;
|
||||
}
|
||||
|
||||
public void setAttendanceRate(Double attendanceRate) {
|
||||
this.attendanceRate = attendanceRate;
|
||||
}
|
||||
|
||||
public String getPopularCourseName() {
|
||||
return popularCourseName;
|
||||
}
|
||||
|
||||
public void setPopularCourseName(String popularCourseName) {
|
||||
this.popularCourseName = popularCourseName;
|
||||
}
|
||||
|
||||
public Integer getPopularCourseCount() {
|
||||
return popularCourseCount;
|
||||
}
|
||||
|
||||
public void setPopularCourseCount(Integer popularCourseCount) {
|
||||
this.popularCourseCount = popularCourseCount;
|
||||
}
|
||||
|
||||
public Integer getTotalCourses() {
|
||||
return totalCourses;
|
||||
}
|
||||
|
||||
public void setTotalCourses(Integer totalCourses) {
|
||||
this.totalCourses = totalCourses;
|
||||
}
|
||||
|
||||
public Integer getFullCourses() {
|
||||
return fullCourses;
|
||||
}
|
||||
|
||||
public void setFullCourses(Integer fullCourses) {
|
||||
this.fullCourses = fullCourses;
|
||||
}
|
||||
|
||||
public LocalDate getStartDate() {
|
||||
return startDate;
|
||||
}
|
||||
|
||||
public void setStartDate(LocalDate startDate) {
|
||||
this.startDate = startDate;
|
||||
}
|
||||
|
||||
public LocalDate getEndDate() {
|
||||
return endDate;
|
||||
}
|
||||
|
||||
public void setEndDate(LocalDate endDate) {
|
||||
this.endDate = endDate;
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package cn.novalon.gym.manage.datacount.domain;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Schema(description = "会员数据统计")
|
||||
public class MemberStatistics {
|
||||
|
||||
@Schema(description = "统计日期", example = "2026-01-01")
|
||||
private LocalDate date;
|
||||
|
||||
@Schema(description = "新增会员数", example = "10")
|
||||
private Integer newMembers;
|
||||
|
||||
@Schema(description = "活跃会员数", example = "50")
|
||||
private Integer activeMembers;
|
||||
|
||||
@Schema(description = "留存会员数", example = "45")
|
||||
private Integer retainedMembers;
|
||||
|
||||
@Schema(description = "总会员数", example = "200")
|
||||
private Integer totalMembers;
|
||||
|
||||
@Schema(description = "男性会员数", example = "100")
|
||||
private Integer maleMembers;
|
||||
|
||||
@Schema(description = "女性会员数", example = "100")
|
||||
private Integer femaleMembers;
|
||||
|
||||
@Schema(description = "会员卡购买人数", example = "30")
|
||||
private Integer cardPurchaseCount;
|
||||
|
||||
@Schema(description = "平均年龄", example = "28")
|
||||
private Double averageAge;
|
||||
|
||||
@Schema(description = "开始日期(用于汇总统计)")
|
||||
private LocalDate startDate;
|
||||
|
||||
@Schema(description = "结束日期(用于汇总统计)")
|
||||
private LocalDate endDate;
|
||||
|
||||
public MemberStatistics() {
|
||||
}
|
||||
|
||||
public MemberStatistics(LocalDate date) {
|
||||
this.date = date;
|
||||
this.newMembers = 0;
|
||||
this.activeMembers = 0;
|
||||
this.retainedMembers = 0;
|
||||
this.totalMembers = 0;
|
||||
this.maleMembers = 0;
|
||||
this.femaleMembers = 0;
|
||||
this.cardPurchaseCount = 0;
|
||||
this.averageAge = 0.0;
|
||||
}
|
||||
|
||||
public LocalDate getDate() {
|
||||
return date;
|
||||
}
|
||||
|
||||
public void setDate(LocalDate date) {
|
||||
this.date = date;
|
||||
}
|
||||
|
||||
public Integer getNewMembers() {
|
||||
return newMembers;
|
||||
}
|
||||
|
||||
public void setNewMembers(Integer newMembers) {
|
||||
this.newMembers = newMembers;
|
||||
}
|
||||
|
||||
public Integer getActiveMembers() {
|
||||
return activeMembers;
|
||||
}
|
||||
|
||||
public void setActiveMembers(Integer activeMembers) {
|
||||
this.activeMembers = activeMembers;
|
||||
}
|
||||
|
||||
public Integer getRetainedMembers() {
|
||||
return retainedMembers;
|
||||
}
|
||||
|
||||
public void setRetainedMembers(Integer retainedMembers) {
|
||||
this.retainedMembers = retainedMembers;
|
||||
}
|
||||
|
||||
public Integer getTotalMembers() {
|
||||
return totalMembers;
|
||||
}
|
||||
|
||||
public void setTotalMembers(Integer totalMembers) {
|
||||
this.totalMembers = totalMembers;
|
||||
}
|
||||
|
||||
public Integer getMaleMembers() {
|
||||
return maleMembers;
|
||||
}
|
||||
|
||||
public void setMaleMembers(Integer maleMembers) {
|
||||
this.maleMembers = maleMembers;
|
||||
}
|
||||
|
||||
public Integer getFemaleMembers() {
|
||||
return femaleMembers;
|
||||
}
|
||||
|
||||
public void setFemaleMembers(Integer femaleMembers) {
|
||||
this.femaleMembers = femaleMembers;
|
||||
}
|
||||
|
||||
public Integer getCardPurchaseCount() {
|
||||
return cardPurchaseCount;
|
||||
}
|
||||
|
||||
public void setCardPurchaseCount(Integer cardPurchaseCount) {
|
||||
this.cardPurchaseCount = cardPurchaseCount;
|
||||
}
|
||||
|
||||
public Double getAverageAge() {
|
||||
return averageAge;
|
||||
}
|
||||
|
||||
public void setAverageAge(Double averageAge) {
|
||||
this.averageAge = averageAge;
|
||||
}
|
||||
|
||||
public LocalDate getStartDate() {
|
||||
return startDate;
|
||||
}
|
||||
|
||||
public void setStartDate(LocalDate startDate) {
|
||||
this.startDate = startDate;
|
||||
}
|
||||
|
||||
public LocalDate getEndDate() {
|
||||
return endDate;
|
||||
}
|
||||
|
||||
public void setEndDate(LocalDate endDate) {
|
||||
this.endDate = endDate;
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package cn.novalon.gym.manage.datacount.domain;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Schema(description = "签到数据统计")
|
||||
public class SignInStatistics {
|
||||
|
||||
@Schema(description = "统计日期", example = "2026-01-01")
|
||||
private LocalDate date;
|
||||
|
||||
@Schema(description = "签到总次数", example = "100")
|
||||
private Integer totalSignIns;
|
||||
|
||||
@Schema(description = "成功签到次数", example = "95")
|
||||
private Integer successSignIns;
|
||||
|
||||
@Schema(description = "失败签到次数", example = "5")
|
||||
private Integer failedSignIns;
|
||||
|
||||
@Schema(description = "签到成功率", example = "0.95")
|
||||
private Double successRate;
|
||||
|
||||
@Schema(description = "签到会员数(去重)", example = "80")
|
||||
private Integer uniqueMembers;
|
||||
|
||||
@Schema(description = "平均到店频次(月均)", example = "4.5")
|
||||
private Double averageFrequency;
|
||||
|
||||
@Schema(description = "扫码签到次数", example = "80")
|
||||
private Integer qrCodeSignIns;
|
||||
|
||||
@Schema(description = "手动签到次数", example = "15")
|
||||
private Integer manualSignIns;
|
||||
|
||||
@Schema(description = "人脸识别签到次数", example = "5")
|
||||
private Integer faceSignIns;
|
||||
|
||||
@Schema(description = "开始日期(用于汇总统计)")
|
||||
private LocalDate startDate;
|
||||
|
||||
@Schema(description = "结束日期(用于汇总统计)")
|
||||
private LocalDate endDate;
|
||||
|
||||
public SignInStatistics() {
|
||||
}
|
||||
|
||||
public SignInStatistics(LocalDate date) {
|
||||
this.date = date;
|
||||
this.totalSignIns = 0;
|
||||
this.successSignIns = 0;
|
||||
this.failedSignIns = 0;
|
||||
this.successRate = 0.0;
|
||||
this.uniqueMembers = 0;
|
||||
this.averageFrequency = 0.0;
|
||||
this.qrCodeSignIns = 0;
|
||||
this.manualSignIns = 0;
|
||||
this.faceSignIns = 0;
|
||||
}
|
||||
|
||||
public LocalDate getDate() {
|
||||
return date;
|
||||
}
|
||||
|
||||
public void setDate(LocalDate date) {
|
||||
this.date = date;
|
||||
}
|
||||
|
||||
public Integer getTotalSignIns() {
|
||||
return totalSignIns;
|
||||
}
|
||||
|
||||
public void setTotalSignIns(Integer totalSignIns) {
|
||||
this.totalSignIns = totalSignIns;
|
||||
}
|
||||
|
||||
public Integer getSuccessSignIns() {
|
||||
return successSignIns;
|
||||
}
|
||||
|
||||
public void setSuccessSignIns(Integer successSignIns) {
|
||||
this.successSignIns = successSignIns;
|
||||
}
|
||||
|
||||
public Integer getFailedSignIns() {
|
||||
return failedSignIns;
|
||||
}
|
||||
|
||||
public void setFailedSignIns(Integer failedSignIns) {
|
||||
this.failedSignIns = failedSignIns;
|
||||
}
|
||||
|
||||
public Double getSuccessRate() {
|
||||
return successRate;
|
||||
}
|
||||
|
||||
public void setSuccessRate(Double successRate) {
|
||||
this.successRate = successRate;
|
||||
}
|
||||
|
||||
public Integer getUniqueMembers() {
|
||||
return uniqueMembers;
|
||||
}
|
||||
|
||||
public void setUniqueMembers(Integer uniqueMembers) {
|
||||
this.uniqueMembers = uniqueMembers;
|
||||
}
|
||||
|
||||
public Double getAverageFrequency() {
|
||||
return averageFrequency;
|
||||
}
|
||||
|
||||
public void setAverageFrequency(Double averageFrequency) {
|
||||
this.averageFrequency = averageFrequency;
|
||||
}
|
||||
|
||||
public Integer getQrCodeSignIns() {
|
||||
return qrCodeSignIns;
|
||||
}
|
||||
|
||||
public void setQrCodeSignIns(Integer qrCodeSignIns) {
|
||||
this.qrCodeSignIns = qrCodeSignIns;
|
||||
}
|
||||
|
||||
public Integer getManualSignIns() {
|
||||
return manualSignIns;
|
||||
}
|
||||
|
||||
public void setManualSignIns(Integer manualSignIns) {
|
||||
this.manualSignIns = manualSignIns;
|
||||
}
|
||||
|
||||
public Integer getFaceSignIns() {
|
||||
return faceSignIns;
|
||||
}
|
||||
|
||||
public void setFaceSignIns(Integer faceSignIns) {
|
||||
this.faceSignIns = faceSignIns;
|
||||
}
|
||||
|
||||
public LocalDate getStartDate() {
|
||||
return startDate;
|
||||
}
|
||||
|
||||
public void setStartDate(LocalDate startDate) {
|
||||
this.startDate = startDate;
|
||||
}
|
||||
|
||||
public LocalDate getEndDate() {
|
||||
return endDate;
|
||||
}
|
||||
|
||||
public void setEndDate(LocalDate endDate) {
|
||||
this.endDate = endDate;
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package cn.novalon.gym.manage.datacount.domain;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "统计数据导出DTO")
|
||||
public class StatisticsExportDTO {
|
||||
|
||||
@Schema(description = "导出文件名", example = "statistics_report_20260101")
|
||||
private String fileName;
|
||||
|
||||
@Schema(description = "导出格式", example = "EXCEL")
|
||||
private String format;
|
||||
|
||||
@Schema(description = "开始日期", example = "2026-01-01")
|
||||
private LocalDate startDate;
|
||||
|
||||
@Schema(description = "结束日期", example = "2026-01-31")
|
||||
private LocalDate endDate;
|
||||
|
||||
@Schema(description = "导出类型", example = "MEMBER")
|
||||
private String exportType;
|
||||
|
||||
@Schema(description = "会员统计数据")
|
||||
private List<MemberStatistics> memberStatisticsList;
|
||||
|
||||
@Schema(description = "预约统计数据")
|
||||
private List<BookingStatistics> bookingStatisticsList;
|
||||
|
||||
@Schema(description = "签到统计数据")
|
||||
private List<SignInStatistics> signInStatisticsList;
|
||||
|
||||
public StatisticsExportDTO() {
|
||||
}
|
||||
|
||||
public String getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public void setFileName(String fileName) {
|
||||
this.fileName = fileName;
|
||||
}
|
||||
|
||||
public String getFormat() {
|
||||
return format;
|
||||
}
|
||||
|
||||
public void setFormat(String format) {
|
||||
this.format = format;
|
||||
}
|
||||
|
||||
public LocalDate getStartDate() {
|
||||
return startDate;
|
||||
}
|
||||
|
||||
public void setStartDate(LocalDate startDate) {
|
||||
this.startDate = startDate;
|
||||
}
|
||||
|
||||
public LocalDate getEndDate() {
|
||||
return endDate;
|
||||
}
|
||||
|
||||
public void setEndDate(LocalDate endDate) {
|
||||
this.endDate = endDate;
|
||||
}
|
||||
|
||||
public String getExportType() {
|
||||
return exportType;
|
||||
}
|
||||
|
||||
public void setExportType(String exportType) {
|
||||
this.exportType = exportType;
|
||||
}
|
||||
|
||||
public List<MemberStatistics> getMemberStatisticsList() {
|
||||
return memberStatisticsList;
|
||||
}
|
||||
|
||||
public void setMemberStatisticsList(List<MemberStatistics> memberStatisticsList) {
|
||||
this.memberStatisticsList = memberStatisticsList;
|
||||
}
|
||||
|
||||
public List<BookingStatistics> getBookingStatisticsList() {
|
||||
return bookingStatisticsList;
|
||||
}
|
||||
|
||||
public void setBookingStatisticsList(List<BookingStatistics> bookingStatisticsList) {
|
||||
this.bookingStatisticsList = bookingStatisticsList;
|
||||
}
|
||||
|
||||
public List<SignInStatistics> getSignInStatisticsList() {
|
||||
return signInStatisticsList;
|
||||
}
|
||||
|
||||
public void setSignInStatisticsList(List<SignInStatistics> signInStatisticsList) {
|
||||
this.signInStatisticsList = signInStatisticsList;
|
||||
}
|
||||
}
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
package cn.novalon.gym.manage.datacount.handler;
|
||||
|
||||
import cn.novalon.gym.manage.datacount.domain.BookingStatistics;
|
||||
import cn.novalon.gym.manage.datacount.domain.MemberStatistics;
|
||||
import cn.novalon.gym.manage.datacount.domain.SignInStatistics;
|
||||
import cn.novalon.gym.manage.datacount.service.IDataCountService;
|
||||
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.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
@Tag(name = "数据统计", description = "数据统计相关操作")
|
||||
public class DataCountHandler {
|
||||
|
||||
private final IDataCountService dataCountService;
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
public DataCountHandler(IDataCountService dataCountService) {
|
||||
this.dataCountService = dataCountService;
|
||||
}
|
||||
|
||||
@Operation(summary = "获取会员数据统计(按日期)", description = "获取指定日期的会员数据统计")
|
||||
public Mono<ServerResponse> getMemberStatisticsByDate(ServerRequest request) {
|
||||
String dateStr = request.pathVariable("date");
|
||||
LocalDate date = LocalDate.parse(dateStr, DATE_FORMATTER);
|
||||
|
||||
return dataCountService.getMemberStatisticsByDate(date)
|
||||
.flatMap(stats -> ServerResponse.ok().bodyValue(stats))
|
||||
.switchIfEmpty(ServerResponse.notFound().build())
|
||||
.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> getMemberStatisticsByDateRange(ServerRequest request) {
|
||||
String startDateStr = request.queryParam("startDate").orElseThrow(() -> new IllegalArgumentException("startDate必填"));
|
||||
String endDateStr = request.queryParam("endDate").orElseThrow(() -> new IllegalArgumentException("endDate必填"));
|
||||
|
||||
LocalDate startDate = LocalDate.parse(startDateStr, DATE_FORMATTER);
|
||||
LocalDate endDate = LocalDate.parse(endDateStr, DATE_FORMATTER);
|
||||
|
||||
return dataCountService.getMemberStatisticsByDateRange(startDate, endDate)
|
||||
.collectList()
|
||||
.flatMap(list -> ServerResponse.ok().bodyValue(list))
|
||||
.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> getMemberStatisticsSummary(ServerRequest request) {
|
||||
String startDateStr = request.queryParam("startDate").orElseThrow(() -> new IllegalArgumentException("startDate必填"));
|
||||
String endDateStr = request.queryParam("endDate").orElseThrow(() -> new IllegalArgumentException("endDate必填"));
|
||||
|
||||
LocalDate startDate = LocalDate.parse(startDateStr, DATE_FORMATTER);
|
||||
LocalDate endDate = LocalDate.parse(endDateStr, DATE_FORMATTER);
|
||||
|
||||
return dataCountService.getMemberStatisticsSummary(startDate, endDate)
|
||||
.flatMap(summary -> ServerResponse.ok().bodyValue(summary))
|
||||
.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> getBookingStatisticsByDate(ServerRequest request) {
|
||||
String dateStr = request.pathVariable("date");
|
||||
LocalDate date = LocalDate.parse(dateStr, DATE_FORMATTER);
|
||||
|
||||
return dataCountService.getBookingStatisticsByDate(date)
|
||||
.flatMap(stats -> ServerResponse.ok().bodyValue(stats))
|
||||
.switchIfEmpty(ServerResponse.notFound().build())
|
||||
.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> getBookingStatisticsByDateRange(ServerRequest request) {
|
||||
String startDateStr = request.queryParam("startDate").orElseThrow(() -> new IllegalArgumentException("startDate必填"));
|
||||
String endDateStr = request.queryParam("endDate").orElseThrow(() -> new IllegalArgumentException("endDate必填"));
|
||||
|
||||
LocalDate startDate = LocalDate.parse(startDateStr, DATE_FORMATTER);
|
||||
LocalDate endDate = LocalDate.parse(endDateStr, DATE_FORMATTER);
|
||||
|
||||
return dataCountService.getBookingStatisticsByDateRange(startDate, endDate)
|
||||
.collectList()
|
||||
.flatMap(list -> ServerResponse.ok().bodyValue(list))
|
||||
.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> getBookingStatisticsSummary(ServerRequest request) {
|
||||
String startDateStr = request.queryParam("startDate").orElseThrow(() -> new IllegalArgumentException("startDate必填"));
|
||||
String endDateStr = request.queryParam("endDate").orElseThrow(() -> new IllegalArgumentException("endDate必填"));
|
||||
|
||||
LocalDate startDate = LocalDate.parse(startDateStr, DATE_FORMATTER);
|
||||
LocalDate endDate = LocalDate.parse(endDateStr, DATE_FORMATTER);
|
||||
|
||||
return dataCountService.getBookingStatisticsSummary(startDate, endDate)
|
||||
.flatMap(summary -> ServerResponse.ok().bodyValue(summary))
|
||||
.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 = "导出指定日期范围内的会员数据统计为CSV文件")
|
||||
public Mono<ServerResponse> exportMemberStatistics(ServerRequest request) {
|
||||
String startDateStr = request.queryParam("startDate").orElseThrow(() -> new IllegalArgumentException("startDate必填"));
|
||||
String endDateStr = request.queryParam("endDate").orElseThrow(() -> new IllegalArgumentException("endDate必填"));
|
||||
|
||||
LocalDate startDate = LocalDate.parse(startDateStr, DATE_FORMATTER);
|
||||
LocalDate endDate = LocalDate.parse(endDateStr, DATE_FORMATTER);
|
||||
|
||||
String fileName = "member_statistics_" + startDateStr + "_" + endDateStr + ".csv";
|
||||
|
||||
return dataCountService.exportMemberStatistics(startDate, endDate)
|
||||
.flatMap(data -> ServerResponse.ok()
|
||||
.header("Content-Disposition", "attachment; filename=" + fileName)
|
||||
.header("Content-Type", "text/csv; charset=UTF-8")
|
||||
.bodyValue(data))
|
||||
.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 = "导出指定日期范围内的预约数据统计为CSV文件")
|
||||
public Mono<ServerResponse> exportBookingStatistics(ServerRequest request) {
|
||||
String startDateStr = request.queryParam("startDate").orElseThrow(() -> new IllegalArgumentException("startDate必填"));
|
||||
String endDateStr = request.queryParam("endDate").orElseThrow(() -> new IllegalArgumentException("endDate必填"));
|
||||
|
||||
LocalDate startDate = LocalDate.parse(startDateStr, DATE_FORMATTER);
|
||||
LocalDate endDate = LocalDate.parse(endDateStr, DATE_FORMATTER);
|
||||
|
||||
String fileName = "booking_statistics_" + startDateStr + "_" + endDateStr + ".csv";
|
||||
|
||||
return dataCountService.exportBookingStatistics(startDate, endDate)
|
||||
.flatMap(data -> ServerResponse.ok()
|
||||
.header("Content-Disposition", "attachment; filename=" + fileName)
|
||||
.header("Content-Type", "text/csv; charset=UTF-8")
|
||||
.bodyValue(data))
|
||||
.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 = "导出指定日期范围内的会员和预约数据统计为CSV文件")
|
||||
public Mono<ServerResponse> exportAllStatistics(ServerRequest request) {
|
||||
String startDateStr = request.queryParam("startDate").orElseThrow(() -> new IllegalArgumentException("startDate必填"));
|
||||
String endDateStr = request.queryParam("endDate").orElseThrow(() -> new IllegalArgumentException("endDate必填"));
|
||||
|
||||
LocalDate startDate = LocalDate.parse(startDateStr, DATE_FORMATTER);
|
||||
LocalDate endDate = LocalDate.parse(endDateStr, DATE_FORMATTER);
|
||||
|
||||
String fileName = "all_statistics_" + startDateStr + "_" + endDateStr + ".csv";
|
||||
|
||||
return dataCountService.exportAllStatistics(startDate, endDate)
|
||||
.flatMap(data -> ServerResponse.ok()
|
||||
.header("Content-Disposition", "attachment; filename=" + fileName)
|
||||
.header("Content-Type", "text/csv; charset=UTF-8")
|
||||
.bodyValue(data))
|
||||
.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> getSignInStatisticsByDate(ServerRequest request) {
|
||||
String dateStr = request.pathVariable("date");
|
||||
LocalDate date = LocalDate.parse(dateStr, DATE_FORMATTER);
|
||||
|
||||
return dataCountService.getSignInStatisticsByDate(date)
|
||||
.flatMap(stats -> ServerResponse.ok().bodyValue(stats))
|
||||
.switchIfEmpty(ServerResponse.notFound().build())
|
||||
.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> getSignInStatisticsByDateRange(ServerRequest request) {
|
||||
String startDateStr = request.queryParam("startDate").orElseThrow(() -> new IllegalArgumentException("startDate必填"));
|
||||
String endDateStr = request.queryParam("endDate").orElseThrow(() -> new IllegalArgumentException("endDate必填"));
|
||||
|
||||
LocalDate startDate = LocalDate.parse(startDateStr, DATE_FORMATTER);
|
||||
LocalDate endDate = LocalDate.parse(endDateStr, DATE_FORMATTER);
|
||||
|
||||
return dataCountService.getSignInStatisticsByDateRange(startDate, endDate)
|
||||
.collectList()
|
||||
.flatMap(list -> ServerResponse.ok().bodyValue(list))
|
||||
.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> getSignInStatisticsSummary(ServerRequest request) {
|
||||
String startDateStr = request.queryParam("startDate").orElseThrow(() -> new IllegalArgumentException("startDate必填"));
|
||||
String endDateStr = request.queryParam("endDate").orElseThrow(() -> new IllegalArgumentException("endDate必填"));
|
||||
|
||||
LocalDate startDate = LocalDate.parse(startDateStr, DATE_FORMATTER);
|
||||
LocalDate endDate = LocalDate.parse(endDateStr, DATE_FORMATTER);
|
||||
|
||||
return dataCountService.getSignInStatisticsSummary(startDate, endDate)
|
||||
.flatMap(summary -> ServerResponse.ok().bodyValue(summary))
|
||||
.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 = "导出指定日期范围内的签到数据统计为CSV文件")
|
||||
public Mono<ServerResponse> exportSignInStatistics(ServerRequest request) {
|
||||
String startDateStr = request.queryParam("startDate").orElseThrow(() -> new IllegalArgumentException("startDate必填"));
|
||||
String endDateStr = request.queryParam("endDate").orElseThrow(() -> new IllegalArgumentException("endDate必填"));
|
||||
|
||||
LocalDate startDate = LocalDate.parse(startDateStr, DATE_FORMATTER);
|
||||
LocalDate endDate = LocalDate.parse(endDateStr, DATE_FORMATTER);
|
||||
|
||||
String fileName = "signin_statistics_" + startDateStr + "_" + endDateStr + ".csv";
|
||||
|
||||
return dataCountService.exportSignInStatistics(startDate, endDate)
|
||||
.flatMap(data -> ServerResponse.ok()
|
||||
.header("Content-Disposition", "attachment; filename=" + fileName)
|
||||
.header("Content-Type", "text/csv; charset=UTF-8")
|
||||
.bodyValue(data))
|
||||
.onErrorResume(error -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", error.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(response);
|
||||
});
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package cn.novalon.gym.manage.datacount.scheduler;
|
||||
|
||||
import cn.novalon.gym.manage.datacount.service.IDataCountService;
|
||||
import cn.novalon.gym.manage.datacount.service.impl.DataCountServiceImpl;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 数据统计定时任务
|
||||
*
|
||||
* 功能:每日凌晨1点自动计算前一天的数据统计并缓存到Redis
|
||||
*
|
||||
* @author 数据统计模块
|
||||
* @date 2026-06-06
|
||||
*/
|
||||
@Component
|
||||
public class StatisticsDailyScheduler {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(StatisticsDailyScheduler.class);
|
||||
|
||||
private final IDataCountService dataCountService;
|
||||
|
||||
public StatisticsDailyScheduler(IDataCountService dataCountService) {
|
||||
this.dataCountService = dataCountService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 每日凌晨1点执行前一天的统计数据计算并缓存
|
||||
* cron表达式:秒 分 时 日 月 周
|
||||
*/
|
||||
@Scheduled(cron = "0 0 1 * * ?")
|
||||
public void calculateDailyStatistics() {
|
||||
LocalDate yesterday = LocalDate.now().minusDays(1);
|
||||
logger.info("定时任务开始计算 {} 的数据统计", yesterday);
|
||||
|
||||
dataCountService.getMemberStatisticsByDate(yesterday)
|
||||
.doOnSuccess(stats -> logger.info("会员统计计算完成: date={}, newMembers={}, totalMembers={}",
|
||||
yesterday, stats.getNewMembers(), stats.getTotalMembers()))
|
||||
.doOnError(error -> logger.error("会员统计计算失败: date={}", yesterday, error))
|
||||
.subscribe();
|
||||
|
||||
dataCountService.getBookingStatisticsByDate(yesterday)
|
||||
.doOnSuccess(stats -> logger.info("预约统计计算完成: date={}, totalBookings={}, attendanceRate={}%",
|
||||
yesterday, stats.getTotalBookings(), stats.getAttendanceRate()))
|
||||
.doOnError(error -> logger.error("预约统计计算失败: date={}", yesterday, error))
|
||||
.subscribe();
|
||||
|
||||
dataCountService.getSignInStatisticsByDate(yesterday)
|
||||
.doOnSuccess(stats -> logger.info("签到统计计算完成: date={}, totalSignIns={}, successRate={}%",
|
||||
yesterday, stats.getTotalSignIns(), stats.getSuccessRate()))
|
||||
.doOnError(error -> logger.error("签到统计计算失败: date={}", yesterday, error))
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* 每周一凌晨2点执行上周的数据汇总统计
|
||||
*/
|
||||
@Scheduled(cron = "0 0 2 * * 1")
|
||||
public void calculateWeeklyStatistics() {
|
||||
LocalDate lastWeekEnd = LocalDate.now().minusDays(1);
|
||||
LocalDate lastWeekStart = lastWeekEnd.minusDays(6);
|
||||
logger.info("定时任务开始计算上周汇总统计: {} ~ {}", lastWeekStart, lastWeekEnd);
|
||||
|
||||
dataCountService.getMemberStatisticsSummary(lastWeekStart, lastWeekEnd)
|
||||
.doOnSuccess(stats -> logger.info("上周会员汇总统计完成: newMembers={}", stats.getNewMembers()))
|
||||
.doOnError(error -> logger.error("上周会员汇总统计失败", error))
|
||||
.subscribe();
|
||||
|
||||
dataCountService.getBookingStatisticsSummary(lastWeekStart, lastWeekEnd)
|
||||
.doOnSuccess(stats -> logger.info("上周预约汇总统计完成: totalBookings={}", stats.getTotalBookings()))
|
||||
.doOnError(error -> logger.error("上周预约汇总统计失败", error))
|
||||
.subscribe();
|
||||
|
||||
dataCountService.getSignInStatisticsSummary(lastWeekStart, lastWeekEnd)
|
||||
.doOnSuccess(stats -> logger.info("上周签到汇总统计完成: totalSignIns={}", stats.getTotalSignIns()))
|
||||
.doOnError(error -> logger.error("上周签到汇总统计失败", error))
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* 每月1号凌晨3点执行上月的数据汇总统计
|
||||
*/
|
||||
@Scheduled(cron = "0 0 3 1 * ?")
|
||||
public void calculateMonthlyStatistics() {
|
||||
LocalDate lastMonthEnd = LocalDate.now().minusDays(1);
|
||||
LocalDate lastMonthStart = lastMonthEnd.withDayOfMonth(1);
|
||||
logger.info("定时任务开始计算上月汇总统计: {} ~ {}", lastMonthStart, lastMonthEnd);
|
||||
|
||||
dataCountService.getMemberStatisticsSummary(lastMonthStart, lastMonthEnd)
|
||||
.doOnSuccess(stats -> logger.info("上月会员汇总统计完成: newMembers={}", stats.getNewMembers()))
|
||||
.doOnError(error -> logger.error("上月会员汇总统计失败", error))
|
||||
.subscribe();
|
||||
|
||||
dataCountService.getBookingStatisticsSummary(lastMonthStart, lastMonthEnd)
|
||||
.doOnSuccess(stats -> logger.info("上月预约汇总统计完成: totalBookings={}", stats.getTotalBookings()))
|
||||
.doOnError(error -> logger.error("上月预约汇总统计失败", error))
|
||||
.subscribe();
|
||||
|
||||
dataCountService.getSignInStatisticsSummary(lastMonthStart, lastMonthEnd)
|
||||
.doOnSuccess(stats -> logger.info("上月签到汇总统计完成: totalSignIns={}", stats.getTotalSignIns()))
|
||||
.doOnError(error -> logger.error("上月签到汇总统计失败", error))
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* 每日凌晨4点清理30天前的统计缓存数据
|
||||
* 业务规则:统计数据保留30天(用于趋势分析),业务记录永久保存
|
||||
*/
|
||||
@Scheduled(cron = "0 0 4 * * ?")
|
||||
public void cleanOldStatisticsCache() {
|
||||
LocalDate thirtyDaysAgo = LocalDate.now().minusDays(30);
|
||||
logger.info("定时任务开始清理30天前的统计缓存数据: before {}", thirtyDaysAgo);
|
||||
|
||||
String thirtyDaysAgoStr = thirtyDaysAgo.toString();
|
||||
|
||||
// 清理会员统计缓存
|
||||
String memberCacheKey = DataCountServiceImpl.MEMBER_STATS_CACHE_PREFIX + thirtyDaysAgoStr;
|
||||
dataCountService.getRedisUtil().delete(memberCacheKey)
|
||||
.doOnSuccess(deleted -> logger.info("清理会员统计缓存: key={}, deleted={}", memberCacheKey, deleted))
|
||||
.subscribe();
|
||||
|
||||
// 清理预约统计缓存
|
||||
String bookingCacheKey = DataCountServiceImpl.BOOKING_STATS_CACHE_PREFIX + thirtyDaysAgoStr;
|
||||
dataCountService.getRedisUtil().delete(bookingCacheKey)
|
||||
.doOnSuccess(deleted -> logger.info("清理预约统计缓存: key={}, deleted={}", bookingCacheKey, deleted))
|
||||
.subscribe();
|
||||
|
||||
// 清理签到统计缓存
|
||||
String signInCacheKey = DataCountServiceImpl.SIGN_IN_STATS_CACHE_PREFIX + thirtyDaysAgoStr;
|
||||
dataCountService.getRedisUtil().delete(signInCacheKey)
|
||||
.doOnSuccess(deleted -> logger.info("清理签到统计缓存: key={}, deleted={}", signInCacheKey, deleted))
|
||||
.subscribe();
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package cn.novalon.gym.manage.datacount.service;
|
||||
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import cn.novalon.gym.manage.datacount.domain.BookingStatistics;
|
||||
import cn.novalon.gym.manage.datacount.domain.MemberStatistics;
|
||||
import cn.novalon.gym.manage.datacount.domain.SignInStatistics;
|
||||
import cn.novalon.gym.manage.datacount.domain.StatisticsExportDTO;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public interface IDataCountService {
|
||||
|
||||
Mono<MemberStatistics> getMemberStatisticsByDate(LocalDate date);
|
||||
|
||||
Flux<MemberStatistics> getMemberStatisticsByDateRange(LocalDate startDate, LocalDate endDate);
|
||||
|
||||
Mono<MemberStatistics> getMemberStatisticsSummary(LocalDate startDate, LocalDate endDate);
|
||||
|
||||
Mono<BookingStatistics> getBookingStatisticsByDate(LocalDate date);
|
||||
|
||||
Flux<BookingStatistics> getBookingStatisticsByDateRange(LocalDate startDate, LocalDate endDate);
|
||||
|
||||
Mono<BookingStatistics> getBookingStatisticsSummary(LocalDate startDate, LocalDate endDate);
|
||||
|
||||
// ========== 签到数据统计 ==========
|
||||
|
||||
Mono<SignInStatistics> getSignInStatisticsByDate(LocalDate date);
|
||||
|
||||
Flux<SignInStatistics> getSignInStatisticsByDateRange(LocalDate startDate, LocalDate endDate);
|
||||
|
||||
Mono<SignInStatistics> getSignInStatisticsSummary(LocalDate startDate, LocalDate endDate);
|
||||
|
||||
Mono<byte[]> exportSignInStatistics(LocalDate startDate, LocalDate endDate);
|
||||
|
||||
Mono<byte[]> exportMemberStatistics(LocalDate startDate, LocalDate endDate);
|
||||
|
||||
Mono<byte[]> exportBookingStatistics(LocalDate startDate, LocalDate endDate);
|
||||
|
||||
Mono<byte[]> exportAllStatistics(LocalDate startDate, LocalDate endDate);
|
||||
|
||||
RedisUtil getRedisUtil();
|
||||
}
|
||||
+475
@@ -0,0 +1,475 @@
|
||||
package cn.novalon.gym.manage.datacount.service.impl;
|
||||
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import cn.novalon.gym.manage.datacount.dao.StatisticsDao;
|
||||
import cn.novalon.gym.manage.datacount.domain.BookingStatistics;
|
||||
import cn.novalon.gym.manage.datacount.domain.MemberStatistics;
|
||||
import cn.novalon.gym.manage.datacount.domain.SignInStatistics;
|
||||
import cn.novalon.gym.manage.datacount.service.IDataCountService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class DataCountServiceImpl implements IDataCountService {
|
||||
|
||||
private final StatisticsDao statisticsDao;
|
||||
private final RedisUtil redisUtil;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public static final String MEMBER_STATS_CACHE_PREFIX = "stats:member:";
|
||||
public static final String BOOKING_STATS_CACHE_PREFIX = "stats:booking:";
|
||||
public static final String SIGN_IN_STATS_CACHE_PREFIX = "stats:signin:";
|
||||
private static final long CACHE_EXPIRE_SECONDS = 3600;
|
||||
|
||||
public DataCountServiceImpl(StatisticsDao statisticsDao, RedisUtil redisUtil, ObjectMapper objectMapper) {
|
||||
this.statisticsDao = statisticsDao;
|
||||
this.redisUtil = redisUtil;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<MemberStatistics> getMemberStatisticsByDate(LocalDate date) {
|
||||
String cacheKey = MEMBER_STATS_CACHE_PREFIX + date.toString();
|
||||
return redisUtil.get(cacheKey)
|
||||
.flatMap(cached -> {
|
||||
if (cached != null) {
|
||||
try {
|
||||
MemberStatistics stats = objectMapper.readValue(cached.toString(), MemberStatistics.class);
|
||||
log.debug("从缓存获取会员统计数据, date: {}", date);
|
||||
return Mono.just(stats);
|
||||
} catch (Exception e) {
|
||||
log.warn("缓存数据解析失败, date: {}", date, e);
|
||||
}
|
||||
}
|
||||
return Mono.empty();
|
||||
})
|
||||
.switchIfEmpty(fetchMemberStatisticsByDate(date)
|
||||
.flatMap(stats -> {
|
||||
try {
|
||||
String json = objectMapper.writeValueAsString(stats);
|
||||
return redisUtil.setWithExpire(cacheKey, json, CACHE_EXPIRE_SECONDS)
|
||||
.then(Mono.just(stats));
|
||||
} catch (Exception e) {
|
||||
log.warn("缓存会员统计数据失败, date: {}", date, e);
|
||||
return Mono.just(stats);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private Mono<MemberStatistics> fetchMemberStatisticsByDate(LocalDate date) {
|
||||
return Mono.zip(
|
||||
statisticsDao.countTotalMembers(),
|
||||
statisticsDao.countNewMembersByDate(date),
|
||||
statisticsDao.countActiveMembersByDate(date),
|
||||
statisticsDao.countMaleMembers(),
|
||||
statisticsDao.countFemaleMembers(),
|
||||
statisticsDao.countCardPurchaseByDate(date),
|
||||
statisticsDao.calculateAverageAge()
|
||||
).map(tuple -> {
|
||||
MemberStatistics stats = new MemberStatistics(date);
|
||||
stats.setTotalMembers(tuple.getT1());
|
||||
stats.setNewMembers(tuple.getT2());
|
||||
stats.setActiveMembers(tuple.getT3());
|
||||
stats.setMaleMembers(tuple.getT4());
|
||||
stats.setFemaleMembers(tuple.getT5());
|
||||
stats.setCardPurchaseCount(tuple.getT6());
|
||||
stats.setAverageAge(tuple.getT7() != null ? tuple.getT7() : 0.0);
|
||||
return stats;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<MemberStatistics> getMemberStatisticsByDateRange(LocalDate startDate, LocalDate endDate) {
|
||||
return statisticsDao.findMemberStatisticsByDateRange(startDate, endDate)
|
||||
.map(row -> {
|
||||
LocalDate date = (LocalDate) row[0];
|
||||
MemberStatistics stats = new MemberStatistics(date);
|
||||
stats.setNewMembers(((Number) row[1]).intValue());
|
||||
return stats;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<MemberStatistics> getMemberStatisticsSummary(LocalDate startDate, LocalDate endDate) {
|
||||
return getMemberStatisticsByDateRange(startDate, endDate)
|
||||
.collectList()
|
||||
.flatMap(list -> {
|
||||
MemberStatistics summary = new MemberStatistics();
|
||||
summary.setStartDate(startDate);
|
||||
summary.setEndDate(endDate);
|
||||
summary.setNewMembers(list.stream().mapToInt(MemberStatistics::getNewMembers).sum());
|
||||
|
||||
return Mono.zip(
|
||||
statisticsDao.countTotalMembers(),
|
||||
statisticsDao.countMaleMembers(),
|
||||
statisticsDao.countFemaleMembers(),
|
||||
statisticsDao.calculateAverageAge()
|
||||
).map(tuple -> {
|
||||
summary.setTotalMembers(tuple.getT1());
|
||||
summary.setMaleMembers(tuple.getT2());
|
||||
summary.setFemaleMembers(tuple.getT3());
|
||||
summary.setAverageAge(tuple.getT4() != null ? tuple.getT4() : 0.0);
|
||||
return summary;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<BookingStatistics> getBookingStatisticsByDate(LocalDate date) {
|
||||
String cacheKey = BOOKING_STATS_CACHE_PREFIX + date.toString();
|
||||
return redisUtil.get(cacheKey)
|
||||
.flatMap(cached -> {
|
||||
if (cached != null) {
|
||||
try {
|
||||
BookingStatistics stats = objectMapper.readValue(cached.toString(), BookingStatistics.class);
|
||||
log.debug("从缓存获取预约统计数据, date: {}", date);
|
||||
return Mono.just(stats);
|
||||
} catch (Exception e) {
|
||||
log.warn("缓存数据解析失败, date: {}", date, e);
|
||||
}
|
||||
}
|
||||
return Mono.empty();
|
||||
})
|
||||
.switchIfEmpty(fetchBookingStatisticsByDate(date)
|
||||
.flatMap(stats -> {
|
||||
try {
|
||||
String json = objectMapper.writeValueAsString(stats);
|
||||
return redisUtil.setWithExpire(cacheKey, json, CACHE_EXPIRE_SECONDS)
|
||||
.then(Mono.just(stats));
|
||||
} catch (Exception e) {
|
||||
log.warn("缓存预约统计数据失败, date: {}", date, e);
|
||||
return Mono.just(stats);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private Mono<BookingStatistics> fetchBookingStatisticsByDate(LocalDate date) {
|
||||
return Mono.zip(
|
||||
statisticsDao.countTotalBookingsByDate(date),
|
||||
statisticsDao.countCancelledBookingsByDate(date),
|
||||
statisticsDao.countAttendedBookingsByDate(date),
|
||||
statisticsDao.countTotalCoursesByDate(date),
|
||||
statisticsDao.countFullCoursesByDate(date),
|
||||
statisticsDao.findMostPopularCourseByDate(date)
|
||||
).map(tuple -> {
|
||||
int totalBookings = tuple.getT1();
|
||||
int cancelledBookings = tuple.getT2();
|
||||
int attendedBookings = tuple.getT3();
|
||||
|
||||
BookingStatistics stats = new BookingStatistics(date);
|
||||
stats.setTotalBookings(totalBookings);
|
||||
stats.setCancelledBookings(cancelledBookings);
|
||||
stats.setAttendedBookings(attendedBookings);
|
||||
stats.setTotalCourses(tuple.getT4());
|
||||
stats.setFullCourses(tuple.getT5());
|
||||
|
||||
if (totalBookings > 0) {
|
||||
stats.setCancellationRate(Math.round(cancelledBookings * 10000.0 / totalBookings) / 100.0);
|
||||
stats.setAttendanceRate(Math.round(attendedBookings * 10000.0 / totalBookings) / 100.0);
|
||||
}
|
||||
|
||||
Object[] popularCourse = tuple.getT6();
|
||||
if (popularCourse != null && popularCourse.length >= 2) {
|
||||
stats.setPopularCourseName((String) popularCourse[0]);
|
||||
stats.setPopularCourseCount(((Number) popularCourse[1]).intValue());
|
||||
}
|
||||
|
||||
return stats;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<BookingStatistics> getBookingStatisticsByDateRange(LocalDate startDate, LocalDate endDate) {
|
||||
return statisticsDao.findBookingStatisticsByDateRange(startDate, endDate)
|
||||
.map(row -> {
|
||||
LocalDate date = (LocalDate) row[0];
|
||||
int totalBookings = ((Number) row[1]).intValue();
|
||||
int cancelledBookings = ((Number) row[2]).intValue();
|
||||
int attendedBookings = ((Number) row[3]).intValue();
|
||||
|
||||
BookingStatistics stats = new BookingStatistics(date);
|
||||
stats.setTotalBookings(totalBookings);
|
||||
stats.setCancelledBookings(cancelledBookings);
|
||||
stats.setAttendedBookings(attendedBookings);
|
||||
|
||||
if (totalBookings > 0) {
|
||||
stats.setCancellationRate(Math.round(cancelledBookings * 10000.0 / totalBookings) / 100.0);
|
||||
stats.setAttendanceRate(Math.round(attendedBookings * 10000.0 / totalBookings) / 100.0);
|
||||
}
|
||||
|
||||
return stats;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<BookingStatistics> getBookingStatisticsSummary(LocalDate startDate, LocalDate endDate) {
|
||||
return getBookingStatisticsByDateRange(startDate, endDate)
|
||||
.collectList()
|
||||
.map(list -> {
|
||||
BookingStatistics summary = new BookingStatistics();
|
||||
summary.setStartDate(startDate);
|
||||
summary.setEndDate(endDate);
|
||||
summary.setTotalBookings(list.stream().mapToInt(BookingStatistics::getTotalBookings).sum());
|
||||
summary.setCancelledBookings(list.stream().mapToInt(BookingStatistics::getCancelledBookings).sum());
|
||||
summary.setAttendedBookings(list.stream().mapToInt(BookingStatistics::getAttendedBookings).sum());
|
||||
|
||||
int total = summary.getTotalBookings();
|
||||
if (total > 0) {
|
||||
summary.setCancellationRate(Math.round(summary.getCancelledBookings() * 10000.0 / total) / 100.0);
|
||||
summary.setAttendanceRate(Math.round(summary.getAttendedBookings() * 10000.0 / total) / 100.0);
|
||||
}
|
||||
|
||||
return summary;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SignInStatistics> getSignInStatisticsByDate(LocalDate date) {
|
||||
String cacheKey = SIGN_IN_STATS_CACHE_PREFIX + date.toString();
|
||||
return redisUtil.get(cacheKey)
|
||||
.flatMap(cached -> {
|
||||
if (cached != null) {
|
||||
try {
|
||||
SignInStatistics stats = objectMapper.readValue(cached.toString(), SignInStatistics.class);
|
||||
log.debug("从缓存获取签到统计数据, date: {}", date);
|
||||
return Mono.just(stats);
|
||||
} catch (Exception e) {
|
||||
log.warn("缓存数据解析失败, date: {}", date, e);
|
||||
}
|
||||
}
|
||||
return Mono.empty();
|
||||
})
|
||||
.switchIfEmpty(fetchSignInStatisticsByDate(date)
|
||||
.flatMap(stats -> {
|
||||
try {
|
||||
String json = objectMapper.writeValueAsString(stats);
|
||||
return redisUtil.setWithExpire(cacheKey, json, CACHE_EXPIRE_SECONDS)
|
||||
.then(Mono.just(stats));
|
||||
} catch (Exception e) {
|
||||
log.warn("缓存签到统计数据失败, date: {}", date, e);
|
||||
return Mono.just(stats);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private Mono<SignInStatistics> fetchSignInStatisticsByDate(LocalDate date) {
|
||||
return Mono.zip(
|
||||
statisticsDao.countTotalSignInsByDate(date),
|
||||
statisticsDao.countSuccessSignInsByDate(date),
|
||||
statisticsDao.countFailedSignInsByDate(date),
|
||||
statisticsDao.countUniqueMembersByDate(date),
|
||||
statisticsDao.countQrCodeSignInsByDate(date),
|
||||
statisticsDao.countManualSignInsByDate(date),
|
||||
statisticsDao.countFaceSignInsByDate(date)
|
||||
).map(tuple -> {
|
||||
int totalSignIns = tuple.getT1();
|
||||
int successSignIns = tuple.getT2();
|
||||
int failedSignIns = tuple.getT3();
|
||||
|
||||
SignInStatistics stats = new SignInStatistics(date);
|
||||
stats.setTotalSignIns(totalSignIns);
|
||||
stats.setSuccessSignIns(successSignIns);
|
||||
stats.setFailedSignIns(failedSignIns);
|
||||
stats.setUniqueMembers(tuple.getT4());
|
||||
stats.setQrCodeSignIns(tuple.getT5());
|
||||
stats.setManualSignIns(tuple.getT6());
|
||||
stats.setFaceSignIns(tuple.getT7());
|
||||
|
||||
if (totalSignIns > 0) {
|
||||
stats.setSuccessRate(Math.round(successSignIns * 10000.0 / totalSignIns) / 100.0);
|
||||
}
|
||||
|
||||
return stats;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<SignInStatistics> getSignInStatisticsByDateRange(LocalDate startDate, LocalDate endDate) {
|
||||
return statisticsDao.findSignInStatisticsByDateRange(startDate, endDate)
|
||||
.map(row -> {
|
||||
LocalDate date = (LocalDate) row[0];
|
||||
int totalSignIns = ((Number) row[1]).intValue();
|
||||
int successSignIns = ((Number) row[2]).intValue();
|
||||
int failedSignIns = ((Number) row[3]).intValue();
|
||||
int uniqueMembers = ((Number) row[4]).intValue();
|
||||
int qrCodeSignIns = ((Number) row[5]).intValue();
|
||||
int manualSignIns = ((Number) row[6]).intValue();
|
||||
int faceSignIns = ((Number) row[7]).intValue();
|
||||
|
||||
SignInStatistics stats = new SignInStatistics(date);
|
||||
stats.setTotalSignIns(totalSignIns);
|
||||
stats.setSuccessSignIns(successSignIns);
|
||||
stats.setFailedSignIns(failedSignIns);
|
||||
stats.setUniqueMembers(uniqueMembers);
|
||||
stats.setQrCodeSignIns(qrCodeSignIns);
|
||||
stats.setManualSignIns(manualSignIns);
|
||||
stats.setFaceSignIns(faceSignIns);
|
||||
|
||||
if (totalSignIns > 0) {
|
||||
stats.setSuccessRate(Math.round(successSignIns * 10000.0 / totalSignIns) / 100.0);
|
||||
}
|
||||
|
||||
return stats;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<SignInStatistics> getSignInStatisticsSummary(LocalDate startDate, LocalDate endDate) {
|
||||
return getSignInStatisticsByDateRange(startDate, endDate)
|
||||
.collectList()
|
||||
.map(list -> {
|
||||
SignInStatistics summary = new SignInStatistics();
|
||||
summary.setStartDate(startDate);
|
||||
summary.setEndDate(endDate);
|
||||
summary.setTotalSignIns(list.stream().mapToInt(SignInStatistics::getTotalSignIns).sum());
|
||||
summary.setSuccessSignIns(list.stream().mapToInt(SignInStatistics::getSuccessSignIns).sum());
|
||||
summary.setFailedSignIns(list.stream().mapToInt(SignInStatistics::getFailedSignIns).sum());
|
||||
summary.setUniqueMembers(list.stream().mapToInt(SignInStatistics::getUniqueMembers).sum());
|
||||
summary.setQrCodeSignIns(list.stream().mapToInt(SignInStatistics::getQrCodeSignIns).sum());
|
||||
summary.setManualSignIns(list.stream().mapToInt(SignInStatistics::getManualSignIns).sum());
|
||||
summary.setFaceSignIns(list.stream().mapToInt(SignInStatistics::getFaceSignIns).sum());
|
||||
|
||||
int total = summary.getTotalSignIns();
|
||||
if (total > 0) {
|
||||
summary.setSuccessRate(Math.round(summary.getSuccessSignIns() * 10000.0 / total) / 100.0);
|
||||
}
|
||||
|
||||
return summary;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<byte[]> exportSignInStatistics(LocalDate startDate, LocalDate endDate) {
|
||||
return getSignInStatisticsByDateRange(startDate, endDate)
|
||||
.collectList()
|
||||
.map(this::generateSignInStatisticsCSV);
|
||||
}
|
||||
|
||||
private byte[] generateSignInStatisticsCSV(List<SignInStatistics> statsList) {
|
||||
return generateSignInStatisticsCSVContent(statsList).getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private String generateSignInStatisticsCSVContent(List<SignInStatistics> statsList) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("统计日期,签到总次数,成功签到次数,失败签到次数,签到成功率(%),签到会员数,扫码签到次数,手动签到次数,人脸识别签到次数\n");
|
||||
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
for (SignInStatistics stats : statsList) {
|
||||
sb.append(stats.getDate().format(formatter)).append(",");
|
||||
sb.append(stats.getTotalSignIns()).append(",");
|
||||
sb.append(stats.getSuccessSignIns()).append(",");
|
||||
sb.append(stats.getFailedSignIns()).append(",");
|
||||
sb.append(String.format("%.2f", stats.getSuccessRate())).append(",");
|
||||
sb.append(stats.getUniqueMembers()).append(",");
|
||||
sb.append(stats.getQrCodeSignIns()).append(",");
|
||||
sb.append(stats.getManualSignIns()).append(",");
|
||||
sb.append(stats.getFaceSignIns()).append("\n");
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<byte[]> exportMemberStatistics(LocalDate startDate, LocalDate endDate) {
|
||||
return getMemberStatisticsByDateRange(startDate, endDate)
|
||||
.collectList()
|
||||
.map(this::generateMemberStatisticsCSV);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<byte[]> exportBookingStatistics(LocalDate startDate, LocalDate endDate) {
|
||||
return getBookingStatisticsByDateRange(startDate, endDate)
|
||||
.collectList()
|
||||
.map(this::generateBookingStatisticsCSV);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<byte[]> exportAllStatistics(LocalDate startDate, LocalDate endDate) {
|
||||
return Mono.zip(
|
||||
getMemberStatisticsByDateRange(startDate, endDate).collectList(),
|
||||
getBookingStatisticsByDateRange(startDate, endDate).collectList(),
|
||||
getSignInStatisticsByDateRange(startDate, endDate).collectList()
|
||||
).map(tuple -> {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
PrintWriter writer = new PrintWriter(new OutputStreamWriter(baos, StandardCharsets.UTF_8));
|
||||
|
||||
writer.println("=== 会员数据统计 ===");
|
||||
writer.println(generateMemberStatisticsCSVContent(tuple.getT1()));
|
||||
writer.println();
|
||||
writer.println("=== 预约数据统计 ===");
|
||||
writer.println(generateBookingStatisticsCSVContent(tuple.getT2()));
|
||||
writer.println();
|
||||
writer.println("=== 签到数据统计 ===");
|
||||
writer.println(generateSignInStatisticsCSVContent(tuple.getT3()));
|
||||
|
||||
writer.flush();
|
||||
return baos.toByteArray();
|
||||
});
|
||||
}
|
||||
|
||||
private byte[] generateMemberStatisticsCSV(List<MemberStatistics> statsList) {
|
||||
return generateMemberStatisticsCSVContent(statsList).getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private String generateMemberStatisticsCSVContent(List<MemberStatistics> statsList) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("统计日期,新增会员数,活跃会员数,总会员数,男性会员数,女性会员数,购卡人数,平均年龄\n");
|
||||
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
for (MemberStatistics stats : statsList) {
|
||||
sb.append(stats.getDate().format(formatter)).append(",");
|
||||
sb.append(stats.getNewMembers()).append(",");
|
||||
sb.append(stats.getActiveMembers()).append(",");
|
||||
sb.append(stats.getTotalMembers()).append(",");
|
||||
sb.append(stats.getMaleMembers()).append(",");
|
||||
sb.append(stats.getFemaleMembers()).append(",");
|
||||
sb.append(stats.getCardPurchaseCount()).append(",");
|
||||
sb.append(String.format("%.1f", stats.getAverageAge())).append("\n");
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private byte[] generateBookingStatisticsCSV(List<BookingStatistics> statsList) {
|
||||
return generateBookingStatisticsCSVContent(statsList).getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private String generateBookingStatisticsCSVContent(List<BookingStatistics> statsList) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("统计日期,预约总数,取消预约数,实际到场数,取消率(%),到场率(%),热门课程,热门课程预约数,团课总数,满员课程数\n");
|
||||
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
for (BookingStatistics stats : statsList) {
|
||||
sb.append(stats.getDate().format(formatter)).append(",");
|
||||
sb.append(stats.getTotalBookings()).append(",");
|
||||
sb.append(stats.getCancelledBookings()).append(",");
|
||||
sb.append(stats.getAttendedBookings()).append(",");
|
||||
sb.append(String.format("%.2f", stats.getCancellationRate())).append(",");
|
||||
sb.append(String.format("%.2f", stats.getAttendanceRate())).append(",");
|
||||
sb.append(stats.getPopularCourseName() != null ? stats.getPopularCourseName() : "").append(",");
|
||||
sb.append(stats.getPopularCourseCount()).append(",");
|
||||
sb.append(stats.getTotalCourses()).append(",");
|
||||
sb.append(stats.getFullCourses()).append("\n");
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RedisUtil getRedisUtil() {
|
||||
return redisUtil;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
spring:
|
||||
application:
|
||||
name: gym-dataCount
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package cn.novalon.gym.manage.datacount;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class GymDataCountApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
wrapperVersion=3.3.4
|
||||
distributionType=only-script
|
||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.15/apache-maven-3.9.15-bin.zip
|
||||
@@ -0,0 +1,95 @@
|
||||
<?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 https://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>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-groupCourse</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>gym-groupCourse</name>
|
||||
<description>Group Course Management Module</description>
|
||||
<url/>
|
||||
<licenses>
|
||||
<license/>
|
||||
</licenses>
|
||||
<developers>
|
||||
<developer/>
|
||||
</developers>
|
||||
<scm>
|
||||
<connection/>
|
||||
<developerConnection/>
|
||||
<tag/>
|
||||
<url/>
|
||||
</scm>
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
</properties>
|
||||
<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>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.swagger.core.v3</groupId>
|
||||
<artifactId>swagger-annotations-jakarta</artifactId>
|
||||
<version>2.2.43</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
|
||||
|
||||
<!-- Redis依赖-->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 会员模块依赖 -->
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-member</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
|
||||
package cn.novalon.gym.manage.groupcourse.converter;
|
||||
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
|
||||
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseBookingEntity;
|
||||
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 团课相关转换器
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-06-01
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class GroupCourseConverter {
|
||||
|
||||
/**
|
||||
* 将团课实体转换为领域模型
|
||||
*/
|
||||
public GroupCourse toDomain(GroupCourseEntity entity){
|
||||
if(entity == null){
|
||||
return null;
|
||||
}
|
||||
GroupCourse groupCourse = new GroupCourse();
|
||||
BeanUtil.copyProperties(entity,groupCourse);
|
||||
log.info("转换bean,entity-domain:",groupCourse);
|
||||
return groupCourse;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将团课领域模型转换为实体
|
||||
*/
|
||||
public GroupCourseEntity toEntity(GroupCourse domain){
|
||||
if(domain == null){
|
||||
return null;
|
||||
}
|
||||
GroupCourseEntity entity = new GroupCourseEntity();
|
||||
BeanUtil.copyProperties(domain,entity);
|
||||
log.info("转换bean,domain-entity:",entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将团课实体列表转换为领域模型列表
|
||||
*/
|
||||
public List<GroupCourse> toDomainList(List<GroupCourseEntity> entities){
|
||||
if (entities == null) {
|
||||
return null;
|
||||
}
|
||||
return entities.stream()
|
||||
.map(this::toDomain)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将团课领域模型列表转换为实体列表
|
||||
*/
|
||||
public List<GroupCourseEntity> toEntityList(List<GroupCourse> domains){
|
||||
if (domains == null) {
|
||||
return null;
|
||||
}
|
||||
return domains.stream()
|
||||
.map(this::toEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将团课预约实体转换为领域模型
|
||||
*/
|
||||
public GroupCourseBooking toBookingDomain(GroupCourseBookingEntity entity){
|
||||
if(entity == null){
|
||||
return null;
|
||||
}
|
||||
GroupCourseBooking booking = new GroupCourseBooking();
|
||||
BeanUtil.copyProperties(entity, booking);
|
||||
log.debug("转换预约记录实体到领域模型:bookingId={}", entity.getId());
|
||||
return booking;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将团课预约领域模型转换为实体
|
||||
*/
|
||||
public GroupCourseBookingEntity toBookingEntity(GroupCourseBooking domain){
|
||||
if(domain == null){
|
||||
return null;
|
||||
}
|
||||
GroupCourseBookingEntity entity = new GroupCourseBookingEntity();
|
||||
BeanUtil.copyProperties(domain, entity);
|
||||
if (domain.getId() != null) {
|
||||
entity.markNotNew();
|
||||
}
|
||||
log.debug("转换预约记录领域模型到实体:bookingId={}", domain.getId());
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将团课预约实体列表转换为领域模型列表
|
||||
*/
|
||||
public List<GroupCourseBooking> toBookingDomainList(List<GroupCourseBookingEntity> entities){
|
||||
if (entities == null) {
|
||||
return null;
|
||||
}
|
||||
return entities.stream()
|
||||
.map(this::toBookingDomain)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将团课预约领域模型列表转换为实体列表
|
||||
*/
|
||||
public List<GroupCourseBookingEntity> toBookingEntityList(List<GroupCourseBooking> domains){
|
||||
if (domains == null) {
|
||||
return null;
|
||||
}
|
||||
return domains.stream()
|
||||
.map(this::toBookingEntity)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package cn.novalon.gym.manage.groupcourse.dao;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseBookingEntity;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 团课预约记录DAO接口
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-06-01
|
||||
*/
|
||||
@Repository
|
||||
public interface GroupCourseBookingDao extends R2dbcRepository<GroupCourseBookingEntity, Long> {
|
||||
|
||||
/**
|
||||
* 根据ID查询未删除的预约记录
|
||||
*/
|
||||
Mono<GroupCourseBookingEntity> findByIdIsAndDeletedAtIsNull(Long id);
|
||||
|
||||
/**
|
||||
* 根据会员ID查询所有预约记录
|
||||
*/
|
||||
Flux<GroupCourseBookingEntity> findByMemberIdAndDeletedAtIsNull(Long memberId);
|
||||
|
||||
/**
|
||||
* 根据会员ID查询所有预约记录(带排序)
|
||||
*/
|
||||
Flux<GroupCourseBookingEntity> findByMemberIdAndDeletedAtIsNull(Long memberId, Sort sort);
|
||||
|
||||
/**
|
||||
* 根据团课ID查询所有预约记录
|
||||
*/
|
||||
Flux<GroupCourseBookingEntity> findByCourseIdAndDeletedAtIsNull(Long courseId);
|
||||
|
||||
/**
|
||||
* 根据团课ID和会员ID查询预约记录
|
||||
*/
|
||||
Mono<GroupCourseBookingEntity> findByCourseIdAndMemberIdAndDeletedAtIsNull(Long courseId, Long memberId);
|
||||
|
||||
/**
|
||||
* 根据团课ID和状态查询预约记录
|
||||
*/
|
||||
Flux<GroupCourseBookingEntity> findByCourseIdAndStatusAndDeletedAtIsNull(Long courseId, String status);
|
||||
|
||||
/**
|
||||
* 查询会员在指定课程的有效预约(状态为已预约且未取消)
|
||||
*/
|
||||
Mono<GroupCourseBookingEntity> findByCourseIdAndMemberIdAndStatusAndDeletedAtIsNull(Long courseId, Long memberId, String status);
|
||||
|
||||
/**
|
||||
* 根据会员卡记录ID查询预约记录
|
||||
*/
|
||||
Flux<GroupCourseBookingEntity> findByMemberCardRecordIdAndDeletedAtIsNull(Long memberCardRecordId);
|
||||
|
||||
/**
|
||||
* 统计团课已预约人数
|
||||
*/
|
||||
Mono<Long> countByCourseIdAndStatusAndDeletedAtIsNull(Long courseId, String status);
|
||||
|
||||
/**
|
||||
* 查询会员是否有时间冲突的预约(状态为已预约且未取消)
|
||||
* 时间冲突条件:新课程的开始时间 < 已预约课程的结束时间 且 新课程的结束时间 > 已预约课程的开始时间
|
||||
*/
|
||||
@org.springframework.data.r2dbc.repository.Query("SELECT * FROM group_course_booking b " +
|
||||
"JOIN group_course c ON b.course_id = c.id " +
|
||||
"WHERE b.member_id = :memberId " +
|
||||
"AND b.status = '0' " +
|
||||
"AND b.deleted_at IS NULL " +
|
||||
"AND c.deleted_at IS NULL " +
|
||||
"AND :newStartTime < c.end_time " +
|
||||
"AND :newEndTime > c.start_time")
|
||||
Flux<GroupCourseBookingEntity> findConflictingBookings(Long memberId, java.time.LocalDateTime newStartTime, java.time.LocalDateTime newEndTime);
|
||||
|
||||
/**
|
||||
* 更新预约状态
|
||||
*/
|
||||
@org.springframework.data.r2dbc.repository.Modifying
|
||||
@org.springframework.data.r2dbc.repository.Query("UPDATE group_course_booking SET status = :status, cancel_time = :cancelTime, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
|
||||
Mono<Integer> updateStatus(Long id, String status, java.time.LocalDateTime cancelTime, java.time.LocalDateTime updatedAt);
|
||||
|
||||
/**
|
||||
* 软删除预约记录
|
||||
*/
|
||||
@org.springframework.data.r2dbc.repository.Modifying
|
||||
@org.springframework.data.r2dbc.repository.Query("UPDATE group_course_booking SET deleted_at = :deletedAt WHERE id = :id")
|
||||
Mono<Integer> softDelete(Long id, java.time.LocalDateTime deletedAt);
|
||||
|
||||
/**
|
||||
* 查询已开始课程但未到场会员的预约记录
|
||||
* 条件:课程开始时间早于当前时间,预约状态为已预约(0)
|
||||
*/
|
||||
@org.springframework.data.r2dbc.repository.Query("SELECT b.* FROM group_course_booking b " +
|
||||
"JOIN group_course c ON b.course_id = c.id " +
|
||||
"WHERE b.status = '0' " +
|
||||
"AND b.deleted_at IS NULL " +
|
||||
"AND c.deleted_at IS NULL " +
|
||||
"AND c.status = '0' " +
|
||||
"AND c.start_time < CURRENT_TIMESTAMP")
|
||||
Flux<GroupCourseBookingEntity> findAbsentMembers();
|
||||
|
||||
/**
|
||||
* 批量更新预约状态为缺席
|
||||
*/
|
||||
@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")
|
||||
Mono<Integer> updateToAbsent(Long id, java.time.LocalDateTime updatedAt);
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
|
||||
package cn.novalon.gym.manage.groupcourse.dao;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
|
||||
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 GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long> {
|
||||
|
||||
Mono<GroupCourseEntity> findByIdIsAndDeletedAtIsNull(Long id);
|
||||
|
||||
Flux<GroupCourseEntity> findAll();
|
||||
|
||||
Flux<GroupCourseEntity> findAll(Sort sort);
|
||||
|
||||
Flux<GroupCourseEntity> findAllByDeletedAtIsNull();
|
||||
|
||||
Flux<GroupCourseEntity> findAllByDeletedAtIsNull(Sort sort);
|
||||
|
||||
@Modifying
|
||||
@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);
|
||||
|
||||
@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
|
||||
@Query("UPDATE group_course SET deleted_at = :deletedAt WHERE id = :id")
|
||||
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
|
||||
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.time.LocalDateTime;
|
||||
|
||||
public class GroupCourse extends BaseDomain{
|
||||
|
||||
//课程名称
|
||||
@Schema(description = "团课名", example = "Push-up")
|
||||
private String courseName;
|
||||
|
||||
//教练id
|
||||
@Schema(description = "教练id", example = "1")
|
||||
private Long coachId;
|
||||
|
||||
//课程类型
|
||||
@Schema(description = "课程类型", example = "1")
|
||||
private Long courseType;
|
||||
|
||||
//开始时间
|
||||
@Schema(description = "开始时间", example = "2026-01-01")
|
||||
private LocalDateTime startTime;
|
||||
|
||||
//结束时间
|
||||
@Schema(description = "结束时间", example = "2026-01-02")
|
||||
private LocalDateTime endTime;
|
||||
|
||||
//最大参与人数
|
||||
@Schema(description = "最大参与人数", example = "20")
|
||||
private Integer maxMembers;
|
||||
|
||||
//当前参与人数
|
||||
@Schema(description = "当前参与人数", example = "2")
|
||||
private Integer currentMembers;
|
||||
|
||||
//课程状态:0-正常,1-已取消,2-已结束
|
||||
@Schema(description = "课程状态", example = "0")
|
||||
private Long status;
|
||||
|
||||
//上课地点
|
||||
@Schema(description = "上课地点", example = "龙泉驿区幸福路")
|
||||
private String location;
|
||||
|
||||
//封面图URL
|
||||
@Schema(description = "封面图URL", example = "https://12345.com")
|
||||
private String coverImage;
|
||||
|
||||
//课程描述
|
||||
@Schema(description = "课程描述", example = "从入门到入土")
|
||||
private String description;
|
||||
|
||||
//点卡额度(消耗次数)
|
||||
@Schema(description = "点卡额度(消耗次数)", example = "1")
|
||||
private Integer pointCardAmount;
|
||||
|
||||
//储值卡额度(消耗金额)
|
||||
@Schema(description = "储值卡额度(消耗金额)", example = "50.00")
|
||||
private java.math.BigDecimal storedValueAmount;
|
||||
|
||||
public String getCourseName() {
|
||||
return courseName;
|
||||
}
|
||||
|
||||
public void setCourseName(String courseName) {
|
||||
this.courseName = courseName;
|
||||
}
|
||||
|
||||
public Long getCoachId() {
|
||||
return coachId;
|
||||
}
|
||||
|
||||
public void setCoachId(Long coachId) {
|
||||
this.coachId = coachId;
|
||||
}
|
||||
|
||||
public Long getCourseType() {
|
||||
return courseType;
|
||||
}
|
||||
|
||||
public void setCourseType(Long courseType) {
|
||||
this.courseType = courseType;
|
||||
}
|
||||
|
||||
public LocalDateTime getStartTime() {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
public void setStartTime(LocalDateTime startTime) {
|
||||
this.startTime = startTime;
|
||||
}
|
||||
|
||||
public LocalDateTime getEndTime() {
|
||||
return endTime;
|
||||
}
|
||||
|
||||
public void setEndTime(LocalDateTime endTime) {
|
||||
this.endTime = endTime;
|
||||
}
|
||||
|
||||
public Integer getMaxMembers() {
|
||||
return maxMembers;
|
||||
}
|
||||
|
||||
public void setMaxMembers(Integer maxMembers) {
|
||||
this.maxMembers = maxMembers;
|
||||
}
|
||||
|
||||
public Integer getCurrentMembers() {
|
||||
return currentMembers;
|
||||
}
|
||||
|
||||
public void setCurrentMembers(Integer currentMembers) {
|
||||
this.currentMembers = currentMembers;
|
||||
}
|
||||
|
||||
public Long getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(Long status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getLocation() {
|
||||
return location;
|
||||
}
|
||||
|
||||
public void setLocation(String location) {
|
||||
this.location = location;
|
||||
}
|
||||
|
||||
public String getCoverImage() {
|
||||
return coverImage;
|
||||
}
|
||||
|
||||
public void setCoverImage(String coverImage) {
|
||||
this.coverImage = coverImage;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public Integer getPointCardAmount() {
|
||||
return pointCardAmount;
|
||||
}
|
||||
|
||||
public void setPointCardAmount(Integer pointCardAmount) {
|
||||
this.pointCardAmount = pointCardAmount;
|
||||
}
|
||||
|
||||
public java.math.BigDecimal getStoredValueAmount() {
|
||||
return storedValueAmount;
|
||||
}
|
||||
|
||||
public void setStoredValueAmount(java.math.BigDecimal storedValueAmount) {
|
||||
this.storedValueAmount = storedValueAmount;
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
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.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 团课预约记录领域模型
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-06-01
|
||||
*/
|
||||
public class GroupCourseBooking extends BaseDomain {
|
||||
|
||||
//团课ID
|
||||
@Schema(description = "团课ID", example = "1")
|
||||
private Long courseId;
|
||||
|
||||
//团课名称(关联查询)
|
||||
@Schema(description = "团课名称", example = "瑜伽入门")
|
||||
private String courseName;
|
||||
|
||||
//会员ID
|
||||
@Schema(description = "会员ID", example = "1")
|
||||
private Long memberId;
|
||||
|
||||
//会员卡记录ID
|
||||
@Schema(description = "会员卡记录ID", example = "1")
|
||||
private Long memberCardRecordId;
|
||||
|
||||
//预约时间
|
||||
@Schema(description = "预约时间", example = "2026-06-01 10:00:00")
|
||||
private LocalDateTime bookingTime;
|
||||
|
||||
//状态:0-已预约,1-已取消,2-已出席,3-缺席
|
||||
@Schema(description = "状态:0-已预约,1-已取消,2-已出席,3-缺席", example = "0")
|
||||
private String status;
|
||||
|
||||
//取消时间
|
||||
@Schema(description = "取消时间", example = "2026-06-01 11:00:00")
|
||||
private LocalDateTime cancelTime;
|
||||
|
||||
//课程开始时间
|
||||
@Schema(description = "课程开始时间", example = "2026-06-02 09:00:00")
|
||||
private LocalDateTime courseStartTime;
|
||||
|
||||
//课程结束时间
|
||||
@Schema(description = "课程结束时间", example = "2026-06-02 10:00:00")
|
||||
private LocalDateTime courseEndTime;
|
||||
|
||||
//上课地点
|
||||
@Schema(description = "上课地点", example = "健身房A区")
|
||||
private String location;
|
||||
|
||||
public Long getCourseId() {
|
||||
return courseId;
|
||||
}
|
||||
|
||||
public void setCourseId(Long courseId) {
|
||||
this.courseId = courseId;
|
||||
}
|
||||
|
||||
public String getCourseName() {
|
||||
return courseName;
|
||||
}
|
||||
|
||||
public void setCourseName(String courseName) {
|
||||
this.courseName = courseName;
|
||||
}
|
||||
|
||||
public Long getMemberId() {
|
||||
return memberId;
|
||||
}
|
||||
|
||||
public void setMemberId(Long memberId) {
|
||||
this.memberId = memberId;
|
||||
}
|
||||
|
||||
public Long getMemberCardRecordId() {
|
||||
return memberCardRecordId;
|
||||
}
|
||||
|
||||
public void setMemberCardRecordId(Long memberCardRecordId) {
|
||||
this.memberCardRecordId = memberCardRecordId;
|
||||
}
|
||||
|
||||
public LocalDateTime getBookingTime() {
|
||||
return bookingTime;
|
||||
}
|
||||
|
||||
public void setBookingTime(LocalDateTime bookingTime) {
|
||||
this.bookingTime = bookingTime;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public LocalDateTime getCancelTime() {
|
||||
return cancelTime;
|
||||
}
|
||||
|
||||
public void setCancelTime(LocalDateTime cancelTime) {
|
||||
this.cancelTime = cancelTime;
|
||||
}
|
||||
|
||||
public LocalDateTime getCourseStartTime() {
|
||||
return courseStartTime;
|
||||
}
|
||||
|
||||
public void setCourseStartTime(LocalDateTime courseStartTime) {
|
||||
this.courseStartTime = courseStartTime;
|
||||
}
|
||||
|
||||
public LocalDateTime getCourseEndTime() {
|
||||
return courseEndTime;
|
||||
}
|
||||
|
||||
public void setCourseEndTime(LocalDateTime courseEndTime) {
|
||||
this.courseEndTime = courseEndTime;
|
||||
}
|
||||
|
||||
public String getLocation() {
|
||||
return location;
|
||||
}
|
||||
|
||||
public void setLocation(String location) {
|
||||
this.location = location;
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* 团课预约记录实体类 - 对应 group_course_booking 表
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-06-01
|
||||
*/
|
||||
@Table("group_course_booking")
|
||||
public class GroupCourseBookingEntity extends BaseEntity {
|
||||
|
||||
//团课ID
|
||||
@Column("course_id")
|
||||
private Long courseId;
|
||||
|
||||
//会员ID
|
||||
@Column("member_id")
|
||||
private Long memberId;
|
||||
|
||||
//会员卡记录ID
|
||||
@Column("member_card_id")
|
||||
private Long memberCardRecordId;
|
||||
|
||||
//预约时间
|
||||
@Column("booking_time")
|
||||
private LocalDateTime bookingTime;
|
||||
|
||||
//状态:0-已预约,1-已取消,2-已出席,3-缺席
|
||||
@Column("status")
|
||||
private String status;
|
||||
|
||||
//取消时间
|
||||
@Column("cancel_time")
|
||||
private LocalDateTime cancelTime;
|
||||
|
||||
//课程名称(冗余字段,保存预约时的课程快照)
|
||||
@Column("course_name")
|
||||
private String courseName;
|
||||
|
||||
//课程开始时间(冗余字段,保存预约时的课程快照)
|
||||
@Column("course_start_time")
|
||||
private LocalDateTime courseStartTime;
|
||||
|
||||
//课程结束时间(冗余字段,保存预约时的课程快照)
|
||||
@Column("course_end_time")
|
||||
private LocalDateTime courseEndTime;
|
||||
|
||||
//上课地点(冗余字段,保存预约时的课程快照)
|
||||
@Column("location")
|
||||
private String location;
|
||||
|
||||
public Long getCourseId() {
|
||||
return courseId;
|
||||
}
|
||||
|
||||
public void setCourseId(Long courseId) {
|
||||
this.courseId = courseId;
|
||||
}
|
||||
|
||||
public Long getMemberId() {
|
||||
return memberId;
|
||||
}
|
||||
|
||||
public void setMemberId(Long memberId) {
|
||||
this.memberId = memberId;
|
||||
}
|
||||
|
||||
public Long getMemberCardRecordId() {
|
||||
return memberCardRecordId;
|
||||
}
|
||||
|
||||
public void setMemberCardRecordId(Long memberCardRecordId) {
|
||||
this.memberCardRecordId = memberCardRecordId;
|
||||
}
|
||||
|
||||
public LocalDateTime getBookingTime() {
|
||||
return bookingTime;
|
||||
}
|
||||
|
||||
public void setBookingTime(LocalDateTime bookingTime) {
|
||||
this.bookingTime = bookingTime;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public LocalDateTime getCancelTime() {
|
||||
return cancelTime;
|
||||
}
|
||||
|
||||
public void setCancelTime(LocalDateTime cancelTime) {
|
||||
this.cancelTime = cancelTime;
|
||||
}
|
||||
|
||||
public String getCourseName() {
|
||||
return courseName;
|
||||
}
|
||||
|
||||
public void setCourseName(String courseName) {
|
||||
this.courseName = courseName;
|
||||
}
|
||||
|
||||
public LocalDateTime getCourseStartTime() {
|
||||
return courseStartTime;
|
||||
}
|
||||
|
||||
public void setCourseStartTime(LocalDateTime courseStartTime) {
|
||||
this.courseStartTime = courseStartTime;
|
||||
}
|
||||
|
||||
public LocalDateTime getCourseEndTime() {
|
||||
return courseEndTime;
|
||||
}
|
||||
|
||||
public void setCourseEndTime(LocalDateTime courseEndTime) {
|
||||
this.courseEndTime = courseEndTime;
|
||||
}
|
||||
|
||||
public String getLocation() {
|
||||
return location;
|
||||
}
|
||||
|
||||
public void setLocation(String location) {
|
||||
this.location = location;
|
||||
}
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
|
||||
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")
|
||||
public class GroupCourseEntity extends BaseEntity {
|
||||
|
||||
//课程名称
|
||||
@Column("course_name")
|
||||
private String courseName;
|
||||
|
||||
//教练id
|
||||
@Column("coach_id")
|
||||
private Long coachId;
|
||||
|
||||
//课程类型
|
||||
@Column("course_type")
|
||||
private Long courseType;
|
||||
|
||||
//开始时间
|
||||
@Column("start_time")
|
||||
private LocalDateTime startTime;
|
||||
|
||||
//结束时间
|
||||
@Column("end_time")
|
||||
private LocalDateTime endTime;
|
||||
|
||||
//最大参与人数
|
||||
@Column("max_members")
|
||||
private Integer maxMembers;
|
||||
|
||||
//当前参与人数
|
||||
@Column("current_members")
|
||||
private Integer currentMembers;
|
||||
|
||||
//课程状态:0-正常,1-已取消,2-已结束
|
||||
@Column("status")
|
||||
private Long status;
|
||||
|
||||
//上课地点
|
||||
@Column("location")
|
||||
private String location;
|
||||
|
||||
//封面图URL
|
||||
@Column("cover_image")
|
||||
private String coverImage;
|
||||
|
||||
//课程描述
|
||||
@Column("description")
|
||||
private String description;
|
||||
|
||||
//点卡额度(消耗次数)
|
||||
@Column("point_card_amount")
|
||||
private Integer pointCardAmount;
|
||||
|
||||
//储值卡额度(消耗金额)
|
||||
@Column("stored_value_amount")
|
||||
private java.math.BigDecimal storedValueAmount;
|
||||
|
||||
public String getCourseName() {
|
||||
return courseName;
|
||||
}
|
||||
|
||||
public void setCourseName(String courseName) {
|
||||
this.courseName = courseName;
|
||||
}
|
||||
|
||||
public Long getCoachId() {
|
||||
return coachId;
|
||||
}
|
||||
|
||||
public void setCoachId(Long coachId) {
|
||||
this.coachId = coachId;
|
||||
}
|
||||
|
||||
public Long getCourseType() {
|
||||
return courseType;
|
||||
}
|
||||
|
||||
public void setCourseType(Long courseType) {
|
||||
this.courseType = courseType;
|
||||
}
|
||||
|
||||
public LocalDateTime getStartTime() {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
public void setStartTime(LocalDateTime startTime) {
|
||||
this.startTime = startTime;
|
||||
}
|
||||
|
||||
public LocalDateTime getEndTime() {
|
||||
return endTime;
|
||||
}
|
||||
|
||||
public void setEndTime(LocalDateTime endTime) {
|
||||
this.endTime = endTime;
|
||||
}
|
||||
|
||||
public Integer getMaxMembers() {
|
||||
return maxMembers;
|
||||
}
|
||||
|
||||
public void setMaxMembers(Integer maxMembers) {
|
||||
this.maxMembers = maxMembers;
|
||||
}
|
||||
|
||||
public Integer getCurrentMembers() {
|
||||
return currentMembers;
|
||||
}
|
||||
|
||||
public void setCurrentMembers(Integer currentMembers) {
|
||||
this.currentMembers = currentMembers;
|
||||
}
|
||||
|
||||
public Long getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(Long status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getLocation() {
|
||||
return location;
|
||||
}
|
||||
|
||||
public void setLocation(String location) {
|
||||
this.location = location;
|
||||
}
|
||||
|
||||
public String getCoverImage() {
|
||||
return coverImage;
|
||||
}
|
||||
|
||||
public void setCoverImage(String coverImage) {
|
||||
this.coverImage = coverImage;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public Integer getPointCardAmount() {
|
||||
return pointCardAmount;
|
||||
}
|
||||
|
||||
public void setPointCardAmount(Integer pointCardAmount) {
|
||||
this.pointCardAmount = pointCardAmount;
|
||||
}
|
||||
|
||||
public java.math.BigDecimal getStoredValueAmount() {
|
||||
return storedValueAmount;
|
||||
}
|
||||
|
||||
public void setStoredValueAmount(java.math.BigDecimal storedValueAmount) {
|
||||
this.storedValueAmount = storedValueAmount;
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package cn.novalon.gym.manage.groupcourse.enums;
|
||||
|
||||
/**
|
||||
* 团课状态机事件枚举
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-06-02
|
||||
*/
|
||||
public enum CourseEvent {
|
||||
|
||||
CANCEL("取消课程"),
|
||||
END("结束课程"),
|
||||
START("开始课程");
|
||||
|
||||
private final String desc;
|
||||
|
||||
CourseEvent(String desc) {
|
||||
this.desc = desc;
|
||||
}
|
||||
|
||||
public String getDesc() {
|
||||
return desc;
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package cn.novalon.gym.manage.groupcourse.enums;
|
||||
|
||||
/**
|
||||
* 团课状态枚举
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-06-02
|
||||
*/
|
||||
public enum CourseStatus {
|
||||
|
||||
NORMAL(0L, "正常"),
|
||||
CANCELLED(1L, "已取消"),
|
||||
ENDED(2L, "已结束"),
|
||||
IN_PROGRESS(3L, "进行中");
|
||||
|
||||
private final Long value;
|
||||
private final String desc;
|
||||
|
||||
CourseStatus(Long value, String desc) {
|
||||
this.value = value;
|
||||
this.desc = desc;
|
||||
}
|
||||
|
||||
public Long getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public String getDesc() {
|
||||
return desc;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据值获取枚举
|
||||
*/
|
||||
public static CourseStatus fromValue(Long value) {
|
||||
for (CourseStatus status : values()) {
|
||||
if (status.value.equals(value)) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
return NORMAL;
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package cn.novalon.gym.manage.groupcourse.event;
|
||||
|
||||
import org.springframework.context.ApplicationEvent;
|
||||
|
||||
/**
|
||||
* 预约提醒事件
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-06-01
|
||||
*/
|
||||
public class BookingReminderEvent extends ApplicationEvent {
|
||||
|
||||
/**
|
||||
* 消息类型枚举
|
||||
*/
|
||||
public enum ReminderType {
|
||||
BOOKING_SUCCESS, // 预约成功
|
||||
COURSE_REMINDER, // 课程即将开始提醒
|
||||
BOOKING_CANCEL // 预约取消
|
||||
}
|
||||
|
||||
private final Long bookingId;
|
||||
private final Long memberId;
|
||||
private final String courseName;
|
||||
private final String courseTime;
|
||||
private final ReminderType type;
|
||||
|
||||
public BookingReminderEvent(Object source, Long bookingId, Long memberId,
|
||||
String courseName, String courseTime, ReminderType type) {
|
||||
super(source);
|
||||
this.bookingId = bookingId;
|
||||
this.memberId = memberId;
|
||||
this.courseName = courseName;
|
||||
this.courseTime = courseTime;
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public Long getBookingId() {
|
||||
return bookingId;
|
||||
}
|
||||
|
||||
public Long getMemberId() {
|
||||
return memberId;
|
||||
}
|
||||
|
||||
public String getCourseName() {
|
||||
return courseName;
|
||||
}
|
||||
|
||||
public String getCourseTime() {
|
||||
return courseTime;
|
||||
}
|
||||
|
||||
public ReminderType getType() {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package cn.novalon.gym.manage.groupcourse.event;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.event.BookingReminderEvent.ReminderType;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 预约提醒事件监听器
|
||||
*
|
||||
* 监听预约相关事件,处理提醒业务
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-06-01
|
||||
*/
|
||||
@Component
|
||||
public class BookingReminderEventListener {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(BookingReminderEventListener.class);
|
||||
|
||||
/**
|
||||
* 处理预约提醒事件
|
||||
*
|
||||
* @param event 预约提醒事件
|
||||
*/
|
||||
@EventListener
|
||||
public void handleBookingReminderEvent(BookingReminderEvent event) {
|
||||
logger.info("收到预约提醒事件:type={}, bookingId={}, memberId={}",
|
||||
event.getType(), event.getBookingId(), event.getMemberId());
|
||||
|
||||
try {
|
||||
processReminderEvent(event);
|
||||
} catch (Exception e) {
|
||||
logger.error("处理预约提醒事件失败:bookingId={}, error={}",
|
||||
event.getBookingId(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理提醒事件
|
||||
*
|
||||
* 根据事件类型执行不同的提醒逻辑
|
||||
*/
|
||||
private void processReminderEvent(BookingReminderEvent event) {
|
||||
switch (event.getType()) {
|
||||
case BOOKING_SUCCESS:
|
||||
handleBookingSuccess(event);
|
||||
break;
|
||||
case COURSE_REMINDER:
|
||||
handleCourseReminder(event);
|
||||
break;
|
||||
case BOOKING_CANCEL:
|
||||
handleBookingCancel(event);
|
||||
break;
|
||||
default:
|
||||
logger.warn("未知的提醒事件类型:{}", event.getType());
|
||||
}
|
||||
|
||||
logger.info("预约提醒事件处理完成:bookingId={}", event.getBookingId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理预约成功事件
|
||||
*/
|
||||
private void handleBookingSuccess(BookingReminderEvent event) {
|
||||
logger.info("处理预约成功提醒:会员ID={}, 课程={}, 时间={}",
|
||||
event.getMemberId(), event.getCourseName(), event.getCourseTime());
|
||||
|
||||
// 实际业务中会调用通知服务发送短信、APP推送等
|
||||
// sendNotification(event.getMemberId(), "预约成功",
|
||||
// "您已成功预约课程:" + event.getCourseName() + ",时间:" + event.getCourseTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理课程即将开始提醒事件
|
||||
*/
|
||||
private void handleCourseReminder(BookingReminderEvent event) {
|
||||
logger.info("处理课程提醒:会员ID={}, 课程={}, 时间={}",
|
||||
event.getMemberId(), event.getCourseName(), event.getCourseTime());
|
||||
|
||||
// 实际业务中会调用通知服务发送课程开始前提醒
|
||||
// sendNotification(event.getMemberId(), "课程提醒",
|
||||
// "您预约的课程" + event.getCourseName() + "即将开始,时间:" + event.getCourseTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理预约取消事件
|
||||
*/
|
||||
private void handleBookingCancel(BookingReminderEvent event) {
|
||||
logger.info("处理预约取消提醒:会员ID={}, 课程={}",
|
||||
event.getMemberId(), event.getCourseName());
|
||||
|
||||
// 实际业务中会调用通知服务发送预约取消通知
|
||||
// sendNotification(event.getMemberId(), "预约取消",
|
||||
// "您已取消课程:" + event.getCourseName() + "的预约");
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package cn.novalon.gym.manage.groupcourse.event;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.event.BookingReminderEvent.ReminderType;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 预约提醒事件发布器
|
||||
*
|
||||
* 使用Spring事件机制发布预约相关事件
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-06-01
|
||||
*/
|
||||
@Component
|
||||
public class BookingReminderEventPublisher {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(BookingReminderEventPublisher.class);
|
||||
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public BookingReminderEventPublisher(ApplicationEventPublisher eventPublisher) {
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布预约成功事件
|
||||
*
|
||||
* @param bookingId 预约ID
|
||||
* @param memberId 会员ID
|
||||
* @param courseName 课程名称
|
||||
* @param courseTime 课程时间
|
||||
*/
|
||||
public void publishBookingSuccessEvent(Long bookingId, Long memberId, String courseName, String courseTime) {
|
||||
BookingReminderEvent event = new BookingReminderEvent(
|
||||
this, bookingId, memberId, courseName, courseTime, ReminderType.BOOKING_SUCCESS);
|
||||
|
||||
logger.info("发布预约成功事件:bookingId={}, memberId={}", bookingId, memberId);
|
||||
eventPublisher.publishEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布课程即将开始提醒事件
|
||||
*
|
||||
* @param bookingId 预约ID
|
||||
* @param memberId 会员ID
|
||||
* @param courseName 课程名称
|
||||
* @param courseTime 课程时间
|
||||
*/
|
||||
public void publishCourseReminderEvent(Long bookingId, Long memberId, String courseName, String courseTime) {
|
||||
BookingReminderEvent event = new BookingReminderEvent(
|
||||
this, bookingId, memberId, courseName, courseTime, ReminderType.COURSE_REMINDER);
|
||||
|
||||
logger.info("发布课程提醒事件:bookingId={}, memberId={}", bookingId, memberId);
|
||||
eventPublisher.publishEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布预约取消事件
|
||||
*
|
||||
* @param bookingId 预约ID
|
||||
* @param memberId 会员ID
|
||||
* @param courseName 课程名称
|
||||
*/
|
||||
public void publishBookingCancelEvent(Long bookingId, Long memberId, String courseName) {
|
||||
BookingReminderEvent event = new BookingReminderEvent(
|
||||
this, bookingId, memberId, courseName, null, ReminderType.BOOKING_CANCEL);
|
||||
|
||||
logger.info("发布预约取消事件:bookingId={}, memberId={}", bookingId, memberId);
|
||||
eventPublisher.publishEvent(event);
|
||||
}
|
||||
}
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
package cn.novalon.gym.manage.groupcourse.handler;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
|
||||
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.MemberCardRecord;
|
||||
import cn.novalon.gym.manage.member.enums.MemberCardType;
|
||||
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
|
||||
import cn.novalon.gym.manage.member.service.IMemberCardRecordService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 预约 Saga 处理器
|
||||
*
|
||||
* 实现预约和取消预约的分布式事务处理
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-06-02
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class BookingSagaHandler {
|
||||
|
||||
private final IGroupCourseBookingRepository bookingRepository;
|
||||
private final IGroupCourseRepository courseRepository;
|
||||
private final IMemberCardRecordService memberCardRecordService;
|
||||
private final MemberCardRepository memberCardRepository;
|
||||
private final GroupCourseRedisService redisService;
|
||||
|
||||
private static final double DEFAULT_GROUP_COURSE_PRICE = 50.0;
|
||||
|
||||
/**
|
||||
* 执行预约事务
|
||||
*
|
||||
* 步骤:
|
||||
* 1. 保存预约记录
|
||||
* 2. 扣减会员卡权益
|
||||
* 3. 更新课程当前人数
|
||||
*/
|
||||
public Mono<GroupCourseBooking> executeBooking(GroupCourseBooking booking, Long recordId) {
|
||||
List<SagaStep> steps = new ArrayList<>();
|
||||
List<SagaStep> rollbackSteps = new ArrayList<>();
|
||||
|
||||
// 步骤1:保存预约记录
|
||||
SagaStep step1 = new SagaStep(
|
||||
"保存预约记录",
|
||||
saveBooking(booking),
|
||||
Mono.defer(() -> deleteBooking(booking.getId()))
|
||||
);
|
||||
steps.add(step1);
|
||||
rollbackSteps.add(0, step1);
|
||||
|
||||
// 步骤2:扣减会员卡权益(根据卡类型决定扣除次数还是金额)
|
||||
SagaStep step2 = new SagaStep(
|
||||
"扣减会员卡权益",
|
||||
deductCardUsageByCardType(booking.getMemberId(), recordId),
|
||||
Mono.defer(() -> restoreCardUsageByCardType(booking.getMemberId(), recordId))
|
||||
);
|
||||
steps.add(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)
|
||||
.then(Mono.just(booking));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据会员卡类型扣减权益
|
||||
*/
|
||||
private Mono<Void> deductCardUsageByCardType(Long memberId, Long recordId) {
|
||||
return memberCardRecordService.findById(recordId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
|
||||
.flatMap(record -> {
|
||||
// 验证会员卡归属
|
||||
if (!record.getMemberId().equals(memberId)) {
|
||||
return Mono.error(new RuntimeException("会员卡不归属当前用户"));
|
||||
}
|
||||
|
||||
return memberCardRepository.findById(record.getMemberCardId())
|
||||
.flatMap(card -> {
|
||||
MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType());
|
||||
|
||||
switch (cardType) {
|
||||
case COUNT_CARD:
|
||||
// 次卡扣除次数
|
||||
return deductCardUsage(recordId, 1, 0.0);
|
||||
case STORED_VALUE_CARD:
|
||||
// 储值卡扣除金额
|
||||
return deductCardUsage(recordId, 0, DEFAULT_GROUP_COURSE_PRICE);
|
||||
case TIME_CARD:
|
||||
// 时长卡不扣除,但需验证有效期
|
||||
return validateTimeCard(record);
|
||||
default:
|
||||
return Mono.error(new RuntimeException("不支持的会员卡类型: " + cardType));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证时长卡有效期
|
||||
*
|
||||
* @param record 会员卡记录
|
||||
* @return 验证通过返回Mono.empty(),否则返回Mono.error()
|
||||
*/
|
||||
private Mono<Void> validateTimeCard(MemberCardRecord 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) {
|
||||
return Mono.error(new RuntimeException("会员卡有效期未设置"));
|
||||
}
|
||||
if (expireTime.isBefore(java.time.LocalDateTime.now())) {
|
||||
return Mono.error(new RuntimeException("会员卡已过期,有效期至: " + expireTime));
|
||||
}
|
||||
|
||||
log.debug("时长卡验证通过: recordId={}, expireTime={}", record.getId(), expireTime);
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据会员卡类型恢复权益
|
||||
*/
|
||||
private Mono<Void> restoreCardUsageByCardType(Long memberId, Long recordId) {
|
||||
return memberCardRecordService.findById(recordId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在,无法恢复权益")))
|
||||
.flatMap(record -> {
|
||||
// 验证会员卡归属(memberId为null时跳过验证)
|
||||
if (memberId != null && !record.getMemberId().equals(memberId)) {
|
||||
return Mono.error(new RuntimeException("会员卡不归属当前用户"));
|
||||
}
|
||||
|
||||
return memberCardRepository.findById(record.getMemberCardId())
|
||||
.flatMap(card -> {
|
||||
MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType());
|
||||
|
||||
switch (cardType) {
|
||||
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();
|
||||
default:
|
||||
return Mono.error(new RuntimeException("不支持的会员卡类型: " + cardType));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行取消预约事务
|
||||
*/
|
||||
public Mono<GroupCourseBooking> executeCancelBooking(Long bookingId, Long courseId, Long recordId, Long memberId) {
|
||||
List<SagaStep> steps = new ArrayList<>();
|
||||
List<SagaStep> rollbackSteps = new ArrayList<>();
|
||||
|
||||
// 步骤1:更新预约状态为已取消
|
||||
SagaStep step1 = new SagaStep(
|
||||
"更新预约状态为已取消",
|
||||
updateBookingStatus(bookingId, "1"),
|
||||
Mono.defer(() -> updateBookingStatus(bookingId, "0"))
|
||||
);
|
||||
steps.add(step1);
|
||||
rollbackSteps.add(0, step1);
|
||||
|
||||
// 步骤2:恢复会员卡权益
|
||||
SagaStep step2 = new SagaStep(
|
||||
"恢复会员卡权益",
|
||||
restoreCardUsageByCardType(memberId, recordId),
|
||||
Mono.defer(() -> deductCardUsageByCardType(memberId, recordId))
|
||||
);
|
||||
steps.add(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)
|
||||
.then(bookingRepository.findById(bookingId));
|
||||
}
|
||||
|
||||
private Mono<Void> updateBookingStatus(Long bookingId, String status) {
|
||||
return bookingRepository.updateStatus(bookingId, status)
|
||||
.flatMap(rows -> {
|
||||
if (rows == 0) {
|
||||
return Mono.error(new RuntimeException("更新预约状态失败"));
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Void> saveBooking(GroupCourseBooking booking) {
|
||||
return bookingRepository.save(booking)
|
||||
.flatMap(saved -> {
|
||||
if (saved.getId() == null) {
|
||||
return Mono.error(new RuntimeException("保存预约记录失败"));
|
||||
}
|
||||
booking.setId(saved.getId());
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Void> deleteBooking(Long bookingId) {
|
||||
return bookingRepository.deleteById(bookingId);
|
||||
}
|
||||
|
||||
private Mono<Void> deductCardUsage(Long recordId, Integer deductTimes, Double deductAmount) {
|
||||
if (deductTimes == 0 && deductAmount == 0.0) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
return memberCardRecordService.findById(recordId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
|
||||
.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 -> {
|
||||
if (rows == 0) {
|
||||
return Mono.error(new RuntimeException("扣减会员卡权益失败,请重试"));
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Void> restoreCardUsage(Long recordId, Integer addTimes, Double addAmount) {
|
||||
if (addTimes == 0 && addAmount == 0.0) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
return memberCardRecordService.findById(recordId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在,无法恢复权益")))
|
||||
.flatMap(record -> {
|
||||
// 使用当前记录的过期时间,避免清空过期时间
|
||||
return memberCardRecordService.renewCard(recordId, addTimes, addAmount, record.getExpireTime())
|
||||
.flatMap(rows -> {
|
||||
if (rows == 0) {
|
||||
return Mono.error(new RuntimeException("恢复会员卡权益失败,请重试"));
|
||||
}
|
||||
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) {
|
||||
List<SagaStep> completedSteps = new ArrayList<>();
|
||||
return executeStep(steps, 0, completedSteps);
|
||||
}
|
||||
|
||||
private Mono<Void> executeStep(List<SagaStep> steps, int index, List<SagaStep> completedSteps) {
|
||||
if (index >= steps.size()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
SagaStep currentStep = steps.get(index);
|
||||
|
||||
return currentStep.operation()
|
||||
.doOnSuccess(unused -> completedSteps.add(currentStep))
|
||||
.then(Mono.defer(() -> executeStep(steps, index + 1, completedSteps)))
|
||||
.onErrorResume(error -> {
|
||||
log.error("Saga步骤执行失败: step={}, error={}", currentStep.description(), error.getMessage());
|
||||
return rollbackCompletedSteps(completedSteps).then(Mono.error(error));
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Void> rollbackCompletedSteps(List<SagaStep> completedSteps) {
|
||||
if (completedSteps.isEmpty()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
// 反转已完成的步骤,按逆序回滚
|
||||
List<SagaStep> reversed = new ArrayList<>(completedSteps);
|
||||
Collections.reverse(reversed);
|
||||
|
||||
return rollbackSteps(reversed, 0);
|
||||
}
|
||||
|
||||
private Mono<Void> rollbackSteps(List<SagaStep> rollbackSteps, int index) {
|
||||
if (index >= rollbackSteps.size()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
SagaStep currentStep = rollbackSteps.get(index);
|
||||
|
||||
return currentStep.rollbackOperation()
|
||||
.then(Mono.defer(() -> rollbackSteps(rollbackSteps, index + 1)))
|
||||
.doOnError(error -> log.error("Saga回滚失败: step={}, error={}", currentStep.description(), error.getMessage()))
|
||||
.onErrorResume(e -> Mono.empty());
|
||||
}
|
||||
|
||||
private record SagaStep(String description, Mono<Void> operation, Mono<Void> rollbackOperation) {}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package cn.novalon.gym.manage.groupcourse.handler;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
|
||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 团课预约Handler
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-06-01
|
||||
*/
|
||||
@Component
|
||||
@Tag(name = "团课预约", description = "团课预约相关操作")
|
||||
public class GroupCourseBookingHandler {
|
||||
|
||||
private final IGroupCourseBookingService bookingService;
|
||||
|
||||
public GroupCourseBookingHandler(IGroupCourseBookingService bookingService) {
|
||||
this.bookingService = bookingService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预约团课
|
||||
*/
|
||||
@Operation(summary = "预约团课", description = "会员预约指定团课")
|
||||
public Mono<ServerResponse> bookCourse(ServerRequest request) {
|
||||
return request.bodyToMono(Map.class)
|
||||
.flatMap(body -> {
|
||||
// 验证必填参数
|
||||
if (body.get("courseId") == null) {
|
||||
return buildErrorResponse("请提供课程ID");
|
||||
}
|
||||
if (body.get("memberId") == null) {
|
||||
return buildErrorResponse("请提供会员ID");
|
||||
}
|
||||
if (body.get("memberCardRecordId") == null) {
|
||||
return buildErrorResponse("请提供会员卡记录ID");
|
||||
}
|
||||
|
||||
Long courseId = ((Number) body.get("courseId")).longValue();
|
||||
Long memberId = ((Number) body.get("memberId")).longValue();
|
||||
Long memberCardRecordId = ((Number) body.get("memberCardRecordId")).longValue();
|
||||
|
||||
return bookingService.bookCourse(courseId, memberId, memberCardRecordId)
|
||||
.flatMap(booking -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "预约成功");
|
||||
response.put("data", booking);
|
||||
return ServerResponse.ok().bodyValue(response);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
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> cancelBooking(ServerRequest request) {
|
||||
Long bookingId = Long.valueOf(request.pathVariable("bookingId"));
|
||||
|
||||
return request.bodyToMono(Map.class)
|
||||
.flatMap(body -> {
|
||||
Long memberId = ((Number) body.get("memberId")).longValue();
|
||||
|
||||
return bookingService.cancelBooking(bookingId, memberId)
|
||||
.flatMap(booking -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "取消成功");
|
||||
response.put("data", booking);
|
||||
return ServerResponse.ok().bodyValue(response);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", error.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询会员预约记录
|
||||
*/
|
||||
@Operation(summary = "查询会员预约记录", description = "根据会员ID查询所有预约记录")
|
||||
public Mono<ServerResponse> getBookingsByMemberId(ServerRequest request) {
|
||||
Long memberId = Long.valueOf(request.pathVariable("memberId"));
|
||||
|
||||
return ServerResponse.ok()
|
||||
.body(bookingService.getBookingsByMemberId(memberId), GroupCourseBooking.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询预约详情
|
||||
*/
|
||||
@Operation(summary = "查询预约详情", description = "根据预约ID查询预约详情")
|
||||
public Mono<ServerResponse> getBookingById(ServerRequest request) {
|
||||
Long bookingId = Long.valueOf(request.pathVariable("bookingId"));
|
||||
|
||||
return bookingService.getBookingById(bookingId)
|
||||
.flatMap(booking -> ServerResponse.ok().bodyValue(booking))
|
||||
.switchIfEmpty(ServerResponse.notFound().build());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询课程预约记录
|
||||
*/
|
||||
@Operation(summary = "查询课程预约记录", description = "根据团课ID查询所有预约记录")
|
||||
public Mono<ServerResponse> getBookingsByCourseId(ServerRequest request) {
|
||||
Long courseId = Long.valueOf(request.pathVariable("courseId"));
|
||||
|
||||
return ServerResponse.ok()
|
||||
.body(bookingService.getBookingsByCourseId(courseId), GroupCourseBooking.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建错误响应
|
||||
*/
|
||||
private Mono<ServerResponse> buildErrorResponse(String message) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", message);
|
||||
return ServerResponse.badRequest().bodyValue(response);
|
||||
}
|
||||
}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
|
||||
package cn.novalon.gym.manage.groupcourse.handler;
|
||||
|
||||
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Validator;
|
||||
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 GroupCourseHandler {
|
||||
private final IGroupCourseService groupCourseService;
|
||||
private final Validator validator;
|
||||
private final RedisUtil redisUtil;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public GroupCourseHandler(IGroupCourseService groupCourseService,
|
||||
Validator validator,
|
||||
RedisUtil redisUtil,
|
||||
ObjectMapper objectMapper){
|
||||
this.groupCourseService = groupCourseService;
|
||||
this.validator = validator;
|
||||
this.redisUtil = redisUtil;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Operation(summary = "获取所有团课", description = "获取系统中所有团课列表")
|
||||
public Mono<ServerResponse> getAllGroupCourse(ServerRequest request){
|
||||
boolean includeDeleted = Boolean.valueOf(request.queryParam("includeDeleted").orElse("false"));
|
||||
return ServerResponse.ok()
|
||||
.body(groupCourseService.findAll(includeDeleted), GroupCourse.class);
|
||||
}
|
||||
|
||||
@Operation(summary = "分页获取团课", description = "根据分页参数获取团课列表")
|
||||
public Mono<ServerResponse> getGroupCoursesByPage(ServerRequest request) {
|
||||
return request.bodyToMono(PageRequest.class)
|
||||
.flatMap(pageRequest -> {
|
||||
boolean includeDeleted = request.queryParam("includeDeleted")
|
||||
.map(Boolean::parseBoolean)
|
||||
.orElse(false);
|
||||
|
||||
if (pageRequest.getPage() < 0) {
|
||||
pageRequest.setPage(0);
|
||||
}
|
||||
if (pageRequest.getSize() <= 0 || pageRequest.getSize() > 100) {
|
||||
pageRequest.setSize(10);
|
||||
}
|
||||
if (pageRequest.getSort() == null || pageRequest.getSort().isEmpty()) {
|
||||
pageRequest.setSort("id");
|
||||
}
|
||||
if (pageRequest.getOrder() == null || pageRequest.getOrder().isEmpty()) {
|
||||
pageRequest.setOrder("asc");
|
||||
}
|
||||
|
||||
return groupCourseService.findByPage(pageRequest, includeDeleted)
|
||||
.flatMap(response -> ServerResponse.ok().bodyValue(response));
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "根据ID获取团课", description = "根据ID获取团课详情")
|
||||
public Mono<ServerResponse> getGroupCourseById(ServerRequest request){
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
return groupCourseService.findById(id)
|
||||
.flatMap(course -> ServerResponse.ok().bodyValue(course))
|
||||
.switchIfEmpty(ServerResponse.notFound().build());
|
||||
}
|
||||
|
||||
@Operation(summary = "创建团课", description = "创建新的团课")
|
||||
public Mono<ServerResponse> createGroupCourse(ServerRequest request) {
|
||||
return request.bodyToMono(GroupCourse.class)
|
||||
.flatMap(groupCourse -> {
|
||||
if (groupCourse.getCourseName() == null || groupCourse.getCourseName().isEmpty()) {
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("success", false);
|
||||
error.put("message", "课程名称不能为空");
|
||||
return ServerResponse.badRequest().bodyValue(error);
|
||||
}
|
||||
|
||||
return groupCourseService.create(groupCourse)
|
||||
.flatMap(course -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "团课创建成功");
|
||||
response.put("data", course);
|
||||
return ServerResponse.ok().bodyValue(response);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", error.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "更新团课", description = "更新指定团课信息")
|
||||
public Mono<ServerResponse> updateGroupCourse(ServerRequest request) {
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
|
||||
return request.bodyToMono(GroupCourse.class)
|
||||
.flatMap(groupCourse -> {
|
||||
return groupCourseService.update(id, groupCourse)
|
||||
.flatMap(course -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "团课更新成功");
|
||||
response.put("data", course);
|
||||
return ServerResponse.ok().bodyValue(response);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", error.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "取消团课", description = "取消指定团课(需提前24小时)")
|
||||
public Mono<ServerResponse> cancelGroupCourse(ServerRequest request) {
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
|
||||
return groupCourseService.cancel(id)
|
||||
.flatMap(course -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "团课取消成功");
|
||||
response.put("data", course);
|
||||
return ServerResponse.ok().bodyValue(response);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", error.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(response);
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "团课签到", description = "会员签到参加团课")
|
||||
public Mono<ServerResponse> signIn(ServerRequest request) {
|
||||
Long courseId = Long.valueOf(request.pathVariable("courseId"));
|
||||
|
||||
return request.bodyToMono(Map.class)
|
||||
.flatMap(body -> {
|
||||
if (body == null || body.isEmpty()) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", "请求体不能为空");
|
||||
return ServerResponse.badRequest().bodyValue(response);
|
||||
}
|
||||
|
||||
Object memberIdObj = body.get("memberId");
|
||||
if (memberIdObj == null) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", "memberId不能为空");
|
||||
return ServerResponse.badRequest().bodyValue(response);
|
||||
}
|
||||
|
||||
Long memberId = ((Number) memberIdObj).longValue();
|
||||
|
||||
return groupCourseService.signIn(courseId, memberId)
|
||||
.flatMap(course -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "签到成功");
|
||||
response.put("data", course);
|
||||
return ServerResponse.ok().bodyValue(response);
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", error.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "删除团课", description = "删除指定团课(软删除)")
|
||||
public Mono<ServerResponse> deleteGroupCourse(ServerRequest request) {
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
|
||||
return groupCourseService.delete(id)
|
||||
.then(Mono.defer(() -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("message", "团课删除成功");
|
||||
return ServerResponse.ok().bodyValue(response);
|
||||
}))
|
||||
.onErrorResume(error -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", error.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(response);
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "测试-根据Key获取Redis缓存", description = "测试接口:根据传入的key值获取Redis中缓存的数据")
|
||||
public Mono<ServerResponse> getCacheByKey(ServerRequest request) {
|
||||
return request.bodyToMono(Map.class)
|
||||
.flatMap(body -> {
|
||||
String key = (String) body.get("key");
|
||||
|
||||
if (key == null || key.isEmpty()) {
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("success", false);
|
||||
error.put("message", "key参数不能为空");
|
||||
return ServerResponse.badRequest().bodyValue(error);
|
||||
}
|
||||
|
||||
return redisUtil.get(key)
|
||||
.map(cachedValue -> {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (cachedValue != null) {
|
||||
result.put("success", true);
|
||||
result.put("key", key);
|
||||
result.put("value", cachedValue);
|
||||
result.put("message", "缓存命中");
|
||||
|
||||
try {
|
||||
if (cachedValue instanceof String) {
|
||||
Object jsonObject = objectMapper.readValue((String) cachedValue, Object.class);
|
||||
result.put("parsedValue", jsonObject);
|
||||
result.put("valueType", "JSON字符串");
|
||||
} else {
|
||||
result.put("valueType", cachedValue.getClass().getSimpleName());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
result.put("parsedValue", null);
|
||||
result.put("valueType", "无法解析");
|
||||
}
|
||||
} else {
|
||||
result.put("success", false);
|
||||
result.put("key", key);
|
||||
result.put("value", null);
|
||||
result.put("message", "缓存未命中");
|
||||
}
|
||||
|
||||
return result;
|
||||
})
|
||||
.flatMap(result -> ServerResponse.ok().bodyValue(result))
|
||||
.onErrorResume(error -> {
|
||||
Map<String, Object> errorResponse = new HashMap<>();
|
||||
errorResponse.put("success", false);
|
||||
errorResponse.put("message", "请求处理失败: " + error.getMessage());
|
||||
return ServerResponse.status(500).bodyValue(errorResponse);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package cn.novalon.gym.manage.groupcourse.handler;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||
import cn.novalon.gym.manage.groupcourse.enums.CourseEvent;
|
||||
import cn.novalon.gym.manage.groupcourse.enums.CourseStatus;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 团课状态机处理器
|
||||
*
|
||||
* 管理团课的状态转换逻辑
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-06-02
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class GroupCourseStateMachine {
|
||||
|
||||
private final Map<CourseStatus, Map<CourseEvent, CourseStatus>> stateTransitionMap;
|
||||
|
||||
public GroupCourseStateMachine() {
|
||||
this.stateTransitionMap = buildStateTransitionMap();
|
||||
}
|
||||
|
||||
private Map<CourseStatus, Map<CourseEvent, CourseStatus>> buildStateTransitionMap() {
|
||||
Map<CourseStatus, Map<CourseEvent, CourseStatus>> map = new HashMap<>();
|
||||
|
||||
// NORMAL 状态可以转换的事件
|
||||
Map<CourseEvent, CourseStatus> normalTransitions = new HashMap<>();
|
||||
normalTransitions.put(CourseEvent.CANCEL, CourseStatus.CANCELLED);
|
||||
normalTransitions.put(CourseEvent.END, CourseStatus.ENDED);
|
||||
normalTransitions.put(CourseEvent.START, CourseStatus.IN_PROGRESS);
|
||||
map.put(CourseStatus.NORMAL, normalTransitions);
|
||||
|
||||
// IN_PROGRESS 状态可以转换的事件
|
||||
Map<CourseEvent, CourseStatus> inProgressTransitions = new HashMap<>();
|
||||
inProgressTransitions.put(CourseEvent.END, CourseStatus.ENDED);
|
||||
map.put(CourseStatus.IN_PROGRESS, inProgressTransitions);
|
||||
|
||||
// CANCELLED 状态是终态,不允许任何转换
|
||||
|
||||
// ENDED 状态是终态,不允许任何转换
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以转换状态
|
||||
*/
|
||||
public Mono<Boolean> canTransition(CourseStatus currentState, CourseEvent event) {
|
||||
return Mono.fromSupplier(() -> {
|
||||
Map<CourseEvent, CourseStatus> transitions = stateTransitionMap.get(currentState);
|
||||
if (transitions == null) {
|
||||
return false;
|
||||
}
|
||||
return transitions.containsKey(event);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行状态转换
|
||||
*/
|
||||
public Mono<CourseStatus> transition(CourseStatus currentState, CourseEvent event) {
|
||||
return Mono.fromSupplier(() -> {
|
||||
Map<CourseEvent, CourseStatus> transitions = stateTransitionMap.get(currentState);
|
||||
if (transitions == null || !transitions.containsKey(event)) {
|
||||
log.error("Invalid state transition: currentState={}, event={}", currentState, event);
|
||||
throw new IllegalStateException(
|
||||
String.format("不允许的状态转换: 当前状态=%s, 事件=%s", currentState, event));
|
||||
}
|
||||
CourseStatus newState = transitions.get(event);
|
||||
log.info("State transition: {} --({})--> {}", currentState, event, newState);
|
||||
return newState;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证状态转换是否允许
|
||||
*/
|
||||
public Mono<Void> validateTransition(GroupCourse course, CourseEvent event) {
|
||||
Long statusValue = course.getStatus();
|
||||
CourseStatus currentState = CourseStatus.fromValue(statusValue != null ? statusValue : 0L);
|
||||
|
||||
return canTransition(currentState, event)
|
||||
.flatMap(canTransition -> {
|
||||
if (!canTransition) {
|
||||
String errorMessage = buildErrorMessage(course.getId(), currentState, event);
|
||||
return Mono.error(new IllegalStateException(errorMessage));
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建状态转换错误信息
|
||||
*/
|
||||
private String buildErrorMessage(Long courseId, CourseStatus currentState, CourseEvent event) {
|
||||
String currentStateDesc = currentState.getDesc();
|
||||
String eventDesc = event.getDesc();
|
||||
|
||||
String advice = "";
|
||||
switch (currentState) {
|
||||
case CANCELLED:
|
||||
advice = "(课程已取消,无法进行此操作)";
|
||||
break;
|
||||
case ENDED:
|
||||
advice = "(课程已结束,无法进行此操作)";
|
||||
break;
|
||||
case IN_PROGRESS:
|
||||
if (event == CourseEvent.CANCEL) {
|
||||
advice = "(课程正在进行中,无法取消)";
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return String.format("团课ID=%d 不允许的状态转换: 当前状态=%s(%s), 操作=%s%s",
|
||||
courseId, currentState, currentStateDesc, eventDesc, advice);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行状态转换并更新课程对象
|
||||
*/
|
||||
public Mono<GroupCourse> applyTransition(GroupCourse course, CourseEvent event) {
|
||||
return validateTransition(course, event)
|
||||
.then(transition(CourseStatus.fromValue(course.getStatus()), event))
|
||||
.map(newStatus -> {
|
||||
course.setStatus(newStatus.getValue());
|
||||
return course;
|
||||
});
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package cn.novalon.gym.manage.groupcourse.repository;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 团课预约记录Repository接口
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-06-01
|
||||
*/
|
||||
public interface IGroupCourseBookingRepository {
|
||||
|
||||
/**
|
||||
* 根据ID查询预约记录
|
||||
*/
|
||||
Mono<GroupCourseBooking> findById(Long id);
|
||||
|
||||
/**
|
||||
* 根据会员ID查询预约记录列表
|
||||
*/
|
||||
Flux<GroupCourseBooking> findByMemberId(Long memberId);
|
||||
|
||||
/**
|
||||
* 根据团课ID查询预约记录列表
|
||||
*/
|
||||
Flux<GroupCourseBooking> findByCourseId(Long courseId);
|
||||
|
||||
/**
|
||||
* 查询会员是否已预约某课程
|
||||
*/
|
||||
Mono<GroupCourseBooking> findByCourseIdAndMemberId(Long courseId, Long memberId);
|
||||
|
||||
/**
|
||||
* 查询会员在指定课程的有效预约
|
||||
*/
|
||||
Mono<GroupCourseBooking> findValidBooking(Long courseId, Long memberId);
|
||||
|
||||
/**
|
||||
* 统计课程有效预约人数
|
||||
*/
|
||||
Mono<Long> countValidBookings(Long courseId);
|
||||
|
||||
/**
|
||||
* 保存预约记录
|
||||
*/
|
||||
Mono<GroupCourseBooking> save(GroupCourseBooking booking);
|
||||
|
||||
/**
|
||||
* 更新预约记录
|
||||
*/
|
||||
Mono<GroupCourseBooking> update(GroupCourseBooking booking);
|
||||
|
||||
/**
|
||||
* 更新预约状态
|
||||
*/
|
||||
Mono<Integer> updateStatus(Long bookingId, String status);
|
||||
|
||||
/**
|
||||
* 删除预约记录
|
||||
*/
|
||||
Mono<Void> deleteById(Long bookingId);
|
||||
|
||||
/**
|
||||
* 根据会员卡记录ID查询预约记录
|
||||
*/
|
||||
Flux<GroupCourseBooking> findByMemberCardRecordId(Long memberCardRecordId);
|
||||
|
||||
/**
|
||||
* 查询会员是否有时间冲突的预约
|
||||
*
|
||||
* @param memberId 会员ID
|
||||
* @param newStartTime 新课程开始时间
|
||||
* @param newEndTime 新课程结束时间
|
||||
* @return 冲突的预约记录列表
|
||||
*/
|
||||
Flux<GroupCourseBooking> findConflictingBookings(Long memberId, java.time.LocalDateTime newStartTime, java.time.LocalDateTime newEndTime);
|
||||
|
||||
/**
|
||||
* 查询已开始课程但未到场会员的预约记录
|
||||
* @return 缺席会员的预约记录列表
|
||||
*/
|
||||
Flux<GroupCourseBooking> findAbsentMembers();
|
||||
|
||||
/**
|
||||
* 更新预约状态为缺席(3)
|
||||
* @param bookingId 预约ID
|
||||
* @return 更新记录数
|
||||
*/
|
||||
Mono<Integer> updateToAbsent(Long bookingId);
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
|
||||
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.GroupCourse;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface IGroupCourseRepository {
|
||||
Mono<GroupCourse> findByIdAndDeletedAtIsNull(Long id);
|
||||
Flux<GroupCourse> findAll();
|
||||
Flux<GroupCourse> findAll(Sort sort);
|
||||
Flux<GroupCourse> findByDeletedAtIsNull();
|
||||
Flux<GroupCourse> findByDeletedAtIsNull(Sort sort);
|
||||
|
||||
Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest);
|
||||
Mono<PageResponse<GroupCourse>> findByPageAndNotDeleted(PageRequest pageRequest);
|
||||
|
||||
Mono<GroupCourse> save(GroupCourse groupCourse);
|
||||
|
||||
Mono<GroupCourse> update(GroupCourse groupCourse);
|
||||
|
||||
Mono<GroupCourse> cancel(Long id);
|
||||
|
||||
Mono<Void> deleteById(Long id);
|
||||
|
||||
Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta);
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package cn.novalon.gym.manage.groupcourse.repository.impl;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
|
||||
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseBookingDao;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
|
||||
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseBookingEntity;
|
||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 团课预约记录Repository实现类
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-06-01
|
||||
*/
|
||||
@Repository
|
||||
public class GroupCourseBookingRepository implements IGroupCourseBookingRepository {
|
||||
|
||||
private final GroupCourseBookingDao groupCourseBookingDao;
|
||||
private final GroupCourseConverter groupCourseConverter;
|
||||
|
||||
public GroupCourseBookingRepository(GroupCourseBookingDao groupCourseBookingDao,
|
||||
GroupCourseConverter groupCourseConverter) {
|
||||
this.groupCourseBookingDao = groupCourseBookingDao;
|
||||
this.groupCourseConverter = groupCourseConverter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourseBooking> findById(Long id) {
|
||||
return groupCourseBookingDao.findByIdIsAndDeletedAtIsNull(id)
|
||||
.map(groupCourseConverter::toBookingDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourseBooking> findByMemberId(Long memberId) {
|
||||
return groupCourseBookingDao.findByMemberIdAndDeletedAtIsNull(memberId, Sort.by(Sort.Direction.DESC, "bookingTime"))
|
||||
.map(groupCourseConverter::toBookingDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourseBooking> findByCourseId(Long courseId) {
|
||||
return groupCourseBookingDao.findByCourseIdAndDeletedAtIsNull(courseId)
|
||||
.map(groupCourseConverter::toBookingDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourseBooking> findByCourseIdAndMemberId(Long courseId, Long memberId) {
|
||||
return groupCourseBookingDao.findByCourseIdAndMemberIdAndDeletedAtIsNull(courseId, memberId)
|
||||
.map(groupCourseConverter::toBookingDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourseBooking> findValidBooking(Long courseId, Long memberId) {
|
||||
return groupCourseBookingDao.findByCourseIdAndMemberIdAndStatusAndDeletedAtIsNull(courseId, memberId, "0")
|
||||
.map(groupCourseConverter::toBookingDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Long> countValidBookings(Long courseId) {
|
||||
return groupCourseBookingDao.countByCourseIdAndStatusAndDeletedAtIsNull(courseId, "0");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourseBooking> save(GroupCourseBooking booking) {
|
||||
GroupCourseBookingEntity entity = groupCourseConverter.toBookingEntity(booking);
|
||||
return groupCourseBookingDao.save(entity)
|
||||
.map(groupCourseConverter::toBookingDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourseBooking> update(GroupCourseBooking booking) {
|
||||
GroupCourseBookingEntity entity = groupCourseConverter.toBookingEntity(booking);
|
||||
return groupCourseBookingDao.save(entity)
|
||||
.map(groupCourseConverter::toBookingDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Integer> updateStatus(Long bookingId, String status) {
|
||||
java.time.LocalDateTime now = java.time.LocalDateTime.now();
|
||||
java.time.LocalDateTime cancelTime = "1".equals(status) ? now : null;
|
||||
return groupCourseBookingDao.updateStatus(bookingId, status, cancelTime, now);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> deleteById(Long bookingId) {
|
||||
return groupCourseBookingDao.softDelete(bookingId, java.time.LocalDateTime.now()).then();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourseBooking> findByMemberCardRecordId(Long memberCardRecordId) {
|
||||
return groupCourseBookingDao.findByMemberCardRecordIdAndDeletedAtIsNull(memberCardRecordId)
|
||||
.map(groupCourseConverter::toBookingDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourseBooking> findConflictingBookings(Long memberId, java.time.LocalDateTime newStartTime, java.time.LocalDateTime newEndTime) {
|
||||
return groupCourseBookingDao.findConflictingBookings(memberId, newStartTime, newEndTime)
|
||||
.map(groupCourseConverter::toBookingDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourseBooking> findAbsentMembers() {
|
||||
return groupCourseBookingDao.findAbsentMembers()
|
||||
.map(groupCourseConverter::toBookingDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Integer> updateToAbsent(Long bookingId) {
|
||||
java.time.LocalDateTime now = java.time.LocalDateTime.now();
|
||||
return groupCourseBookingDao.updateToAbsent(bookingId, now);
|
||||
}
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
|
||||
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.GroupCourseDao;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
|
||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
|
||||
import org.springframework.data.relational.core.query.Query;
|
||||
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 class GroupCourseRepository implements IGroupCourseRepository {
|
||||
private final GroupCourseDao groupCourseDao;
|
||||
private final GroupCourseConverter groupCourseConverter;
|
||||
private final R2dbcEntityTemplate r2dbcEntityTemplate;
|
||||
|
||||
public GroupCourseRepository(GroupCourseDao groupCourseDao, GroupCourseConverter groupCourseConverter,
|
||||
R2dbcEntityTemplate r2dbcEntityTemplate){
|
||||
this.groupCourseDao = groupCourseDao;
|
||||
this.groupCourseConverter = groupCourseConverter;
|
||||
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourse> findByIdAndDeletedAtIsNull(Long id) {
|
||||
return groupCourseDao.findByIdIsAndDeletedAtIsNull(id)
|
||||
.map(groupCourseConverter::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourse> findAll() {
|
||||
return groupCourseDao.findAll()
|
||||
.map(groupCourseConverter::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourse> findAll(Sort sort) {
|
||||
return groupCourseDao.findAll(sort)
|
||||
.map(groupCourseConverter::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourse> findByDeletedAtIsNull() {
|
||||
return groupCourseDao.findAllByDeletedAtIsNull()
|
||||
.map(groupCourseConverter::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourse> findByDeletedAtIsNull(Sort sort) {
|
||||
return groupCourseDao.findAllByDeletedAtIsNull(sort)
|
||||
.map(groupCourseConverter::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest) {
|
||||
int page = pageRequest.getPage();
|
||||
int size = pageRequest.getSize();
|
||||
String sort = pageRequest.getSort();
|
||||
String order = pageRequest.getOrder();
|
||||
|
||||
Sort sortObj = Sort.unsorted();
|
||||
if (sort != null && !sort.isEmpty()) {
|
||||
sortObj = Sort.by(Sort.Direction.fromString(order), sort);
|
||||
}
|
||||
|
||||
org.springframework.data.domain.PageRequest pageable = org.springframework.data.domain.PageRequest.of(page, size, sortObj);
|
||||
|
||||
Query query = Query.empty();
|
||||
|
||||
return r2dbcEntityTemplate.select(GroupCourseEntity.class)
|
||||
.matching(query.with(pageable))
|
||||
.all()
|
||||
.collectList()
|
||||
.zipWith(r2dbcEntityTemplate.count(query, GroupCourseEntity.class))
|
||||
.map(tuple -> {
|
||||
long total = tuple.getT2();
|
||||
int totalPages = (int) Math.ceil((double) total / size);
|
||||
List<GroupCourse> courseList = tuple.getT1().stream()
|
||||
.map(groupCourseConverter::toDomain)
|
||||
.toList();
|
||||
return new PageResponse<>(courseList, totalPages, total, page, size);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<PageResponse<GroupCourse>> findByPageAndNotDeleted(PageRequest pageRequest) {
|
||||
int page = pageRequest.getPage();
|
||||
int size = pageRequest.getSize();
|
||||
String sort = pageRequest.getSort();
|
||||
String order = pageRequest.getOrder();
|
||||
|
||||
Sort sortObj = Sort.unsorted();
|
||||
if (sort != null && !sort.isEmpty()) {
|
||||
sortObj = Sort.by(Sort.Direction.fromString(order), sort);
|
||||
}
|
||||
|
||||
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)
|
||||
.toList();
|
||||
} else {
|
||||
courseList = List.of();
|
||||
}
|
||||
|
||||
int totalPages = (int) Math.ceil((double) total / size);
|
||||
return new PageResponse<>(courseList, totalPages, total, page, size);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourse> save(GroupCourse groupCourse) {
|
||||
GroupCourseEntity entity = groupCourseConverter.toEntity(groupCourse);
|
||||
entity.setCreatedAt(LocalDateTime.now());
|
||||
entity.setUpdatedAt(LocalDateTime.now());
|
||||
entity.setStatus(0L);
|
||||
entity.setCurrentMembers(0);
|
||||
|
||||
return groupCourseDao.save(entity)
|
||||
.map(groupCourseConverter::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourse> update(GroupCourse groupCourse) {
|
||||
GroupCourseEntity entity = groupCourseConverter.toEntity(groupCourse);
|
||||
entity.setUpdatedAt(LocalDateTime.now());
|
||||
|
||||
return r2dbcEntityTemplate.update(entity)
|
||||
.then(findByIdAndDeletedAtIsNull(groupCourse.getId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourse> cancel(Long id) {
|
||||
return groupCourseDao.cancelCourse(id, LocalDateTime.now())
|
||||
.flatMap(updated -> {
|
||||
if (updated > 0) {
|
||||
return findByIdAndDeletedAtIsNull(id);
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> deleteById(Long id) {
|
||||
return groupCourseDao.softDelete(id, LocalDateTime.now())
|
||||
.then();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta) {
|
||||
return groupCourseDao.updateCurrentMembers(id, delta, LocalDateTime.now())
|
||||
.flatMap(updated -> {
|
||||
if (updated > 0) {
|
||||
return findByIdAndDeletedAtIsNull(id);
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package cn.novalon.gym.manage.groupcourse.scheduler;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 团课缺席会员定时任务
|
||||
*
|
||||
* 功能:定期检查已开始但会员未到场的课程,自动更新预约记录状态为缺席
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-06-02
|
||||
*/
|
||||
@Component
|
||||
public class GroupCourseAbsentScheduler {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GroupCourseAbsentScheduler.class);
|
||||
|
||||
private final IGroupCourseBookingService bookingService;
|
||||
|
||||
public GroupCourseAbsentScheduler(IGroupCourseBookingService bookingService) {
|
||||
this.bookingService = bookingService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 每分钟检查一次已开始课程但未到场会员的预约记录
|
||||
* 使用fixedRate保证任务间隔稳定执行
|
||||
*/
|
||||
@Scheduled(fixedRate = 60000)
|
||||
public void checkAndUpdateAbsentMembers() {
|
||||
logger.debug("定时任务开始检查已开始课程但未到场会员的预约记录");
|
||||
|
||||
bookingService.processAbsentMembers()
|
||||
.subscribe(
|
||||
count -> logger.debug("定时任务完成,更新了 {} 条缺席记录", count),
|
||||
error -> logger.error("定时任务执行失败:{}", error.getMessage(), error)
|
||||
);
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package cn.novalon.gym.manage.groupcourse.service;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 团课预约服务接口
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-06-01
|
||||
*/
|
||||
public interface IGroupCourseBookingService {
|
||||
|
||||
/**
|
||||
* 预约团课
|
||||
*
|
||||
* @param courseId 团课ID
|
||||
* @param memberId 会员ID
|
||||
* @param memberCardRecordId 会员卡记录ID
|
||||
* @return 预约记录
|
||||
*/
|
||||
Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId, Long memberCardRecordId);
|
||||
|
||||
/**
|
||||
* 取消预约
|
||||
*
|
||||
* @param bookingId 预约ID
|
||||
* @param memberId 会员ID
|
||||
* @return 取消后的预约记录
|
||||
*/
|
||||
Mono<GroupCourseBooking> cancelBooking(Long bookingId, Long memberId);
|
||||
|
||||
/**
|
||||
* 根据会员ID查询预约记录列表
|
||||
*
|
||||
* @param memberId 会员ID
|
||||
* @return 预约记录列表
|
||||
*/
|
||||
Flux<GroupCourseBooking> getBookingsByMemberId(Long memberId);
|
||||
|
||||
/**
|
||||
* 根据预约ID查询预约详情
|
||||
*
|
||||
* @param bookingId 预约ID
|
||||
* @return 预约记录
|
||||
*/
|
||||
Mono<GroupCourseBooking> getBookingById(Long bookingId);
|
||||
|
||||
/**
|
||||
* 根据团课ID查询预约记录列表
|
||||
*
|
||||
* @param courseId 团课ID
|
||||
* @return 预约记录列表
|
||||
*/
|
||||
Flux<GroupCourseBooking> getBookingsByCourseId(Long courseId);
|
||||
|
||||
/**
|
||||
* 处理已开始课程但未到场会员的预约记录
|
||||
* 将状态更新为缺席(3)
|
||||
*
|
||||
* @return 处理的记录数
|
||||
*/
|
||||
Mono<Integer> processAbsentMembers();
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
|
||||
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.GroupCourse;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public interface IGroupCourseService {
|
||||
Mono<GroupCourse> findById(Long id);
|
||||
Flux<GroupCourse> findAll();
|
||||
Flux<GroupCourse> findAll(boolean includeDeleted);
|
||||
|
||||
Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted);
|
||||
|
||||
Mono<GroupCourse> create(GroupCourse groupCourse);
|
||||
|
||||
Mono<GroupCourse> update(Long id, GroupCourse groupCourse);
|
||||
|
||||
Mono<GroupCourse> cancel(Long id);
|
||||
|
||||
Mono<GroupCourse> signIn(Long courseId, Long memberId);
|
||||
|
||||
Mono<Void> delete(Long id);
|
||||
}
|
||||
+372
@@ -0,0 +1,372 @@
|
||||
package cn.novalon.gym.manage.groupcourse.service.impl;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
|
||||
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.IGroupCourseRepository;
|
||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
|
||||
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.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 团课预约服务实现类
|
||||
*
|
||||
* 业务规则:
|
||||
* - 预约需在课程开始前至少30分钟
|
||||
* - 取消预约需在课程开始前至少2小时
|
||||
* - 每节课最多20人
|
||||
* - 预约成功后发送提醒
|
||||
* - 预约成功后扣减权益
|
||||
*
|
||||
* 技术要点:
|
||||
* - 使用Redis缓存团课信息
|
||||
* - 使用分布式锁防止预约冲突
|
||||
* - 使用响应式编程实现高并发预约
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-06-01
|
||||
*/
|
||||
@Service
|
||||
public class GroupCourseBookingService implements IGroupCourseBookingService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GroupCourseBookingService.class);
|
||||
|
||||
private final IGroupCourseBookingRepository bookingRepository;
|
||||
private final IGroupCourseRepository courseRepository;
|
||||
private final GroupCourseRedisService redisService;
|
||||
private final BookingReminderEventPublisher bookingReminderEventPublisher;
|
||||
private final BookingSagaHandler bookingSagaHandler;
|
||||
|
||||
// 预约提前时间限制(分钟)
|
||||
private static final long BOOKING_MIN_ADVANCE_MINUTES = 30;
|
||||
// 取消预约提前时间限制(小时)
|
||||
private static final long CANCEL_MIN_ADVANCE_HOURS = 2;
|
||||
|
||||
public GroupCourseBookingService(IGroupCourseBookingRepository bookingRepository,
|
||||
IGroupCourseRepository courseRepository,
|
||||
GroupCourseRedisService redisService,
|
||||
BookingReminderEventPublisher bookingReminderEventPublisher,
|
||||
BookingSagaHandler bookingSagaHandler) {
|
||||
this.bookingRepository = bookingRepository;
|
||||
this.courseRepository = courseRepository;
|
||||
this.redisService = redisService;
|
||||
this.bookingReminderEventPublisher = bookingReminderEventPublisher;
|
||||
this.bookingSagaHandler = bookingSagaHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId, Long memberCardRecordId) {
|
||||
logger.info("开始预约团课:courseId={}, memberId={}, memberCardRecordId={}", courseId, memberId, memberCardRecordId);
|
||||
|
||||
// 生成唯一请求ID用于分布式锁
|
||||
String requestId = UUID.randomUUID().toString();
|
||||
|
||||
// 1. 获取分布式锁
|
||||
return redisService.acquireLock(courseId, requestId)
|
||||
.flatMap(lockAcquired -> {
|
||||
if (!lockAcquired) {
|
||||
return Mono.error(new RuntimeException("系统繁忙,请稍后重试"));
|
||||
}
|
||||
|
||||
// 2. 尝试从缓存获取课程信息,如果缓存不存在则从数据库获取
|
||||
return getCourseWithCache(courseId)
|
||||
.flatMap(course -> {
|
||||
// 3. 验证课程状态
|
||||
Long status = course.getStatus();
|
||||
if (status == null || status != 0L) {
|
||||
String errorMessage;
|
||||
if (status == null) {
|
||||
errorMessage = "课程状态异常";
|
||||
} else if (status == 1L) {
|
||||
errorMessage = "课程已取消,无法预约";
|
||||
} else if (status == 2L) {
|
||||
errorMessage = "课程已结束,无法预约";
|
||||
} else {
|
||||
errorMessage = "课程状态不可预约";
|
||||
}
|
||||
return releaseLockAndError(courseId, requestId, errorMessage);
|
||||
}
|
||||
|
||||
// 4. 验证预约时间
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime startTime = course.getStartTime();
|
||||
long minutesUntilStart = ChronoUnit.MINUTES.between(now, startTime);
|
||||
if (minutesUntilStart < BOOKING_MIN_ADVANCE_MINUTES) {
|
||||
return releaseLockAndError(courseId, requestId,
|
||||
"需在课程开始前" + BOOKING_MIN_ADVANCE_MINUTES + "分钟预约");
|
||||
}
|
||||
|
||||
// 5. 验证课程人数是否已满(使用数据库中的current_members字段)
|
||||
if (course.getCurrentMembers() != null &&
|
||||
course.getMaxMembers() != null &&
|
||||
course.getCurrentMembers() >= course.getMaxMembers()) {
|
||||
return releaseLockAndError(courseId, requestId, "课程已满");
|
||||
}
|
||||
|
||||
// 6. 验证时间冲突(会员是否已预约时间重叠的课程)
|
||||
return bookingRepository.findConflictingBookings(memberId, course.getStartTime(), course.getEndTime())
|
||||
.collectList()
|
||||
.flatMap(conflictingBookings -> {
|
||||
if (!conflictingBookings.isEmpty()) {
|
||||
StringBuilder errorMsg = new StringBuilder("您已预约的课程与当前课程时间冲突:");
|
||||
for (GroupCourseBooking conflict : conflictingBookings) {
|
||||
errorMsg.append(conflict.getCourseName())
|
||||
.append("(")
|
||||
.append(conflict.getCourseStartTime())
|
||||
.append(" - ")
|
||||
.append(conflict.getCourseEndTime())
|
||||
.append(") ");
|
||||
}
|
||||
return releaseLockAndError(courseId, requestId, errorMsg.toString());
|
||||
}
|
||||
|
||||
// 7. 验证是否已预约
|
||||
return bookingRepository.findValidBooking(courseId, memberId)
|
||||
.flatMap(existingBooking -> {
|
||||
return releaseLockAndError(courseId, requestId, "您已预约该课程");
|
||||
})
|
||||
.switchIfEmpty(
|
||||
// 8. 使用Redis原子操作验证课程人数是否已满
|
||||
validateAndIncrementBookingCount(courseId, course.getMaxMembers())
|
||||
.flatMap(countValid -> {
|
||||
if (countValid > course.getMaxMembers()) {
|
||||
return releaseLockAndError(courseId, requestId, "课程已满");
|
||||
}
|
||||
|
||||
// 9. 创建预约记录
|
||||
GroupCourseBooking booking = new GroupCourseBooking();
|
||||
booking.setCourseId(courseId);
|
||||
booking.setMemberId(memberId);
|
||||
booking.setMemberCardRecordId(memberCardRecordId);
|
||||
booking.setBookingTime(LocalDateTime.now());
|
||||
booking.setStatus("0"); // 0-已预约
|
||||
|
||||
// 添加课程信息到预约记录
|
||||
booking.setCourseName(course.getCourseName());
|
||||
booking.setCourseStartTime(course.getStartTime());
|
||||
booking.setCourseEndTime(course.getEndTime());
|
||||
booking.setLocation(course.getLocation());
|
||||
|
||||
// 10. 使用Saga事务执行预约(包含权益扣减)
|
||||
return bookingSagaHandler.executeBooking(booking, memberCardRecordId)
|
||||
.flatMap(savedBooking -> {
|
||||
// 11. 释放锁
|
||||
return redisService.releaseLock(courseId, requestId)
|
||||
.then(Mono.just(savedBooking));
|
||||
})
|
||||
.doOnSuccess(savedBooking -> {
|
||||
logger.info("预约成功:bookingId={}, courseId={}, memberId={}",
|
||||
savedBooking.getId(), courseId, memberId);
|
||||
// 发布预约成功事件
|
||||
bookingReminderEventPublisher.publishBookingSuccessEvent(
|
||||
savedBooking.getId(),
|
||||
savedBooking.getMemberId(),
|
||||
savedBooking.getCourseName(),
|
||||
savedBooking.getCourseStartTime().toString()
|
||||
);
|
||||
})
|
||||
.doOnError(error -> {
|
||||
// 回滚Redis计数
|
||||
redisService.decrementBookingCount(courseId).subscribe();
|
||||
logger.error("预约失败:courseId={}, memberId={}, error={}",
|
||||
courseId, memberId, error.getMessage());
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
// 发生错误时释放锁
|
||||
redisService.releaseLock(courseId, requestId).subscribe();
|
||||
return Mono.error(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存或数据库获取课程信息
|
||||
*/
|
||||
private Mono<GroupCourse> getCourseWithCache(Long courseId) {
|
||||
return redisService.getCachedCourse(courseId)
|
||||
.switchIfEmpty(
|
||||
// 缓存未命中,从数据库查询
|
||||
courseRepository.findByIdAndDeletedAtIsNull(courseId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
|
||||
.flatMap(course -> {
|
||||
// 将课程信息存入缓存
|
||||
return redisService.cacheCourse(course).then(Mono.just(course));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证并增加预约人数(使用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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放锁并返回错误
|
||||
*/
|
||||
private Mono<GroupCourseBooking> releaseLockAndError(Long courseId, String requestId, String errorMessage) {
|
||||
return redisService.releaseLock(courseId, requestId)
|
||||
.then(Mono.error(new RuntimeException(errorMessage)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourseBooking> cancelBooking(Long bookingId, Long memberId) {
|
||||
logger.info("开始取消预约:bookingId={}, memberId={}", bookingId, memberId);
|
||||
|
||||
// 生成唯一请求ID用于分布式锁
|
||||
String requestId = UUID.randomUUID().toString();
|
||||
|
||||
// 获取锁防止并发取消
|
||||
return redisService.acquireLock(bookingId, requestId)
|
||||
.flatMap(lockAcquired -> {
|
||||
if (!lockAcquired) {
|
||||
return Mono.error(new RuntimeException("系统繁忙,请稍后重试"));
|
||||
}
|
||||
|
||||
// 1. 查询预约记录
|
||||
return bookingRepository.findById(bookingId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("预约记录不存在")))
|
||||
.flatMap(booking -> {
|
||||
// 2. 验证预约归属
|
||||
if (!booking.getMemberId().equals(memberId)) {
|
||||
return releaseLockAndError(bookingId, requestId, "无权取消他人预约");
|
||||
}
|
||||
|
||||
// 3. 验证预约状态
|
||||
String status = booking.getStatus();
|
||||
if (!"0".equals(status)) {
|
||||
String errorMessage;
|
||||
if ("1".equals(status)) {
|
||||
errorMessage = "预约已取消,无需重复取消";
|
||||
} else if ("2".equals(status)) {
|
||||
errorMessage = "课程已出席,无法取消";
|
||||
} else if ("3".equals(status)) {
|
||||
errorMessage = "已缺席课程,无法取消";
|
||||
} else {
|
||||
errorMessage = "预约状态不允许取消";
|
||||
}
|
||||
return releaseLockAndError(bookingId, requestId, errorMessage);
|
||||
}
|
||||
|
||||
// 4. 验证取消时间
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime startTime = booking.getCourseStartTime();
|
||||
if (startTime == null) {
|
||||
return releaseLockAndError(bookingId, requestId, "课程开始时间为空,无法取消预约");
|
||||
}
|
||||
long hoursUntilStart = ChronoUnit.HOURS.between(now, startTime);
|
||||
if (hoursUntilStart < CANCEL_MIN_ADVANCE_HOURS) {
|
||||
return releaseLockAndError(bookingId, requestId,
|
||||
"需在课程开始前" + CANCEL_MIN_ADVANCE_HOURS + "小时取消");
|
||||
}
|
||||
|
||||
// 5. 使用Saga事务执行取消预约(包含权益恢复)
|
||||
return bookingSagaHandler.executeCancelBooking(bookingId, booking.getCourseId(), booking.getMemberCardRecordId(), memberId)
|
||||
.flatMap(updatedBooking -> {
|
||||
// 6. 释放锁
|
||||
return redisService.releaseLock(bookingId, requestId)
|
||||
.then(Mono.just(updatedBooking));
|
||||
})
|
||||
.doOnSuccess(updatedBooking -> {
|
||||
logger.info("取消预约成功:bookingId={}, memberId={}", bookingId, memberId);
|
||||
// 发布预约取消事件
|
||||
bookingReminderEventPublisher.publishBookingCancelEvent(
|
||||
updatedBooking.getId(),
|
||||
updatedBooking.getMemberId(),
|
||||
updatedBooking.getCourseName()
|
||||
);
|
||||
})
|
||||
.doOnError(error -> {
|
||||
logger.error("取消预约失败:bookingId={}, memberId={}, error={}",
|
||||
bookingId, memberId, error.getMessage());
|
||||
});
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
redisService.releaseLock(bookingId, requestId).subscribe();
|
||||
return Mono.error(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourseBooking> getBookingsByMemberId(Long memberId) {
|
||||
logger.debug("查询会员预约记录:memberId={}", memberId);
|
||||
return bookingRepository.findByMemberId(memberId)
|
||||
.doOnComplete(() -> logger.debug("查询完成:memberId={}", memberId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourseBooking> getBookingById(Long bookingId) {
|
||||
logger.debug("查询预约详情:bookingId={}", bookingId);
|
||||
return bookingRepository.findById(bookingId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourseBooking> getBookingsByCourseId(Long courseId) {
|
||||
logger.debug("查询课程预约记录:courseId={}", courseId);
|
||||
return bookingRepository.findByCourseId(courseId)
|
||||
.doOnComplete(() -> logger.debug("查询完成:courseId={}", courseId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Integer> processAbsentMembers() {
|
||||
logger.info("开始处理已开始课程但未到场会员的预约记录");
|
||||
|
||||
return bookingRepository.findAbsentMembers()
|
||||
.collectList()
|
||||
.flatMap(absentBookings -> {
|
||||
if (absentBookings.isEmpty()) {
|
||||
logger.info("没有需要处理的缺席会员记录");
|
||||
return Mono.just(0);
|
||||
}
|
||||
|
||||
logger.info("发现 {} 条需要更新为缺席状态的预约记录", absentBookings.size());
|
||||
|
||||
// 批量更新状态为缺席
|
||||
return Flux.fromIterable(absentBookings)
|
||||
.flatMap(booking -> {
|
||||
logger.debug("更新预约记录为缺席状态:bookingId={}, memberId={}, courseId={}",
|
||||
booking.getId(), booking.getMemberId(), booking.getCourseId());
|
||||
return bookingRepository.updateToAbsent(booking.getId());
|
||||
})
|
||||
.reduce(Integer::sum)
|
||||
.defaultIfEmpty(0)
|
||||
.doOnSuccess(count -> {
|
||||
logger.info("成功更新 {} 条预约记录为缺席状态", count);
|
||||
})
|
||||
.doOnError(error -> {
|
||||
logger.error("处理缺席会员记录时发生错误:{}", error.getMessage(), error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
package cn.novalon.gym.manage.groupcourse.service.impl;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.redis.core.ReactiveRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 团课Redis缓存服务
|
||||
*
|
||||
* 负责团课信息的缓存管理和分布式锁实现
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-06-01
|
||||
*/
|
||||
@Service
|
||||
public class GroupCourseRedisService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GroupCourseRedisService.class);
|
||||
|
||||
// 团课信息缓存Key前缀
|
||||
private static final String GROUP_COURSE_CACHE_PREFIX = "group_course:";
|
||||
// 团课预约锁Key前缀
|
||||
private static final String BOOKING_LOCK_PREFIX = "booking_lock:";
|
||||
// 缓存过期时间(5分钟)
|
||||
private static final Duration CACHE_EXPIRE_TIME = Duration.ofMinutes(5);
|
||||
// 锁过期时间(30秒)
|
||||
private static final Duration LOCK_EXPIRE_TIME = Duration.ofSeconds(30);
|
||||
|
||||
private final ReactiveRedisTemplate<String, Object> reactiveRedisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public GroupCourseRedisService(ReactiveRedisTemplate<String, Object> reactiveRedisTemplate,
|
||||
ObjectMapper objectMapper) {
|
||||
this.reactiveRedisTemplate = reactiveRedisTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取团课缓存Key
|
||||
*/
|
||||
private String getCourseCacheKey(Long courseId) {
|
||||
return GROUP_COURSE_CACHE_PREFIX + courseId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预约锁Key
|
||||
*/
|
||||
private String getBookingLockKey(Long courseId) {
|
||||
return BOOKING_LOCK_PREFIX + courseId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存团课信息
|
||||
*/
|
||||
public Mono<Void> cacheCourse(GroupCourse course) {
|
||||
String key = getCourseCacheKey(course.getId());
|
||||
try {
|
||||
String value = objectMapper.writeValueAsString(course);
|
||||
return reactiveRedisTemplate.opsForValue()
|
||||
.set(key, value, CACHE_EXPIRE_TIME)
|
||||
.doOnSuccess(result -> logger.debug("团课信息已缓存:courseId={}", course.getId()))
|
||||
.then();
|
||||
} catch (JsonProcessingException e) {
|
||||
logger.error("序列化团课信息失败:courseId={}", course.getId(), e);
|
||||
return Mono.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的团课信息
|
||||
*/
|
||||
public Mono<GroupCourse> getCachedCourse(Long courseId) {
|
||||
String key = getCourseCacheKey(courseId);
|
||||
return reactiveRedisTemplate.opsForValue()
|
||||
.get(key)
|
||||
.cast(String.class)
|
||||
.flatMap(value -> {
|
||||
try {
|
||||
GroupCourse course = objectMapper.readValue(value, GroupCourse.class);
|
||||
logger.debug("从缓存获取团课信息:courseId={}", courseId);
|
||||
return Mono.just(course);
|
||||
} catch (JsonProcessingException e) {
|
||||
logger.error("反序列化团课信息失败:courseId={}", courseId, e);
|
||||
return Mono.empty();
|
||||
}
|
||||
})
|
||||
.switchIfEmpty(Mono.fromRunnable(() -> logger.debug("缓存中未找到团课信息:courseId={}", courseId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除团课缓存
|
||||
*/
|
||||
public Mono<Void> invalidateCourseCache(Long courseId) {
|
||||
String key = getCourseCacheKey(courseId);
|
||||
return reactiveRedisTemplate.delete(key)
|
||||
.doOnSuccess(result -> logger.debug("团课缓存已删除:courseId={}", courseId))
|
||||
.then();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分布式锁
|
||||
*
|
||||
* @param courseId 课程ID
|
||||
* @param requestId 请求ID(用于锁的唯一性校验)
|
||||
* @return 是否获取成功
|
||||
*/
|
||||
public Mono<Boolean> acquireLock(Long courseId, String requestId) {
|
||||
String key = getBookingLockKey(courseId);
|
||||
return reactiveRedisTemplate.opsForValue()
|
||||
.setIfAbsent(key, requestId, LOCK_EXPIRE_TIME)
|
||||
.doOnSuccess(acquired -> {
|
||||
if (acquired) {
|
||||
logger.debug("获取预约锁成功:courseId={}, requestId={}", courseId, requestId);
|
||||
} else {
|
||||
logger.debug("获取预约锁失败:courseId={}", courseId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放分布式锁
|
||||
*
|
||||
* @param courseId 课程ID
|
||||
* @param requestId 请求ID(用于锁的唯一性校验)
|
||||
* @return 是否释放成功
|
||||
*/
|
||||
public Mono<Boolean> releaseLock(Long courseId, String requestId) {
|
||||
String key = getBookingLockKey(courseId);
|
||||
return reactiveRedisTemplate.opsForValue()
|
||||
.get(key)
|
||||
.cast(String.class)
|
||||
.flatMap(storedRequestId -> {
|
||||
if (requestId.equals(storedRequestId)) {
|
||||
return reactiveRedisTemplate.delete(key)
|
||||
.map(deleted -> deleted > 0)
|
||||
.doOnSuccess(result -> logger.debug("释放预约锁成功:courseId={}, requestId={}", courseId, requestId));
|
||||
} else {
|
||||
logger.warn("锁归属校验失败:courseId={}, expectedRequestId={}, actualRequestId={}",
|
||||
courseId, requestId, storedRequestId);
|
||||
return Mono.just(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();
|
||||
}
|
||||
}
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
|
||||
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.GroupCourse;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
|
||||
import cn.novalon.gym.manage.groupcourse.enums.CourseEvent;
|
||||
import cn.novalon.gym.manage.groupcourse.enums.CourseStatus;
|
||||
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseStateMachine;
|
||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository;
|
||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
|
||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
|
||||
import cn.novalon.gym.manage.member.entity.MemberCard;
|
||||
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
|
||||
import cn.novalon.gym.manage.member.enums.MemberCardType;
|
||||
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
|
||||
import cn.novalon.gym.manage.member.service.IMemberCardRecordService;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
public class GroupCourseService implements IGroupCourseService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(GroupCourseService.class);
|
||||
|
||||
private final IGroupCourseRepository groupCourseRepository;
|
||||
private final IGroupCourseBookingRepository bookingRepository;
|
||||
private final IMemberCardRecordService memberCardRecordService;
|
||||
private final MemberCardRepository memberCardRepository;
|
||||
private final RedisUtil redisUtil;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final GroupCourseStateMachine stateMachine;
|
||||
|
||||
private static final String CACHE_KEY_PREFIX = "group_course:page:";
|
||||
private static final String CACHE_KEY_ID_PREFIX = "group_course:id:";
|
||||
private static final long CACHE_EXPIRE_SECONDS = 300;
|
||||
|
||||
private static final double DEFAULT_GROUP_COURSE_PRICE = 50.0;
|
||||
|
||||
public GroupCourseService(IGroupCourseRepository groupCourseRepository,
|
||||
IGroupCourseBookingRepository bookingRepository,
|
||||
IMemberCardRecordService memberCardRecordService,
|
||||
MemberCardRepository memberCardRepository,
|
||||
RedisUtil redisUtil,
|
||||
ObjectMapper objectMapper,
|
||||
GroupCourseStateMachine stateMachine){
|
||||
this.groupCourseRepository = groupCourseRepository;
|
||||
this.bookingRepository = bookingRepository;
|
||||
this.memberCardRecordService = memberCardRecordService;
|
||||
this.memberCardRepository = memberCardRepository;
|
||||
this.redisUtil = redisUtil;
|
||||
this.objectMapper = objectMapper;
|
||||
this.stateMachine = stateMachine;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourse> findById(Long id) {
|
||||
String cacheKey = CACHE_KEY_ID_PREFIX + id;
|
||||
|
||||
return redisUtil.get(cacheKey, String.class)
|
||||
.flatMap(cachedJson -> {
|
||||
if (cachedJson != null) {
|
||||
try {
|
||||
GroupCourse groupCourse = objectMapper.readValue(cachedJson, GroupCourse.class);
|
||||
logger.info("缓存命中 - findById: id={}", id);
|
||||
return Mono.just(groupCourse);
|
||||
} 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(groupCourse -> {
|
||||
try {
|
||||
String jsonData = objectMapper.writeValueAsString(groupCourse);
|
||||
return redisUtil.setWithExpire(cacheKey, jsonData, CACHE_EXPIRE_SECONDS)
|
||||
.thenReturn(groupCourse)
|
||||
.doOnSuccess(gc -> logger.debug("缓存已设置 - findById: id={}", id));
|
||||
} catch (JsonProcessingException e) {
|
||||
logger.error("缓存设置失败 - id: {}, error: {}", id, e.getMessage());
|
||||
return Mono.just(groupCourse);
|
||||
}
|
||||
})
|
||||
.doOnSubscribe(sub -> logger.debug("缓存未命中,查询数据库 - findById: id={}", id))
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourse> findAll() {
|
||||
return groupCourseRepository.findAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourse> findAll(boolean includeDeleted) {
|
||||
if(includeDeleted){
|
||||
return groupCourseRepository.findAll();
|
||||
}else{
|
||||
return groupCourseRepository.findByDeletedAtIsNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted) {
|
||||
int page = pageRequest.getPage();
|
||||
int size = pageRequest.getSize();
|
||||
String sort = pageRequest.getSort();
|
||||
String order = pageRequest.getOrder();
|
||||
String keyword = pageRequest.getKeyword() != null ? pageRequest.getKeyword() : "";
|
||||
|
||||
String cacheKey = CACHE_KEY_PREFIX + page + ":" + size + ":" + includeDeleted + ":" + sort + ":" + order + ":" + keyword;
|
||||
|
||||
return redisUtil.get(cacheKey, String.class)
|
||||
.flatMap(cachedJson -> {
|
||||
if (cachedJson != null) {
|
||||
try {
|
||||
PageResponse<GroupCourse> pageResponse = objectMapper.readValue(cachedJson,
|
||||
objectMapper.getTypeFactory().constructParametricType(PageResponse.class, GroupCourse.class));
|
||||
logger.info("缓存命中 - findByPage: key={}", cacheKey);
|
||||
return Mono.just(pageResponse);
|
||||
} catch (JsonProcessingException e) {
|
||||
logger.warn("缓存解析失败,删除缓存 - key: {}, error: {}", cacheKey, e.getMessage());
|
||||
return redisUtil.delete(cacheKey).then(Mono.empty());
|
||||
}
|
||||
}
|
||||
return Mono.empty();
|
||||
})
|
||||
.switchIfEmpty(
|
||||
Mono.defer(() -> {
|
||||
logger.debug("缓存未命中,查询数据库 - findByPage: key={}", cacheKey);
|
||||
Mono<PageResponse<GroupCourse>> resultMono;
|
||||
if (includeDeleted) {
|
||||
resultMono = groupCourseRepository.findByPage(pageRequest);
|
||||
} else {
|
||||
resultMono = groupCourseRepository.findByPageAndNotDeleted(pageRequest);
|
||||
}
|
||||
|
||||
return resultMono.flatMap(pageResponse -> {
|
||||
try {
|
||||
String jsonData = objectMapper.writeValueAsString(pageResponse);
|
||||
return redisUtil.setWithExpire(cacheKey, jsonData, CACHE_EXPIRE_SECONDS)
|
||||
.thenReturn(pageResponse)
|
||||
.doOnSuccess(pr -> logger.debug("缓存已设置 - findByPage: key={}", cacheKey));
|
||||
} catch (JsonProcessingException e) {
|
||||
logger.error("缓存设置失败 - key: {}, error: {}", cacheKey, e.getMessage());
|
||||
return Mono.just(pageResponse);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourse> create(GroupCourse groupCourse) {
|
||||
return groupCourseRepository.save(groupCourse)
|
||||
.doOnSuccess(course -> logger.info("团课创建成功 - id={}, name={}", course.getId(), course.getCourseName()))
|
||||
.flatMap(course -> clearCache().thenReturn(course))
|
||||
.doOnError(error -> logger.error("团课创建失败 - error: {}", error.getMessage()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourse> update(Long id, GroupCourse groupCourse) {
|
||||
return groupCourseRepository.findByIdAndDeletedAtIsNull(id)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
|
||||
.flatMap(existing -> {
|
||||
if (groupCourse.getCourseName() != null) {
|
||||
existing.setCourseName(groupCourse.getCourseName());
|
||||
}
|
||||
if (groupCourse.getCoachId() != null) {
|
||||
existing.setCoachId(groupCourse.getCoachId());
|
||||
}
|
||||
if (groupCourse.getCourseType() != null) {
|
||||
existing.setCourseType(groupCourse.getCourseType());
|
||||
}
|
||||
if (groupCourse.getStartTime() != null) {
|
||||
existing.setStartTime(groupCourse.getStartTime());
|
||||
}
|
||||
if (groupCourse.getEndTime() != null) {
|
||||
existing.setEndTime(groupCourse.getEndTime());
|
||||
}
|
||||
if (groupCourse.getMaxMembers() != null) {
|
||||
existing.setMaxMembers(groupCourse.getMaxMembers());
|
||||
}
|
||||
if (groupCourse.getStatus() != null) {
|
||||
existing.setStatus(groupCourse.getStatus());
|
||||
}
|
||||
if (groupCourse.getLocation() != null) {
|
||||
existing.setLocation(groupCourse.getLocation());
|
||||
}
|
||||
if (groupCourse.getCoverImage() != null) {
|
||||
existing.setCoverImage(groupCourse.getCoverImage());
|
||||
}
|
||||
if (groupCourse.getDescription() != null) {
|
||||
existing.setDescription(groupCourse.getDescription());
|
||||
}
|
||||
if (groupCourse.getPointCardAmount() != null) {
|
||||
existing.setPointCardAmount(groupCourse.getPointCardAmount());
|
||||
}
|
||||
if (groupCourse.getStoredValueAmount() != null) {
|
||||
existing.setStoredValueAmount(groupCourse.getStoredValueAmount());
|
||||
}
|
||||
return groupCourseRepository.update(existing);
|
||||
})
|
||||
.doOnSuccess(course -> logger.info("团课更新成功 - id={}", id))
|
||||
.flatMap(course -> clearCache().thenReturn(course))
|
||||
.doOnError(error -> logger.error("团课更新失败 - id={}, error: {}", id, error.getMessage()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourse> cancel(Long id) {
|
||||
return groupCourseRepository.findByIdAndDeletedAtIsNull(id)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
|
||||
.flatMap(course -> {
|
||||
// 使用状态机验证状态转换
|
||||
return stateMachine.validateTransition(course, CourseEvent.CANCEL)
|
||||
.then(Mono.just(course));
|
||||
})
|
||||
.flatMap(course -> {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
// 检查当前时间是否超过课程开始前24小时
|
||||
// 如果当前时间 > (课程开始时间 - 24小时),则不允许取消
|
||||
if (now.isAfter(course.getStartTime().minusHours(24))) {
|
||||
return Mono.error(new RuntimeException("课程取消需提前24小时"));
|
||||
}
|
||||
|
||||
// 取消课程并返还权益
|
||||
return groupCourseRepository.cancel(id)
|
||||
.flatMap(canceledCourse -> {
|
||||
// 为所有预约该课程的会员返还权益
|
||||
return refundBookingMembers(canceledCourse.getId())
|
||||
.then(Mono.just(canceledCourse));
|
||||
});
|
||||
})
|
||||
.doOnSuccess(course -> logger.info("团课取消成功 - id={}", id))
|
||||
.flatMap(course -> clearCache().thenReturn(course))
|
||||
.doOnError(error -> logger.error("团课取消失败 - id={}, error: {}", id, error.getMessage()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 为预约课程的所有会员返还权益
|
||||
*
|
||||
* @param courseId 课程ID
|
||||
* @return 空Mono
|
||||
*/
|
||||
private Mono<Void> refundBookingMembers(Long courseId) {
|
||||
logger.info("开始为课程预约会员返还权益: courseId={}", courseId);
|
||||
|
||||
return groupCourseRepository.findByIdAndDeletedAtIsNull(courseId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("课程不存在")))
|
||||
.flatMap(course -> {
|
||||
Integer pointCardAmount = course.getPointCardAmount() != null ? course.getPointCardAmount() : 1;
|
||||
Double storedValueAmount = course.getStoredValueAmount() != null ?
|
||||
course.getStoredValueAmount().doubleValue() : DEFAULT_GROUP_COURSE_PRICE;
|
||||
|
||||
logger.info("课程权益信息: courseId={}, pointCardAmount={}, storedValueAmount={}",
|
||||
courseId, pointCardAmount, storedValueAmount);
|
||||
|
||||
return bookingRepository.findByCourseId(courseId)
|
||||
.filter(booking -> "0".equals(booking.getStatus())) // 只处理已预约状态的记录
|
||||
.flatMap(booking -> {
|
||||
Long memberCardRecordId = booking.getMemberCardRecordId();
|
||||
if (memberCardRecordId == null) {
|
||||
logger.warn("预约记录没有会员卡记录ID: bookingId={}", booking.getId());
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
// 根据会员卡类型返还权益
|
||||
return refundCardUsage(booking.getId(), memberCardRecordId, pointCardAmount, storedValueAmount)
|
||||
.doOnSuccess(unused -> logger.info("会员权益返还成功: bookingId={}, memberId={}",
|
||||
booking.getId(), booking.getMemberId()))
|
||||
.doOnError(error -> logger.error("会员权益返还失败: bookingId={}, memberId={}, error={}",
|
||||
booking.getId(), booking.getMemberId(), error.getMessage()))
|
||||
.onErrorResume(e -> Mono.empty()); // 单个会员返还失败不影响其他会员
|
||||
})
|
||||
.then();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据会员卡类型返还权益
|
||||
*
|
||||
* @param bookingId 预约ID
|
||||
* @param recordId 会员卡记录ID
|
||||
* @param pointCardAmount 次卡返还次数
|
||||
* @param storedValueAmount 储值卡返还金额
|
||||
* @return 空Mono
|
||||
*/
|
||||
private Mono<Void> refundCardUsage(Long bookingId, Long recordId, Integer pointCardAmount, Double storedValueAmount) {
|
||||
return memberCardRecordService.findById(recordId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
|
||||
.flatMap(record -> {
|
||||
return memberCardRepository.findById(record.getMemberCardId())
|
||||
.flatMap(card -> {
|
||||
MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType());
|
||||
|
||||
Mono<Void> refundOperation;
|
||||
switch (cardType) {
|
||||
case COUNT_CARD:
|
||||
// 次卡返还次数(基于课程设置的pointCardAmount)
|
||||
logger.debug("返还次卡次数: recordId={}, amount={}", recordId, pointCardAmount);
|
||||
refundOperation = memberCardRecordService.renewCard(recordId, pointCardAmount, 0.0, record.getExpireTime())
|
||||
.then(Mono.empty());
|
||||
break;
|
||||
case STORED_VALUE_CARD:
|
||||
// 储值卡返还金额(基于课程设置的storedValueAmount)
|
||||
logger.debug("返还储值卡金额: recordId={}, amount={}", recordId, storedValueAmount);
|
||||
refundOperation = memberCardRecordService.renewCard(recordId, 0, storedValueAmount, record.getExpireTime())
|
||||
.then(Mono.empty());
|
||||
break;
|
||||
case TIME_CARD:
|
||||
// 时长卡不返还,仅修改预约记录状态
|
||||
logger.debug("时长卡不返还权益,仅更新预约状态: bookingId={}", bookingId);
|
||||
refundOperation = Mono.empty();
|
||||
break;
|
||||
default:
|
||||
return Mono.error(new RuntimeException("不支持的会员卡类型: " + cardType));
|
||||
}
|
||||
|
||||
// 无论哪种卡类型,都需要更新预约记录状态为已取消
|
||||
return refundOperation.then(bookingRepository.updateStatus(bookingId, "1"))
|
||||
.flatMap(rows -> {
|
||||
if (rows == 0) {
|
||||
logger.warn("更新预约状态失败: bookingId={}", bookingId);
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourse> signIn(Long courseId, Long memberId) {
|
||||
return groupCourseRepository.findByIdAndDeletedAtIsNull(courseId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
|
||||
.flatMap(course -> {
|
||||
if (course.getStatus() != 0L) {
|
||||
return Mono.error(new RuntimeException("课程状态不允许签到"));
|
||||
}
|
||||
|
||||
if (course.getCurrentMembers() >= course.getMaxMembers()) {
|
||||
return Mono.error(new RuntimeException("课程已满员"));
|
||||
}
|
||||
|
||||
// 检查会员是否已预约此课程
|
||||
return bookingRepository.findValidBooking(courseId, memberId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("会员未预约此课程")))
|
||||
.flatMap(booking -> {
|
||||
// 更新课程当前人数
|
||||
return groupCourseRepository.updateCurrentMembers(courseId, 1)
|
||||
.flatMap(updatedCourse -> {
|
||||
// 更新预约状态为已出席
|
||||
return bookingRepository.updateStatus(booking.getId(), "2")
|
||||
.thenReturn(updatedCourse);
|
||||
});
|
||||
});
|
||||
})
|
||||
.doOnSuccess(course -> logger.info("团课签到成功 - courseId={}, memberId={}", courseId, memberId))
|
||||
.flatMap(course -> clearCache().thenReturn(course))
|
||||
.doOnError(error -> logger.error("团课签到失败 - courseId={}, memberId={}, error: {}", courseId, memberId, error.getMessage()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> delete(Long id) {
|
||||
// 先查询课程状态,只有已取消的课程才能删除
|
||||
return groupCourseRepository.findByIdAndDeletedAtIsNull(id)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
|
||||
.flatMap(course -> {
|
||||
// 检查课程状态是否为已取消(状态码1)
|
||||
if (course.getStatus() == null || !course.getStatus().equals(CourseStatus.CANCELLED.getValue())) {
|
||||
return Mono.error(new RuntimeException("只有已取消的课程才能删除,当前状态: " +
|
||||
(course.getStatus() != null ? course.getStatus() : "未知")));
|
||||
}
|
||||
|
||||
// 删除课程
|
||||
return groupCourseRepository.deleteById(id)
|
||||
.doOnSuccess(v -> logger.info("团课删除成功 - id={}", id))
|
||||
.then(clearCache())
|
||||
.doOnError(error -> logger.error("团课删除失败 - id={}, error: {}", id, error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Void> clearCache() {
|
||||
return redisUtil.deleteByPattern(CACHE_KEY_PREFIX + "*")
|
||||
.then(redisUtil.deleteByPattern(CACHE_KEY_ID_PREFIX + "*")).then();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
spring:
|
||||
application:
|
||||
name: gym-groupCourse
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
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() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -128,11 +128,7 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
|
||||
</dependency>
|
||||
<!-- Redis响应式支持(会员卡模块需要) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
-3
@@ -12,9 +12,6 @@ public class SearchMemberDto {
|
||||
// 搜索字段 - 包括 会员号、昵称、手机号
|
||||
private String searchValue;
|
||||
|
||||
// 排序
|
||||
private String filter;
|
||||
|
||||
// 页码
|
||||
private Integer pageNum = 1;
|
||||
|
||||
|
||||
+11
-9
@@ -1,30 +1,32 @@
|
||||
package cn.novalon.gym.manage.member.dto;
|
||||
|
||||
import cn.novalon.gym.manage.member.enums.GenderEnum;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 更新会员信息Dto
|
||||
*
|
||||
*
|
||||
* @author 付嘉
|
||||
* @date 2026-05-10
|
||||
*/
|
||||
@Data
|
||||
public class UpdateMemberInfoDto {
|
||||
|
||||
|
||||
// 昵称
|
||||
private String nickname;
|
||||
|
||||
|
||||
// 性别
|
||||
private Integer gender;
|
||||
|
||||
private GenderEnum gender;
|
||||
|
||||
// 生日
|
||||
private Date birthday;
|
||||
|
||||
private LocalDate birthday;
|
||||
|
||||
// 头像
|
||||
private String avatar;
|
||||
|
||||
|
||||
// 地址
|
||||
private String address;
|
||||
}
|
||||
}
|
||||
+3
@@ -33,6 +33,9 @@ public abstract class BaseEntity implements Persistable<Long> {
|
||||
@Column("updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Column("deleted_at")
|
||||
private LocalDateTime deletedAt;
|
||||
|
||||
// 判断当前实体是否是新建的
|
||||
@Override
|
||||
public boolean isNew() {
|
||||
|
||||
+3
-6
@@ -1,13 +1,10 @@
|
||||
package cn.novalon.gym.manage.member.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.*;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Date;
|
||||
|
||||
@@ -44,7 +41,7 @@ public class Member extends BaseEntity {
|
||||
|
||||
//生日
|
||||
@Column("birthday")
|
||||
private Date birthday;
|
||||
private LocalDate birthday;
|
||||
|
||||
//地址
|
||||
@Column("address")
|
||||
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
package cn.novalon.gym.manage.member.entity;
|
||||
|
||||
import lombok.*;
|
||||
import org.springframework.data.annotation.CreatedDate;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Table("sign_in_record")
|
||||
public class SignInRecord {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
// 会员ID
|
||||
@Column("member_id")
|
||||
private Long memberId;
|
||||
|
||||
// 签到日期
|
||||
@Column("sign_in_date")
|
||||
private LocalDate signInDate;
|
||||
|
||||
// 签到时间
|
||||
@Column("sign_in_time")
|
||||
private LocalDateTime signInTime;
|
||||
|
||||
// 创建时间
|
||||
@CreatedDate
|
||||
@Column("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package cn.novalon.gym.manage.member.enums;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 性别枚举
|
||||
*
|
||||
* @author 付嘉
|
||||
* @date 2026-05-29
|
||||
*/
|
||||
@Getter
|
||||
public enum GenderEnum {
|
||||
|
||||
UNKNOWN(0, "未知"),
|
||||
MALE(1, "男"),
|
||||
FEMALE(2, "女");
|
||||
|
||||
private final Integer code;
|
||||
private final String desc;
|
||||
|
||||
GenderEnum(Integer code, String desc) {
|
||||
this.code = code;
|
||||
this.desc = desc;
|
||||
}
|
||||
|
||||
public static GenderEnum fromCode(Integer code) {
|
||||
if (code == null) {
|
||||
return UNKNOWN;
|
||||
}
|
||||
for (GenderEnum gender : values()) {
|
||||
if (gender.code.equals(code)) {
|
||||
return gender;
|
||||
}
|
||||
}
|
||||
return UNKNOWN;
|
||||
}
|
||||
}
|
||||
+6
@@ -1,12 +1,18 @@
|
||||
package cn.novalon.gym.manage.member.es.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.elasticsearch.annotations.Document;
|
||||
import org.springframework.data.elasticsearch.annotations.Field;
|
||||
import org.springframework.data.elasticsearch.annotations.FieldType;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Document(indexName = "gym_members")
|
||||
public class MemberES {
|
||||
|
||||
|
||||
+3
-2
@@ -2,6 +2,7 @@ package cn.novalon.gym.manage.member.es.repository;
|
||||
|
||||
import cn.novalon.gym.manage.member.es.entity.MemberES;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.elasticsearch.annotations.Query;
|
||||
import org.springframework.data.elasticsearch.repository.ReactiveElasticsearchRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
@@ -15,6 +16,6 @@ public interface MemberESRepository extends ReactiveElasticsearchRepository<Memb
|
||||
/**
|
||||
* 前台通用搜索:会员号(精确匹配) 或 昵称(模糊匹配) 或 手机号(精确匹配)并且 性别筛选(精确匹配)
|
||||
*/
|
||||
Flux<MemberES> findByMemberNoOrPhoneOrNicknameContainingAndGender(
|
||||
String memberNo, String phone, String nickname,String gender, Pageable pageable);
|
||||
Flux<MemberES> findByMemberNoOrPhoneOrNicknameContaining(
|
||||
String memberNo, String phone, String nickname, Pageable pageable);
|
||||
}
|
||||
+44
-90
@@ -8,8 +8,11 @@ import cn.novalon.gym.manage.member.service.MemberService;
|
||||
import cn.novalon.gym.manage.member.service.WechatAuthService;
|
||||
import cn.novalon.gym.manage.member.service.WechatOfficialService;
|
||||
import cn.novalon.gym.manage.member.util.AesUtil;
|
||||
import cn.novalon.gym.manage.member.util.WechatPhoneUtil;
|
||||
import cn.novalon.gym.manage.sys.util.AuthUtil;
|
||||
import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.math.NumberUtils;
|
||||
@@ -29,21 +32,15 @@ import reactor.core.publisher.Mono;
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "会员管理", description = "会员信息管理、微信绑定、服务号关注等")
|
||||
public class MemberHandler {
|
||||
|
||||
private final MemberService memberService;
|
||||
private final WechatAuthService wechatAuthService;
|
||||
private final WechatOfficialService wechatOfficialService;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final WechatProperties wechatProperties;
|
||||
private final AuthUtil authUtil;
|
||||
|
||||
/**
|
||||
* 获取会员信息
|
||||
*
|
||||
* GET /api/member/info
|
||||
* header: { "Authorization": "Bearer xxx" }
|
||||
*/
|
||||
@Operation(summary = "获取会员信息", description = "根据当前登录用户获取会员基本信息")
|
||||
public Mono<ServerResponse> getMemberInfo(ServerRequest request) {
|
||||
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
@@ -56,19 +53,7 @@ public class MemberHandler {
|
||||
.bodyValue(info));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会员信息
|
||||
*
|
||||
* PUT /api/member/info
|
||||
* header: { "Authorization": "Bearer xxx" }
|
||||
* Body: {
|
||||
* "nickname": "新昵称",
|
||||
* "gender": 1,
|
||||
* "birthday": "2000-01-01",
|
||||
* "avatar": "https://example.com/avatar.jpg",
|
||||
* "address": "北京市朝阳区"
|
||||
* }
|
||||
*/
|
||||
@Operation(summary = "更新会员信息", description = "更新会员昵称、性别、生日、头像、地址等信息")
|
||||
public Mono<ServerResponse> updateMemberInfo(ServerRequest request) {
|
||||
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
@@ -82,11 +67,7 @@ public class MemberHandler {
|
||||
.bodyValue(info));
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定手机号(微信小程序)
|
||||
* header: { "Authorization": "Bearer xxx" }
|
||||
* POST /api/member/phone/bind?code=PHONE_CODE
|
||||
*/
|
||||
@Operation(summary = "绑定手机号", description = "通过微信小程序手机号code绑定会员手机号")
|
||||
public Mono<ServerResponse> bindPhone(ServerRequest request) {
|
||||
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
@@ -103,12 +84,7 @@ public class MemberHandler {
|
||||
.bodyValue(success));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询服务号关注状态
|
||||
*
|
||||
* GET /api/member/subscribe/status
|
||||
*
|
||||
*/
|
||||
@Operation(summary = "查询服务号关注状态", description = "查询会员是否关注微信服务号")
|
||||
public Mono<ServerResponse> checkSubscribeStatus(ServerRequest request) {
|
||||
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
@@ -123,14 +99,7 @@ public class MemberHandler {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员更新手机号
|
||||
*
|
||||
* POST /api/admin/member/123/phone
|
||||
* header: { "Authorization": "Bearer xxx" }
|
||||
* Body: { "phone": "13800138000" }
|
||||
*
|
||||
*/
|
||||
@Operation(summary = "管理员更新手机号", description = "后台管理员为会员更新手机号")
|
||||
public Mono<ServerResponse> adminUpdatePhone(ServerRequest request) {
|
||||
|
||||
Long adminId = authUtil.getMemberIdOrThrow(request);
|
||||
@@ -162,13 +131,7 @@ public class MemberHandler {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 前台查看会员信息
|
||||
*
|
||||
* GET /api/admin/member/{id}
|
||||
* header: { "Authorization": "xxx" }
|
||||
*
|
||||
*/
|
||||
@Operation(summary = "管理员查看会员详情", description = "后台管理员查看指定会员的详细信息")
|
||||
public Mono<ServerResponse> adminGetMemberInfo(ServerRequest request) {
|
||||
|
||||
Long adminId = authUtil.getMemberIdOrThrow(request);
|
||||
@@ -179,20 +142,24 @@ public class MemberHandler {
|
||||
|
||||
log.info("前台查看会员信息, adminId: {}, memberId: {}", adminId, memberId);
|
||||
|
||||
// TODO 多表查询:会员信息、团课信息、会员卡信息
|
||||
|
||||
return ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue("成功");
|
||||
return memberService.getMemberDetail(memberId)
|
||||
.flatMap(detail -> {
|
||||
if (detail.getPhone() != null && !detail.getPhone().isEmpty()) {
|
||||
try {
|
||||
String decryptedPhone = AesUtil.decrypt(detail.getPhone());
|
||||
detail.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
|
||||
} catch (Exception e) {
|
||||
log.error("手机号解密失败, memberId: {}", detail.getId(), e);
|
||||
detail.setPhone(null);
|
||||
}
|
||||
}
|
||||
return ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(detail);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 前台编辑会员信息
|
||||
*
|
||||
* PUT /api/admin/member/{id}
|
||||
* header: { "Authorization": "xxx" }
|
||||
* Body:{"字段","值"}
|
||||
*/
|
||||
@Operation(summary = "管理员编辑会员信息", description = "后台管理员编辑会员信息")
|
||||
public Mono<ServerResponse> adminUpdateMemberInfo(ServerRequest request) {
|
||||
|
||||
Long adminId = authUtil.getMemberIdOrThrow(request);
|
||||
@@ -201,42 +168,35 @@ public class MemberHandler {
|
||||
long memberId = NumberUtils.toLong(memberIdStr, 0L);
|
||||
if(memberId <= 0L) throw new IllegalArgumentException("会员ID格式错误");
|
||||
|
||||
// TODO: 补充签到记录
|
||||
log.info("前台编辑会员信息, adminId: {}, memberId: {}", adminId, memberId);
|
||||
|
||||
// TODO 多表查询:会员信息、团课信息、会员卡信息
|
||||
|
||||
return ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue("成功");
|
||||
return request.bodyToMono(UpdateMemberInfoDto.class)
|
||||
.flatMap(updateDto -> memberService.adminUpdateMemberInfo(memberId, updateDto))
|
||||
.flatMap(detail -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(detail));
|
||||
}
|
||||
|
||||
/**
|
||||
* 前台搜索会员列表
|
||||
*
|
||||
* GET /api/admin/members?searchValue=手机号/姓名/会员号&filter=男/女&pageNum=1&pageSize=10
|
||||
* header: { "Authorization": "Bearer xxx" }
|
||||
*/
|
||||
@Operation(summary = "搜索会员列表", description = "后台管理员按关键词搜索会员,支持性别筛选和分页")
|
||||
public Mono<ServerResponse> searchMembers(ServerRequest request) {
|
||||
|
||||
Long adminId = authUtil.getMemberIdOrThrow(request);
|
||||
|
||||
String keyword = request.queryParam("searchValue").orElse(null);
|
||||
String filter = request.queryParam("filter").orElse(null);
|
||||
int pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1);
|
||||
int pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10);
|
||||
Integer pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1);
|
||||
Integer pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10);
|
||||
|
||||
log.info("前台搜索会员列表, adminId: {}, keyword: {}, filter: {}, pageNum: {}, pageSize: {}",
|
||||
adminId, keyword, filter, pageNum, pageSize);
|
||||
log.info("前台搜索会员列表, adminId: {}, keyword: {},pageNum: {}, pageSize: {}",
|
||||
adminId, keyword, pageNum, pageSize);
|
||||
|
||||
return memberService.searchMember(new SearchMemberDto(keyword, filter, pageNum, pageSize))
|
||||
return memberService.searchMember(new SearchMemberDto(keyword, pageNum, pageSize))
|
||||
.map(member -> {
|
||||
// 解密手机号
|
||||
if (member.getPhone() != null && !member.getPhone().isEmpty()) {
|
||||
try {
|
||||
String secretKey = wechatProperties.getPhoneEncryption().getSecretKey();
|
||||
String iv = wechatProperties.getPhoneEncryption().getIv();
|
||||
String decryptedPhone = AesUtil.decrypt(member.getPhone(), secretKey, iv);
|
||||
member.setPhone(decryptedPhone);
|
||||
String decryptedPhone = AesUtil.decrypt(member.getPhone());
|
||||
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
|
||||
} catch (Exception e) {
|
||||
log.error("手机号解密失败, memberId: {}", member.getId(), e);
|
||||
member.setPhone(null);
|
||||
@@ -249,12 +209,7 @@ public class MemberHandler {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 前台查看会员列表
|
||||
*
|
||||
* GET /api/admin/members/all?pageNum=1&pageSize=10
|
||||
* header: { "Authorization": "Bearer xxx" }
|
||||
*/
|
||||
@Operation(summary = "查看会员列表", description = "后台管理员分页查看所有会员列表")
|
||||
public Mono<ServerResponse> getAllMembers(ServerRequest request) {
|
||||
|
||||
Long adminId = authUtil.getMemberIdOrThrow(request);
|
||||
@@ -263,16 +218,15 @@ public class MemberHandler {
|
||||
int pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10);
|
||||
|
||||
log.info("前台查看会员列表, adminId: {}, pageNum: {}, pageSize: {}", adminId, pageNum, pageSize);
|
||||
// TODO: 补充签到记录
|
||||
|
||||
return memberService.findAll(pageNum, pageSize)
|
||||
.map(member -> {
|
||||
// 解密手机号
|
||||
if (member.getPhone() != null && !member.getPhone().isEmpty()) {
|
||||
try {
|
||||
String secretKey = wechatProperties.getPhoneEncryption().getSecretKey();
|
||||
String iv = wechatProperties.getPhoneEncryption().getIv();
|
||||
String decryptedPhone = AesUtil.decrypt(member.getPhone(), secretKey, iv);
|
||||
member.setPhone(decryptedPhone);
|
||||
String decryptedPhone = AesUtil.decrypt(member.getPhone());
|
||||
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
|
||||
} catch (Exception e) {
|
||||
log.error("手机号解密失败, memberId: {}", member.getId(), e);
|
||||
member.setPhone(null);
|
||||
|
||||
+6
-17
@@ -2,6 +2,8 @@ package cn.novalon.gym.manage.member.handler;
|
||||
|
||||
import cn.novalon.gym.manage.member.dto.WechatLoginDto;
|
||||
import cn.novalon.gym.manage.member.service.WechatAuthService;
|
||||
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;
|
||||
@@ -20,20 +22,13 @@ import reactor.core.publisher.Mono;
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "微信认证", description = "微信小程序登录、公众号回调等")
|
||||
public class WechatAuthHandler {
|
||||
|
||||
private final WechatAuthService wechatAuthService;
|
||||
private final WechatOfficialEventHandler wechatOfficialEventHandler;
|
||||
|
||||
/**
|
||||
* 小程序更新
|
||||
*
|
||||
* POST /api/member/auth/miniapp/login
|
||||
* Body: {"code": "wx_login_code"}
|
||||
*
|
||||
* @param request ServerRequest
|
||||
* @return Mono<ServerResponse> 登录响应
|
||||
*/
|
||||
@Operation(summary = "微信小程序登录", description = "通过微信小程序code获取session_key,完成会员登录或注册")
|
||||
public Mono<ServerResponse> miniappLogin(ServerRequest request) {
|
||||
log.info("收到小程序登录请求");
|
||||
|
||||
@@ -50,18 +45,12 @@ public class WechatAuthHandler {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 公众号回调
|
||||
*
|
||||
* POST /api/member/auth/mp/callback
|
||||
* Body: <xml><Event>subscribe</Event><FromUserName>openid</FromUserName></xml>
|
||||
*
|
||||
*/
|
||||
@Operation(summary = "微信公众号回调", description = "处理微信公众号事件(关注、取消关注等)")
|
||||
public Mono<ServerResponse> mpCallback(ServerRequest request) {
|
||||
return wechatOfficialEventHandler.handleEvent(request);
|
||||
}
|
||||
|
||||
// 验证微信公众号签名
|
||||
@Operation(summary = "验证微信公众号签名", description = "微信公众号服务器验证,返回echostr")
|
||||
public Mono<ServerResponse> verifyMpSignature(ServerRequest request) {
|
||||
return wechatOfficialEventHandler.verifySignature(request);
|
||||
}
|
||||
|
||||
-3
@@ -39,9 +39,6 @@ public class WechatOfficialEventHandler {
|
||||
return request.bodyToMono(String.class)
|
||||
.flatMap(xmlBody -> {
|
||||
log.info("收到微信公众号事件 {}", xmlBody);
|
||||
|
||||
// TODO: 将XML解析为WechatOfficialEventDto
|
||||
// 目前简化处理直接获取openId和event
|
||||
|
||||
String openId = extractOpenId(xmlBody);
|
||||
String event = extractEvent(xmlBody);
|
||||
|
||||
+40
-1
@@ -1,7 +1,9 @@
|
||||
package cn.novalon.gym.manage.member.repository;
|
||||
|
||||
import cn.novalon.gym.manage.member.entity.Member;
|
||||
import cn.novalon.gym.manage.member.vo.MemberCardInfoVO;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
@@ -30,7 +32,44 @@ public interface IMemberRepository extends R2dbcRepository<Member, Long> {
|
||||
|
||||
/**
|
||||
* 分页查询所有会员
|
||||
* 方法名 findAllBy 是 Spring Data 的约定,表示按条件查询所有
|
||||
*/
|
||||
Flux<Member> findAllBy(Pageable pageable);
|
||||
|
||||
/**
|
||||
* 查询会员的所有卡片
|
||||
*/
|
||||
@Query("SELECT " +
|
||||
" r.id, " +
|
||||
" r.member_card_record_id, " +
|
||||
" r.member_id, " +
|
||||
" r.member_card_id, " +
|
||||
" r.status, " +
|
||||
" r.remaining_times, " +
|
||||
" r.remaining_amount, " +
|
||||
" r.expire_time, " +
|
||||
" r.purchase_time, " +
|
||||
" r.source_order_id, " +
|
||||
" r.created_at, " +
|
||||
" r.updated_at, " +
|
||||
" r.version, " +
|
||||
" r.card_composition, " +
|
||||
" c.id AS card_id, " +
|
||||
" c.member_card_id, " +
|
||||
" c.member_card_name, " +
|
||||
" c.member_card_type, " +
|
||||
" c.member_card_price, " +
|
||||
" c.member_card_validity_days, " +
|
||||
" c.member_card_total_times, " +
|
||||
" c.member_card_amount, " +
|
||||
" c.member_card_status, " +
|
||||
" c.extra_config, " +
|
||||
" c.created_at AS card_created_at, " +
|
||||
" c.updated_at AS card_updated_at " +
|
||||
"FROM member_card_record r " +
|
||||
"LEFT JOIN member_card c ON r.member_card_id = c.id " +
|
||||
"WHERE r.member_id = :memberId " +
|
||||
"AND r.deleted_at IS NULL " +
|
||||
"AND c.deleted_at IS NULL " +
|
||||
"ORDER BY r.created_at DESC")
|
||||
Flux<MemberCardInfoVO> findCardRecordsWithCardInfoByMemberId(Long memberId);
|
||||
}
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 会员卡记录 Repository(会员持有的卡)
|
||||
*
|
||||
*
|
||||
* @author 付嘉
|
||||
* @date 2026-05-27
|
||||
*/
|
||||
|
||||
+18
@@ -4,6 +4,7 @@ import cn.novalon.gym.manage.member.dto.SearchMemberDto;
|
||||
import cn.novalon.gym.manage.member.dto.UpdateMemberInfoDto;
|
||||
import cn.novalon.gym.manage.member.entity.Member;
|
||||
import cn.novalon.gym.manage.member.es.entity.MemberES;
|
||||
import cn.novalon.gym.manage.member.vo.MemberDetailVO;
|
||||
import cn.novalon.gym.manage.member.vo.MemberInfoVO;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
@@ -58,4 +59,21 @@ public interface MemberService {
|
||||
* @return 所有会员信息
|
||||
*/
|
||||
Flux<Member> findAll(Integer pageNum, Integer pageSize);
|
||||
|
||||
/**
|
||||
* 前台管理端获取会员详情(含会员卡信息)
|
||||
*
|
||||
* @param memberId 会员ID
|
||||
* @return 会员详情
|
||||
*/
|
||||
Mono<MemberDetailVO> getMemberDetail(Long memberId);
|
||||
|
||||
/**
|
||||
* 前台管理端编辑会员信息
|
||||
*
|
||||
* @param memberId 会员ID
|
||||
* @param updateDto 更新信息DTO
|
||||
* @return 更新后的会员详情
|
||||
*/
|
||||
Mono<Boolean> adminUpdateMemberInfo(Long memberId, UpdateMemberInfoDto updateDto);
|
||||
}
|
||||
|
||||
+46
-5
@@ -3,6 +3,8 @@ package cn.novalon.gym.manage.member.service.impl;
|
||||
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
|
||||
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
|
||||
import cn.novalon.gym.manage.member.service.IMemberCardRecordService;
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
@@ -16,17 +18,35 @@ import java.time.LocalDateTime;
|
||||
* @author 付嘉
|
||||
* @date 2026-05-27
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class MemberCardRecordServiceImpl implements IMemberCardRecordService {
|
||||
private final MemberCardRecordRepository memberCardRecordRepository;
|
||||
private final RedisUtil redisUtil;
|
||||
|
||||
public MemberCardRecordServiceImpl(MemberCardRecordRepository memberCardRecordRepository) {
|
||||
private static final String MEMBER_CARD_RECORD_CACHE_PREFIX = "member:card:record:";
|
||||
private static final long CACHE_EXPIRE_SECONDS = 300;
|
||||
|
||||
public MemberCardRecordServiceImpl(MemberCardRecordRepository memberCardRecordRepository, RedisUtil redisUtil) {
|
||||
this.memberCardRecordRepository = memberCardRecordRepository;
|
||||
this.redisUtil = redisUtil;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<MemberCardRecord> findById(Long recordId) {
|
||||
return memberCardRecordRepository.findById(recordId);
|
||||
String cacheKey = MEMBER_CARD_RECORD_CACHE_PREFIX + recordId;
|
||||
Object cached = redisUtil.get(cacheKey);
|
||||
if (cached != null && cached instanceof MemberCardRecord) {
|
||||
log.debug("从缓存获取会员卡记录, recordId: {}", recordId);
|
||||
return Mono.just((MemberCardRecord) cached);
|
||||
}
|
||||
|
||||
return memberCardRecordRepository.findById(recordId)
|
||||
.doOnSuccess(record -> {
|
||||
if (record != null) {
|
||||
redisUtil.setWithExpire(cacheKey, record, CACHE_EXPIRE_SECONDS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -53,17 +73,32 @@ public class MemberCardRecordServiceImpl implements IMemberCardRecordService {
|
||||
|
||||
@Override
|
||||
public Mono<Integer> deductUsage(Long recordId, Integer deductTimes, Double deductAmount) {
|
||||
return memberCardRecordRepository.deductUsage(recordId, deductTimes, deductAmount);
|
||||
return memberCardRecordRepository.deductUsage(recordId, deductTimes, deductAmount)
|
||||
.doOnSuccess(updated -> {
|
||||
if (updated > 0) {
|
||||
clearRecordCache(recordId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Integer> renewCard(Long recordId, Integer addTimes, Double addAmount, LocalDateTime newExpireTime) {
|
||||
return memberCardRecordRepository.renewCard(recordId, addTimes, addAmount, newExpireTime);
|
||||
return memberCardRecordRepository.renewCard(recordId, addTimes, addAmount, newExpireTime)
|
||||
.doOnSuccess(updated -> {
|
||||
if (updated > 0) {
|
||||
clearRecordCache(recordId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Integer> updateStatus(Long recordId, String status) {
|
||||
return memberCardRecordRepository.updateStatus(recordId, status);
|
||||
return memberCardRecordRepository.updateStatus(recordId, status)
|
||||
.doOnSuccess(updated -> {
|
||||
if (updated > 0) {
|
||||
clearRecordCache(recordId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -80,4 +115,10 @@ public class MemberCardRecordServiceImpl implements IMemberCardRecordService {
|
||||
public Flux<MemberCardRecord> findExpiredCards() {
|
||||
return memberCardRecordRepository.findExpiredCards();
|
||||
}
|
||||
|
||||
private void clearRecordCache(Long recordId) {
|
||||
String cacheKey = MEMBER_CARD_RECORD_CACHE_PREFIX + recordId;
|
||||
redisUtil.delete(cacheKey);
|
||||
log.debug("清除会员卡记录缓存, recordId: {}", recordId);
|
||||
}
|
||||
}
|
||||
|
||||
+33
-3
@@ -15,6 +15,7 @@ import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
|
||||
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
|
||||
import cn.novalon.gym.manage.member.service.IMemberCardService;
|
||||
import cn.novalon.gym.manage.member.service.IMemberCardTransactionService;
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -39,6 +40,10 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
||||
private final DistributedLockService distributedLockService;
|
||||
private final ExpirationReminderService expirationReminderService;
|
||||
private final RefundSagaHandler refundSagaHandler;
|
||||
private final RedisUtil redisUtil;
|
||||
|
||||
private static final String MEMBER_CARD_CACHE_PREFIX = "member:card:";
|
||||
private static final long CACHE_EXPIRE_SECONDS = 300;
|
||||
|
||||
public MemberCardServiceImpl(MemberCardRepository memberCardRepository,
|
||||
MemberCardRecordRepository recordRepository,
|
||||
@@ -46,7 +51,8 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
||||
MemberCardStateMachine stateMachine,
|
||||
DistributedLockService distributedLockService,
|
||||
ExpirationReminderService expirationReminderService,
|
||||
RefundSagaHandler refundSagaHandler) {
|
||||
RefundSagaHandler refundSagaHandler,
|
||||
RedisUtil redisUtil) {
|
||||
this.memberCardRepository = memberCardRepository;
|
||||
this.recordRepository = recordRepository;
|
||||
this.transactionService = transactionService;
|
||||
@@ -54,11 +60,24 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
||||
this.distributedLockService = distributedLockService;
|
||||
this.expirationReminderService = expirationReminderService;
|
||||
this.refundSagaHandler = refundSagaHandler;
|
||||
this.redisUtil = redisUtil;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<MemberCard> findByMemberCardIdAndDeletedAtIsNull(Long memberCardId) {
|
||||
return memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(memberCardId);
|
||||
String cacheKey = MEMBER_CARD_CACHE_PREFIX + memberCardId;
|
||||
Object cached = redisUtil.get(cacheKey);
|
||||
if (cached != null && cached instanceof MemberCard) {
|
||||
log.debug("从缓存获取会员卡信息, memberCardId: {}", memberCardId);
|
||||
return Mono.just((MemberCard) cached);
|
||||
}
|
||||
|
||||
return memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(memberCardId)
|
||||
.doOnSuccess(card -> {
|
||||
if (card != null) {
|
||||
redisUtil.setWithExpire(cacheKey, card, CACHE_EXPIRE_SECONDS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -95,7 +114,12 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
||||
|
||||
@Override
|
||||
public Mono<MemberCard> save(MemberCard entity) {
|
||||
return memberCardRepository.save(entity);
|
||||
return memberCardRepository.save(entity)
|
||||
.doOnSuccess(saved -> {
|
||||
if (saved.getMemberCardId() != null) {
|
||||
clearCardCache(saved.getMemberCardId());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -329,4 +353,10 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
||||
|
||||
return transactionService.createTransaction(transaction);
|
||||
}
|
||||
|
||||
private void clearCardCache(Long memberCardId) {
|
||||
String cacheKey = MEMBER_CARD_CACHE_PREFIX + memberCardId;
|
||||
redisUtil.delete(cacheKey);
|
||||
log.debug("清除会员卡缓存, memberCardId: {}", memberCardId);
|
||||
}
|
||||
}
|
||||
|
||||
+161
-29
@@ -4,28 +4,35 @@ import cn.novalon.gym.manage.common.exception.ConflictException;
|
||||
import cn.novalon.gym.manage.common.exception.ErrorCode;
|
||||
import cn.novalon.gym.manage.common.exception.NotFoundException;
|
||||
import cn.novalon.gym.manage.common.exception.SystemException;
|
||||
import cn.novalon.gym.manage.member.config.WechatProperties;
|
||||
import cn.novalon.gym.manage.common.util.HtmlEscapeUtil;
|
||||
import cn.novalon.gym.manage.member.dto.SearchMemberDto;
|
||||
import cn.novalon.gym.manage.member.dto.UpdateMemberInfoDto;
|
||||
import cn.novalon.gym.manage.member.entity.Member;
|
||||
import cn.novalon.gym.manage.member.enums.GenderEnum;
|
||||
import cn.novalon.gym.manage.member.enums.MemberCardType;
|
||||
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.service.MemberService;
|
||||
import cn.novalon.gym.manage.member.util.AesUtil;
|
||||
import cn.novalon.gym.manage.member.util.BeanConvertUtil;
|
||||
import cn.novalon.gym.manage.member.util.EsSyncUtils;
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import cn.novalon.gym.manage.member.vo.MemberCardInfoVO;
|
||||
import cn.novalon.gym.manage.member.vo.MemberDetailVO;
|
||||
import cn.novalon.gym.manage.member.vo.MemberInfoVO;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 会员服务实现
|
||||
@@ -41,10 +48,14 @@ public class MemberServiceImpl implements MemberService {
|
||||
private final IMemberRepository memberRepository;
|
||||
private final MemberESRepository memberESRepository;
|
||||
private final EsSyncUtils esSyncUtils;
|
||||
private final WechatProperties wechatProperties;
|
||||
private final RedisUtil redisUtil;
|
||||
|
||||
private EsSyncUtils.EntitySyncer<Member, MemberES, String> memberSyncer;
|
||||
|
||||
private static final String MEMBER_INFO_CACHE_PREFIX = "member:info:";
|
||||
private static final String MEMBER_DETAIL_CACHE_PREFIX = "member:detail:";
|
||||
private static final long CACHE_EXPIRE_SECONDS = 300;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
this.memberSyncer = esSyncUtils.bind(Member.class, MemberES.class, memberESRepository);
|
||||
@@ -52,12 +63,23 @@ public class MemberServiceImpl implements MemberService {
|
||||
|
||||
@Override
|
||||
public Mono<MemberInfoVO> getMemberInfo(Long memberId) {
|
||||
return memberRepository.findById(memberId)
|
||||
.map(this::buildMemberInfoResponse)
|
||||
.switchIfEmpty(Mono.error(() -> {
|
||||
log.error("会员不存在: memberId={}", memberId);
|
||||
throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
|
||||
}));
|
||||
String cacheKey = MEMBER_INFO_CACHE_PREFIX + memberId;
|
||||
|
||||
return redisUtil.get(cacheKey, MemberInfoVO.class)
|
||||
.flatMap(cached -> {
|
||||
if (cached != null) {
|
||||
log.debug("从缓存获取会员信息, memberId: {}", memberId);
|
||||
return Mono.just(cached);
|
||||
}
|
||||
return memberRepository.findById(memberId)
|
||||
.map(this::buildMemberInfoResponse)
|
||||
.flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS)
|
||||
.then(Mono.just(vo)))
|
||||
.switchIfEmpty(Mono.error(() -> {
|
||||
log.error("会员不存在: memberId={}", memberId);
|
||||
throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -67,10 +89,10 @@ public class MemberServiceImpl implements MemberService {
|
||||
return memberRepository.findById(memberId)
|
||||
.flatMap(member -> {
|
||||
if (updateDto.getNickname() != null) {
|
||||
member.setNickname(updateDto.getNickname());
|
||||
member.setNickname(HtmlEscapeUtil.escape(updateDto.getNickname()));
|
||||
}
|
||||
if (updateDto.getGender() != null) {
|
||||
member.setGender(updateDto.getGender());
|
||||
member.setGender(updateDto.getGender().getCode());
|
||||
}
|
||||
if (updateDto.getBirthday() != null) {
|
||||
member.setBirthday(updateDto.getBirthday());
|
||||
@@ -79,12 +101,16 @@ public class MemberServiceImpl implements MemberService {
|
||||
member.setAvatar(updateDto.getAvatar());
|
||||
}
|
||||
if (updateDto.getAddress() != null) {
|
||||
member.setAddress(updateDto.getAddress());
|
||||
member.setAddress(HtmlEscapeUtil.escape(updateDto.getAddress()));
|
||||
}
|
||||
|
||||
return memberRepository.save(member);
|
||||
})
|
||||
.doOnSuccess(memberSyncer::sync)
|
||||
.flatMap(savedMember -> {
|
||||
memberSyncer.sync(savedMember);
|
||||
return clearMemberCache(memberId)
|
||||
.then(Mono.just(savedMember));
|
||||
})
|
||||
.map(savedMember -> {
|
||||
log.info("会员信息更新成功, memberId: {}", savedMember.getId());
|
||||
return buildMemberInfoResponse(savedMember);
|
||||
@@ -99,11 +125,14 @@ public class MemberServiceImpl implements MemberService {
|
||||
String phone = member.getPhone();
|
||||
String maskedPhone = phone != null ? phone.replace(phone.substring(3, 7), "****") : null;
|
||||
|
||||
GenderEnum genderEnum = GenderEnum.fromCode(member.getGender());
|
||||
|
||||
return MemberInfoVO.builder()
|
||||
.id(member.getId())
|
||||
.nickname(member.getNickname())
|
||||
.phone(maskedPhone)
|
||||
.gender(member.getGender())
|
||||
.gender(genderEnum)
|
||||
.genderDesc(genderEnum.getDesc())
|
||||
.birthday(member.getBirthday())
|
||||
.avatar(member.getAvatar())
|
||||
.hasPhone(phone != null)
|
||||
@@ -117,9 +146,7 @@ public class MemberServiceImpl implements MemberService {
|
||||
|
||||
String encryptedPhone;
|
||||
try {
|
||||
String secretKey = wechatProperties.getPhoneEncryption().getSecretKey();
|
||||
String iv = wechatProperties.getPhoneEncryption().getIv();
|
||||
encryptedPhone = AesUtil.encrypt(phone, secretKey, iv);
|
||||
encryptedPhone = AesUtil.encrypt(phone);
|
||||
log.info("手机号加密成功");
|
||||
} catch (Exception e) {
|
||||
log.error("手机号加密失败", e);
|
||||
@@ -145,32 +172,32 @@ public class MemberServiceImpl implements MemberService {
|
||||
|
||||
@Override
|
||||
public Flux<MemberES> searchMember(SearchMemberDto searchMemberDto) {
|
||||
log.info("搜索会员, searchValue: {}, filter: {}, pageNum: {}, pageSize: {}",
|
||||
log.info("搜索会员, searchValue: {}, pageNum: {}, pageSize: {}",
|
||||
searchMemberDto.getSearchValue(),
|
||||
searchMemberDto.getFilter(),
|
||||
searchMemberDto.getPageNum(),
|
||||
searchMemberDto.getPageSize());
|
||||
|
||||
String searchValue = searchMemberDto.getSearchValue();
|
||||
|
||||
if(searchValue != null && searchValue.matches("^1[3-9]\\d{9}$")){
|
||||
if (searchValue != null && searchValue.matches("^1[3-9]\\d{9}$")) {
|
||||
log.debug("搜索值为手机号格式,进行加密处理");
|
||||
String secretKey = wechatProperties.getPhoneEncryption().getSecretKey();
|
||||
String iv = wechatProperties.getPhoneEncryption().getIv();
|
||||
searchValue = AesUtil.encrypt(searchValue,secretKey,iv);
|
||||
searchValue = AesUtil.encrypt(searchValue);
|
||||
}
|
||||
|
||||
Pageable pageable = PageRequest.of(
|
||||
searchMemberDto.getPageNum() - 1,
|
||||
searchMemberDto.getPageSize(),
|
||||
Sort.by(Sort.Direction.DESC, "update_at")
|
||||
searchMemberDto.getPageSize()
|
||||
);
|
||||
|
||||
return memberESRepository.findByMemberNoOrPhoneOrNicknameContainingAndGender(
|
||||
if (searchValue == null) {
|
||||
log.warn("搜索值为空,返回空结果");
|
||||
return Flux.empty();
|
||||
}
|
||||
|
||||
return memberESRepository.findByMemberNoOrPhoneOrNicknameContaining(
|
||||
searchValue,
|
||||
searchValue,
|
||||
searchValue,
|
||||
searchMemberDto.getFilter() ,
|
||||
pageable
|
||||
);
|
||||
}
|
||||
@@ -187,6 +214,99 @@ public class MemberServiceImpl implements MemberService {
|
||||
return memberRepository.findAllBy(pageable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<MemberDetailVO> getMemberDetail(Long memberId) {
|
||||
log.info("查询会员详情, memberId: {}", memberId);
|
||||
|
||||
String cacheKey = MEMBER_DETAIL_CACHE_PREFIX + memberId;
|
||||
|
||||
return redisUtil.get(cacheKey, MemberDetailVO.class)
|
||||
.flatMap(cached -> {
|
||||
if (cached != null) {
|
||||
log.debug("从缓存获取会员详情, memberId: {}", memberId);
|
||||
return Mono.just(cached);
|
||||
}
|
||||
return memberRepository.findById(memberId)
|
||||
.zipWith(
|
||||
memberRepository.findCardRecordsWithCardInfoByMemberId(memberId)
|
||||
.collectList(),
|
||||
(baseInfo, cardList) -> {
|
||||
MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class);
|
||||
|
||||
GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender());
|
||||
memberDetailVO.setGenderDesc(genderEnum.getDesc());
|
||||
|
||||
List<MemberCardInfoVO> enrichedCards = cardList.stream()
|
||||
.peek(vo -> {
|
||||
if (vo.getMemberCardType() != null) {
|
||||
try {
|
||||
MemberCardType cardType = MemberCardType.valueOf(vo.getMemberCardType());
|
||||
vo.setMemberCardTypeDesc(cardType.getDesc());
|
||||
} catch (IllegalArgumentException e) {
|
||||
vo.setMemberCardTypeDesc(vo.getMemberCardType());
|
||||
}
|
||||
}
|
||||
if (vo.getMemberCardStatus() != null) {
|
||||
vo.setMemberCardStatusDesc(vo.getMemberCardStatus() == 1 ? "上架" : "下架");
|
||||
}
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
memberDetailVO.setMemberCards(enrichedCards);
|
||||
|
||||
long activeCount = enrichedCards.stream()
|
||||
.filter(card -> card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1)
|
||||
.count();
|
||||
memberDetailVO.setActiveCardCount((int) activeCount);
|
||||
memberDetailVO.setInactiveCardCount(enrichedCards.size() - (int) activeCount);
|
||||
|
||||
return memberDetailVO;
|
||||
}
|
||||
)
|
||||
.flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS)
|
||||
.then(Mono.just(vo)));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Mono<Boolean> adminUpdateMemberInfo(Long memberId, UpdateMemberInfoDto updateDto) {
|
||||
log.info("前台管理端编辑会员信息, memberId: {}", memberId);
|
||||
|
||||
return memberRepository.findById(memberId)
|
||||
.switchIfEmpty(Mono.error(() -> {
|
||||
log.error("会员不存在: memberId={}", memberId);
|
||||
throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
|
||||
}))
|
||||
.flatMap(member -> {
|
||||
if (updateDto.getNickname() != null) {
|
||||
member.setNickname(HtmlEscapeUtil.escape(updateDto.getNickname()));
|
||||
}
|
||||
if (updateDto.getGender() != null) {
|
||||
member.setGender(updateDto.getGender().getCode());
|
||||
}
|
||||
if (updateDto.getBirthday() != null) {
|
||||
member.setBirthday(updateDto.getBirthday());
|
||||
}
|
||||
if (updateDto.getAvatar() != null) {
|
||||
member.setAvatar(updateDto.getAvatar());
|
||||
}
|
||||
if (updateDto.getAddress() != null) {
|
||||
member.setAddress(HtmlEscapeUtil.escape(updateDto.getAddress()));
|
||||
}
|
||||
|
||||
return memberRepository.save(member);
|
||||
})
|
||||
.flatMap(savedMember -> {
|
||||
memberSyncer.sync(savedMember);
|
||||
return clearMemberCache(memberId)
|
||||
.then(Mono.just(true));
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.error("编辑会员信息失败, memberId: {}, error: {}", memberId, e.getMessage(), e);
|
||||
return Mono.just(false);
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Boolean> updateMemberPhone(Long memberId, String encryptedPhone) {
|
||||
return memberRepository.findById(memberId)
|
||||
.flatMap(member -> {
|
||||
@@ -194,7 +314,11 @@ public class MemberServiceImpl implements MemberService {
|
||||
member.setLastLoginAt(LocalDateTime.now());
|
||||
|
||||
return memberRepository.save(member)
|
||||
.doOnSuccess(memberSyncer::sync)
|
||||
.flatMap(savedMember -> {
|
||||
memberSyncer.sync(savedMember);
|
||||
return clearMemberCache(memberId)
|
||||
.then(Mono.just(savedMember));
|
||||
})
|
||||
.map(savedMember -> {
|
||||
log.info("手机号录入成功, memberId: {}", savedMember.getId());
|
||||
return true;
|
||||
@@ -205,4 +329,12 @@ public class MemberServiceImpl implements MemberService {
|
||||
throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private Mono<Long> clearMemberCache(Long memberId) {
|
||||
String infoCacheKey = MEMBER_INFO_CACHE_PREFIX + memberId;
|
||||
String detailCacheKey = MEMBER_DETAIL_CACHE_PREFIX + memberId;
|
||||
return redisUtil.delete(infoCacheKey)
|
||||
.then(redisUtil.delete(detailCacheKey))
|
||||
.doOnSuccess(result -> log.debug("清除会员缓存, memberId: {}", memberId));
|
||||
}
|
||||
}
|
||||
+52
-15
@@ -1,9 +1,11 @@
|
||||
package cn.novalon.gym.manage.member.service.impl;
|
||||
|
||||
import cn.novalon.gym.manage.common.util.HtmlEscapeUtil;
|
||||
import cn.novalon.gym.manage.member.entity.RefundApplication;
|
||||
import cn.novalon.gym.manage.member.enums.RefundStatus;
|
||||
import cn.novalon.gym.manage.member.repository.RefundApplicationRepository;
|
||||
import cn.novalon.gym.manage.member.service.IRefundApplicationService;
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
@@ -21,9 +23,14 @@ import java.time.LocalDateTime;
|
||||
public class RefundApplicationServiceImpl implements IRefundApplicationService {
|
||||
|
||||
private final RefundApplicationRepository refundApplicationRepository;
|
||||
private final RedisUtil redisUtil;
|
||||
|
||||
public RefundApplicationServiceImpl(RefundApplicationRepository refundApplicationRepository) {
|
||||
private static final String REFUND_APPLICATION_CACHE_PREFIX = "member:refund:";
|
||||
private static final long CACHE_EXPIRE_SECONDS = 300;
|
||||
|
||||
public RefundApplicationServiceImpl(RefundApplicationRepository refundApplicationRepository, RedisUtil redisUtil) {
|
||||
this.refundApplicationRepository = refundApplicationRepository;
|
||||
this.redisUtil = redisUtil;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -38,14 +45,16 @@ public class RefundApplicationServiceImpl implements IRefundApplicationService {
|
||||
.then(Mono.defer(() -> {
|
||||
RefundApplication application = RefundApplication.builder()
|
||||
.recordId(recordId)
|
||||
.reason(reason)
|
||||
.reason(HtmlEscapeUtil.escape(reason))
|
||||
.status(RefundStatus.PENDING)
|
||||
.applyTime(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
return refundApplicationRepository.save(application)
|
||||
.doOnSuccess(app -> log.info("创建退款申请成功: applicationId={}, recordId={}",
|
||||
app.getId(), recordId));
|
||||
.doOnSuccess(app -> {
|
||||
log.info("创建退款申请成功: applicationId={}, recordId={}", app.getId(), recordId);
|
||||
clearRefundCache(recordId);
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -54,14 +63,19 @@ public class RefundApplicationServiceImpl implements IRefundApplicationService {
|
||||
return refundApplicationRepository.findById(applicationId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("退款申请不存在")))
|
||||
.flatMap(application -> {
|
||||
if (!"PENDING".equals(application.getStatus())) {
|
||||
if (application.getStatus() != RefundStatus.PENDING) {
|
||||
return Mono.error(new RuntimeException("退款申请状态不正确,当前状态: " + application.getStatus()));
|
||||
}
|
||||
|
||||
return refundApplicationRepository.approve(applicationId, "APPROVED", auditorId, remark)
|
||||
.thenReturn(application)
|
||||
.doOnSuccess(app -> log.info("批准退款申请成功: applicationId={}, auditorId={}",
|
||||
applicationId, auditorId));
|
||||
return refundApplicationRepository.approve(applicationId, "APPROVED", auditorId, HtmlEscapeUtil.escape(remark))
|
||||
.flatMap(updatedRows -> {
|
||||
if (updatedRows == 0) {
|
||||
return Mono.error(new RuntimeException("批准退款申请失败"));
|
||||
}
|
||||
clearRefundCache(application.getRecordId());
|
||||
log.info("批准退款申请成功: applicationId={}, auditorId={}", applicationId, auditorId);
|
||||
return refundApplicationRepository.findById(applicationId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,19 +84,42 @@ public class RefundApplicationServiceImpl implements IRefundApplicationService {
|
||||
return refundApplicationRepository.findById(applicationId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("退款申请不存在")))
|
||||
.flatMap(application -> {
|
||||
if (!"PENDING".equals(application.getStatus())) {
|
||||
if (application.getStatus() != RefundStatus.PENDING) {
|
||||
return Mono.error(new RuntimeException("退款申请状态不正确,当前状态: " + application.getStatus()));
|
||||
}
|
||||
|
||||
return refundApplicationRepository.approve(applicationId, "REJECTED", auditorId, remark)
|
||||
.thenReturn(application)
|
||||
.doOnSuccess(app -> log.info("拒绝退款申请成功: applicationId={}, auditorId={}",
|
||||
applicationId, auditorId));
|
||||
return refundApplicationRepository.approve(applicationId, "REJECTED", auditorId, HtmlEscapeUtil.escape(remark))
|
||||
.flatMap(updatedRows -> {
|
||||
if (updatedRows == 0) {
|
||||
return Mono.error(new RuntimeException("拒绝退款申请失败"));
|
||||
}
|
||||
clearRefundCache(application.getRecordId());
|
||||
log.info("拒绝退款申请成功: applicationId={}, auditorId={}", applicationId, auditorId);
|
||||
return refundApplicationRepository.findById(applicationId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<RefundApplication> findByRecordId(Long recordId) {
|
||||
return refundApplicationRepository.findByRecordId(recordId);
|
||||
String cacheKey = REFUND_APPLICATION_CACHE_PREFIX + recordId;
|
||||
Object cached = redisUtil.get(cacheKey);
|
||||
if (cached != null && cached instanceof RefundApplication) {
|
||||
log.debug("从缓存获取退款申请, recordId: {}", recordId);
|
||||
return Mono.just((RefundApplication) cached);
|
||||
}
|
||||
|
||||
return refundApplicationRepository.findByRecordId(recordId)
|
||||
.doOnSuccess(application -> {
|
||||
if (application != null) {
|
||||
redisUtil.setWithExpire(cacheKey, application, CACHE_EXPIRE_SECONDS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void clearRefundCache(Long recordId) {
|
||||
String cacheKey = REFUND_APPLICATION_CACHE_PREFIX + recordId;
|
||||
redisUtil.delete(cacheKey);
|
||||
log.debug("清除退款申请缓存, recordId: {}", recordId);
|
||||
}
|
||||
}
|
||||
|
||||
+43
-27
@@ -4,6 +4,7 @@ import cn.novalon.gym.manage.common.exception.ErrorCode;
|
||||
import cn.novalon.gym.manage.common.exception.SystemException;
|
||||
import cn.novalon.gym.manage.member.config.WechatProperties;
|
||||
import cn.novalon.gym.manage.member.service.WechatApiService;
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -30,6 +31,10 @@ import java.util.Map;
|
||||
public class WechatApiServiceImpl implements WechatApiService {
|
||||
|
||||
private final WechatProperties wechatProperties;
|
||||
private final RedisUtil redisUtil;
|
||||
|
||||
private static final String ACCESS_TOKEN_CACHE_PREFIX = "wechat:access_token:";
|
||||
private static final long ACCESS_TOKEN_EXPIRE_SECONDS = 7000; // 比官方过期时间短100秒
|
||||
|
||||
private final WebClient webClient = WebClient.builder()
|
||||
.baseUrl("https://api.weixin.qq.com")
|
||||
@@ -147,35 +152,46 @@ public class WechatApiServiceImpl implements WechatApiService {
|
||||
public Mono<String> getAccessToken(String appType) {
|
||||
log.debug("获取access_token, appType: {}", appType);
|
||||
|
||||
String appId, appSecret;
|
||||
if ("miniapp".equals(appType)) {
|
||||
appId = wechatProperties.getMiniapp().getAppId();
|
||||
appSecret = wechatProperties.getMiniapp().getAppSecret();
|
||||
} else {
|
||||
appId = wechatProperties.getMp().getAppId();
|
||||
appSecret = wechatProperties.getMp().getAppSecret();
|
||||
}
|
||||
String cacheKey = ACCESS_TOKEN_CACHE_PREFIX + appType;
|
||||
|
||||
return webClient.get()
|
||||
.uri(uriBuilder -> uriBuilder
|
||||
.path("/cgi-bin/token")
|
||||
.queryParam("grant_type", "client_credential")
|
||||
.queryParam("appid", appId)
|
||||
.queryParam("secret", appSecret)
|
||||
.build())
|
||||
.retrieve()
|
||||
.bodyToMono(Map.class)
|
||||
.map(response -> {
|
||||
if (response.containsKey("access_token")) {
|
||||
String accessToken = (String) response.get("access_token");
|
||||
Integer expiresIn = (Integer) response.get("expires_in");
|
||||
log.info("获取access_token成功, expires_in: {}s", expiresIn);
|
||||
return accessToken;
|
||||
} else {
|
||||
String errmsg = (String) response.get("errmsg");
|
||||
log.error("获取access_token失败: {}", errmsg);
|
||||
throw new SystemException(ErrorCode.SYSTEM_INTERNAL_ERROR, "获取access_token失败: " + errmsg);
|
||||
return redisUtil.get(cacheKey, String.class)
|
||||
.flatMap(cachedToken -> {
|
||||
if (cachedToken != null) {
|
||||
log.debug("从缓存获取access_token, appType: {}", appType);
|
||||
return Mono.just(cachedToken);
|
||||
}
|
||||
|
||||
String appId, appSecret;
|
||||
if ("miniapp".equals(appType)) {
|
||||
appId = wechatProperties.getMiniapp().getAppId();
|
||||
appSecret = wechatProperties.getMiniapp().getAppSecret();
|
||||
} else {
|
||||
appId = wechatProperties.getMp().getAppId();
|
||||
appSecret = wechatProperties.getMp().getAppSecret();
|
||||
}
|
||||
|
||||
return webClient.get()
|
||||
.uri(uriBuilder -> uriBuilder
|
||||
.path("/cgi-bin/token")
|
||||
.queryParam("grant_type", "client_credential")
|
||||
.queryParam("appid", appId)
|
||||
.queryParam("secret", appSecret)
|
||||
.build())
|
||||
.retrieve()
|
||||
.bodyToMono(Map.class)
|
||||
.flatMap(response -> {
|
||||
if (response.containsKey("access_token")) {
|
||||
String accessToken = (String) response.get("access_token");
|
||||
Integer expiresIn = (Integer) response.get("expires_in");
|
||||
log.info("获取access_token成功, expires_in: {}s", expiresIn);
|
||||
return redisUtil.setWithExpire(cacheKey, accessToken, ACCESS_TOKEN_EXPIRE_SECONDS)
|
||||
.then(Mono.just(accessToken));
|
||||
} else {
|
||||
String errmsg = (String) response.get("errmsg");
|
||||
log.error("获取access_token失败: {}", errmsg);
|
||||
throw new SystemException(ErrorCode.SYSTEM_INTERNAL_ERROR, "获取access_token失败: " + errmsg);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+33
-14
@@ -4,7 +4,6 @@ import cn.novalon.gym.manage.common.exception.ConflictException;
|
||||
import cn.novalon.gym.manage.common.exception.ErrorCode;
|
||||
import cn.novalon.gym.manage.common.exception.NotFoundException;
|
||||
import cn.novalon.gym.manage.common.exception.SystemException;
|
||||
import cn.novalon.gym.manage.member.config.WechatProperties;
|
||||
import cn.novalon.gym.manage.member.dto.WechatLoginDto;
|
||||
import cn.novalon.gym.manage.member.entity.Member;
|
||||
import cn.novalon.gym.manage.member.es.entity.MemberES;
|
||||
@@ -15,6 +14,7 @@ import cn.novalon.gym.manage.member.service.WechatAuthService;
|
||||
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.common.util.RedisUtil;
|
||||
import cn.novalon.gym.manage.member.util.WechatPhoneUtil;
|
||||
import cn.novalon.gym.manage.member.vo.WechatLoginVO;
|
||||
import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
|
||||
@@ -42,14 +42,17 @@ public class WechatAuthServiceImpl implements WechatAuthService {
|
||||
|
||||
private final WechatApiService wechatApiService;
|
||||
private final IMemberRepository memberRepository;
|
||||
private final WechatProperties wechatProperties;
|
||||
private final WechatPhoneUtil wechatPhoneUtil;
|
||||
private final MemberESRepository memberESRepository;
|
||||
private final EsSyncUtils esSyncUtils;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final RedisUtil redisUtil;
|
||||
|
||||
private EsSyncUtils.EntitySyncer<Member, MemberES, String> memberSyncer;
|
||||
|
||||
private static final String MEMBER_INFO_CACHE_PREFIX = "member:info:";
|
||||
private static final long CACHE_EXPIRE_SECONDS = 300;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
this.memberSyncer = esSyncUtils.bind(Member.class, MemberES.class, memberESRepository);
|
||||
@@ -79,7 +82,10 @@ public class WechatAuthServiceImpl implements WechatAuthService {
|
||||
member.setLastLoginAt(LocalDateTime.now());
|
||||
|
||||
return memberRepository.save(member)
|
||||
.doOnSuccess(memberSyncer::sync)
|
||||
.doOnSuccess(saved -> {
|
||||
memberSyncer.sync(saved);
|
||||
clearMemberCache(saved.getId());
|
||||
})
|
||||
.flatMap(savedMember -> {
|
||||
WechatLoginVO response = buildLoginResponse(savedMember, false, sessionKey);
|
||||
return Mono.just(response);
|
||||
@@ -89,7 +95,10 @@ public class WechatAuthServiceImpl implements WechatAuthService {
|
||||
member.setLastLoginAt(LocalDateTime.now());
|
||||
|
||||
return memberRepository.save(member)
|
||||
.doOnSuccess(memberSyncer::sync)
|
||||
.doOnSuccess(saved -> {
|
||||
memberSyncer.sync(saved);
|
||||
clearMemberCache(saved.getId());
|
||||
})
|
||||
.flatMap(savedMember -> {
|
||||
WechatLoginVO response = buildLoginResponse(savedMember, false, sessionKey);
|
||||
return Mono.just(response);
|
||||
@@ -105,7 +114,10 @@ public class WechatAuthServiceImpl implements WechatAuthService {
|
||||
member.setLastLoginAt(LocalDateTime.now());
|
||||
|
||||
return memberRepository.save(member)
|
||||
.doOnSuccess(memberSyncer::sync)
|
||||
.doOnSuccess(saved -> {
|
||||
memberSyncer.sync(saved);
|
||||
clearMemberCache(saved.getId());
|
||||
})
|
||||
.flatMap(savedMember -> {
|
||||
WechatLoginVO response = buildLoginResponse(savedMember, false, sessionKey);
|
||||
return Mono.just(response);
|
||||
@@ -124,7 +136,10 @@ public class WechatAuthServiceImpl implements WechatAuthService {
|
||||
member.setLastLoginAt(LocalDateTime.now());
|
||||
|
||||
return memberRepository.save(member)
|
||||
.doOnSuccess(memberSyncer::sync)
|
||||
.doOnSuccess(saved -> {
|
||||
memberSyncer.sync(saved);
|
||||
clearMemberCache(saved.getId());
|
||||
})
|
||||
.flatMap(savedMember -> {
|
||||
WechatLoginVO response = buildLoginResponse(savedMember, false, sessionKey);
|
||||
return Mono.just(response);
|
||||
@@ -185,7 +200,10 @@ public class WechatAuthServiceImpl implements WechatAuthService {
|
||||
member.setPhone(encryptedPhone);
|
||||
member.setLastLoginAt(LocalDateTime.now());
|
||||
return memberRepository.save(member)
|
||||
.doOnSuccess(memberSyncer::sync)
|
||||
.doOnSuccess(saved -> {
|
||||
memberSyncer.sync(saved);
|
||||
clearMemberCache(saved.getId());
|
||||
})
|
||||
.map(savedMember -> {
|
||||
log.info("更新会员手机号成功, memberId: {}", savedMember.getId());
|
||||
return true;
|
||||
@@ -197,12 +215,15 @@ public class WechatAuthServiceImpl implements WechatAuthService {
|
||||
}));
|
||||
}
|
||||
|
||||
private void clearMemberCache(Long memberId) {
|
||||
String cacheKey = MEMBER_INFO_CACHE_PREFIX + memberId;
|
||||
redisUtil.delete(cacheKey);
|
||||
log.debug("清除会员缓存, memberId: {}", memberId);
|
||||
}
|
||||
|
||||
private String encryptPhone(String phoneNumber) {
|
||||
try {
|
||||
String secretKey = wechatProperties.getPhoneEncryption().getSecretKey();
|
||||
String iv = wechatProperties.getPhoneEncryption().getIv();
|
||||
|
||||
String encryptedPhone = AesUtil.encrypt(phoneNumber, secretKey, iv);
|
||||
String encryptedPhone = AesUtil.encrypt(phoneNumber);
|
||||
|
||||
log.debug("手机号加密成功");
|
||||
return encryptedPhone;
|
||||
@@ -214,10 +235,8 @@ public class WechatAuthServiceImpl implements WechatAuthService {
|
||||
|
||||
public String decryptPhone(String encryptedPhone) {
|
||||
try {
|
||||
String secretKey = wechatProperties.getPhoneEncryption().getSecretKey();
|
||||
String iv = wechatProperties.getPhoneEncryption().getIv();
|
||||
|
||||
String phoneNumber = AesUtil.decrypt(encryptedPhone, secretKey, iv);
|
||||
String phoneNumber = AesUtil.decrypt(encryptedPhone);
|
||||
|
||||
log.debug("手机号解密成功");
|
||||
return phoneNumber;
|
||||
|
||||
+71
-30
@@ -1,5 +1,6 @@
|
||||
package cn.novalon.gym.manage.member.service.impl;
|
||||
|
||||
import cn.novalon.gym.manage.common.util.HtmlEscapeUtil;
|
||||
import cn.novalon.gym.manage.member.config.WechatProperties;
|
||||
import cn.novalon.gym.manage.member.entity.Member;
|
||||
import cn.novalon.gym.manage.member.es.entity.MemberES;
|
||||
@@ -7,6 +8,7 @@ import cn.novalon.gym.manage.member.es.repository.MemberESRepository;
|
||||
import cn.novalon.gym.manage.member.repository.IMemberRepository;
|
||||
import cn.novalon.gym.manage.member.service.WechatOfficialService;
|
||||
import cn.novalon.gym.manage.member.util.EsSyncUtils;
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import cn.novalon.gym.manage.member.vo.WechatUserInfoVO;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -40,11 +42,16 @@ public class WechatOfficialServiceImpl implements WechatOfficialService {
|
||||
private final MemberESRepository memberESRepository;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
private final EsSyncUtils esSyncUtils;
|
||||
private final RedisUtil redisUtil;
|
||||
|
||||
private EsSyncUtils.EntitySyncer<Member, MemberES, String> memberSyncer;
|
||||
|
||||
private static final String ACCESS_TOKEN_CACHE_PREFIX = "wechat:access_token:";
|
||||
private static final String MEMBER_INFO_CACHE_PREFIX = "member:info:";
|
||||
private static final long ACCESS_TOKEN_EXPIRE_SECONDS = 7000;
|
||||
private static final long CACHE_EXPIRE_SECONDS = 300;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
this.memberSyncer = esSyncUtils.bind(Member.class, MemberES.class, memberESRepository);
|
||||
@@ -74,24 +81,30 @@ public class WechatOfficialServiceImpl implements WechatOfficialService {
|
||||
existingMember.setOfficialOpenId(openId);
|
||||
|
||||
if (existingMember.getNickname() == null || existingMember.getNickname().isEmpty()) {
|
||||
existingMember.setNickname(userInfo.getNickname());
|
||||
existingMember.setNickname(HtmlEscapeUtil.escape(userInfo.getNickname()));
|
||||
}
|
||||
|
||||
if (existingMember.getAvatar() == null || existingMember.getAvatar().isEmpty()) {
|
||||
existingMember.setAvatar(userInfo.getHeadimgurl());
|
||||
existingMember.setAvatar(HtmlEscapeUtil.escape(userInfo.getHeadimgurl()));
|
||||
}
|
||||
|
||||
return memberRepository.save(existingMember)
|
||||
.doOnSuccess(memberSyncer::sync)
|
||||
.then(sendWelcomeMessage(openId));
|
||||
.flatMap(saved -> {
|
||||
memberSyncer.sync(saved);
|
||||
return clearMemberCache(saved.getId())
|
||||
.then(sendWelcomeMessage(openId));
|
||||
});
|
||||
} else {
|
||||
log.info("老用户关注服务号: memberId={}", existingMember.getId());
|
||||
existingMember.setSubscribed(true);
|
||||
existingMember.setLastLoginAt(LocalDateTime.now());
|
||||
|
||||
return memberRepository.save(existingMember)
|
||||
.doOnSuccess(memberSyncer::sync)
|
||||
.then(sendWelcomeMessage(openId));
|
||||
.flatMap(saved -> {
|
||||
memberSyncer.sync(saved);
|
||||
return clearMemberCache(saved.getId())
|
||||
.then(sendWelcomeMessage(openId));
|
||||
});
|
||||
}
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
@@ -104,7 +117,10 @@ public class WechatOfficialServiceImpl implements WechatOfficialService {
|
||||
existingMember.setLastLoginAt(LocalDateTime.now());
|
||||
|
||||
return memberRepository.save(existingMember)
|
||||
.doOnSuccess(memberSyncer::sync)
|
||||
.doOnSuccess(saved -> {
|
||||
memberSyncer.sync(saved);
|
||||
clearMemberCache(saved.getId());
|
||||
})
|
||||
.then(sendWelcomeMessage(openId));
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
@@ -122,7 +138,10 @@ public class WechatOfficialServiceImpl implements WechatOfficialService {
|
||||
existingMember.setLastLoginAt(LocalDateTime.now());
|
||||
|
||||
return memberRepository.save(existingMember)
|
||||
.doOnSuccess(memberSyncer::sync)
|
||||
.doOnSuccess(saved -> {
|
||||
memberSyncer.sync(saved);
|
||||
clearMemberCache(saved.getId());
|
||||
})
|
||||
.then(sendWelcomeMessage(openId));
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
@@ -148,8 +167,10 @@ public class WechatOfficialServiceImpl implements WechatOfficialService {
|
||||
member.setSubscribed(false);
|
||||
member.setLastLoginAt(LocalDateTime.now());
|
||||
return memberRepository.save(member)
|
||||
.doOnSuccess(memberSyncer::sync)
|
||||
.then();
|
||||
.flatMap(saved -> {
|
||||
memberSyncer.sync(saved);
|
||||
return clearMemberCache(saved.getId()).then();
|
||||
});
|
||||
})
|
||||
.then()
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
@@ -213,7 +234,11 @@ public class WechatOfficialServiceImpl implements WechatOfficialService {
|
||||
member.setOfficialOpenId(officialOpenId);
|
||||
}
|
||||
return memberRepository.save(member)
|
||||
.doOnSuccess(memberSyncer::sync)
|
||||
.flatMap(saved -> {
|
||||
memberSyncer.sync(saved);
|
||||
return clearMemberCache(saved.getId())
|
||||
.then(Mono.just(saved));
|
||||
})
|
||||
.map(savedMember -> {
|
||||
log.info("关联成功, memberId: {}", savedMember.getId());
|
||||
return true;
|
||||
@@ -268,28 +293,38 @@ public class WechatOfficialServiceImpl implements WechatOfficialService {
|
||||
|
||||
/**
|
||||
* 获取微信AccessToken
|
||||
*
|
||||
* TODO: 应该使用缓存,避免频繁请求
|
||||
*/
|
||||
private Mono<String> getAccessToken() {
|
||||
String appId = wechatProperties.getMp().getAppId();
|
||||
String appSecret = wechatProperties.getMp().getAppSecret();
|
||||
String cacheKey = ACCESS_TOKEN_CACHE_PREFIX + "mp";
|
||||
|
||||
String url = "https://api.weixin.qq.com/cgi-bin/token"
|
||||
+ "?grant_type=client_credential"
|
||||
+ "&appid=" + appId
|
||||
+ "&secret=" + appSecret;
|
||||
|
||||
return webClient.get()
|
||||
.uri(url)
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.retrieve()
|
||||
.bodyToMono(Map.class)
|
||||
.map(response -> {
|
||||
if (response.containsKey("errcode")) {
|
||||
throw new RuntimeException("获取AccessToken失败: " + response.get("errmsg"));
|
||||
return redisUtil.get(cacheKey, String.class)
|
||||
.flatMap(cachedToken -> {
|
||||
if (cachedToken != null) {
|
||||
log.debug("从缓存获取服务号access_token");
|
||||
return Mono.just(cachedToken);
|
||||
}
|
||||
return (String) response.get("access_token");
|
||||
|
||||
String appId = wechatProperties.getMp().getAppId();
|
||||
String appSecret = wechatProperties.getMp().getAppSecret();
|
||||
|
||||
String url = "https://api.weixin.qq.com/cgi-bin/token"
|
||||
+ "?grant_type=client_credential"
|
||||
+ "&appid=" + appId
|
||||
+ "&secret=" + appSecret;
|
||||
|
||||
return webClient.get()
|
||||
.uri(url)
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.retrieve()
|
||||
.bodyToMono(Map.class)
|
||||
.flatMap(response -> {
|
||||
if (response.containsKey("errcode")) {
|
||||
throw new RuntimeException("获取AccessToken失败: " + response.get("errmsg"));
|
||||
}
|
||||
String accessToken = (String) response.get("access_token");
|
||||
return redisUtil.setWithExpire(cacheKey, accessToken, ACCESS_TOKEN_EXPIRE_SECONDS)
|
||||
.then(Mono.just(accessToken));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -335,4 +370,10 @@ public class WechatOfficialServiceImpl implements WechatOfficialService {
|
||||
return Mono.empty(); // 即使发送失败也不影响主流程
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Long> clearMemberCache(Long memberId) {
|
||||
String cacheKey = MEMBER_INFO_CACHE_PREFIX + memberId;
|
||||
return redisUtil.delete(cacheKey)
|
||||
.doOnSuccess(result -> log.debug("清除会员缓存, memberId: {}", memberId));
|
||||
}
|
||||
}
|
||||
|
||||
+31
-10
@@ -1,12 +1,24 @@
|
||||
package cn.novalon.gym.manage.member.util;
|
||||
|
||||
import cn.novalon.gym.manage.member.config.WechatProperties;
|
||||
import co.elastic.clients.elasticsearch.ElasticsearchClient;
|
||||
import co.elastic.clients.elasticsearch.core.IndexResponse;
|
||||
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
|
||||
import co.elastic.clients.transport.rest_client.RestClientTransport;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.http.HttpHost;
|
||||
import org.checkerframework.checker.units.qual.K;
|
||||
import org.elasticsearch.client.RestClient;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AES加密工具类
|
||||
@@ -16,24 +28,35 @@ import java.util.Base64;
|
||||
*/
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AesUtil {
|
||||
|
||||
private static final String ALGORITHM = "AES";
|
||||
private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding";
|
||||
|
||||
private static String KEY;
|
||||
private static String IV;
|
||||
|
||||
@Autowired
|
||||
public void setWechatProperties(WechatProperties props) {
|
||||
KEY = props.getPhoneEncryption().getSecretKey(); // 从配置类读取
|
||||
IV = props.getPhoneEncryption().getIv();
|
||||
if(KEY == null || IV == null) throw new RuntimeException("请配置AES密钥和偏移量");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* AES 解密
|
||||
*
|
||||
* @param encryptedData 加密数据,Base64编码
|
||||
* @param key AES密钥,Base64编码(32字节)
|
||||
* @param iv 初始化向量IV,Base64编码(16字节)
|
||||
* @return 解密后的字符串
|
||||
*/
|
||||
public static String decrypt(String encryptedData, String key, String iv) {
|
||||
public static String decrypt(String encryptedData) {
|
||||
|
||||
try {
|
||||
byte[] dataByte = Base64.getDecoder().decode(encryptedData);
|
||||
byte[] keyByte = Base64.getDecoder().decode(key);
|
||||
byte[] ivByte = Base64.getDecoder().decode(iv);
|
||||
byte[] keyByte = Base64.getDecoder().decode(KEY);
|
||||
byte[] ivByte = Base64.getDecoder().decode(IV);
|
||||
|
||||
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||
SecretKeySpec secretKeySpec = new SecretKeySpec(keyByte, ALGORITHM);
|
||||
@@ -52,15 +75,13 @@ public class AesUtil {
|
||||
* AES 加密
|
||||
*
|
||||
* @param data 原始数据
|
||||
* @param key AES密钥,Base64编码(32字节)
|
||||
* @param iv 初始化向量IV,Base64编码(16字节)
|
||||
* @return Base64编码的加密数据
|
||||
*/
|
||||
public static String encrypt(String data, String key, String iv) {
|
||||
public static String encrypt(String data) {
|
||||
try {
|
||||
byte[] dataByte = data.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] keyByte = Base64.getDecoder().decode(key);
|
||||
byte[] ivByte = Base64.getDecoder().decode(iv);
|
||||
byte[] keyByte = Base64.getDecoder().decode(KEY);
|
||||
byte[] ivByte = Base64.getDecoder().decode(IV);
|
||||
|
||||
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||
SecretKeySpec secretKeySpec = new SecretKeySpec(keyByte, ALGORITHM);
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ public class WechatPhoneUtil {
|
||||
* @param phone 手机号
|
||||
* @return 脱敏后的手机号,如:138****8000
|
||||
*/
|
||||
private String maskPhone(String phone) {
|
||||
public static String maskPhone(String phone) {
|
||||
if (phone == null || phone.length() < 7) {
|
||||
return "***";
|
||||
}
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package cn.novalon.gym.manage.member.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 会员卡类型响应 VO
|
||||
*
|
||||
* @author 付嘉
|
||||
* @date 2026-05-27
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MemberCardInfoVO {
|
||||
|
||||
/**
|
||||
* 主键ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 会员卡ID
|
||||
*/
|
||||
private Long memberCardId;
|
||||
|
||||
/**
|
||||
* 会员卡名称
|
||||
*/
|
||||
private String memberCardName;
|
||||
|
||||
/**
|
||||
* 会员卡类型:TIME_CARD-时长卡, COUNT_CARD-次卡, STORED_VALUE_CARD-储值卡
|
||||
*/
|
||||
private String memberCardType;
|
||||
|
||||
/**
|
||||
* 卡类型描述
|
||||
*/
|
||||
private String memberCardTypeDesc;
|
||||
|
||||
/**
|
||||
* 会员卡价格
|
||||
*/
|
||||
private BigDecimal memberCardPrice;
|
||||
|
||||
/**
|
||||
* 有效天数(时长卡用)
|
||||
*/
|
||||
private Integer memberCardValidityDays;
|
||||
|
||||
/**
|
||||
* 总次数(次卡用)
|
||||
*/
|
||||
private Integer memberCardTotalTimes;
|
||||
|
||||
/**
|
||||
* 面额(储值卡用)
|
||||
*/
|
||||
private BigDecimal memberCardAmount;
|
||||
|
||||
/**
|
||||
* 状态:0-下架, 1-上架
|
||||
*/
|
||||
private Integer memberCardStatus;
|
||||
|
||||
/**
|
||||
* 状态描述
|
||||
*/
|
||||
private String memberCardStatusDesc;
|
||||
|
||||
/**
|
||||
* 扩展配置(JSON格式)
|
||||
*/
|
||||
private String extraConfig;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+2
-2
@@ -9,7 +9,7 @@ import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 会员卡记录响应 VO
|
||||
*
|
||||
*
|
||||
* @author 付嘉
|
||||
* @date 2026-05-27
|
||||
*/
|
||||
@@ -68,4 +68,4 @@ public class MemberCardRecordVO {
|
||||
* 创建时间
|
||||
*/
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package cn.novalon.gym.manage.member.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 会员详情 VO(管理端使用)
|
||||
*
|
||||
* @author 付嘉
|
||||
* @date 2026-05-27
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MemberDetailVO {
|
||||
|
||||
// ==================== 会员基础信息 ====================
|
||||
|
||||
/**
|
||||
* 会员ID
|
||||
*/
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 会员编号
|
||||
*/
|
||||
private String memberNo;
|
||||
|
||||
/**
|
||||
* 昵称
|
||||
*/
|
||||
private String nickname;
|
||||
|
||||
/**
|
||||
* 手机号(脱敏显示)
|
||||
*/
|
||||
private String phone;
|
||||
|
||||
/**
|
||||
* 性别描述
|
||||
*/
|
||||
private String genderDesc;
|
||||
|
||||
/**
|
||||
* 生日
|
||||
*/
|
||||
private Date birthday;
|
||||
|
||||
/**
|
||||
* 地址
|
||||
*/
|
||||
private String address;
|
||||
|
||||
/**
|
||||
* 头像URL
|
||||
*/
|
||||
private String avatar;
|
||||
|
||||
/**
|
||||
* 是否关注服务号
|
||||
*/
|
||||
private Boolean subscribed;
|
||||
|
||||
/**
|
||||
* 最后登录时间
|
||||
*/
|
||||
private LocalDateTime lastLoginAt;
|
||||
|
||||
/**
|
||||
* 注册时间
|
||||
*/
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
// ==================== 会员卡信息 ====================
|
||||
|
||||
/**
|
||||
* 会员持有的卡列表
|
||||
*/
|
||||
private List<MemberCardInfoVO> memberCards;
|
||||
|
||||
/**
|
||||
* 有效会员卡数量
|
||||
*/
|
||||
private Integer activeCardCount;
|
||||
|
||||
/**
|
||||
* 过期/用完会员卡数量
|
||||
*/
|
||||
private Integer inactiveCardCount;
|
||||
}
|
||||
+9
-4
@@ -1,15 +1,17 @@
|
||||
package cn.novalon.gym.manage.member.vo;
|
||||
|
||||
import cn.novalon.gym.manage.member.enums.GenderEnum;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 会员信息 VO
|
||||
*
|
||||
*
|
||||
* @author 付嘉
|
||||
* @date 2026-05-01
|
||||
*/
|
||||
@@ -30,10 +32,13 @@ public class MemberInfoVO {
|
||||
private String phone;
|
||||
|
||||
// 性别
|
||||
private Integer gender;
|
||||
private GenderEnum gender;
|
||||
|
||||
// 性别描述
|
||||
private String genderDesc;
|
||||
|
||||
// 生日
|
||||
private Date birthday;
|
||||
private LocalDate birthday;
|
||||
|
||||
// 头像
|
||||
private String avatar;
|
||||
@@ -43,4 +48,4 @@ public class MemberInfoVO {
|
||||
|
||||
// 是否已关注公众号
|
||||
private Boolean isSubscribed;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS member_user (
|
||||
nickname VARCHAR(100), -- 昵称
|
||||
phone VARCHAR(255), -- 手机号(AES加密存储)
|
||||
gender INTEGER DEFAULT 0, -- 性别:0-未知,1-男,2-女
|
||||
birthday TIMESTAMP, -- 生日
|
||||
birthday DATE, -- 生日
|
||||
address VARCHAR(500), -- 地址
|
||||
avatar VARCHAR(500), -- 头像URL
|
||||
subscribed BOOLEAN DEFAULT FALSE, -- 是否关注服务号
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# 微信配置(测试环境使用模拟数据)
|
||||
wechat:
|
||||
# Mock模式:true=使用模拟数据(开发测试),false=调用真实微信API(生产环境)
|
||||
mock-enabled: false
|
||||
miniapp:
|
||||
app-id: wx4d480112b426100b
|
||||
app-secret: 78548f0c0ff66c73d3e8b071897eb1e5
|
||||
mp:
|
||||
app-id: wx6f138c9aacc8a0e8
|
||||
app-secret: 5df2e315e9268e96a43bb2cce1d2270b
|
||||
token: test_token
|
||||
aes-key: ${WECHAT_MP_AESKEY:test_aes_key}
|
||||
# 服务器回调地址(微信服务器推送事件的URL)
|
||||
callback-url: https://1me240209tk74.vicp.fun/api/member/auth/mp/callback
|
||||
# 手机号加密配置
|
||||
phone-encryption:
|
||||
secret-key: nVnA99iBfyK0IE6SkcUYdVAaVrezyn2sLRdLfkIyWnY=
|
||||
iv: LMpG6Ih9mmfEAALOCeIJBw==
|
||||
|
||||
spring:
|
||||
elasticsearch:
|
||||
uris: http://localhost:9200 # ES 服务器地址(支持多个,逗号分隔)
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user