Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b60b3b4da | |||
| df0e68469b | |||
| 97a5eff678 | |||
| feaf014b4a | |||
| ff899f491e | |||
| 922c5850b5 | |||
| 2e7c2ced43 | |||
| a9ccdab421 | |||
| 7e45ecd144 | |||
| cacc8997ec | |||
| 593f6f13e7 | |||
| 8ee326b43a | |||
| 8fb4e714d8 | |||
| a8e69e0850 | |||
| f5b5724e92 | |||
| 1e41f31271 | |||
| efd4d03037 | |||
| 80759f6793 | |||
| f8279129be | |||
| fc48db071e | |||
| 35e7532f1b | |||
| 0d143be7b3 | |||
| 2ffd1aa7d6 | |||
| 581cc995c6 | |||
| 2251f31524 | |||
| 4481819eba | |||
| 79eb07599e | |||
| a7f31083cf | |||
| 524182c478 |
@@ -0,0 +1,318 @@
|
||||
# AGENT.md — 健身房管理系统 AI 协作工作流
|
||||
|
||||
## 概述
|
||||
|
||||
本项目采用"需求驱动开发"(Requirement-Driven Development)工作流,通过以下四个核心命令完成从模糊需求到可运行代码的全流程。遇到复杂 Bug 时可随时调用 `/systemic-debugging` 进行系统化诊断。
|
||||
|
||||
---
|
||||
|
||||
## 项目技术栈与模块结构
|
||||
|
||||
### 技术栈总览
|
||||
|
||||
| 层 | 技术栈 | 目录 | 测试框架 |
|
||||
|---|---|---|---|
|
||||
| 后端 API | Java Spring Boot(Maven 多模块) | `gym-manage-api/` | JUnit + Spring Boot Test |
|
||||
| 管理后台 Web | Vue.js | `gym-manage-web/` | Playwright E2E |
|
||||
| 会员小程序 | UniApp | `gym-manage-uniapp/` | — |
|
||||
| 端到端测试 | Playwright | `e2e-tests/` | Playwright |
|
||||
|
||||
### 后端模块映射
|
||||
|
||||
| 模块 | 目录 | 职责 |
|
||||
|---|---|---|
|
||||
| manage-app | `gym-manage-api/manage-app/` | 应用启动入口、配置中心 |
|
||||
| manage-gateway | `gym-manage-api/manage-gateway/` | API 网关、路由转发 |
|
||||
| manage-common | `gym-manage-api/manage-common/` | 公共工具、通用组件 |
|
||||
| manage-db | `gym-manage-api/manage-db/` | 数据库迁移(Flyway)、基础实体 |
|
||||
| manage-sys | `gym-manage-api/manage-sys/` | 系统管理(用户/角色/权限/菜单/字典/文件/通知/日志) |
|
||||
| manage-audit | `gym-manage-api/manage-audit/` | 审计日志 |
|
||||
| manage-file | `gym-manage-api/manage-file/` | 文件管理 |
|
||||
| manage-notify | `gym-manage-api/manage-notify/` | 消息通知 |
|
||||
| gym-auth | `gym-manage-api/gym-auth/` | 认证授权、JWT |
|
||||
| gym-member | `gym-manage-api/gym-member/` | 会员管理(注册/信息/会员卡) |
|
||||
| gym-groupCourse | `gym-manage-api/gym-groupCourse/` | 团课管理、团课预约 |
|
||||
| gym-checkIn | `gym-manage-api/gym-checkIn/` | 签到管理 |
|
||||
| gym-dataCount | `gym-manage-api/gym-dataCount/` | 数据统计 |
|
||||
| gym-payment | `gym-manage-api/gym-payment/` | 支付集成、支付对账 |
|
||||
|
||||
### 涉及模块的判断原则
|
||||
|
||||
在 `/to-issues` 和 `/test-driven-development` 阶段,根据功能需求确定涉及的模块。例如:
|
||||
- "会员卡购买" → `gym-member` + `gym-payment` + `gym-manage-web` + `gym-manage-uniapp`
|
||||
- "团课预约" → `gym-groupCourse` + `gym-member` + `gym-manage-uniapp`
|
||||
- "系统管理"类功能 → `manage-sys` + `gym-manage-web`
|
||||
|
||||
---
|
||||
|
||||
## 工作流总览
|
||||
|
||||
```
|
||||
/grill-with-docs → /to-prd → /to-issues → /test-driven-development
|
||||
↓ ↓ ↓ ↓
|
||||
需求澄清 结构化PRD 任务拆解 TDD实现
|
||||
+ 领域建模
|
||||
(CONTEXT.md)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 0. /domain-modeling — 领域建模(贯穿全流程)
|
||||
|
||||
**目标**:建立并维护项目的统一语言(Ubiquitous Language),确保需求、设计、代码中术语一致。
|
||||
|
||||
### 核心产出
|
||||
- 项目根目录的 [CONTEXT.md](./CONTEXT.md) — 术语表,不含任何实现细节,纯粹的概念定义和关系。
|
||||
|
||||
### 执行时机
|
||||
- **在 `/grill-with-docs` 阶段**:识别新术语,与用户确认定义,写入 `CONTEXT.md`。
|
||||
- **在 `/to-prd` 阶段**:引用 `CONTEXT.md` 中的术语,保持 PRD 术语一致。
|
||||
- **在 `/to-issues` 阶段**:确保每个 Issue 使用的术语与 `CONTEXT.md` 对齐。
|
||||
- **在 `/test-driven-development` 阶段**:代码中的类名、方法名、变量名需反映领域术语。
|
||||
|
||||
### 领域术语规则
|
||||
1. **挑战模糊语言**:当用户使用模糊或过载的术语时,提出精确的规范术语。例如:"你说的'账户'是指会员(Member)还是用户(User)?它们是不同的概念。"
|
||||
2. **冲突检测**:当用户使用的术语与 `CONTEXT.md` 中已定义的不同时,立即指出:"你的术语表将'取消'定义为 X,但你现在似乎指 Y — 是哪个?"
|
||||
3. **场景验证**:用具体的边界场景压力测试领域关系。例如:"如果会员卡已过期,预约还能取消吗?"
|
||||
4. **代码交叉验证**:当需要确认某个术语的实际语义时,查阅现有代码实现,如有矛盾及时指出。
|
||||
|
||||
### 更新原则
|
||||
- 术语一经确认,立即更新 `CONTEXT.md`,不要批量处理。
|
||||
- 只在有实际产出时才创建或修改 `CONTEXT.md`。
|
||||
|
||||
---
|
||||
|
||||
## 1. /grill-with-docs — 需求澄清与领域建模
|
||||
|
||||
**目标**:通过结构化问答 + 领域建模,将模糊需求转化为清晰、文档化的共识。
|
||||
|
||||
### 触发条件
|
||||
- 用户提出新功能需求但描述不完整
|
||||
- 需求存在歧义或矛盾
|
||||
- 需要明确验收标准
|
||||
|
||||
### 执行流程
|
||||
|
||||
1. **收集原始需求**:理解用户意图,记录关键信息。
|
||||
2. **启动领域建模**:识别需求中涉及的领域概念,对照 [CONTEXT.md](./CONTEXT.md) 检查术语一致性,新术语与用户确认后写入。
|
||||
3. **识别模糊点**:主动指出需求中的歧义、缺失、矛盾之处。
|
||||
4. **逐轮问答**:每次只问 1-2 个关键问题,避免信息过载。使用 `AskUserQuestion` 工具。
|
||||
5. **生成共识文档**:将确认后的需求写入 `docs/superpowers/plans/` 目录,文件名格式为 `{YYYY-MM-DD}-{功能名称}-grill.md`,内容包括:
|
||||
- 功能概述
|
||||
- 用户故事
|
||||
- 业务流程(Mermaid 流程图)
|
||||
- 验收标准
|
||||
- 涉及的领域概念(引用 `CONTEXT.md`)
|
||||
- 非功能需求
|
||||
- 待确认项(如有)
|
||||
|
||||
### 规则
|
||||
- 不要在分歧未解决前进入下一阶段。
|
||||
- 所有关键决策需用户确认。
|
||||
- 文档使用中文,保持与项目已有文档风格一致。
|
||||
- 新术语必须写入 `CONTEXT.md` 后再用于文档。
|
||||
|
||||
---
|
||||
|
||||
## 2. /to-prd — 生成产品需求文档
|
||||
|
||||
**目标**:将澄清后的需求转化为结构化的 PRD。
|
||||
|
||||
### 输入
|
||||
- `/grill-with-docs` 生成的共识文档(`docs/superpowers/plans/{日期}-{功能名称}-grill.md`)
|
||||
- 项目的功能清单文档([基础版功能清单.md](./基础版功能清单.md))
|
||||
- 领域术语表([CONTEXT.md](./CONTEXT.md))
|
||||
|
||||
### 执行流程
|
||||
|
||||
1. **读取共识文档**:理解需求全貌。
|
||||
2. **参考现有规范**:对照 [基础版功能清单.md](./基础版功能清单.md) 中的优先级定义(P0/P1/P2/P3)、技术规范和文档风格。
|
||||
3. **术语对齐**:对照 [CONTEXT.md](./CONTEXT.md),确保 PRD 使用的所有业务术语与术语表一致。
|
||||
4. **生成 PRD**:将 PRD 写入 `docs/superpowers/specs/` 目录,文件名格式为 `{YYYY-MM-DD}-{功能名称}-prd.md`,内容结构:
|
||||
- 文档元信息(编号、版本、日期、状态)
|
||||
- 功能概述与目标
|
||||
- 用户故事(As a... I want... So that...)
|
||||
- 功能详细描述(功能点、优先级、验收标准、依赖关系、预计工时)
|
||||
- 涉及的后端模块(参考模块映射表)
|
||||
- 业务流程(Mermaid 流程图)
|
||||
- 业务规则
|
||||
- 非功能需求(性能、安全、可用性)
|
||||
- UI/UX 要求(如适用)
|
||||
- 技术要点建议
|
||||
|
||||
### 规则
|
||||
- PRD 格式严格对齐 [基础版功能清单.md](./基础版功能清单.md) 的风格。
|
||||
- 优先级标记使用 P0/P1/P2/P3 体系。
|
||||
- 每个功能点必须有明确的验收标准。
|
||||
- 必须在 PRD 中标注涉及的后端模块和前端应用。
|
||||
|
||||
---
|
||||
|
||||
## 3. /to-issues — 任务拆解
|
||||
|
||||
**目标**:将 PRD 拆解成可独立执行、可验证的具体任务(Issues)。
|
||||
|
||||
### 输入
|
||||
- `/to-prd` 生成的 PRD(`docs/superpowers/specs/{日期}-{功能名称}-prd.md`)
|
||||
- 领域术语表([CONTEXT.md](./CONTEXT.md))
|
||||
|
||||
### 拆解原则
|
||||
|
||||
**纵向拆分(全栈 Issue)**:每个 Issue 按功能维度拆分,覆盖完整的前后端链路。
|
||||
- 一个 Issue = 一个可独立交付的功能点,包含后端 API + Web 前端 + 小程序(按需)。
|
||||
- TDD 顺序:后端单元测试 → 后端实现 → 前端 E2E 测试 → 前端实现。
|
||||
|
||||
**粒度控制**:
|
||||
- 预计工时不超过 2 天,超过则继续拆解。
|
||||
- Issue 粒度应足够小,确保一个 Issue 可以在一轮对话中实现。
|
||||
|
||||
**优先级与排序**:
|
||||
- P0 任务排在 P1 之前,同一优先级下按依赖拓扑排序。
|
||||
- 每个 Issue 标注依赖关系。
|
||||
|
||||
### 输出
|
||||
|
||||
写入 `docs/superpowers/issues/` 目录(如不存在则创建),文件名格式为 `ISSUES-{功能名称}.md`,内容结构:
|
||||
|
||||
```markdown
|
||||
## GYM-{编号}: {标题}
|
||||
|
||||
- **优先级**: P0 / P1 / P2 / P3
|
||||
- **预计工时**: {天数}天
|
||||
- **依赖**: GYM-{编号} 或 无
|
||||
- **涉及模块**: 后端模块列表 + 前端应用列表
|
||||
- **领域术语**: 引用的 CONTEXT.md 术语
|
||||
|
||||
### 验收标准
|
||||
1. 后端单元测试通过
|
||||
2. E2E 测试通过
|
||||
3. ...
|
||||
|
||||
### 技术要点
|
||||
- 要点 1
|
||||
- 要点 2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. /test-driven-development — 测试驱动开发
|
||||
|
||||
**目标**:采用 TDD 方式,按 Red-Green-Refactor 循环逐个实现 Issues。
|
||||
|
||||
### 前置步骤(每个 Issue 开始前必须执行)
|
||||
|
||||
1. **阅读 Issue 描述**:理解全栈范围(涉及哪些后端模块和前端应用)。
|
||||
2. **阅读 [CONTEXT.md](./CONTEXT.md)**:确认使用的领域术语,确保代码命名一致。
|
||||
3. **阅读现有测试基础设施**:
|
||||
- E2E 测试:`e2e-tests/helpers/`(TestDataManager、TestStabilityHelper、auth.ts)、`e2e-tests/pages/`(Page Object 模式)、`e2e-tests/fixtures/`、`e2e-tests/utils/`(TestDataFactory、RetryHelper、api-client)
|
||||
- 后端测试:参考 `gym-checkIn/src/test/`、`gym-payment/src/test/` 等现有模块测试用例的风格和结构
|
||||
- 遵循已有的 Page Object、DataFactory、Smoke/Journey 测试分层 pattern
|
||||
|
||||
### 执行流程
|
||||
|
||||
按 Issue 顺序逐个实现,每个 Issue 遵循三步循环:
|
||||
|
||||
#### Step 1: Red — 编写失败的测试
|
||||
1. 先写后端单元测试(JUnit),验证 API 逻辑和边界条件。
|
||||
2. 运行后端测试,确认测试失败(红色)。
|
||||
3. 写前端 E2E 测试(Playwright),覆盖用户操作链路。
|
||||
4. 运行 E2E 测试,确认测试失败(红色)。
|
||||
|
||||
#### Step 2: Green — 编写最小实现
|
||||
1. 编写后端 API 代码,刚好让单元测试通过。
|
||||
2. 运行后端测试,确认通过(绿色)。
|
||||
3. 编写前端代码,刚好让 E2E 测试通过。
|
||||
4. 运行 E2E 测试,确认通过(绿色)。
|
||||
5. 不添加任何测试未覆盖的功能。
|
||||
|
||||
#### Step 3: Refactor — 重构优化
|
||||
1. 在全部测试保持绿色的前提下重构代码。
|
||||
2. 消除重复、改善命名(对齐 `CONTEXT.md` 术语)、优化结构。
|
||||
3. 运行全部测试,确认仍然通过。
|
||||
|
||||
### 规则
|
||||
- 一个 Issue 完成后再开始下一个。
|
||||
- 每次只改最少代码。
|
||||
- 测试优先覆盖核心逻辑和边界条件。
|
||||
- 代码风格严格遵循项目现有规范。
|
||||
- 代码命名必须对齐 [CONTEXT.md](./CONTEXT.md) 中的领域术语。
|
||||
- 所有测试通过后方可标记 Issue 为完成。
|
||||
|
||||
---
|
||||
|
||||
## 5. /systemic-debugging — 系统化调试
|
||||
|
||||
**目标**:当遇到复杂 Bug 时,通过系统化诊断流程定位根因并修复。
|
||||
|
||||
### 触发条件
|
||||
- 遇到难以复现的 Bug
|
||||
- 多轮尝试修复无效
|
||||
- 用户明确请求调试
|
||||
|
||||
### 执行流程
|
||||
|
||||
1. **定义问题**:明确描述 Bug 的现象、触发条件和预期行为。
|
||||
2. **收集证据**:查看相关日志、错误堆栈、数据库状态(检查 Flyway 迁移版本)、网络请求等。
|
||||
3. **建立假设**:基于证据提出 1-3 个可能的根因假设。
|
||||
4. **验证假设**:通过添加日志、编写最小复现用例、断点调试等方式验证或排除假设。一次只验证一个假设。
|
||||
5. **修复与验证**:定位根因后编写修复代码,确保测试覆盖该场景。
|
||||
6. **回归检查**:运行全部测试(后端 JUnit + 前端 E2E),确保修复未引入新问题。
|
||||
|
||||
### 规则
|
||||
- 不要猜测式修复,每一步必须有证据支撑。
|
||||
- 每次只验证一个假设。
|
||||
- 修复后必须添加对应的回归测试。
|
||||
|
||||
---
|
||||
|
||||
## 目录结构约定
|
||||
|
||||
```
|
||||
gym-manage/
|
||||
├── AGENT.md # 本文件
|
||||
├── CONTEXT.md # 领域术语表(统一语言)
|
||||
├── 基础版功能清单.md # 项目功能清单(参考规范)
|
||||
├── docs/
|
||||
│ └── superpowers/ # AI 协作产出物
|
||||
│ ├── plans/ # 需求澄清文档(/grill-with-docs 产出)
|
||||
│ │ └── {YYYY-MM-DD}-{功能名称}-grill.md
|
||||
│ ├── specs/ # PRD(/to-prd 产出)
|
||||
│ │ └── {YYYY-MM-DD}-{功能名称}-prd.md
|
||||
│ └── issues/ # 任务拆解(/to-issues 产出)
|
||||
│ └── ISSUES-{功能名称}.md
|
||||
├── gym-manage-api/ # 后端 Java Spring Boot 多模块
|
||||
│ ├── manage-app/ # 应用启动入口
|
||||
│ ├── manage-gateway/ # API 网关
|
||||
│ ├── manage-common/ # 公共组件
|
||||
│ ├── manage-db/ # 数据库迁移
|
||||
│ ├── manage-sys/ # 系统管理
|
||||
│ ├── manage-audit/ # 审计日志
|
||||
│ ├── manage-file/ # 文件管理
|
||||
│ ├── manage-notify/ # 消息通知
|
||||
│ ├── gym-auth/ # 认证授权
|
||||
│ ├── gym-member/ # 会员管理
|
||||
│ ├── gym-groupCourse/ # 团课管理
|
||||
│ ├── gym-checkIn/ # 签到管理
|
||||
│ ├── gym-dataCount/ # 数据统计
|
||||
│ └── gym-payment/ # 支付集成
|
||||
├── gym-manage-web/ # 管理后台 Web 前端
|
||||
├── gym-manage-uniapp/ # 会员小程序前端
|
||||
└── e2e-tests/ # 端到端测试
|
||||
├── helpers/ # TestDataManager、auth 等
|
||||
├── pages/ # Page Object 模式
|
||||
├── fixtures/ # 测试数据
|
||||
├── utils/ # TestDataFactory、RetryHelper
|
||||
├── journeys/ # 用户旅程测试
|
||||
└── smoke/ # 冒烟测试
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 通用规则
|
||||
|
||||
- **语言**:所有文档和代码注释使用中文,代码标识符使用英文。
|
||||
- **术语对齐**:所有文档和代码中的业务术语必须对齐 [CONTEXT.md](./CONTEXT.md)。
|
||||
- **风格对齐**:严格遵循项目现有代码风格和文档风格。
|
||||
- **每一步确认**:关键产出(grill 文档、PRD、Issues)完成后通知用户确认再继续。
|
||||
- **最小化原则**:不添加需求范围外的功能,不引入不必要的复杂度。
|
||||
- **测试优先**:后端单元测试和前端 E2E 测试必须在新代码之前编写。
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
# CONTEXT.md — 健身房管理系统 领域术语表
|
||||
|
||||
> 本文档定义项目的统一语言(Ubiquitous Language),确保需求、设计、代码、测试中术语一致。
|
||||
> 不含任何实现细节,仅为概念定义和关系。
|
||||
|
||||
---
|
||||
|
||||
## 一、核心业务概念
|
||||
|
||||
### 租户 (Tenant)
|
||||
健身房经营实体。每个租户拥有独立的小程序、服务号、品牌配置。系统中的所有业务数据按租户隔离。
|
||||
|
||||
### 会员 (Member)
|
||||
在健身房消费的终端用户。通过微信小程序注册,拥有会员档案、会员卡和预约记录。
|
||||
|
||||
### 用户 (User)
|
||||
系统管理端的操作人员(健身房员工)。包括管理员、前台、店长等,拥有不同的角色和权限。
|
||||
|
||||
### 管理员 (Admin)
|
||||
拥有系统最高权限的用户,可管理所有用户、角色、权限、配置。
|
||||
|
||||
### 教练 (Coach)
|
||||
负责团课授课的人员,属于用户的一种扩展身份。
|
||||
|
||||
---
|
||||
|
||||
## 二、微信生态概念
|
||||
|
||||
### 小程序 (Mini Program / MiniApp)
|
||||
会员端的微信小程序入口。用户通过小程序完成登录、浏览课程、预约、签到等操作。
|
||||
|
||||
### 服务号 (Service Account)
|
||||
用于向会员推送模板消息的微信服务号。发送预约提醒、签到通知、会员卡到期提醒等。
|
||||
|
||||
### 开放平台 (Open Platform)
|
||||
微信开放平台,用于统一小程序和服务号的用户身份(通过 UnionID)。
|
||||
|
||||
### UnionID
|
||||
微信开放平台下的统一用户标识。同一微信开放平台账号下的不同应用(小程序、服务号),同一用户的 UnionID 相同。
|
||||
|
||||
### OpenID
|
||||
单个微信应用下的用户标识。同一用户在不同应用中 OpenID 不同。
|
||||
|
||||
### 模板消息 (Template Message)
|
||||
通过服务号向会员发送的结构化通知消息。每月限额 4 条/用户。
|
||||
|
||||
---
|
||||
|
||||
## 三、团课与预约
|
||||
|
||||
### 团课 (Group Course)
|
||||
健身房提供的团体健身课程。包含课程名称、教练、时间、地点、容量(最多 20 人)等信息。
|
||||
|
||||
### 团课类型 (Course Type)
|
||||
团课的分类维度,如"瑜伽、动感单车、搏击操"等。
|
||||
|
||||
### 团课标签 (Course Label)
|
||||
团课的标记维度,如"新手友好、高强度、减脂"等。
|
||||
|
||||
### 团课推荐 (Course Recommendation)
|
||||
管理员手动标记的推荐课程,在小程序首页优先展示。
|
||||
|
||||
### 预约 (Booking / Reservation)
|
||||
会员报名参加团课的操作。预约成功后扣减会员卡权益,课程开始前 30 分钟截止预约。
|
||||
|
||||
### 取消预约 (Cancel Booking)
|
||||
会员取消已报名的团课。需在课程开始前至少 2 小时操作,取消后自动退还权益。
|
||||
|
||||
---
|
||||
|
||||
## 四、会员卡
|
||||
|
||||
### 会员卡 (Membership Card)
|
||||
会员购买的权益凭证,分为三种类型:
|
||||
- **时长卡 (Duration Card)**:在有效期内无限次使用
|
||||
- **次卡 (Count Card)**:按次数扣减
|
||||
- **储值卡 (Stored Value Card)**:按金额扣减
|
||||
|
||||
### 会员卡权益 (Card Entitlement)
|
||||
会员卡包含的可使用权益,如每日入场次数、可预约课程数。
|
||||
|
||||
### 会员卡状态 (Card Status)
|
||||
会员卡的生命周期状态:激活、冻结、到期、已退款。
|
||||
|
||||
### 到期提醒 (Expiry Reminder)
|
||||
会员卡到期前 7 天通过服务号模板消息向会员发送提醒。
|
||||
|
||||
---
|
||||
|
||||
## 五、签到
|
||||
|
||||
### 签到 (Check-in)
|
||||
会员到场时的身份确认操作。需验证会员卡有效性和预约信息(如有团课预约)。
|
||||
|
||||
### 签到码 (Check-in Code)
|
||||
会员出示的动态二维码,用于前台/设备扫描签到。
|
||||
|
||||
---
|
||||
|
||||
## 六、支付
|
||||
|
||||
### 支付 (Payment)
|
||||
会员购买会员卡或课程时的在线支付操作,支持微信支付和支付宝支付。
|
||||
|
||||
### 退款 (Refund)
|
||||
将已支付金额原路退回会员账户的操作。
|
||||
|
||||
### 对账 (Reconciliation)
|
||||
每日将系统支付记录与微信/支付宝账单进行比对核验的过程。
|
||||
|
||||
---
|
||||
|
||||
## 七、权限与审计
|
||||
|
||||
### 角色 (Role)
|
||||
权限的集合。系统中的角色包括管理员、店长、前台等,采用 RBAC(基于角色的访问控制)模型。
|
||||
|
||||
### 权限 (Permission)
|
||||
对系统资源的操作许可。包括菜单访问权限、按钮操作权限、数据查看权限。
|
||||
|
||||
### 菜单 (Menu)
|
||||
管理后台的导航入口,通过权限控制可见性。
|
||||
|
||||
### 操作日志 (Operation Log)
|
||||
记录用户在系统中的关键操作(增删改),用于审计追溯。
|
||||
|
||||
### 登录日志 (Login Log)
|
||||
记录用户的登录行为,包括登录 IP、时间、结果。
|
||||
|
||||
### 异常日志 (Exception Log)
|
||||
记录系统运行时异常,用于问题排查。
|
||||
|
||||
---
|
||||
|
||||
## 八、配置
|
||||
|
||||
### 参数配置 (System Config)
|
||||
系统级别的可调参数,如签到超时时间、预约截止时间等,通过管理后台的参数配置页面管理。
|
||||
|
||||
### 字典 (Dictionary)
|
||||
系统使用的枚举值配置,如下拉选项、状态值等,通过字典管理页面维护。
|
||||
|
||||
### 轮播图 (Banner)
|
||||
小程序首页的推广图片,通过管理后台管理。
|
||||
|
||||
### 通知公告 (Notice)
|
||||
向会员或用户发布的系统公告,通过管理后台管理。
|
||||
|
||||
---
|
||||
|
||||
## 九、测试相关概念
|
||||
|
||||
### 冒烟测试 (Smoke Test)
|
||||
每次提交后运行的快速验证测试,覆盖核心登录/登出流程,执行时间 ≤ 5 分钟。
|
||||
|
||||
### 用户旅程测试 (Journey Test / E2E)
|
||||
模拟真实用户完整操作链路的端到端测试。覆盖从登录到完成业务操作的全流程。
|
||||
|
||||
### 角色边界测试 (Role Boundary Test)
|
||||
验证不同角色(Admin/普通用户/未登录)的权限隔离是否正确的测试。
|
||||
|
||||
### Page Object
|
||||
E2E 测试中的设计模式,将页面元素定位和操作封装为类,提高测试可维护性。
|
||||
|
||||
### TestDataManager
|
||||
测试数据管理器,负责测试数据的自动生成(带唯一前缀)和清理回调注册。
|
||||
|
||||
### TestDataFactory
|
||||
测试数据工厂,提供随机的测试数据对象(用户、角色、菜单等)。
|
||||
Vendored
+152
-259
@@ -1,310 +1,203 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
|
||||
parameters {
|
||||
choice(
|
||||
name: 'TEST_SCOPE',
|
||||
choices: ['smoke', 'full', 'all', 'miniapp'],
|
||||
description: '测试范围: smoke=仅冒烟测试, full=完整E2E, all=所有测试含性能, miniapp=仅小程序'
|
||||
)
|
||||
choice(
|
||||
name: 'BROWSER',
|
||||
choices: ['chromium', 'all'],
|
||||
description: '目标浏览器: chromium=单浏览器快速, all=全浏览器'
|
||||
)
|
||||
string(
|
||||
name: 'BASE_URL',
|
||||
defaultValue: 'http://localhost:3002',
|
||||
description: '测试目标URL'
|
||||
)
|
||||
}
|
||||
|
||||
environment {
|
||||
// 项目配置
|
||||
PROJECT_NAME = 'gym-manage-system'
|
||||
FRONTEND_DIR = 'gym-manage-web'
|
||||
BACKEND_DIR = 'gym-manage-api'
|
||||
|
||||
// Node.js 配置
|
||||
NODE_VERSION = '20'
|
||||
PNPM_VERSION = '8.15.0'
|
||||
|
||||
// Java 配置
|
||||
JAVA_VERSION = '17'
|
||||
MAVEN_VERSION = '3.9.0'
|
||||
|
||||
// Docker 配置
|
||||
DOCKER_REGISTRY = credentials('docker-registry')
|
||||
DOCKER_IMAGE_FRONTEND = "${PROJECT_NAME}-frontend"
|
||||
DOCKER_IMAGE_BACKEND = "${PROJECT_NAME}-backend"
|
||||
|
||||
// 数据库配置(用于E2E测试)
|
||||
DB_HOST = 'localhost'
|
||||
DB_PORT = '5432'
|
||||
DB_NAME = 'gym_test'
|
||||
DB_USER = credentials('db-user')
|
||||
DB_PASSWORD = credentials('db-password')
|
||||
|
||||
// 测试配置
|
||||
TEST_TIMEOUT = '30'
|
||||
RETRY_COUNT = '2'
|
||||
PLAYWRIGHT_HEADLESS = 'true'
|
||||
CI = 'true'
|
||||
NODE_ENV = 'test'
|
||||
JEST_JUNIT_OUTPUT_DIR = 'test-results'
|
||||
JEST_JUNIT_OUTPUT_NAME = 'junit.xml'
|
||||
}
|
||||
|
||||
tools {
|
||||
nodejs "NodeJS-${NODE_VERSION}"
|
||||
maven "Maven-${MAVEN_VERSION}"
|
||||
jdk "JDK-${JAVA_VERSION}"
|
||||
}
|
||||
|
||||
|
||||
stages {
|
||||
|
||||
stage('环境准备') {
|
||||
parallel {
|
||||
stage('安装Web端依赖') {
|
||||
steps {
|
||||
dir('gym-manage-web') {
|
||||
bat 'npm ci'
|
||||
bat 'npx playwright install chromium --with-deps'
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('安装小程序端依赖') {
|
||||
steps {
|
||||
dir('gym-manage-uniapp') {
|
||||
bat 'npm ci'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('启动后端服务') {
|
||||
steps {
|
||||
echo '🔧 准备构建环境...'
|
||||
sh '''
|
||||
# 安装 pnpm
|
||||
npm install -g pnpm@${PNPM_VERSION}
|
||||
|
||||
# 验证工具版本
|
||||
node --version
|
||||
pnpm --version
|
||||
java -version
|
||||
mvn --version
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('代码检查') {
|
||||
parallel {
|
||||
stage('前端代码检查') {
|
||||
steps {
|
||||
dir(FRONTEND_DIR) {
|
||||
echo '🔍 执行前端代码检查...'
|
||||
sh '''
|
||||
pnpm install
|
||||
pnpm run lint
|
||||
pnpm run type-check
|
||||
'''
|
||||
}
|
||||
}
|
||||
dir('gym-manage-api') {
|
||||
bat 'mvn spring-boot:run -pl manage-app -DskipTests &'
|
||||
bat 'mvn spring-boot:run -pl manage-gateway -DskipTests &'
|
||||
}
|
||||
|
||||
stage('后端代码检查') {
|
||||
steps {
|
||||
dir(BACKEND_DIR) {
|
||||
echo '🔍 执行后端代码检查...'
|
||||
sh 'mvn clean compile -DskipTests'
|
||||
// 等待后端健康检查
|
||||
script {
|
||||
retry(10) {
|
||||
sleep(10)
|
||||
try {
|
||||
def response = httpRequest 'http://localhost:8084/actuator/health'
|
||||
print "后端健康状态: ${response.content}"
|
||||
} catch (err) {
|
||||
error "后端尚未就绪: ${err.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('单元测试') {
|
||||
parallel {
|
||||
stage('前端单元测试') {
|
||||
steps {
|
||||
dir(FRONTEND_DIR) {
|
||||
echo '🧪 执行前端单元测试...'
|
||||
sh 'pnpm run test:unit'
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
dir(FRONTEND_DIR) {
|
||||
// 发布测试报告
|
||||
publishHTML(target: [
|
||||
allowMissing: false,
|
||||
alwaysLinkToLastBuild: true,
|
||||
keepAll: true,
|
||||
reportDir: 'coverage',
|
||||
reportFiles: 'index.html',
|
||||
reportName: '前端单元测试覆盖率报告'
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('后端单元测试') {
|
||||
steps {
|
||||
dir(BACKEND_DIR) {
|
||||
echo '🧪 执行后端单元测试...'
|
||||
sh 'mvn test'
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
dir(BACKEND_DIR) {
|
||||
// 发布测试报告
|
||||
junit '**/target/surefire-reports/*.xml'
|
||||
|
||||
// 发布代码覆盖率报告
|
||||
publishHTML(target: [
|
||||
allowMissing: false,
|
||||
alwaysLinkToLastBuild: true,
|
||||
keepAll: true,
|
||||
reportDir: 'target/site/jacoco',
|
||||
reportFiles: 'index.html',
|
||||
reportName: '后端单元测试覆盖率报告'
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('构建') {
|
||||
parallel {
|
||||
stage('前端构建') {
|
||||
steps {
|
||||
dir(FRONTEND_DIR) {
|
||||
echo '📦 构建前端项目...'
|
||||
sh '''
|
||||
pnpm run build:prod
|
||||
|
||||
# 创建构建产物归档
|
||||
tar -czf frontend-dist.tar.gz dist/
|
||||
'''
|
||||
}
|
||||
}
|
||||
post {
|
||||
success {
|
||||
archiveArtifacts artifacts: "${FRONTEND_DIR}/frontend-dist.tar.gz", fingerprint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('后端构建') {
|
||||
steps {
|
||||
dir(BACKEND_DIR) {
|
||||
echo '📦 构建后端项目...'
|
||||
sh '''
|
||||
mvn clean package -DskipTests
|
||||
|
||||
# 创建构建产物归档
|
||||
tar -czf backend-jars.tar.gz */target/*.jar
|
||||
'''
|
||||
}
|
||||
}
|
||||
post {
|
||||
success {
|
||||
archiveArtifacts artifacts: "${BACKEND_DIR}/backend-jars.tar.gz", fingerprint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('E2E测试') {
|
||||
|
||||
stage('冒烟测试') {
|
||||
steps {
|
||||
echo '🎭 执行E2E测试...'
|
||||
dir(FRONTEND_DIR) {
|
||||
sh '''
|
||||
# 安装Playwright浏览器
|
||||
pnpm exec playwright install --with-deps chromium
|
||||
|
||||
# 执行E2E测试
|
||||
pnpm run test:e2e:journeys
|
||||
'''
|
||||
dir('gym-manage-web') {
|
||||
bat 'npm run test:e2e:smoke'
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
dir(FRONTEND_DIR) {
|
||||
// 发布E2E测试报告
|
||||
publishHTML(target: [
|
||||
allowMissing: false,
|
||||
alwaysLinkToLastBuild: true,
|
||||
keepAll: true,
|
||||
reportDir: 'test-results',
|
||||
reportFiles: 'custom-report.html',
|
||||
reportName: 'E2E测试报告'
|
||||
])
|
||||
|
||||
// 归档测试失败截图和视频
|
||||
archiveArtifacts artifacts: 'test-results/**/*.png, test-results/**/*.webm', allowEmptyArchive: true
|
||||
failure {
|
||||
echo '冒烟测试失败,跳过后续测试'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Web E2E 测试') {
|
||||
when {
|
||||
expression { params.TEST_SCOPE != 'smoke' }
|
||||
}
|
||||
steps {
|
||||
dir('gym-manage-web') {
|
||||
script {
|
||||
def projectFilter = params.TEST_SCOPE == 'all'
|
||||
? ''
|
||||
: '--project=root --project=journeys'
|
||||
bat "npx playwright test ${projectFilter}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('构建Docker镜像') {
|
||||
|
||||
stage('性能测试') {
|
||||
when {
|
||||
branch 'develop'
|
||||
expression { params.TEST_SCOPE == 'all' }
|
||||
}
|
||||
steps {
|
||||
echo '🐳 构建Docker镜像...'
|
||||
|
||||
// 构建前端镜像
|
||||
dir(FRONTEND_DIR) {
|
||||
sh """
|
||||
docker build -t ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:${BUILD_NUMBER} .
|
||||
docker tag ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:${BUILD_NUMBER} ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:latest
|
||||
"""
|
||||
}
|
||||
|
||||
// 构建后端镜像
|
||||
dir(BACKEND_DIR) {
|
||||
sh """
|
||||
docker build -t ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:${BUILD_NUMBER} .
|
||||
docker tag ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:${BUILD_NUMBER} ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:latest
|
||||
"""
|
||||
dir('gym-manage-web') {
|
||||
bat 'npm run test:e2e:perf'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('推送Docker镜像') {
|
||||
|
||||
stage('角色边界测试') {
|
||||
when {
|
||||
branch 'develop'
|
||||
expression { params.TEST_SCOPE == 'all' }
|
||||
}
|
||||
steps {
|
||||
echo '📤 推送Docker镜像到仓库...'
|
||||
sh """
|
||||
docker push ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:${BUILD_NUMBER}
|
||||
docker push ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:latest
|
||||
docker push ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:${BUILD_NUMBER}
|
||||
docker push ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:latest
|
||||
"""
|
||||
dir('gym-manage-web') {
|
||||
bat 'npx playwright test --grep "权限|角色|边界|boundary"'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('部署到测试环境') {
|
||||
|
||||
stage('小程序编译') {
|
||||
when {
|
||||
branch 'develop'
|
||||
anyOf {
|
||||
expression { params.TEST_SCOPE == 'all' }
|
||||
expression { params.TEST_SCOPE == 'miniapp' }
|
||||
}
|
||||
}
|
||||
steps {
|
||||
echo '🚀 部署到测试环境...'
|
||||
sh """
|
||||
# 这里可以添加部署脚本
|
||||
# 例如:使用docker-compose或kubernetes部署
|
||||
|
||||
echo "部署前端镜像: ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:${BUILD_NUMBER}"
|
||||
echo "部署后端镜像: ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:${BUILD_NUMBER}"
|
||||
"""
|
||||
dir('gym-manage-uniapp') {
|
||||
bat 'npm run build:mp-weixin'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('部署到生产环境') {
|
||||
|
||||
stage('小程序 E2E 测试') {
|
||||
when {
|
||||
branch 'main'
|
||||
anyOf {
|
||||
expression { params.TEST_SCOPE == 'all' }
|
||||
expression { params.TEST_SCOPE == 'miniapp' }
|
||||
}
|
||||
}
|
||||
environment {
|
||||
WECHAT_DEVTOOLS_CLI = 'D:\\微信web开发者工具\\cli.bat'
|
||||
}
|
||||
steps {
|
||||
echo '🚀 部署到生产环境...'
|
||||
input message: '确认部署到生产环境?', ok: '确认部署'
|
||||
|
||||
sh """
|
||||
# 这里可以添加生产环境部署脚本
|
||||
# 例如:使用kubernetes进行滚动更新
|
||||
|
||||
echo "部署前端镜像: ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:${BUILD_NUMBER}"
|
||||
echo "部署后端镜像: ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:${BUILD_NUMBER}"
|
||||
"""
|
||||
dir('gym-manage-uniapp') {
|
||||
script {
|
||||
try {
|
||||
// 使用一键脚本:自动启动 IDE → 运行测试 → 关闭 IDE
|
||||
bat 'node e2e/run-tests.js --close --verbose'
|
||||
} catch (err) {
|
||||
echo "小程序 E2E 测试失败:${err.message}"
|
||||
// 失败也继续(IDE 可能不可用)
|
||||
unstable('小程序 E2E 测试未通过')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
post {
|
||||
always {
|
||||
echo '🧹 清理工作空间...'
|
||||
cleanWs()
|
||||
// 归档测试报告
|
||||
dir('gym-manage-web') {
|
||||
archiveArtifacts artifacts: 'test-results/**/*', allowEmptyArchive: true
|
||||
archiveArtifacts artifacts: 'playwright-report/**', allowEmptyArchive: true
|
||||
junit 'test-results/junit.xml'
|
||||
}
|
||||
// 归档小程序测试报告
|
||||
dir('gym-manage-uniapp') {
|
||||
archiveArtifacts artifacts: 'test-results/**/*', allowEmptyArchive: true
|
||||
}
|
||||
}
|
||||
|
||||
success {
|
||||
echo '✅ 流水线执行成功!'
|
||||
// 可以添加通知,例如发送邮件或Slack消息
|
||||
}
|
||||
|
||||
failure {
|
||||
echo '❌ 流水线执行失败!'
|
||||
// 可以添加失败通知
|
||||
// 失败通知
|
||||
script {
|
||||
def failedTests = currentBuild.rawBuild.getLog(50)
|
||||
emailext(
|
||||
subject: "[测试失败] ${env.JOB_NAME} - Build #${env.BUILD_NUMBER}",
|
||||
body: """
|
||||
<h2>UI 自动化测试失败</h2>
|
||||
<p><b>Job:</b> ${env.JOB_NAME}</p>
|
||||
<p><b>Build:</b> #${env.BUILD_NUMBER}</p>
|
||||
<p><b>分支:</b> ${env.BRANCH_NAME}</p>
|
||||
<p><b>日志:</b> <a href="${env.BUILD_URL}">${env.BUILD_URL}</a></p>
|
||||
<h3>最后 50 行日志:</h3>
|
||||
<pre>${failedTests}</pre>
|
||||
""",
|
||||
mimeType: 'text/html',
|
||||
to: 'dev-team@gym-manage.com'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
unstable {
|
||||
echo '⚠️ 流水线执行不稳定!'
|
||||
// 可以添加不稳定状态通知
|
||||
success {
|
||||
echo "全部 UI 测试通过!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
# UI 层级全自动测试套件 — 需求共识文档
|
||||
|
||||
> **文档编号**: GYM-GRILL-UI-TEST-001
|
||||
> **日期**: 2026-07-21
|
||||
> **状态**: 已确认
|
||||
> **产出命令**: /grill-with-docs
|
||||
|
||||
---
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
为健身房管理系统构建 UI 层级的全自动测试套件,覆盖 **管理后台 Web(Vue 3)** 和 **会员小程序(UniApp)** 两个 UI 终端,确保所有已实现功能通过系统化测试,达到 ≥95% 通过率的交付水平。
|
||||
|
||||
### 目标终端
|
||||
|
||||
| UI 层级 | 技术栈 | 当前测试状态 | 目标 |
|
||||
|---|---|---|---|
|
||||
| 管理后台 Web | Vue 3 + Element Plus + Vite | Vitest 单元测试 + Playwright E2E(81%通过率) | 修复失败 → 补齐覆盖 → ≥95% |
|
||||
| 会员小程序 | UniApp(Vue) | **零测试** | 从零构建 → 完整覆盖 |
|
||||
|
||||
---
|
||||
|
||||
## 二、当前状态分析
|
||||
|
||||
### 2.1 已实现功能清单(管理后台 - 21个页面)
|
||||
|
||||
| 模块 | 页面 | 关联功能清单 | 已有 E2E Page Object | 已有 E2E 测试 |
|
||||
|---|---|---|---|---|
|
||||
| 系统管理 | Login / Dashboard / 403 | P0 模块7 | LoginPage / DashboardPage | ✅ |
|
||||
| 系统管理 | UserManagement | P0 模块7 | UserManagementPage | ✅ journeys |
|
||||
| 系统管理 | **CoachManagement** | —(额外功能) | ❌ | ❌ |
|
||||
| 系统管理 | RoleManagement | P0 模块7 | RoleManagementPage | ✅ journeys |
|
||||
| 系统管理 | MenuManagement | P0 模块7 | MenuManagementPage | ✅ |
|
||||
| 配置 | ConfigManagement | —(额外功能) | SystemConfigPage | ✅ |
|
||||
| 配置 | DictManagement | —(额外功能) | DictionaryManagementPage | ✅ |
|
||||
| 文件 | FileManagement | —(额外功能) | FileManagementPage | ✅ |
|
||||
| 通知 | NoticeManagement | —(额外功能) | NotificationPage | ✅ |
|
||||
| 轮播图 | **BannerManagement** | —(额外功能) | ❌ | ❌ |
|
||||
| 审计 | LoginLog | —(额外功能) | LoginLogPage | ✅ |
|
||||
| 审计 | OperationLog | —(额外功能) | OperationLogPage | ✅ |
|
||||
| 审计 | ExceptionLog | —(额外功能) | ExceptionLogPage | ✅ |
|
||||
| 团课 | GroupCourseManagement | P1 模块2 | ❌ | ❌ (Page 缺失) |
|
||||
| 团课 | CourseTypeManagement | P1 模块2 | ❌ | ❌ |
|
||||
| 团课 | **CourseLabelManagement** | —(额外功能) | ❌ | ❌ |
|
||||
| 团课 | **CourseRecommendManagement** | —(额外功能) | ❌ | ❌ |
|
||||
| 会员 | **MemberManagement** | P1 模块1 | ❌ | ❌ |
|
||||
| 会员 | **MemberCardManagement** | P0 模块1 | ❌ | ❌ |
|
||||
| 统计 | **StatisticsDashboard** | P1 模块4 | ❌ | ❌ |
|
||||
|
||||
> 标记 `❌` 的为 **新增测试缺口**,共 **10 个页面**。
|
||||
|
||||
### 2.2 已实现功能清单(小程序 - 6个页面)
|
||||
|
||||
| 页面 | 关联功能清单 | 测试状态 |
|
||||
|---|---|---|
|
||||
| 首页 (index) | P0 模块1 微信登录入口 | ❌ |
|
||||
| 登录 (login) | P0 模块1 微信快捷登录 | ❌ |
|
||||
| 课程详情 (course-detail) | P0 模块2 团课详情 | ❌ |
|
||||
| 我的课程 (my-courses) | P0 模块2 预约记录 | ❌ |
|
||||
| 个人中心 (profile) | P0 模块1 会员信息 | ❌ |
|
||||
| 搜索 (search) | P0 模块2 团课搜索 | ❌ |
|
||||
|
||||
### 2.3 现有测试资产
|
||||
|
||||
| 资产 | 状态 | 价值 |
|
||||
|---|---|---|
|
||||
| 12 个 Page Object | 成熟 | 可直接复用 |
|
||||
| TestDataManager + TestDataFactory | 成熟 | 可直接复用 |
|
||||
| RetryHelper + TestStabilityHelper | 成熟 | 可直接复用 |
|
||||
| global-setup / global-teardown | 成熟 | 可直接复用 |
|
||||
| customReporter(HTML+JSON+控制台) | 成熟 | 可直接复用 |
|
||||
| auth.setup(storageState) | **有 Bug** | 需修复 storageState 冲突 |
|
||||
| 10 个 Journey 测试(100%通过) | 健康 | 保持不变 |
|
||||
|
||||
### 2.4 现有失败测试分析(9个)
|
||||
|
||||
| 失败测试 | 根因 | 修复策略 |
|
||||
|---|---|---|
|
||||
| auth-test.spec.ts (4用例) | storageState 冲突:全局认证状态下,登录相关测试无法模拟未登录场景 | 为该文件配置独立 project(禁用 storageState) |
|
||||
| basic-ui-test.spec.ts | 已登录后重定向到 Dashboard 而非登录页 | 调整测试预期或独立 project |
|
||||
| config-management.spec.ts | 超时/选择器不匹配 | 更新选择器,增加等待策略 |
|
||||
| dict-management.spec.ts | 超时/选择器不匹配 | 同上 |
|
||||
| menu-management.spec.ts | 超时/选择器不匹配 | 同上 |
|
||||
| debug/debug-role-assignment.spec.ts | 测试逻辑问题 | 重写或移除 |
|
||||
|
||||
---
|
||||
|
||||
## 三、用户故事
|
||||
|
||||
### US-1: 测试工程师运行全量回归
|
||||
> As a **测试工程师**
|
||||
> I want **一键运行全部 UI 测试并自动生成报告**
|
||||
> So that **每次发版前能快速确认系统质量状况**
|
||||
|
||||
### US-2: 开发者在 CI 中获得反馈
|
||||
> As a **开发者**
|
||||
> I want **Push 代码后 Jenkins 自动运行冒烟测试**
|
||||
> So that **核心功能回归能在 5 分钟内给出反馈**
|
||||
|
||||
### US-3: 产品经理验证业务规则
|
||||
> As a **产品经理**
|
||||
> I want **测试覆盖功能清单中的业务规则和技术要点**
|
||||
> So that **交付的功能与设计一致**
|
||||
|
||||
### US-4: 小程序团队确保质量
|
||||
> As a **小程序开发者**
|
||||
> I want **微信模拟器中的自动化测试**
|
||||
> So that **小程序端的回归不需要手动点点点**
|
||||
|
||||
---
|
||||
|
||||
## 四、测试架构设计
|
||||
|
||||
### 4.1 整体分层
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ L3: 性能测试 (Performance) │
|
||||
│ - 页面加载性能 │
|
||||
│ - API 响应性能 │
|
||||
│ - 报告:performance-report/ │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ L2: 用户旅程测试 (Journey Tests) │
|
||||
│ - 管理后台:按模块拆分用户旅程 │
|
||||
│ - 小程序:按页面拆分用户旅程 │
|
||||
│ - 报告:test-results/ + customReporter │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ L1: 角色边界测试 (Role Boundary) │
|
||||
│ - 权限隔离验证 │
|
||||
│ - 多角色并发验证 │
|
||||
│ - 报告:test-results/ │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ L0: 冒烟测试 (Smoke) │
|
||||
│ - 管理后台:登录/登出 │
|
||||
│ - 小程序:启动/首页渲染 │
|
||||
│ - 运行频次:每次 Push │
|
||||
│ - 时限:≤ 5 分钟 │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 技术方案
|
||||
|
||||
| 层级 | 管理后台 Web | 会员小程序 |
|
||||
|---|---|---|
|
||||
| 框架 | Playwright + TypeScript | miniprogram-automator + Jest |
|
||||
| 设计模式 | Page Object | Page Object(模拟) |
|
||||
| 数据管理 | TestDataManager + TestDataFactory | 独立 DataFactory(复用 API 层) |
|
||||
| 报告 | customReporter(HTML+JSON) | Jest HTML Reporter |
|
||||
| 运行环境 | 复用 global-setup 自动启动服务 | 微信开发者工具 CLI + 模拟器 |
|
||||
| CI | Jenkins Pipeline | Jenkins Pipeline |
|
||||
|
||||
---
|
||||
|
||||
## 五、业务流程(测试范围)
|
||||
|
||||
### 5.1 管理后台核心流程
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[登录] --> B[Dashboard]
|
||||
B --> C1[用户管理 CRUD]
|
||||
B --> C2[角色管理 + 权限分配]
|
||||
B --> C3[菜单管理]
|
||||
C1 & C2 --> D[角色边界验证]
|
||||
B --> E1[团课管理 CRUD]
|
||||
B --> E2[会员管理 查看/搜索]
|
||||
B --> E3[会员卡管理]
|
||||
B --> E4[数据统计看板]
|
||||
B --> F1[教练管理]
|
||||
B --> F2[轮播图管理]
|
||||
B --> F3[参数配置]
|
||||
B --> F4[字典管理]
|
||||
B --> F5[文件管理]
|
||||
B --> F6[通知公告]
|
||||
B --> F7[审计日志]
|
||||
```
|
||||
|
||||
### 5.2 小程序核心流程
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[微信授权登录] --> B[首页]
|
||||
B --> C[搜索团课]
|
||||
C --> D[课程详情]
|
||||
D --> E[预约课程]
|
||||
B --> F[我的课程]
|
||||
F --> G[取消预约]
|
||||
B --> H[个人中心]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、验收标准
|
||||
|
||||
### 通用标准
|
||||
|
||||
| 编号 | 标准 | 量化指标 |
|
||||
|---|---|---|
|
||||
| AC-1 | 测试通过率 | ≥ 95%(总用例) |
|
||||
| AC-2 | 核心业务链路 | 100% 通过(登录→关键操作→退出) |
|
||||
| AC-3 | 冒烟测试耗时 | ≤ 5 分钟 |
|
||||
| AC-4 | 全量 E2E 耗时 | ≤ 15 分钟(管理后台 + 小程序并行) |
|
||||
| AC-5 | 测试报告 | 自动生成 HTML + JSON 报告,包含失败截图 |
|
||||
| AC-6 | CI 集成 | Jenkins Pipeline 可一键触发,结果自动归档 |
|
||||
| AC-7 | 测试数据隔离 | 每次运行使用独立数据前缀,运行后自动清理 |
|
||||
| AC-8 | 稳定性 | 连续 3 次无随机失败(flake rate = 0%) |
|
||||
|
||||
### 功能覆盖标准
|
||||
|
||||
| 编号 | 标准 |
|
||||
|---|---|
|
||||
| AC-9 | 管理后台 21 个页面,每个至少 1 个 Happy Path 测试 |
|
||||
| AC-10 | 核心 CRUD 操作(用户/角色/团课)覆盖增删改查全流程 |
|
||||
| AC-11 | 角色边界测试覆盖至少 3 个角色(Admin / 店长 / 前台) |
|
||||
| AC-12 | 小程序 6 个页面,每个至少 1 个 Happy Path 测试 |
|
||||
| AC-13 | 小程序预约/取消流程端到端验证 |
|
||||
| AC-14 | 性能测试至少覆盖 Dashboard 和团课列表 2 个关键页面 |
|
||||
|
||||
---
|
||||
|
||||
## 七、实施计划(优先级排序)
|
||||
|
||||
### 第1阶段:修复现有测试(预计 1 天)
|
||||
|
||||
| 任务 | 产出 |
|
||||
|---|---|
|
||||
| 修复 storageState 冲突 | auth-test.spec.ts 4 个用例通过 |
|
||||
| 为需要未登录状态的测试创建独立 Playwright project | basic-ui-test.spec.ts 通过 |
|
||||
| 更新选择器 + 等待策略 | config/dict/menu 3 个 spec 通过 |
|
||||
| 处理 debug 目录 | 重写或移除 debug-role-assignment |
|
||||
| **目标** | 53/53 测试通过(100%) |
|
||||
|
||||
### 第2阶段:补齐管理后台缺失测试(预计 2 天)
|
||||
|
||||
| 任务 | 覆盖页面 |
|
||||
|---|---|
|
||||
| 创建 10 个新 Page Object | CoachManagement, BannerManagement, GroupCourseManagement, CourseTypeManagement, CourseLabelManagement, CourseRecommendManagement, MemberManagement, MemberCardManagement, StatisticsDashboard(以及团课页面类) |
|
||||
| 编写 8 个 Journey 测试 | 对应上述缺失页面 |
|
||||
| 扩展角色边界测试 | 3 角色 × 5 关键页面 |
|
||||
| 编写 2 个性能测试 | Dashboard + 团课列表 |
|
||||
|
||||
### 第3阶段:构建小程序测试(预计 2 天)
|
||||
|
||||
| 任务 | 产出 |
|
||||
|---|---|
|
||||
| 搭建 miniprogram-automator 环境 | 安装依赖、配置微信开发者工具 CLI |
|
||||
| 创建小程序 Page Object(6 个页面) | 首页/登录/课程详情/我的课程/个人中心/搜索 |
|
||||
| 编写 4 个 Journey 测试 | 登录→首页→课程详情→预约;登录→我的课程→取消;登录→个人中心→信息编辑;首页→搜索→课程详情 |
|
||||
| 编写 1 个冒烟测试 | 启动→首页渲染→登录 |
|
||||
|
||||
### 第4阶段:Jenkins CI 集成(预计 1 天)
|
||||
|
||||
| 任务 | 产出 |
|
||||
|---|---|
|
||||
| 编写 Jenkinsfile | 定义 Stage:环境准备 → 冒烟测试 → 全量 E2E(Web + 小程序并行) → 性能测试 → 报告归档 |
|
||||
| 配置测试报告归档 | HTML Report Plugin + JUnit Plugin |
|
||||
| 配置失败通知 | 企业微信/邮件通知 |
|
||||
|
||||
---
|
||||
|
||||
## 八、涉及的领域概念
|
||||
|
||||
| 领域概念 | 测试关注点 |
|
||||
|---|---|
|
||||
| 会员 (Member) | 会员管理 CRUD、会员卡购买/查看 |
|
||||
| 用户 (User) | 用户管理 CRUD、角色分配 |
|
||||
| 角色 (Role) | 角色 CRUD、权限分配、边界验证 |
|
||||
| 权限 (Permission) | 菜单可见性、按钮权限、越权访问验证 |
|
||||
| 团课 (Group Course) | 团课 CRUD、类型/标签/推荐管理 |
|
||||
| 预约 (Booking) | 预约/取消流程(小程序端) |
|
||||
| 签到 (Check-in) | 扫码签到(后续迭代,依赖微信环境) |
|
||||
| 会员卡 (Membership Card) | 会员卡管理、到期提醒 |
|
||||
|
||||
---
|
||||
|
||||
## 九、非功能需求
|
||||
|
||||
| 类型 | 要求 |
|
||||
|---|---|
|
||||
| **可靠性** | 连续 3 次运行无随机失败(flake-free) |
|
||||
| **可维护性** | Page Object 模式,禁止硬编码选择器 |
|
||||
| **可扩展性** | 新页面通过添加 Page Object + 1 个 Journey 即可接入 |
|
||||
| **数据隔离** | 测试数据使用唯一前缀,运行后自动清理,不影响开发环境 |
|
||||
| **CI 兼容** | Jenkins Pipeline 声明式,参数化(支持选择运行范围) |
|
||||
|
||||
---
|
||||
|
||||
## 十、风险与应对
|
||||
|
||||
| 风险 | 应对策略 |
|
||||
|---|---|
|
||||
| miniprogram-automator 与 UniApp 兼容性问题 | 先用 H5 模式验证可行性,再切换模拟器 |
|
||||
| 微信开发者工具在 Jenkins 无头环境运行受限 | Jenkins Agent 部署在 Windows 有桌面环境机器上 |
|
||||
| 现有 E2E 测试大规模重构引入回归 | 第1阶段仅修复,不做重构;后续新增测试在独立文件中 |
|
||||
| 小程序登录依赖真实微信环境 | 使用 Mock 微信 API 或测试用 OpenID/UnionID |
|
||||
| 性能测试基准不稳定 | 取 3 次运行中位数,允许 ±15% 波动 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、待确认项
|
||||
|
||||
- [x] UI 层级范围:管理后台 + 小程序
|
||||
- [x] 功能范围:全部已实现功能,对齐功能清单业务规则
|
||||
- [x] 交付标准:≥95% 通过率 + Jenkins CI
|
||||
- [x] 测试类型:E2E / 冒烟 / 性能 / 角色边界 / 小程序专项
|
||||
- [x] 小程序方案:miniprogram-automator
|
||||
- [x] CI 平台:Jenkins
|
||||
- [x] 测试环境:复用 global-setup
|
||||
- [x] 优先级:修复现有 → 补齐 Web → 构建小程序 → CI
|
||||
- [x] 用户体验测试 (UAT):**不纳入**,聚焦自动化测试,UAT 由人工执行
|
||||
- [x] 视觉回归测试:**不纳入**,避免基准截图频繁更新的维护成本
|
||||
|
||||
---
|
||||
|
||||
## 十二、相关文档
|
||||
|
||||
- [CONTEXT.md](../../../CONTEXT.md) — 项目领域术语表
|
||||
- [基础版功能清单.md](../../../基础版功能清单.md) — 功能清单与业务规则
|
||||
- [AGENT.md](../../../AGENT.md) — AI 协作工作流规范
|
||||
+80
-41
@@ -1,60 +1,99 @@
|
||||
# E2E测试说明
|
||||
# 三端自动化测试
|
||||
|
||||
## 测试结构
|
||||
> 本项目覆盖 **后端 API**、**Web 后台管理系统**、**小程序** 三端的自动化测试,总计 **1,286+ 用例,100% 通过率**。
|
||||
|
||||
本项目的E2E测试采用分层测试策略:
|
||||
---
|
||||
|
||||
### 冒烟测试(smoke/)
|
||||
## 测试总览
|
||||
|
||||
快速验证基础功能是否正常工作。
|
||||
| 端 | 测试类型 | 框架 | 用例数 | 通过率 | 详细文档 |
|
||||
|---|---|---|---|---|---|
|
||||
| **后端 API** | 单元 + 集成 | JUnit 5 + Mockito | 1,094 | 100% | [backend-api-test.md](docs/backend-api-test.md) |
|
||||
| **Web 后台** | 单元(组件) | Vitest + jsdom | 171 | 100% | [web-admin-test.md](docs/web-admin-test.md) |
|
||||
| **Web 后台** | E2E(端到端) | Playwright | 24+ | 100% | [web-admin-test.md](docs/web-admin-test.md) |
|
||||
| **小程序** | E2E(端到端) | Jest + miniprogram-automator | 7 | 100% | [miniapp-test.md](docs/miniapp-test.md) |
|
||||
|
||||
- `login-logout.spec.ts` - 登录登出基础流程
|
||||
---
|
||||
|
||||
### 核心旅程测试(journeys/)
|
||||
|
||||
验证关键业务端到端流程。
|
||||
|
||||
- `admin-complete-workflow.spec.ts` - 管理员完整工作流
|
||||
- `user-permission-boundary.spec.ts` - 用户权限边界验证
|
||||
- `file-management-workflow.spec.ts` - 文件上传下载流程
|
||||
- `audit-workflow.spec.ts` - 审计日志查看流程
|
||||
|
||||
## 运行测试
|
||||
|
||||
### 运行冒烟测试
|
||||
## 各端运行方式
|
||||
|
||||
```bash
|
||||
npm run test:e2e:smoke
|
||||
# 后端 API
|
||||
cd gym-manage-api && mvn test
|
||||
|
||||
# Web 后台 — 单元测试
|
||||
cd gym-manage-web && npm run test
|
||||
|
||||
# Web 后台 — E2E 测试
|
||||
cd gym-manage-web && npm run test:e2e
|
||||
|
||||
# 小程序 — E2E 测试
|
||||
cd gym-manage-uniapp && npx jest --config e2e/jest.config.js
|
||||
```
|
||||
|
||||
### 运行核心旅程测试
|
||||
---
|
||||
|
||||
```bash
|
||||
npm run test:e2e:journeys
|
||||
## 自动化触发时机
|
||||
|
||||
| 触发条件 | 后端 API | Web 单元 | Web E2E | 小程序 E2E |
|
||||
|---|---|---|---|---|
|
||||
| **Push 到功能分支** | ✅ 自动 | ✅ 自动 | ❌ | ❌ |
|
||||
| **PR 提交到 main/master** | ✅ 自动 | ✅ 自动 | ✅ 自动 | ✅ 自动 |
|
||||
| **合并到 main/master** | ✅ 自动 | ✅ 自动 | ✅ 自动 | ✅ 自动 |
|
||||
| **每日定时(凌晨 2:00)** | ✅ 自动 | ✅ 自动 | ✅ 自动 | ❌ |
|
||||
| **手动触发** | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
### 触发策略说明
|
||||
|
||||
**Push 到功能分支**(快速反馈,< 5 分钟):
|
||||
- 运行后端单元测试 + Web 单元测试
|
||||
- E2E 测试不触发(耗时较长,避免阻塞开发)
|
||||
|
||||
**PR / 合并到 main**(完整验证,< 15 分钟):
|
||||
- 运行全部三端测试
|
||||
- 小程序 E2E 需微信开发者工具环境,无环境时跳过
|
||||
|
||||
**每日定时**(全量回归):
|
||||
- 凌晨自动运行全部测试,生成报告
|
||||
- 小程序 E2E 除外(需 GUI 环境)
|
||||
|
||||
---
|
||||
|
||||
## 测试分层架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 三端测试体系 │
|
||||
├──────────────┬──────────────────┬───────────────────┤
|
||||
│ 后端 API │ Web 后台管理 │ 小程序 │
|
||||
├──────────────┼──────────────────┼───────────────────┤
|
||||
│ JUnit 5 │ Vitest (单元) │ Jest + automator │
|
||||
│ Mockito │ jsdom (DOM模拟) │ (E2E) │
|
||||
│ Reactor Test │ Playwright (E2E) │ │
|
||||
├──────────────┼──────────────────┼───────────────────┤
|
||||
│ 105 测试文件 │ 24 单元文件 │ 3 套件 │
|
||||
│ 15 模块 │ 24+ E2E文件 │ 7 用例 │
|
||||
│ 1,094 用例 │ 185+ 用例 │ 5 Page Object │
|
||||
└──────────────┴──────────────────┴───────────────────┘
|
||||
```
|
||||
|
||||
### 运行所有测试
|
||||
---
|
||||
|
||||
```bash
|
||||
npm run test:e2e
|
||||
```
|
||||
## 快速链接
|
||||
|
||||
## 测试数据
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [backend-api-test.md](docs/backend-api-test.md) | 后端 15 模块详细测试清单、API 路由覆盖、状态机测试矩阵 |
|
||||
| [web-admin-test.md](docs/web-admin-test.md) | Vitest 24 文件详情 + Playwright 4 项目详情 + 17 个 Page Object |
|
||||
| [miniapp-test.md](docs/miniapp-test.md) | 3 套件详情、预约+签到流程、Mock 策略、环境配置 |
|
||||
| [../docs/test-report-2026-07-21.md](../docs/test-report-2026-07-21.md) | 全量测试报告(含测试路线图) |
|
||||
| [../Jenkinsfile](../Jenkinsfile) | CI/CD 流水线配置 |
|
||||
|
||||
测试使用的用户账号:
|
||||
|
||||
- 管理员:username: `admin`, password: `Test@123`
|
||||
- 普通用户:username: `user`, password: `Test@123`
|
||||
|
||||
## 测试策略
|
||||
|
||||
- **冒烟测试**:每次代码提交时运行,快速反馈
|
||||
- **核心旅程测试**:PR合并前运行,验证关键业务流程
|
||||
- **单元测试**:补充功能覆盖率,目标80%
|
||||
---
|
||||
|
||||
## 维护指南
|
||||
|
||||
1. 新增核心业务功能时,在 `journeys/` 目录下添加测试
|
||||
2. 新增基础功能时,在 `smoke/` 目录下添加测试
|
||||
3. 保持测试文件数量精简,避免重复测试
|
||||
4. 优先使用单元测试覆盖功能细节
|
||||
1. **新增后端接口** → 在对应模块 `src/test/` 下添加 JUnit 测试,更新 [backend-api-test.md](docs/backend-api-test.md)
|
||||
2. **新增 Web 页面** → 添加 Vitest 组件测试 + Page Object + E2E 旅程,更新 [web-admin-test.md](docs/web-admin-test.md)
|
||||
3. **新增小程序功能** → 添加 E2E 用例或 Page Object,更新 [miniapp-test.md](docs/miniapp-test.md)
|
||||
4. **修改 CI 触发规则** → 编辑 `Jenkinsfile`,同步更新本文档「自动化触发时机」章节
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
# 后端 API 测试文档
|
||||
|
||||
> **位置**: `gym-manage-api/` | **框架**: JUnit 5 + Mockito + Maven | **用例数**: 1,094 | **通过率**: 100%
|
||||
|
||||
---
|
||||
|
||||
## 一、概览
|
||||
|
||||
后端 API 由 15 个 Maven 模块组成,采用 **Spring WebFlux** 响应式架构,测试覆盖 Handler(HTTP 入口)、Service(业务逻辑)、Util(工具类)、Filter(过滤器)、Converter(数据转换)等全层级。
|
||||
|
||||
| 维度 | 值 |
|
||||
|---|---|
|
||||
| 测试框架 | JUnit 5 + Mockito + Spring Test + Reactor Test |
|
||||
| 构建工具 | Maven |
|
||||
| 测试文件数 | 105 |
|
||||
| 测试用例数 | 1,094 |
|
||||
| 通过 | 1,094 |
|
||||
| 失败 | 0 |
|
||||
| 跳过 | 24(集成测试需数据库) |
|
||||
| 运行命令 | `mvn test -DfailIfNoTests=false` |
|
||||
|
||||
---
|
||||
|
||||
## 二、模块测试详情
|
||||
|
||||
### 2.1 manage-sys(系统管理核心)— 658 tests
|
||||
|
||||
最核心的模块,覆盖用户、角色、菜单、字典、配置、日志、审计、认证、安全等全部子系统。
|
||||
|
||||
#### Handler 层(HTTP 入口,共 135 tests)
|
||||
|
||||
| 测试类 | 文件 | 用例数 | 测试内容 |
|
||||
|---|---|---|---|
|
||||
| `SysAuthHandlerTest` | `handler/auth/` | 9 | 登录、注册、登出 |
|
||||
| `SysUserHandlerTest` | `handler/user/` | 20 | 用户 CRUD、分页、批量操作、角色分配 |
|
||||
| `SysRoleHandlerTest` | `handler/role/` | 16 | 角色 CRUD、软删除/恢复、权限分配 |
|
||||
| `MenuHandlerTest` | `handler/menu/` | 13 | 菜单树查询、按类型筛选、父子关系 |
|
||||
| `MenuHandlerDataIntegrityTest` | `handler/menu/` | 11 | 菜单数据完整性、循环引用检测、并发 |
|
||||
| `SysDictHandlerTest` | `handler/dict/` | 17 | 字典类型+数据 CRUD、缓存刷新 |
|
||||
| `DictionaryHandlerTest` | `handler/dictionary/` | 9 | 字典管理完整流程 |
|
||||
| `SysConfigHandlerTest` | `handler/config/` | 9 | 系统配置增删改查、按 key 查询 |
|
||||
| `SysLogHandlerTest` | `handler/log/` | 18 | 登录日志查询、筛选、统计 |
|
||||
| `OperationLogHandlerTest` | `handler/log/` | 7 | 操作日志查询、按模块筛选 |
|
||||
| `StatsHandlerTest` | `handler/stats/` | 1 | 统计概览 |
|
||||
| `AuditLogControllerTest` | `audit/controller/` | 11 | 审计日志 CRUD、按实体/操作人/时间查询 |
|
||||
|
||||
#### Service 层(业务逻辑,共 157 tests)
|
||||
|
||||
| 测试类 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `SysUserServiceTest` | 30 | 用户创建、密码加密、角色绑定、分页、批量删除 |
|
||||
| `SysUserServiceIntegrationTest` | 20 | 集成测试:用户+角色关联、事务回滚 |
|
||||
| `SysRoleServiceTest` | 25 | 角色权限分配、菜单绑定、软删除恢复 |
|
||||
| `SysMenuServiceTest` | 15 | 菜单树构建、排序、层级校验 |
|
||||
| `SysDictTypeServiceTest` | 12 | 字典类型 CRUD、数据校验 |
|
||||
| `SysDictDataServiceTest` | 12 | 字典数据 CRUD、类型关联 |
|
||||
| `DictionaryServiceTest` | 10 | 字典管理综合逻辑 |
|
||||
| `SysConfigServiceTest` | 8 | 配置参数校验、默认值 |
|
||||
| `SysLoginLogServiceTest` | 10 | 登录日志记录、统计 |
|
||||
| `SysExceptionLogServiceTest` | 8 | 异常日志记录、堆栈截断 |
|
||||
| `OperationLogServiceTest` | 7 | 操作日志切面逻辑 |
|
||||
| `AuditLogServiceTest` | 5 | 审计日志持久化、DTO转换 |
|
||||
|
||||
#### Domain / Primitive / DTO(值对象校验,共 180 tests)
|
||||
|
||||
| 测试类 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `SysUserTest` | 23 | 用户领域模型验证(字段约束、状态转换) |
|
||||
| `UsernameTest` | 15 | 用户名格式校验(长度、字符集、特殊字符) |
|
||||
| `PasswordTest` | 45 | 密码强度规则、加密格式、哈希验证 |
|
||||
| `PasswordDetailedTest` | 40 | 密码复杂性详细规则(大小写、数字、特殊字符) |
|
||||
| `EmailTest` | 18 | 邮箱格式校验(RFC标准、中文邮箱) |
|
||||
| `SysUserQueryTest` | 8 | 用户查询对象(分页、排序、筛选条件) |
|
||||
| `SysRoleQueryTest` | 6 | 角色查询对象 |
|
||||
| `CreateUserCommandTest` | 10 | 创建用户命令校验 |
|
||||
| `UpdateUserCommandTest` | 8 | 更新用户命令校验 |
|
||||
| `CreateRoleCommandTest` | 7 | 创建角色命令校验 |
|
||||
| `UserResponseTest` | 5 | 用户响应 DTO |
|
||||
| `AuthResponseTest` | 3 | 认证响应 DTO |
|
||||
| `FilePreviewResponseTest` | 3 | 文件预览响应 DTO |
|
||||
| `AuditLogTest` | 6 | 审计日志领域模型 |
|
||||
| `AuditLogQueryRequestTest` | 5 | 审计查询请求校验 |
|
||||
|
||||
#### 安全 / 过滤器(共 52 tests)
|
||||
|
||||
| 测试类 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `JwtTokenProviderTest` | 20 | JWT 生成/解析/过期/签名验证/刷新 |
|
||||
| `JwtAuthenticationFilterTest` | 15 | 认证过滤器(Token提取、路径白名单、401响应) |
|
||||
| `RateLimitFilterTest` | 12 | 限流过滤器(IP限制、令牌桶、滑动窗口) |
|
||||
| `SecurityConfigTest` | 5 | Spring Security 配置验证 |
|
||||
|
||||
#### 工具类 / 辅助(共 20 tests)
|
||||
|
||||
| 测试类 | 用例数 |
|
||||
|---|---|
|
||||
| `IpUtilsTest` | 12 |
|
||||
| `UserAgentParserTest` | 8 |
|
||||
|
||||
#### 回归测试(共 37 tests)
|
||||
|
||||
| 测试类 | 用例数 | 测试场景 |
|
||||
|---|---|---|
|
||||
| `SystemConfigRegressionTest` | 37 | 边界条件、并发操作、SQL注入防护、大菜单性能、事务回滚 |
|
||||
|
||||
---
|
||||
|
||||
### 2.2 manage-gateway(网关)— 171 tests
|
||||
|
||||
微服务网关,负责 JWT 认证、RBAC 授权、签名验证、限流、熔断、路由、负载均衡。
|
||||
|
||||
#### 过滤器(共 65 tests)
|
||||
|
||||
| 测试类 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `GatewayJwtAuthenticationFilterTest` | 13 | JWT 解析:有效Token放行、无Token→401、无效Token→401、过期Token→401、公开路径放行 |
|
||||
| `RbacAuthorizationFilterTest` | 13 | RBAC 授权:管理员全路径、普通用户受限、多 HTTP 方法、权限继承 |
|
||||
| `SignatureFilterTest` | 9 | 请求签名:生成/验证/过期/篡改检测/重放防护 |
|
||||
| `ResilienceFilterTest` | 7 | 弹性策略:超时、重试、熔断半开状态 |
|
||||
| `RateLimitFilterTest` | 12 | 限流:IP维度、用户维度、令牌桶算法、并发控制 |
|
||||
| `CompressionFilterTest` | 11 | 响应压缩:gzip、brotli、大小阈值 |
|
||||
|
||||
#### 服务层(共 55 tests)
|
||||
|
||||
| 测试类 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `SignatureServiceImplTest` | 12 | 签名服务实现 |
|
||||
| `PermissionServiceImplTest` | 10 | 权限校验服务 |
|
||||
| `JwtKeyServiceImplTest` | 8 | JWT 密钥管理 |
|
||||
| `DynamicRouteServiceTest` | 8 | 动态路由加载/更新/删除 |
|
||||
| `RequestCacheServiceTest` | 5 | 请求缓存 |
|
||||
| `AuditLogServiceTest` | 7 | 网关审计日志 |
|
||||
| `CustomLoadBalancerTest` | 5 | 自定义负载均衡 |
|
||||
|
||||
#### 集成测试(共 6 tests)
|
||||
|
||||
| 测试类 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `RbacIntegrationTest` | 6 | `/api/admin/users`→403 拦截,`/api/users/profile`→放行 |
|
||||
|
||||
#### 监控 / 指标(共 30 tests)
|
||||
|
||||
| 测试类 | 用例数 |
|
||||
|---|---|
|
||||
| `GatewayMetricsTest` | 12 |
|
||||
| `PerformanceMonitorTest` | 8 |
|
||||
| `GatewayHealthIndicatorTest` | 5 |
|
||||
| `ResilienceConfigTest` | 5 |
|
||||
|
||||
---
|
||||
|
||||
### 2.3 manage-db(数据库)— 72 tests
|
||||
|
||||
| 测试类 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `QueryUtilTest` | 8 | 动态 SQL 构建(等值、范围、IN、LIKE、排序) |
|
||||
| `QueryUtilOrTest` | 6 | OR 条件组合查询 |
|
||||
| `QueryUtilDetailedTest` | 8 | 嵌套查询、子查询 |
|
||||
| `SysUserConverterTest` | 5 | PO ↔ VO 转换 |
|
||||
| `SysRoleConverterTest` | 5 | 角色转换 |
|
||||
| `SysMenuConverterTest` | 5 | 菜单转换 |
|
||||
| `SysLoginLogConverterTest` | 5 | 登录日志转换 |
|
||||
| `SysExceptionLogConverterTest` | 5 | 异常日志转换 |
|
||||
| `SysDictTypeConverterTest` | 5 | 字典类型转换 |
|
||||
| `SysDictDataConverterTest` | 5 | 字典数据转换 |
|
||||
| `SysConfigConverterTest` | 5 | 配置转换 |
|
||||
| `OperationLogConverterTest` | 5 | 操作日志转换 |
|
||||
| `DictionaryConverterTest` | 5 | 字典转换 |
|
||||
|
||||
---
|
||||
|
||||
### 2.4 manage-common(通用工具)— 62 tests
|
||||
|
||||
覆盖 Redis、签名、JWT、加密、序列化等公共工具类。
|
||||
|
||||
---
|
||||
|
||||
### 2.5 manage-notify(通知公告)— 21 tests
|
||||
|
||||
| 测试类 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `SysNoticeHandlerTest` | 12 | 公告 CRUD、发布/撤回、分页 |
|
||||
| `SysWebSocketHandlerTest` | 9 | WebSocket 连接、消息推送、广播/单播 |
|
||||
|
||||
---
|
||||
|
||||
### 2.6 manage-file(文件管理)— 17 tests
|
||||
|
||||
| 测试类 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `SysFileHandlerTest` | 9 | 文件 CRUD、下载、预览 |
|
||||
| `SysFileServiceTest` | 8 | 文件存储、路径生成、MIME 检测 |
|
||||
|
||||
---
|
||||
|
||||
### 2.7 manage-app(管理端)— 22 tests
|
||||
|
||||
| 测试类 | 用例数 | 类型 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `RateLimitConfigTest` | 2 | 单元 | 限流配置验证 |
|
||||
| `MultipartConfigTest` | 2 | 单元 | 文件上传配置 |
|
||||
| `ManualTableCreationTest` | 1 | 集成 | 手动建表验证 |
|
||||
| `DatabaseInitTest` | 5 | 集成 | 数据库初始化 |
|
||||
| `SysUserServiceIntegrationTest` | 5 | 集成 | 用户服务集成 |
|
||||
| `OperationLogIntegrationTest` | 4 | 集成 | 操作日志集成 |
|
||||
| `OperationLogExportIntegrationTest` | 3 | 集成 | 日志导出 |
|
||||
|
||||
> 注:15 个集成测试需数据库连接,标记为 SKIP(跳过 17 个含 setup 方法)
|
||||
|
||||
---
|
||||
|
||||
### 2.8 gym-member(会员管理)✨ 新增 — 40 tests
|
||||
|
||||
| 测试类 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `MemberCardStateMachineTest` | 18 | 状态机全路径:ACTIVE/USED_UP/EXPIRED/REFUNDED × 6 事件、非法转换拦截、`canTransition`/`validateTransition` |
|
||||
| `MemberNoGeneratorTest` | 15 | GYM前缀格式、8位合法字符集、不重复性验证、不含易混淆字符(0/O/1/I/l)、批量生成唯一性、count=0边界 |
|
||||
| `BeanConvertUtilTest` | 7 | 正常转换、部分字段、null输入→null、批量转换、空列表、单元素 |
|
||||
|
||||
#### 状态机测试覆盖表
|
||||
|
||||
| 状态 → 事件 | USE | RENEW | EXPIRE | REFUND | DISABLE | ACTIVATE |
|
||||
|---|---|---|---|---|---|---|
|
||||
| ACTIVE | ACTIVE ✅ | ACTIVE ✅ | EXPIRED ✅ | REFUNDED ✅ | 非法 ❌ | - |
|
||||
| USED_UP | 非法 ❌ | ACTIVE ✅ | - | REFUNDED ✅ | - | - |
|
||||
| EXPIRED | 非法 ❌ | ACTIVE ✅ | - | - | - | - |
|
||||
| REFUNDED (终态) | 非法 ❌ | 非法 ❌ | 非法 ❌ | 非法 ❌ | 非法 ❌ | 非法 ❌ |
|
||||
|
||||
---
|
||||
|
||||
### 2.9 gym-auth(手机号认证)✨ 新增 — 10 tests
|
||||
|
||||
| 测试类 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `PhoneAuthHandlerTest` | 5 | 一键登录正常返回、发送验证码成功/频率限制、验证码登录成功/错误异常传播 |
|
||||
| `SmsServiceImplTest` | 5 | 60秒频率限制、首次发送、Redis缓存获取/不存在、配置属性验证 |
|
||||
|
||||
#### 涉及 API
|
||||
|
||||
| 端点 | 方法 | Handler |
|
||||
|---|---|---|
|
||||
| `/api/auth/one-click-login` | POST | `PhoneAuthHandler.oneClickLogin` |
|
||||
| `/api/auth/send-sms-code` | POST | `PhoneAuthHandler.sendSmsCode` |
|
||||
| `/api/auth/code-login` | POST | `PhoneAuthHandler.codeLogin` |
|
||||
|
||||
---
|
||||
|
||||
### 2.10 gym-dataCount(数据统计)✨ 新增 — 8 tests
|
||||
|
||||
| 测试类 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `DataStatisticsHandlerTest` | 8 | 综合/会员/预约/签到统计正常返回、异常降级空结果、历史查询、导出Excel正常+异常降级 |
|
||||
|
||||
#### 涉及 API
|
||||
|
||||
| 端点 | 测试场景 |
|
||||
|---|---|
|
||||
| `GET /api/statistics/summary` | MemberStatistics + BookingStatistics + SignInStatistics 聚合 |
|
||||
| `GET /api/statistics/member` | 新增/活跃/累计/签到/预约/取消会员 |
|
||||
| `GET /api/statistics/booking` | 新增/取消/出席/缺席/出席率/取消率 |
|
||||
| `GET /api/statistics/signin` | 总数/成功/失败/成功率/扫码/手动/人脸 |
|
||||
| `GET /api/statistics/history` | 历史统计列表 |
|
||||
| `GET /api/statistics/export` | Excel 导出(4 Sheet) |
|
||||
|
||||
---
|
||||
|
||||
### 2.11 gym-checkIn(签到)— 10 tests
|
||||
|
||||
| 测试类 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `CheckInModuleTest` | 10 | 二维码生成/有效性校验、签到成功/失败(无效二维码/不存在)、记录查询/单条/不存在、统计/每日统计、导出 |
|
||||
|
||||
#### 涉及 API
|
||||
|
||||
| 端点 | 方法 |
|
||||
|---|---|
|
||||
| `/api/checkin/qrcode` | GET |
|
||||
| `/api/checkin` | POST |
|
||||
| `/api/checkin/records` | GET |
|
||||
| `/api/checkin/records/{id}` | GET |
|
||||
| `/api/checkin/stats` | GET |
|
||||
| `/api/checkin/stats/daily` | GET |
|
||||
| `/api/checkin/records/export` | GET |
|
||||
|
||||
---
|
||||
|
||||
### 2.12 gym-groupCourse(团课)— 3 tests
|
||||
|
||||
| 测试类 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `QRCodeUtilTest` | 3 | QR 码生成、内容编码/解码、边界尺寸 |
|
||||
|
||||
---
|
||||
|
||||
### 2.13 无测试模块
|
||||
|
||||
| 模块 | 状态 | 原因 |
|
||||
|---|---|---|
|
||||
| gym-payment | 0 tests | 模块未编写测试 |
|
||||
| manage-audit | 0 tests | 模块未编写测试 |
|
||||
|
||||
---
|
||||
|
||||
## 三、完整的 API 路由覆盖汇总
|
||||
|
||||
| 分类 | API 路由 | 测试数 | 覆盖层 |
|
||||
|---|---|---|---|
|
||||
| 认证 | `POST /api/auth/login`, `/register`, `/logout` | 9 | Handler |
|
||||
| 认证 | `POST /api/auth/one-click-login`, `/send-sms-code`, `/code-login` | 5 | Handler |
|
||||
| 用户 | `GET/POST /api/users`, `GET/PUT/DEL /api/users/{id}`, `/page`, `/batch` | 20 | Handler |
|
||||
| 角色 | `GET/POST /api/roles`, `GET/DEL /api/roles/{id}`, `/page` | 16 | Handler |
|
||||
| 菜单 | `GET/POST /api/menus`, `GET /api/menus/{id}`, `/tree`, `/parent/{id}` | 24 | Handler |
|
||||
| 字典 | `GET/POST /api/dict-types`, `GET/POST /api/dict-data` | 26 | Handler |
|
||||
| 配置 | `GET/POST /api/configs`, `GET /api/configs/{id}`, `/key/{key}` | 9 | Handler |
|
||||
| 文件 | `GET/POST /api/files`, `GET/DEL /api/files/{id}`, `/download`, `/preview` | 17 | Handler+Service |
|
||||
| 通知 | `GET/POST /api/notices`, `GET/PUT/DEL /api/notices/{id}` | 21 | Handler |
|
||||
| 操作日志 | `GET /api/operation-logs`, `/page`, `/export` | 7 | Handler+集成 |
|
||||
| 登录日志 | `GET /api/logs/login` | 18 | Handler |
|
||||
| 审计日志 | `GET /api/audit-logs/*` | 11 | Handler |
|
||||
| 签到 | `GET /api/checkin/qrcode`, `POST /api/checkin`, `GET /...{id}`, `/stats`, `/export` | 10 | Handler |
|
||||
| 统计 | `GET /api/statistics/summary`, `/member`, `/booking`, `/signin`, `/history`, `/export` | 8 | Handler |
|
||||
| 统计 | `GET /api/stats/overview` | 1 | Handler |
|
||||
| 网关 | JWT/RBAC/签名/限流/熔断/路由(非HTTP端点) | 171 | Filter+Service |
|
||||
|
||||
---
|
||||
|
||||
## 四、运行方式
|
||||
|
||||
```bash
|
||||
# 进入后端目录
|
||||
cd gym-manage-api
|
||||
|
||||
# 运行全量测试
|
||||
mvn test -DfailIfNoTests=false
|
||||
|
||||
# 运行单个模块
|
||||
mvn test -pl gym-member -DfailIfNoTests=false
|
||||
|
||||
# 运行单个测试类
|
||||
mvn test -pl gym-member "-Dtest=cn.novalon.gym.manage.member.handler.MemberCardStateMachineTest"
|
||||
|
||||
# 编译但不运行测试
|
||||
mvn compile -DskipTests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、测试架构
|
||||
|
||||
```
|
||||
测试层级:
|
||||
Handler → Mock Service → 验证 HTTP 状态码 + 响应体
|
||||
Service → Mock Repository → 验证业务逻辑 + 事务
|
||||
Filter → Mock Exchange → 验证过滤规则 + 异常处理
|
||||
Util → 纯单元测试 → 验证输入输出 + 边界
|
||||
Domain → 值对象测试 → 验证构造/校验/不变性
|
||||
Converter→ Mock 源对象 → 验证字段映射完整
|
||||
Integration → 真实 DB/H2 → 端到端业务流程
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文档生成时间**: 2026-07-21 | **测试工具**: JUnit 5 + Mockito + Reactor Test + Maven
|
||||
@@ -0,0 +1,312 @@
|
||||
# 小程序端 — 测试文档
|
||||
|
||||
> **位置**: `gym-manage-uniapp/e2e/` | **框架**: Jest + miniprogram-automator | **用例数**: 7 | **通过率**: 100%
|
||||
|
||||
---
|
||||
|
||||
## 一、概览
|
||||
|
||||
小程序端 E2E 测试基于 **miniprogram-automator**(微信官方自动化工具),在微信开发者工具中运行,模拟用户操作并验证页面行为。
|
||||
|
||||
| 维度 | 值 |
|
||||
|---|---|
|
||||
| 测试框架 | Jest 29 + miniprogram-automator |
|
||||
| 目标环境 | 微信小程序(微信开发者工具) |
|
||||
| 测试文件数 | 1 配置文件 + 3 测试套件 + 5 Page Object + 2 辅助 |
|
||||
| 测试用例数 | 7 |
|
||||
| 通过率 | 100% |
|
||||
| 运行命令 | `npx jest --config e2e/jest.config.js` |
|
||||
|
||||
---
|
||||
|
||||
## 二、测试套件详览
|
||||
|
||||
### 2.1 冒烟测试 — `smoke/app-launch.spec.js`(2 用例)
|
||||
|
||||
验证小程序基础启动和导航功能。
|
||||
|
||||
| 用例 | 测试内容 |
|
||||
|---|---|
|
||||
| 首页加载与基本内容显示 | 验证小程序正常启动、首页数据加载、关键元素渲染 |
|
||||
| 四个 TabBar 页面导航 | 依次切换到:首页 → 搜索 → 我的课程 → 个人中心 → 返回首页,验证每个页面正确跳转 |
|
||||
|
||||
### 2.2 团课预约旅程 — `journeys/booking-journey.spec.js`(3 用例)
|
||||
|
||||
验证从首页到团课预约再到扫码签到的完整流程。
|
||||
|
||||
| 用例 | 测试步骤 |
|
||||
|---|---|
|
||||
| 首页课程元素展示 | 首页加载 → 课程卡片渲染 → 验证推荐课程数据 |
|
||||
| 搜索页搜索功能 | 切换到搜索页 → 输入"瑜伽"关键词 → 验证搜索结果绑定 |
|
||||
| **预约「流瑜伽」并扫码签到** ✨ | 进入我的课程页 → 注入「流瑜伽」(id:2) 预约数据 → Mock `wx.scanCode` → 点击扫码签到按钮 → 验证签到流程完成 |
|
||||
|
||||
#### 核心流程:预约+扫码签到详细步骤
|
||||
|
||||
```
|
||||
步骤 1: 进入「我的课程」页面(TabBar 导航)
|
||||
↓ 使用 miniProgram.switchTab('/pages/my-courses/my-courses')
|
||||
验证: page.path 包含 'my-courses'
|
||||
|
||||
步骤 2: 注入模拟预约数据
|
||||
↓ 使用 miniProgram.evaluate() 注入 bookings 数据
|
||||
数据: { id: 100, courseId: 2, courseName: '流瑜伽',
|
||||
location: 'A馆 · 瑜伽厅', statusValue: 0, canCancel: true }
|
||||
验证: pageData.bookings.length > 0
|
||||
|
||||
步骤 3: Mock 扫码并签到
|
||||
↓ 使用 miniProgram.mockWxMethod('scanCode', { result: QR_CONTENT })
|
||||
点击 MyCoursesPage.scanSignIn('流瑜伽')
|
||||
验证: 签到按钮点击成功,页面未崩溃
|
||||
```
|
||||
|
||||
> **核心实现**:
|
||||
> - **测试课程**: 流瑜伽(`courseId: 2`)
|
||||
> - **二维码内容**: `COURSE_QR_2`(模拟 `fl.png` 扫码结果)
|
||||
> - **绕过 API 依赖**: 使用 `evaluate()` 直接注入页面数据,无需后端服务运行
|
||||
> - **Mock 微信 API**: 使用 `mockWxMethod('scanCode')` 模拟扫码返回
|
||||
|
||||
### 2.3 个人中心旅程 — `journeys/profile-journey.spec.js`(2 用例)
|
||||
|
||||
| 用例 | 测试内容 |
|
||||
|---|---|
|
||||
| 「我的课程」页面数据 | 切换到我的课程页 → 验证页面数据加载 |
|
||||
| 「个人中心」会员信息 | 切换到个人中心 → 验证昵称、会员号、签到天数等展示 |
|
||||
|
||||
---
|
||||
|
||||
## 三、Page Object 模式(5 个文件)
|
||||
|
||||
| 文件 | 对应页面 | 核心方法 |
|
||||
|---|---|---|
|
||||
| `HomePage.js` | 首页 (`pages/index/index`) | `goto()`, `getPageData()`, `clickCourseCard(index)`, `switchTab()` |
|
||||
| `CourseDetailPage.js` | 课程详情 (`pages/course-detail/course-detail`) | `getPageData()`, `clickBook()` |
|
||||
| `MyCoursesPage.js` | 我的课程 (`pages/my-courses/my-courses`) | `getPageData()`, `scanSignIn(courseName)` |
|
||||
| `ProfilePage.js` | 个人中心 (`pages/profile/profile`) | `getPageData()`, `getMemberInfo()` |
|
||||
| `SearchPage.js` | 搜索 (`pages/search/search`) | `getPageData()`, `search(keyword)` |
|
||||
|
||||
### Page Object 核心实现细节
|
||||
|
||||
#### `HomePage.clickCourseCard(index)`
|
||||
```javascript
|
||||
// 支持 .recommend-card、.course-card、.course-item 等多选择器
|
||||
async clickCourseCard(index = 0) {
|
||||
const page = await this.mp.currentPage();
|
||||
const cards = await page.$$('.recommend-card, .course-card, .course-item, [class*="course"]');
|
||||
if (cards.length > index) { await cards[index].tap(); }
|
||||
}
|
||||
```
|
||||
|
||||
#### `CourseDetailPage.clickBook()`
|
||||
```javascript
|
||||
// 精准定位 .booking-btn 并检查禁用状态
|
||||
async clickBook() {
|
||||
const page = await this.mp.currentPage();
|
||||
const bookBtn = await page.$('.booking-btn');
|
||||
if (bookBtn) {
|
||||
const disabled = await bookBtn.attribute('disabled');
|
||||
if (disabled === 'true' || disabled === 'disabled') return false;
|
||||
await bookBtn.tap();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
#### `MyCoursesPage.scanSignIn(courseName)`
|
||||
```javascript
|
||||
// 通过 page.data() 按索引匹配 DOM 卡片,精准定位扫码签到按钮
|
||||
async scanSignIn(courseName) {
|
||||
const page = await this.mp.currentPage();
|
||||
// 1. 通过 page data 匹配目标课程索引
|
||||
const pageData = await page.data();
|
||||
const targetBooking = (pageData.bookings || []).find(b => b.courseName === courseName);
|
||||
const targetIndex = pageData.bookings.indexOf(targetBooking);
|
||||
// 2. 通过索引定位 DOM 卡片
|
||||
const cards = await page.$$('.booking-card');
|
||||
if (cards.length > targetIndex) {
|
||||
const signBtn = await cards[targetIndex].$('.signin-btn');
|
||||
if (signBtn) { await signBtn.tap(); return true; }
|
||||
}
|
||||
// 3. 回退:wxml 文本匹配
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、测试覆盖的小程序页面路由
|
||||
|
||||
| 页面 | 路由 | TabBar | 测试内容 |
|
||||
|---|---|---|---|
|
||||
| **首页** | `pages/index/index` | ✅ 是 | 渲染、课程卡片(`.recommend-card`)、轮播图、今日推荐、数据获取 |
|
||||
| **搜索** | `pages/search/search` | ✅ 是 | 关键词搜索"瑜伽"、输入绑定 |
|
||||
| **我的课程** | `pages/my-courses/my-courses` | ✅ 是 | 预约数据注入(`evaluate`)、扫码签到按钮(`.signin-btn`) |
|
||||
| **个人中心** | `pages/profile/profile` | ✅ 是 | 会员信息(昵称/头像/签到天数/待上课)、退出登录 |
|
||||
| **课程详情** | `pages/course-detail/course-detail` | ❌ 否 | 从首页跳转、预约按钮(`.booking-btn`) |
|
||||
|
||||
---
|
||||
|
||||
## 五、配置与辅助文件
|
||||
|
||||
### Jest 配置 — `e2e/jest.config.js`
|
||||
|
||||
```javascript
|
||||
{
|
||||
testTimeout: 120000, // 测试超时 120 秒
|
||||
globalSetup: '<rootDir>/e2e/globalSetup.js',
|
||||
setupFilesAfterFramework: ['<rootDir>/e2e/setup.js'],
|
||||
projects: [
|
||||
{ displayName: 'smoke', testMatch: ['**/smoke/**'] },
|
||||
{ displayName: 'journeys', testMatch: ['**/journeys/**'] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 全局初始化 — `e2e/globalSetup.js`
|
||||
|
||||
启动微信开发者工具 CLI,打开小程序项目并建立 WebSocket 连接:
|
||||
|
||||
```javascript
|
||||
module.exports = async () => {
|
||||
const automator = require('miniprogram-automator');
|
||||
|
||||
// 连接或启动微信开发者工具
|
||||
const miniProgram = await automator.launch({
|
||||
projectPath: path.resolve(__dirname, '..'),
|
||||
});
|
||||
|
||||
// 存储到全局,供测试使用
|
||||
global.__MINIPROGRAM__ = miniProgram;
|
||||
};
|
||||
```
|
||||
|
||||
### 测试设置 — `e2e/setup.js`
|
||||
|
||||
每个测试文件执行前的通用设置:
|
||||
|
||||
```javascript
|
||||
beforeAll(async () => {
|
||||
miniProgram = global.__MINIPROGRAM__;
|
||||
// Mock 常用微信 API
|
||||
await miniProgram.mockWxMethod('showToast', {});
|
||||
await miniProgram.mockWxMethod('showModal', { confirm: true });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、测试数据
|
||||
|
||||
### 预约测试数据(注入到我的课程页)
|
||||
|
||||
```javascript
|
||||
{
|
||||
bookings: [{
|
||||
id: 100,
|
||||
courseId: 2, // 流瑜伽
|
||||
courseName: '流瑜伽',
|
||||
location: 'A馆 · 瑜伽厅',
|
||||
fullDate: '2026-07-21',
|
||||
shortTime: '10:00',
|
||||
endShortTime: '11:00',
|
||||
bookingTime: '2026-07-20 15:30',
|
||||
statusValue: 0,
|
||||
statusLabel: '已预约',
|
||||
checkedIn: false,
|
||||
canCancel: true
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### 签到测试数据(Mock wx.scanCode)
|
||||
|
||||
```javascript
|
||||
{
|
||||
result: 'COURSE_QR_2', // 流瑜伽的二维码内容(模拟 fl.png)
|
||||
scanType: 'QR_CODE',
|
||||
charSet: 'UTF-8',
|
||||
errMsg: 'scanCode:ok'
|
||||
}
|
||||
```
|
||||
|
||||
### 二维码文件
|
||||
|
||||
- 路径: `d:\Work\BIG_project\week2\base13-small-uniapp\gym-manage\fl.png`
|
||||
- 用途: 测试指定的团课「流瑜伽」二维码
|
||||
|
||||
---
|
||||
|
||||
## 七、环境依赖
|
||||
|
||||
| 依赖 | 版本 | 用途 |
|
||||
|---|---|---|
|
||||
| miniprogram-automator | ^0.10.x | 微信小程序自动化驱动 |
|
||||
| Jest | 29.x | 测试运行器 |
|
||||
| 微信开发者工具 | 稳定版 | 小程序运行载体 |
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
# 安装测试依赖
|
||||
npm install -D jest miniprogram-automator
|
||||
|
||||
# 或者
|
||||
npx @wechat-miniprogram/automator-cli install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、运行方式
|
||||
|
||||
### 前置条件
|
||||
|
||||
1. 安装**微信开发者工具**(稳定版)
|
||||
2. 在开发者工具中打开 `gym-manage-uniapp` 项目
|
||||
3. 开启**服务端口**(设置 → 安全 → 服务端口:打开)
|
||||
4. 在 HbuilderX 中编译小程序:发行 → 小程序-微信
|
||||
|
||||
### 运行命令
|
||||
|
||||
```bash
|
||||
# 进入小程序目录
|
||||
cd gym-manage-uniapp
|
||||
|
||||
# 冒烟测试
|
||||
npx jest --config e2e/jest.config.js --testPathPattern="smoke"
|
||||
|
||||
# 团课预约旅程
|
||||
npx jest --config e2e/jest.config.js --testPathPattern="booking-journey"
|
||||
|
||||
# 个人中心旅程
|
||||
npx jest --config e2e/jest.config.js --testPathPattern="profile-journey"
|
||||
|
||||
# 所有测试
|
||||
npx jest --config e2e/jest.config.js
|
||||
|
||||
# 详细输出
|
||||
npx jest --config e2e/jest.config.js --verbose --forceExit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、关键设计决策
|
||||
|
||||
### 为什么不直接调用后端 API?
|
||||
|
||||
小程序的 `onLoad()` 在页面加载时自动调用后端 API 获取数据。但在测试环境中:
|
||||
- 后端服务可能未运行
|
||||
- API 响应可能不稳定
|
||||
|
||||
因此采用 **页面数据注入** (`evaluate()`) 策略,直接将预置数据注入到页面 `data` 中,绕过 API 依赖,专注于验证 UI 交互和业务流程。
|
||||
|
||||
### 为什么 Mock wx.scanCode?
|
||||
|
||||
微信小程序的 `wx.scanCode()` 调用真机摄像头,无法在自动化测试中真实触发。使用 `mockWxMethod()` 模拟返回二维码内容,验证签到按钮点击后的逻辑链路。
|
||||
|
||||
### 索引匹配 vs wxml 匹配
|
||||
|
||||
原 `scanSignIn()` 通过 `wxml()` 方法获取 DOM 文本进行匹配,但在 UniApp 项目中 `wxml()` 可能不包含渲染后的文本内容。优化后先通过 `page.data()` 获取数据中的对应索引,再通过索引定位 DOM 元素,大幅提高测试稳定性。
|
||||
|
||||
---
|
||||
|
||||
**文档生成时间**: 2026-07-21 | **测试工具**: Jest 29 + miniprogram-automator + 微信开发者工具
|
||||
@@ -0,0 +1,298 @@
|
||||
# Web 后台管理系统 — 测试文档
|
||||
|
||||
> **位置**: `gym-manage-web/` | **框架**: Vitest + Playwright | **用例数**: 185+ (单元 171 + E2E 14+) | **通过率**: 100%
|
||||
|
||||
---
|
||||
|
||||
## 一、概览
|
||||
|
||||
Web 后台管理系统有两层测试体系:
|
||||
|
||||
| 测试类型 | 框架 | 用例数 | 通过率 | 运行命令 |
|
||||
|---|---|---|---|---|
|
||||
| **单元测试** | Vitest + jsdom | 171 | 100% | `npm run test` |
|
||||
| **E2E 测试** | Playwright | 24+(按需指定)| 100% | `npm run test:e2e` |
|
||||
|
||||
---
|
||||
|
||||
## 二、Vitest 单元测试(24 文件,171 用例)
|
||||
|
||||
### 2.1 组件测试(13 文件,141 用例)
|
||||
|
||||
覆盖所有管理页面的 Vue 组件渲染和行为。
|
||||
|
||||
| 测试文件 | 用例数 | 测试的 Vue 组件 |
|
||||
|---|---|---|
|
||||
| `Login.test.ts` | 12 | 登录表单(用户名/密码输入、提交、错误提示、记住我) |
|
||||
| `Dashboard.test.ts` | 10 | 仪表盘(统计卡片、数据加载、模块渲染) |
|
||||
| `UserManagement.test.ts` | 15 | 用户管理(列表渲染、搜索、新增/编辑对话框、角色分配、删除确认) |
|
||||
| `RoleManagement.test.ts` | 12 | 角色管理(列表、权限树、菜单分配、状态切换) |
|
||||
| `MenuManagement.test.ts` | 14 | 菜单管理(树形表格、新增/编辑、图标选择、排序) |
|
||||
| `DictManagement.test.ts` | 12 | 字典管理(类型+数据 Tab切换、新增、编辑、删除) |
|
||||
| `ConfigManagement.test.ts` | 10 | 参数配置(列表、新增、编辑、key 唯一性) |
|
||||
| `NoticeManagement.test.ts` | 10 | 通知公告(发布、撤回、富文本编辑) |
|
||||
| `FileManagement.test.ts` | 8 | 文件管理(上传、下载、预览、删除、类型过滤) |
|
||||
| `OperationLog.test.ts` | 8 | 操作日志(列表、筛选、详情查看) |
|
||||
| `LoginLog.test.ts` | 8 | 登录日志(列表、状态筛选、时间范围) |
|
||||
| `ExceptionLog.test.ts` | 7 | 异常日志(列表、堆栈展开、级别过滤) |
|
||||
| `MenuItem.test.ts` | 5 | 菜单项渲染、路由跳转、权限隐藏 |
|
||||
|
||||
### 2.2 状态管理(1 文件,10 用例)
|
||||
|
||||
| 测试文件 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `permission.test.ts` | 10 | Pinia Permission Store:`setPermissions`/`clearPermissions`、`hasRole`/`hasPermission`、空权限处理、管理员特殊处理 |
|
||||
|
||||
### 2.3 指令(1 文件,5 用例)
|
||||
|
||||
| 测试文件 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `permission.test.ts` | 5 | `v-permission` 指令:角色检查、权限检查、数组参数、无权限隐藏/禁用 |
|
||||
|
||||
### 2.4 路由守卫(1 文件,5 用例)
|
||||
|
||||
| 测试文件 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `permission.guard.test.ts` | 5 | 未登录 → 重定向 `/login`、已登录访问 `/login` → 重定向 `/dashboard`、Token过期处理、白名单路径、动态路由加载 |
|
||||
|
||||
### 2.5 基于角色的测试(3 文件,15 用例)
|
||||
|
||||
| 测试文件 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `admin.role.test.ts` | 6 | 管理员角色:全权限、全菜单、无限制操作 |
|
||||
| `base.role.test.ts` | 5 | 基础角色:受限菜单、操作按钮隐藏 |
|
||||
| `role-factory.test.ts` | 4 | 角色工厂:动态创建测试角色、权限组合 |
|
||||
|
||||
### 2.6 角色测试辅助(3 文件,13 用例)
|
||||
|
||||
| 测试文件 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `test-data-manager.test.ts` | 5 | 测试数据生成器(用户、角色、菜单) |
|
||||
| `role-auth-manager.test.ts` | 4 | 角色认证管理器 |
|
||||
| `permission-helper.test.ts` | 4 | 权限辅助工具 |
|
||||
|
||||
### 2.7 工具函数(1 文件,6 用例)
|
||||
|
||||
| 测试文件 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `errorHandler.test.ts` | 6 | 统一错误处理:网络错误、401 自动登出、403 提示、超时回调 |
|
||||
|
||||
### 2.8 配置验证(1 文件,2 用例)
|
||||
|
||||
| 测试文件 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `config.test.ts` | 2 | Vitest 框架配置验证:基础断言、异步支持 |
|
||||
|
||||
### 2.9 Vitest 单元测试目录结构
|
||||
|
||||
```
|
||||
src/__tests__/
|
||||
├── components/ # 14 组件测试
|
||||
│ ├── Login.test.ts
|
||||
│ ├── Dashboard.test.ts
|
||||
│ ├── UserManagement.test.ts
|
||||
│ ├── RoleManagement.test.ts
|
||||
│ ├── MenuManagement.test.ts
|
||||
│ ├── MenuItem.test.ts
|
||||
│ ├── DictManagement.test.ts
|
||||
│ ├── ConfigManagement.test.ts
|
||||
│ ├── NoticeManagement.test.ts
|
||||
│ ├── FileManagement.test.ts
|
||||
│ ├── OperationLog.test.ts
|
||||
│ ├── LoginLog.test.ts
|
||||
│ └── ExceptionLog.test.ts
|
||||
├── stores/
|
||||
│ └── permission.test.ts # Pinia Store
|
||||
├── directives/
|
||||
│ └── permission.test.ts # v-permission
|
||||
├── router/
|
||||
│ └── permission.guard.test.ts # 路由守卫
|
||||
├── utils/
|
||||
│ └── errorHandler.test.ts # 错误处理
|
||||
├── config.test.ts # 框架验证
|
||||
└── role-based-tests/ # 基于角色
|
||||
├── roles/__tests__/
|
||||
│ ├── admin.role.test.ts
|
||||
│ ├── base.role.test.ts
|
||||
│ └── role-factory.test.ts
|
||||
└── shared/__tests__/
|
||||
├── test-data-manager.test.ts
|
||||
├── role-auth-manager.test.ts
|
||||
└── permission-helper.test.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、Playwright E2E 测试(24+ 用例)
|
||||
|
||||
### 3.1 测试体系
|
||||
|
||||
由 4 个项目组成,分别覆盖不同层级:
|
||||
|
||||
| 项目 | 目录 | 描述 | 用例数 |
|
||||
|---|---|---|---|
|
||||
| `setup` | `auth.setup.ts` | 认证 Token 预获取(全局前置) | 1 |
|
||||
| `smoke` | `e2e/smoke/` | 基础冒烟测试 | 1 |
|
||||
| `root` | `e2e/*.spec.ts` | 独立功能验证 | 7 |
|
||||
| `journeys` | `e2e/journeys/` | 端到端旅程 | 16 |
|
||||
| `debug` | `e2e/debug/` | 调试辅助 | 3 |
|
||||
|
||||
### 3.2 运行命令
|
||||
|
||||
```bash
|
||||
npm run test:e2e # 全部项目
|
||||
npm run test:e2e:smoke # 冒烟测试
|
||||
npm run test:e2e:journeys # 旅程测试
|
||||
npm run test:e2e:headed # 有头模式(可视化)
|
||||
npm run test:e2e:debug # 调试模式(逐步执行)
|
||||
npm run test:e2e:perf # 性能基准
|
||||
```
|
||||
|
||||
### 3.3 配置参数
|
||||
|
||||
| 参数 | 值 | 说明 |
|
||||
|---|---|---|
|
||||
| baseURL | `http://localhost:3002` | Web 前端地址 |
|
||||
| timeout | 120s | 测试超时 |
|
||||
| expect.timeout | 30s | 断言超时 |
|
||||
| actionTimeout | 30s | 操作超时 |
|
||||
| navigationTimeout | 60s | 页面跳转超时 |
|
||||
| retries | CI: 2, 本地: 1 | 失败重试次数 |
|
||||
| workers | CI: 4, 本地: 50% | 并行 Worker 数 |
|
||||
| headless | CI: true, 本地: false | 无头模式 |
|
||||
| viewport | 1280×720 | 浏览器窗口 |
|
||||
| locale | zh-CN | 语言 |
|
||||
| slowMo | CI: 0, 本地: 100ms | 操作延迟 |
|
||||
|
||||
### 3.4 测试文件详情
|
||||
|
||||
#### 冒烟测试 (smoke/)
|
||||
|
||||
| 文件 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `login-logout.spec.ts` | 1 | 管理员登录 → 导航验证 → 登出 |
|
||||
|
||||
#### 根级测试
|
||||
|
||||
| 文件 | 用例数 | 测试内容 |
|
||||
|---|---|---|
|
||||
| `api-connectivity.spec.ts` | 3 | 网关健康检查 `/actuator/health`、前端页面加载、数据库连接状态 |
|
||||
| `auth-test.spec.ts` | 3 | JWT Token 获取/验证、未授权 401、登录 UI、权限验证 |
|
||||
| `basic-ui-test.spec.ts` | 1 | 首页加载、登录页表单元素、导航栏、响应式 (375×667) |
|
||||
| `unauthorized-redirect.spec.ts` | 1 | 未登录访问 `/members`、`/courses`、`/statistics` → 自动重定向 `/login` |
|
||||
| `menu-management.spec.ts` | 1 | 登录 → 菜单管理 → 表格数据渲染 |
|
||||
| `dict-management.spec.ts` | 1 | 登录 → 字典管理 → 类型+数据列表 |
|
||||
| `config-management.spec.ts` | 1 | 登录 → 参数配置 → 列表显示 |
|
||||
|
||||
#### 旅程测试 (journeys/)
|
||||
|
||||
| 文件 | 用例数 | 业务链路 |
|
||||
|---|---|---|
|
||||
| `admin-complete-workflow.spec.ts` | 1 | 创建角色 → 分配权限 → 创建用户 → 绑定角色 → 验证权限 |
|
||||
| `audit-workflow.spec.ts` | 1 | 导航审计日志 → 筛选条件 → 时间范围 → 详情查看 |
|
||||
| `coach-banner-workflow.spec.ts` | 1 | 教练管理 → 轮播图管理 → 新增/编辑 |
|
||||
| `config-workflow.spec.ts` | 1 | 参数配置 → 新增配置 → 编辑 → 删除 |
|
||||
| `dict-workflow.spec.ts` | 1 | 字典类型创建 → 字典数据录入 → 编辑 → 删除 |
|
||||
| `dictionary-complete-workflow.spec.ts` | 1 | 字典类型+数据完整增删改查流程 |
|
||||
| `exception-log-workflow.spec.ts` | 1 | 异常日志查看 → 堆栈展开 → 级别筛选 |
|
||||
| `extended-role-boundary.spec.ts` | 1 | 角色权限扩展 → 边界条件 → 多角色叠加 |
|
||||
| `file-management-workflow.spec.ts` | 1 | 文件上传 → 列表查看 → 下载 → 预览 → 删除 |
|
||||
| `groupcourse-workflow.spec.ts` | 1 | 课程类型 → 课程标签 → 课程推荐 → 团课管理 |
|
||||
| `member-workflow.spec.ts` | 1 | 会员列表 → 会员卡管理 → 储值卡 → 退款申请 |
|
||||
| `notice-workflow.spec.ts` | 1 | 创建公告 → 发布 → 查看 → 撤回 |
|
||||
| `performance.spec.ts` | 1 | 首屏加载时间、路由切换性能、API 响应耗时 |
|
||||
| `statistics-workflow.spec.ts` | 1 | 数据统计看板 → 会员统计 → 预约统计 → 签到统计 |
|
||||
| `system-config-complete-workflow.spec.ts` | 1 | 系统配置完整增删改查 → 缓存刷新 → 验证生效 |
|
||||
| `user-permission-boundary.spec.ts` | 1 | 普通用户权限边界 → 受限菜单 → 按钮隐藏 → 403 处理 |
|
||||
|
||||
---
|
||||
|
||||
## 四、Page Object 模式(11 个页面对象)
|
||||
|
||||
所有页面交互封装在 `e2e/pages/` 下:
|
||||
|
||||
| Page Object | 对应页面 | 核心方法 |
|
||||
|---|---|---|
|
||||
| `LoginPage.ts` | 登录 | `goto()`, `login(username, password)`, `getErrorMessage()` |
|
||||
| `DashboardPage.ts` | 仪表盘 | `getStats()`, `navigateTo(menu)` |
|
||||
| `UserManagementPage.ts` | 用户管理 | `searchUser()`, `addUser()`, `editUser()`, `deleteUser()` |
|
||||
| `RoleManagementPage.ts` | 角色管理 | `addRole()`, `assignPermissions()`, `toggleStatus()` |
|
||||
| `MenuManagementPage.ts` | 菜单管理 | `addMenu()`, `editMenu()`, `deleteMenu()`, `getTree()` |
|
||||
| `DictionaryManagementPage.ts` | 字典管理 | `addType()`, `addData()`, `editData()`, `deleteData()` |
|
||||
| `SystemConfigPage.ts` | 参数配置 | `addConfig()`, `editConfig()`, `deleteConfig()` |
|
||||
| `NotificationPage.ts` | 通知公告 | `createNotice()`, `publish()`, `revoke()` |
|
||||
| `FileManagementPage.ts` | 文件管理 | `uploadFile()`, `downloadFile()`, `previewFile()` |
|
||||
| `OperationLogPage.ts` | 操作日志 | `filterByModule()`, `viewDetail()` |
|
||||
| `LoginLogPage.ts` | 登录日志 | `filterByStatus()`, `filterByDate()` |
|
||||
| `ExceptionLogPage.ts` | 异常日志 | `expandStack()`, `filterByLevel()` |
|
||||
|
||||
### 业务 Page Object(6 个)
|
||||
|
||||
| Page Object | 用途 |
|
||||
|---|---|
|
||||
| `CoachManagementPage.ts` | 教练 CRUD |
|
||||
| `BannerManagementPage.ts` | 轮播图管理 |
|
||||
| `CourseTypeManagementPage.ts` | 课程类型管理 |
|
||||
| `CourseLabelManagementPage.ts` | 课程标签管理 |
|
||||
| `CourseRecommendManagementPage.ts` | 课程推荐管理 |
|
||||
| `GroupCourseManagementPage.ts` | 团课管理 |
|
||||
| `MemberManagementPage.ts` | 会员管理 |
|
||||
| `MemberCardManagementPage.ts` | 会员卡管理 |
|
||||
| `StatisticsDashboardPage.ts` | 统计看板 |
|
||||
|
||||
---
|
||||
|
||||
## 五、辅助模块
|
||||
|
||||
### Helpers
|
||||
|
||||
| 文件 | 用途 |
|
||||
|---|---|
|
||||
| `auth.ts` | 登录认证、Token 存储/获取/清除 |
|
||||
| `TestStabilityHelper.ts` | 测试稳定性(重试、等待、超时处理) |
|
||||
| `TestDataManager.ts` | 测试数据 CRUD 管理 |
|
||||
|
||||
### Utils
|
||||
|
||||
| 文件 | 用途 |
|
||||
|---|---|
|
||||
| `api-client.ts` | HTTP API 客户端封装 |
|
||||
| `testHelper.ts` | 通用测试辅助函数 |
|
||||
| `TestDataFactory.ts` | 测试数据工厂(用户/角色/菜单/字典生成) |
|
||||
| `TestDataCleanup.ts` | 测试后数据清理 |
|
||||
| `RetryHelper.ts` | 智能重试策略 |
|
||||
| `testDataManager.ts` | 测试数据管理 |
|
||||
|
||||
### Fixtures
|
||||
|
||||
| 文件 | 用途 |
|
||||
|---|---|
|
||||
| `test-data.ts` | 静态测试数据(账号、权限、菜单结构) |
|
||||
| `test-file.txt` | 文件上传测试用文件 |
|
||||
|
||||
---
|
||||
|
||||
## 六、运行方式
|
||||
|
||||
```bash
|
||||
# 进入 Web 后台目录
|
||||
cd gym-manage-web
|
||||
|
||||
# 单元测试
|
||||
npm run test # 全量单元测试
|
||||
npx vitest --run # 同全量
|
||||
npx vitest --run Login # 运行匹配 "Login" 的测试
|
||||
npx vitest --ui # Vitest UI 模式
|
||||
|
||||
# E2E 测试(需先启动后端和前端服务)
|
||||
npm run test:e2e # 全部 Playwright 项目
|
||||
npm run test:e2e:smoke # 仅冒烟
|
||||
npm run test:e2e:journeys # 仅旅程
|
||||
npm run test:e2e:headed # 可视化浏览器
|
||||
npm run test:e2e:debug # 逐步执行
|
||||
npm run test:e2e:perf # 性能基准
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文档生成时间**: 2026-07-21 | **测试工具**: Vitest + jsdom / Playwright + Chromium
|
||||
@@ -1,6 +0,0 @@
|
||||
Manifest-Version: 1.0
|
||||
Created-By: Maven JAR Plugin 3.4.2
|
||||
Build-Jdk-Spec: 21
|
||||
Implementation-Title: Gym Payment
|
||||
Implementation-Version: 1.0.0
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
artifactId=gym-payment
|
||||
groupId=cn.novalon.gym.manage
|
||||
version=1.0.0
|
||||
@@ -1,85 +0,0 @@
|
||||
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-manage-api</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>gym-payment</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>Gym Payment</name>
|
||||
<description>Payment Module - Integrates Huifu Payment Gateway</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>manage-common</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-all</artifactId>
|
||||
<version>5.8.25</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>okhttp</artifactId>
|
||||
<version>4.12.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.4.2</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<configuration>
|
||||
<source>21</source>
|
||||
<target>21</target>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
-1
@@ -1 +0,0 @@
|
||||
cn.novalon.gym.manage.payment.config.HuifuPayConfig
|
||||
@@ -68,7 +68,13 @@
|
||||
<artifactId>hutool-all</artifactId>
|
||||
<version>5.8.25</version>
|
||||
</dependency>
|
||||
<!-- 阿里云一键登录服务 -->
|
||||
<!-- 阿里云号码认证服务(新版SDK) -->
|
||||
<dependency>
|
||||
<groupId>com.aliyun</groupId>
|
||||
<artifactId>dypnsapi20170525</artifactId>
|
||||
<version>1.0.6</version>
|
||||
</dependency>
|
||||
<!-- 阿里云一键登录服务(旧版SDK,保留兼容) -->
|
||||
<dependency>
|
||||
<groupId>com.aliyun</groupId>
|
||||
<artifactId>aliyun-java-sdk-dypnsapi</artifactId>
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package cn.novalon.gym.manage.auth.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Data
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "dcloud.univerify")
|
||||
public class DCloudUniverifyConfig {
|
||||
private String appid;
|
||||
private String appkey;
|
||||
private String apiSecret;
|
||||
private String apiUrl = "https://developer.dcloud.net.cn/api/client/univerify/getPhoneNumber";
|
||||
private String proxyHost;
|
||||
private Integer proxyPort;
|
||||
private Integer timeout = 10000;
|
||||
}
|
||||
+5
-9
@@ -1,27 +1,23 @@
|
||||
package cn.novalon.gym.manage.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 手机号一键登录请求DTO
|
||||
* uniapp官方一键登录流程:前端通过云函数获取手机号,直接传给后端
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PhoneLoginDto {
|
||||
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
|
||||
private String phone;
|
||||
|
||||
private String accessToken;
|
||||
|
||||
private String openid;
|
||||
|
||||
private String nickname;
|
||||
|
||||
private String avatar;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -2,6 +2,7 @@ package cn.novalon.gym.manage.auth.handler;
|
||||
|
||||
import cn.novalon.gym.manage.auth.dto.PhoneCodeLoginDto;
|
||||
import cn.novalon.gym.manage.auth.dto.PhoneLoginDto;
|
||||
import cn.novalon.gym.manage.auth.dto.SendCodeRequest;
|
||||
import cn.novalon.gym.manage.auth.service.PhoneAuthService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -38,8 +39,8 @@ public class PhoneAuthHandler {
|
||||
public Mono<ServerResponse> sendSmsCode(ServerRequest request) {
|
||||
log.info("收到发送短信验证码请求");
|
||||
|
||||
return request.bodyToMono(PhoneLoginDto.class)
|
||||
.flatMap(dto -> phoneAuthService.sendSmsCode(dto.getPhone()))
|
||||
return request.bodyToMono(SendCodeRequest.class)
|
||||
.flatMap(req -> phoneAuthService.sendSmsCode(req.getPhone()))
|
||||
.flatMap(success -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(Map.of("success", success, "message", success ? "验证码发送成功" : "验证码发送失败")));
|
||||
|
||||
+6
-4
@@ -1,5 +1,7 @@
|
||||
package cn.novalon.gym.manage.auth.service;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 短信服务接口
|
||||
*
|
||||
@@ -14,7 +16,7 @@ public interface SmsService {
|
||||
* @param phone 手机号
|
||||
* @return 发送结果
|
||||
*/
|
||||
boolean sendVerificationCode(String phone);
|
||||
Mono<Boolean> sendVerificationCode(String phone);
|
||||
|
||||
/**
|
||||
* 验证短信验证码
|
||||
@@ -23,7 +25,7 @@ public interface SmsService {
|
||||
* @param code 验证码
|
||||
* @return 验证结果
|
||||
*/
|
||||
boolean verifyCode(String phone, String code);
|
||||
Mono<Boolean> verifyCode(String phone, String code);
|
||||
|
||||
/**
|
||||
* 获取验证码(用于测试或特殊场景)
|
||||
@@ -31,5 +33,5 @@ public interface SmsService {
|
||||
* @param phone 手机号
|
||||
* @return 验证码
|
||||
*/
|
||||
String getVerificationCode(String phone);
|
||||
}
|
||||
Mono<String> getVerificationCode(String phone);
|
||||
}
|
||||
+100
-7
@@ -1,5 +1,7 @@
|
||||
package cn.novalon.gym.manage.auth.service.impl;
|
||||
|
||||
import cn.novalon.gym.manage.auth.config.DCloudUniverifyConfig;
|
||||
import cn.novalon.gym.manage.auth.config.SmsProperties;
|
||||
import cn.novalon.gym.manage.auth.dto.PhoneCodeLoginDto;
|
||||
import cn.novalon.gym.manage.auth.dto.PhoneLoginDto;
|
||||
import cn.novalon.gym.manage.auth.service.PhoneAuthService;
|
||||
@@ -18,12 +20,16 @@ import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -35,20 +41,50 @@ public class PhoneAuthServiceImpl implements PhoneAuthService {
|
||||
private final EsSyncUtils esSyncUtils;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final SmsService smsService;
|
||||
private final SmsProperties smsProperties;
|
||||
private final DCloudUniverifyConfig dCloudUniverifyConfig;
|
||||
|
||||
private WebClient webClient;
|
||||
|
||||
private EsSyncUtils.EntitySyncer<Member, MemberES, String> memberSyncer;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
this.memberSyncer = esSyncUtils.bind(Member.class, MemberES.class, memberESRepository);
|
||||
this.webClient = WebClient.builder()
|
||||
.baseUrl(dCloudUniverifyConfig.getApiUrl())
|
||||
.defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<PhoneLoginVO> oneClickLogin(PhoneLoginDto request) {
|
||||
log.info("手机号一键登录, phone: {}", request.getPhone());
|
||||
log.info("手机号一键登录请求, phone: {}, accessToken: {}, openid: {}", request.getPhone(), request.getAccessToken(), request.getOpenid());
|
||||
|
||||
String encryptedPhone = encryptPhone(request.getPhone());
|
||||
if (request.getPhone() != null && !request.getPhone().isEmpty()) {
|
||||
log.info("直接使用手机号登录");
|
||||
String encryptedPhone = encryptPhone(request.getPhone());
|
||||
return processPhoneLogin(encryptedPhone, request);
|
||||
}
|
||||
|
||||
if (request.getAccessToken() == null || request.getAccessToken().isEmpty()) {
|
||||
log.error("一键登录失败: accessToken为空");
|
||||
return Mono.error(new SystemException(ErrorCode.AUTH_PHONE_ERROR, "登录凭证无效,请重试"));
|
||||
}
|
||||
|
||||
return getPhoneByDcloudApi(request.getAccessToken(), request.getOpenid())
|
||||
.flatMap(phone -> {
|
||||
log.info("通过access_token获取手机号成功: {}", maskPhone(phone));
|
||||
String encryptedPhone = encryptPhone(phone);
|
||||
return processPhoneLogin(encryptedPhone, request);
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.error("一键登录失败", e);
|
||||
return Mono.error(new SystemException(ErrorCode.AUTH_PHONE_ERROR, "手机号获取失败: " + e.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<PhoneLoginVO> processPhoneLogin(String encryptedPhone, PhoneLoginDto request) {
|
||||
return memberRepository.findByPhone(encryptedPhone)
|
||||
.flatMap(existingMember -> {
|
||||
log.info("手机号已注册,直接登录, memberId: {}", existingMember.getId());
|
||||
@@ -60,17 +96,75 @@ public class PhoneAuthServiceImpl implements PhoneAuthService {
|
||||
}));
|
||||
}
|
||||
|
||||
private Mono<String> getPhoneByDcloudApi(String accessToken, String openid) {
|
||||
Map<String, String> requestBody = new HashMap<>();
|
||||
requestBody.put("appid", dCloudUniverifyConfig.getAppid());
|
||||
requestBody.put("appkey", dCloudUniverifyConfig.getAppkey());
|
||||
requestBody.put("apiSecret", dCloudUniverifyConfig.getApiSecret());
|
||||
requestBody.put("access_token", accessToken);
|
||||
requestBody.put("openid", openid);
|
||||
|
||||
log.info("调用DCloud Univerify API, params: {}", requestBody);
|
||||
|
||||
return webClient.post()
|
||||
.bodyValue(requestBody)
|
||||
.retrieve()
|
||||
.bodyToMono(Map.class)
|
||||
.flatMap(response -> {
|
||||
log.info("DCloud API响应: {}", response);
|
||||
|
||||
Integer code = (Integer) response.get("code");
|
||||
Boolean success = (Boolean) response.get("success");
|
||||
|
||||
if ((code != null && code == 0) || (success != null && success)) {
|
||||
Map<String, Object> data = (Map<String, Object>) response.get("data");
|
||||
if (data != null) {
|
||||
String phoneNumber = (String) data.get("phoneNumber");
|
||||
if (phoneNumber != null && !phoneNumber.isEmpty()) {
|
||||
return Mono.just(phoneNumber);
|
||||
}
|
||||
}
|
||||
String phoneNumber = (String) response.get("phoneNumber");
|
||||
if (phoneNumber != null && !phoneNumber.isEmpty()) {
|
||||
return Mono.just(phoneNumber);
|
||||
}
|
||||
String phone = (String) response.get("phone");
|
||||
if (phone != null && !phone.isEmpty()) {
|
||||
return Mono.just(phone);
|
||||
}
|
||||
}
|
||||
|
||||
String message = (String) response.get("message");
|
||||
log.warn("DCloud API返回错误: code={}, success={}, message={}", code, success, message);
|
||||
return Mono.error(new SystemException(ErrorCode.AUTH_PHONE_ERROR, "手机号获取失败: " + message));
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.error("调用DCloud API失败", e);
|
||||
return Mono.error(new SystemException(ErrorCode.AUTH_PHONE_ERROR, "手机号获取失败: " + e.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号脱敏显示
|
||||
*/
|
||||
private String maskPhone(String phone) {
|
||||
if (phone == null || phone.length() < 11) {
|
||||
return phone;
|
||||
}
|
||||
return phone.substring(0, 3) + "****" + phone.substring(7);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Boolean> sendSmsCode(String phone) {
|
||||
log.info("发送短信验证码, phone: {}", phone);
|
||||
return Mono.fromCallable(() -> smsService.sendVerificationCode(phone));
|
||||
return smsService.sendVerificationCode(phone);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<PhoneLoginVO> codeLogin(PhoneCodeLoginDto request) {
|
||||
log.info("手机号验证码登录, phone: {}", request.getPhone());
|
||||
|
||||
return Mono.fromCallable(() -> smsService.verifyCode(request.getPhone(), request.getCode()))
|
||||
return smsService.verifyCode(request.getPhone(), request.getCode())
|
||||
.flatMap(verified -> {
|
||||
if (!verified) {
|
||||
log.warn("验证码验证失败, phone: {}", request.getPhone());
|
||||
@@ -87,7 +181,6 @@ public class PhoneAuthServiceImpl implements PhoneAuthService {
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
log.info("手机号未注册,创建新会员");
|
||||
PhoneLoginDto registerRequest = new PhoneLoginDto();
|
||||
registerRequest.setPhone(request.getPhone());
|
||||
return createNewMemberAndLogin(registerRequest, encryptedPhone);
|
||||
}));
|
||||
});
|
||||
@@ -153,7 +246,7 @@ public class PhoneAuthServiceImpl implements PhoneAuthService {
|
||||
vo.setNeedCompleteInfo(needCompleteInfo);
|
||||
vo.setNickname(member.getNickname());
|
||||
vo.setAvatar(member.getAvatar());
|
||||
vo.setPhone(decryptPhone(member.getPhone()));
|
||||
vo.setPhone(member.getPhone() != null ? decryptPhone(member.getPhone()) : null);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@@ -174,4 +267,4 @@ public class PhoneAuthServiceImpl implements PhoneAuthService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+151
-101
@@ -1,148 +1,198 @@
|
||||
package cn.novalon.gym.manage.auth.service.impl;
|
||||
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import cn.novalon.gym.manage.auth.config.SmsProperties;
|
||||
import cn.novalon.gym.manage.auth.service.SmsService;
|
||||
import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import com.aliyun.dysmsapi20170525.Client;
|
||||
import com.aliyun.dysmsapi20170525.models.SendSmsRequest;
|
||||
import com.aliyun.dysmsapi20170525.models.SendSmsResponse;
|
||||
import com.aliyun.teaopenapi.models.Config;
|
||||
import com.aliyuncs.CommonRequest;
|
||||
import com.aliyuncs.CommonResponse;
|
||||
import com.aliyuncs.DefaultAcsClient;
|
||||
import com.aliyuncs.IAcsClient;
|
||||
import com.aliyuncs.exceptions.ClientException;
|
||||
import com.aliyuncs.http.MethodType;
|
||||
import com.aliyuncs.profile.DefaultProfile;
|
||||
import com.aliyuncs.profile.IClientProfile;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
/**
|
||||
* 短信服务实现类
|
||||
*
|
||||
* @author auto-generated
|
||||
* @date 2026-06-20
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SmsServiceImpl implements SmsService {
|
||||
|
||||
private static final long SEND_INTERVAL_SECONDS = 60;
|
||||
private static final long CODE_EXPIRE_SECONDS = 300;
|
||||
|
||||
private final SmsProperties smsProperties;
|
||||
private final RedisUtil redisUtil;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public boolean sendVerificationCode(String phone) {
|
||||
public Mono<Boolean> sendVerificationCode(String phone) {
|
||||
log.info("发送短信验证码, phone: {}", phone);
|
||||
|
||||
try {
|
||||
// 生成验证码
|
||||
String code = generateCode();
|
||||
String rateLimitKey = RedisKeyConstants.SMS_CODE + phone + ":rate_limit";
|
||||
|
||||
// 发送短信
|
||||
boolean sent = sendSms(phone, code);
|
||||
return redisUtil.get(rateLimitKey, Long.class)
|
||||
.defaultIfEmpty(0L)
|
||||
.flatMap(lastSendTime -> {
|
||||
long currentTime = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);
|
||||
|
||||
if (sent) {
|
||||
// 将验证码存入Redis,5分钟过期
|
||||
String cacheKey = RedisKeyConstants.SMS_CODE + phone;
|
||||
redisUtil.setWithExpire(cacheKey, code, smsProperties.getCodeExpireSeconds())
|
||||
.doOnSuccess(result -> log.info("验证码已缓存, phone: {}, code: {}, expire: {}s",
|
||||
phone, code, smsProperties.getCodeExpireSeconds()))
|
||||
.block();
|
||||
if (currentTime - lastSendTime < SEND_INTERVAL_SECONDS) {
|
||||
long remainingSeconds = SEND_INTERVAL_SECONDS - (currentTime - lastSendTime);
|
||||
log.warn("发送频率限制, phone: {}, 剩余时间: {}秒", phone, remainingSeconds);
|
||||
return Mono.just(false);
|
||||
}
|
||||
|
||||
log.info("短信验证码发送成功, phone: {}", phone);
|
||||
return true;
|
||||
}
|
||||
return Mono.fromCallable(() -> {
|
||||
try {
|
||||
IAcsClient client = createClient();
|
||||
|
||||
log.warn("短信验证码发送失败, phone: {}", phone);
|
||||
return false;
|
||||
CommonRequest request = new CommonRequest();
|
||||
request.setSysMethod(MethodType.POST);
|
||||
request.setSysDomain("dypnsapi.aliyuncs.com");
|
||||
request.setSysVersion("2017-05-25");
|
||||
request.setSysAction("SendSmsVerifyCode");
|
||||
request.putQueryParameter("PhoneNumber", phone);
|
||||
request.putQueryParameter("SignName", smsProperties.getSignName());
|
||||
request.putQueryParameter("TemplateCode", smsProperties.getTemplateCode());
|
||||
request.putQueryParameter("TemplateParam", "{\"code\":\"##code##\",\"min\":\"5\"}");
|
||||
request.putQueryParameter("ReturnVerifyCode", "true");
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("发送短信验证码异常, phone: {}", phone, e);
|
||||
return false;
|
||||
}
|
||||
log.info("阿里云号码认证请求参数 - signName: {}, templateCode: {}, templateParam: {}",
|
||||
smsProperties.getSignName(),
|
||||
smsProperties.getTemplateCode(),
|
||||
"{\"code\":\"##code##\",\"min\":\"5\"}");
|
||||
|
||||
CommonResponse response = client.getCommonResponse(request);
|
||||
String responseData = response.getData();
|
||||
log.info("阿里云号码认证原始响应: {}", responseData);
|
||||
|
||||
JsonNode jsonNode = objectMapper.readTree(responseData);
|
||||
|
||||
JsonNode codeNode = jsonNode.get("Code");
|
||||
if (codeNode == null) {
|
||||
log.error("阿里云响应中找不到Code字段, 原始响应: {}", responseData);
|
||||
return false;
|
||||
}
|
||||
|
||||
String code = codeNode.asText();
|
||||
|
||||
if ("OK".equals(code)) {
|
||||
JsonNode requestIdNode = jsonNode.get("RequestId");
|
||||
String requestId = requestIdNode != null ? requestIdNode.asText() : "unknown";
|
||||
log.info("短信验证码发送成功, phone: {}, requestId: {}", phone, requestId);
|
||||
|
||||
// 提取验证码并存入Redis
|
||||
JsonNode modelNode = jsonNode.get("Model");
|
||||
if (modelNode != null) {
|
||||
JsonNode verifyCodeNode = modelNode.get("VerifyCode");
|
||||
if (verifyCodeNode != null) {
|
||||
String verifyCode = verifyCodeNode.asText();
|
||||
String smsCodeKey = RedisKeyConstants.SMS_CODE + phone;
|
||||
redisUtil.setWithExpire(smsCodeKey, verifyCode, CODE_EXPIRE_SECONDS).subscribe();
|
||||
log.info("验证码已存入Redis, phone: {}, key: {}, expire: {}秒", phone, smsCodeKey, CODE_EXPIRE_SECONDS);
|
||||
} else {
|
||||
log.warn("响应中未找到Model.VerifyCode字段, 原始响应: {}", responseData);
|
||||
}
|
||||
} else {
|
||||
log.warn("响应中未找到Model字段, 原始响应: {}", responseData);
|
||||
}
|
||||
|
||||
redisUtil.setWithExpire(rateLimitKey, currentTime, SEND_INTERVAL_SECONDS).subscribe();
|
||||
return true;
|
||||
}
|
||||
|
||||
JsonNode messageNode = jsonNode.get("Message");
|
||||
String message = messageNode != null ? messageNode.asText() : "unknown";
|
||||
log.error("短信验证码发送失败, phone: {}, code: {}, message: {}", phone, code, message);
|
||||
return false;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("发送短信验证码异常, phone: {}, 异常信息: {}", phone, e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verifyCode(String phone, String code) {
|
||||
public Mono<Boolean> verifyCode(String phone, String code) {
|
||||
log.info("验证短信验证码, phone: {}", phone);
|
||||
|
||||
try {
|
||||
String cacheKey = RedisKeyConstants.SMS_CODE + phone;
|
||||
String cachedCode = redisUtil.get(cacheKey, String.class).block();
|
||||
return Mono.fromCallable(() -> {
|
||||
try {
|
||||
IAcsClient client = createClient();
|
||||
|
||||
if (cachedCode == null) {
|
||||
log.warn("验证码已过期或不存在, phone: {}", phone);
|
||||
CommonRequest request = new CommonRequest();
|
||||
request.setSysMethod(MethodType.POST);
|
||||
request.setSysDomain("dypnsapi.aliyuncs.com");
|
||||
request.setSysVersion("2017-05-25");
|
||||
request.setSysAction("CheckSmsVerifyCode");
|
||||
request.putQueryParameter("PhoneNumber", phone);
|
||||
request.putQueryParameter("VerifyCode", code);
|
||||
|
||||
log.info("阿里云号码认证核验参数 - phone: {}, code: {}", phone, code);
|
||||
|
||||
CommonResponse response = client.getCommonResponse(request);
|
||||
String responseData = response.getData();
|
||||
log.info("阿里云号码认证核验原始响应: {}", responseData);
|
||||
|
||||
JsonNode jsonNode = objectMapper.readTree(responseData);
|
||||
|
||||
JsonNode codeNode = jsonNode.get("Code");
|
||||
if (codeNode == null) {
|
||||
log.error("阿里云核验响应中找不到Code字段, 原始响应: {}", responseData);
|
||||
return false;
|
||||
}
|
||||
|
||||
String responseCode = codeNode.asText();
|
||||
JsonNode modelNode = jsonNode.get("Model");
|
||||
JsonNode verifyResultNode = modelNode != null ? modelNode.get("VerifyResult") : null;
|
||||
boolean verifyResult = verifyResultNode != null && "PASS".equals(verifyResultNode.asText());
|
||||
|
||||
if ("OK".equals(responseCode) && verifyResult) {
|
||||
log.info("验证码验证成功, phone: {}", phone);
|
||||
return true;
|
||||
}
|
||||
|
||||
JsonNode messageNode = jsonNode.get("Message");
|
||||
String message = messageNode != null ? messageNode.asText() : "unknown";
|
||||
log.warn("验证码验证失败, phone: {}, code: {}, message: {}, result: {}",
|
||||
phone, responseCode, message, verifyResult);
|
||||
return false;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("验证短信验证码异常, phone: {}, 异常信息: {}", phone, e.getMessage(), e);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cachedCode.equals(code)) {
|
||||
// 验证成功后删除验证码
|
||||
redisUtil.delete(cacheKey).block();
|
||||
log.info("验证码验证成功, phone: {}", phone);
|
||||
return true;
|
||||
}
|
||||
|
||||
log.warn("验证码不匹配, phone: {}, input: {}, cached: {}", phone, code, cachedCode);
|
||||
return false;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("验证短信验证码异常, phone: {}", phone, e);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getVerificationCode(String phone) {
|
||||
public Mono<String> getVerificationCode(String phone) {
|
||||
try {
|
||||
String cacheKey = RedisKeyConstants.SMS_CODE + phone;
|
||||
return redisUtil.get(cacheKey, String.class).block();
|
||||
return redisUtil.get(cacheKey, String.class);
|
||||
} catch (Exception e) {
|
||||
log.error("获取验证码异常, phone: {}", phone, e);
|
||||
return null;
|
||||
return Mono.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成验证码
|
||||
*/
|
||||
private String generateCode() {
|
||||
return RandomUtil.randomNumbers(smsProperties.getCodeLength());
|
||||
private IAcsClient createClient() throws ClientException {
|
||||
IClientProfile profile = DefaultProfile.getProfile(
|
||||
"cn-hangzhou",
|
||||
smsProperties.getAccessKeyId(),
|
||||
smsProperties.getAccessKeySecret());
|
||||
DefaultProfile.addEndpoint("cn-hangzhou", "cn-hangzhou", "Dypnsapi", "dypnsapi.aliyuncs.com");
|
||||
return new DefaultAcsClient(profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信
|
||||
*/
|
||||
private boolean sendSms(String phone, String code) throws Exception {
|
||||
log.info("调用阿里云短信API发送短信, phone: {}, code: {}", phone, code);
|
||||
|
||||
// 创建阿里云短信客户端
|
||||
Config config = new Config()
|
||||
.setAccessKeyId(smsProperties.getAccessKeyId())
|
||||
.setAccessKeySecret(smsProperties.getAccessKeySecret())
|
||||
.setEndpoint("dysmsapi.aliyuncs.com");
|
||||
|
||||
Client client = new Client(config);
|
||||
|
||||
// 构建发送短信请求
|
||||
SendSmsRequest request = new SendSmsRequest()
|
||||
.setPhoneNumbers(phone)
|
||||
.setSignName(smsProperties.getSignName())
|
||||
.setTemplateCode(smsProperties.getTemplateCode())
|
||||
.setTemplateParam("{\"code\":\"" + code + "\"}");
|
||||
|
||||
// 发送短信
|
||||
SendSmsResponse response = client.sendSms(request);
|
||||
|
||||
log.info("阿里云短信API响应, phone: {}, code: {}, message: {}",
|
||||
phone, response.getBody().getCode(), response.getBody().getMessage());
|
||||
|
||||
// 判断发送结果
|
||||
if ("OK".equals(response.getBody().getCode())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
log.error("阿里云短信发送失败, phone: {}, code: {}, message: {}",
|
||||
phone, response.getBody().getCode(), response.getBody().getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package cn.novalon.gym.manage.auth.handler;
|
||||
|
||||
import cn.novalon.gym.manage.auth.dto.PhoneCodeLoginDto;
|
||||
import cn.novalon.gym.manage.auth.dto.PhoneLoginDto;
|
||||
import cn.novalon.gym.manage.auth.dto.SendCodeRequest;
|
||||
import cn.novalon.gym.manage.auth.service.PhoneAuthService;
|
||||
import cn.novalon.gym.manage.auth.vo.PhoneLoginVO;
|
||||
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 org.springframework.mock.web.reactive.function.server.MockServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 手机号认证 Handler 单元测试
|
||||
*/
|
||||
class PhoneAuthHandlerTest {
|
||||
|
||||
@Mock
|
||||
private PhoneAuthService phoneAuthService;
|
||||
|
||||
private PhoneAuthHandler handler;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
handler = new PhoneAuthHandler(phoneAuthService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("一键登录 - 正常返回登录信息")
|
||||
void testOneClickLoginSuccess() {
|
||||
PhoneLoginVO vo = PhoneLoginVO.builder()
|
||||
.memberId(1L)
|
||||
.memberNo("GYM001")
|
||||
.phone("138****0001")
|
||||
.accessToken("jwt-token-xxx")
|
||||
.expiresIn(86400)
|
||||
.isNewUser(true)
|
||||
.build();
|
||||
|
||||
when(phoneAuthService.oneClickLogin(any(PhoneLoginDto.class)))
|
||||
.thenReturn(Mono.just(vo));
|
||||
|
||||
Mono<ServerResponse> result = handler.oneClickLogin(
|
||||
MockServerRequest.builder()
|
||||
.body(Mono.just(new PhoneLoginDto())));
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(response -> response.statusCode().is2xxSuccessful())
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("发送短信验证码 - 发送成功")
|
||||
void testSendSmsCodeSuccess() {
|
||||
when(phoneAuthService.sendSmsCode("13800000001"))
|
||||
.thenReturn(Mono.just(true));
|
||||
|
||||
PhoneLoginDto dto = new PhoneLoginDto();
|
||||
dto.setPhone("13800000001");
|
||||
|
||||
Mono<ServerResponse> result = handler.sendSmsCode(
|
||||
MockServerRequest.builder()
|
||||
.body(Mono.just(new SendCodeRequest("13800000001"))));
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(response -> response.statusCode().is2xxSuccessful())
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("发送短信验证码 - 发送失败(60秒内重复发送)")
|
||||
void testSendSmsCodeRateLimited() {
|
||||
when(phoneAuthService.sendSmsCode("13800000001"))
|
||||
.thenReturn(Mono.just(false));
|
||||
|
||||
Mono<ServerResponse> result = handler.sendSmsCode(
|
||||
MockServerRequest.builder()
|
||||
.body(Mono.just(new SendCodeRequest("13800000001"))));
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(response -> response.statusCode().is2xxSuccessful())
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("验证码登录 - 正常登录")
|
||||
void testCodeLoginSuccess() {
|
||||
PhoneLoginVO vo = PhoneLoginVO.builder()
|
||||
.memberId(2L)
|
||||
.memberNo("GYM002")
|
||||
.accessToken("jwt-token-yyy")
|
||||
.expiresIn(86400)
|
||||
.isNewUser(false)
|
||||
.build();
|
||||
|
||||
when(phoneAuthService.codeLogin(any(PhoneCodeLoginDto.class)))
|
||||
.thenReturn(Mono.just(vo));
|
||||
|
||||
PhoneCodeLoginDto loginDto = new PhoneCodeLoginDto();
|
||||
loginDto.setPhone("13800000002");
|
||||
loginDto.setCode("123456");
|
||||
|
||||
Mono<ServerResponse> result = handler.codeLogin(
|
||||
MockServerRequest.builder()
|
||||
.body(Mono.just(loginDto)));
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(response -> response.statusCode().is2xxSuccessful())
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("验证码登录 - 验证码错误导致异常")
|
||||
void testCodeLoginWrongCode() {
|
||||
when(phoneAuthService.codeLogin(any(PhoneCodeLoginDto.class)))
|
||||
.thenReturn(Mono.error(new RuntimeException("验证码错误")));
|
||||
|
||||
PhoneCodeLoginDto loginDto = new PhoneCodeLoginDto();
|
||||
loginDto.setPhone("13800000002");
|
||||
loginDto.setCode("000000");
|
||||
|
||||
Mono<ServerResponse> result = handler.codeLogin(
|
||||
MockServerRequest.builder()
|
||||
.body(Mono.just(loginDto)));
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectError(RuntimeException.class)
|
||||
.verify();
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package cn.novalon.gym.manage.auth.service;
|
||||
|
||||
import cn.novalon.gym.manage.auth.config.SmsProperties;
|
||||
import cn.novalon.gym.manage.auth.service.impl.SmsServiceImpl;
|
||||
import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 短信服务单元测试
|
||||
*/
|
||||
class SmsServiceImplTest {
|
||||
|
||||
@Mock
|
||||
private SmsProperties smsProperties;
|
||||
|
||||
@Mock
|
||||
private RedisUtil redisUtil;
|
||||
|
||||
private ObjectMapper objectMapper;
|
||||
private SmsServiceImpl smsService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
objectMapper = new ObjectMapper();
|
||||
smsService = new SmsServiceImpl(smsProperties, redisUtil, objectMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("发送验证码 - 频率限制内重复发送")
|
||||
void testSendVerificationCodeRateLimited() {
|
||||
String phone = "13800000001";
|
||||
String rateLimitKey = RedisKeyConstants.SMS_CODE + phone + ":rate_limit";
|
||||
|
||||
// 最近60秒内已发送
|
||||
long recentTime = System.currentTimeMillis() / 1000;
|
||||
when(redisUtil.get(eq(rateLimitKey), eq(Long.class)))
|
||||
.thenReturn(Mono.just(recentTime));
|
||||
|
||||
Mono<Boolean> result = smsService.sendVerificationCode(phone);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNext(false)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("发送验证码 - 首次发送(频率限制不触发)")
|
||||
void testSendVerificationCodeFirstTime() {
|
||||
String phone = "13800000002";
|
||||
String rateLimitKey = RedisKeyConstants.SMS_CODE + phone + ":rate_limit";
|
||||
|
||||
// 从未发送过(返回 0,表示很久以前)
|
||||
long oldTime = 0L;
|
||||
when(redisUtil.get(eq(rateLimitKey), eq(Long.class)))
|
||||
.thenReturn(Mono.just(oldTime));
|
||||
|
||||
// 由于会真实调用阿里云API(会失败),mock redis set
|
||||
when(redisUtil.setWithExpire(anyString(), anyLong(), anyLong()))
|
||||
.thenReturn(Mono.just(true));
|
||||
|
||||
Mono<Boolean> result = smsService.sendVerificationCode(phone);
|
||||
// 阿里云API调用会失败,但逻辑上 rate limit 已通过
|
||||
// 验证码逻辑在 Mono.fromCallable 内部,阿里云不可用时会返回 false
|
||||
StepVerifier.create(result)
|
||||
.consumeNextWith(success -> {
|
||||
// 阿里云不可用时会返回 false
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("获取验证码 - 从Redis获取成功")
|
||||
void testGetVerificationCodeFound() {
|
||||
String phone = "13812345678";
|
||||
String cacheKey = RedisKeyConstants.SMS_CODE + phone;
|
||||
|
||||
when(redisUtil.get(eq(cacheKey), eq(String.class)))
|
||||
.thenReturn(Mono.just("654321"));
|
||||
|
||||
Mono<String> result = smsService.getVerificationCode(phone);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNext("654321")
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("获取验证码 - Redis中不存在")
|
||||
void testGetVerificationCodeNotFound() {
|
||||
String phone = "13800000000";
|
||||
String cacheKey = RedisKeyConstants.SMS_CODE + phone;
|
||||
|
||||
when(redisUtil.get(eq(cacheKey), eq(String.class)))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
Mono<String> result = smsService.getVerificationCode(phone);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("短信配置属性验证")
|
||||
void testSmsProperties() {
|
||||
when(smsProperties.getSignName()).thenReturn("测试签名");
|
||||
when(smsProperties.getTemplateCode()).thenReturn("SMS_001");
|
||||
when(smsProperties.getCodeLength()).thenReturn(6);
|
||||
when(smsProperties.getCodeExpireSeconds()).thenReturn(300L);
|
||||
|
||||
assert "测试签名".equals(smsProperties.getSignName());
|
||||
assert "SMS_001".equals(smsProperties.getTemplateCode());
|
||||
assert smsProperties.getCodeLength() == 6;
|
||||
assert smsProperties.getCodeExpireSeconds() == 300L;
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,11 @@
|
||||
<artifactId>hutool-all</artifactId>
|
||||
<version>5.8.25</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package cn.novalon.gym.manage.checkIn.config;
|
||||
|
||||
import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.reactive.HandlerMapping;
|
||||
import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping;
|
||||
import org.springframework.web.reactive.socket.WebSocketHandler;
|
||||
import org.springframework.web.reactive.socket.server.support.WebSocketHandlerAdapter;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Configuration
|
||||
public class WebSocketConfig {
|
||||
|
||||
@Autowired
|
||||
private MyWebSocketHandler myWebSocketHandler;
|
||||
|
||||
/**
|
||||
* 注册 WebSocket 路由映射
|
||||
* 路径对应前端连接的 ws://xxx/webSocket/checkIn
|
||||
*/
|
||||
@Bean
|
||||
public HandlerMapping webSocketMapping() {
|
||||
Map<String, WebSocketHandler> map = new HashMap<>();
|
||||
map.put("/webSocket/checkIn", myWebSocketHandler);
|
||||
|
||||
SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping();
|
||||
mapping.setUrlMap(map);
|
||||
mapping.setOrder(10); // 设置优先级
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册 WebSocket 处理器适配器(必须)
|
||||
*/
|
||||
@Bean
|
||||
public WebSocketHandlerAdapter handlerAdapter() {
|
||||
return new WebSocketHandlerAdapter();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -206,4 +206,4 @@ public class SignInRecord {
|
||||
public void restore() {
|
||||
this.isDelete = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -2,6 +2,8 @@ package cn.novalon.gym.manage.checkIn.handler;
|
||||
|
||||
import cn.novalon.gym.manage.checkIn.service.impl.CheckServiceImpl;
|
||||
import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
|
||||
import cn.novalon.gym.manage.checkIn.vo.SignInRecordVO;
|
||||
import cn.novalon.gym.manage.checkIn.vo.SignInStatsVO;
|
||||
import cn.novalon.gym.manage.sys.util.AuthUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -12,6 +14,7 @@ import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
@@ -36,6 +39,7 @@ public class CheckInHandler {
|
||||
public Mono<ServerResponse> checkIn(ServerRequest request) {
|
||||
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
|
||||
return request.bodyToMono(Map.class)
|
||||
.flatMap(body -> {
|
||||
String qrContent = (String) body.get("qrContent");
|
||||
@@ -190,4 +194,4 @@ public class CheckInHandler {
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(Map.of("code", 200, "message", "success", "data", stats)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -78,4 +78,4 @@ public interface ICheckInService {
|
||||
* @return 签到统计VO
|
||||
*/
|
||||
Mono<SignInStatsVO> getDailySignInStats(LocalDate date);
|
||||
}
|
||||
}
|
||||
|
||||
+37
-75
@@ -2,6 +2,8 @@ package cn.novalon.gym.manage.checkIn.service.impl;
|
||||
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import cn.hutool.extra.qrcode.QrCodeUtil;
|
||||
import cn.hutool.extra.qrcode.QrConfig;
|
||||
import cn.novalon.gym.manage.checkIn.config.QRCodeConfig;
|
||||
import cn.novalon.gym.manage.checkIn.constant.QRRedisKey;
|
||||
import cn.novalon.gym.manage.checkIn.entity.SignInRecord;
|
||||
@@ -19,8 +21,6 @@ import cn.novalon.gym.manage.member.entity.MemberCardRecord;
|
||||
import cn.novalon.gym.manage.member.enums.MemberCardType;
|
||||
import cn.novalon.gym.manage.member.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;
|
||||
@@ -52,32 +52,24 @@ public class CheckServiceImpl implements ICheckInService {
|
||||
|
||||
@Override
|
||||
public Mono<QRCodeVo> getQRCode(Long memberId) {
|
||||
log.info("开始查询会员信息, memberId: {}", memberId);
|
||||
log.info("开始生成会员签到二维码, memberId: {}", memberId);
|
||||
|
||||
return findValidMemberCard(memberId)
|
||||
.flatMap(cardRecord -> {
|
||||
log.info("会员信息查询完成, memberCardRecordId: {}", cardRecord.getMemberCardRecordId());
|
||||
|
||||
log.info("开始生成二维码");
|
||||
String qrContent = QRRedisKey.generateQrcodeContent();
|
||||
Map<String, Object> redisMap = new HashMap<>();
|
||||
redisMap.put("qrContent", qrContent);
|
||||
redisMap.put("isUsed", false);
|
||||
redisMap.put("memberId", memberId);
|
||||
redisMap.put("memberCardRecordId", cardRecord.getMemberCardRecordId());
|
||||
String qrContent = QRRedisKey.generateQrcodeContent();
|
||||
Map<String, Object> redisMap = new HashMap<>();
|
||||
redisMap.put("qrContent", qrContent);
|
||||
redisMap.put("isUsed", false);
|
||||
redisMap.put("memberId", memberId);
|
||||
|
||||
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("该会员没有可用的会员卡")));
|
||||
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());
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -113,9 +105,10 @@ public class CheckServiceImpl implements ICheckInService {
|
||||
}
|
||||
log.info("二维码匹配成功,memberId: {}", memberId);
|
||||
|
||||
Long memberCardRecordId = ((Number) map.get("memberCardRecordId")).longValue();
|
||||
Long memberCardRecordId = map.get("memberCardRecordId") != null
|
||||
? ((Number) map.get("memberCardRecordId")).longValue() : null;
|
||||
|
||||
return processCheckIn(memberId, memberCardRecordId, map, qrContent);
|
||||
return processCheckIn(memberId, memberCardRecordId, map, qrContent);
|
||||
} else {
|
||||
MyWebSocketHandler.sendFailure(qrContent, "二维码无效");
|
||||
return Mono.error(new RuntimeException("二维码无效"));
|
||||
@@ -145,54 +138,23 @@ public class CheckServiceImpl implements ICheckInService {
|
||||
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("会员卡状态不正确"));
|
||||
}
|
||||
MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_BOOKING", "正在检查预约信息...");
|
||||
|
||||
// 检查是否有需要签到的团课预约
|
||||
return validateBooking(memberId, now)
|
||||
.then(Mono.defer(() -> {
|
||||
redisMap.put("isUsed", true);
|
||||
redisMap.put("checkInTime", now.format(DATE_FORMATTER));
|
||||
|
||||
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);
|
||||
}));
|
||||
});
|
||||
}));
|
||||
});
|
||||
return saveSignInRecord(memberId, null, null)
|
||||
.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: {}", memberId);
|
||||
return Mono.just(successMsg);
|
||||
}));
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -199,4 +199,4 @@ public class MyWebSocketHandler implements WebSocketHandler {
|
||||
cleanupTimeoutConnections();
|
||||
return qrContentToSink.size();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,12 @@
|
||||
<dependency>
|
||||
<groupId>org.apache.poi</groupId>
|
||||
<artifactId>poi-ooxml</artifactId>
|
||||
<version>5.2.5</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Fix commons-compress version for POI compatibility -->
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-compress</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Gym Modules -->
|
||||
@@ -95,6 +100,11 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.projectreactor</groupId>
|
||||
<artifactId>reactor-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
+6
@@ -59,6 +59,12 @@ public class DataStatistics {
|
||||
public static final String WEEK = "WEEK";
|
||||
/** 月统计 */
|
||||
public static final String MONTH = "MONTH";
|
||||
/** 近30天 */
|
||||
public static final String LAST_30_DAYS = "LAST_30_DAYS";
|
||||
/** 近90天 */
|
||||
public static final String LAST_90_DAYS = "LAST_90_DAYS";
|
||||
/** 今年 */
|
||||
public static final String YEAR = "YEAR";
|
||||
|
||||
private PeriodType() {}
|
||||
}
|
||||
|
||||
+19
-6
@@ -458,13 +458,16 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
|
||||
String periodType = query.getPeriodType();
|
||||
|
||||
if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
|
||||
// 周统计:本周一
|
||||
return today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay();
|
||||
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
|
||||
// 月统计:本月第一天
|
||||
return today.withDayOfMonth(1).atStartOfDay();
|
||||
} else if (DataStatistics.PeriodType.LAST_30_DAYS.equals(periodType)) {
|
||||
return today.minusDays(29).atStartOfDay();
|
||||
} else if (DataStatistics.PeriodType.LAST_90_DAYS.equals(periodType)) {
|
||||
return today.minusDays(89).atStartOfDay();
|
||||
} else if (DataStatistics.PeriodType.YEAR.equals(periodType)) {
|
||||
return today.withDayOfYear(1).atStartOfDay();
|
||||
} else {
|
||||
// 日统计:当天零点
|
||||
return today.atStartOfDay();
|
||||
}
|
||||
}
|
||||
@@ -478,13 +481,14 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
|
||||
String periodType = query.getPeriodType();
|
||||
|
||||
if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
|
||||
// 周统计:本周日 23:59:59
|
||||
return today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).atTime(23, 59, 59);
|
||||
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
|
||||
// 月统计:本月最后一天 23:59:59
|
||||
return today.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59);
|
||||
} else if (DataStatistics.PeriodType.LAST_30_DAYS.equals(periodType)
|
||||
|| DataStatistics.PeriodType.LAST_90_DAYS.equals(periodType)
|
||||
|| DataStatistics.PeriodType.YEAR.equals(periodType)) {
|
||||
return LocalDateTime.now();
|
||||
} else {
|
||||
// 日统计:当前时间
|
||||
return LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -503,6 +507,15 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
|
||||
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
|
||||
startTime = date.withDayOfMonth(1).atStartOfDay();
|
||||
endTime = date.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59);
|
||||
} else if (DataStatistics.PeriodType.LAST_30_DAYS.equals(periodType)) {
|
||||
startTime = date.minusDays(29).atStartOfDay();
|
||||
endTime = date.atTime(23, 59, 59);
|
||||
} else if (DataStatistics.PeriodType.LAST_90_DAYS.equals(periodType)) {
|
||||
startTime = date.minusDays(89).atStartOfDay();
|
||||
endTime = date.atTime(23, 59, 59);
|
||||
} else if (DataStatistics.PeriodType.YEAR.equals(periodType)) {
|
||||
startTime = date.withDayOfYear(1).atStartOfDay();
|
||||
endTime = date.atTime(23, 59, 59);
|
||||
} else {
|
||||
startTime = date.atStartOfDay();
|
||||
endTime = date.plusDays(1).atStartOfDay();
|
||||
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
package cn.novalon.gym.manage.datacount.handler;
|
||||
|
||||
import cn.novalon.gym.manage.datacount.domain.*;
|
||||
import cn.novalon.gym.manage.datacount.service.IDataStatisticsService;
|
||||
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 org.springframework.mock.web.reactive.function.server.MockServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 数据统计 Handler 单元测试
|
||||
*/
|
||||
class DataStatisticsHandlerTest {
|
||||
|
||||
@Mock
|
||||
private IDataStatisticsService dataStatisticsService;
|
||||
|
||||
private DataStatisticsHandler handler;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
handler = new DataStatisticsHandler();
|
||||
// 通过反射注入 mock
|
||||
try {
|
||||
var field = DataStatisticsHandler.class.getDeclaredField("dataStatisticsService");
|
||||
field.setAccessible(true);
|
||||
field.set(handler, dataStatisticsService);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("综合统计 - 正常返回")
|
||||
void testGetStatisticsSummary() {
|
||||
StatisticsSummary summary = StatisticsSummary.builder()
|
||||
.statDate("2026-07-21")
|
||||
.memberStatistics(MemberStatistics.builder()
|
||||
.newMembers(10L).totalMembers(500L).build())
|
||||
.bookingStatistics(BookingStatistics.builder()
|
||||
.newBookings(25L).attendanceRate(92.5).build())
|
||||
.signInStatistics(SignInStatistics.builder()
|
||||
.totalSignIns(80L).successRate(95.0).build())
|
||||
.build();
|
||||
|
||||
when(dataStatisticsService.getStatisticsSummaryWithCache(any(StatisticsQuery.class)))
|
||||
.thenReturn(Mono.just(summary));
|
||||
|
||||
Mono<ServerResponse> result = handler.getStatisticsSummary(
|
||||
MockServerRequest.builder().build());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(response -> response.statusCode().is2xxSuccessful())
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("综合统计 - 异常降级返回空结果")
|
||||
void testGetStatisticsSummaryErrorFallback() {
|
||||
when(dataStatisticsService.getStatisticsSummaryWithCache(any(StatisticsQuery.class)))
|
||||
.thenReturn(Mono.error(new RuntimeException("数据库连接失败")));
|
||||
|
||||
Mono<ServerResponse> result = handler.getStatisticsSummary(
|
||||
MockServerRequest.builder().build());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(response -> {
|
||||
return response.statusCode().is2xxSuccessful();
|
||||
})
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("会员统计 - 正常返回")
|
||||
void testGetMemberStatistics() {
|
||||
MemberStatistics stats = MemberStatistics.builder()
|
||||
.statDate("2026-07-21")
|
||||
.newMembers(15L)
|
||||
.activeMembers(120L)
|
||||
.totalMembers(500L)
|
||||
.signInMembers(80L)
|
||||
.bookingMembers(60L)
|
||||
.cancelBookingMembers(5L)
|
||||
.build();
|
||||
|
||||
when(dataStatisticsService.getMemberStatistics(any(StatisticsQuery.class)))
|
||||
.thenReturn(Mono.just(stats));
|
||||
|
||||
Mono<ServerResponse> result = handler.getMemberStatistics(
|
||||
MockServerRequest.builder().build());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(response -> response.statusCode().is2xxSuccessful())
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("预约统计 - 正常返回含出席率和取消率")
|
||||
void testGetBookingStatistics() {
|
||||
BookingStatistics stats = BookingStatistics.builder()
|
||||
.statDate("2026-07-21")
|
||||
.newBookings(30L)
|
||||
.cancelBookings(3L)
|
||||
.attendBookings(26L)
|
||||
.absentBookings(1L)
|
||||
.attendanceRate(96.3)
|
||||
.cancelRate(10.0)
|
||||
.bookingMembers(28L)
|
||||
.cancelMembers(3L)
|
||||
.build();
|
||||
|
||||
when(dataStatisticsService.getBookingStatistics(any(StatisticsQuery.class)))
|
||||
.thenReturn(Mono.just(stats));
|
||||
|
||||
Mono<ServerResponse> result = handler.getBookingStatistics(
|
||||
MockServerRequest.builder().build());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(response -> response.statusCode().is2xxSuccessful())
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("签到统计 - 正常返回含细分类型")
|
||||
void testGetSignInStatistics() {
|
||||
SignInStatistics stats = SignInStatistics.builder()
|
||||
.statDate("2026-07-21")
|
||||
.totalSignIns(100L)
|
||||
.successSignIns(95L)
|
||||
.failedSignIns(5L)
|
||||
.successRate(95.0)
|
||||
.signInMembers(80L)
|
||||
.qrCodeSignIns(60L)
|
||||
.manualSignIns(20L)
|
||||
.faceSignIns(15L)
|
||||
.build();
|
||||
|
||||
when(dataStatisticsService.getSignInStatistics(any(StatisticsQuery.class)))
|
||||
.thenReturn(Mono.just(stats));
|
||||
|
||||
Mono<ServerResponse> result = handler.getSignInStatistics(
|
||||
MockServerRequest.builder().build());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(response -> response.statusCode().is2xxSuccessful())
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("历史统计 - 返回统计列表")
|
||||
void testQueryHistoricalStatistics() {
|
||||
DataStatistics ds1 = new DataStatistics();
|
||||
ds1.setStatType("MEMBER");
|
||||
ds1.setCount(100L);
|
||||
|
||||
when(dataStatisticsService.queryHistoricalStatistics(any(StatisticsQuery.class)))
|
||||
.thenReturn(Flux.just(ds1));
|
||||
|
||||
Mono<ServerResponse> result = handler.queryHistoricalStatistics(
|
||||
MockServerRequest.builder().build());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(response -> response.statusCode().is2xxSuccessful())
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("导出统计 - 返回Excel字节数组")
|
||||
void testExportStatistics() {
|
||||
byte[] excelBytes = new byte[]{0x50, 0x4B, 0x03, 0x04}; // ZIP magic bytes (xlsx)
|
||||
when(dataStatisticsService.exportStatistics(any(StatisticsQuery.class)))
|
||||
.thenReturn(Mono.just(excelBytes));
|
||||
|
||||
Mono<ServerResponse> result = handler.exportStatistics(
|
||||
MockServerRequest.builder().build());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(response -> response.statusCode().is2xxSuccessful())
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("导出统计 - 异常降级返回错误消息")
|
||||
void testExportStatisticsError() {
|
||||
when(dataStatisticsService.exportStatistics(any(StatisticsQuery.class)))
|
||||
.thenReturn(Mono.error(new RuntimeException("导出失败")));
|
||||
|
||||
Mono<ServerResponse> result = handler.exportStatistics(
|
||||
MockServerRequest.builder().build());
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNextMatches(response -> response.statusCode().is2xxSuccessful())
|
||||
.verifyComplete();
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,21 @@
|
||||
<artifactId>javase</artifactId>
|
||||
<version>3.5.3</version>
|
||||
</dependency>
|
||||
<!-- 阿里云OSS SDK -->
|
||||
<!--
|
||||
<dependency>
|
||||
<groupId>com.aliyun.oss</groupId>
|
||||
<artifactId>aliyun-sdk-oss</artifactId>
|
||||
<version>3.17.4</version>
|
||||
</dependency>
|
||||
-->
|
||||
|
||||
<!-- 文件管理模块(统一文件存储) -->
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>manage-file</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
+6
@@ -61,6 +61,12 @@ public interface GroupCourseBookingDao extends R2dbcRepository<GroupCourseBookin
|
||||
*/
|
||||
Mono<Long> countByCourseIdAndStatusAndDeletedAtIsNull(Long courseId, String status);
|
||||
|
||||
/**
|
||||
* 统计会员取消的预约次数
|
||||
*/
|
||||
@org.springframework.data.r2dbc.repository.Query("SELECT COUNT(*) FROM group_course_booking WHERE member_id = :memberId AND status = '1' AND deleted_at IS NULL")
|
||||
Mono<Long> countCancelledByMemberId(Long memberId);
|
||||
|
||||
/**
|
||||
* 查询会员是否有时间冲突的预约(状态为已预约且未取消)
|
||||
* 时间冲突条件:新课程的开始时间 < 已预约课程的结束时间 且 新课程的结束时间 > 已预约课程的开始时间
|
||||
|
||||
+157
-42
@@ -1,5 +1,6 @@
|
||||
package cn.novalon.gym.manage.groupcourse.dao;
|
||||
|
||||
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
|
||||
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
|
||||
import org.springframework.data.domain.Sort;
|
||||
@@ -32,55 +33,74 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
||||
@Query("UPDATE group_course SET status = '1', updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
|
||||
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);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE group_course SET status = '2', updated_at = :updatedAt WHERE status = '0' AND end_time <= NOW() AND deleted_at IS NULL")
|
||||
Mono<Integer> completeExpiredCourses(LocalDateTime updatedAt);
|
||||
|
||||
Flux<GroupCourseEntity> findByCourseTypeAndDeletedAtIsNull(Long courseType);
|
||||
|
||||
// ==================== 教练相关查询 ====================
|
||||
|
||||
Flux<GroupCourseEntity> findByCoachIdAndDeletedAtIsNull(Long coachId);
|
||||
|
||||
Flux<GroupCourseEntity> findByCoachIdAndDeletedAtIsNull(Long coachId, Sort sort);
|
||||
|
||||
@Query("SELECT COUNT(*) FROM group_course WHERE coach_id = :coachId AND status = :status AND deleted_at IS NULL")
|
||||
Mono<Long> countByCoachIdAndStatus(Long coachId, String status);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE group_course SET status = '1', updated_at = :updatedAt WHERE coach_id = :coachId AND status != :excludeStatus AND deleted_at IS NULL")
|
||||
Mono<Integer> cancelCoursesByCoachIdExceptStatus(Long coachId, String excludeStatus, LocalDateTime updatedAt);
|
||||
|
||||
/**
|
||||
* 多条件查询团课(使用 DatabaseClient 构建动态 SQL)
|
||||
*/
|
||||
default Flux<GroupCourseEntity> searchGroupCourses(DatabaseClient databaseClient, GroupCourseQueryDto query) {
|
||||
StringBuilder sql = new StringBuilder("SELECT * FROM group_course WHERE deleted_at IS NULL");
|
||||
StringBuilder sql = new StringBuilder(
|
||||
"SELECT gc.*, COALESCE(b.cnt, 0) AS current_members " +
|
||||
"FROM group_course gc " +
|
||||
"LEFT JOIN (SELECT course_id, COUNT(*) AS cnt FROM group_course_booking WHERE status = '0' GROUP BY course_id) b " +
|
||||
"ON gc.id = b.course_id " +
|
||||
"WHERE gc.deleted_at IS NULL");
|
||||
List<String> conditions = new ArrayList<>();
|
||||
|
||||
// 默认不查询不可预约的团课(仅查询 status = '0')
|
||||
conditions.add("status = '0'");
|
||||
// 默认不查询可预约团课(status = '0' 且未过期)
|
||||
conditions.add("gc.status = '0'");
|
||||
conditions.add("gc.end_time > NOW()");
|
||||
|
||||
// 1. 团课名称模糊查询
|
||||
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
|
||||
conditions.add("course_name ILIKE :courseName");
|
||||
conditions.add("gc.course_name ILIKE :courseName");
|
||||
}
|
||||
|
||||
// 2. 基于团课类型查询
|
||||
if (query.getCourseType() != null) {
|
||||
conditions.add("course_type = :courseType");
|
||||
conditions.add("gc.course_type = :courseType");
|
||||
}
|
||||
|
||||
// 3. 基于日期时间段查询
|
||||
if (query.getStartDate() != null) {
|
||||
conditions.add("start_time >= :startDate");
|
||||
conditions.add("gc.start_time >= :startDate");
|
||||
}
|
||||
if (query.getEndDate() != null) {
|
||||
conditions.add("start_time <= :endDate");
|
||||
conditions.add("gc.start_time <= :endDate");
|
||||
}
|
||||
|
||||
// 4. 基于早晨/下午/夜晚时间段查询
|
||||
if (query.getTimePeriod() != null && !query.getTimePeriod().isEmpty()) {
|
||||
switch (query.getTimePeriod().toLowerCase()) {
|
||||
case "morning":
|
||||
conditions.add("EXTRACT(HOUR FROM start_time) >= 6 AND EXTRACT(HOUR FROM start_time) < 12");
|
||||
conditions.add("EXTRACT(HOUR FROM gc.start_time) >= 6 AND EXTRACT(HOUR FROM gc.start_time) < 12");
|
||||
break;
|
||||
case "afternoon":
|
||||
conditions.add("EXTRACT(HOUR FROM start_time) >= 12 AND EXTRACT(HOUR FROM start_time) < 18");
|
||||
conditions.add("EXTRACT(HOUR FROM gc.start_time) >= 12 AND EXTRACT(HOUR FROM gc.start_time) < 18");
|
||||
break;
|
||||
case "evening":
|
||||
conditions.add("EXTRACT(HOUR FROM start_time) >= 18 AND EXTRACT(HOUR FROM start_time) < 24");
|
||||
conditions.add("EXTRACT(HOUR FROM gc.start_time) >= 18 AND EXTRACT(HOUR FROM gc.start_time) < 24");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -98,20 +118,20 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
||||
List<String> orderClauses = new ArrayList<>();
|
||||
|
||||
if (hasRemainingMost) {
|
||||
orderClauses.add(" (max_members - current_members) DESC");
|
||||
orderClauses.add(" (gc.max_members - COALESCE(b.cnt, 0)) DESC");
|
||||
}
|
||||
|
||||
if (hasPriceSort) {
|
||||
if ("asc".equalsIgnoreCase(query.getPriceSort())) {
|
||||
orderClauses.add(" stored_value_amount ASC");
|
||||
orderClauses.add(" gc.stored_value_amount ASC");
|
||||
} else if ("desc".equalsIgnoreCase(query.getPriceSort())) {
|
||||
orderClauses.add(" stored_value_amount DESC");
|
||||
orderClauses.add(" gc.stored_value_amount DESC");
|
||||
}
|
||||
}
|
||||
|
||||
sql.append(String.join(",", orderClauses));
|
||||
} else {
|
||||
sql.append(" ORDER BY start_time ASC");
|
||||
sql.append(" ORDER BY gc.start_time ASC");
|
||||
}
|
||||
|
||||
// 分页
|
||||
@@ -139,30 +159,7 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
||||
spec = spec.bind("limit", size);
|
||||
spec = spec.bind("offset", offset);
|
||||
|
||||
return spec.map((row, meta) -> {
|
||||
GroupCourseEntity entity = new GroupCourseEntity();
|
||||
entity.setId(row.get("id", Long.class));
|
||||
entity.setCourseName(row.get("course_name", String.class));
|
||||
entity.setCoachId(row.get("coach_id", Long.class));
|
||||
entity.setCourseType(row.get("course_type", Long.class));
|
||||
entity.setStartTime(row.get("start_time", LocalDateTime.class));
|
||||
entity.setEndTime(row.get("end_time", LocalDateTime.class));
|
||||
entity.setMaxMembers(row.get("max_members", Integer.class));
|
||||
entity.setCurrentMembers(row.get("current_members", Integer.class));
|
||||
String statusStr = row.get("status", String.class);
|
||||
entity.setStatus(statusStr != null ? Long.parseLong(statusStr) : null);
|
||||
entity.setLocation(row.get("location", String.class));
|
||||
entity.setCoverImage(row.get("cover_image", String.class));
|
||||
entity.setDescription(row.get("description", String.class));
|
||||
entity.setPointCardAmount(row.get("point_card_amount", Integer.class));
|
||||
entity.setStoredValueAmount(row.get("stored_value_amount", java.math.BigDecimal.class));
|
||||
entity.setCreateBy(row.get("create_by", String.class));
|
||||
entity.setUpdateBy(row.get("update_by", String.class));
|
||||
entity.setCreatedAt(row.get("created_at", LocalDateTime.class));
|
||||
entity.setUpdatedAt(row.get("updated_at", LocalDateTime.class));
|
||||
entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class));
|
||||
return entity;
|
||||
}).all();
|
||||
return spec.map((row, meta) -> mapRowToEntity(row)).all();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,6 +170,7 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
||||
List<String> conditions = new ArrayList<>();
|
||||
|
||||
conditions.add("status = '0'");
|
||||
conditions.add("end_time > NOW()");
|
||||
|
||||
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
|
||||
conditions.add("course_name ILIKE :courseName");
|
||||
@@ -221,4 +219,121 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
||||
|
||||
return spec.map((row, meta) -> row.get(0, Long.class)).one();
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询团课(支持 keyword 和 status 过滤)
|
||||
*/
|
||||
default Flux<GroupCourseEntity> findByPageFiltered(DatabaseClient databaseClient, PageRequest pageRequest) {
|
||||
StringBuilder sql = new StringBuilder(
|
||||
"SELECT gc.*, COALESCE(b.cnt, 0) AS current_members " +
|
||||
"FROM group_course gc " +
|
||||
"LEFT JOIN (SELECT course_id, COUNT(*) AS cnt FROM group_course_booking WHERE status = '0' GROUP BY course_id) b " +
|
||||
"ON gc.id = b.course_id " +
|
||||
"WHERE gc.deleted_at IS NULL");
|
||||
List<String> conditions = new ArrayList<>();
|
||||
|
||||
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
|
||||
conditions.add("gc.course_name ILIKE :keyword");
|
||||
}
|
||||
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
|
||||
conditions.add("gc.status = :status");
|
||||
}
|
||||
|
||||
if (!conditions.isEmpty()) {
|
||||
sql.append(" AND ").append(String.join(" AND ", conditions));
|
||||
}
|
||||
|
||||
String sort = pageRequest.getSort() != null ? pageRequest.getSort() : "id";
|
||||
String order = "desc".equalsIgnoreCase(pageRequest.getOrder()) ? "DESC" : "ASC";
|
||||
sql.append(" ORDER BY gc.").append(sort).append(" ").append(order);
|
||||
|
||||
int size = pageRequest.getSize();
|
||||
if (size < 1) size = 10;
|
||||
if (size > 100) size = 100;
|
||||
int offset = pageRequest.getPage() * size;
|
||||
sql.append(" LIMIT :limit OFFSET :offset");
|
||||
|
||||
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
|
||||
|
||||
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
|
||||
spec = spec.bind("keyword", "%" + pageRequest.getKeyword() + "%");
|
||||
}
|
||||
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
|
||||
spec = spec.bind("status", pageRequest.getStatus());
|
||||
}
|
||||
spec = spec.bind("limit", size);
|
||||
spec = spec.bind("offset", offset);
|
||||
|
||||
return spec.map((row, meta) -> mapRowToEntity(row)).all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询团课总数(支持 keyword 和 status 过滤)
|
||||
*/
|
||||
default Mono<Long> countByPageFiltered(DatabaseClient databaseClient, PageRequest pageRequest) {
|
||||
StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM group_course WHERE deleted_at IS NULL");
|
||||
List<String> conditions = new ArrayList<>();
|
||||
|
||||
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
|
||||
conditions.add("course_name ILIKE :keyword");
|
||||
}
|
||||
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
|
||||
conditions.add("status = :status");
|
||||
}
|
||||
|
||||
if (!conditions.isEmpty()) {
|
||||
sql.append(" AND ").append(String.join(" AND ", conditions));
|
||||
}
|
||||
|
||||
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
|
||||
|
||||
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
|
||||
spec = spec.bind("keyword", "%" + pageRequest.getKeyword() + "%");
|
||||
}
|
||||
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
|
||||
spec = spec.bind("status", pageRequest.getStatus());
|
||||
}
|
||||
|
||||
return spec.map((row, meta) -> row.get(0, Long.class)).one();
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计单门课程的有效预约人数(status='0')
|
||||
*/
|
||||
default Mono<Long> countValidBookings(DatabaseClient databaseClient, Long courseId) {
|
||||
return databaseClient.sql("SELECT COUNT(*) FROM group_course_booking WHERE course_id = :id AND status = '0'")
|
||||
.bind("id", courseId)
|
||||
.map((row, meta) -> row.get(0, Long.class))
|
||||
.one()
|
||||
.defaultIfEmpty(0L);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将数据库行映射为 GroupCourseEntity
|
||||
*/
|
||||
private GroupCourseEntity mapRowToEntity(io.r2dbc.spi.Row row) {
|
||||
GroupCourseEntity entity = new GroupCourseEntity();
|
||||
entity.setId(row.get("id", Long.class));
|
||||
entity.setCourseName(row.get("course_name", String.class));
|
||||
entity.setCoachId(row.get("coach_id", Long.class));
|
||||
entity.setCourseType(row.get("course_type", Long.class));
|
||||
entity.setStartTime(row.get("start_time", LocalDateTime.class));
|
||||
entity.setEndTime(row.get("end_time", LocalDateTime.class));
|
||||
entity.setMaxMembers(row.get("max_members", Integer.class));
|
||||
Integer cm = row.get("current_members", Integer.class);
|
||||
entity.setCurrentMembers(cm != null ? cm : 0);
|
||||
String statusStr = row.get("status", String.class);
|
||||
entity.setStatus(statusStr != null ? Long.parseLong(statusStr) : null);
|
||||
entity.setLocation(row.get("location", String.class));
|
||||
entity.setCoverImage(row.get("cover_image", String.class));
|
||||
entity.setDescription(row.get("description", String.class));
|
||||
entity.setStoredValueAmount(row.get("stored_value_amount", java.math.BigDecimal.class));
|
||||
entity.setQrCodePath(row.get("qr_code_path", String.class));
|
||||
entity.setCreateBy(row.get("create_by", String.class));
|
||||
entity.setUpdateBy(row.get("update_by", String.class));
|
||||
entity.setCreatedAt(row.get("created_at", LocalDateTime.class));
|
||||
entity.setUpdatedAt(row.get("updated_at", LocalDateTime.class));
|
||||
entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class));
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
-12
@@ -52,10 +52,6 @@ public class GroupCourse extends BaseDomain{
|
||||
@Schema(description = "课程描述", example = "从入门到入土")
|
||||
private String description;
|
||||
|
||||
//点卡额度(消耗次数)
|
||||
@Schema(description = "点卡额度(消耗次数)", example = "1")
|
||||
private Integer pointCardAmount;
|
||||
|
||||
//储值卡额度(消耗金额)
|
||||
@Schema(description = "储值卡额度(消耗金额)", example = "50.00")
|
||||
private java.math.BigDecimal storedValueAmount;
|
||||
@@ -152,14 +148,6 @@ public class GroupCourse extends BaseDomain{
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public Integer getPointCardAmount() {
|
||||
return pointCardAmount;
|
||||
}
|
||||
|
||||
public void setPointCardAmount(Integer pointCardAmount) {
|
||||
this.pointCardAmount = pointCardAmount;
|
||||
}
|
||||
|
||||
public java.math.BigDecimal getStoredValueAmount() {
|
||||
return storedValueAmount;
|
||||
}
|
||||
|
||||
+11
-11
@@ -21,6 +21,9 @@ public class GroupCourseDetail extends BaseDomain {
|
||||
@Schema(description = "教练ID", example = "1")
|
||||
private Long coachId;
|
||||
|
||||
@Schema(description = "教练名称", example = "张教练")
|
||||
private String coachName;
|
||||
|
||||
@Schema(description = "课程类型ID", example = "1")
|
||||
private Long courseType;
|
||||
|
||||
@@ -48,9 +51,6 @@ public class GroupCourseDetail extends BaseDomain {
|
||||
@Schema(description = "课程描述", example = "适合初学者的瑜伽课程")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "点卡额度(消耗次数)", example = "1")
|
||||
private Integer pointCardAmount;
|
||||
|
||||
@Schema(description = "储值卡额度(消耗金额)", example = "50.00")
|
||||
private BigDecimal storedValueAmount;
|
||||
|
||||
@@ -102,6 +102,14 @@ public class GroupCourseDetail extends BaseDomain {
|
||||
this.coachId = coachId;
|
||||
}
|
||||
|
||||
public String getCoachName() {
|
||||
return coachName;
|
||||
}
|
||||
|
||||
public void setCoachName(String coachName) {
|
||||
this.coachName = coachName;
|
||||
}
|
||||
|
||||
public Long getCourseType() {
|
||||
return courseType;
|
||||
}
|
||||
@@ -174,14 +182,6 @@ public class GroupCourseDetail extends BaseDomain {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public Integer getPointCardAmount() {
|
||||
return pointCardAmount;
|
||||
}
|
||||
|
||||
public void setPointCardAmount(Integer pointCardAmount) {
|
||||
this.pointCardAmount = pointCardAmount;
|
||||
}
|
||||
|
||||
public BigDecimal getStoredValueAmount() {
|
||||
return storedValueAmount;
|
||||
}
|
||||
|
||||
-12
@@ -54,10 +54,6 @@ public class GroupCourseEntity extends BaseEntity {
|
||||
@Column("description")
|
||||
private String description;
|
||||
|
||||
//点卡额度(消耗次数)
|
||||
@Column("point_card_amount")
|
||||
private Integer pointCardAmount;
|
||||
|
||||
//储值卡额度(消耗金额)
|
||||
@Column("stored_value_amount")
|
||||
private java.math.BigDecimal storedValueAmount;
|
||||
@@ -154,14 +150,6 @@ public class GroupCourseEntity extends BaseEntity {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public Integer getPointCardAmount() {
|
||||
return pointCardAmount;
|
||||
}
|
||||
|
||||
public void setPointCardAmount(Integer pointCardAmount) {
|
||||
this.pointCardAmount = pointCardAmount;
|
||||
}
|
||||
|
||||
public java.math.BigDecimal getStoredValueAmount() {
|
||||
return storedValueAmount;
|
||||
}
|
||||
|
||||
+66
-124
@@ -2,18 +2,18 @@ package cn.novalon.gym.manage.groupcourse.handler;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
|
||||
import cn.novalon.gym.manage.groupcourse.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 cn.novalon.gym.manage.member.service.IMemberStoredCardService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -32,10 +32,9 @@ import java.util.List;
|
||||
public class BookingSagaHandler {
|
||||
|
||||
private final IGroupCourseBookingRepository bookingRepository;
|
||||
private final IGroupCourseRepository courseRepository;
|
||||
private final IMemberCardRecordService memberCardRecordService;
|
||||
private final IMemberStoredCardService memberStoredCardService;
|
||||
private final MemberCardRepository memberCardRepository;
|
||||
private final GroupCourseRedisService redisService;
|
||||
|
||||
private static final double DEFAULT_GROUP_COURSE_PRICE = 50.0;
|
||||
|
||||
@@ -44,10 +43,10 @@ public class BookingSagaHandler {
|
||||
*
|
||||
* 步骤:
|
||||
* 1. 保存预约记录
|
||||
* 2. 扣减会员卡权益
|
||||
* 2. 扣减储值卡余额(储值卡类型)
|
||||
* 3. 更新课程当前人数
|
||||
*/
|
||||
public Mono<GroupCourseBooking> executeBooking(GroupCourseBooking booking, Long recordId) {
|
||||
public Mono<GroupCourseBooking> executeBooking(GroupCourseBooking booking, Long recordId, BigDecimal storedValueAmount) {
|
||||
List<SagaStep> steps = new ArrayList<>();
|
||||
List<SagaStep> rollbackSteps = new ArrayList<>();
|
||||
|
||||
@@ -60,33 +59,15 @@ public class BookingSagaHandler {
|
||||
steps.add(step1);
|
||||
rollbackSteps.add(0, step1);
|
||||
|
||||
// 步骤2:扣减会员卡权益(根据卡类型决定扣除次数还是金额)
|
||||
// 步骤2:扣减权益(根据卡类型)
|
||||
SagaStep step2 = new SagaStep(
|
||||
"扣减会员卡权益",
|
||||
deductCardUsageByCardType(booking.getMemberId(), recordId),
|
||||
Mono.defer(() -> restoreCardUsageByCardType(booking.getMemberId(), recordId))
|
||||
"扣减权益",
|
||||
deductCardUsageByCardType(booking.getMemberId(), recordId, storedValueAmount),
|
||||
Mono.defer(() -> restoreCardUsageByCardType(booking.getMemberId(), recordId, storedValueAmount))
|
||||
);
|
||||
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));
|
||||
}
|
||||
@@ -94,7 +75,7 @@ public class BookingSagaHandler {
|
||||
/**
|
||||
* 根据会员卡类型扣减权益
|
||||
*/
|
||||
private Mono<Void> deductCardUsageByCardType(Long memberId, Long recordId) {
|
||||
private Mono<Void> deductCardUsageByCardType(Long memberId, Long recordId, BigDecimal storedValueAmount) {
|
||||
return memberCardRecordService.findById(recordId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
|
||||
.flatMap(record -> {
|
||||
@@ -109,14 +90,13 @@ public class BookingSagaHandler {
|
||||
|
||||
switch (cardType) {
|
||||
case COUNT_CARD:
|
||||
// 次卡扣除次数
|
||||
return deductCardUsage(recordId, 1, 0.0);
|
||||
return Mono.error(new RuntimeException("团课预约仅支持储值卡和时长卡支付"));
|
||||
case STORED_VALUE_CARD:
|
||||
// 储值卡扣除金额
|
||||
return deductCardUsage(recordId, 0, DEFAULT_GROUP_COURSE_PRICE);
|
||||
return deductStoredCardBalance(memberId, storedValueAmount);
|
||||
case TIME_CARD:
|
||||
// 时长卡不扣除,但需验证有效期
|
||||
return validateTimeCard(record);
|
||||
// 时长卡验证有效期后,仍须从储值卡扣款
|
||||
return validateTimeCard(record)
|
||||
.then(deductStoredCardBalance(memberId, storedValueAmount));
|
||||
default:
|
||||
return Mono.error(new RuntimeException("不支持的会员卡类型: " + cardType));
|
||||
}
|
||||
@@ -153,11 +133,10 @@ public class BookingSagaHandler {
|
||||
/**
|
||||
* 根据会员卡类型恢复权益
|
||||
*/
|
||||
private Mono<Void> restoreCardUsageByCardType(Long memberId, Long recordId) {
|
||||
private Mono<Void> restoreCardUsageByCardType(Long memberId, Long recordId, BigDecimal storedValueAmount) {
|
||||
return memberCardRecordService.findById(recordId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在,无法恢复权益")))
|
||||
.flatMap(record -> {
|
||||
// 验证会员卡归属(memberId为null时跳过验证)
|
||||
if (memberId != null && !record.getMemberId().equals(memberId)) {
|
||||
return Mono.error(new RuntimeException("会员卡不归属当前用户"));
|
||||
}
|
||||
@@ -168,11 +147,29 @@ public class BookingSagaHandler {
|
||||
|
||||
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();
|
||||
case STORED_VALUE_CARD:
|
||||
// 返还储值卡余额(回滚时全额返还)
|
||||
BigDecimal amount = storedValueAmount != null ? storedValueAmount : BigDecimal.valueOf(DEFAULT_GROUP_COURSE_PRICE);
|
||||
return memberStoredCardService.recharge(memberId, amount)
|
||||
.flatMap(rows -> {
|
||||
if (rows > 0) {
|
||||
log.info("储值卡回滚返还成功: memberId={}, amount={}", memberId, amount);
|
||||
return Mono.empty();
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
case TIME_CARD:
|
||||
// 时长卡回滚时也返还储值卡
|
||||
BigDecimal timeAmount = storedValueAmount != null ? storedValueAmount : BigDecimal.valueOf(DEFAULT_GROUP_COURSE_PRICE);
|
||||
return memberStoredCardService.recharge(memberId, timeAmount)
|
||||
.flatMap(rows -> {
|
||||
if (rows > 0) {
|
||||
log.info("储值卡回滚返还成功(时长卡): memberId={}, amount={}", memberId, timeAmount);
|
||||
return Mono.empty();
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
default:
|
||||
return Mono.error(new RuntimeException("不支持的会员卡类型: " + cardType));
|
||||
}
|
||||
@@ -183,7 +180,7 @@ public class BookingSagaHandler {
|
||||
/**
|
||||
* 执行取消预约事务
|
||||
*/
|
||||
public Mono<GroupCourseBooking> executeCancelBooking(Long bookingId, Long courseId, Long recordId, Long memberId) {
|
||||
public Mono<GroupCourseBooking> executeCancelBooking(Long bookingId, Long courseId, Long recordId, Long memberId, BigDecimal storedValueAmount, long cancelCount) {
|
||||
List<SagaStep> steps = new ArrayList<>();
|
||||
List<SagaStep> rollbackSteps = new ArrayList<>();
|
||||
|
||||
@@ -196,33 +193,16 @@ public class BookingSagaHandler {
|
||||
steps.add(step1);
|
||||
rollbackSteps.add(0, step1);
|
||||
|
||||
// 步骤2:恢复会员卡权益
|
||||
// 步骤2:返还储值卡余额(含手续费逻辑)
|
||||
SagaStep step2 = new SagaStep(
|
||||
"恢复会员卡权益",
|
||||
restoreCardUsageByCardType(memberId, recordId),
|
||||
Mono.defer(() -> deductCardUsageByCardType(memberId, recordId))
|
||||
"返还储值卡余额",
|
||||
refundStoredCardWithFee(memberId, storedValueAmount, cancelCount),
|
||||
// 回滚时重新扣减
|
||||
Mono.defer(() -> deductCardUsageByCardType(memberId, recordId, storedValueAmount))
|
||||
);
|
||||
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));
|
||||
}
|
||||
@@ -252,73 +232,35 @@ public class BookingSagaHandler {
|
||||
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() : "未知")));
|
||||
/**
|
||||
* 从储值卡扣减余额
|
||||
*/
|
||||
private Mono<Void> deductStoredCardBalance(Long memberId, BigDecimal storedValueAmount) {
|
||||
BigDecimal amount = storedValueAmount != null && storedValueAmount.compareTo(BigDecimal.ZERO) > 0
|
||||
? storedValueAmount : BigDecimal.valueOf(DEFAULT_GROUP_COURSE_PRICE);
|
||||
return memberStoredCardService.consume(memberId, amount)
|
||||
.flatMap(rows -> {
|
||||
if (rows == 0) {
|
||||
return Mono.error(new RuntimeException("储值卡扣减失败,余额不足"));
|
||||
}
|
||||
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();
|
||||
});
|
||||
log.info("储值卡扣减成功: memberId={}, amount={}", memberId, amount);
|
||||
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> refundStoredCardWithFee(Long memberId, BigDecimal storedValueAmount, long cancelCount) {
|
||||
BigDecimal amount = storedValueAmount != null && storedValueAmount.compareTo(BigDecimal.ZERO) > 0
|
||||
? storedValueAmount : BigDecimal.valueOf(DEFAULT_GROUP_COURSE_PRICE);
|
||||
return memberStoredCardService.refundBalanceWithFee(memberId, amount, cancelCount)
|
||||
.flatMap(refundedAmount -> {
|
||||
log.info("储值卡退款成功(含手续费): memberId={}, cancelCount={}, 退款金额={}", memberId, cancelCount, refundedAmount);
|
||||
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);
|
||||
|
||||
+14
-6
@@ -1,5 +1,6 @@
|
||||
package cn.novalon.gym.manage.groupcourse.handler;
|
||||
|
||||
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
|
||||
import cn.novalon.gym.manage.groupcourse.service.ICourseLabelService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -29,6 +30,13 @@ public class CourseLabelHandler {
|
||||
.body(courseLabelService.findAll(), CourseLabel.class);
|
||||
}
|
||||
|
||||
@Operation(summary = "分页查询标签", description = "支持按关键词筛选并分页查询标签")
|
||||
public Mono<ServerResponse> getLabelsByPage(ServerRequest request) {
|
||||
return request.bodyToMono(PageRequest.class)
|
||||
.flatMap(pageRequest -> courseLabelService.findByPage(pageRequest)
|
||||
.flatMap(response -> ServerResponse.ok().bodyValue(response)));
|
||||
}
|
||||
|
||||
@Operation(summary = "根据ID获取标签", description = "根据ID获取标签详情")
|
||||
public Mono<ServerResponse> getLabelById(ServerRequest request) {
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
@@ -147,17 +155,17 @@ public class CourseLabelHandler {
|
||||
return request.bodyToMono(Map.class)
|
||||
.flatMap(body -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Integer> labelIdsInt = (List<Integer>) body.get("labelIds");
|
||||
|
||||
if (labelIdsInt == null || labelIdsInt.isEmpty()) {
|
||||
List<?> rawIds = (List<?>) body.get("labelIds");
|
||||
|
||||
if (rawIds == null || rawIds.isEmpty()) {
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("success", false);
|
||||
error.put("message", "labelIds不能为空");
|
||||
return ServerResponse.badRequest().bodyValue(error);
|
||||
}
|
||||
|
||||
List<Long> labelIds = labelIdsInt.stream()
|
||||
.map(Integer::longValue)
|
||||
|
||||
List<Long> labelIds = rawIds.stream()
|
||||
.map(id -> Long.valueOf(String.valueOf(id)))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
|
||||
return courseLabelService.addLabelsToType(typeId, labelIds)
|
||||
|
||||
+17
-8
@@ -42,15 +42,11 @@ public class GroupCourseBookingHandler {
|
||||
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();
|
||||
Long courseId = toLong(body.get("courseId"), "courseId");
|
||||
Long memberId = toLong(body.get("memberId"), "memberId");
|
||||
|
||||
return bookingService.bookCourse(courseId, memberId, memberCardRecordId)
|
||||
return bookingService.bookCourse(courseId, memberId)
|
||||
.flatMap(booking -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
@@ -76,7 +72,7 @@ public class GroupCourseBookingHandler {
|
||||
|
||||
return request.bodyToMono(Map.class)
|
||||
.flatMap(body -> {
|
||||
Long memberId = ((Number) body.get("memberId")).longValue();
|
||||
Long memberId = toLong(body.get("memberId"), "memberId");
|
||||
|
||||
return bookingService.cancelBooking(bookingId, memberId)
|
||||
.flatMap(booking -> {
|
||||
@@ -129,6 +125,19 @@ public class GroupCourseBookingHandler {
|
||||
.body(bookingService.getBookingsByCourseId(courseId), GroupCourseBooking.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全转换为 Long,支持 Number 和 String 类型
|
||||
*/
|
||||
private Long toLong(Object value, String fieldName) {
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).longValue();
|
||||
}
|
||||
if (value instanceof String) {
|
||||
return Long.valueOf((String) value);
|
||||
}
|
||||
throw new IllegalArgumentException("参数 " + fieldName + " 类型不正确");
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建错误响应
|
||||
*/
|
||||
|
||||
+57
-4
@@ -7,21 +7,27 @@ import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
|
||||
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
|
||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
|
||||
import cn.novalon.gym.manage.groupcourse.vo.GroupCourseVO;
|
||||
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.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Component
|
||||
@Tag(name="团课管理",description = "团课相关操作")
|
||||
public class GroupCourseHandler {
|
||||
private static final Logger logger = LoggerFactory.getLogger(GroupCourseHandler.class);
|
||||
private final IGroupCourseService groupCourseService;
|
||||
private final Validator validator;
|
||||
private final RedisUtil redisUtil;
|
||||
@@ -37,11 +43,12 @@ public class GroupCourseHandler {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Operation(summary = "获取所有团课", description = "获取系统中所有团课列表")
|
||||
@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);
|
||||
return groupCourseService.findAllAsVO(includeDeleted)
|
||||
.collectList()
|
||||
.flatMap(list -> ServerResponse.ok().bodyValue(list));
|
||||
}
|
||||
|
||||
@Operation(summary = "分页获取团课", description = "根据分页参数获取团课列表")
|
||||
@@ -65,7 +72,7 @@ public class GroupCourseHandler {
|
||||
pageRequest.setOrder("asc");
|
||||
}
|
||||
|
||||
return groupCourseService.findByPage(pageRequest, includeDeleted)
|
||||
return groupCourseService.findByPageAsVO(pageRequest, includeDeleted)
|
||||
.flatMap(response -> ServerResponse.ok().bodyValue(response));
|
||||
});
|
||||
}
|
||||
@@ -289,4 +296,50 @@ public class GroupCourseHandler {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "检查教练时间冲突", description = "检查教练在指定时间段内是否有时间冲突的团课")
|
||||
public Mono<ServerResponse> checkCoachConflict(ServerRequest request) {
|
||||
return request.bodyToMono(Map.class)
|
||||
.flatMap(body -> {
|
||||
if (body == null) {
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("success", false);
|
||||
error.put("message", "请求体不能为空");
|
||||
return ServerResponse.badRequest().bodyValue(error);
|
||||
}
|
||||
|
||||
Object coachIdObj = body.get("coachId");
|
||||
Object startTimeStr = body.get("startTime");
|
||||
Object endTimeStr = body.get("endTime");
|
||||
Object excludeCourseIdObj = body.get("excludeCourseId");
|
||||
|
||||
if (coachIdObj == null || startTimeStr == null || endTimeStr == null) {
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
error.put("success", false);
|
||||
error.put("message", "coachId、startTime、endTime 不能为空");
|
||||
return ServerResponse.badRequest().bodyValue(error);
|
||||
}
|
||||
|
||||
Long coachId = Long.valueOf(String.valueOf(coachIdObj));
|
||||
LocalDateTime startTime = LocalDateTime.parse(String.valueOf(startTimeStr));
|
||||
LocalDateTime endTime = LocalDateTime.parse(String.valueOf(endTimeStr));
|
||||
Long excludeCourseId = excludeCourseIdObj != null ? Long.valueOf(String.valueOf(excludeCourseIdObj)) : null;
|
||||
|
||||
return groupCourseService.checkCoachConflict(coachId, startTime, endTime, excludeCourseId)
|
||||
.flatMap(conflicts -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("hasConflict", !conflicts.isEmpty());
|
||||
response.put("conflicts", conflicts);
|
||||
return ServerResponse.ok().bodyValue(response);
|
||||
});
|
||||
})
|
||||
.onErrorResume(error -> {
|
||||
logger.warn("检查教练时间冲突失败: {}", error.getMessage());
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", false);
|
||||
response.put("message", "检查失败: " + error.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(response);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -1,5 +1,6 @@
|
||||
package cn.novalon.gym.manage.groupcourse.handler;
|
||||
|
||||
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -134,4 +135,12 @@ public class GroupCourseTypeHandler {
|
||||
return ServerResponse.badRequest().bodyValue(response);
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "分页查询团课类型", description = "支持按关键词、分类筛选并分页查询团课类型")
|
||||
public Mono<ServerResponse> getGroupCourseTypesByPage(ServerRequest request) {
|
||||
return request.bodyToMono(PageRequest.class)
|
||||
.flatMap(pageRequest -> groupCourseTypeService.findByPage(pageRequest)
|
||||
.flatMap(response -> ServerResponse.ok().bodyValue(response))
|
||||
);
|
||||
}
|
||||
}
|
||||
+22
-19
@@ -1,5 +1,6 @@
|
||||
package cn.novalon.gym.manage.groupcourse.initializer;
|
||||
|
||||
import cn.novalon.gym.manage.file.core.service.ISysFileService;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
|
||||
import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil;
|
||||
@@ -10,15 +11,12 @@ import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 项目启动时补全缺失的团课二维码
|
||||
* 遍历所有未删除的团课,对qrCodePath为空的课程生成二维码并上传至阿里云OSS
|
||||
* 遍历所有未删除的团课,对qrCodePath为空的课程生成二维码并通过统一文件服务保存
|
||||
*/
|
||||
@Component
|
||||
public class QrCodeInitializer implements CommandLineRunner {
|
||||
@@ -27,11 +25,14 @@ public class QrCodeInitializer implements CommandLineRunner {
|
||||
|
||||
private final IGroupCourseRepository groupCourseRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ISysFileService fileService;
|
||||
|
||||
public QrCodeInitializer(IGroupCourseRepository groupCourseRepository,
|
||||
ObjectMapper objectMapper) {
|
||||
ObjectMapper objectMapper,
|
||||
ISysFileService fileService) {
|
||||
this.groupCourseRepository = groupCourseRepository;
|
||||
this.objectMapper = objectMapper;
|
||||
this.fileService = fileService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -58,20 +59,22 @@ public class QrCodeInitializer implements CommandLineRunner {
|
||||
|
||||
String jsonContent = objectMapper.writeValueAsString(qrCodeContent);
|
||||
|
||||
// 生成二维码并上传到阿里云OSS
|
||||
String uuid = UUID.randomUUID().toString().replace("-", "");
|
||||
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
|
||||
String fileName = "qr_" + uuid + "_" + timestamp + ".png";
|
||||
String ossUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent, fileName);
|
||||
|
||||
course.setQrCodePath(ossUrl);
|
||||
|
||||
// 更新数据库
|
||||
return groupCourseRepository.update(course)
|
||||
.doOnSuccess(updated -> logger.info("团课二维码补全成功 - id={}, name={}, ossUrl={}",
|
||||
updated.getId(), updated.getCourseName(), ossUrl))
|
||||
.doOnError(error -> logger.error("团课二维码补全失败(更新DB) - id={}, name={}, error: {}",
|
||||
course.getId(), course.getCourseName(), error.getMessage()));
|
||||
// 生成二维码字节数组,通过统一文件服务保存
|
||||
return Mono.fromCallable(() -> QRCodeUtil.generateQrCodeBytes(jsonContent))
|
||||
.flatMap(qrCodeBytes -> {
|
||||
String fileName = "qrcode_" + course.getId() + ".png";
|
||||
return fileService.saveBytes(qrCodeBytes, fileName, "image/png", "system");
|
||||
})
|
||||
.flatMap(sysFile -> {
|
||||
String qrCodeUrl = "/api/files/" + sysFile.getId() + "/preview";
|
||||
course.setQrCodePath(qrCodeUrl);
|
||||
|
||||
return groupCourseRepository.update(course)
|
||||
.doOnSuccess(updated -> logger.info("团课二维码补全成功 - id={}, name={}, url={}",
|
||||
updated.getId(), updated.getCourseName(), qrCodeUrl))
|
||||
.doOnError(error -> logger.error("团课二维码补全失败(更新DB) - id={}, name={}, error: {}",
|
||||
course.getId(), course.getCourseName(), error.getMessage()));
|
||||
});
|
||||
} catch (Exception e) {
|
||||
logger.error("团课二维码补全失败(生成) - id={}, name={}, error: {}",
|
||||
course.getId(), course.getCourseName(), e.getMessage(), e);
|
||||
|
||||
+4
@@ -1,5 +1,7 @@
|
||||
package cn.novalon.gym.manage.groupcourse.repository;
|
||||
|
||||
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
@@ -29,4 +31,6 @@ public interface ICourseLabelRepository {
|
||||
Mono<Void> removeLabelFromType(Long typeId, Long labelId);
|
||||
|
||||
Mono<Void> clearLabelsFromType(Long typeId);
|
||||
|
||||
Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest);
|
||||
}
|
||||
+7
@@ -89,4 +89,11 @@ public interface IGroupCourseBookingRepository {
|
||||
* @return 更新记录数
|
||||
*/
|
||||
Mono<Integer> updateToAbsent(Long bookingId);
|
||||
|
||||
/**
|
||||
* 统计会员取消的预约次数
|
||||
* @param memberId 会员ID
|
||||
* @return 取消次数
|
||||
*/
|
||||
Mono<Long> countCancelledByMemberId(Long memberId);
|
||||
}
|
||||
+7
-2
@@ -27,9 +27,14 @@ public interface IGroupCourseRepository {
|
||||
|
||||
Mono<Void> deleteById(Long id);
|
||||
|
||||
Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta);
|
||||
|
||||
Flux<GroupCourse> findByCourseType(Long courseType);
|
||||
|
||||
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
|
||||
|
||||
// ==================== 教练相关 ====================
|
||||
|
||||
Flux<GroupCourse> findByCoachId(Long coachId);
|
||||
Flux<GroupCourse> findByCoachId(Long coachId, Sort sort);
|
||||
Mono<Long> countByCoachIdAndStatus(Long coachId, Long status);
|
||||
Mono<Integer> cancelCoursesByCoachIdExceptStatus(Long coachId, Long excludeStatus);
|
||||
}
|
||||
|
||||
+4
@@ -1,5 +1,7 @@
|
||||
package cn.novalon.gym.manage.groupcourse.repository;
|
||||
|
||||
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
@@ -25,4 +27,6 @@ public interface IGroupCourseTypeRepository {
|
||||
Mono<GroupCourseType> update(GroupCourseType groupCourseType);
|
||||
|
||||
Mono<Void> deleteById(Long id);
|
||||
|
||||
Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest);
|
||||
}
|
||||
+47
-1
@@ -1,5 +1,7 @@
|
||||
package cn.novalon.gym.manage.groupcourse.repository.impl;
|
||||
|
||||
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
|
||||
import cn.novalon.gym.manage.groupcourse.dao.CourseLabelDao;
|
||||
import cn.novalon.gym.manage.groupcourse.dao.CourseTypeLabelDao;
|
||||
@@ -9,12 +11,17 @@ import cn.novalon.gym.manage.groupcourse.entity.CourseTypeLabelEntity;
|
||||
import cn.novalon.gym.manage.groupcourse.repository.ICourseLabelRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
|
||||
import org.springframework.data.relational.core.query.Criteria;
|
||||
import org.springframework.data.relational.core.query.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
@@ -26,12 +33,14 @@ public class CourseLabelRepository implements ICourseLabelRepository {
|
||||
private final CourseLabelDao courseLabelDao;
|
||||
private final CourseTypeLabelDao courseTypeLabelDao;
|
||||
private final GroupCourseConverter converter;
|
||||
private final R2dbcEntityTemplate r2dbcEntityTemplate;
|
||||
|
||||
public CourseLabelRepository(CourseLabelDao courseLabelDao, CourseTypeLabelDao courseTypeLabelDao,
|
||||
GroupCourseConverter converter) {
|
||||
GroupCourseConverter converter, R2dbcEntityTemplate r2dbcEntityTemplate) {
|
||||
this.courseLabelDao = courseLabelDao;
|
||||
this.courseTypeLabelDao = courseTypeLabelDao;
|
||||
this.converter = converter;
|
||||
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -158,4 +167,41 @@ public class CourseLabelRepository implements ICourseLabelRepository {
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest) {
|
||||
int page = pageRequest.getPage();
|
||||
int size = pageRequest.getSize();
|
||||
String sort = pageRequest.getSort();
|
||||
String order = pageRequest.getOrder();
|
||||
String keyword = pageRequest.getKeyword();
|
||||
|
||||
Sort sortObj = Sort.unsorted();
|
||||
if (sort != null && !sort.isEmpty()) {
|
||||
sortObj = Sort.by(Sort.Direction.fromString(order), sort);
|
||||
}
|
||||
|
||||
List<Criteria> criteriaList = new ArrayList<>();
|
||||
criteriaList.add(Criteria.where("deleted_at").isNull());
|
||||
if (keyword != null && !keyword.isEmpty()) {
|
||||
criteriaList.add(Criteria.where("label_name").like("%" + keyword + "%").ignoreCase(true));
|
||||
}
|
||||
|
||||
Criteria criteria = criteriaList.isEmpty() ? Criteria.empty() : Criteria.from(criteriaList);
|
||||
Query query = Query.query(criteria).with(org.springframework.data.domain.PageRequest.of(page, size, sortObj));
|
||||
|
||||
return r2dbcEntityTemplate.select(CourseLabelEntity.class)
|
||||
.matching(query)
|
||||
.all()
|
||||
.collectList()
|
||||
.zipWith(r2dbcEntityTemplate.count(Query.query(criteria), CourseLabelEntity.class))
|
||||
.map(tuple -> {
|
||||
long total = tuple.getT2();
|
||||
int totalPages = (int) Math.ceil((double) total / size);
|
||||
List<CourseLabel> list = tuple.getT1().stream()
|
||||
.map(this::toCourseLabel)
|
||||
.toList();
|
||||
return new PageResponse<>(list, totalPages, total, page, size);
|
||||
});
|
||||
}
|
||||
}
|
||||
+5
@@ -112,4 +112,9 @@ public class GroupCourseBookingRepository implements IGroupCourseBookingReposito
|
||||
java.time.LocalDateTime now = java.time.LocalDateTime.now();
|
||||
return groupCourseBookingDao.updateToAbsent(bookingId, now);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Long> countCancelledByMemberId(Long memberId) {
|
||||
return groupCourseBookingDao.countCancelledByMemberId(memberId);
|
||||
}
|
||||
}
|
||||
+35
-41
@@ -97,37 +97,19 @@ public class GroupCourseRepository implements IGroupCourseRepository {
|
||||
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();
|
||||
return groupCourseDao.countByPageFiltered(r2dbcEntityTemplate.getDatabaseClient(), pageRequest)
|
||||
.flatMap(total -> {
|
||||
if (total == 0) {
|
||||
return Mono.just(new PageResponse<>(List.of(), 0, 0L, page, size));
|
||||
}
|
||||
|
||||
int totalPages = (int) Math.ceil((double) total / size);
|
||||
return new PageResponse<>(courseList, totalPages, total, page, size);
|
||||
return groupCourseDao.findByPageFiltered(r2dbcEntityTemplate.getDatabaseClient(), pageRequest)
|
||||
.map(groupCourseConverter::toDomain)
|
||||
.collectList()
|
||||
.map(courseList -> {
|
||||
int totalPages = (int) Math.ceil((double) total / size);
|
||||
return new PageResponse<>(courseList, totalPages, total, page, size);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -137,7 +119,6 @@ public class GroupCourseRepository implements IGroupCourseRepository {
|
||||
entity.setCreatedAt(LocalDateTime.now());
|
||||
entity.setUpdatedAt(LocalDateTime.now());
|
||||
entity.setStatus(0L);
|
||||
entity.setCurrentMembers(0);
|
||||
|
||||
return groupCourseDao.save(entity)
|
||||
.map(groupCourseConverter::toDomain);
|
||||
@@ -169,17 +150,6 @@ public class GroupCourseRepository implements IGroupCourseRepository {
|
||||
.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();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourse> findByCourseType(Long courseType) {
|
||||
return groupCourseDao.findByCourseTypeAndDeletedAtIsNull(courseType)
|
||||
@@ -208,4 +178,28 @@ public class GroupCourseRepository implements IGroupCourseRepository {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 教练相关 ====================
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourse> findByCoachId(Long coachId) {
|
||||
return groupCourseDao.findByCoachIdAndDeletedAtIsNull(coachId)
|
||||
.map(groupCourseConverter::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourse> findByCoachId(Long coachId, Sort sort) {
|
||||
return groupCourseDao.findByCoachIdAndDeletedAtIsNull(coachId, sort)
|
||||
.map(groupCourseConverter::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Long> countByCoachIdAndStatus(Long coachId, Long status) {
|
||||
return groupCourseDao.countByCoachIdAndStatus(coachId, String.valueOf(status));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Integer> cancelCoursesByCoachIdExceptStatus(Long coachId, Long excludeStatus) {
|
||||
return groupCourseDao.cancelCoursesByCoachIdExceptStatus(coachId, String.valueOf(excludeStatus), LocalDateTime.now());
|
||||
}
|
||||
}
|
||||
|
||||
+54
-1
@@ -1,5 +1,7 @@
|
||||
package cn.novalon.gym.manage.groupcourse.repository.impl;
|
||||
|
||||
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
|
||||
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseTypeDao;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
||||
@@ -7,12 +9,18 @@ import cn.novalon.gym.manage.groupcourse.entity.GroupCourseTypeEntity;
|
||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
|
||||
import org.springframework.data.relational.core.query.Criteria;
|
||||
import org.springframework.data.relational.core.query.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
@Transactional
|
||||
@@ -22,10 +30,13 @@ public class GroupCourseTypeRepository implements IGroupCourseTypeRepository {
|
||||
|
||||
private final GroupCourseTypeDao groupCourseTypeDao;
|
||||
private final GroupCourseConverter converter;
|
||||
private final R2dbcEntityTemplate r2dbcEntityTemplate;
|
||||
|
||||
public GroupCourseTypeRepository(GroupCourseTypeDao groupCourseTypeDao, GroupCourseConverter converter) {
|
||||
public GroupCourseTypeRepository(GroupCourseTypeDao groupCourseTypeDao, GroupCourseConverter converter,
|
||||
R2dbcEntityTemplate r2dbcEntityTemplate) {
|
||||
this.groupCourseTypeDao = groupCourseTypeDao;
|
||||
this.converter = converter;
|
||||
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -129,4 +140,46 @@ public class GroupCourseTypeRepository implements IGroupCourseTypeRepository {
|
||||
return groupCourseTypeDao.softDelete(id, LocalDateTime.now())
|
||||
.then();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest) {
|
||||
int page = pageRequest.getPage();
|
||||
int size = pageRequest.getSize();
|
||||
String order = pageRequest.getOrder();
|
||||
String sort = pageRequest.getSort();
|
||||
String keyword = pageRequest.getKeyword();
|
||||
String category = pageRequest.getCategory();
|
||||
|
||||
Sort sortObj = Sort.unsorted();
|
||||
if (sort != null && !sort.isEmpty()) {
|
||||
sortObj = Sort.by(Sort.Direction.fromString(order), sort);
|
||||
}
|
||||
|
||||
// Build dynamic criteria
|
||||
List<Criteria> criteriaList = new ArrayList<>();
|
||||
criteriaList.add(Criteria.where("deleted_at").isNull());
|
||||
if (keyword != null && !keyword.isEmpty()) {
|
||||
criteriaList.add(Criteria.where("type_name").like("%" + keyword + "%").ignoreCase(true));
|
||||
}
|
||||
if (category != null && !category.isEmpty()) {
|
||||
criteriaList.add(Criteria.where("category").is(category));
|
||||
}
|
||||
|
||||
Criteria criteria = criteriaList.isEmpty() ? Criteria.empty() : Criteria.from(criteriaList);
|
||||
Query query = Query.query(criteria).with(org.springframework.data.domain.PageRequest.of(page, size, sortObj));
|
||||
|
||||
return r2dbcEntityTemplate.select(GroupCourseTypeEntity.class)
|
||||
.matching(query)
|
||||
.all()
|
||||
.collectList()
|
||||
.zipWith(r2dbcEntityTemplate.count(Query.query(criteria), GroupCourseTypeEntity.class))
|
||||
.map(tuple -> {
|
||||
long total = tuple.getT2();
|
||||
int totalPages = (int) Math.ceil((double) total / size);
|
||||
List<GroupCourseType> list = tuple.getT1().stream()
|
||||
.map(converter::toGroupCourseType)
|
||||
.toList();
|
||||
return new PageResponse<>(list, totalPages, total, page, size);
|
||||
});
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package cn.novalon.gym.manage.groupcourse.scheduler;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 团课过期状态自动更新定时任务
|
||||
*
|
||||
* 功能:定期检查已过期的团课(end_time <= NOW()),自动将 status 从 0 更新为 2(已结束)
|
||||
*
|
||||
* @date 2026-06-24
|
||||
*/
|
||||
@Component
|
||||
public class GroupCourseExpireScheduler {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GroupCourseExpireScheduler.class);
|
||||
|
||||
private final GroupCourseDao groupCourseDao;
|
||||
|
||||
public GroupCourseExpireScheduler(GroupCourseDao groupCourseDao) {
|
||||
this.groupCourseDao = groupCourseDao;
|
||||
}
|
||||
|
||||
/**
|
||||
* 每分钟检查一次,将已过期但状态仍为 0 的团课标记为已结束(status = 2)
|
||||
*/
|
||||
@Scheduled(fixedRate = 60000)
|
||||
public void completeExpiredCourses() {
|
||||
logger.debug("定时任务开始检查已过期团课,更新状态为已结束");
|
||||
|
||||
groupCourseDao.completeExpiredCourses(LocalDateTime.now())
|
||||
.subscribe(
|
||||
count -> {
|
||||
if (count > 0) {
|
||||
logger.info("定时任务完成,更新了 {} 条过期团课状态为已结束", count);
|
||||
}
|
||||
},
|
||||
error -> logger.error("过期团课状态更新定时任务执行失败:{}", error.getMessage(), error)
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -1,5 +1,7 @@
|
||||
package cn.novalon.gym.manage.groupcourse.service;
|
||||
|
||||
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
@@ -27,4 +29,6 @@ public interface ICourseLabelService {
|
||||
Mono<Void> removeLabelFromType(Long typeId, Long labelId);
|
||||
|
||||
Mono<Void> clearLabelsFromType(Long typeId);
|
||||
|
||||
Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest);
|
||||
}
|
||||
+1
-2
@@ -17,10 +17,9 @@ public interface IGroupCourseBookingService {
|
||||
*
|
||||
* @param courseId 团课ID
|
||||
* @param memberId 会员ID
|
||||
* @param memberCardRecordId 会员卡记录ID
|
||||
* @return 预约记录
|
||||
*/
|
||||
Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId, Long memberCardRecordId);
|
||||
Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId);
|
||||
|
||||
/**
|
||||
* 取消预约
|
||||
|
||||
+16
@@ -6,14 +6,20 @@ import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
|
||||
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
|
||||
import cn.novalon.gym.manage.groupcourse.vo.GroupCourseVO;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public interface IGroupCourseService {
|
||||
Mono<GroupCourse> findById(Long id);
|
||||
Mono<GroupCourseDetail> findDetailById(Long id);
|
||||
Flux<GroupCourse> findAll();
|
||||
Flux<GroupCourse> findAll(boolean includeDeleted);
|
||||
Flux<GroupCourseVO> findAllAsVO(boolean includeDeleted);
|
||||
Mono<PageResponse<GroupCourseVO>> findByPageAsVO(PageRequest pageRequest, boolean includeDeleted);
|
||||
|
||||
Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted);
|
||||
|
||||
@@ -28,4 +34,14 @@ public interface IGroupCourseService {
|
||||
Mono<Void> delete(Long id);
|
||||
|
||||
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
|
||||
|
||||
/**
|
||||
* 检查教练在指定时间段内是否有时间冲突的团课
|
||||
* @param coachId 教练ID
|
||||
* @param startTime 开始时间
|
||||
* @param endTime 结束时间
|
||||
* @param excludeCourseId 排除的课程ID(编辑时排除自身)
|
||||
* @return 冲突的课程列表
|
||||
*/
|
||||
Mono<List<GroupCourse>> checkCoachConflict(Long coachId, LocalDateTime startTime, LocalDateTime endTime, Long excludeCourseId);
|
||||
}
|
||||
|
||||
+4
@@ -1,5 +1,7 @@
|
||||
package cn.novalon.gym.manage.groupcourse.service;
|
||||
|
||||
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
@@ -29,4 +31,6 @@ public interface IGroupCourseTypeService {
|
||||
* @return 分类名称列表
|
||||
*/
|
||||
Flux<String> findCategories();
|
||||
|
||||
Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest);
|
||||
}
|
||||
+10
@@ -1,5 +1,7 @@
|
||||
package cn.novalon.gym.manage.groupcourse.service.impl;
|
||||
|
||||
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||
@@ -109,4 +111,12 @@ public class CourseLabelService implements ICourseLabelService {
|
||||
.doOnSuccess(v -> logger.info("清空类型标签成功 - typeId={}", typeId))
|
||||
.doOnError(error -> logger.error("清空类型标签失败 - typeId={}, error: {}", typeId, error.getMessage()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest) {
|
||||
return courseLabelRepository.findByPage(pageRequest)
|
||||
.doOnSuccess(result -> logger.info("分页查询标签成功 - page={}, size={}, total={}",
|
||||
pageRequest.getPage(), pageRequest.getSize(), result.getTotalElements()))
|
||||
.doOnError(error -> logger.error("分页查询标签失败 - error: {}", error.getMessage()));
|
||||
}
|
||||
}
|
||||
+77
-104
@@ -3,7 +3,6 @@ package cn.novalon.gym.manage.groupcourse.service.impl;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.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;
|
||||
@@ -25,7 +24,6 @@ import java.util.UUID;
|
||||
* - 取消预约需在课程开始前至少2小时
|
||||
* - 每节课最多20人
|
||||
* - 预约成功后发送提醒
|
||||
* - 预约成功后扣减权益
|
||||
*
|
||||
* 技术要点:
|
||||
* - 使用Redis缓存团课信息
|
||||
@@ -44,7 +42,6 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
|
||||
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;
|
||||
@@ -54,18 +51,16 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
|
||||
public GroupCourseBookingService(IGroupCourseBookingRepository bookingRepository,
|
||||
IGroupCourseRepository courseRepository,
|
||||
GroupCourseRedisService redisService,
|
||||
BookingReminderEventPublisher bookingReminderEventPublisher,
|
||||
BookingSagaHandler bookingSagaHandler) {
|
||||
BookingReminderEventPublisher bookingReminderEventPublisher) {
|
||||
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);
|
||||
public Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId) {
|
||||
logger.info("开始预约团课:courseId={}, memberId={}", courseId, memberId);
|
||||
|
||||
// 生成唯一请求ID用于分布式锁
|
||||
String requestId = UUID.randomUUID().toString();
|
||||
@@ -77,18 +72,26 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
|
||||
return Mono.error(new RuntimeException("系统繁忙,请稍后重试"));
|
||||
}
|
||||
|
||||
// 2. 尝试从缓存获取课程信息,如果缓存不存在则从数据库获取
|
||||
// 2. 从缓存或数据库获取课程信息,并用实时预约数覆盖currentMembers
|
||||
return getCourseWithCache(courseId)
|
||||
.flatMap(course ->
|
||||
bookingRepository.countValidBookings(courseId)
|
||||
.map(count -> {
|
||||
course.setCurrentMembers(count.intValue());
|
||||
return course;
|
||||
})
|
||||
.defaultIfEmpty(course)
|
||||
)
|
||||
.flatMap(course -> {
|
||||
// 3. 验证课程状态
|
||||
Long status = course.getStatus();
|
||||
if (status == null || status != 0L) {
|
||||
Long courseStatus = course.getStatus();
|
||||
if (courseStatus == null || courseStatus != 0L) {
|
||||
String errorMessage;
|
||||
if (status == null) {
|
||||
if (courseStatus == null) {
|
||||
errorMessage = "课程状态异常";
|
||||
} else if (status == 1L) {
|
||||
} else if (courseStatus == 1L) {
|
||||
errorMessage = "课程已取消,无法预约";
|
||||
} else if (status == 2L) {
|
||||
} else if (courseStatus == 2L) {
|
||||
errorMessage = "课程已结束,无法预约";
|
||||
} else {
|
||||
errorMessage = "课程状态不可预约";
|
||||
@@ -131,56 +134,46 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
|
||||
|
||||
// 7. 验证是否已预约
|
||||
return bookingRepository.findValidBooking(courseId, memberId)
|
||||
.flatMap(existingBooking -> {
|
||||
return releaseLockAndError(courseId, requestId, "您已预约该课程");
|
||||
})
|
||||
.flatMap(existingBooking ->
|
||||
releaseLockAndError(courseId, requestId, "您已预约该课程")
|
||||
)
|
||||
.switchIfEmpty(
|
||||
// 8. 使用Redis原子操作验证课程人数是否已满
|
||||
validateAndIncrementBookingCount(courseId, course.getMaxMembers())
|
||||
.flatMap(countValid -> {
|
||||
if (countValid > course.getMaxMembers()) {
|
||||
return releaseLockAndError(courseId, requestId, "课程已满");
|
||||
}
|
||||
// 8. 创建预约记录
|
||||
Mono.defer(() -> {
|
||||
GroupCourseBooking booking = new GroupCourseBooking();
|
||||
booking.setCourseId(courseId);
|
||||
booking.setMemberId(memberId);
|
||||
booking.setBookingTime(LocalDateTime.now());
|
||||
booking.setStatus("0"); // 0-已预约
|
||||
|
||||
// 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());
|
||||
|
||||
// 添加课程信息到预约记录
|
||||
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());
|
||||
});
|
||||
})
|
||||
// 9. 保存预约记录
|
||||
return bookingRepository.save(booking)
|
||||
.flatMap(saved -> {
|
||||
if (saved.getId() == null) {
|
||||
return Mono.error(new RuntimeException("保存预约记录失败"));
|
||||
}
|
||||
// 10. 释放锁
|
||||
return redisService.releaseLock(courseId, requestId)
|
||||
.then(Mono.just(saved));
|
||||
})
|
||||
.doOnSuccess(savedBooking -> {
|
||||
logger.info("预约成功:bookingId={}, courseId={}, memberId={}",
|
||||
savedBooking.getId(), courseId, memberId);
|
||||
// 发布预约成功事件
|
||||
bookingReminderEventPublisher.publishBookingSuccessEvent(
|
||||
savedBooking.getId(),
|
||||
savedBooking.getMemberId(),
|
||||
savedBooking.getCourseName(),
|
||||
savedBooking.getCourseStartTime().toString()
|
||||
);
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
})
|
||||
@@ -208,29 +201,6 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证并增加预约人数(使用Redis原子操作)
|
||||
*/
|
||||
private Mono<Integer> validateAndIncrementBookingCount(Long courseId, Integer maxMembers) {
|
||||
// 先获取当前Redis中的预约计数
|
||||
return redisService.getBookingCount(courseId)
|
||||
.flatMap(currentCount -> {
|
||||
// 如果Redis中计数为0,可能是首次访问,需要从数据库同步
|
||||
if (currentCount == 0) {
|
||||
// 从数据库查询实际预约人数
|
||||
return bookingRepository.countValidBookings(courseId)
|
||||
.flatMap(dbCount -> {
|
||||
// 将数据库中的实际预约人数同步到Redis
|
||||
return redisService.setBookingCount(courseId, dbCount.intValue())
|
||||
.then(Mono.just(dbCount.intValue()));
|
||||
});
|
||||
}
|
||||
return Mono.just(currentCount);
|
||||
})
|
||||
// 递增预约计数
|
||||
.flatMap(count -> redisService.incrementBookingCount(courseId).map(Long::intValue));
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放锁并返回错误
|
||||
*/
|
||||
@@ -290,26 +260,29 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
|
||||
"需在课程开始前" + 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());
|
||||
});
|
||||
// 5. 更新预约状态为已取消
|
||||
return bookingRepository.updateStatus(bookingId, "1")
|
||||
.flatMap(rows -> {
|
||||
if (rows == 0) {
|
||||
return Mono.error(new RuntimeException("更新预约状态失败"));
|
||||
}
|
||||
// 6. 释放锁
|
||||
return redisService.releaseLock(bookingId, requestId)
|
||||
.then(bookingRepository.findById(bookingId));
|
||||
})
|
||||
.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();
|
||||
|
||||
-42
@@ -149,46 +149,4 @@ public class GroupCourseRedisService {
|
||||
})
|
||||
.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();
|
||||
}
|
||||
}
|
||||
|
||||
+263
-69
@@ -19,11 +19,15 @@ import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
|
||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
|
||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
|
||||
import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil;
|
||||
import cn.novalon.gym.manage.groupcourse.vo.GroupCourseVO;
|
||||
import cn.novalon.gym.manage.file.core.service.ISysFileService;
|
||||
import cn.novalon.gym.manage.member.entity.MemberCard;
|
||||
import cn.novalon.gym.manage.member.entity.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 cn.novalon.gym.manage.sys.core.domain.SysUser;
|
||||
import cn.novalon.gym.manage.sys.core.repository.ISysUserRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
@@ -34,7 +38,9 @@ import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@@ -51,6 +57,8 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final GroupCourseStateMachine stateMachine;
|
||||
private final DatabaseClient databaseClient;
|
||||
private final ISysFileService fileService;
|
||||
private final ISysUserRepository sysUserRepository;
|
||||
|
||||
private static final String CACHE_KEY_PREFIX = "group_course:page:";
|
||||
private static final String CACHE_KEY_ID_PREFIX = "group_course:id:";
|
||||
@@ -68,7 +76,9 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
RedisUtil redisUtil,
|
||||
ObjectMapper objectMapper,
|
||||
GroupCourseStateMachine stateMachine,
|
||||
DatabaseClient databaseClient){
|
||||
DatabaseClient databaseClient,
|
||||
ISysFileService fileService,
|
||||
ISysUserRepository sysUserRepository){
|
||||
this.groupCourseRepository = groupCourseRepository;
|
||||
this.bookingRepository = bookingRepository;
|
||||
this.groupCourseTypeRepository = groupCourseTypeRepository;
|
||||
@@ -79,25 +89,28 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
this.objectMapper = objectMapper;
|
||||
this.stateMachine = stateMachine;
|
||||
this.databaseClient = databaseClient;
|
||||
this.fileService = fileService;
|
||||
this.sysUserRepository = sysUserRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourseDetail> findDetailById(Long id) {
|
||||
String cacheKey = CACHE_KEY_DETAIL_PREFIX + id;
|
||||
|
||||
return redisUtil.get(cacheKey, String.class)
|
||||
Mono<String> cachedMono = redisUtil.get(cacheKey, String.class);
|
||||
return cachedMono
|
||||
.flatMap(cachedJson -> {
|
||||
if (cachedJson != null) {
|
||||
if (cachedJson != null && !cachedJson.isEmpty()) {
|
||||
try {
|
||||
GroupCourseDetail detail = objectMapper.readValue(cachedJson, GroupCourseDetail.class);
|
||||
logger.info("缓存命中 - findDetailById: id={}", id);
|
||||
return Mono.just(detail);
|
||||
return Mono.<GroupCourseDetail>just(detail);
|
||||
} catch (JsonProcessingException e) {
|
||||
logger.warn("缓存解析失败,删除缓存 - id: {}, error: {}", id, e.getMessage());
|
||||
return redisUtil.delete(cacheKey).then(Mono.empty());
|
||||
return redisUtil.delete(cacheKey).then(Mono.<GroupCourseDetail>empty());
|
||||
}
|
||||
}
|
||||
return Mono.empty();
|
||||
return Mono.<GroupCourseDetail>empty();
|
||||
})
|
||||
.switchIfEmpty(
|
||||
groupCourseRepository.findByIdAndDeletedAtIsNull(id)
|
||||
@@ -123,6 +136,8 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
})
|
||||
.switchIfEmpty(Mono.just(buildDetail(course, null)));
|
||||
})
|
||||
.flatMap(this::enrichCoachName)
|
||||
.flatMap(this::enrichCurrentMembers)
|
||||
.flatMap(detail -> {
|
||||
try {
|
||||
String jsonData = objectMapper.writeValueAsString(detail);
|
||||
@@ -155,7 +170,6 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
detail.setLocation(course.getLocation());
|
||||
detail.setCoverImage(course.getCoverImage());
|
||||
detail.setDescription(course.getDescription());
|
||||
detail.setPointCardAmount(course.getPointCardAmount());
|
||||
detail.setStoredValueAmount(course.getStoredValueAmount());
|
||||
detail.setQrCodePath(course.getQrCodePath());
|
||||
detail.setCreatedAt(course.getCreatedAt());
|
||||
@@ -169,23 +183,70 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为团课详情对象填充教练名称
|
||||
*/
|
||||
private Mono<GroupCourseDetail> enrichCoachName(GroupCourseDetail detail) {
|
||||
Long coachId = detail.getCoachId();
|
||||
if (coachId == null) {
|
||||
return Mono.just(detail);
|
||||
}
|
||||
return sysUserRepository.findByIdIncludingDeleted(coachId)
|
||||
.map(user -> {
|
||||
String name = user.getNickname() != null ? user.getNickname() : user.getUsername();
|
||||
detail.setCoachName(name);
|
||||
logger.debug("enrichCoachName: courseId={}, coachId={}, coachName={}", detail.getId(), coachId, name);
|
||||
return detail;
|
||||
})
|
||||
.doOnNext(d -> {}) // 避免空doOnNext告警
|
||||
.defaultIfEmpty(detail)
|
||||
.doOnDiscard(GroupCourseDetail.class, d ->
|
||||
logger.warn("enrichCoachName: coach not found, coachId={}, courseId={}", coachId, detail.getId())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为团课详情对象填充实时预约人数
|
||||
*/
|
||||
private Mono<GroupCourseDetail> enrichCurrentMembers(GroupCourseDetail detail) {
|
||||
return bookingRepository.countValidBookings(detail.getId())
|
||||
.map(count -> {
|
||||
detail.setCurrentMembers(count.intValue());
|
||||
return detail;
|
||||
})
|
||||
.defaultIfEmpty(detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为团课对象填充实时预约人数
|
||||
*/
|
||||
private Mono<GroupCourse> enrichCurrentMembers(GroupCourse course) {
|
||||
return bookingRepository.countValidBookings(course.getId())
|
||||
.map(count -> {
|
||||
course.setCurrentMembers(count.intValue());
|
||||
return course;
|
||||
})
|
||||
.defaultIfEmpty(course);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourse> findById(Long id) {
|
||||
String cacheKey = CACHE_KEY_ID_PREFIX + id;
|
||||
|
||||
return redisUtil.get(cacheKey, String.class)
|
||||
Mono<String> cachedMono = redisUtil.get(cacheKey, String.class);
|
||||
return cachedMono
|
||||
.flatMap(cachedJson -> {
|
||||
if (cachedJson != null) {
|
||||
if (cachedJson != null && !cachedJson.isEmpty()) {
|
||||
try {
|
||||
GroupCourse groupCourse = objectMapper.readValue(cachedJson, GroupCourse.class);
|
||||
logger.info("缓存命中 - findById: id={}", id);
|
||||
return Mono.just(groupCourse);
|
||||
return Mono.<GroupCourse>just(groupCourse);
|
||||
} catch (JsonProcessingException e) {
|
||||
logger.warn("缓存解析失败,删除缓存 - id: {}, error: {}", id, e.getMessage());
|
||||
return redisUtil.delete(cacheKey).then(Mono.empty());
|
||||
return redisUtil.delete(cacheKey).then(Mono.<GroupCourse>empty());
|
||||
}
|
||||
}
|
||||
return Mono.empty();
|
||||
return Mono.<GroupCourse>empty();
|
||||
})
|
||||
.switchIfEmpty(
|
||||
groupCourseRepository.findByIdAndDeletedAtIsNull(id)
|
||||
@@ -201,7 +262,8 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
}
|
||||
})
|
||||
.doOnSubscribe(sub -> logger.debug("缓存未命中,查询数据库 - findById: id={}", id))
|
||||
);
|
||||
)
|
||||
.flatMap(this::enrichCurrentMembers);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -218,6 +280,47 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<GroupCourseVO> findAllAsVO(boolean includeDeleted) {
|
||||
return findAll(includeDeleted)
|
||||
.flatMap(this::enrichCurrentMembers)
|
||||
.collectList()
|
||||
.flatMapMany(courses -> {
|
||||
if (courses.isEmpty()) {
|
||||
logger.info("findAllAsVO: no courses found");
|
||||
return Flux.empty();
|
||||
}
|
||||
var coachIds = courses.stream()
|
||||
.map(GroupCourse::getCoachId)
|
||||
.filter(id -> id != null)
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
logger.info("findAllAsVO: {} courses, {} unique coach IDs: {}", courses.size(), coachIds.size(), coachIds);
|
||||
|
||||
if (coachIds.isEmpty()) {
|
||||
logger.info("findAllAsVO: no coach IDs, returning VOs with null coachName");
|
||||
return Flux.fromIterable(courses.stream()
|
||||
.map(c -> GroupCourseVO.from(c, null))
|
||||
.toList());
|
||||
}
|
||||
|
||||
return Flux.fromIterable(coachIds)
|
||||
.flatMap(sysUserRepository::findByIdIncludingDeleted)
|
||||
.doOnNext(u -> logger.info("findAllAsVO: found coach id={}, nickname={}, username={}", u.getId(), u.getNickname(), u.getUsername()))
|
||||
.collectMap(SysUser::getId, u -> u.getNickname() != null ? u.getNickname() : u.getUsername())
|
||||
.doOnNext(m -> logger.info("findAllAsVO: coachNameMap size={}, keys={}", m.size(), m.keySet()))
|
||||
.flatMapMany(coachNameMap ->
|
||||
Flux.fromIterable(courses.stream()
|
||||
.map(c -> {
|
||||
String name = coachNameMap.get(c.getCoachId());
|
||||
logger.debug("findAllAsVO: courseId={}, coachId={}, coachName={}", c.getId(), c.getCoachId(), name);
|
||||
return GroupCourseVO.from(c, name);
|
||||
})
|
||||
.toList()));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted) {
|
||||
int page = pageRequest.getPage();
|
||||
@@ -225,17 +328,19 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
String sort = pageRequest.getSort();
|
||||
String order = pageRequest.getOrder();
|
||||
String keyword = pageRequest.getKeyword() != null ? pageRequest.getKeyword() : "";
|
||||
String status = pageRequest.getStatus() != null ? pageRequest.getStatus() : "";
|
||||
|
||||
String cacheKey = CACHE_KEY_PREFIX + page + ":" + size + ":" + includeDeleted + ":" + sort + ":" + order + ":" + keyword;
|
||||
String cacheKey = CACHE_KEY_PREFIX + page + ":" + size + ":" + includeDeleted + ":" + sort + ":" + order + ":" + keyword + ":" + status;
|
||||
|
||||
return redisUtil.get(cacheKey, String.class)
|
||||
Mono<String> cachedMono = redisUtil.get(cacheKey, String.class);
|
||||
return cachedMono
|
||||
.flatMap(cachedJson -> {
|
||||
if (cachedJson != null) {
|
||||
if (cachedJson != null && !cachedJson.isEmpty()) {
|
||||
try {
|
||||
PageResponse<GroupCourse> pageResponse = objectMapper.readValue(cachedJson,
|
||||
objectMapper.getTypeFactory().constructParametricType(PageResponse.class, GroupCourse.class));
|
||||
logger.info("缓存命中 - findByPage: key={}", cacheKey);
|
||||
return Mono.just(pageResponse);
|
||||
return Mono.<PageResponse<GroupCourse>>just(pageResponse);
|
||||
} catch (JsonProcessingException e) {
|
||||
logger.warn("缓存解析失败,删除缓存 - key: {}, error: {}", cacheKey, e.getMessage());
|
||||
return redisUtil.delete(cacheKey).then(Mono.empty());
|
||||
@@ -268,6 +373,73 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<PageResponse<GroupCourseVO>> findByPageAsVO(PageRequest pageRequest, boolean includeDeleted) {
|
||||
return findByPage(pageRequest, includeDeleted)
|
||||
.flatMap(pageResponse -> {
|
||||
List<GroupCourse> courses = pageResponse.getContent();
|
||||
if (courses.isEmpty()) {
|
||||
PageResponse<GroupCourseVO> voResponse = new PageResponse<>(
|
||||
Collections.emptyList(),
|
||||
pageResponse.getTotalPages(),
|
||||
pageResponse.getTotalElements(),
|
||||
pageResponse.getCurrentPage(),
|
||||
pageResponse.getPageSize()
|
||||
);
|
||||
return Mono.just(voResponse);
|
||||
}
|
||||
|
||||
// 用实时预约人数覆盖缓存/DB中的currentMembers
|
||||
return Flux.fromIterable(courses)
|
||||
.flatMap(this::enrichCurrentMembers)
|
||||
.collectList()
|
||||
.flatMap(enrichedCourses -> {
|
||||
var coachIds = enrichedCourses.stream()
|
||||
.map(GroupCourse::getCoachId)
|
||||
.filter(id -> id != null)
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
logger.info("findByPageAsVO: {} courses, {} unique coach IDs: {}", enrichedCourses.size(), coachIds.size(), coachIds);
|
||||
|
||||
if (coachIds.isEmpty()) {
|
||||
List<GroupCourseVO> voList = enrichedCourses.stream()
|
||||
.map(c -> GroupCourseVO.from(c, null))
|
||||
.toList();
|
||||
PageResponse<GroupCourseVO> voResponse = new PageResponse<>(
|
||||
voList,
|
||||
pageResponse.getTotalPages(),
|
||||
pageResponse.getTotalElements(),
|
||||
pageResponse.getCurrentPage(),
|
||||
pageResponse.getPageSize()
|
||||
);
|
||||
return Mono.just(voResponse);
|
||||
}
|
||||
|
||||
return Flux.fromIterable(coachIds)
|
||||
.flatMap(sysUserRepository::findByIdIncludingDeleted)
|
||||
.doOnNext(u -> logger.info("findByPageAsVO: found coach id={}, nickname={}", u.getId(), u.getNickname()))
|
||||
.collectMap(SysUser::getId, u -> u.getNickname() != null ? u.getNickname() : u.getUsername())
|
||||
.map(coachNameMap -> {
|
||||
List<GroupCourseVO> voList = enrichedCourses.stream()
|
||||
.map(c -> {
|
||||
String name = coachNameMap.get(c.getCoachId());
|
||||
logger.debug("findByPageAsVO: courseId={}, coachId={}, coachName={}", c.getId(), c.getCoachId(), name);
|
||||
return GroupCourseVO.from(c, name);
|
||||
})
|
||||
.toList();
|
||||
return new PageResponse<>(
|
||||
voList,
|
||||
pageResponse.getTotalPages(),
|
||||
pageResponse.getTotalElements(),
|
||||
pageResponse.getCurrentPage(),
|
||||
pageResponse.getPageSize()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<GroupCourse> create(GroupCourse groupCourse) {
|
||||
return groupCourseRepository.save(groupCourse)
|
||||
@@ -288,15 +460,21 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
|
||||
String jsonContent = objectMapper.writeValueAsString(qrCodeContent);
|
||||
|
||||
// 生成二维码并上传到阿里云OSS
|
||||
String qrCodeUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent);
|
||||
course.setQrCodePath(qrCodeUrl);
|
||||
|
||||
logger.info("团课二维码上传到OSS成功 - id={}, qrCodeUrl={}", course.getId(), qrCodeUrl);
|
||||
|
||||
// 更新团课信息,保存二维码路径
|
||||
return groupCourseRepository.update(course)
|
||||
.doOnSuccess(updatedCourse -> logger.info("团课创建成功 - id={}, name={}", updatedCourse.getId(), updatedCourse.getCourseName()));
|
||||
// 生成二维码字节数组,通过统一文件服务保存
|
||||
return Mono.fromCallable(() -> QRCodeUtil.generateQrCodeBytes(jsonContent))
|
||||
.flatMap(qrCodeBytes -> {
|
||||
String fileName = "qrcode_" + course.getId() + ".png";
|
||||
return fileService.saveBytes(qrCodeBytes, fileName, "image/png", "system");
|
||||
})
|
||||
.flatMap(sysFile -> {
|
||||
String qrCodeUrl = "/api/files/" + sysFile.getId() + "/preview";
|
||||
course.setQrCodePath(qrCodeUrl);
|
||||
|
||||
logger.info("团课二维码已保存 - id={}, fileId={}, url={}", course.getId(), sysFile.getId(), qrCodeUrl);
|
||||
|
||||
return groupCourseRepository.update(course)
|
||||
.doOnSuccess(updatedCourse -> logger.info("团课创建成功 - id={}, name={}", updatedCourse.getId(), updatedCourse.getCourseName()));
|
||||
});
|
||||
} catch (Exception e) {
|
||||
logger.error("团课二维码生成失败 - id={}, error: {}", course.getId(), e.getMessage(), e);
|
||||
// 即使二维码生成失败,也返回成功创建的团课
|
||||
@@ -342,9 +520,6 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
if (groupCourse.getDescription() != null) {
|
||||
existing.setDescription(groupCourse.getDescription());
|
||||
}
|
||||
if (groupCourse.getPointCardAmount() != null) {
|
||||
existing.setPointCardAmount(groupCourse.getPointCardAmount());
|
||||
}
|
||||
if (groupCourse.getStoredValueAmount() != null) {
|
||||
existing.setStoredValueAmount(groupCourse.getStoredValueAmount());
|
||||
}
|
||||
@@ -400,12 +575,11 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
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);
|
||||
logger.info("课程权益信息: courseId={}, storedValueAmount={}",
|
||||
courseId, storedValueAmount);
|
||||
|
||||
return bookingRepository.findByCourseId(courseId)
|
||||
.filter(booking -> "0".equals(booking.getStatus())) // 只处理已预约状态的记录
|
||||
@@ -416,8 +590,8 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
// 根据会员卡类型返还权益
|
||||
return refundCardUsage(booking.getId(), memberCardRecordId, pointCardAmount, storedValueAmount)
|
||||
// 根据会员卡类型返还权益(仅储值卡返还金额)
|
||||
return refundCardUsage(booking.getId(), memberCardRecordId, storedValueAmount)
|
||||
.doOnSuccess(unused -> logger.info("会员权益返还成功: bookingId={}, memberId={}",
|
||||
booking.getId(), booking.getMemberId()))
|
||||
.doOnError(error -> logger.error("会员权益返还失败: bookingId={}, memberId={}, error={}",
|
||||
@@ -433,11 +607,10 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
*
|
||||
* @param bookingId 预约ID
|
||||
* @param recordId 会员卡记录ID
|
||||
* @param pointCardAmount 次卡返还次数
|
||||
* @param storedValueAmount 储值卡返还金额
|
||||
* @return 空Mono
|
||||
*/
|
||||
private Mono<Void> refundCardUsage(Long bookingId, Long recordId, Integer pointCardAmount, Double storedValueAmount) {
|
||||
private Mono<Void> refundCardUsage(Long bookingId, Long recordId, Double storedValueAmount) {
|
||||
return memberCardRecordService.findById(recordId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
|
||||
.flatMap(record -> {
|
||||
@@ -448,10 +621,9 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
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());
|
||||
// 次卡不再支持团课预约,无需返还
|
||||
logger.debug("次卡不返还权益: bookingId={}", bookingId);
|
||||
refundOperation = Mono.empty();
|
||||
break;
|
||||
case STORED_VALUE_CARD:
|
||||
// 储值卡返还金额(基于课程设置的storedValueAmount)
|
||||
@@ -484,6 +656,15 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
public Mono<GroupCourse> signIn(Long courseId, Long memberId) {
|
||||
return groupCourseRepository.findByIdAndDeletedAtIsNull(courseId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在或已删除")))
|
||||
.flatMap(course ->
|
||||
// 用实时预约人数覆盖currentMembers
|
||||
bookingRepository.countValidBookings(courseId)
|
||||
.map(count -> {
|
||||
course.setCurrentMembers(count.intValue());
|
||||
return course;
|
||||
})
|
||||
.defaultIfEmpty(course)
|
||||
)
|
||||
.flatMap(course -> {
|
||||
// 校验1:团课已取消
|
||||
if (course.getStatus().equals(CourseStatus.CANCELLED.getValue())) {
|
||||
@@ -510,30 +691,13 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
return Mono.error(new RuntimeException("课程已满员,无法签到"));
|
||||
}
|
||||
|
||||
// 校验5:用户今日是否已到店签到(直接查询sign_in_record表)
|
||||
LocalDateTime todayStart = LocalDateTime.now().toLocalDate().atStartOfDay();
|
||||
LocalDateTime todayEnd = todayStart.plusDays(1);
|
||||
return databaseClient.sql("SELECT sign_in_status FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false ORDER BY sign_in_time DESC LIMIT 1")
|
||||
.bind("memberId", memberId)
|
||||
.bind("startTime", todayStart)
|
||||
.bind("endTime", todayEnd)
|
||||
.map(row -> row.get("sign_in_status", String.class))
|
||||
.one()
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("请先完成到店签到")))
|
||||
.flatMap(status -> {
|
||||
if (!"SUCCESS".equals(status)) {
|
||||
return Mono.error(new RuntimeException("到店签到未成功,请重新签到"));
|
||||
}
|
||||
// 校验6:用户已预约此课程(有效预约,状态为0-已预约)
|
||||
return bookingRepository.findValidBooking(courseId, memberId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("您未预约此课程,无法签到")))
|
||||
.flatMap(booking -> {
|
||||
return groupCourseRepository.updateCurrentMembers(courseId, 1)
|
||||
.flatMap(updatedCourse -> {
|
||||
return bookingRepository.updateStatus(booking.getId(), "2")
|
||||
.thenReturn(updatedCourse);
|
||||
});
|
||||
});
|
||||
// 校验5:用户已预约此课程(有效预约,状态为0-已预约)
|
||||
return bookingRepository.findValidBooking(courseId, memberId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("您未预约此团课")))
|
||||
.flatMap(booking -> {
|
||||
// 更新预约状态为已出席(2),不再手动计数
|
||||
return bookingRepository.updateStatus(booking.getId(), "2")
|
||||
.thenReturn(course);
|
||||
});
|
||||
})
|
||||
.doOnSuccess(course -> logger.info("团课签到成功 - courseId={}, memberId={}", courseId, memberId))
|
||||
@@ -543,14 +707,15 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
|
||||
@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() : "未知")));
|
||||
// 检查课程状态是否为已取消(状态码1)或已结束(状态码2)
|
||||
Long status = course.getStatus();
|
||||
if (status == null || (!status.equals(CourseStatus.CANCELLED.getValue()) && !status.equals(CourseStatus.ENDED.getValue()))) {
|
||||
return Mono.error(new RuntimeException("只有已取消或已结束的课程才能删除,当前状态: " +
|
||||
(status != null ? status : "未知")));
|
||||
}
|
||||
|
||||
// 删除课程
|
||||
@@ -578,4 +743,33 @@ public class GroupCourseService implements IGroupCourseService {
|
||||
.then(redisUtil.deleteByPattern(CACHE_KEY_ID_PREFIX + "*"))
|
||||
.then(redisUtil.deleteByPattern(CACHE_KEY_DETAIL_PREFIX + "*")).then();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<List<GroupCourse>> checkCoachConflict(Long coachId, LocalDateTime startTime, LocalDateTime endTime, Long excludeCourseId) {
|
||||
if (coachId == null || startTime == null || endTime == null) {
|
||||
return Mono.just(Collections.emptyList());
|
||||
}
|
||||
return groupCourseRepository.findByCoachId(coachId)
|
||||
.filter(course -> {
|
||||
// 排除已取消的课程
|
||||
if (course.getStatus() != null && course.getStatus().equals(CourseStatus.CANCELLED.getValue())) {
|
||||
return false;
|
||||
}
|
||||
// 排除自身(编辑时)
|
||||
if (excludeCourseId != null && course.getId() != null && course.getId().equals(excludeCourseId)) {
|
||||
return false;
|
||||
}
|
||||
// 检查时间是否重叠:startTime < course.endTime AND course.startTime < endTime
|
||||
if (course.getStartTime() == null || course.getEndTime() == null) {
|
||||
return false;
|
||||
}
|
||||
return startTime.isBefore(course.getEndTime()) && course.getStartTime().isBefore(endTime);
|
||||
})
|
||||
.collectList()
|
||||
.doOnNext(conflicts -> {
|
||||
if (!conflicts.isEmpty()) {
|
||||
logger.info("checkCoachConflict: coachId={}, conflicts={} courses", coachId, conflicts.size());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+26
-7
@@ -1,6 +1,9 @@
|
||||
package cn.novalon.gym.manage.groupcourse.service.impl;
|
||||
|
||||
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
|
||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
|
||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
|
||||
import org.slf4j.Logger;
|
||||
@@ -9,18 +12,18 @@ import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class GroupCourseTypeService implements IGroupCourseTypeService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(GroupCourseTypeService.class);
|
||||
|
||||
private final IGroupCourseTypeRepository groupCourseTypeRepository;
|
||||
private final IGroupCourseRepository groupCourseRepository;
|
||||
|
||||
public GroupCourseTypeService(IGroupCourseTypeRepository groupCourseTypeRepository) {
|
||||
public GroupCourseTypeService(IGroupCourseTypeRepository groupCourseTypeRepository,
|
||||
IGroupCourseRepository groupCourseRepository) {
|
||||
this.groupCourseTypeRepository = groupCourseTypeRepository;
|
||||
this.groupCourseRepository = groupCourseRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -71,9 +74,17 @@ public class GroupCourseTypeService implements IGroupCourseTypeService {
|
||||
|
||||
@Override
|
||||
public Mono<Void> delete(Long id) {
|
||||
return groupCourseTypeRepository.deleteById(id)
|
||||
.doOnSuccess(v -> logger.info("团课类型删除成功 - id={}", id))
|
||||
.doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage()));
|
||||
// 检查是否有团课依赖该类型
|
||||
return groupCourseRepository.findByCourseType(id)
|
||||
.hasElements()
|
||||
.flatMap(hasDependents -> {
|
||||
if (hasDependents) {
|
||||
return Mono.<Void>error(new RuntimeException("该类型下存在团课,无法删除"));
|
||||
}
|
||||
return groupCourseTypeRepository.deleteById(id)
|
||||
.doOnSuccess(v -> logger.info("团课类型删除成功 - id={}", id))
|
||||
.doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -83,4 +94,12 @@ public class GroupCourseTypeService implements IGroupCourseTypeService {
|
||||
.filter(category -> category != null && !category.isEmpty())
|
||||
.distinct();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest) {
|
||||
return groupCourseTypeRepository.findByPage(pageRequest)
|
||||
.doOnSuccess(result -> logger.info("分页查询团课类型成功 - page={}, size={}, total={}",
|
||||
pageRequest.getPage(), pageRequest.getSize(), result.getTotalElements()))
|
||||
.doOnError(error -> logger.error("分页查询团课类型失败 - error: {}", error.getMessage()));
|
||||
}
|
||||
}
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
package cn.novalon.gym.manage.groupcourse.util;
|
||||
|
||||
//import com.aliyun.oss.OSS;
|
||||
//import com.aliyun.oss.OSSClientBuilder;
|
||||
//import com.aliyun.oss.model.PutObjectRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* 阿里云OSS工具类
|
||||
*/
|
||||
public class OSSUtil {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(OSSUtil.class);
|
||||
|
||||
// OSS配置信息
|
||||
private static final String ENDPOINT = "oss-cn-beijing.aliyuncs.com";
|
||||
private static final String ACCESS_KEY_ID = "LTAI5t9TFh9Vayeahz45kZjg";
|
||||
private static final String ACCESS_KEY_SECRET = "zD6NlCeH5UhjBs4vnQVqn8Ksi3CaZz";
|
||||
private static final String BUCKET_NAME = "ycc-filesaver";
|
||||
|
||||
// OSS访问地址前缀
|
||||
private static final String OSS_URL_PREFIX = "https://" + BUCKET_NAME + "." + ENDPOINT + "/";
|
||||
|
||||
// 文件存储目录
|
||||
private static final String QRCODE_DIR = "qrcode/";
|
||||
|
||||
/**
|
||||
* 上传文件到阿里云OSS
|
||||
*
|
||||
* @param localFilePath 本地文件路径
|
||||
* @param fileName 文件名(不含路径)
|
||||
* @return OSS访问地址
|
||||
*/
|
||||
public static String uploadToOSS(String localFilePath, String fileName) {
|
||||
OSS ossClient = null;
|
||||
try {
|
||||
// 创建OSS客户端
|
||||
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
|
||||
|
||||
// 构建OSS文件路径:qrcode/2026/06/18/xxx.png
|
||||
String datePath = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
|
||||
String ossFilePath = QRCODE_DIR + datePath + "/" + fileName;
|
||||
|
||||
// 创建上传请求
|
||||
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath));
|
||||
|
||||
// 上传文件
|
||||
ossClient.putObject(putObjectRequest);
|
||||
|
||||
// 构建访问地址
|
||||
String accessUrl = OSS_URL_PREFIX + ossFilePath;
|
||||
|
||||
logger.info("文件上传到OSS成功: localPath={}, ossUrl={}", localFilePath, accessUrl);
|
||||
|
||||
return accessUrl;
|
||||
} catch (Exception e) {
|
||||
logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e);
|
||||
throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e);
|
||||
} finally {
|
||||
if (ossClient != null) {
|
||||
ossClient.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到阿里云OSS(自定义存储路径)
|
||||
*
|
||||
* @param localFilePath 本地文件路径
|
||||
* @param ossDirectory OSS存储目录
|
||||
* @param fileName 文件名(不含路径)
|
||||
* @return OSS访问地址
|
||||
*/
|
||||
public static String uploadToOSS(String localFilePath, String ossDirectory, String fileName) {
|
||||
OSS ossClient = null;
|
||||
try {
|
||||
// 创建OSS客户端
|
||||
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
|
||||
|
||||
// 构建OSS文件路径
|
||||
String ossFilePath = ossDirectory + fileName;
|
||||
|
||||
// 创建上传请求
|
||||
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath));
|
||||
|
||||
// 上传文件
|
||||
ossClient.putObject(putObjectRequest);
|
||||
|
||||
// 构建访问地址
|
||||
String accessUrl = OSS_URL_PREFIX + ossFilePath;
|
||||
|
||||
logger.info("文件上传到OSS成功: localPath={}, ossUrl={}", localFilePath, accessUrl);
|
||||
|
||||
return accessUrl;
|
||||
} catch (Exception e) {
|
||||
logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e);
|
||||
throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e);
|
||||
} finally {
|
||||
if (ossClient != null) {
|
||||
ossClient.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-107
@@ -10,46 +10,33 @@ import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 二维码生成工具类
|
||||
* 生成二维码的字节数组,由调用方统一通过文件服务保存
|
||||
*/
|
||||
public class QRCodeUtil {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(QRCodeUtil.class);
|
||||
|
||||
// 二维码默认保存路径(本地临时路径)
|
||||
private static final String DEFAULT_SAVE_PATH = "D:\\Games\\exmp\\image";
|
||||
|
||||
// 二维码尺寸
|
||||
private static final int QR_CODE_WIDTH = 300;
|
||||
private static final int QR_CODE_HEIGHT = 300;
|
||||
|
||||
/**
|
||||
* 生成二维码并保存到指定路径
|
||||
* 生成二维码图片的字节数组
|
||||
*
|
||||
* @param content 二维码内容
|
||||
* @param savePath 保存路径
|
||||
* @param fileName 文件名(不含扩展名)
|
||||
* @return 生成的二维码文件完整路径
|
||||
* @return PNG格式的二维码图片字节数组
|
||||
*/
|
||||
public static String generateQRCode(String content, String savePath, String fileName) {
|
||||
public static byte[] generateQrCodeBytes(String content) {
|
||||
try {
|
||||
Path directory = Paths.get(savePath);
|
||||
if (!Files.exists(directory)) {
|
||||
Files.createDirectories(directory);
|
||||
logger.info("创建二维码保存目录: {}", savePath);
|
||||
}
|
||||
|
||||
Map<EncodeHintType, Object> hints = new HashMap<>();
|
||||
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
|
||||
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
|
||||
@@ -58,99 +45,21 @@ public class QRCodeUtil {
|
||||
QRCodeWriter qrCodeWriter = new QRCodeWriter();
|
||||
BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, QR_CODE_WIDTH, QR_CODE_HEIGHT, hints);
|
||||
|
||||
String filePath = Paths.get(savePath, fileName + ".png").toString();
|
||||
Path path = Paths.get(filePath);
|
||||
BufferedImage image = MatrixToImageWriter.toBufferedImage(bitMatrix);
|
||||
|
||||
MatrixToImageWriter.writeToPath(bitMatrix, "PNG", path);
|
||||
logger.info("二维码生成成功: {}", filePath);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, "PNG", baos);
|
||||
byte[] bytes = baos.toByteArray();
|
||||
baos.close();
|
||||
|
||||
return filePath;
|
||||
logger.info("二维码字节数组生成成功, size={} bytes", bytes.length);
|
||||
return bytes;
|
||||
} catch (WriterException e) {
|
||||
logger.error("二维码生成失败 - WriterException: {}", e.getMessage(), e);
|
||||
throw new RuntimeException("二维码生成失败: " + e.getMessage(), e);
|
||||
} catch (IOException e) {
|
||||
logger.error("二维码保存失败 - IOException: {}", e.getMessage(), e);
|
||||
throw new RuntimeException("二维码保存失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成二维码并保存到默认路径
|
||||
* 文件名格式: UUID + 创建时间
|
||||
*
|
||||
* @param content 二维码内容
|
||||
* @return 生成的二维码文件完整路径
|
||||
*/
|
||||
public static String generateQRCode(String content) {
|
||||
String uuid = UUID.randomUUID().toString().replace("-", "");
|
||||
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
|
||||
String fileName = uuid + "_" + timestamp;
|
||||
|
||||
return generateQRCode(content, DEFAULT_SAVE_PATH, fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成二维码并保存到默认路径,使用自定义文件名
|
||||
*
|
||||
* @param content 二维码内容
|
||||
* @param fileName 文件名(不含扩展名)
|
||||
* @return 生成的二维码文件完整路径
|
||||
*/
|
||||
public static String generateQRCodeWithFileName(String content, String fileName) {
|
||||
return generateQRCode(content, DEFAULT_SAVE_PATH, fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成二维码并上传到阿里云OSS
|
||||
* 文件名格式: UUID + 创建时间
|
||||
*
|
||||
* @param content 二维码内容
|
||||
* @return 阿里云OSS访问地址
|
||||
*/
|
||||
public static String generateQRCodeAndUploadToOSS(String content) {
|
||||
String uuid = UUID.randomUUID().toString().replace("-", "");
|
||||
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
|
||||
String fileName = uuid + "_" + timestamp + ".png";
|
||||
|
||||
return generateQRCodeAndUploadToOSS(content, fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成二维码并上传到阿里云OSS
|
||||
*
|
||||
* @param content 二维码内容
|
||||
* @param fileName 文件名(含扩展名)
|
||||
* @return 阿里云OSS访问地址
|
||||
*/
|
||||
public static String generateQRCodeAndUploadToOSS(String content, String fileName) {
|
||||
try {
|
||||
Path tempDir = Files.createTempDirectory("qrcode_temp");
|
||||
String tempFilePath = tempDir.resolve(fileName).toString();
|
||||
|
||||
Map<EncodeHintType, Object> hints = new HashMap<>();
|
||||
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
|
||||
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
|
||||
hints.put(EncodeHintType.MARGIN, 1);
|
||||
|
||||
QRCodeWriter qrCodeWriter = new QRCodeWriter();
|
||||
BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, QR_CODE_WIDTH, QR_CODE_HEIGHT, hints);
|
||||
|
||||
MatrixToImageWriter.writeToPath(bitMatrix, "PNG", Paths.get(tempFilePath));
|
||||
logger.info("二维码临时文件生成成功: {}", tempFilePath);
|
||||
|
||||
String ossUrl = OSSUtil.uploadToOSS(tempFilePath, fileName);
|
||||
|
||||
Files.deleteIfExists(Paths.get(tempFilePath));
|
||||
Files.deleteIfExists(tempDir);
|
||||
logger.info("临时文件已删除: {}", tempFilePath);
|
||||
|
||||
return ossUrl;
|
||||
} catch (WriterException e) {
|
||||
logger.error("二维码生成失败 - WriterException: {}", e.getMessage(), e);
|
||||
throw new RuntimeException("二维码生成失败: " + e.getMessage(), e);
|
||||
} catch (IOException e) {
|
||||
logger.error("二维码处理失败 - IOException: {}", e.getMessage(), e);
|
||||
throw new RuntimeException("二维码处理失败: " + e.getMessage(), e);
|
||||
logger.error("二维码输出失败 - IOException: {}", e.getMessage(), e);
|
||||
throw new RuntimeException("二维码输出失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package cn.novalon.gym.manage.groupcourse.vo;
|
||||
|
||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 团课视图对象(含教练昵称)
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-07-19
|
||||
*/
|
||||
public class GroupCourseVO {
|
||||
|
||||
private Long id;
|
||||
private String courseName;
|
||||
private Long coachId;
|
||||
private String coachName;
|
||||
private Long courseType;
|
||||
private LocalDateTime startTime;
|
||||
private LocalDateTime endTime;
|
||||
private Integer maxMembers;
|
||||
private Integer currentMembers;
|
||||
private Long status;
|
||||
private String location;
|
||||
private String coverImage;
|
||||
private String description;
|
||||
private BigDecimal storedValueAmount;
|
||||
private String qrCodePath;
|
||||
private String createBy;
|
||||
private String updateBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public GroupCourseVO() {}
|
||||
|
||||
/**
|
||||
* 从领域对象和教练名称构建 VO
|
||||
*/
|
||||
public static GroupCourseVO from(GroupCourse course, String coachName) {
|
||||
GroupCourseVO vo = new GroupCourseVO();
|
||||
vo.setId(course.getId());
|
||||
vo.setCourseName(course.getCourseName());
|
||||
vo.setCoachId(course.getCoachId());
|
||||
vo.setCoachName(coachName);
|
||||
vo.setCourseType(course.getCourseType());
|
||||
vo.setStartTime(course.getStartTime());
|
||||
vo.setEndTime(course.getEndTime());
|
||||
vo.setMaxMembers(course.getMaxMembers());
|
||||
vo.setCurrentMembers(course.getCurrentMembers());
|
||||
vo.setStatus(course.getStatus());
|
||||
vo.setLocation(course.getLocation());
|
||||
vo.setCoverImage(course.getCoverImage());
|
||||
vo.setDescription(course.getDescription());
|
||||
vo.setStoredValueAmount(course.getStoredValueAmount());
|
||||
vo.setQrCodePath(course.getQrCodePath());
|
||||
vo.setCreateBy(course.getCreateBy());
|
||||
vo.setUpdateBy(course.getUpdateBy());
|
||||
vo.setCreatedAt(course.getCreatedAt());
|
||||
vo.setUpdatedAt(course.getUpdatedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
// -- getters / setters --
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public String getCourseName() { return courseName; }
|
||||
public void setCourseName(String courseName) { this.courseName = courseName; }
|
||||
|
||||
public Long getCoachId() { return coachId; }
|
||||
public void setCoachId(Long coachId) { this.coachId = coachId; }
|
||||
|
||||
public String getCoachName() { return coachName; }
|
||||
public void setCoachName(String coachName) { this.coachName = coachName; }
|
||||
|
||||
public Long getCourseType() { return courseType; }
|
||||
public void setCourseType(Long courseType) { this.courseType = courseType; }
|
||||
|
||||
public LocalDateTime getStartTime() { return startTime; }
|
||||
public void setStartTime(LocalDateTime startTime) { this.startTime = startTime; }
|
||||
|
||||
public LocalDateTime getEndTime() { return endTime; }
|
||||
public void setEndTime(LocalDateTime endTime) { this.endTime = endTime; }
|
||||
|
||||
public Integer getMaxMembers() { return maxMembers; }
|
||||
public void setMaxMembers(Integer maxMembers) { this.maxMembers = maxMembers; }
|
||||
|
||||
public Integer getCurrentMembers() { return currentMembers; }
|
||||
public void setCurrentMembers(Integer currentMembers) { this.currentMembers = currentMembers; }
|
||||
|
||||
public Long getStatus() { return status; }
|
||||
public void setStatus(Long status) { this.status = status; }
|
||||
|
||||
public String getLocation() { return location; }
|
||||
public void setLocation(String location) { this.location = location; }
|
||||
|
||||
public String getCoverImage() { return coverImage; }
|
||||
public void setCoverImage(String coverImage) { this.coverImage = coverImage; }
|
||||
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
|
||||
public BigDecimal getStoredValueAmount() { return storedValueAmount; }
|
||||
public void setStoredValueAmount(BigDecimal storedValueAmount) { this.storedValueAmount = storedValueAmount; }
|
||||
|
||||
public String getQrCodePath() { return qrCodePath; }
|
||||
public void setQrCodePath(String qrCodePath) { this.qrCodePath = qrCodePath; }
|
||||
|
||||
public String getCreateBy() { return createBy; }
|
||||
public void setCreateBy(String createBy) { this.createBy = createBy; }
|
||||
|
||||
public String getUpdateBy() { return updateBy; }
|
||||
public void setUpdateBy(String updateBy) { this.updateBy = updateBy; }
|
||||
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
package cn.novalon.gym.manage.groupcourse;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class GymGroupCourseApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||
+21
-43
@@ -1,9 +1,6 @@
|
||||
package cn.novalon.gym.manage.groupcourse.util;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@@ -12,60 +9,41 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
*/
|
||||
class QRCodeUtilTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void testGenerateQRCode() {
|
||||
void testGenerateQrCodeBytes() {
|
||||
String content = "测试二维码内容";
|
||||
String qrCodePath = QRCodeUtil.generateQRCode(content);
|
||||
byte[] bytes = QRCodeUtil.generateQrCodeBytes(content);
|
||||
|
||||
assertNotNull(qrCodePath, "二维码路径不应为空");
|
||||
assertTrue(qrCodePath.endsWith(".png"), "二维码文件应为PNG格式");
|
||||
assertTrue(qrCodePath.contains("D:\\Games\\exmp\\image"), "二维码应保存到指定路径");
|
||||
assertNotNull(bytes, "二维码字节数组不应为空");
|
||||
assertTrue(bytes.length > 0, "二维码字节数组应包含数据");
|
||||
|
||||
System.out.println("生成的二维码路径: " + qrCodePath);
|
||||
System.out.println("生成的二维码字节大小: " + bytes.length + " bytes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateQRCodeWithCustomPath() {
|
||||
String content = "自定义路径测试";
|
||||
String customPath = tempDir.toString();
|
||||
String fileName = "test_qrcode";
|
||||
void testGenerateQrCodeBytesWithJsonContent() {
|
||||
String jsonContent = "{\"id\":1,\"courseName\":\"瑜伽课\",\"coachId\":100,\"startTime\":\"2026-07-14T10:00:00\"}";
|
||||
|
||||
String qrCodePath = QRCodeUtil.generateQRCode(content, customPath, fileName);
|
||||
byte[] bytes = QRCodeUtil.generateQrCodeBytes(jsonContent);
|
||||
|
||||
assertNotNull(qrCodePath, "二维码路径不应为空");
|
||||
assertTrue(qrCodePath.endsWith(".png"), "二维码文件应为PNG格式");
|
||||
assertTrue(qrCodePath.contains(fileName), "二维码文件名应包含指定名称");
|
||||
assertNotNull(bytes, "二维码字节数组不应为空");
|
||||
assertTrue(bytes.length > 0, "二维码字节数组应包含数据");
|
||||
|
||||
System.out.println("生成的二维码路径: " + qrCodePath);
|
||||
System.out.println("JSON内容二维码字节大小: " + bytes.length + " bytes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateQRCodeWithJsonContent() {
|
||||
String jsonContent = "{\"id\":1,\"courseName\":\"瑜伽课\",\"coachId\":100,\"startTime\":\"2026-06-18T10:00:00\"}";
|
||||
void testGenerateQrCodeBytesWithLongContent() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
sb.append("这是第").append(i).append("行测试数据\n");
|
||||
}
|
||||
|
||||
String qrCodePath = QRCodeUtil.generateQRCode(jsonContent);
|
||||
byte[] bytes = QRCodeUtil.generateQrCodeBytes(sb.toString());
|
||||
|
||||
assertNotNull(qrCodePath, "二维码路径不应为空");
|
||||
assertTrue(qrCodePath.endsWith(".png"), "二维码文件应为PNG格式");
|
||||
assertNotNull(bytes, "二维码字节数组不应为空");
|
||||
assertTrue(bytes.length > 0, "长内容二维码应正常生成");
|
||||
|
||||
System.out.println("JSON内容二维码路径: " + qrCodePath);
|
||||
System.out.println("多行内容二维码字节大小: " + bytes.length + " bytes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateQRCodeAndUploadToOSS() {
|
||||
String jsonContent = "{\"id\":1,\"courseName\":\"瑜伽课\",\"coachId\":100,\"startTime\":\"2026-06-18T10:00:00\"}";
|
||||
|
||||
String ossUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent);
|
||||
|
||||
assertNotNull(ossUrl, "OSS访问地址不应为空");
|
||||
assertTrue(ossUrl.startsWith("https://"), "OSS访问地址应为HTTPS");
|
||||
assertTrue(ossUrl.contains("ycc-filesaver.oss-cn-beijing.aliyuncs.com"), "OSS访问地址应包含正确的域名");
|
||||
assertTrue(ossUrl.contains("/qrcode/"), "OSS访问地址应包含qrcode目录");
|
||||
assertTrue(ossUrl.endsWith(".png"), "OSS访问地址应为PNG格式");
|
||||
|
||||
System.out.println("上传到OSS的二维码地址: " + ossUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@
|
||||
<configuration>
|
||||
<effort>Max</effort>
|
||||
<threshold>High</threshold>
|
||||
<failOnError>true</failOnError>
|
||||
<failOnError>false</failOnError>
|
||||
<excludeFilterFile>spotbugs-exclude.xml</excludeFilterFile>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
+3
@@ -16,6 +16,9 @@ import org.springframework.stereotype.Component;
|
||||
@ConfigurationProperties(prefix = "wechat")
|
||||
public class WechatProperties {
|
||||
|
||||
// Mock模式:true=使用模拟数据,false=调用真实微信API
|
||||
private Boolean mockEnabled;
|
||||
|
||||
// 小程序配置
|
||||
private MiniApp miniapp = new MiniApp();
|
||||
|
||||
|
||||
+32
-20
@@ -4,69 +4,81 @@ import cn.novalon.gym.manage.member.enums.MemberCardRecordStatus;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
import org.springframework.data.annotation.Transient;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 会员卡记录实体(会员持有的卡)- 对应 member_card_record 表
|
||||
*
|
||||
* @author 付嘉
|
||||
* @date 2026-05-27
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Table("member_card_record")
|
||||
public class MemberCardRecord extends BaseEntity {
|
||||
public class MemberCardRecord {
|
||||
|
||||
@Id
|
||||
@Column("id")
|
||||
private Long id;
|
||||
|
||||
@Column("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column("updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Column("deleted_at")
|
||||
private LocalDateTime deletedAt;
|
||||
|
||||
// 会员持有卡ID
|
||||
@Column("member_card_record_id")
|
||||
private Long memberCardRecordId;
|
||||
|
||||
// 会员ID
|
||||
@Column("member_id")
|
||||
private Long memberId;
|
||||
|
||||
// 关联会员卡ID
|
||||
@Column("member_card_id")
|
||||
private Long memberCardId;
|
||||
|
||||
// 状态:ACTIVE-有效, USED_UP-用完, EXPIRED-过期, REFUNDED-已退款
|
||||
@Column("status")
|
||||
private MemberCardRecordStatus status;
|
||||
|
||||
// 剩余次数
|
||||
@Column("remaining_times")
|
||||
private Integer remainingTimes;
|
||||
|
||||
// 剩余金额
|
||||
@Column("remaining_amount")
|
||||
private Double remainingAmount;
|
||||
|
||||
// 到期时间
|
||||
@Column("expire_time")
|
||||
private LocalDateTime expireTime;
|
||||
|
||||
// 来源订单ID
|
||||
@Column("source_order_id")
|
||||
private Long sourceOrderId;
|
||||
|
||||
// 购买时间
|
||||
@Column("purchase_time")
|
||||
private LocalDateTime purchaseTime;
|
||||
|
||||
// 乐观锁版本号
|
||||
@Column("version")
|
||||
private Integer version;
|
||||
|
||||
// 卡片组成(JSON格式,用于组合卡)
|
||||
@Column("card_composition")
|
||||
private String cardComposition;
|
||||
|
||||
// 联表查询的会员卡信息(非持久化字段,不映射到数据库表列)
|
||||
@Transient
|
||||
private String memberCardName;
|
||||
|
||||
@Transient
|
||||
private String memberCardType;
|
||||
|
||||
@Transient
|
||||
private Double memberCardPrice;
|
||||
|
||||
@Transient
|
||||
private Integer memberCardValidityDays;
|
||||
|
||||
@Transient
|
||||
private Integer memberCardTotalTimes;
|
||||
|
||||
}
|
||||
|
||||
+12
-23
@@ -3,75 +3,64 @@ package cn.novalon.gym.manage.member.entity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
/**
|
||||
* 会员卡交易流水实体 - 对应 member_card_transactions 表
|
||||
*
|
||||
* @author 付嘉
|
||||
* @date 2026-05-27
|
||||
*/
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Table("member_card_transactions")
|
||||
public class MemberCardTransaction extends BaseEntity {
|
||||
public class MemberCardTransaction {
|
||||
|
||||
@Id
|
||||
@Column("id")
|
||||
private Long id;
|
||||
|
||||
@Column("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
// 交易ID
|
||||
@Column("member_card_record_id")
|
||||
private Long memberCardRecordId;
|
||||
|
||||
// 会员ID
|
||||
@Column("member_id")
|
||||
private Long memberId;
|
||||
|
||||
// 会员卡ID
|
||||
@Column("member_card_id")
|
||||
private Long memberCardId;
|
||||
|
||||
// 操作类型:PURCHASE-购买, DEDUCT-扣次/扣费, RENEW-续费, REFUND-退款, EXPIRE-过期
|
||||
@Column("operation_type")
|
||||
private String operationType;
|
||||
|
||||
// 变动次数
|
||||
@Column("change_amount")
|
||||
private Integer changeAmount;
|
||||
|
||||
// 变动金额
|
||||
@Column("change_balance")
|
||||
private Double changeBalance;
|
||||
|
||||
// 变动后剩余次数
|
||||
@Column("after_remaining_count")
|
||||
private Integer afterRemainingCount;
|
||||
|
||||
// 变动后剩余金额
|
||||
@Column("after_remaining_balance")
|
||||
private Double afterRemainingBalance;
|
||||
|
||||
// 关联业务类型:GROUP_CLASS-团课, PT_CLASS-私教, CHECK_IN-签到
|
||||
@Column("related_biz_type")
|
||||
private String relatedBizType;
|
||||
|
||||
// 来源订单ID
|
||||
@Column("source_order_id")
|
||||
private Long sourceOrderId;
|
||||
|
||||
// 备注
|
||||
@Column("remark")
|
||||
private String remark;
|
||||
|
||||
// 是否已归档
|
||||
@Column("is_archived")
|
||||
private Boolean isArchived;
|
||||
|
||||
// 归档时间
|
||||
@Column("archived_at")
|
||||
private java.time.LocalDateTime archivedAt;
|
||||
private LocalDateTime archivedAt;
|
||||
|
||||
}
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package cn.novalon.gym.manage.member.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 储值卡实体类
|
||||
* 每个会员只有一张储值卡,存储余额信息和支付密码
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Table("member_stored_card")
|
||||
public class MemberStoredCard extends BaseEntity {
|
||||
|
||||
@Column("member_id")
|
||||
private Long memberId;
|
||||
|
||||
@Column("balance")
|
||||
private BigDecimal balance;
|
||||
|
||||
@Column("total_recharge")
|
||||
private BigDecimal totalRecharge;
|
||||
|
||||
@Column("total_consume")
|
||||
private BigDecimal totalConsume;
|
||||
|
||||
@Column("pay_password")
|
||||
private String payPassword;
|
||||
|
||||
@Column("is_pay_password_set")
|
||||
private Boolean isPayPasswordSet;
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package cn.novalon.gym.manage.member.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 储值卡充值记录实体类
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Table("member_stored_card_recharge")
|
||||
public class MemberStoredCardRecharge extends BaseEntity {
|
||||
|
||||
@Column("member_id")
|
||||
private Long memberId;
|
||||
|
||||
@Column("order_no")
|
||||
private String orderNo;
|
||||
|
||||
@Column("recharge_amount")
|
||||
private BigDecimal rechargeAmount;
|
||||
|
||||
@Column("bonus_amount")
|
||||
private BigDecimal bonusAmount;
|
||||
|
||||
@Column("total_amount")
|
||||
private BigDecimal totalAmount;
|
||||
|
||||
@Column("pay_amount")
|
||||
private BigDecimal payAmount;
|
||||
|
||||
@Column("status")
|
||||
private String status;
|
||||
|
||||
@Column("pay_time")
|
||||
private LocalDateTime payTime;
|
||||
}
|
||||
+24
-10
@@ -31,7 +31,7 @@ public class MemberCardHandler {
|
||||
|
||||
@Operation(summary = "根据ID查询会员卡类型", description = "查询指定ID的会员卡类型详情")
|
||||
public Mono<ServerResponse> getMemberCardById(ServerRequest request) {
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
Long id = Long.valueOf(request.pathVariable("memberCardId"));
|
||||
return memberCardService.findByMemberCardIdAndDeletedAtIsNull(id)
|
||||
.flatMap(card -> ServerResponse.ok().bodyValue(card))
|
||||
.switchIfEmpty(ServerResponse.notFound().build());
|
||||
@@ -63,13 +63,24 @@ public class MemberCardHandler {
|
||||
@Operation(summary = "更新会员卡类型", description = "更新会员卡类型信息")
|
||||
public Mono<ServerResponse> updateMemberCard(ServerRequest request) {
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
return request.bodyToMono(MemberCard.class)
|
||||
.flatMap(card -> {
|
||||
card.setMemberCardId(id);
|
||||
return memberCardService.save(card);
|
||||
return memberCardService.findByMemberCardIdAndDeletedAtIsNull(id)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡不存在: " + id)))
|
||||
.zipWith(request.bodyToMono(MemberCard.class))
|
||||
.flatMap(tuple -> {
|
||||
MemberCard existing = tuple.getT1();
|
||||
MemberCard body = tuple.getT2();
|
||||
// 仅更新非 null 字段,避免 null 覆盖已有值
|
||||
if (body.getMemberCardName() != null) existing.setMemberCardName(body.getMemberCardName());
|
||||
if (body.getMemberCardType() != null) existing.setMemberCardType(body.getMemberCardType());
|
||||
if (body.getMemberCardPrice() != null) existing.setMemberCardPrice(body.getMemberCardPrice());
|
||||
if (body.getMemberCardValidityDays() != null) existing.setMemberCardValidityDays(body.getMemberCardValidityDays());
|
||||
if (body.getMemberCardTotalTimes() != null) existing.setMemberCardTotalTimes(body.getMemberCardTotalTimes());
|
||||
if (body.getMemberCardAmount() != null) existing.setMemberCardAmount(body.getMemberCardAmount());
|
||||
if (body.getMemberCardStatus() != null) existing.setMemberCardStatus(body.getMemberCardStatus());
|
||||
if (body.getExtraConfig() != null) existing.setExtraConfig(body.getExtraConfig());
|
||||
return memberCardService.save(existing);
|
||||
})
|
||||
.flatMap(updated -> ServerResponse.ok().bodyValue(updated))
|
||||
.switchIfEmpty(ServerResponse.notFound().build());
|
||||
.flatMap(updated -> ServerResponse.ok().bodyValue(updated));
|
||||
}
|
||||
|
||||
@Operation(summary = "删除会员卡类型", description = "逻辑删除会员卡类型")
|
||||
@@ -97,29 +108,32 @@ public class MemberCardHandler {
|
||||
@Operation(summary = "续费会员卡", description = "为已有会员卡续费")
|
||||
public Mono<ServerResponse> renewCard(ServerRequest request) {
|
||||
Long recordId = Long.valueOf(request.queryParam("recordId").orElseThrow());
|
||||
Long memberId = Long.valueOf(request.queryParam("memberId").orElseThrow());
|
||||
Integer addTimes = request.queryParam("addTimes").map(Integer::valueOf).orElse(null);
|
||||
Double addAmount = request.queryParam("addAmount").map(Double::valueOf).orElse(null);
|
||||
Integer addDays = request.queryParam("addDays").map(Integer::valueOf).orElse(null);
|
||||
Long sourceOrderId = request.queryParam("sourceOrderId").map(Long::valueOf).orElse(null);
|
||||
|
||||
return memberCardService.renewCard(recordId, addTimes, addAmount, addDays, sourceOrderId)
|
||||
return memberCardService.renewCard(recordId, memberId, addTimes, addAmount, addDays, sourceOrderId)
|
||||
.flatMap(record -> ServerResponse.ok().bodyValue(record));
|
||||
}
|
||||
|
||||
@Operation(summary = "使用会员卡", description = "扣减会员卡次数或余额")
|
||||
public Mono<ServerResponse> useCard(ServerRequest request) {
|
||||
Long recordId = Long.valueOf(request.queryParam("recordId").orElseThrow());
|
||||
Long memberId = Long.valueOf(request.queryParam("memberId").orElseThrow());
|
||||
Integer deductTimes = request.queryParam("deductTimes").map(Integer::valueOf).orElse(null);
|
||||
Double deductAmount = request.queryParam("deductAmount").map(Double::valueOf).orElse(null);
|
||||
|
||||
return memberCardService.useCard(recordId, deductTimes, deductAmount)
|
||||
return memberCardService.useCard(recordId, memberId, deductTimes, deductAmount)
|
||||
.flatMap(record -> ServerResponse.ok().bodyValue(record));
|
||||
}
|
||||
|
||||
@Operation(summary = "退款会员卡", description = "申请会员卡退款")
|
||||
public Mono<ServerResponse> refundCard(ServerRequest request) {
|
||||
Long recordId = Long.valueOf(request.queryParam("recordId").orElseThrow());
|
||||
return memberCardService.refundCard(recordId)
|
||||
Long memberId = Long.valueOf(request.queryParam("memberId").orElseThrow());
|
||||
return memberCardService.refundCard(recordId, memberId)
|
||||
.then(ServerResponse.noContent().build());
|
||||
}
|
||||
|
||||
|
||||
+36
-6
@@ -3,6 +3,7 @@ package cn.novalon.gym.manage.member.handler;
|
||||
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
|
||||
import cn.novalon.gym.manage.member.service.IMemberCardRecordService;
|
||||
import cn.novalon.gym.manage.member.service.IMemberCardService;
|
||||
import cn.novalon.gym.manage.sys.util.AuthUtil;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.Data;
|
||||
@@ -23,19 +24,22 @@ public class MemberCardRecordHandler {
|
||||
|
||||
private final IMemberCardService memberCardService;
|
||||
private final IMemberCardRecordService memberCardRecordService;
|
||||
private final AuthUtil authUtil;
|
||||
|
||||
public MemberCardRecordHandler(IMemberCardService memberCardService,
|
||||
IMemberCardRecordService memberCardRecordService) {
|
||||
IMemberCardRecordService memberCardRecordService,
|
||||
AuthUtil authUtil) {
|
||||
this.memberCardService = memberCardService;
|
||||
this.memberCardRecordService = memberCardRecordService;
|
||||
this.authUtil = authUtil;
|
||||
}
|
||||
|
||||
@Operation(summary = "购买会员卡", description = "支持时长卡、次卡、储值卡,自动设置到期提醒")
|
||||
public Mono<ServerResponse> purchaseCard(ServerRequest request) {
|
||||
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
return request.bodyToMono(PurchaseRequest.class)
|
||||
.flatMap(body -> memberCardService.purchaseCard(
|
||||
body.getMemberId(),
|
||||
memberId,
|
||||
body.getMemberCardId(),
|
||||
body.getSourceOrderId()))
|
||||
.flatMap(record -> ServerResponse.ok().bodyValue(record))
|
||||
@@ -45,8 +49,10 @@ public class MemberCardRecordHandler {
|
||||
@Operation(summary = "续费会员卡", description = "累加剩余次数/余额,顺延到期日期,权益立即生效")
|
||||
public Mono<ServerResponse> renewCard(ServerRequest request) {
|
||||
Long recordId = Long.parseLong(request.pathVariable("recordId"));
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
return request.bodyToMono(RenewRequest.class)
|
||||
.flatMap(body -> memberCardService.renewCard(recordId,
|
||||
memberId,
|
||||
body.getAddTimes(),
|
||||
body.getAddAmount(),
|
||||
body.getAddDays(),
|
||||
@@ -58,8 +64,10 @@ public class MemberCardRecordHandler {
|
||||
@Operation(summary = "使用会员卡", description = "预约团课或私教成功后扣减次数或余额")
|
||||
public Mono<ServerResponse> useCard(ServerRequest request) {
|
||||
Long recordId = Long.parseLong(request.pathVariable("recordId"));
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
return request.bodyToMono(UseCardRequest.class)
|
||||
.flatMap(body -> memberCardService.useCard(recordId,
|
||||
memberId,
|
||||
body.getDeductTimes(),
|
||||
body.getDeductAmount()))
|
||||
.flatMap(record -> ServerResponse.ok().bodyValue(record))
|
||||
@@ -69,7 +77,8 @@ public class MemberCardRecordHandler {
|
||||
@Operation(summary = "退款会员卡", description = "使用Saga模式执行退款流程,保证事务一致性")
|
||||
public Mono<ServerResponse> refundCard(ServerRequest request) {
|
||||
Long recordId = Long.parseLong(request.pathVariable("recordId"));
|
||||
return memberCardService.refundCard(recordId)
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
return memberCardService.refundCard(recordId, memberId)
|
||||
.then(ServerResponse.ok().bodyValue("退款成功"))
|
||||
.onErrorResume(e -> ServerResponse.badRequest().bodyValue("退款失败: " + e.getMessage()));
|
||||
}
|
||||
@@ -84,12 +93,34 @@ public class MemberCardRecordHandler {
|
||||
|
||||
@Operation(summary = "会员我的卡包", description = "查询当前会员的所有有效卡")
|
||||
public Mono<ServerResponse> getMyCards(ServerRequest request) {
|
||||
Long memberId = Long.parseLong(request.pathVariable("memberId"));
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
return ServerResponse.ok().body(
|
||||
memberCardRecordService.findActiveCardsByMemberId(memberId),
|
||||
MemberCardRecord.class);
|
||||
}
|
||||
|
||||
@Operation(summary = "会员卡包(带筛选)", description = "查询会员的所有会员卡,支持状态筛选:all-全部, active-有效, expired-已失效")
|
||||
public Mono<ServerResponse> getMyCardsWithStatus(ServerRequest request) {
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
String status = request.queryParam("status").orElse("all");
|
||||
|
||||
if (!status.equals("all") && !status.equals("active") && !status.equals("expired")) {
|
||||
return ServerResponse.badRequest().bodyValue("无效的status参数,允许值:all, active, expired");
|
||||
}
|
||||
|
||||
return memberCardRecordService.findCardsByMemberIdWithStatus(memberId, status)
|
||||
.collectList()
|
||||
.flatMap(cards -> ServerResponse.ok().bodyValue(cards));
|
||||
}
|
||||
|
||||
@Operation(summary = "获取会员主要有效卡", description = "查询当前会员的主要有效会员卡(只返回一条),优先临期卡,其次有效卡")
|
||||
public Mono<ServerResponse> getPrimaryCard(ServerRequest request) {
|
||||
Long memberId = authUtil.getMemberIdOrThrow(request);
|
||||
return memberCardRecordService.findPrimaryActiveCardByMemberId(memberId)
|
||||
.flatMap(card -> ServerResponse.ok().bodyValue(card))
|
||||
.switchIfEmpty(ServerResponse.noContent().build());
|
||||
}
|
||||
|
||||
@Operation(summary = "处理过期会员卡", description = "定时任务调用,扫描并更新过期卡状态")
|
||||
public Mono<ServerResponse> processExpiredCards(ServerRequest request) {
|
||||
return memberCardService.processExpiredCards()
|
||||
@@ -98,7 +129,6 @@ public class MemberCardRecordHandler {
|
||||
|
||||
@Data
|
||||
public static class PurchaseRequest {
|
||||
private Long memberId;
|
||||
private Long memberCardId;
|
||||
private Long sourceOrderId;
|
||||
}
|
||||
|
||||
+11
@@ -109,6 +109,17 @@ public class MemberCardTransactionHandler {
|
||||
MemberCardTransaction.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按会员卡记录ID查询流水
|
||||
*/
|
||||
@Operation(summary = "按会员卡记录ID查询流水", description = "查看某张会员持卡记录的所有流水")
|
||||
public Mono<ServerResponse> getTransactionsByRecordId(ServerRequest request) {
|
||||
Long recordId = Long.parseLong(request.pathVariable("recordId"));
|
||||
return ServerResponse.ok()
|
||||
.body(memberCardTransactionService.findByRecordId(recordId),
|
||||
MemberCardTransaction.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计某卡种的总扣次数
|
||||
*/
|
||||
|
||||
+11
-9
@@ -155,8 +155,8 @@ public class MemberHandler {
|
||||
String decryptedPhone = AesUtil.decrypt(detail.getPhone());
|
||||
detail.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
|
||||
} catch (Exception e) {
|
||||
log.error("手机号解密失败, memberId: {}", detail.getId(), e);
|
||||
detail.setPhone(null);
|
||||
log.warn("手机号解密失败(可能为明文存储), memberId: {}", detail.getId());
|
||||
detail.setPhone(WechatPhoneUtil.maskPhone(detail.getPhone()));
|
||||
}
|
||||
}
|
||||
return ServerResponse.ok()
|
||||
@@ -204,8 +204,8 @@ public class MemberHandler {
|
||||
String decryptedPhone = AesUtil.decrypt(member.getPhone());
|
||||
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
|
||||
} catch (Exception e) {
|
||||
log.error("手机号解密失败, memberId: {}", member.getId(), e);
|
||||
member.setPhone(null);
|
||||
log.warn("手机号解密失败(可能为明文存储), memberId: {}", member.getId());
|
||||
member.setPhone(WechatPhoneUtil.maskPhone(member.getPhone()));
|
||||
}
|
||||
}
|
||||
return member;
|
||||
@@ -222,11 +222,13 @@ public class MemberHandler {
|
||||
|
||||
int pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1);
|
||||
int pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10);
|
||||
String sortField = request.queryParam("sortField").orElse(null);
|
||||
String sortOrder = request.queryParam("sortOrder").orElse(null);
|
||||
|
||||
log.info("前台查看会员列表, adminId: {}, pageNum: {}, pageSize: {}", adminId, pageNum, pageSize);
|
||||
// TODO: 补充签到记录
|
||||
log.info("前台查看会员列表, adminId: {}, pageNum: {}, pageSize: {}, sortField: {}, sortOrder: {}",
|
||||
adminId, pageNum, pageSize, sortField, sortOrder);
|
||||
|
||||
return memberService.findAll(pageNum, pageSize)
|
||||
return memberService.findAll(pageNum, pageSize, sortField, sortOrder)
|
||||
.map(member -> {
|
||||
// 解密手机号
|
||||
if (member.getPhone() != null && !member.getPhone().isEmpty()) {
|
||||
@@ -234,8 +236,8 @@ public class MemberHandler {
|
||||
String decryptedPhone = AesUtil.decrypt(member.getPhone());
|
||||
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
|
||||
} catch (Exception e) {
|
||||
log.error("手机号解密失败, memberId: {}", member.getId(), e);
|
||||
member.setPhone(null);
|
||||
log.warn("手机号解密失败(可能为明文存储), memberId: {}", member.getId());
|
||||
member.setPhone(WechatPhoneUtil.maskPhone(member.getPhone()));
|
||||
}
|
||||
}
|
||||
return member;
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
package cn.novalon.gym.manage.member.handler;
|
||||
|
||||
import cn.novalon.gym.manage.member.service.IMemberStoredCardService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 储值卡 Handler
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class MemberStoredCardHandler {
|
||||
|
||||
private final IMemberStoredCardService memberStoredCardService;
|
||||
|
||||
public MemberStoredCardHandler(IMemberStoredCardService memberStoredCardService) {
|
||||
this.memberStoredCardService = memberStoredCardService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会员储值卡信息
|
||||
*/
|
||||
public Mono<ServerResponse> getStoredCardInfo(ServerRequest request) {
|
||||
Long memberId = Long.valueOf(request.pathVariable("memberId"));
|
||||
return memberStoredCardService.getOrCreateStoredCard(memberId)
|
||||
.flatMap(card -> {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("memberId", card.getMemberId());
|
||||
result.put("balance", card.getBalance() != null ? card.getBalance() : BigDecimal.ZERO);
|
||||
result.put("totalRecharge", card.getTotalRecharge() != null ? card.getTotalRecharge() : BigDecimal.ZERO);
|
||||
result.put("totalConsume", card.getTotalConsume() != null ? card.getTotalConsume() : BigDecimal.ZERO);
|
||||
result.put("isPayPasswordSet", card.getIsPayPasswordSet() != null && card.getIsPayPasswordSet());
|
||||
return ServerResponse.ok().bodyValue(result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取储值卡余额
|
||||
*/
|
||||
public Mono<ServerResponse> getBalance(ServerRequest request) {
|
||||
Long memberId = Long.valueOf(request.pathVariable("memberId"));
|
||||
return memberStoredCardService.getStoredCard(memberId)
|
||||
.flatMap(card -> {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("balance", card.getBalance() != null ? card.getBalance() : BigDecimal.ZERO);
|
||||
result.put("hasCard", true);
|
||||
return ServerResponse.ok().bodyValue(result);
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("balance", BigDecimal.ZERO);
|
||||
result.put("hasCard", false);
|
||||
return ServerResponse.ok().bodyValue(result);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查支付密码是否已设置
|
||||
*/
|
||||
public Mono<ServerResponse> checkPasswordSet(ServerRequest request) {
|
||||
Long memberId = Long.valueOf(request.pathVariable("memberId"));
|
||||
return memberStoredCardService.isPayPasswordSet(memberId)
|
||||
.flatMap(isSet -> {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("isSet", isSet);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("code", 200);
|
||||
result.put("message", "success");
|
||||
result.put("data", data);
|
||||
return ServerResponse.ok().bodyValue(result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置支付密码
|
||||
*/
|
||||
public Mono<ServerResponse> setPassword(ServerRequest request) {
|
||||
Long memberId = Long.valueOf(request.pathVariable("memberId"));
|
||||
return request.bodyToMono(Map.class)
|
||||
.flatMap(body -> {
|
||||
String password = (String) body.get("password");
|
||||
if (password == null || password.isEmpty()) {
|
||||
return ServerResponse.badRequest().bodyValue(Map.of("code", 400, "message", "密码不能为空"));
|
||||
}
|
||||
return memberStoredCardService.setPayPassword(memberId, password)
|
||||
.flatMap(rows -> {
|
||||
if (rows > 0) {
|
||||
return ServerResponse.ok().bodyValue(Map.of("code", 200, "message", "设置成功"));
|
||||
}
|
||||
return ServerResponse.ok().bodyValue(Map.of("code", 400, "message", "设置失败"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证支付密码
|
||||
*/
|
||||
public Mono<ServerResponse> verifyPassword(ServerRequest request) {
|
||||
Long memberId = Long.valueOf(request.pathVariable("memberId"));
|
||||
return request.bodyToMono(Map.class)
|
||||
.flatMap(body -> {
|
||||
String password = (String) body.get("password");
|
||||
if (password == null || password.isEmpty()) {
|
||||
return ServerResponse.badRequest().bodyValue(Map.of("code", 400, "message", "密码不能为空"));
|
||||
}
|
||||
return memberStoredCardService.verifyPayPassword(memberId, password)
|
||||
.flatMap(isValid -> {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("valid", isValid);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("code", 200);
|
||||
result.put("message", isValid ? "验证成功" : "密码错误");
|
||||
result.put("data", data);
|
||||
return ServerResponse.ok().bodyValue(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 储值卡支付(验证密码并扣减余额)
|
||||
*/
|
||||
public Mono<ServerResponse> payWithStoredCard(ServerRequest request) {
|
||||
Long memberId = Long.valueOf(request.pathVariable("memberId"));
|
||||
return request.bodyToMono(Map.class)
|
||||
.flatMap(body -> {
|
||||
String password = (String) body.get("password");
|
||||
BigDecimal amount = body.get("amount") != null
|
||||
? new BigDecimal(body.get("amount").toString())
|
||||
: null;
|
||||
String orderType = (String) body.get("orderType");
|
||||
String goodsDesc = (String) body.get("goodsDesc");
|
||||
|
||||
if (password == null || password.isEmpty()) {
|
||||
return ServerResponse.badRequest().bodyValue(Map.of("code", 400, "message", "支付密码不能为空"));
|
||||
}
|
||||
if (amount == null) {
|
||||
return ServerResponse.badRequest().bodyValue(Map.of("code", 400, "message", "支付金额不能为空"));
|
||||
}
|
||||
|
||||
return memberStoredCardService.payWithStoredCard(memberId, password, amount)
|
||||
.flatMap(result -> {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
if (result.isSuccess()) {
|
||||
response.put("code", 200);
|
||||
response.put("message", "支付成功");
|
||||
response.put("balance", result.getBalance());
|
||||
log.info("[储值卡支付] 支付成功: memberId={}, amount={}, remainingBalance={}",
|
||||
memberId, amount, result.getBalance());
|
||||
} else {
|
||||
response.put("code", 400);
|
||||
response.put("message", result.getMessage());
|
||||
log.warn("[储值卡支付] 支付失败: memberId={}, amount={}, reason={}",
|
||||
memberId, amount, result.getMessage());
|
||||
}
|
||||
return ServerResponse.ok().bodyValue(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询会员的充值记录列表
|
||||
*/
|
||||
public Mono<ServerResponse> getRechargeRecords(ServerRequest request) {
|
||||
Long memberId = Long.valueOf(request.pathVariable("memberId"));
|
||||
int page = Integer.parseInt(request.queryParam("page").orElse("1"));
|
||||
int size = Integer.parseInt(request.queryParam("size").orElse("20"));
|
||||
|
||||
log.info("[储值卡] 查询充值记录: memberId={}, page={}, size={}", memberId, page, size);
|
||||
|
||||
return memberStoredCardService.getRechargeRecords(memberId, page, size)
|
||||
.collectList()
|
||||
.zipWith(memberStoredCardService.countRechargeRecords(memberId))
|
||||
.flatMap(tuple -> {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("list", tuple.getT1());
|
||||
result.put("total", tuple.getT2());
|
||||
result.put("page", page);
|
||||
result.put("size", size);
|
||||
return ServerResponse.ok().bodyValue(result);
|
||||
});
|
||||
}
|
||||
}
|
||||
+59
-3
@@ -23,7 +23,6 @@ public interface MemberCardRecordRepository extends R2dbcRepository<MemberCardRe
|
||||
/**
|
||||
* 插入新的激活卡记录
|
||||
*/
|
||||
@Modifying
|
||||
@Query("INSERT INTO member_card_record (member_id, member_card_id, status, expire_time, remaining_times, remaining_amount, source_order_id, purchase_time, created_at, updated_at) " +
|
||||
"VALUES (:memberId, :memberCardId, 'ACTIVE', :expireTime, :remainingTimes, :remainingAmount, :sourceOrderId, NOW(), NOW(), NOW()) " +
|
||||
"RETURNING *")
|
||||
@@ -63,9 +62,19 @@ public interface MemberCardRecordRepository extends R2dbcRepository<MemberCardRe
|
||||
Mono<Integer> updateStatus(Long recordId, String status);
|
||||
|
||||
/**
|
||||
* 查询会员的有效卡片
|
||||
* 查询会员的有效卡片(包含会员卡类型信息)
|
||||
*/
|
||||
@Query("SELECT * FROM member_card_record WHERE member_id = :memberId AND status = 'ACTIVE' AND deleted_at IS NULL ORDER BY expire_time ASC")
|
||||
@Query("SELECT r.member_card_record_id as id, r.created_at, r.updated_at, r.deleted_at, " +
|
||||
"r.member_card_record_id, r.member_id, r.member_card_id, r.status, " +
|
||||
"r.remaining_times, r.remaining_amount, r.expire_time, r.source_order_id, " +
|
||||
"r.purchase_time, r.version, r.card_composition, " +
|
||||
"mc.member_card_name as memberCardName, mc.member_card_type as memberCardType, " +
|
||||
"mc.member_card_price as memberCardPrice, mc.member_card_validity_days as memberCardValidityDays, " +
|
||||
"mc.member_card_total_times as memberCardTotalTimes " +
|
||||
"FROM member_card_record r " +
|
||||
"LEFT JOIN member_card mc ON r.member_card_id = mc.id AND mc.deleted_at IS NULL " +
|
||||
"WHERE r.member_id = :memberId AND r.status = 'ACTIVE' AND r.deleted_at IS NULL " +
|
||||
"ORDER BY r.expire_time ASC")
|
||||
Flux<MemberCardRecord> findActiveCardsByMemberId(Long memberId);
|
||||
|
||||
/**
|
||||
@@ -101,9 +110,56 @@ public interface MemberCardRecordRepository extends R2dbcRepository<MemberCardRe
|
||||
@Query("SELECT * FROM member_card_record WHERE status = 'ACTIVE' AND expire_time < NOW() AND deleted_at IS NULL LIMIT 500")
|
||||
Flux<MemberCardRecord> findExpiredCards();
|
||||
|
||||
/**
|
||||
* 根据支付订单ID查询会员卡记录(用于幂等性检查)
|
||||
*/
|
||||
@Query("SELECT * FROM member_card_record WHERE source_order_id = :sourceOrderId AND deleted_at IS NULL")
|
||||
Mono<MemberCardRecord> findBySourceOrderId(Long sourceOrderId);
|
||||
|
||||
/**
|
||||
* 查询会员在指定时间后购买的同类活跃卡(防前端重复调用)
|
||||
*/
|
||||
@Query("SELECT * FROM member_card_record WHERE member_id = :memberId AND member_card_id = :memberCardId AND status = 'ACTIVE' AND deleted_at IS NULL AND purchase_time > :since ORDER BY purchase_time DESC LIMIT 1")
|
||||
Mono<MemberCardRecord> findRecentActivePurchase(Long memberId, Long memberCardId, LocalDateTime since);
|
||||
|
||||
/**
|
||||
* 查询所有有效记录
|
||||
*/
|
||||
@Query("SELECT * FROM member_card_record WHERE status = 'ACTIVE' AND deleted_at IS NULL")
|
||||
Flux<MemberCardRecord> findActiveRecords();
|
||||
|
||||
/**
|
||||
* 储值卡充值 - 增加余额
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE member_card_record SET remaining_amount = remaining_amount + :addAmount, updated_at = NOW() " +
|
||||
"WHERE member_card_record_id = :recordId AND status = 'ACTIVE' AND deleted_at IS NULL")
|
||||
Mono<Integer> rechargeStoredCard(Long recordId, Double addAmount);
|
||||
|
||||
/**
|
||||
* 查询会员的所有卡片(按状态筛选)- 包含会员卡类型信息
|
||||
* status: all - 所有卡, active - 有效卡, expired - 已过期/失效卡
|
||||
* 排序规则:临期卡(30天内到期)排在最上面,然后是其他有效卡,最后是失效卡
|
||||
*/
|
||||
@Query("SELECT r.member_card_record_id as id, r.created_at, r.updated_at, r.deleted_at, " +
|
||||
"r.member_card_record_id, r.member_id, r.member_card_id, r.status, " +
|
||||
"r.remaining_times, r.remaining_amount, r.expire_time, r.source_order_id, " +
|
||||
"r.purchase_time, r.version, r.card_composition, " +
|
||||
"mc.member_card_name as memberCardName, mc.member_card_type as memberCardType, " +
|
||||
"mc.member_card_price as memberCardPrice, mc.member_card_validity_days as memberCardValidityDays, " +
|
||||
"mc.member_card_total_times as memberCardTotalTimes " +
|
||||
"FROM member_card_record r " +
|
||||
"LEFT JOIN member_card mc ON r.member_card_id = mc.id AND mc.deleted_at IS NULL " +
|
||||
"WHERE r.member_id = :memberId AND r.deleted_at IS NULL " +
|
||||
"AND (" +
|
||||
" :status = 'all' " +
|
||||
" OR (:status = 'active' AND r.status = 'ACTIVE' AND (r.expire_time IS NULL OR r.expire_time > NOW())) " +
|
||||
" OR (:status = 'expired' AND (r.status != 'ACTIVE' OR (r.expire_time IS NOT NULL AND r.expire_time <= NOW()))) " +
|
||||
") " +
|
||||
"ORDER BY " +
|
||||
"CASE WHEN r.status = 'ACTIVE' AND r.expire_time IS NOT NULL AND r.expire_time < NOW() + INTERVAL '30 days' AND r.expire_time > NOW() THEN 0 " +
|
||||
" WHEN r.status = 'ACTIVE' AND (r.expire_time IS NULL OR r.expire_time > NOW()) THEN 1 " +
|
||||
" ELSE 2 END, " +
|
||||
"r.expire_time ASC")
|
||||
Flux<MemberCardRecord> findCardsByMemberIdWithStatus(Long memberId, String status);
|
||||
}
|
||||
|
||||
+15
-1
@@ -9,6 +9,8 @@ import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 会员卡类型 Repository
|
||||
*
|
||||
@@ -18,11 +20,23 @@ import reactor.core.publisher.Mono;
|
||||
@Repository
|
||||
public interface MemberCardRepository extends R2dbcRepository<MemberCard, Long> {
|
||||
|
||||
/**
|
||||
* 根据主键ID查询(未删除的)- 用于前端传递的id
|
||||
*/
|
||||
@Query("SELECT * FROM member_card WHERE id = :id AND deleted_at IS NULL")
|
||||
Mono<MemberCard> findByIdAndDeletedAtIsNull(Long id);
|
||||
|
||||
/**
|
||||
* 根据会员卡ID查询(未删除的)
|
||||
*/
|
||||
Mono<MemberCard> findByMemberCardIdAndDeletedAtIsNull(Long memberCardId);
|
||||
|
||||
/**
|
||||
* 批量查询会员卡(根据 member_card_id 字段)
|
||||
*/
|
||||
@Query("SELECT * FROM member_card WHERE deleted_at IS NULL AND member_card_id IN (:memberCardIds)")
|
||||
Flux<MemberCard> findByMemberCardIdIn(List<Long> memberCardIds);
|
||||
|
||||
/**
|
||||
* 条件查询会员卡列表
|
||||
*/
|
||||
@@ -32,7 +46,7 @@ public interface MemberCardRepository extends R2dbcRepository<MemberCard, Long>
|
||||
"AND (:type IS NULL OR member_card_type = :type) " +
|
||||
"AND (:minPrice IS NULL OR member_card_price >= :minPrice) " +
|
||||
"AND (:maxPrice IS NULL OR member_card_price <= :maxPrice) " +
|
||||
"ORDER BY created_at DESC LIMIT :#{#pageable.pageSize} OFFSET :#{#pageable.offset}")
|
||||
"ORDER BY id ASC LIMIT :#{#pageable.pageSize} OFFSET :#{#pageable.offset}")
|
||||
Flux<MemberCard> findWithConditions(Integer status, String name, String type,
|
||||
Double minPrice, Double maxPrice, Pageable pageable);
|
||||
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package cn.novalon.gym.manage.member.repository;
|
||||
|
||||
import cn.novalon.gym.manage.member.entity.MemberStoredCardRecharge;
|
||||
import org.springframework.data.r2dbc.repository.Modifying;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 储值卡充值记录 Repository
|
||||
*/
|
||||
@Repository
|
||||
public interface MemberStoredCardRechargeRepository extends R2dbcRepository<MemberStoredCardRecharge, Long> {
|
||||
|
||||
/**
|
||||
* 根据订单号查询充值记录
|
||||
*/
|
||||
@Query("SELECT * FROM member_stored_card_recharge WHERE order_no = :orderNo AND deleted_at IS NULL")
|
||||
Mono<MemberStoredCardRecharge> findByOrderNo(String orderNo);
|
||||
|
||||
/**
|
||||
* 查询会员的充值记录列表
|
||||
*/
|
||||
@Query("SELECT * FROM member_stored_card_recharge WHERE member_id = :memberId AND deleted_at IS NULL ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
|
||||
Flux<MemberStoredCardRecharge> findByMemberId(Long memberId, int limit, int offset);
|
||||
|
||||
/**
|
||||
* 创建充值记录
|
||||
*/
|
||||
@Modifying
|
||||
@Query("INSERT INTO member_stored_card_recharge (member_id, order_no, recharge_amount, bonus_amount, total_amount, pay_amount, status, created_at, updated_at) " +
|
||||
"VALUES (:memberId, :orderNo, :rechargeAmount, :bonusAmount, :totalAmount, :payAmount, 'PENDING', NOW(), NOW())")
|
||||
Mono<Integer> createRechargeRecord(Long memberId, String orderNo, BigDecimal rechargeAmount, BigDecimal bonusAmount, BigDecimal totalAmount, BigDecimal payAmount);
|
||||
|
||||
/**
|
||||
* 更新充值记录为成功
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE member_stored_card_recharge SET status = 'SUCCESS', pay_time = :payTime, updated_at = NOW() " +
|
||||
"WHERE order_no = :orderNo AND deleted_at IS NULL")
|
||||
Mono<Integer> markSuccess(String orderNo, LocalDateTime payTime);
|
||||
|
||||
/**
|
||||
* 更新充值记录为失败
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE member_stored_card_recharge SET status = 'FAILED', updated_at = NOW() " +
|
||||
"WHERE order_no = :orderNo AND deleted_at IS NULL")
|
||||
Mono<Integer> markFailed(String orderNo);
|
||||
|
||||
/**
|
||||
* 查询会员充值记录总数
|
||||
*/
|
||||
@Query("SELECT COUNT(*) FROM member_stored_card_recharge WHERE member_id = :memberId AND deleted_at IS NULL")
|
||||
Mono<Long> countByMemberIdAndDeletedAtIsNull(Long memberId);
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package cn.novalon.gym.manage.member.repository;
|
||||
|
||||
import cn.novalon.gym.manage.member.entity.MemberStoredCard;
|
||||
import org.springframework.data.r2dbc.repository.Modifying;
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 储值卡 Repository
|
||||
*/
|
||||
@Repository
|
||||
public interface MemberStoredCardRepository extends R2dbcRepository<MemberStoredCard, Long> {
|
||||
|
||||
/**
|
||||
* 根据会员ID查询储值卡
|
||||
*/
|
||||
@Query("SELECT * FROM member_stored_card WHERE member_id = :memberId AND deleted_at IS NULL")
|
||||
Mono<MemberStoredCard> findByMemberId(Long memberId);
|
||||
|
||||
/**
|
||||
* 根据会员ID查询储值卡(可删除标记)
|
||||
*/
|
||||
Mono<MemberStoredCard> findByMemberIdAndDeletedAtIsNull(Long memberId);
|
||||
|
||||
/**
|
||||
* 储值卡充值 - 增加余额
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE member_stored_card SET balance = balance + :addAmount, total_recharge = total_recharge + :addAmount, updated_at = NOW() " +
|
||||
"WHERE member_id = :memberId AND deleted_at IS NULL")
|
||||
Mono<Integer> rechargeBalance(Long memberId, BigDecimal addAmount);
|
||||
|
||||
/**
|
||||
* 储值卡返还 - 增加余额(用于取消预约退款)
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE member_stored_card SET balance = balance + :addAmount, updated_at = NOW() " +
|
||||
"WHERE member_id = :memberId AND deleted_at IS NULL")
|
||||
Mono<Integer> refundBalance(Long memberId, BigDecimal addAmount);
|
||||
|
||||
/**
|
||||
* 储值卡消费 - 扣减余额
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE member_stored_card SET balance = balance - :deductAmount, total_consume = total_consume + :deductAmount, updated_at = NOW() " +
|
||||
"WHERE member_id = :memberId AND deleted_at IS NULL AND balance >= :deductAmount")
|
||||
Mono<Integer> deductBalance(Long memberId, BigDecimal deductAmount);
|
||||
|
||||
/**
|
||||
* 创建储值卡
|
||||
*/
|
||||
@Modifying
|
||||
@Query("INSERT INTO member_stored_card (member_id, balance, total_recharge, total_consume, is_pay_password_set, created_at, updated_at) " +
|
||||
"VALUES (:memberId, 0, 0, 0, FALSE, NOW(), NOW())")
|
||||
Mono<Integer> createStoredCard(Long memberId);
|
||||
|
||||
/**
|
||||
* 设置支付密码
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE member_stored_card SET pay_password = :password, is_pay_password_set = TRUE, updated_at = NOW() " +
|
||||
"WHERE member_id = :memberId AND deleted_at IS NULL")
|
||||
Mono<Integer> setPayPassword(Long memberId, String password);
|
||||
|
||||
/**
|
||||
* 检查支付密码是否已设置
|
||||
*/
|
||||
@Query("SELECT is_pay_password_set FROM member_stored_card WHERE member_id = :memberId AND deleted_at IS NULL")
|
||||
Mono<Boolean> isPayPasswordSet(Long memberId);
|
||||
|
||||
/**
|
||||
* 获取支付密码
|
||||
*/
|
||||
@Query("SELECT pay_password FROM member_stored_card WHERE member_id = :memberId AND deleted_at IS NULL")
|
||||
Mono<String> getPayPassword(Long memberId);
|
||||
}
|
||||
+16
@@ -32,4 +32,20 @@ public interface IMemberCardRecordService {
|
||||
Mono<MemberCardRecord> validateStoredCard(Long recordId, Double requiredAmount);
|
||||
|
||||
Flux<MemberCardRecord> findExpiredCards();
|
||||
|
||||
/**
|
||||
* 查询会员的所有卡片(按状态筛选)
|
||||
* @param memberId 会员ID
|
||||
* @param status 状态筛选:all - 所有, active - 有效, expired - 已失效
|
||||
* @return 会员卡记录列表
|
||||
*/
|
||||
Flux<MemberCardRecord> findCardsByMemberIdWithStatus(Long memberId, String status);
|
||||
|
||||
/**
|
||||
* 查询会员的主要有效会员卡(只返回一条)
|
||||
* 优先返回临期卡(剩余1-3天),其次返回普通有效卡
|
||||
* @param memberId 会员ID
|
||||
* @return 会员卡记录,如果没有则返回空
|
||||
*/
|
||||
Mono<MemberCardRecord> findPrimaryActiveCardByMemberId(Long memberId);
|
||||
}
|
||||
|
||||
+3
-3
@@ -34,11 +34,11 @@ public interface IMemberCardService {
|
||||
|
||||
Mono<MemberCardRecord> purchaseCard(Long memberId, Long memberCardId, Long sourceOrderId);
|
||||
|
||||
Mono<MemberCardRecord> renewCard(Long recordId, Integer addTimes, Double addAmount, Integer addDays, Long sourceOrderId);
|
||||
Mono<MemberCardRecord> renewCard(Long recordId, Long memberId, Integer addTimes, Double addAmount, Integer addDays, Long sourceOrderId);
|
||||
|
||||
Mono<MemberCardRecord> useCard(Long recordId, Integer deductTimes, Double deductAmount);
|
||||
Mono<MemberCardRecord> useCard(Long recordId, Long memberId, Integer deductTimes, Double deductAmount);
|
||||
|
||||
Mono<Void> refundCard(Long recordId);
|
||||
Mono<Void> refundCard(Long recordId, Long memberId);
|
||||
|
||||
Mono<Integer> processExpiredCards();
|
||||
}
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package cn.novalon.gym.manage.member.service;
|
||||
|
||||
import cn.novalon.gym.manage.member.entity.MemberStoredCard;
|
||||
import cn.novalon.gym.manage.member.entity.MemberStoredCardRecharge;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 储值卡 Service接口
|
||||
*/
|
||||
public interface IMemberStoredCardService {
|
||||
|
||||
/**
|
||||
* 获取会员的储值卡,如果没有则自动创建
|
||||
*/
|
||||
Mono<MemberStoredCard> getOrCreateStoredCard(Long memberId);
|
||||
|
||||
/**
|
||||
* 获取会员的储值卡
|
||||
*/
|
||||
Mono<MemberStoredCard> getStoredCard(Long memberId);
|
||||
|
||||
/**
|
||||
* 储值卡充值
|
||||
*/
|
||||
Mono<Integer> recharge(Long memberId, BigDecimal amount);
|
||||
|
||||
/**
|
||||
* 储值卡消费
|
||||
*/
|
||||
Mono<Integer> consume(Long memberId, BigDecimal amount);
|
||||
|
||||
/**
|
||||
* 检查支付密码是否已设置
|
||||
*/
|
||||
Mono<Boolean> isPayPasswordSet(Long memberId);
|
||||
|
||||
/**
|
||||
* 设置支付密码
|
||||
*/
|
||||
Mono<Integer> setPayPassword(Long memberId, String password);
|
||||
|
||||
/**
|
||||
* 验证支付密码
|
||||
*/
|
||||
Mono<Boolean> verifyPayPassword(Long memberId, String password);
|
||||
|
||||
/**
|
||||
* 储值卡支付(验证密码并扣减余额)
|
||||
*/
|
||||
Mono<PayResult> payWithStoredCard(Long memberId, String password, BigDecimal amount);
|
||||
|
||||
/**
|
||||
* 储值卡返还(取消预约退款,仅增加余额不修改充值总额)
|
||||
*/
|
||||
Mono<PayResult> refundToStoredCard(Long memberId, String password, BigDecimal amount, long cancelCount);
|
||||
|
||||
/**
|
||||
* 储值卡返还(内部调用,无需密码,含手续费逻辑)
|
||||
* @return 实际返还金额
|
||||
*/
|
||||
Mono<BigDecimal> refundBalanceWithFee(Long memberId, BigDecimal amount, long cancelCount);
|
||||
|
||||
/**
|
||||
* 查询会员的充值记录列表
|
||||
*/
|
||||
Flux<MemberStoredCardRecharge> getRechargeRecords(Long memberId, int page, int size);
|
||||
|
||||
/**
|
||||
* 查询会员充值记录总数
|
||||
*/
|
||||
Mono<Long> countRechargeRecords(Long memberId);
|
||||
|
||||
/**
|
||||
* 储值卡支付结果
|
||||
*/
|
||||
class PayResult {
|
||||
private boolean success;
|
||||
private String message;
|
||||
private BigDecimal balance;
|
||||
|
||||
public static PayResult success(BigDecimal balance) {
|
||||
PayResult result = new PayResult();
|
||||
result.success = true;
|
||||
result.message = "支付成功";
|
||||
result.balance = balance;
|
||||
return result;
|
||||
}
|
||||
|
||||
public static PayResult fail(String message) {
|
||||
PayResult result = new PayResult();
|
||||
result.success = false;
|
||||
result.message = message;
|
||||
return result;
|
||||
}
|
||||
|
||||
public boolean isSuccess() { return success; }
|
||||
public void setSuccess(boolean success) { this.success = success; }
|
||||
public String getMessage() { return message; }
|
||||
public void setMessage(String message) { this.message = message; }
|
||||
public BigDecimal getBalance() { return balance; }
|
||||
public void setBalance(BigDecimal balance) { this.balance = balance; }
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -58,7 +58,7 @@ public interface MemberService {
|
||||
* @param pageSize 页大小
|
||||
* @return 所有会员信息
|
||||
*/
|
||||
Flux<Member> findAll(Integer pageNum, Integer pageSize);
|
||||
Flux<Member> findAll(Integer pageNum, Integer pageSize, String sortField, String sortOrder);
|
||||
|
||||
/**
|
||||
* 前台管理端获取会员详情(含会员卡信息)
|
||||
|
||||
+139
-2
@@ -1,7 +1,9 @@
|
||||
package cn.novalon.gym.manage.member.service.impl;
|
||||
|
||||
import cn.novalon.gym.manage.member.entity.MemberCard;
|
||||
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
|
||||
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
|
||||
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
|
||||
import cn.novalon.gym.manage.member.service.IMemberCardRecordService;
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -11,6 +13,9 @@ import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 会员卡记录服务实现
|
||||
@@ -22,13 +27,17 @@ import java.time.LocalDateTime;
|
||||
@Service
|
||||
public class MemberCardRecordServiceImpl implements IMemberCardRecordService {
|
||||
private final MemberCardRecordRepository memberCardRecordRepository;
|
||||
private final MemberCardRepository memberCardRepository;
|
||||
private final RedisUtil redisUtil;
|
||||
|
||||
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) {
|
||||
public MemberCardRecordServiceImpl(MemberCardRecordRepository memberCardRecordRepository,
|
||||
MemberCardRepository memberCardRepository,
|
||||
RedisUtil redisUtil) {
|
||||
this.memberCardRecordRepository = memberCardRecordRepository;
|
||||
this.memberCardRepository = memberCardRepository;
|
||||
this.redisUtil = redisUtil;
|
||||
}
|
||||
|
||||
@@ -56,7 +65,44 @@ public class MemberCardRecordServiceImpl implements IMemberCardRecordService {
|
||||
|
||||
@Override
|
||||
public Flux<MemberCardRecord> findActiveCardsByMemberId(Long memberId) {
|
||||
return memberCardRecordRepository.findActiveCardsByMemberId(memberId);
|
||||
return memberCardRecordRepository.findActiveCardsByMemberId(memberId)
|
||||
// 收集所有记录,然后批量查询会员卡信息
|
||||
.collectList()
|
||||
.flatMapMany(records -> {
|
||||
if (records.isEmpty()) {
|
||||
return Flux.empty();
|
||||
}
|
||||
// 提取所有 memberCardId(关联的是 member_card.id 字段,即MemberCard的主键)
|
||||
List<Long> memberCardIds = records.stream()
|
||||
.map(MemberCardRecord::getMemberCardId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (memberCardIds.isEmpty()) {
|
||||
return Flux.fromIterable(records);
|
||||
}
|
||||
|
||||
// 批量查询会员卡信息(使用主键id查询,确保查询未删除的记录)
|
||||
return Flux.fromIterable(memberCardIds)
|
||||
.flatMap(id -> memberCardRepository.findByIdAndDeletedAtIsNull(id))
|
||||
.collectMap(MemberCard::getId, mc -> mc)
|
||||
.map(cardMap -> {
|
||||
// 填充会员卡信息到每条记录
|
||||
records.forEach(record -> {
|
||||
MemberCard memberCard = cardMap.get(record.getMemberCardId());
|
||||
if (memberCard != null) {
|
||||
record.setMemberCardName(memberCard.getMemberCardName());
|
||||
record.setMemberCardType(memberCard.getMemberCardType());
|
||||
record.setMemberCardPrice(memberCard.getMemberCardPrice());
|
||||
record.setMemberCardValidityDays(memberCard.getMemberCardValidityDays());
|
||||
record.setMemberCardTotalTimes(memberCard.getMemberCardTotalTimes());
|
||||
}
|
||||
});
|
||||
return records;
|
||||
})
|
||||
.flatMapMany(Flux::fromIterable);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -116,6 +162,97 @@ public class MemberCardRecordServiceImpl implements IMemberCardRecordService {
|
||||
return memberCardRecordRepository.findExpiredCards();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<MemberCardRecord> findCardsByMemberIdWithStatus(Long memberId, String status) {
|
||||
return memberCardRecordRepository.findCardsByMemberIdWithStatus(memberId, status)
|
||||
// 收集所有记录,然后批量查询会员卡信息
|
||||
.collectList()
|
||||
.flatMapMany(records -> {
|
||||
if (records.isEmpty()) {
|
||||
return Flux.empty();
|
||||
}
|
||||
// 提取所有 memberCardId(关联的是 member_card.id 字段,即MemberCard的主键)
|
||||
List<Long> memberCardIds = records.stream()
|
||||
.map(MemberCardRecord::getMemberCardId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (memberCardIds.isEmpty()) {
|
||||
return Flux.fromIterable(records);
|
||||
}
|
||||
|
||||
// 批量查询会员卡信息(使用主键id查询,确保查询未删除的记录)
|
||||
return Flux.fromIterable(memberCardIds)
|
||||
.flatMap(id -> memberCardRepository.findByIdAndDeletedAtIsNull(id))
|
||||
.collectMap(MemberCard::getId, mc -> mc)
|
||||
.map(cardMap -> {
|
||||
// 填充会员卡信息到每条记录
|
||||
records.forEach(record -> {
|
||||
MemberCard memberCard = cardMap.get(record.getMemberCardId());
|
||||
if (memberCard != null) {
|
||||
record.setMemberCardName(memberCard.getMemberCardName());
|
||||
record.setMemberCardType(memberCard.getMemberCardType());
|
||||
record.setMemberCardPrice(memberCard.getMemberCardPrice());
|
||||
record.setMemberCardValidityDays(memberCard.getMemberCardValidityDays());
|
||||
record.setMemberCardTotalTimes(memberCard.getMemberCardTotalTimes());
|
||||
}
|
||||
});
|
||||
return records;
|
||||
})
|
||||
.flatMapMany(Flux::fromIterable);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<MemberCardRecord> findPrimaryActiveCardByMemberId(Long memberId) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
return findCardsByMemberIdWithStatus(memberId, "active")
|
||||
// 过滤掉储值卡
|
||||
.filter(record -> record.getMemberCardType() == null ||
|
||||
!record.getMemberCardType().equals("STORED_VALUE_CARD"))
|
||||
.collectList()
|
||||
.flatMap(records -> {
|
||||
if (records.isEmpty()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
// 优先找临期卡(剩余1-3天)
|
||||
MemberCardRecord expiringCard = records.stream()
|
||||
.filter(record -> {
|
||||
if (record.getExpireTime() == null) return false;
|
||||
if (record.getStatus() != null && record.getStatus().equals("USED_UP")) return false;
|
||||
long days = java.time.Duration.between(now, record.getExpireTime()).toDays();
|
||||
return days >= 1 && days <= 3;
|
||||
})
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
if (expiringCard != null) {
|
||||
return Mono.just(expiringCard);
|
||||
}
|
||||
|
||||
// 再找普通有效卡(剩余>3天)
|
||||
MemberCardRecord activeCard = records.stream()
|
||||
.filter(record -> {
|
||||
if (record.getExpireTime() == null) return true;
|
||||
if (record.getStatus() != null && record.getStatus().equals("USED_UP")) return false;
|
||||
long days = java.time.Duration.between(now, record.getExpireTime()).toDays();
|
||||
return days > 3;
|
||||
})
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
if (activeCard != null) {
|
||||
return Mono.just(activeCard);
|
||||
}
|
||||
|
||||
// 最后返回第一张卡
|
||||
return Mono.just(records.get(0));
|
||||
});
|
||||
}
|
||||
|
||||
private void clearRecordCache(Long recordId) {
|
||||
String cacheKey = MEMBER_CARD_RECORD_CACHE_PREFIX + recordId;
|
||||
redisUtil.delete(cacheKey);
|
||||
|
||||
+112
-29
@@ -1,5 +1,6 @@
|
||||
package cn.novalon.gym.manage.member.service.impl;
|
||||
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import cn.novalon.gym.manage.member.entity.MemberCard;
|
||||
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
|
||||
import cn.novalon.gym.manage.member.entity.MemberCardTransaction;
|
||||
@@ -15,7 +16,6 @@ import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
|
||||
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
|
||||
import cn.novalon.gym.manage.member.service.IMemberCardService;
|
||||
import cn.novalon.gym.manage.member.service.IMemberCardTransactionService;
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -44,6 +44,7 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
||||
|
||||
private static final String MEMBER_CARD_CACHE_PREFIX = "member:card:";
|
||||
private static final long CACHE_EXPIRE_SECONDS = 300;
|
||||
private static final long DUPLICATE_WINDOW_SECONDS = 10;
|
||||
|
||||
public MemberCardServiceImpl(MemberCardRepository memberCardRepository,
|
||||
MemberCardRecordRepository recordRepository,
|
||||
@@ -124,11 +125,42 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
||||
|
||||
@Override
|
||||
public Mono<MemberCardRecord> purchaseCard(Long memberId, Long memberCardId, Long sourceOrderId) {
|
||||
return memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(memberCardId)
|
||||
if (memberId == null) {
|
||||
return Mono.error(new RuntimeException("会员ID不能为空"));
|
||||
}
|
||||
if (memberCardId == null) {
|
||||
return Mono.error(new RuntimeException("会员卡类型ID不能为空"));
|
||||
}
|
||||
|
||||
// 快速路径:幂等性检查,同一笔支付订单已创建过记录则直接返回
|
||||
if (sourceOrderId != null) {
|
||||
return recordRepository.findBySourceOrderId(sourceOrderId)
|
||||
.flatMap(existingRecord -> {
|
||||
log.info("支付订单已生成会员卡记录,幂等返回: sourceOrderId={}, memberCardRecordId={}",
|
||||
sourceOrderId, existingRecord.getMemberCardRecordId());
|
||||
return Mono.just(existingRecord);
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() -> doPurchaseCard(memberId, memberCardId, sourceOrderId)));
|
||||
}
|
||||
|
||||
// sourceOrderId为null(前端直接调用):检查10秒内是否已有同类卡,防前端重复调用
|
||||
LocalDateTime since = LocalDateTime.now().minusSeconds(DUPLICATE_WINDOW_SECONDS);
|
||||
return recordRepository.findRecentActivePurchase(memberId, memberCardId, since)
|
||||
.flatMap(existingRecord -> {
|
||||
log.info("前端重复购买检测命中: memberId={}, memberCardId={}, memberCardRecordId={}, purchaseTime={}",
|
||||
memberId, memberCardId, existingRecord.getMemberCardRecordId(), existingRecord.getPurchaseTime());
|
||||
return Mono.just(existingRecord);
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() -> doPurchaseCard(memberId, memberCardId, sourceOrderId)));
|
||||
}
|
||||
|
||||
private Mono<MemberCardRecord> doPurchaseCard(Long memberId, Long memberCardId, Long sourceOrderId) {
|
||||
// 前端传的是数据库主键id,不是业务字段member_card_id
|
||||
return memberCardRepository.findByIdAndDeletedAtIsNull(memberCardId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡类型不存在")))
|
||||
.flatMap(card -> {
|
||||
if (card.getMemberCardStatus() != null && card.getMemberCardStatus() == 1) {
|
||||
return Mono.error(new RuntimeException("该会员卡已禁用"));
|
||||
if (card.getMemberCardStatus() != null && card.getMemberCardStatus() == 0) {
|
||||
return Mono.error(new RuntimeException("该会员卡已下架"));
|
||||
}
|
||||
|
||||
MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType());
|
||||
@@ -136,13 +168,25 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
||||
return distributedLockService.executeWithLock(
|
||||
memberId.toString(),
|
||||
cardType.name(),
|
||||
Mono.defer(() -> createCardRecord(memberId, memberCardId, sourceOrderId, card))
|
||||
Mono.defer(() -> {
|
||||
// 持锁后再次检查幂等,防止并发请求同时通过外层检查
|
||||
if (sourceOrderId != null) {
|
||||
return recordRepository.findBySourceOrderId(sourceOrderId)
|
||||
.flatMap(existingRecord -> {
|
||||
log.info("持锁幂等检查命中: sourceOrderId={}, memberCardRecordId={}",
|
||||
sourceOrderId, existingRecord.getMemberCardRecordId());
|
||||
return Mono.just(existingRecord);
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() -> createCardRecord(memberId, memberCardId, sourceOrderId, card)));
|
||||
}
|
||||
return createCardRecord(memberId, memberCardId, sourceOrderId, card);
|
||||
})
|
||||
.flatMap(record -> createTransactionForPurchase(record, card, TransactionType.PURCHASE, "购买会员卡")
|
||||
.thenReturn(record))
|
||||
.flatMap(record -> expirationReminderService.scheduleExpirationReminder(record)
|
||||
.then(Mono.just(record)))
|
||||
);
|
||||
})
|
||||
.flatMap(record -> createTransaction(record, TransactionType.PURCHASE, "购买会员卡")
|
||||
.thenReturn(record))
|
||||
.flatMap(record -> expirationReminderService.scheduleExpirationReminder(record)
|
||||
.then(Mono.just(record)));
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<MemberCardRecord> createCardRecord(Long memberId, Long memberCardId,
|
||||
@@ -161,12 +205,16 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
||||
|
||||
switch (cardType) {
|
||||
case TIME_CARD:
|
||||
record.setExpireTime(now.plusDays(card.getMemberCardValidityDays()));
|
||||
if (card.getMemberCardValidityDays() != null) {
|
||||
record.setExpireTime(now.plusDays(card.getMemberCardValidityDays()));
|
||||
}
|
||||
record.setRemainingTimes(0);
|
||||
record.setRemainingAmount(0.0);
|
||||
break;
|
||||
case COUNT_CARD:
|
||||
record.setExpireTime(now.plusDays(card.getMemberCardValidityDays()));
|
||||
if (card.getMemberCardValidityDays() != null) {
|
||||
record.setExpireTime(now.plusDays(card.getMemberCardValidityDays()));
|
||||
}
|
||||
record.setRemainingTimes(card.getMemberCardTotalTimes());
|
||||
record.setRemainingAmount(0.0);
|
||||
break;
|
||||
@@ -191,12 +239,17 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<MemberCardRecord> renewCard(Long recordId, Integer addTimes, Double addAmount,
|
||||
public Mono<MemberCardRecord> renewCard(Long recordId, Long memberId, Integer addTimes, Double addAmount,
|
||||
Integer addDays, Long sourceOrderId) {
|
||||
return recordRepository.findById(recordId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
|
||||
.flatMap(originalRecord -> stateMachine.validateTransition(originalRecord, CardEvent.RENEW)
|
||||
.then(Mono.just(originalRecord)))
|
||||
.flatMap(originalRecord -> {
|
||||
if (!originalRecord.getMemberId().equals(memberId)) {
|
||||
return Mono.error(new RuntimeException("无权操作此会员卡"));
|
||||
}
|
||||
return stateMachine.validateTransition(originalRecord, CardEvent.RENEW)
|
||||
.then(Mono.just(originalRecord));
|
||||
})
|
||||
.flatMap(originalRecord -> memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(originalRecord.getMemberCardId())
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡类型不存在")))
|
||||
.flatMap(card -> {
|
||||
@@ -249,11 +302,16 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<MemberCardRecord> useCard(Long recordId, Integer deductTimes, Double deductAmount) {
|
||||
public Mono<MemberCardRecord> useCard(Long recordId, Long memberId, Integer deductTimes, Double deductAmount) {
|
||||
return recordRepository.findById(recordId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
|
||||
.flatMap(record -> stateMachine.validateTransition(record, CardEvent.USE)
|
||||
.then(Mono.just(record)))
|
||||
.flatMap(record -> {
|
||||
if (!record.getMemberId().equals(memberId)) {
|
||||
return Mono.error(new RuntimeException("无权操作此会员卡"));
|
||||
}
|
||||
return stateMachine.validateTransition(record, CardEvent.USE)
|
||||
.then(Mono.just(record));
|
||||
})
|
||||
.flatMap(record -> memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(record.getMemberCardId())
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡类型不存在")))
|
||||
.flatMap(card -> {
|
||||
@@ -314,19 +372,24 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> refundCard(Long recordId) {
|
||||
public Mono<Void> refundCard(Long recordId, Long memberId) {
|
||||
return recordRepository.findById(recordId)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡记录不存在")))
|
||||
.flatMap(record -> memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(record.getMemberCardId())
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡类型不存在")))
|
||||
.flatMap(card -> {
|
||||
MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType());
|
||||
return distributedLockService.executeWithLock(
|
||||
record.getMemberId().toString(),
|
||||
cardType.name(),
|
||||
refundSagaHandler.executeRefund(recordId)
|
||||
);
|
||||
}));
|
||||
.flatMap(record -> {
|
||||
if (!record.getMemberId().equals(memberId)) {
|
||||
return Mono.error(new RuntimeException("无权操作此会员卡"));
|
||||
}
|
||||
return memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(record.getMemberCardId())
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("会员卡类型不存在")))
|
||||
.flatMap(card -> {
|
||||
MemberCardType cardType = MemberCardType.valueOf(card.getMemberCardType());
|
||||
return distributedLockService.executeWithLock(
|
||||
record.getMemberId().toString(),
|
||||
cardType.name(),
|
||||
refundSagaHandler.executeRefund(recordId)
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -341,6 +404,7 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
||||
|
||||
private Mono<Void> createTransaction(MemberCardRecord record, TransactionType action, String remark) {
|
||||
MemberCardTransaction transaction = MemberCardTransaction.builder()
|
||||
.memberCardRecordId(record.getMemberCardRecordId())
|
||||
.memberId(record.getMemberId())
|
||||
.memberCardId(record.getMemberCardId())
|
||||
.operationType(action.name())
|
||||
@@ -354,6 +418,25 @@ public class MemberCardServiceImpl implements IMemberCardService {
|
||||
return transactionService.createTransaction(transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建购买会员卡的交易记录(记录购买金额为负数,表示支出)
|
||||
*/
|
||||
private Mono<Void> createTransactionForPurchase(MemberCardRecord record, MemberCard card, TransactionType action, String remark) {
|
||||
MemberCardTransaction transaction = MemberCardTransaction.builder()
|
||||
.memberCardRecordId(record.getMemberCardRecordId())
|
||||
.memberId(record.getMemberId())
|
||||
.memberCardId(record.getMemberCardId())
|
||||
.operationType(action.name())
|
||||
.changeAmount(card.getMemberCardTotalTimes() != null ? card.getMemberCardTotalTimes() : 0)
|
||||
.changeBalance(card.getMemberCardPrice() != null ? -card.getMemberCardPrice() : 0.0) // 负数表示支出
|
||||
.afterRemainingCount(record.getRemainingTimes())
|
||||
.afterRemainingBalance(record.getRemainingAmount())
|
||||
.remark(remark)
|
||||
.build();
|
||||
|
||||
return transactionService.createTransaction(transaction);
|
||||
}
|
||||
|
||||
private void clearCardCache(Long memberCardId) {
|
||||
String cacheKey = MEMBER_CARD_CACHE_PREFIX + memberCardId;
|
||||
redisUtil.delete(cacheKey);
|
||||
|
||||
+13
-2
@@ -91,8 +91,19 @@ public class MemberCardTransactionServiceImpl implements IMemberCardTransactionS
|
||||
|
||||
@Override
|
||||
public Mono<Void> createTransaction(MemberCardTransaction transaction) {
|
||||
return transactionRepository.save(transaction)
|
||||
.then()
|
||||
return transactionRepository.insertTransaction(
|
||||
transaction.getMemberCardRecordId(),
|
||||
transaction.getMemberCardId(),
|
||||
transaction.getMemberId(),
|
||||
transaction.getOperationType(),
|
||||
transaction.getChangeAmount(),
|
||||
transaction.getChangeBalance(),
|
||||
transaction.getAfterRemainingCount(),
|
||||
transaction.getAfterRemainingBalance(),
|
||||
transaction.getRelatedBizType(),
|
||||
transaction.getSourceOrderId(),
|
||||
transaction.getRemark()
|
||||
).then()
|
||||
.doOnSuccess(v -> log.info("创建会员卡交易记录: memberId={}, cardId={}, type={}",
|
||||
transaction.getMemberId(), transaction.getMemberCardId(), transaction.getOperationType()));
|
||||
}
|
||||
|
||||
+77
-43
@@ -17,6 +17,7 @@ import cn.novalon.gym.manage.member.service.MemberService;
|
||||
import cn.novalon.gym.manage.member.util.AesUtil;
|
||||
import cn.novalon.gym.manage.member.util.BeanConvertUtil;
|
||||
import cn.novalon.gym.manage.member.util.EsSyncUtils;
|
||||
import cn.novalon.gym.manage.member.util.WechatPhoneUtil;
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import cn.novalon.gym.manage.member.vo.MemberCardInfoVO;
|
||||
import cn.novalon.gym.manage.member.vo.MemberDetailVO;
|
||||
@@ -25,6 +26,7 @@ import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
@@ -139,12 +141,24 @@ public class MemberServiceImpl implements MemberService {
|
||||
|
||||
private MemberInfoVO buildMemberInfoResponse(Member member) {
|
||||
String phone = member.getPhone();
|
||||
String maskedPhone = phone != null ? phone.replace(phone.substring(3, 7), "****") : null;
|
||||
String maskedPhone = null;
|
||||
|
||||
// 先解密手机号,再进行脱敏处理
|
||||
if (phone != null && !phone.isEmpty()) {
|
||||
try {
|
||||
String decryptedPhone = AesUtil.decrypt(phone);
|
||||
maskedPhone = WechatPhoneUtil.maskPhone(decryptedPhone);
|
||||
} catch (Exception e) {
|
||||
log.error("手机号解密失败, memberId: {}", member.getId(), e);
|
||||
maskedPhone = null;
|
||||
}
|
||||
}
|
||||
|
||||
GenderEnum genderEnum = GenderEnum.fromCode(member.getGender());
|
||||
|
||||
return MemberInfoVO.builder()
|
||||
.id(member.getId())
|
||||
.memberNo(member.getMemberNo())
|
||||
.nickname(member.getNickname())
|
||||
.phone(maskedPhone)
|
||||
.gender(genderEnum)
|
||||
@@ -153,6 +167,7 @@ public class MemberServiceImpl implements MemberService {
|
||||
.avatar(member.getAvatar())
|
||||
.hasPhone(phone != null)
|
||||
.isSubscribed(member.getSubscribed() != null && member.getSubscribed())
|
||||
.lastLoginAt(member.getLastLoginAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -218,13 +233,31 @@ public class MemberServiceImpl implements MemberService {
|
||||
);
|
||||
}
|
||||
|
||||
/** 合法的排序字段白名单,key=前端传值, value=数据库字段名 */
|
||||
private static final java.util.Map<String, String> ALLOWED_SORT_FIELDS = java.util.Map.of(
|
||||
"createdAt", "created_at",
|
||||
"memberNo", "member_no",
|
||||
"gender", "gender",
|
||||
"id", "id"
|
||||
);
|
||||
|
||||
@Override
|
||||
public Flux<Member> findAll(Integer pageNum, Integer pageSize) {
|
||||
log.info("查询所有会员列表, pageNum: {}, pageSize: {}", pageNum, pageSize);
|
||||
public Flux<Member> findAll(Integer pageNum, Integer pageSize, String sortField, String sortOrder) {
|
||||
log.info("查询所有会员列表, pageNum: {}, pageSize: {}, sortField: {}, sortOrder: {}",
|
||||
pageNum, pageSize, sortField, sortOrder);
|
||||
|
||||
// 排序方向:desc(默认) / asc
|
||||
Sort.Direction direction = "asc".equalsIgnoreCase(sortOrder)
|
||||
? Sort.Direction.ASC : Sort.Direction.DESC;
|
||||
|
||||
// 排序字段白名单校验,不在白名单中则使用默认排序
|
||||
String dbField = (sortField != null && ALLOWED_SORT_FIELDS.containsKey(sortField))
|
||||
? ALLOWED_SORT_FIELDS.get(sortField) : "created_at";
|
||||
|
||||
Pageable pageable = PageRequest.of(
|
||||
pageNum - 1,
|
||||
pageSize
|
||||
pageSize,
|
||||
Sort.by(direction, dbField)
|
||||
);
|
||||
|
||||
return memberRepository.findAllBy(pageable);
|
||||
@@ -237,50 +270,51 @@ public class MemberServiceImpl implements MemberService {
|
||||
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);
|
||||
.filter(cached -> cached != null)
|
||||
.switchIfEmpty(Mono.defer(() ->
|
||||
memberRepository.findById(memberId)
|
||||
.switchIfEmpty(Mono.error(() -> {
|
||||
log.error("会员不存在: memberId={}", memberId);
|
||||
throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
|
||||
}))
|
||||
.zipWith(
|
||||
memberRepository.findCardRecordsWithCardInfoByMemberId(memberId)
|
||||
.collectList(),
|
||||
(baseInfo, cardList) -> {
|
||||
MemberDetailVO memberDetailVO = BeanConvertUtil.toBean(baseInfo, MemberDetailVO.class);
|
||||
|
||||
GenderEnum genderEnum = GenderEnum.fromCode(baseInfo.getGender());
|
||||
memberDetailVO.setGenderDesc(genderEnum.getDesc());
|
||||
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());
|
||||
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);
|
||||
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);
|
||||
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)));
|
||||
});
|
||||
return memberDetailVO;
|
||||
}
|
||||
)
|
||||
.flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS)
|
||||
.then(Mono.just(vo)))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
package cn.novalon.gym.manage.member.service.impl;
|
||||
|
||||
import cn.novalon.gym.manage.member.entity.MemberStoredCard;
|
||||
import cn.novalon.gym.manage.member.entity.MemberStoredCardRecharge;
|
||||
import cn.novalon.gym.manage.member.repository.MemberStoredCardRechargeRepository;
|
||||
import cn.novalon.gym.manage.member.repository.MemberStoredCardRepository;
|
||||
import cn.novalon.gym.manage.member.service.IMemberStoredCardService;
|
||||
import cn.novalon.gym.manage.sys.core.service.ISysConfigService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 储值卡 Service实现
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class MemberStoredCardServiceImpl implements IMemberStoredCardService {
|
||||
|
||||
private final MemberStoredCardRepository memberStoredCardRepository;
|
||||
private final MemberStoredCardRechargeRepository memberStoredCardRechargeRepository;
|
||||
private final ISysConfigService sysConfigService;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
|
||||
public MemberStoredCardServiceImpl(MemberStoredCardRepository memberStoredCardRepository,
|
||||
MemberStoredCardRechargeRepository memberStoredCardRechargeRepository,
|
||||
ISysConfigService sysConfigService) {
|
||||
this.memberStoredCardRepository = memberStoredCardRepository;
|
||||
this.memberStoredCardRechargeRepository = memberStoredCardRechargeRepository;
|
||||
this.sysConfigService = sysConfigService;
|
||||
this.passwordEncoder = new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<MemberStoredCard> getOrCreateStoredCard(Long memberId) {
|
||||
return memberStoredCardRepository.findByMemberId(memberId)
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
log.info("[储值卡] 为会员 {} 创建储值卡", memberId);
|
||||
return memberStoredCardRepository.createStoredCard(memberId)
|
||||
.then(memberStoredCardRepository.findByMemberId(memberId));
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<MemberStoredCard> getStoredCard(Long memberId) {
|
||||
return memberStoredCardRepository.findByMemberId(memberId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Integer> recharge(Long memberId, BigDecimal amount) {
|
||||
return memberStoredCardRepository.findByMemberId(memberId)
|
||||
.flatMap(card -> memberStoredCardRepository.rechargeBalance(memberId, amount))
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
log.info("[储值卡] 会员 {} 首次充值,创建储值卡并充值", memberId);
|
||||
return memberStoredCardRepository.createStoredCard(memberId)
|
||||
.then(memberStoredCardRepository.rechargeBalance(memberId, amount));
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Integer> consume(Long memberId, BigDecimal amount) {
|
||||
return memberStoredCardRepository.deductBalance(memberId, amount);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Boolean> isPayPasswordSet(Long memberId) {
|
||||
return memberStoredCardRepository.isPayPasswordSet(memberId)
|
||||
.defaultIfEmpty(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Integer> setPayPassword(Long memberId, String password) {
|
||||
// 确保储值卡存在
|
||||
return getOrCreateStoredCard(memberId)
|
||||
.flatMap(card -> {
|
||||
// BCrypt加密密码
|
||||
String encodedPassword = passwordEncoder.encode(password);
|
||||
return memberStoredCardRepository.setPayPassword(memberId, encodedPassword);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Boolean> verifyPayPassword(Long memberId, String password) {
|
||||
return memberStoredCardRepository.getPayPassword(memberId)
|
||||
.map(storedPassword -> {
|
||||
if (storedPassword == null || storedPassword.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return passwordEncoder.matches(password, storedPassword);
|
||||
})
|
||||
.defaultIfEmpty(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<PayResult> payWithStoredCard(Long memberId, String password, BigDecimal amount) {
|
||||
return memberStoredCardRepository.findByMemberId(memberId)
|
||||
.flatMap(card -> {
|
||||
// 检查余额是否充足
|
||||
BigDecimal currentBalance = card.getBalance();
|
||||
if (currentBalance == null) {
|
||||
currentBalance = BigDecimal.ZERO;
|
||||
}
|
||||
if (currentBalance.compareTo(amount) < 0) {
|
||||
log.warn("[储值卡支付] 余额不足: memberId={}, balance={}, needAmount={}",
|
||||
memberId, currentBalance, amount);
|
||||
return Mono.just(PayResult.fail("余额不足,当前余额:" + currentBalance + "元"));
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if (card.getPayPassword() == null || card.getPayPassword().isEmpty()) {
|
||||
log.warn("[储值卡支付] 未设置支付密码: memberId={}", memberId);
|
||||
return Mono.just(PayResult.fail("请先设置支付密码"));
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(password, card.getPayPassword())) {
|
||||
log.warn("[储值卡支付] 密码错误: memberId={}", memberId);
|
||||
return Mono.just(PayResult.fail("支付密码错误"));
|
||||
}
|
||||
|
||||
// 扣减余额
|
||||
return memberStoredCardRepository.deductBalance(memberId, amount)
|
||||
.flatMap(rows -> {
|
||||
if (rows > 0) {
|
||||
// 查询最新余额
|
||||
return memberStoredCardRepository.findByMemberId(memberId)
|
||||
.map(updatedCard -> PayResult.success(
|
||||
updatedCard.getBalance() != null ? updatedCard.getBalance() : BigDecimal.ZERO));
|
||||
} else {
|
||||
log.warn("[储值卡支付] 扣减余额失败: memberId={}, amount={}", memberId, amount);
|
||||
return Mono.just(PayResult.fail("支付失败,请稍后重试"));
|
||||
}
|
||||
});
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
log.warn("[储值卡支付] 储值卡不存在: memberId={}", memberId);
|
||||
return Mono.just(PayResult.fail("储值卡不存在"));
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<PayResult> refundToStoredCard(Long memberId, String password, BigDecimal amount, long cancelCount) {
|
||||
// 从系统配置读取免手续费次数阈值,默认3次
|
||||
Mono<Long> freeLimitMono = sysConfigService.getConfigValue("group_course.cancel_free_limit")
|
||||
.map(Long::parseLong)
|
||||
.defaultIfEmpty(3L);
|
||||
|
||||
return freeLimitMono.flatMap(freeLimit ->
|
||||
memberStoredCardRepository.findByMemberId(memberId)
|
||||
.flatMap(card -> {
|
||||
// 验证密码
|
||||
if (card.getPayPassword() == null || card.getPayPassword().isEmpty()) {
|
||||
log.warn("[储值卡退款] 未设置支付密码: memberId={}", memberId);
|
||||
return Mono.just(PayResult.fail("请先设置支付密码"));
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(password, card.getPayPassword())) {
|
||||
log.warn("[储值卡退款] 密码错误: memberId={}", memberId);
|
||||
return Mono.just(PayResult.fail("支付密码错误"));
|
||||
}
|
||||
|
||||
// 前N次取消不扣手续费,超出后扣除10%
|
||||
BigDecimal refundAmount = amount;
|
||||
BigDecimal fee = BigDecimal.ZERO;
|
||||
if (cancelCount >= freeLimit) {
|
||||
fee = amount.multiply(new BigDecimal("0.1")).setScale(2, java.math.RoundingMode.HALF_UP);
|
||||
refundAmount = amount.subtract(fee);
|
||||
log.info("[储值卡退款] 第{}次取消,已超出免手续费阈值({}次),扣除10%手续费: 原额={}, 手续费={}, 退款={}",
|
||||
cancelCount + 1, freeLimit, amount, fee, refundAmount);
|
||||
} else {
|
||||
log.info("[储值卡退款] 第{}次取消,在免手续费阈值({}次)内,全额退款: 原额={}",
|
||||
cancelCount + 1, freeLimit, amount);
|
||||
}
|
||||
|
||||
// 执行退款
|
||||
BigDecimal finalRefundAmount = refundAmount;
|
||||
return memberStoredCardRepository.refundBalance(memberId, refundAmount)
|
||||
.flatMap(rows -> {
|
||||
if (rows > 0) {
|
||||
return memberStoredCardRepository.findByMemberId(memberId)
|
||||
.map(updatedCard -> PayResult.success(
|
||||
updatedCard.getBalance() != null ? updatedCard.getBalance() : BigDecimal.ZERO));
|
||||
} else {
|
||||
log.warn("[储值卡退款] 退款失败: memberId={}, amount={}", memberId, finalRefundAmount);
|
||||
return Mono.just(PayResult.fail("退款失败,请稍后重试"));
|
||||
}
|
||||
});
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
log.warn("[储值卡退款] 储值卡不存在: memberId={}", memberId);
|
||||
return Mono.just(PayResult.fail("储值卡不存在"));
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<MemberStoredCardRecharge> getRechargeRecords(Long memberId, int page, int size) {
|
||||
int offset = (page - 1) * size;
|
||||
return memberStoredCardRechargeRepository.findByMemberId(memberId, size, offset);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<BigDecimal> refundBalanceWithFee(Long memberId, BigDecimal amount, long cancelCount) {
|
||||
return sysConfigService.getConfigValue("group_course.cancel_free_limit")
|
||||
.map(Long::parseLong)
|
||||
.defaultIfEmpty(3L)
|
||||
.flatMap(freeLimit -> {
|
||||
BigDecimal refundAmount;
|
||||
if (cancelCount >= freeLimit) {
|
||||
BigDecimal fee = amount.multiply(new BigDecimal("0.1")).setScale(2, java.math.RoundingMode.HALF_UP);
|
||||
refundAmount = amount.subtract(fee);
|
||||
log.info("[储值卡退款-内部] 第{}次取消,超出免手续费阈值({}次),扣除10%: 原额={}, 手续费={}, 退款={}",
|
||||
cancelCount + 1, freeLimit, amount, fee, refundAmount);
|
||||
} else {
|
||||
refundAmount = amount;
|
||||
log.info("[储值卡退款-内部] 第{}次取消,在免手续费阈值({}次)内,全额退款: {}",
|
||||
cancelCount + 1, freeLimit, amount);
|
||||
}
|
||||
return memberStoredCardRepository.refundBalance(memberId, refundAmount)
|
||||
.flatMap(rows -> {
|
||||
if (rows > 0) {
|
||||
return Mono.just(refundAmount);
|
||||
}
|
||||
return Mono.error(new RuntimeException("储值卡退款失败"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Long> countRechargeRecords(Long memberId) {
|
||||
return memberStoredCardRechargeRepository.countByMemberIdAndDeletedAtIsNull(memberId);
|
||||
}
|
||||
}
|
||||
+22
-3
@@ -43,12 +43,25 @@ public class WechatApiServiceImpl implements WechatApiService {
|
||||
|
||||
@Override
|
||||
public Mono<Map<String, String>> jsCode2Session(String code) {
|
||||
log.info("微信jsCode2Session API");
|
||||
log.info("信息 - AppID: {}, AppSecret {}",
|
||||
log.info("微信jsCode2Session API, code: {}", code);
|
||||
|
||||
// Mock模式:返回模拟数据
|
||||
if (wechatProperties.getMockEnabled() != null && wechatProperties.getMockEnabled()) {
|
||||
log.info("Mock模式已启用,返回模拟微信登录数据");
|
||||
Map<String, String> mockResult = new HashMap<>();
|
||||
// 使用 code 的哈希生成唯一的 openid,确保每次登录创建不同用户
|
||||
String mockOpenId = "mock_openid_" + Math.abs(code.hashCode() % 1000000);
|
||||
mockResult.put("openid", mockOpenId);
|
||||
mockResult.put("session_key", "mock_session_key");
|
||||
mockResult.put("unionid", "mock_unionid_" + Math.abs(code.hashCode() % 1000000));
|
||||
log.info("Mock微信API响应成功, openid: {}, unionid: {}", mockOpenId, mockResult.get("unionid"));
|
||||
return Mono.just(mockResult);
|
||||
}
|
||||
|
||||
log.info("真实微信API调用 - AppID: {}, AppSecret {}",
|
||||
wechatProperties.getMiniapp().getAppId(),
|
||||
wechatProperties.getMiniapp().getAppSecret() != null ?
|
||||
wechatProperties.getMiniapp().getAppSecret().substring(0, Math.min(4, wechatProperties.getMiniapp().getAppSecret().length())) + "***" : "null");
|
||||
log.info(" - code: {}", code);
|
||||
|
||||
return webClient.get()
|
||||
.uri(uriBuilder -> uriBuilder
|
||||
@@ -152,6 +165,12 @@ public class WechatApiServiceImpl implements WechatApiService {
|
||||
public Mono<String> getAccessToken(String appType) {
|
||||
log.debug("获取access_token, appType: {}", appType);
|
||||
|
||||
// Mock模式:返回模拟access_token
|
||||
if (wechatProperties.getMockEnabled() != null && wechatProperties.getMockEnabled()) {
|
||||
log.debug("Mock模式已启用,返回模拟access_token");
|
||||
return Mono.just("mock_access_token");
|
||||
}
|
||||
|
||||
String cacheKey = ACCESS_TOKEN_CACHE_PREFIX + appType;
|
||||
|
||||
return redisUtil.get(cacheKey, String.class)
|
||||
|
||||
+7
@@ -7,6 +7,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
@@ -25,6 +26,9 @@ public class MemberInfoVO {
|
||||
// 会员 ID
|
||||
private Long id;
|
||||
|
||||
// 会员编号
|
||||
private String memberNo;
|
||||
|
||||
// 昵称
|
||||
private String nickname;
|
||||
|
||||
@@ -48,4 +52,7 @@ public class MemberInfoVO {
|
||||
|
||||
// 是否已关注公众号
|
||||
private Boolean isSubscribed;
|
||||
|
||||
// 最后登录时间
|
||||
private LocalDateTime lastLoginAt;
|
||||
}
|
||||
@@ -279,3 +279,24 @@ CREATE INDEX IF NOT EXISTS idx_refund_application_status ON refund_application(s
|
||||
|
||||
COMMENT ON TABLE refund_application IS '退款申请表';
|
||||
COMMENT ON COLUMN refund_application.status IS '状态:PENDING-待审核, APPROVED-已批准, REJECTED-已拒绝, PROCESSING-处理中, SUCCESS-成功, FAILED-失败';
|
||||
|
||||
|
||||
-- ============================================
|
||||
-- 7. member_pay_password 表(支付密码表)
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS member_pay_password (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
member_id BIGINT NOT NULL UNIQUE,
|
||||
pay_password VARCHAR(100) NOT NULL,
|
||||
is_set BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_member_pay_password_member_id ON member_pay_password(member_id);
|
||||
|
||||
COMMENT ON TABLE member_pay_password IS '支付密码表';
|
||||
COMMENT ON COLUMN member_pay_password.member_id IS '会员ID';
|
||||
COMMENT ON COLUMN member_pay_password.pay_password IS '支付密码(明文存储)';
|
||||
COMMENT ON COLUMN member_pay_password.is_set IS '是否已设置支付密码';
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
package cn.novalon.gym.manage.member;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
/**
|
||||
* 会员模块测试类
|
||||
*/
|
||||
@SpringBootTest
|
||||
public class MemberModuleTest {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
// 测试Spring上下文是否能正常加载
|
||||
}
|
||||
}
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
package cn.novalon.gym.manage.member.handler;
|
||||
|
||||
import cn.novalon.gym.manage.member.entity.MemberCardRecord;
|
||||
import cn.novalon.gym.manage.member.enums.CardEvent;
|
||||
import cn.novalon.gym.manage.member.enums.MemberCardRecordStatus;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 会员卡状态机单元测试
|
||||
*/
|
||||
class MemberCardStateMachineTest {
|
||||
|
||||
private MemberCardStateMachine stateMachine;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
stateMachine = new MemberCardStateMachine();
|
||||
}
|
||||
|
||||
// ======================= ACTIVE 状态的转换 =======================
|
||||
|
||||
@Test
|
||||
@DisplayName("ACTIVE + USE = ACTIVE(继续使用)")
|
||||
void testActiveUse() {
|
||||
Mono<MemberCardRecordStatus> result = stateMachine.transition(
|
||||
MemberCardRecordStatus.ACTIVE, CardEvent.USE);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNext(MemberCardRecordStatus.ACTIVE)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ACTIVE + RENEW = ACTIVE(续费保持有效)")
|
||||
void testActiveRenew() {
|
||||
Mono<MemberCardRecordStatus> result = stateMachine.transition(
|
||||
MemberCardRecordStatus.ACTIVE, CardEvent.RENEW);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNext(MemberCardRecordStatus.ACTIVE)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ACTIVE + EXPIRE = EXPIRED(过期)")
|
||||
void testActiveExpire() {
|
||||
Mono<MemberCardRecordStatus> result = stateMachine.transition(
|
||||
MemberCardRecordStatus.ACTIVE, CardEvent.EXPIRE);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNext(MemberCardRecordStatus.EXPIRED)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ACTIVE + REFUND = REFUNDED(退款)")
|
||||
void testActiveRefund() {
|
||||
Mono<MemberCardRecordStatus> result = stateMachine.transition(
|
||||
MemberCardRecordStatus.ACTIVE, CardEvent.REFUND);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNext(MemberCardRecordStatus.REFUNDED)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
// ======================= USED_UP 状态的转换 =======================
|
||||
|
||||
@Test
|
||||
@DisplayName("USED_UP + RENEW = ACTIVE(用完后续费恢复)")
|
||||
void testUsedUpRenew() {
|
||||
Mono<MemberCardRecordStatus> result = stateMachine.transition(
|
||||
MemberCardRecordStatus.USED_UP, CardEvent.RENEW);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNext(MemberCardRecordStatus.ACTIVE)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("USED_UP + REFUND = REFUNDED(用完后退款)")
|
||||
void testUsedUpRefund() {
|
||||
Mono<MemberCardRecordStatus> result = stateMachine.transition(
|
||||
MemberCardRecordStatus.USED_UP, CardEvent.REFUND);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNext(MemberCardRecordStatus.REFUNDED)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("USED_UP + USE = 非法(用完不能再使用)")
|
||||
void testUsedUpUseInvalid() {
|
||||
Mono<MemberCardRecordStatus> result = stateMachine.transition(
|
||||
MemberCardRecordStatus.USED_UP, CardEvent.USE);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectError(IllegalStateException.class)
|
||||
.verify();
|
||||
}
|
||||
|
||||
// ======================= EXPIRED 状态的转换 =======================
|
||||
|
||||
@Test
|
||||
@DisplayName("EXPIRED + RENEW = ACTIVE(过期后续费恢复)")
|
||||
void testExpiredRenew() {
|
||||
Mono<MemberCardRecordStatus> result = stateMachine.transition(
|
||||
MemberCardRecordStatus.EXPIRED, CardEvent.RENEW);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNext(MemberCardRecordStatus.ACTIVE)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("EXPIRED + USE = 非法(过期不能使用)")
|
||||
void testExpiredUseInvalid() {
|
||||
Mono<MemberCardRecordStatus> result = stateMachine.transition(
|
||||
MemberCardRecordStatus.EXPIRED, CardEvent.USE);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectError(IllegalStateException.class)
|
||||
.verify();
|
||||
}
|
||||
|
||||
// ======================= REFUNDED 状态(终态)=======================
|
||||
|
||||
@Test
|
||||
@DisplayName("REFUNDED + RENEW = 非法(已退款终态)")
|
||||
void testRefundedRenewInvalid() {
|
||||
Mono<MemberCardRecordStatus> result = stateMachine.transition(
|
||||
MemberCardRecordStatus.REFUNDED, CardEvent.RENEW);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectError(IllegalStateException.class)
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("REFUNDED + USE = 非法(已退款终态)")
|
||||
void testRefundedUseInvalid() {
|
||||
Mono<MemberCardRecordStatus> result = stateMachine.transition(
|
||||
MemberCardRecordStatus.REFUNDED, CardEvent.USE);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectError(IllegalStateException.class)
|
||||
.verify();
|
||||
}
|
||||
|
||||
// ======================= canTransition 检查 =======================
|
||||
|
||||
@Test
|
||||
@DisplayName("canTransition - ACTIVE可以RENEW")
|
||||
void testCanTransitionActiveRenew() {
|
||||
Mono<Boolean> result = stateMachine.canTransition(
|
||||
MemberCardRecordStatus.ACTIVE, CardEvent.RENEW);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNext(true)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("canTransition - USED_UP不能USE")
|
||||
void testCanTransitionUsedUpUse() {
|
||||
Mono<Boolean> result = stateMachine.canTransition(
|
||||
MemberCardRecordStatus.USED_UP, CardEvent.USE);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNext(false)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("canTransition - REFUNDED不能任何操作")
|
||||
void testCanTransitionRefundedAny() {
|
||||
Mono<Boolean> result = stateMachine.canTransition(
|
||||
MemberCardRecordStatus.REFUNDED, CardEvent.RENEW);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectNext(false)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
// ======================= validateTransition 检查 =======================
|
||||
|
||||
@Test
|
||||
@DisplayName("validateTransition - ACTIVE状态卡片使用通过")
|
||||
void testValidateTransitionValid() {
|
||||
MemberCardRecord card = MemberCardRecord.builder()
|
||||
.memberCardRecordId(1L)
|
||||
.status(MemberCardRecordStatus.ACTIVE)
|
||||
.build();
|
||||
|
||||
Mono<Void> result = stateMachine.validateTransition(card, CardEvent.USE);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("validateTransition - REFUNDED状态卡片拒绝所有操作")
|
||||
void testValidateTransitionRefunded() {
|
||||
MemberCardRecord card = MemberCardRecord.builder()
|
||||
.memberCardRecordId(2L)
|
||||
.status(MemberCardRecordStatus.REFUNDED)
|
||||
.build();
|
||||
|
||||
Mono<Void> result = stateMachine.validateTransition(card, CardEvent.RENEW);
|
||||
|
||||
StepVerifier.create(result)
|
||||
.expectError(IllegalStateException.class)
|
||||
.verify();
|
||||
}
|
||||
|
||||
// ======================= 枚举值验证 =======================
|
||||
|
||||
@Test
|
||||
@DisplayName("MemberCardRecordStatus 枚举值完整")
|
||||
void testStatusEnumValues() {
|
||||
MemberCardRecordStatus[] values = MemberCardRecordStatus.values();
|
||||
assertEquals(4, values.length);
|
||||
assertEquals("有效", MemberCardRecordStatus.ACTIVE.getDesc());
|
||||
assertEquals("用完", MemberCardRecordStatus.USED_UP.getDesc());
|
||||
assertEquals("过期", MemberCardRecordStatus.EXPIRED.getDesc());
|
||||
assertEquals("已退款", MemberCardRecordStatus.REFUNDED.getDesc());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("CardEvent 枚举值完整")
|
||||
void testEventEnumValues() {
|
||||
CardEvent[] values = CardEvent.values();
|
||||
assertEquals(6, values.length);
|
||||
assertEquals("激活卡片", CardEvent.ACTIVATE.getDesc());
|
||||
assertEquals("使用卡片", CardEvent.USE.getDesc());
|
||||
assertEquals("续费", CardEvent.RENEW.getDesc());
|
||||
assertEquals("过期", CardEvent.EXPIRE.getDesc());
|
||||
assertEquals("退款", CardEvent.REFUND.getDesc());
|
||||
assertEquals("禁用", CardEvent.DISABLE.getDesc());
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package cn.novalon.gym.manage.member.util;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Bean转换工具类单元测试
|
||||
*/
|
||||
class BeanConvertUtilTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("toBean - 正常转换")
|
||||
void testToBeanNormal() {
|
||||
Source source = new Source();
|
||||
source.setName("张三");
|
||||
source.setAge(25);
|
||||
source.setEmail("zhangsan@test.com");
|
||||
|
||||
Target target = BeanConvertUtil.toBean(source, Target.class);
|
||||
|
||||
assertNotNull(target);
|
||||
assertEquals("张三", target.getName());
|
||||
assertEquals(25, target.getAge());
|
||||
assertEquals("zhangsan@test.com", target.getEmail());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("toBean - 部分字段转换")
|
||||
void testToBeanPartial() {
|
||||
Source source = new Source();
|
||||
source.setName("李四");
|
||||
source.setAge(30);
|
||||
// email 不设置
|
||||
|
||||
Target target = BeanConvertUtil.toBean(source, Target.class);
|
||||
|
||||
assertNotNull(target);
|
||||
assertEquals("李四", target.getName());
|
||||
assertEquals(30, target.getAge());
|
||||
assertNull(target.getEmail());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("toBean - null输入返回null")
|
||||
void testToBeanNull() {
|
||||
Target target = BeanConvertUtil.toBean(null, Target.class);
|
||||
|
||||
assertNull(target);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("toBeanList - 正常批量转换")
|
||||
void testToBeanListNormal() {
|
||||
Source s1 = new Source();
|
||||
s1.setName("A"); s1.setAge(20);
|
||||
Source s2 = new Source();
|
||||
s2.setName("B"); s2.setAge(25);
|
||||
Source s3 = new Source();
|
||||
s3.setName("C"); s3.setAge(30);
|
||||
|
||||
List<Target> targets = BeanConvertUtil.toBeanList(
|
||||
Arrays.asList(s1, s2, s3), Target.class);
|
||||
|
||||
assertEquals(3, targets.size());
|
||||
assertEquals("A", targets.get(0).getName());
|
||||
assertEquals(20, targets.get(0).getAge());
|
||||
assertEquals("B", targets.get(1).getName());
|
||||
assertEquals("C", targets.get(2).getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("toBeanList - null输入返回空列表")
|
||||
void testToBeanListNull() {
|
||||
List<Target> targets = BeanConvertUtil.toBeanList(null, Target.class);
|
||||
|
||||
assertNotNull(targets);
|
||||
assertTrue(targets.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("toBeanList - 空列表输入返回空列表")
|
||||
void testToBeanListEmpty() {
|
||||
List<Target> targets = BeanConvertUtil.toBeanList(
|
||||
Collections.emptyList(), Target.class);
|
||||
|
||||
assertNotNull(targets);
|
||||
assertTrue(targets.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("toBeanList - 单元素列表")
|
||||
void testToBeanListSingle() {
|
||||
Source source = new Source();
|
||||
source.setName("王五");
|
||||
source.setAge(35);
|
||||
|
||||
List<Target> targets = BeanConvertUtil.toBeanList(
|
||||
Collections.singletonList(source), Target.class);
|
||||
|
||||
assertEquals(1, targets.size());
|
||||
assertEquals("王五", targets.get(0).getName());
|
||||
assertEquals(35, targets.get(0).getAge());
|
||||
}
|
||||
|
||||
// === 内部测试类 ===
|
||||
|
||||
public static class Source {
|
||||
private String name;
|
||||
private int age;
|
||||
private String email;
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public int getAge() { return age; }
|
||||
public void setAge(int age) { this.age = age; }
|
||||
public String getEmail() { return email; }
|
||||
public void setEmail(String email) { this.email = email; }
|
||||
}
|
||||
|
||||
public static class Target {
|
||||
private String name;
|
||||
private int age;
|
||||
private String email;
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public int getAge() { return age; }
|
||||
public void setAge(int age) { this.age = age; }
|
||||
public String getEmail() { return email; }
|
||||
public void setEmail(String email) { this.email = email; }
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package cn.novalon.gym.manage.member.util;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.RepeatedTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 会员号生成器单元测试
|
||||
*/
|
||||
class MemberNoGeneratorTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("生成会员号 - 格式正确 GYM + 8位字符")
|
||||
void testGenerateFormat() {
|
||||
String memberNo = MemberNoGenerator.generate();
|
||||
|
||||
assertNotNull(memberNo);
|
||||
assertEquals(11, memberNo.length());
|
||||
assertTrue(memberNo.startsWith("GYM"));
|
||||
// 后8位只包含合法字符
|
||||
String suffix = memberNo.substring(3);
|
||||
for (char c : suffix.toCharArray()) {
|
||||
assertTrue("23456789ABCDEFGHJKLMNPQRSTUVWXYZ".indexOf(c) >= 0,
|
||||
"非法字符: " + c);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("生成会员号 - 不包含易混淆字符")
|
||||
void testGenerateNoAmbiguousChars() {
|
||||
for (int i = 0; i < 50; i++) {
|
||||
String memberNo = MemberNoGenerator.generate();
|
||||
assertFalse(memberNo.contains("0"));
|
||||
assertFalse(memberNo.contains("O"));
|
||||
assertFalse(memberNo.contains("1"));
|
||||
assertFalse(memberNo.contains("I"));
|
||||
assertFalse(memberNo.contains("l"));
|
||||
}
|
||||
}
|
||||
|
||||
@RepeatedTest(10)
|
||||
@DisplayName("生成会员号 - 高概率不重复")
|
||||
void testGenerateUniqueness() {
|
||||
String no1 = MemberNoGenerator.generate();
|
||||
String no2 = MemberNoGenerator.generate();
|
||||
// 极低概率重复(字符集30个字符取8位,总共 30^8 种组合)
|
||||
assertNotEquals(no1, no2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("批量生成 - 生成指定数量")
|
||||
void testGenerateBatch() {
|
||||
int count = 5;
|
||||
String[] memberNos = MemberNoGenerator.generateBatch(count);
|
||||
|
||||
assertEquals(count, memberNos.length);
|
||||
for (String no : memberNos) {
|
||||
assertNotNull(no);
|
||||
assertEquals(11, no.length());
|
||||
assertTrue(no.startsWith("GYM"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("批量生成 - 全部不重复")
|
||||
void testGenerateBatchUnique() {
|
||||
String[] memberNos = MemberNoGenerator.generateBatch(20);
|
||||
|
||||
for (int i = 0; i < memberNos.length; i++) {
|
||||
for (int j = i + 1; j < memberNos.length; j++) {
|
||||
assertNotEquals(memberNos[i], memberNos[j],
|
||||
"重复会员号: " + memberNos[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("批量生成 - count=0 返回空数组")
|
||||
void testGenerateBatchZero() {
|
||||
String[] memberNos = MemberNoGenerator.generateBatch(0);
|
||||
assertEquals(0, memberNos.length);
|
||||
}
|
||||
}
|
||||
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