371 changed files with 33590 additions and 21963 deletions
+318
View File
@@ -0,0 +1,318 @@
# AGENT.md — 健身房管理系统 AI 协作工作流
## 概述
本项目采用"需求驱动开发"Requirement-Driven Development)工作流,通过以下四个核心命令完成从模糊需求到可运行代码的全流程。遇到复杂 Bug 时可随时调用 `/systemic-debugging` 进行系统化诊断。
---
## 项目技术栈与模块结构
### 技术栈总览
| 层 | 技术栈 | 目录 | 测试框架 |
|---|---|---|---|
| 后端 API | Java Spring BootMaven 多模块) | `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
View File
@@ -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
+172 -279
View File
@@ -1,310 +1,203 @@
pipeline { pipeline {
agent any agent any
environment { parameters {
// 项目配置 choice(
PROJECT_NAME = 'gym-manage-system' name: 'TEST_SCOPE',
FRONTEND_DIR = 'gym-manage-web' choices: ['smoke', 'full', 'all', 'miniapp'],
BACKEND_DIR = 'gym-manage-api' description: '测试范围: smoke=仅冒烟测试, full=完整E2E, all=所有测试含性能, miniapp=仅小程序'
)
// Node.js 配置 choice(
NODE_VERSION = '20' name: 'BROWSER',
PNPM_VERSION = '8.15.0' choices: ['chromium', 'all'],
description: '目标浏览器: chromium=单浏览器快速, all=全浏览器'
// Java 配置 )
JAVA_VERSION = '17' string(
MAVEN_VERSION = '3.9.0' name: 'BASE_URL',
defaultValue: 'http://localhost:3002',
// Docker 配置 description: '测试目标URL'
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'
} }
tools { environment {
nodejs "NodeJS-${NODE_VERSION}" PLAYWRIGHT_HEADLESS = 'true'
maven "Maven-${MAVEN_VERSION}" CI = 'true'
jdk "JDK-${JAVA_VERSION}" NODE_ENV = 'test'
JEST_JUNIT_OUTPUT_DIR = 'test-results'
JEST_JUNIT_OUTPUT_NAME = 'junit.xml'
} }
stages { stages {
stage('环境准备') { stage('环境准备') {
steps {
echo '🔧 准备构建环境...'
sh '''
# 安装 pnpm
npm install -g pnpm@${PNPM_VERSION}
# 验证工具版本
node --version
pnpm --version
java -version
mvn --version
'''
}
}
stage('代码检查') {
parallel { parallel {
stage('前端代码检查') { stage('安装Web端依赖') {
steps { steps {
dir(FRONTEND_DIR) { dir('gym-manage-web') {
echo '🔍 执行前端代码检查...' bat 'npm ci'
sh ''' bat 'npx playwright install chromium --with-deps'
pnpm install
pnpm run lint
pnpm run type-check
'''
} }
} }
} }
stage('安装小程序端依赖') {
stage('后端代码检查') {
steps { steps {
dir(BACKEND_DIR) { dir('gym-manage-uniapp') {
echo '🔍 执行后端代码检查...' bat 'npm ci'
sh 'mvn clean compile -DskipTests'
} }
} }
} }
} }
} }
stage('单元测试') { stage('启动后端服务') {
parallel {
stage('前端单元测试') {
steps { steps {
dir(FRONTEND_DIR) { dir('gym-manage-api') {
echo '🧪 执行前端单元测试...' bat 'mvn spring-boot:run -pl manage-app -DskipTests &'
sh 'pnpm run test:unit' bat 'mvn spring-boot:run -pl manage-gateway -DskipTests &'
}
// 等待后端健康检查
script {
retry(10) {
sleep(10)
try {
def response = httpRequest 'http://localhost:8084/actuator/health'
print "后端健康状态: ${response.content}"
} catch (err) {
error "后端尚未就绪: ${err.message}"
}
}
}
}
}
stage('冒烟测试') {
steps {
dir('gym-manage-web') {
bat 'npm run test:e2e:smoke'
} }
} }
post { 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测试') {
steps {
echo '🎭 执行E2E测试...'
dir(FRONTEND_DIR) {
sh '''
# 安装Playwright浏览器
pnpm exec playwright install --with-deps chromium
# 执行E2E测试
pnpm run test:e2e:journeys
'''
}
}
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
}
}
}
}
stage('构建Docker镜像') {
when {
branch 'develop'
}
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
"""
}
}
}
stage('推送Docker镜像') {
when {
branch 'develop'
}
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
"""
}
}
stage('部署到测试环境') {
when {
branch 'develop'
}
steps {
echo '🚀 部署到测试环境...'
sh """
# 这里可以添加部署脚本
# 例如:使用docker-compose或kubernetes部署
echo "部署前端镜像: ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:${BUILD_NUMBER}"
echo "部署后端镜像: ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:${BUILD_NUMBER}"
"""
}
}
stage('部署到生产环境') {
when {
branch 'main'
}
steps {
echo '🚀 部署到生产环境...'
input message: '确认部署到生产环境?', ok: '确认部署'
sh """
# 这里可以添加生产环境部署脚本
# 例如:使用kubernetes进行滚动更新
echo "部署前端镜像: ${DOCKER_REGISTRY}/${DOCKER_IMAGE_FRONTEND}:${BUILD_NUMBER}"
echo "部署后端镜像: ${DOCKER_REGISTRY}/${DOCKER_IMAGE_BACKEND}:${BUILD_NUMBER}"
"""
}
}
}
post {
always {
echo '🧹 清理工作空间...'
cleanWs()
}
success {
echo '✅ 流水线执行成功!'
// 可以添加通知,例如发送邮件或Slack消息
}
failure { failure {
echo '❌ 流水线执行失败!' echo '冒烟测试失败,跳过后续测试'
// 可以添加失败通知 }
}
} }
unstable { stage('Web E2E 测试') {
echo '⚠️ 流水线执行不稳定!' 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('性能测试') {
when {
expression { params.TEST_SCOPE == 'all' }
}
steps {
dir('gym-manage-web') {
bat 'npm run test:e2e:perf'
}
}
}
stage('角色边界测试') {
when {
expression { params.TEST_SCOPE == 'all' }
}
steps {
dir('gym-manage-web') {
bat 'npx playwright test --grep "权限|角色|边界|boundary"'
}
}
}
stage('小程序编译') {
when {
anyOf {
expression { params.TEST_SCOPE == 'all' }
expression { params.TEST_SCOPE == 'miniapp' }
}
}
steps {
dir('gym-manage-uniapp') {
bat 'npm run build:mp-weixin'
}
}
}
stage('小程序 E2E 测试') {
when {
anyOf {
expression { params.TEST_SCOPE == 'all' }
expression { params.TEST_SCOPE == 'miniapp' }
}
}
environment {
WECHAT_DEVTOOLS_CLI = 'D:\\微信web开发者工具\\cli.bat'
}
steps {
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 {
// 归档测试报告
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
}
}
failure {
// 失败通知
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'
)
}
}
success {
echo "全部 UI 测试通过!"
} }
} }
} }
@@ -0,0 +1,319 @@
# UI 层级全自动测试套件 — 需求共识文档
> **文档编号**: GYM-GRILL-UI-TEST-001
> **日期**: 2026-07-21
> **状态**: 已确认
> **产出命令**: /grill-with-docs
---
## 一、功能概述
为健身房管理系统构建 UI 层级的全自动测试套件,覆盖 **管理后台 WebVue 3****会员小程序(UniApp** 两个 UI 终端,确保所有已实现功能通过系统化测试,达到 ≥95% 通过率的交付水平。
### 目标终端
| UI 层级 | 技术栈 | 当前测试状态 | 目标 |
|---|---|---|---|
| 管理后台 Web | Vue 3 + Element Plus + Vite | Vitest 单元测试 + Playwright E2E81%通过率) | 修复失败 → 补齐覆盖 → ≥95% |
| 会员小程序 | UniAppVue | **零测试** | 从零构建 → 完整覆盖 |
---
## 二、当前状态分析
### 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 | 成熟 | 可直接复用 |
| customReporterHTML+JSON+控制台) | 成熟 | 可直接复用 |
| auth.setupstorageState | **有 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 层) |
| 报告 | customReporterHTML+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
View File
@@ -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 ```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/` 目录下添加测试 1. **新增后端接口** → 在对应模块 `src/test/` 下添加 JUnit 测试,更新 [backend-api-test.md](docs/backend-api-test.md)
2. 新增基础功能时,在 `smoke/` 目录下添加测试 2. **新增 Web 页面** → 添加 Vitest 组件测试 + Page Object + E2E 旅程,更新 [web-admin-test.md](docs/web-admin-test.md)
3. 保持测试文件数量精简,避免重复测试 3. **新增小程序功能** → 添加 E2E 用例或 Page Object,更新 [miniapp-test.md](docs/miniapp-test.md)
4. 优先使用单元测试覆盖功能细 4. **修改 CI 触发规则** → 编辑 `Jenkinsfile`,同步更新本文档「自动化触发时机」章
+363
View File
@@ -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
+312
View File
@@ -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 + 微信开发者工具
+298
View File
@@ -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 Object6 个)
| 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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -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();
}
}
@@ -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;
}
}
@@ -194,47 +194,4 @@ public class CheckInHandler {
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "success", "data", stats))); .bodyValue(Map.of("code", 200, "message", "success", "data", stats)));
} }
/**
* 管理员查询所有签到记录(支持排序)
*
* GET /api/checkIn/admin/records
*/
public Mono<ServerResponse> getAllSignInRecords(ServerRequest request) {
String startDateStr = request.queryParam("startDate").orElse(null);
String endDateStr = request.queryParam("endDate").orElse(null);
String sortBy = request.queryParam("sortBy").orElse("signInTime");
String sortOrder = request.queryParam("sortOrder").orElse("desc");
LocalDate startDate = startDateStr != null ? LocalDate.parse(startDateStr, DATE_FORMATTER) : LocalDate.now().minusDays(30);
LocalDate endDate = endDateStr != null ? LocalDate.parse(endDateStr, DATE_FORMATTER) : LocalDate.now();
log.info("管理员查询所有签到记录, startDate: {}, endDate: {}, sortBy: {}, sortOrder: {}", startDate, endDate, sortBy, sortOrder);
return checkService.getAllSignInRecords(startDate, endDate, sortBy, sortOrder)
.collectList()
.flatMap(records -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "success", "data", records)));
}
/**
* 管理员查询签到统计(不限会员)
*
* GET /api/checkIn/admin/statistics
*/
public Mono<ServerResponse> getAllSignInStatistics(ServerRequest request) {
String startDateStr = request.queryParam("startDate").orElse(null);
String endDateStr = request.queryParam("endDate").orElse(null);
LocalDate startDate = startDateStr != null ? LocalDate.parse(startDateStr, DATE_FORMATTER) : LocalDate.now().minusDays(30);
LocalDate endDate = endDateStr != null ? LocalDate.parse(endDateStr, DATE_FORMATTER) : LocalDate.now();
log.info("管理员查询签到统计, startDate: {}, endDate: {}", startDate, endDate);
return checkService.getAllSignInStats(startDate, endDate)
.flatMap(stats -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 200, "message", "success", "data", stats)));
}
} }
@@ -57,12 +57,6 @@ public interface SignInRecordRepository extends R2dbcRepository<SignInRecord, Lo
@Query("SELECT * FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false ORDER BY sign_in_time DESC") @Query("SELECT * FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false ORDER BY sign_in_time DESC")
Flux<SignInRecord> findByTimeRange(LocalDateTime startTime, LocalDateTime endTime); Flux<SignInRecord> findByTimeRange(LocalDateTime startTime, LocalDateTime endTime);
/**
* 根据时间范围查询签到记录(支持动态排序)
*/
@Query("SELECT * FROM sign_in_record WHERE sign_in_time >= :startTime AND sign_in_time <= :endTime AND is_delete = false ORDER BY sign_in_time DESC")
Flux<SignInRecord> findByTimeRangeSorted(LocalDateTime startTime, LocalDateTime endTime);
/** /**
* 统计会员在时间范围内的签到次数 * 统计会员在时间范围内的签到次数
*/ */
@@ -78,20 +78,4 @@ public interface ICheckInService {
* @return 签到统计VO * @return 签到统计VO
*/ */
Mono<SignInStatsVO> getDailySignInStats(LocalDate date); Mono<SignInStatsVO> getDailySignInStats(LocalDate date);
/**
* 管理员查询所有签到记录(支持排序)
*
* @param startTime 开始时间
* @param endTime 结束时间
* @param sortBy 排序字段
* @param sortOrder 排序方向
* @return 签到记录列表(含会员姓名、卡类型)
*/
Flux<SignInRecordVO> getAllSignInRecords(LocalDate startTime, LocalDate endTime, String sortBy, String sortOrder);
/**
* 管理员查询签到统计(不限会员)
*/
Mono<SignInStatsVO> getAllSignInStats(LocalDate startTime, LocalDate endTime);
} }
@@ -16,13 +16,11 @@ import cn.novalon.gym.manage.checkIn.websocket.MyWebSocketHandler;
import cn.novalon.gym.manage.common.constant.RedisKeyConstants; import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
import cn.novalon.gym.manage.common.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
import cn.novalon.gym.manage.member.entity.Member;
import cn.novalon.gym.manage.member.entity.MemberCard; import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord; import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.enums.MemberCardType; import cn.novalon.gym.manage.member.enums.MemberCardType;
import cn.novalon.gym.manage.member.repository.IMemberRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository; import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -49,25 +47,18 @@ public class CheckServiceImpl implements ICheckInService {
private final MemberCardRepository memberCardRepository; private final MemberCardRepository memberCardRepository;
private final SignInRecordRepository signInRecordRepository; private final SignInRecordRepository signInRecordRepository;
private final IGroupCourseBookingService groupCourseBookingService; private final IGroupCourseBookingService groupCourseBookingService;
private final IMemberRepository memberRepository;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Override @Override
public Mono<QRCodeVo> getQRCode(Long memberId) { public Mono<QRCodeVo> getQRCode(Long memberId) {
log.info("开始查询会员信息, memberId: {}", memberId); log.info("开始生成会员签到二维码, memberId: {}", memberId);
return findValidMemberCard(memberId)
.flatMap(cardRecord -> {
log.info("会员信息查询完成, memberCardRecordId: {}", cardRecord.getMemberCardRecordId());
log.info("开始生成二维码");
String qrContent = QRRedisKey.generateQrcodeContent(); String qrContent = QRRedisKey.generateQrcodeContent();
Map<String, Object> redisMap = new HashMap<>(); Map<String, Object> redisMap = new HashMap<>();
redisMap.put("qrContent", qrContent); redisMap.put("qrContent", qrContent);
redisMap.put("isUsed", false); redisMap.put("isUsed", false);
redisMap.put("memberId", memberId); redisMap.put("memberId", memberId);
redisMap.put("memberCardRecordId", cardRecord.getMemberCardRecordId());
return redisUtil.setWithExpire( return redisUtil.setWithExpire(
RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(), RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(),
@@ -79,8 +70,6 @@ public class CheckServiceImpl implements ICheckInService {
BeanUtil.copyProperties(qrCodeConfig, QrConfig.class), "png"); BeanUtil.copyProperties(qrCodeConfig, QrConfig.class), "png");
return new QRCodeVo(qrCodeBase64, false, qrContent, qrCodeConfig.getWidth(), qrCodeConfig.getHeight(), LocalDate.now()); return new QRCodeVo(qrCodeBase64, false, qrContent, qrCodeConfig.getWidth(), qrCodeConfig.getHeight(), LocalDate.now());
})); }));
})
.switchIfEmpty(Mono.error(new RuntimeException("该会员没有可用的会员卡")));
} }
@Override @Override
@@ -116,7 +105,8 @@ public class CheckServiceImpl implements ICheckInService {
} }
log.info("二维码匹配成功,memberId: {}", memberId); log.info("二维码匹配成功,memberId: {}", memberId);
Long memberCardRecordId = ((Number) map.get("memberCardRecordId")).longValue(); Long memberCardRecordId = map.get("memberCardRecordId") != null
? ((Number) map.get("memberCardRecordId")).longValue() : null;
return processCheckIn(memberId, memberCardRecordId, map, qrContent); return processCheckIn(memberId, memberCardRecordId, map, qrContent);
} else { } else {
@@ -148,54 +138,23 @@ public class CheckServiceImpl implements ICheckInService {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
// 发送实时进度通知 // 发送实时进度通知
MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_CARD", "正在验证会员卡..."); MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_BOOKING", "正在检查预约信息...");
return memberCardRecordRepository.findById(memberCardRecordId)
.switchIfEmpty(Mono.defer(() -> {
MyWebSocketHandler.sendFailure(qrContent, "会员卡记录不存在");
return Mono.error(new RuntimeException("会员卡记录不存在"));
}))
.flatMap(cardRecord -> {
if (!"ACTIVE".equals(cardRecord.getStatus().name())) {
MyWebSocketHandler.sendFailure(qrContent, "会员卡状态不正确");
return Mono.error(new RuntimeException("会员卡状态不正确"));
}
if (cardRecord.getExpireTime() != null && cardRecord.getExpireTime().isBefore(now)) {
MyWebSocketHandler.sendFailure(qrContent, "会员卡已过期");
return Mono.error(new RuntimeException("会员卡已过期"));
}
// 发送实时进度通知
MyWebSocketHandler.sendProgress(qrContent, "VALIDATE_BOOKING", "会员卡验证通过,正在检查预约信息...");
// 检查是否有需要签到的团课预约 // 检查是否有需要签到的团课预约
return validateBooking(memberId, now) return validateBooking(memberId, now)
.then(memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(cardRecord.getMemberCardId()) .then(Mono.defer(() -> {
.switchIfEmpty(Mono.defer(() -> {
MyWebSocketHandler.sendFailure(qrContent, "会员卡类型不存在");
return Mono.error(new RuntimeException("会员卡类型不存在"));
}))
.flatMap(card -> {
// 发送实时进度通知
MyWebSocketHandler.sendProgress(qrContent, "DEDUCT_USAGE", "正在扣减会员卡次数...");
return deductCardUsage(cardRecord, card)
.flatMap(updatedRecord -> {
redisMap.put("isUsed", true); redisMap.put("isUsed", true);
redisMap.put("checkInTime", now.format(DATE_FORMATTER)); redisMap.put("checkInTime", now.format(DATE_FORMATTER));
return saveSignInRecord(memberId, cardRecord.getMemberCardRecordId(), card.getMemberCardId()) return saveSignInRecord(memberId, null, null)
.then(redisUtil.set(RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(), redisMap)) .then(redisUtil.set(RedisKeyConstants.QRCODE_USER_DAILY + memberId + LocalDate.now(), redisMap))
.then(Mono.defer(() -> { .then(Mono.defer(() -> {
String successMsg = buildSuccessResponse(now); String successMsg = buildSuccessResponse(now);
MyWebSocketHandler.sendSuccess(qrContent, memberId, now.format(DATE_FORMATTER)); MyWebSocketHandler.sendSuccess(qrContent, memberId, now.format(DATE_FORMATTER));
log.info("签到成功, memberId: {}, cardRecordId: {}", memberId, memberCardRecordId); log.info("签到成功, memberId: {}", memberId);
return Mono.just(successMsg); return Mono.just(successMsg);
})); }));
});
})); }));
});
} }
/** /**
@@ -433,83 +392,6 @@ public class CheckServiceImpl implements ICheckInService {
return vo; return vo;
} }
@Override
public Flux<SignInRecordVO> getAllSignInRecords(LocalDate startTime, LocalDate endTime, String sortBy, String sortOrder) {
LocalDateTime start = startTime.atStartOfDay();
LocalDateTime end = endTime.atTime(LocalTime.MAX);
Flux<SignInRecord> recordFlux = signInRecordRepository.findByTimeRangeSorted(start, end);
return recordFlux
.flatMap(record -> {
// fetch member name
Mono<String> memberNameMono = memberRepository.findById(record.getMemberId())
.map(Member::getNickname)
.defaultIfEmpty("未知");
// fetch card type name
Mono<String> cardTypeMono = record.getMemberCardId() != null
? memberCardRepository.findByMemberCardIdAndDeletedAtIsNull(record.getMemberCardId())
.map(MemberCard::getMemberCardName)
.defaultIfEmpty("未知")
: Mono.just("-");
return Mono.zip(memberNameMono, cardTypeMono)
.map(tuple -> {
SignInRecordVO vo = convertToVO(record);
vo.setMemberName(tuple.getT1());
vo.setMemberCardType(tuple.getT2());
return vo;
});
})
.collectList()
.flatMapMany(list -> {
// in-memory sort
boolean asc = "asc".equalsIgnoreCase(sortOrder);
java.util.Comparator<SignInRecordVO> comparator;
switch (sortBy != null ? sortBy : "signInTime") {
case "id":
comparator = java.util.Comparator.comparing(SignInRecordVO::getId, java.util.Comparator.nullsLast(Long::compareTo));
break;
case "memberName":
comparator = java.util.Comparator.comparing(SignInRecordVO::getMemberName, java.util.Comparator.nullsLast(String::compareTo));
break;
case "signInTime":
default:
comparator = java.util.Comparator.comparing(SignInRecordVO::getSignInTime, java.util.Comparator.nullsLast(java.time.LocalDateTime::compareTo));
break;
}
if (!asc) {
comparator = comparator.reversed();
}
list.sort(comparator);
return Flux.fromIterable(list);
});
}
@Override
public Mono<SignInStatsVO> getAllSignInStats(LocalDate startTime, LocalDate endTime) {
LocalDateTime start = startTime.atStartOfDay();
LocalDateTime end = endTime.atTime(LocalTime.MAX);
return Mono.zip(
(Object[] results) -> {
Long total = (Long) results[0];
Long success = (Long) results[1];
Long members = (Long) results[2];
SignInStatsVO stats = new SignInStatsVO();
stats.setTotalCount(total);
stats.setSuccessCount(success);
stats.setStartDate(startTime);
stats.setEndDate(endTime);
stats.setUniqueMemberCount(members);
stats.setSuccessRate(total > 0 ? (double) success / total * 100.0 : 0.0);
return stats;
},
signInRecordRepository.countByTimeRange(start, end),
signInRecordRepository.countSuccessByTimeRange(start, end),
signInRecordRepository.countDistinctMembersByTimeRange(start, end)
);
}
private long getSecondsUntilEndOfDay() { private long getSecondsUntilEndOfDay() {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59); LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59);
@@ -59,16 +59,6 @@ public class SignInRecordVO {
*/ */
private String source; private String source;
/**
* 会员姓名(关联查询)
*/
private String memberName;
/**
* 会员卡类型名称(关联查询)
*/
private String memberCardType;
/** /**
* 创建时间 * 创建时间
*/ */
@@ -14,7 +14,6 @@ import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord; import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository; import cn.novalon.gym.manage.member.repository.MemberCardRecordRepository;
import cn.novalon.gym.manage.member.repository.MemberCardRepository; import cn.novalon.gym.manage.member.repository.MemberCardRepository;
import cn.novalon.gym.manage.member.repository.IMemberRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -58,9 +57,6 @@ class CheckInModuleTest {
@Mock @Mock
private IGroupCourseBookingService groupCourseBookingService; private IGroupCourseBookingService groupCourseBookingService;
@Mock
private IMemberRepository memberRepository;
@Mock @Mock
private MemberCard mockMemberCard; private MemberCard mockMemberCard;
@@ -76,7 +72,7 @@ class CheckInModuleTest {
void setUp() { void setUp() {
MockitoAnnotations.openMocks(this); MockitoAnnotations.openMocks(this);
checkService = new CheckServiceImpl(qrCodeConfig, redisUtil, memberCardRecordRepository, checkService = new CheckServiceImpl(qrCodeConfig, redisUtil, memberCardRecordRepository,
memberCardRepository, signInRecordRepository, groupCourseBookingService, memberRepository); memberCardRepository, signInRecordRepository, groupCourseBookingService);
when(mockMemberCard.getId()).thenReturn(1L); when(mockMemberCard.getId()).thenReturn(1L);
when(mockMemberCard.getMemberCardType()).thenReturn("TIME_CARD"); when(mockMemberCard.getMemberCardType()).thenReturn("TIME_CARD");
+11 -1
View File
@@ -67,7 +67,12 @@
<dependency> <dependency>
<groupId>org.apache.poi</groupId> <groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId> <artifactId>poi-ooxml</artifactId>
<version>5.2.5</version> </dependency>
<!-- Fix commons-compress version for POI compatibility -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
</dependency> </dependency>
<!-- Gym Modules --> <!-- Gym Modules -->
@@ -95,6 +100,11 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -26,7 +26,7 @@ public class DataStatisticsDao {
* 统计指定时间范围内新增会员数 * 统计指定时间范围内新增会员数
*/ */
public Mono<Long> countNewMembers(LocalDateTime startTime, LocalDateTime endTime) { public Mono<Long> countNewMembers(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE created_at >= :startTime AND created_at < :endTime AND deleted_at IS NULL") return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE created_at >= :startTime AND created_at < :endTime AND is_deleted = false")
.bind("startTime", startTime) .bind("startTime", startTime)
.bind("endTime", endTime) .bind("endTime", endTime)
.map(row -> row.get(0, Long.class)) .map(row -> row.get(0, Long.class))
@@ -37,7 +37,7 @@ public class DataStatisticsDao {
* 统计总会员数 * 统计总会员数
*/ */
public Mono<Long> countTotalMembers() { public Mono<Long> countTotalMembers() {
return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE deleted_at IS NULL") return databaseClient.sql("SELECT COUNT(*) FROM member_user WHERE is_deleted = false")
.map(row -> row.get(0, Long.class)) .map(row -> row.get(0, Long.class))
.one(); .one();
} }
@@ -59,6 +59,12 @@ public class DataStatistics {
public static final String WEEK = "WEEK"; public static final String WEEK = "WEEK";
/** 月统计 */ /** 月统计 */
public static final String MONTH = "MONTH"; public static final String MONTH = "MONTH";
/** 近30天 */
public static final String LAST_30_DAYS = "LAST_30_DAYS";
/** 近90天 */
public static final String LAST_90_DAYS = "LAST_90_DAYS";
/** 今年 */
public static final String YEAR = "YEAR";
private PeriodType() {} private PeriodType() {}
} }
@@ -4,8 +4,6 @@ import cn.novalon.gym.manage.datacount.domain.*;
import cn.novalon.gym.manage.datacount.service.IDataStatisticsService; import cn.novalon.gym.manage.datacount.service.IDataStatisticsService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -27,8 +25,6 @@ import java.time.format.DateTimeFormatter;
@Tag(name = "数据统计", description = "数据统计相关操作") @Tag(name = "数据统计", description = "数据统计相关操作")
public class DataStatisticsHandler { public class DataStatisticsHandler {
private static final Logger log = LoggerFactory.getLogger(DataStatisticsHandler.class);
@Autowired @Autowired
private IDataStatisticsService dataStatisticsService; private IDataStatisticsService dataStatisticsService;
@@ -39,7 +35,6 @@ public class DataStatisticsHandler {
return dataStatisticsService.getStatisticsSummaryWithCache(query) return dataStatisticsService.getStatisticsSummaryWithCache(query)
.flatMap(summary -> ServerResponse.ok().bodyValue(summary)) .flatMap(summary -> ServerResponse.ok().bodyValue(summary))
.onErrorResume(e -> { .onErrorResume(e -> {
log.error("获取综合统计数据失败", e);
StatisticsSummary errorSummary = StatisticsSummary.builder() StatisticsSummary errorSummary = StatisticsSummary.builder()
.statDate(LocalDateTime.now().toLocalDate().toString()) .statDate(LocalDateTime.now().toLocalDate().toString())
.generatedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)) .generatedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
@@ -13,7 +13,6 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
@@ -23,9 +22,7 @@ import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters; import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
@@ -176,25 +173,9 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
@Override @Override
public Mono<StatisticsSummary> getStatisticsSummary(StatisticsQuery query) { public Mono<StatisticsSummary> getStatisticsSummary(StatisticsQuery query) {
String statDate = query.getStartTime() != null Mono<MemberStatistics> memberStatsMono = getMemberStatistics(query);
? query.getStartTime().toLocalDate().toString() Mono<BookingStatistics> bookingStatsMono = getBookingStatistics(query);
: LocalDateTime.now().toLocalDate().toString(); Mono<SignInStatistics> signInStatsMono = getSignInStatistics(query);
Mono<MemberStatistics> memberStatsMono = getMemberStatistics(query)
.onErrorResume(e -> {
log.error("获取会员统计数据失败", e);
return Mono.just(MemberStatistics.builder().statDate(statDate).build());
});
Mono<BookingStatistics> bookingStatsMono = getBookingStatistics(query)
.onErrorResume(e -> {
log.error("获取预约统计数据失败", e);
return Mono.just(BookingStatistics.builder().statDate(statDate).build());
});
Mono<SignInStatistics> signInStatsMono = getSignInStatistics(query)
.onErrorResume(e -> {
log.error("获取签到统计数据失败", e);
return Mono.just(SignInStatistics.builder().statDate(statDate).build());
});
return Mono.zip(memberStatsMono, bookingStatsMono, signInStatsMono) return Mono.zip(memberStatsMono, bookingStatsMono, signInStatsMono)
.map(tuple -> StatisticsSummary.builder() .map(tuple -> StatisticsSummary.builder()
@@ -207,75 +188,21 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
} }
@Override @Override
public Flux<DataStatistics> queryHistoricalStatistics(StatisticsQuery query) { public reactor.core.publisher.Flux<DataStatistics> queryHistoricalStatistics(StatisticsQuery query) {
// 历史统计数据查询(从Redis缓存中获取) // 历史统计数据查询(从Redis缓存中获取)
String cacheKey = buildCacheKey(query); String cacheKey = buildCacheKey(query);
return redisUtil.get(cacheKey, String.class) return redisUtil.get(cacheKey, String.class)
.flatMapMany(json -> { .flatMapMany(json -> {
try { try {
List<DataStatistics> stats = objectMapper.readValue(json, java.util.List<DataStatistics> stats = objectMapper.readValue(json,
objectMapper.getTypeFactory().constructCollectionType(List.class, DataStatistics.class)); objectMapper.getTypeFactory().constructCollectionType(java.util.List.class, DataStatistics.class));
return Flux.fromIterable(stats); return reactor.core.publisher.Flux.fromIterable(stats);
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to parse historical statistics from cache", e); log.error("Failed to parse historical statistics from cache", e);
return Flux.empty(); return reactor.core.publisher.Flux.empty();
} }
}) })
.switchIfEmpty(buildLiveStatistics(query)); .switchIfEmpty(reactor.core.publisher.Flux.empty());
}
/**
* 缓存未命中时,从数据库实时构建统计数据
*/
private Flux<DataStatistics> buildLiveStatistics(StatisticsQuery query) {
LocalDateTime startTime = getStartTime(query);
LocalDateTime endTime = getEndTime(query);
String periodType = query.getPeriodType() != null ? query.getPeriodType() : "DAY";
LocalDateTime statDate = endTime;
Mono<Long> memberCountMono = dataStatisticsDao.countNewMembers(startTime, endTime);
Mono<Long> totalMembersMono = dataStatisticsDao.countTotalMembers();
Mono<Long> bookingCountMono = dataStatisticsDao.countBookings(startTime, endTime);
Mono<Long> signInCountMono = dataStatisticsDao.countSignIns(startTime, endTime);
return Mono.zip(memberCountMono, totalMembersMono, bookingCountMono, signInCountMono)
.flatMapMany(tuple -> {
long newMembers = tuple.getT1() != null ? tuple.getT1() : 0L;
long totalMembers = tuple.getT2() != null ? tuple.getT2() : 0L;
long bookings = tuple.getT3() != null ? tuple.getT3() : 0L;
long signIns = tuple.getT4() != null ? tuple.getT4() : 0L;
List<DataStatistics> list = new ArrayList<>();
DataStatistics memberStat = DataStatistics.builder()
.statType(DataStatistics.StatType.MEMBER)
.periodType(periodType)
.statDate(statDate)
.count(totalMembers)
.extraData("新增" + newMembers + "")
.build();
list.add(memberStat);
DataStatistics bookingStat = DataStatistics.builder()
.statType(DataStatistics.StatType.BOOKING)
.periodType(periodType)
.statDate(statDate)
.count(bookings)
.extraData("新增" + bookings + "条预约")
.build();
list.add(bookingStat);
DataStatistics signInStat = DataStatistics.builder()
.statType(DataStatistics.StatType.SIGN_IN)
.periodType(periodType)
.statDate(statDate)
.count(signIns)
.extraData("新增" + signIns + "次签到")
.build();
list.add(signInStat);
return Flux.fromIterable(list);
});
} }
@Override @Override
@@ -531,13 +458,16 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
String periodType = query.getPeriodType(); String periodType = query.getPeriodType();
if (DataStatistics.PeriodType.WEEK.equals(periodType)) { if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
// 周统计:本周一
return today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay(); return today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay();
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) { } else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
// 月统计:本月第一天
return today.withDayOfMonth(1).atStartOfDay(); return today.withDayOfMonth(1).atStartOfDay();
} else if (DataStatistics.PeriodType.LAST_30_DAYS.equals(periodType)) {
return today.minusDays(29).atStartOfDay();
} else if (DataStatistics.PeriodType.LAST_90_DAYS.equals(periodType)) {
return today.minusDays(89).atStartOfDay();
} else if (DataStatistics.PeriodType.YEAR.equals(periodType)) {
return today.withDayOfYear(1).atStartOfDay();
} else { } else {
// 日统计:当天零点
return today.atStartOfDay(); return today.atStartOfDay();
} }
} }
@@ -551,13 +481,14 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
String periodType = query.getPeriodType(); String periodType = query.getPeriodType();
if (DataStatistics.PeriodType.WEEK.equals(periodType)) { if (DataStatistics.PeriodType.WEEK.equals(periodType)) {
// 周统计:本周日 23:59:59
return today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).atTime(23, 59, 59); return today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) { } else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
// 月统计:本月最后一天 23:59:59
return today.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59); return today.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.LAST_30_DAYS.equals(periodType)
|| DataStatistics.PeriodType.LAST_90_DAYS.equals(periodType)
|| DataStatistics.PeriodType.YEAR.equals(periodType)) {
return LocalDateTime.now();
} else { } else {
// 日统计:当前时间
return LocalDateTime.now(); return LocalDateTime.now();
} }
} }
@@ -576,6 +507,15 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
} else if (DataStatistics.PeriodType.MONTH.equals(periodType)) { } else if (DataStatistics.PeriodType.MONTH.equals(periodType)) {
startTime = date.withDayOfMonth(1).atStartOfDay(); startTime = date.withDayOfMonth(1).atStartOfDay();
endTime = date.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59); endTime = date.with(TemporalAdjusters.lastDayOfMonth()).atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.LAST_30_DAYS.equals(periodType)) {
startTime = date.minusDays(29).atStartOfDay();
endTime = date.atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.LAST_90_DAYS.equals(periodType)) {
startTime = date.minusDays(89).atStartOfDay();
endTime = date.atTime(23, 59, 59);
} else if (DataStatistics.PeriodType.YEAR.equals(periodType)) {
startTime = date.withDayOfYear(1).atStartOfDay();
endTime = date.atTime(23, 59, 59);
} else { } else {
startTime = date.atStartOfDay(); startTime = date.atStartOfDay();
endTime = date.plusDays(1).atStartOfDay(); endTime = date.plusDays(1).atStartOfDay();
@@ -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();
}
}
+9
View File
@@ -94,11 +94,20 @@
<version>3.5.3</version> <version>3.5.3</version>
</dependency> </dependency>
<!-- 阿里云OSS SDK --> <!-- 阿里云OSS SDK -->
<!--
<dependency> <dependency>
<groupId>com.aliyun.oss</groupId> <groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId> <artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version> <version>3.17.4</version>
</dependency> </dependency>
-->
<!-- 文件管理模块(统一文件存储) -->
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-file</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -1,34 +0,0 @@
package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.groupcourse.entity.BannerEntity;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.repository.Modifying;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
public interface BannerDao extends R2dbcRepository<BannerEntity, Long> {
Mono<BannerEntity> findByIdAndDeletedAtIsNull(Long id);
Flux<BannerEntity> findAllByDeletedAtIsNull();
Flux<BannerEntity> findAllByDeletedAtIsNull(Sort sort);
Flux<BannerEntity> findByIsActiveTrueAndDeletedAtIsNull();
Flux<BannerEntity> findByIsActiveTrueAndDeletedAtIsNull(Sort sort);
@Modifying
@Query("UPDATE banner SET is_active = :isActive, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateActiveStatus(Long id, Boolean isActive, LocalDateTime updatedAt);
@Modifying
@Query("UPDATE banner SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
}
@@ -1,5 +1,6 @@
package cn.novalon.gym.manage.groupcourse.dao; package cn.novalon.gym.manage.groupcourse.dao;
import cn.novalon.gym.manage.common.dto.PageRequest;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto; import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity; import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
@@ -32,79 +33,80 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
@Query("UPDATE group_course SET status = '1', updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL") @Query("UPDATE group_course SET status = '1', updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> cancelCourse(Long id, LocalDateTime updatedAt); Mono<Integer> cancelCourse(Long id, LocalDateTime updatedAt);
@Modifying
@Query("UPDATE group_course SET current_members = current_members + :delta, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateCurrentMembers(Long id, Integer delta, LocalDateTime updatedAt);
@Modifying @Modifying
@Query("UPDATE group_course SET deleted_at = :deletedAt WHERE id = :id") @Query("UPDATE group_course SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt); Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
@Modifying
@Query("UPDATE group_course SET deleted_at = NULL, status = '1', updated_at = :updatedAt WHERE id = :id AND deleted_at IS NOT NULL")
Mono<Integer> restoreCourse(Long id, LocalDateTime updatedAt);
@Modifying @Modifying
@Query("UPDATE group_course SET status = '2', updated_at = :updatedAt WHERE status = '0' AND end_time <= NOW() AND deleted_at IS NULL") @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); Mono<Integer> completeExpiredCourses(LocalDateTime updatedAt);
@Modifying
@Query("UPDATE group_course SET status = '0', current_members = 0, start_time = start_time + INTERVAL '7 days', end_time = end_time + INTERVAL '7 days', updated_at = :updatedAt WHERE is_recurring = TRUE AND status = '2' AND end_time <= NOW() AND deleted_at IS NULL")
Mono<Integer> renewRecurringCourses(LocalDateTime updatedAt);
Flux<GroupCourseEntity> findByCourseTypeAndDeletedAtIsNull(Long courseType); Flux<GroupCourseEntity> findByCourseTypeAndDeletedAtIsNull(Long courseType);
// ==================== 教练相关查询 ====================
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 * 多条件查询团课(使用 DatabaseClient 构建动态 SQL
*/ */
default Flux<GroupCourseEntity> searchGroupCourses(DatabaseClient databaseClient, GroupCourseQueryDto query) { 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<>(); List<String> conditions = new ArrayList<>();
// 默认不查询可预约团课(status = '0' 且未过期) // 默认不查询可预约团课(status = '0' 且未过期)
conditions.add("status = '0'"); conditions.add("gc.status = '0'");
conditions.add("end_time > NOW()"); conditions.add("gc.end_time > NOW()");
// 1. 团课名称模糊查询 // 1. 团课名称模糊查询
if (query.getCourseName() != null && !query.getCourseName().isEmpty()) { if (query.getCourseName() != null && !query.getCourseName().isEmpty()) {
conditions.add("course_name ILIKE :courseName"); conditions.add("gc.course_name ILIKE :courseName");
} }
// 2. 基于团课类型查询 // 2. 基于团课类型查询
if (query.getCourseType() != null) { if (query.getCourseType() != null) {
conditions.add("course_type = :courseType"); conditions.add("gc.course_type = :courseType");
} }
// 3. 基于日期时间段查询 // 3. 基于日期时间段查询
if (query.getStartDate() != null) { if (query.getStartDate() != null) {
conditions.add("start_time >= :startDate"); conditions.add("gc.start_time >= :startDate");
} }
if (query.getEndDate() != null) { if (query.getEndDate() != null) {
conditions.add("start_time <= :endDate"); conditions.add("gc.start_time <= :endDate");
} }
// 4. 基于早晨/下午/夜晚时间段查询 // 4. 基于早晨/下午/夜晚时间段查询
if (query.getTimePeriod() != null && !query.getTimePeriod().isEmpty()) { if (query.getTimePeriod() != null && !query.getTimePeriod().isEmpty()) {
switch (query.getTimePeriod().toLowerCase()) { switch (query.getTimePeriod().toLowerCase()) {
case "morning": 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; break;
case "afternoon": 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; break;
case "evening": 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; break;
default: default:
break; break;
} }
} }
// 5. 常态化团课筛选
if (query.getIsRecurring() != null) {
conditions.add("is_recurring = :isRecurring");
}
sql.append(" AND ").append(String.join(" AND ", conditions)); sql.append(" AND ").append(String.join(" AND ", conditions));
// 5. 价格排序 / 6. 剩余名额最多排序 // 5. 价格排序 / 6. 剩余名额最多排序
@@ -116,20 +118,20 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
List<String> orderClauses = new ArrayList<>(); List<String> orderClauses = new ArrayList<>();
if (hasRemainingMost) { if (hasRemainingMost) {
orderClauses.add(" (max_members - current_members) DESC"); orderClauses.add(" (gc.max_members - COALESCE(b.cnt, 0)) DESC");
} }
if (hasPriceSort) { if (hasPriceSort) {
if ("asc".equalsIgnoreCase(query.getPriceSort())) { if ("asc".equalsIgnoreCase(query.getPriceSort())) {
orderClauses.add(" stored_value_amount ASC"); orderClauses.add(" gc.stored_value_amount ASC");
} else if ("desc".equalsIgnoreCase(query.getPriceSort())) { } else if ("desc".equalsIgnoreCase(query.getPriceSort())) {
orderClauses.add(" stored_value_amount DESC"); orderClauses.add(" gc.stored_value_amount DESC");
} }
} }
sql.append(String.join(",", orderClauses)); sql.append(String.join(",", orderClauses));
} else { } else {
sql.append(" ORDER BY start_time ASC"); sql.append(" ORDER BY gc.start_time ASC");
} }
// 分页 // 分页
@@ -154,36 +156,10 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
if (query.getEndDate() != null) { if (query.getEndDate() != null) {
spec = spec.bind("endDate", query.getEndDate()); spec = spec.bind("endDate", query.getEndDate());
} }
if (query.getIsRecurring() != null) {
spec = spec.bind("isRecurring", query.getIsRecurring());
}
spec = spec.bind("limit", size); spec = spec.bind("limit", size);
spec = spec.bind("offset", offset); spec = spec.bind("offset", offset);
return spec.map((row, meta) -> { return spec.map((row, meta) -> mapRowToEntity(row)).all();
GroupCourseEntity entity = new GroupCourseEntity();
entity.setId(row.get("id", Long.class));
entity.setCourseName(row.get("course_name", String.class));
entity.setCoachId(row.get("coach_id", Long.class));
entity.setCourseType(row.get("course_type", Long.class));
entity.setStartTime(row.get("start_time", LocalDateTime.class));
entity.setEndTime(row.get("end_time", LocalDateTime.class));
entity.setMaxMembers(row.get("max_members", Integer.class));
entity.setCurrentMembers(row.get("current_members", Integer.class));
String statusStr = row.get("status", String.class);
entity.setStatus(statusStr != null ? Long.parseLong(statusStr) : null);
entity.setLocation(row.get("location", String.class));
entity.setCoverImage(row.get("cover_image", String.class));
entity.setDescription(row.get("description", String.class));
entity.setStoredValueAmount(row.get("stored_value_amount", java.math.BigDecimal.class));
entity.setCreateBy(row.get("create_by", String.class));
entity.setUpdateBy(row.get("update_by", String.class));
entity.setCreatedAt(row.get("created_at", LocalDateTime.class));
entity.setUpdatedAt(row.get("updated_at", LocalDateTime.class));
entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class));
entity.setIsRecurring(row.get("is_recurring", Boolean.class));
return entity;
}).all();
} }
/** /**
@@ -224,10 +200,6 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
} }
} }
if (query.getIsRecurring() != null) {
conditions.add("is_recurring = :isRecurring");
}
sql.append(" AND ").append(String.join(" AND ", conditions)); sql.append(" AND ").append(String.join(" AND ", conditions));
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString()); DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
@@ -244,10 +216,124 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
if (query.getEndDate() != null) { if (query.getEndDate() != null) {
spec = spec.bind("endDate", query.getEndDate()); spec = spec.bind("endDate", query.getEndDate());
} }
if (query.getIsRecurring() != null) {
spec = spec.bind("isRecurring", query.getIsRecurring()); return spec.map((row, meta) -> row.get(0, Long.class)).one();
}
/**
* 分页查询团课(支持 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(); 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;
}
} }
@@ -29,8 +29,4 @@ public interface GroupCourseTypeDao extends R2dbcRepository<GroupCourseTypeEntit
@Modifying @Modifying
@Query("UPDATE group_course_type SET deleted_at = :deletedAt WHERE id = :id") @Query("UPDATE group_course_type SET deleted_at = :deletedAt WHERE id = :id")
Mono<Integer> softDelete(Long id, LocalDateTime deletedAt); Mono<Integer> softDelete(Long id, LocalDateTime deletedAt);
@Modifying
@Query("UPDATE group_course_type SET type_name = :typeName, base_difficulty = :baseDifficulty, description = :description, category = :category, updated_at = :updatedAt WHERE id = :id AND deleted_at IS NULL")
Mono<Integer> updateFields(Long id, String typeName, Integer baseDifficulty, String description, String category, LocalDateTime updatedAt);
} }
@@ -1,43 +0,0 @@
package cn.novalon.gym.manage.groupcourse.domain;
import cn.novalon.gym.manage.sys.core.domain.BaseDomain;
import io.swagger.v3.oas.annotations.media.Schema;
public class Banner extends BaseDomain {
@Schema(description = "背景图URL", example = "https://example.com/banner.jpg")
private String imageUrl;
@Schema(description = "主标题", example = "突破自我")
private String title;
@Schema(description = "副标题", example = "超越极限")
private String subtitle;
@Schema(description = "简介", example = "科学训练 · 遇见更好的自己")
private String description;
@Schema(description = "排序(数值越大越靠前)", example = "10")
private Integer sortOrder;
@Schema(description = "是否启用", example = "true")
private Boolean isActive;
public String getImageUrl() { return imageUrl; }
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getSubtitle() { return subtitle; }
public void setSubtitle(String subtitle) { this.subtitle = subtitle; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Integer getSortOrder() { return sortOrder; }
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
public Boolean getIsActive() { return isActive; }
public void setIsActive(Boolean isActive) { this.isActive = isActive; }
}
@@ -60,10 +60,6 @@ public class GroupCourse extends BaseDomain{
@Schema(description = "二维码路径", example = "D:\\Games\\exmp\\image\\abc123_20260618120000.png") @Schema(description = "二维码路径", example = "D:\\Games\\exmp\\image\\abc123_20260618120000.png")
private String qrCodePath; private String qrCodePath;
//是否常态化团课
@Schema(description = "是否常态化团课", example = "true")
private Boolean isRecurring;
public String getCourseName() { public String getCourseName() {
return courseName; return courseName;
} }
@@ -167,12 +163,4 @@ public class GroupCourse extends BaseDomain{
public void setQrCodePath(String qrCodePath) { public void setQrCodePath(String qrCodePath) {
this.qrCodePath = qrCodePath; this.qrCodePath = qrCodePath;
} }
public Boolean getIsRecurring() {
return isRecurring;
}
public void setIsRecurring(Boolean isRecurring) {
this.isRecurring = isRecurring;
}
} }
@@ -53,10 +53,6 @@ public class GroupCourseBooking extends BaseDomain {
@Schema(description = "上课地点", example = "健身房A区") @Schema(description = "上课地点", example = "健身房A区")
private String location; private String location;
//封面图URL(非DB字段,由 Service 从 GroupCourse 填充)
@Schema(description = "封面图URL", example = "https://example.com/cover.jpg")
private String coverImage;
public Long getCourseId() { public Long getCourseId() {
return courseId; return courseId;
} }
@@ -136,12 +132,4 @@ public class GroupCourseBooking extends BaseDomain {
public void setLocation(String location) { public void setLocation(String location) {
this.location = location; this.location = location;
} }
public String getCoverImage() {
return coverImage;
}
public void setCoverImage(String coverImage) {
this.coverImage = coverImage;
}
} }
@@ -21,6 +21,9 @@ public class GroupCourseDetail extends BaseDomain {
@Schema(description = "教练ID", example = "1") @Schema(description = "教练ID", example = "1")
private Long coachId; private Long coachId;
@Schema(description = "教练名称", example = "张教练")
private String coachName;
@Schema(description = "课程类型ID", example = "1") @Schema(description = "课程类型ID", example = "1")
private Long courseType; private Long courseType;
@@ -99,6 +102,14 @@ public class GroupCourseDetail extends BaseDomain {
this.coachId = coachId; this.coachId = coachId;
} }
public String getCoachName() {
return coachName;
}
public void setCoachName(String coachName) {
this.coachName = coachName;
}
public Long getCourseType() { public Long getCourseType() {
return courseType; return courseType;
} }
@@ -40,9 +40,6 @@ public class GroupCourseQueryDto {
@Schema(description = "每页大小", example = "10") @Schema(description = "每页大小", example = "10")
private Integer size = 10; private Integer size = 10;
@Schema(description = "是否常态化团课筛选:null-不过滤, true-仅常态化, false-仅非常态化", example = "true")
private Boolean isRecurring;
// ===== Getters and Setters ===== // ===== Getters and Setters =====
public String getCourseName() { public String getCourseName() {
@@ -116,12 +113,4 @@ public class GroupCourseQueryDto {
public void setSize(Integer size) { public void setSize(Integer size) {
this.size = size; this.size = size;
} }
public Boolean getIsRecurring() {
return isRecurring;
}
public void setIsRecurring(Boolean isRecurring) {
this.isRecurring = isRecurring;
}
} }
@@ -1,45 +0,0 @@
package cn.novalon.gym.manage.groupcourse.entity;
import cn.novalon.gym.manage.db.entity.BaseEntity;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
@Table("banner")
public class BannerEntity extends BaseEntity {
@Column("image_url")
private String imageUrl;
@Column("title")
private String title;
@Column("subtitle")
private String subtitle;
@Column("description")
private String description;
@Column("sort_order")
private Integer sortOrder;
@Column("is_active")
private Boolean isActive;
public String getImageUrl() { return imageUrl; }
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getSubtitle() { return subtitle; }
public void setSubtitle(String subtitle) { this.subtitle = subtitle; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Integer getSortOrder() { return sortOrder; }
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
public Boolean getIsActive() { return isActive; }
public void setIsActive(Boolean isActive) { this.isActive = isActive; }
}
@@ -62,10 +62,6 @@ public class GroupCourseEntity extends BaseEntity {
@Column("qr_code_path") @Column("qr_code_path")
private String qrCodePath; private String qrCodePath;
//是否常态化团课
@Column("is_recurring")
private Boolean isRecurring;
public String getCourseName() { public String getCourseName() {
return courseName; return courseName;
} }
@@ -169,12 +165,4 @@ public class GroupCourseEntity extends BaseEntity {
public void setQrCodePath(String qrCodePath) { public void setQrCodePath(String qrCodePath) {
this.qrCodePath = qrCodePath; this.qrCodePath = qrCodePath;
} }
public Boolean getIsRecurring() {
return isRecurring;
}
public void setIsRecurring(Boolean isRecurring) {
this.isRecurring = isRecurring;
}
} }
@@ -1,224 +0,0 @@
package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.domain.Banner;
import cn.novalon.gym.manage.groupcourse.service.IBannerService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.Map;
@Component
@Tag(name = "轮播图管理", description = "轮播图相关操作")
public class BannerHandler {
private final IBannerService bannerService;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public BannerHandler(IBannerService bannerService,
ISysUserService sysUserService,
AuthUtil authUtil) {
this.bannerService = bannerService;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
}
@Operation(summary = "获取所有轮播图", description = "获取系统中所有轮播图列表,支持按排序字段排序")
public Mono<ServerResponse> getAllBanners(ServerRequest request) {
String sortBy = request.queryParam("sortBy").orElse("sortOrder");
String sortOrder = request.queryParam("sortOrder").orElse("desc");
return ServerResponse.ok()
.body(bannerService.findAll(sortBy, sortOrder), Banner.class);
}
@Operation(summary = "获取所有启用的轮播图", description = "获取系统中所有已启用的轮播图列表")
public Mono<ServerResponse> getAllActiveBanners(ServerRequest request) {
return ServerResponse.ok()
.body(bannerService.findAllActive(), Banner.class);
}
@Operation(summary = "根据ID获取轮播图", description = "根据ID获取轮播图详情")
public Mono<ServerResponse> getBannerById(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return bannerService.findById(id)
.flatMap(banner -> ServerResponse.ok().bodyValue(banner))
.switchIfEmpty(ServerResponse.notFound().build());
}
@Operation(summary = "创建轮播图", description = "创建新的轮播图记录")
public Mono<ServerResponse> createBanner(ServerRequest request) {
return request.bodyToMono(Banner.class)
.flatMap(banner -> {
if (banner.getImageUrl() == null || banner.getImageUrl().isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "背景图不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
if (banner.getTitle() == null || banner.getTitle().isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "主标题不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return bannerService.create(banner)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "轮播图创建成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "更新轮播图", description = "更新指定轮播图信息,需验证管理员密码")
public Mono<ServerResponse> updateBanner(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return request.bodyToMono(Banner.class)
.flatMap(banner -> bannerService.update(id, banner)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "轮播图更新成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
}));
});
}
@Operation(summary = "删除轮播图", description = "删除指定轮播图(软删除),需验证管理员密码")
public Mono<ServerResponse> deleteBanner(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return bannerService.delete(id)
.then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "轮播图删除成功");
return ServerResponse.ok().bodyValue(response);
}))
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
@Operation(summary = "启用轮播图", description = "启用指定轮播图")
public Mono<ServerResponse> enableBanner(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return bannerService.enable(id)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "轮播图启用成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
}
@Operation(summary = "禁用轮播图", description = "禁用指定轮播图,需验证管理员密码")
public Mono<ServerResponse> disableBanner(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return bannerService.disable(id)
.flatMap(r -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "轮播图禁用成功");
response.put("data", r);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
}
}
@@ -2,8 +2,6 @@ package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.service.impl.GroupCourseRedisService;
import cn.novalon.gym.manage.member.entity.MemberCard; import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord; import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.enums.MemberCardType; import cn.novalon.gym.manage.member.enums.MemberCardType;
@@ -34,11 +32,9 @@ import java.util.List;
public class BookingSagaHandler { public class BookingSagaHandler {
private final IGroupCourseBookingRepository bookingRepository; private final IGroupCourseBookingRepository bookingRepository;
private final IGroupCourseRepository courseRepository;
private final IMemberCardRecordService memberCardRecordService; private final IMemberCardRecordService memberCardRecordService;
private final IMemberStoredCardService memberStoredCardService; private final IMemberStoredCardService memberStoredCardService;
private final MemberCardRepository memberCardRepository; private final MemberCardRepository memberCardRepository;
private final GroupCourseRedisService redisService;
private static final double DEFAULT_GROUP_COURSE_PRICE = 50.0; private static final double DEFAULT_GROUP_COURSE_PRICE = 50.0;
@@ -72,24 +68,6 @@ public class BookingSagaHandler {
steps.add(step2); steps.add(step2);
rollbackSteps.add(0, step2); rollbackSteps.add(0, step2);
// 步骤3:更新课程当前人数
SagaStep step3 = new SagaStep(
"更新课程当前人数",
incrementCourseCurrentMembers(booking.getCourseId()),
Mono.defer(() -> decrementCourseCurrentMembers(booking.getCourseId()))
);
steps.add(step3);
rollbackSteps.add(0, step3);
// 步骤4:更新Redis预约计数
SagaStep step4 = new SagaStep(
"更新Redis预约计数",
incrementRedisBookingCount(booking.getCourseId()),
Mono.defer(() -> decrementRedisBookingCount(booking.getCourseId()))
);
steps.add(step4);
rollbackSteps.add(0, step4);
return executeSaga(steps, rollbackSteps) return executeSaga(steps, rollbackSteps)
.then(Mono.just(booking)); .then(Mono.just(booking));
} }
@@ -225,24 +203,6 @@ public class BookingSagaHandler {
steps.add(step2); steps.add(step2);
rollbackSteps.add(0, step2); rollbackSteps.add(0, step2);
// 步骤3:减少课程当前人数
SagaStep step3 = new SagaStep(
"减少课程当前人数",
decrementCourseCurrentMembers(courseId),
Mono.defer(() -> incrementCourseCurrentMembers(courseId))
);
steps.add(step3);
rollbackSteps.add(0, step3);
// 步骤4:更新Redis预约计数
SagaStep step4 = new SagaStep(
"更新Redis预约计数",
decrementRedisBookingCount(courseId),
Mono.defer(() -> incrementRedisBookingCount(courseId))
);
steps.add(step4);
rollbackSteps.add(0, step4);
return executeSaga(steps, rollbackSteps) return executeSaga(steps, rollbackSteps)
.then(bookingRepository.findById(bookingId)); .then(bookingRepository.findById(bookingId));
} }
@@ -301,22 +261,6 @@ public class BookingSagaHandler {
}); });
} }
private Mono<Void> incrementCourseCurrentMembers(Long courseId) {
return courseRepository.updateCurrentMembers(courseId, 1).then();
}
private Mono<Void> decrementCourseCurrentMembers(Long courseId) {
return courseRepository.updateCurrentMembers(courseId, -1).then();
}
private Mono<Void> incrementRedisBookingCount(Long courseId) {
return redisService.incrementBookingCount(courseId).then();
}
private Mono<Void> decrementRedisBookingCount(Long courseId) {
return redisService.decrementBookingCount(courseId).then();
}
private Mono<Void> executeSaga(List<SagaStep> steps, List<SagaStep> rollbackSteps) { private Mono<Void> executeSaga(List<SagaStep> steps, List<SagaStep> rollbackSteps) {
List<SagaStep> completedSteps = new ArrayList<>(); List<SagaStep> completedSteps = new ArrayList<>();
return executeStep(steps, 0, completedSteps); return executeStep(steps, 0, completedSteps);
@@ -1,120 +0,0 @@
package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.util.OSSUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.http.codec.multipart.Part;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 通用文件上传处理器
*/
@Slf4j
@Component
@Tag(name = "文件上传", description = "通用文件上传到阿里云OSS")
public class CommonUploadHandler {
@Operation(summary = "上传图片文件", description = "上传图片到阿里云OSS,返回ossKey和预签名URL")
public Mono<ServerResponse> uploadImage(ServerRequest request) {
return request.multipartData()
.flatMap(multiValueMap -> {
List<Part> fileParts = multiValueMap.get("file");
if (fileParts == null || fileParts.isEmpty()) {
return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "未选择文件"));
}
Part part = fileParts.get(0);
if (!(part instanceof FilePart filePart)) {
return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "文件格式不正确"));
}
String originalFilename = filePart.filename();
String ext = "";
int dotIndex = originalFilename.lastIndexOf('.');
if (dotIndex > 0) {
ext = originalFilename.substring(dotIndex);
}
String newFileName = UUID.randomUUID().toString().replace("-", "") + ext;
Path tempFile = null;
try {
tempFile = Files.createTempFile("upload-", newFileName);
} catch (Exception e) {
return Mono.error(new RuntimeException("创建临时文件失败", e));
}
Path finalTempFile = tempFile;
return filePart.transferTo(finalTempFile)
.then(Mono.defer(() -> {
try {
String ossKey;
try (InputStream inputStream = Files.newInputStream(finalTempFile)) {
ossKey = OSSUtil.uploadCoverToOSS(inputStream, newFileName);
}
String presignedUrl = OSSUtil.generatePresignedUrl(ossKey);
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of(
"code", 200,
"message", "上传成功",
"data", Map.of(
"ossKey", ossKey,
"presignedUrl", presignedUrl,
"fileName", originalFilename
)
));
} catch (Exception e) {
return ServerResponse.status(500)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 500, "message", "上传失败: " + e.getMessage()));
} finally {
try {
Files.deleteIfExists(finalTempFile);
} catch (Exception ignored) {
}
}
}));
});
}
@Operation(summary = "获取OSS预签名URL", description = "根据ossKey生成临时访问URL,有效期5分钟")
public Mono<ServerResponse> presignUrl(ServerRequest request) {
String ossKey = request.queryParam("key").orElse(null);
if (ossKey == null || ossKey.isBlank()) {
return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "参数 key 不能为空"));
}
try {
String presignedUrl = OSSUtil.generatePresignedUrl(ossKey);
return ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of(
"code", 200,
"data", Map.of("presignedUrl", presignedUrl)
));
} catch (Exception e) {
return ServerResponse.status(500)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 500, "message", "生成预签名URL失败: " + e.getMessage()));
}
}
}
@@ -1,5 +1,6 @@
package cn.novalon.gym.manage.groupcourse.handler; 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.domain.CourseLabel;
import cn.novalon.gym.manage.groupcourse.service.ICourseLabelService; import cn.novalon.gym.manage.groupcourse.service.ICourseLabelService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -29,6 +30,13 @@ public class CourseLabelHandler {
.body(courseLabelService.findAll(), CourseLabel.class); .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获取标签详情") @Operation(summary = "根据ID获取标签", description = "根据ID获取标签详情")
public Mono<ServerResponse> getLabelById(ServerRequest request) { public Mono<ServerResponse> getLabelById(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
@@ -146,24 +154,17 @@ public class CourseLabelHandler {
return request.bodyToMono(Map.class) return request.bodyToMono(Map.class)
.flatMap(body -> { .flatMap(body -> {
Object labelIdsObj = body.get("labelIds"); @SuppressWarnings("unchecked")
List<?> rawIds = (List<?>) body.get("labelIds");
if (!(labelIdsObj instanceof List)) { if (rawIds == null || rawIds.isEmpty()) {
Map<String, Object> error = new HashMap<>(); Map<String, Object> error = new HashMap<>();
error.put("success", false); error.put("success", false);
error.put("message", "labelIds不能为空"); error.put("message", "labelIds不能为空");
return ServerResponse.badRequest().bodyValue(error); return ServerResponse.badRequest().bodyValue(error);
} }
List<?> rawList = (List<?>) labelIdsObj; List<Long> labelIds = rawIds.stream()
if (rawList.isEmpty()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "labelIds不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
List<Long> labelIds = rawList.stream()
.map(id -> Long.valueOf(String.valueOf(id))) .map(id -> Long.valueOf(String.valueOf(id)))
.collect(java.util.stream.Collectors.toList()); .collect(java.util.stream.Collectors.toList());
@@ -2,7 +2,6 @@ package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
import cn.novalon.gym.manage.member.service.IMemberStoredCardService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -24,12 +23,9 @@ import java.util.Map;
public class GroupCourseBookingHandler { public class GroupCourseBookingHandler {
private final IGroupCourseBookingService bookingService; private final IGroupCourseBookingService bookingService;
private final IMemberStoredCardService memberStoredCardService;
public GroupCourseBookingHandler(IGroupCourseBookingService bookingService, public GroupCourseBookingHandler(IGroupCourseBookingService bookingService) {
IMemberStoredCardService memberStoredCardService) {
this.bookingService = bookingService; this.bookingService = bookingService;
this.memberStoredCardService = memberStoredCardService;
} }
/** /**
@@ -46,25 +42,11 @@ public class GroupCourseBookingHandler {
if (body.get("memberId") == null) { if (body.get("memberId") == null) {
return buildErrorResponse("请提供会员ID"); return buildErrorResponse("请提供会员ID");
} }
if (body.get("memberCardRecordId") == null) {
return buildErrorResponse("请提供会员卡记录ID");
}
if (body.get("payPassword") == null || body.get("payPassword").toString().isEmpty()) {
return buildErrorResponse("请输入支付密码");
}
Long courseId = toLong(body.get("courseId"), "courseId"); Long courseId = toLong(body.get("courseId"), "courseId");
Long memberId = toLong(body.get("memberId"), "memberId"); Long memberId = toLong(body.get("memberId"), "memberId");
Long memberCardRecordId = toLong(body.get("memberCardRecordId"), "memberCardRecordId");
String payPassword = body.get("payPassword").toString();
// 验证支付密码 return bookingService.bookCourse(courseId, memberId)
return memberStoredCardService.verifyPayPassword(memberId, payPassword)
.flatMap(passwordValid -> {
if (!passwordValid) {
return buildErrorResponse("支付密码错误");
}
return bookingService.bookCourse(courseId, memberId, memberCardRecordId)
.flatMap(booking -> { .flatMap(booking -> {
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
response.put("success", true); response.put("success", true);
@@ -79,7 +61,6 @@ public class GroupCourseBookingHandler {
return ServerResponse.badRequest().bodyValue(response); return ServerResponse.badRequest().bodyValue(response);
}); });
}); });
});
} }
/** /**
@@ -92,17 +73,7 @@ public class GroupCourseBookingHandler {
return request.bodyToMono(Map.class) return request.bodyToMono(Map.class)
.flatMap(body -> { .flatMap(body -> {
Long memberId = toLong(body.get("memberId"), "memberId"); Long memberId = toLong(body.get("memberId"), "memberId");
if (body.get("payPassword") == null || body.get("payPassword").toString().isEmpty()) {
return buildErrorResponse("请输入支付密码");
}
String payPassword = body.get("payPassword").toString();
// 验证支付密码
return memberStoredCardService.verifyPayPassword(memberId, payPassword)
.flatMap(passwordValid -> {
if (!passwordValid) {
return buildErrorResponse("支付密码错误");
}
return bookingService.cancelBooking(bookingId, memberId) return bookingService.cancelBooking(bookingId, memberId)
.flatMap(booking -> { .flatMap(booking -> {
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
@@ -118,7 +89,6 @@ public class GroupCourseBookingHandler {
return ServerResponse.badRequest().bodyValue(response); return ServerResponse.badRequest().bodyValue(response);
}); });
}); });
});
} }
/** /**
@@ -177,31 +147,4 @@ public class GroupCourseBookingHandler {
response.put("message", message); response.put("message", message);
return ServerResponse.badRequest().bodyValue(response); return ServerResponse.badRequest().bodyValue(response);
} }
/**
* 扫码签到(二维码扫一扫签到)
* 用户扫描团课签到二维码后,更新预约记录状态为 2(已出席)
*/
@Operation(summary = "扫码签到", description = "用户扫描团课二维码签到,更新预约状态为已出席")
public Mono<ServerResponse> qrSignIn(ServerRequest request) {
Long courseId = Long.valueOf(request.pathVariable("courseId"));
return request.bodyToMono(Map.class)
.flatMap(body -> {
if (body.get("memberId") == null) {
return buildErrorResponse("请提供会员ID");
}
Long memberId = toLong(body.get("memberId"), "memberId");
return bookingService.qrSignIn(courseId, memberId)
.flatMap(booking -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "签到成功");
response.put("data", booking);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> buildErrorResponse(error.getMessage()));
});
}
} }
@@ -7,57 +7,48 @@ import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto; import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService; import cn.novalon.gym.manage.groupcourse.vo.GroupCourseVO;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Validator; import jakarta.validation.Validator;
import org.springframework.http.HttpStatus; import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.io.ByteArrayOutputStream;
import java.util.Base64;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.time.LocalDateTime;
@Component @Component
@Tag(name="团课管理",description = "团课相关操作") @Tag(name="团课管理",description = "团课相关操作")
public class GroupCourseHandler { public class GroupCourseHandler {
private static final Logger logger = LoggerFactory.getLogger(GroupCourseHandler.class);
private final IGroupCourseService groupCourseService; private final IGroupCourseService groupCourseService;
private final Validator validator; private final Validator validator;
private final RedisUtil redisUtil; private final RedisUtil redisUtil;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public GroupCourseHandler(IGroupCourseService groupCourseService, public GroupCourseHandler(IGroupCourseService groupCourseService,
Validator validator, Validator validator,
RedisUtil redisUtil, RedisUtil redisUtil,
ObjectMapper objectMapper, ObjectMapper objectMapper){
ISysUserService sysUserService,
AuthUtil authUtil){
this.groupCourseService = groupCourseService; this.groupCourseService = groupCourseService;
this.validator = validator; this.validator = validator;
this.redisUtil = redisUtil; this.redisUtil = redisUtil;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
} }
@Operation(summary = "获取所有团课", description = "获取系统中所有团课列表") @Operation(summary = "获取所有团课", description = "获取系统中所有团课列表(含教练昵称)")
public Mono<ServerResponse> getAllGroupCourse(ServerRequest request){ public Mono<ServerResponse> getAllGroupCourse(ServerRequest request){
boolean includeDeleted = Boolean.valueOf(request.queryParam("includeDeleted").orElse("false")); boolean includeDeleted = Boolean.valueOf(request.queryParam("includeDeleted").orElse("false"));
return ServerResponse.ok() return groupCourseService.findAllAsVO(includeDeleted)
.body(groupCourseService.findAll(includeDeleted), GroupCourse.class); .collectList()
.flatMap(list -> ServerResponse.ok().bodyValue(list));
} }
@Operation(summary = "分页获取团课", description = "根据分页参数获取团课列表") @Operation(summary = "分页获取团课", description = "根据分页参数获取团课列表")
@@ -81,7 +72,7 @@ public class GroupCourseHandler {
pageRequest.setOrder("asc"); pageRequest.setOrder("asc");
} }
return groupCourseService.findByPage(pageRequest, includeDeleted) return groupCourseService.findByPageAsVO(pageRequest, includeDeleted)
.flatMap(response -> ServerResponse.ok().bodyValue(response)); .flatMap(response -> ServerResponse.ok().bodyValue(response));
}); });
} }
@@ -130,27 +121,10 @@ public class GroupCourseHandler {
}); });
} }
@Operation(summary = "更新团课", description = "更新指定团课信息,需验证管理员密码") @Operation(summary = "更新团课", description = "更新指定团课信息")
public Mono<ServerResponse> updateGroupCourse(ServerRequest request) { public Mono<ServerResponse> updateGroupCourse(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return request.bodyToMono(GroupCourse.class) return request.bodyToMono(GroupCourse.class)
.flatMap(groupCourse -> { .flatMap(groupCourse -> {
return groupCourseService.update(id, groupCourse) return groupCourseService.update(id, groupCourse)
@@ -168,7 +142,6 @@ public class GroupCourseHandler {
return ServerResponse.badRequest().bodyValue(response); return ServerResponse.badRequest().bodyValue(response);
}); });
}); });
});
} }
@Operation(summary = "取消团课", description = "取消指定团课(需提前24小时)") @Operation(summary = "取消团课", description = "取消指定团课(需提前24小时)")
@@ -231,27 +204,10 @@ public class GroupCourseHandler {
}); });
} }
@Operation(summary = "删除团课", description = "删除指定团课(软删除),需验证管理员密码") @Operation(summary = "删除团课", description = "删除指定团课(软删除)")
public Mono<ServerResponse> deleteGroupCourse(ServerRequest request) { public Mono<ServerResponse> deleteGroupCourse(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return groupCourseService.delete(id) return groupCourseService.delete(id)
.then(Mono.defer(() -> { .then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
@@ -265,45 +221,6 @@ public class GroupCourseHandler {
response.put("message", error.getMessage()); response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response); return ServerResponse.badRequest().bodyValue(response);
}); });
});
}
@Operation(summary = "恢复已删除团课", description = "将已删除的团课恢复为已取消状态,需验证管理员密码")
public Mono<ServerResponse> restoreGroupCourse(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return groupCourseService.restore(id)
.flatMap(course -> {
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "团课恢复成功");
response.put("data", course);
return ServerResponse.ok().bodyValue(response);
})
.onErrorResume(error -> {
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response);
});
});
} }
@Operation(summary = "多条件查询团课", description = "支持团课名称模糊查询、类型筛选、日期范围、时间段、价格排序、剩余名额排序等多条件组合查询") @Operation(summary = "多条件查询团课", description = "支持团课名称模糊查询、类型筛选、日期范围、时间段、价格排序、剩余名额排序等多条件组合查询")
@@ -380,43 +297,49 @@ public class GroupCourseHandler {
}); });
} }
@Operation(summary = "获取团课签到二维码", description = "根据团课ID生成签到二维码(base64)") @Operation(summary = "检查教练时间冲突", description = "检查教练在指定时间段内是否有时间冲突的团课")
public Mono<ServerResponse> getCourseQRCode(ServerRequest request) { public Mono<ServerResponse> checkCoachConflict(ServerRequest request) {
Long courseId = Long.valueOf(request.pathVariable("id")); 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);
}
return groupCourseService.findById(courseId) Object coachIdObj = body.get("coachId");
.flatMap(course -> { Object startTimeStr = body.get("startTime");
return Mono.fromCallable(() -> { Object endTimeStr = body.get("endTime");
String qrContent = "{\"courseId\":" + course.getId() Object excludeCourseIdObj = body.get("excludeCourseId");
+ ",\"courseName\":\"" + escapeJson(course.getCourseName()) + "\"}";
QRCodeWriter qrCodeWriter = new QRCodeWriter(); if (coachIdObj == null || startTimeStr == null || endTimeStr == null) {
BitMatrix bitMatrix = qrCodeWriter.encode(qrContent, BarcodeFormat.QR_CODE, 300, 300); Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "coachId、startTime、endTime 不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); Long coachId = Long.valueOf(String.valueOf(coachIdObj));
MatrixToImageWriter.writeToStream(bitMatrix, "PNG", outputStream); LocalDateTime startTime = LocalDateTime.parse(String.valueOf(startTimeStr));
byte[] pngBytes = outputStream.toByteArray(); LocalDateTime endTime = LocalDateTime.parse(String.valueOf(endTimeStr));
String base64 = Base64.getEncoder().encodeToString(pngBytes); Long excludeCourseId = excludeCourseIdObj != null ? Long.valueOf(String.valueOf(excludeCourseIdObj)) : null;
Map<String, Object> result = new HashMap<>(); return groupCourseService.checkCoachConflict(coachId, startTime, endTime, excludeCourseId)
result.put("base64", "data:image/png;base64," + base64); .flatMap(conflicts -> {
result.put("courseId", course.getId()); Map<String, Object> response = new HashMap<>();
result.put("courseName", course.getCourseName()); response.put("success", true);
result.put("width", 300); response.put("hasConflict", !conflicts.isEmpty());
result.put("height", 300); response.put("conflicts", conflicts);
return result; return ServerResponse.ok().bodyValue(response);
}).subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic()); });
}) })
.flatMap(result -> ServerResponse.ok().bodyValue(result)) .onErrorResume(error -> {
.switchIfEmpty(ServerResponse.notFound().build()); logger.warn("检查教练时间冲突失败: {}", error.getMessage());
} Map<String, Object> response = new HashMap<>();
response.put("success", false);
private String escapeJson(String s) { response.put("message", "检查失败: " + error.getMessage());
if (s == null) return ""; return ServerResponse.badRequest().bodyValue(response);
return s.replace("\\", "\\\\") });
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
} }
} }
@@ -2,11 +2,8 @@ package cn.novalon.gym.manage.groupcourse.handler;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
@@ -20,15 +17,9 @@ import java.util.Map;
public class GroupCourseRecommendHandler { public class GroupCourseRecommendHandler {
private final IGroupCourseRecommendService recommendService; private final IGroupCourseRecommendService recommendService;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public GroupCourseRecommendHandler(IGroupCourseRecommendService recommendService, public GroupCourseRecommendHandler(IGroupCourseRecommendService recommendService) {
ISysUserService sysUserService,
AuthUtil authUtil) {
this.recommendService = recommendService; this.recommendService = recommendService;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
} }
@Operation(summary = "获取所有团课推荐", description = "获取系统中所有团课推荐列表,支持按优先级排序") @Operation(summary = "获取所有团课推荐", description = "获取系统中所有团课推荐列表,支持按优先级排序")
@@ -89,29 +80,13 @@ public class GroupCourseRecommendHandler {
}); });
} }
@Operation(summary = "更新团课推荐", description = "更新指定团课推荐信息,需验证管理员密码") @Operation(summary = "更新团课推荐", description = "更新指定团课推荐信息")
public Mono<ServerResponse> updateRecommendation(ServerRequest request) { public Mono<ServerResponse> updateRecommendation(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return request.bodyToMono(GroupCourseRecommend.class) return request.bodyToMono(GroupCourseRecommend.class)
.flatMap(recommend -> recommendService.update(id, recommend) .flatMap(recommend -> {
return recommendService.update(id, recommend)
.flatMap(r -> { .flatMap(r -> {
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
response.put("success", true); response.put("success", true);
@@ -124,31 +99,14 @@ public class GroupCourseRecommendHandler {
response.put("success", false); response.put("success", false);
response.put("message", error.getMessage()); response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response); return ServerResponse.badRequest().bodyValue(response);
})); });
}); });
} }
@Operation(summary = "删除团课推荐", description = "删除指定团课推荐(软删除),需验证管理员密码") @Operation(summary = "删除团课推荐", description = "删除指定团课推荐(软删除)")
public Mono<ServerResponse> deleteRecommendation(ServerRequest request) { public Mono<ServerResponse> deleteRecommendation(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return recommendService.delete(id) return recommendService.delete(id)
.then(Mono.defer(() -> { .then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
@@ -162,7 +120,6 @@ public class GroupCourseRecommendHandler {
response.put("message", error.getMessage()); response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response); return ServerResponse.badRequest().bodyValue(response);
}); });
});
} }
@Operation(summary = "启用团课推荐", description = "启用指定团课推荐") @Operation(summary = "启用团课推荐", description = "启用指定团课推荐")
@@ -185,27 +142,10 @@ public class GroupCourseRecommendHandler {
}); });
} }
@Operation(summary = "禁用团课推荐", description = "禁用指定团课推荐,需验证管理员密码") @Operation(summary = "禁用团课推荐", description = "禁用指定团课推荐")
public Mono<ServerResponse> disableRecommendation(ServerRequest request) { public Mono<ServerResponse> disableRecommendation(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return recommendService.disable(id) return recommendService.disable(id)
.flatMap(r -> { .flatMap(r -> {
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
@@ -220,6 +160,5 @@ public class GroupCourseRecommendHandler {
response.put("message", error.getMessage()); response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response); return ServerResponse.badRequest().bodyValue(response);
}); });
});
} }
} }
@@ -1,12 +1,10 @@
package cn.novalon.gym.manage.groupcourse.handler; 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.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
@@ -20,15 +18,9 @@ import java.util.Map;
public class GroupCourseTypeHandler { public class GroupCourseTypeHandler {
private final IGroupCourseTypeService groupCourseTypeService; private final IGroupCourseTypeService groupCourseTypeService;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public GroupCourseTypeHandler(IGroupCourseTypeService groupCourseTypeService, public GroupCourseTypeHandler(IGroupCourseTypeService groupCourseTypeService) {
ISysUserService sysUserService,
AuthUtil authUtil) {
this.groupCourseTypeService = groupCourseTypeService; this.groupCourseTypeService = groupCourseTypeService;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
} }
@Operation(summary = "获取所有团课类型", description = "获取系统中所有团课类型列表") @Operation(summary = "获取所有团课类型", description = "获取系统中所有团课类型列表")
@@ -101,27 +93,10 @@ public class GroupCourseTypeHandler {
}); });
} }
@Operation(summary = "更新团课类型", description = "更新指定团课类型信息,需验证管理员密码") @Operation(summary = "更新团课类型", description = "更新指定团课类型信息")
public Mono<ServerResponse> updateGroupCourseType(ServerRequest request) { public Mono<ServerResponse> updateGroupCourseType(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return request.bodyToMono(GroupCourseType.class) return request.bodyToMono(GroupCourseType.class)
.flatMap(groupCourseType -> { .flatMap(groupCourseType -> {
groupCourseType.setId(id); groupCourseType.setId(id);
@@ -140,30 +115,12 @@ public class GroupCourseTypeHandler {
return ServerResponse.badRequest().bodyValue(response); return ServerResponse.badRequest().bodyValue(response);
}); });
}); });
});
} }
@Operation(summary = "删除团课类型", description = "删除指定团课类型(软删除),需验证管理员密码,且该类型不能被任何团课引用") @Operation(summary = "删除团课类型", description = "删除指定团课类型(软删除)")
public Mono<ServerResponse> deleteGroupCourseType(ServerRequest request) { public Mono<ServerResponse> deleteGroupCourseType(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码不能为空");
return ServerResponse.badRequest().bodyValue(error);
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
Map<String, Object> error = new HashMap<>();
error.put("success", false);
error.put("message", "管理员密码错误");
return ServerResponse.status(HttpStatus.FORBIDDEN).bodyValue(error);
}
return groupCourseTypeService.delete(id) return groupCourseTypeService.delete(id)
.then(Mono.defer(() -> { .then(Mono.defer(() -> {
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
@@ -177,6 +134,13 @@ public class GroupCourseTypeHandler {
response.put("message", error.getMessage()); response.put("message", error.getMessage());
return ServerResponse.badRequest().bodyValue(response); 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))
);
} }
} }
@@ -1,5 +1,6 @@
package cn.novalon.gym.manage.groupcourse.initializer; 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.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil; import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil;
@@ -10,15 +11,12 @@ import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.UUID;
/** /**
* 项目启动时补全缺失的团课二维码 * 项目启动时补全缺失的团课二维码
* 遍历所有未删除的团课,对qrCodePath为空的课程生成二维码并上传至阿里云OSS * 遍历所有未删除的团课,对qrCodePath为空的课程生成二维码并通过统一文件服务保存
*/ */
@Component @Component
public class QrCodeInitializer implements CommandLineRunner { public class QrCodeInitializer implements CommandLineRunner {
@@ -27,11 +25,14 @@ public class QrCodeInitializer implements CommandLineRunner {
private final IGroupCourseRepository groupCourseRepository; private final IGroupCourseRepository groupCourseRepository;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ISysFileService fileService;
public QrCodeInitializer(IGroupCourseRepository groupCourseRepository, public QrCodeInitializer(IGroupCourseRepository groupCourseRepository,
ObjectMapper objectMapper) { ObjectMapper objectMapper,
ISysFileService fileService) {
this.groupCourseRepository = groupCourseRepository; this.groupCourseRepository = groupCourseRepository;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.fileService = fileService;
} }
@Override @Override
@@ -58,20 +59,22 @@ public class QrCodeInitializer implements CommandLineRunner {
String jsonContent = objectMapper.writeValueAsString(qrCodeContent); String jsonContent = objectMapper.writeValueAsString(qrCodeContent);
// 生成二维码并上传到阿里云OSS // 生成二维码字节数组,通过统一文件服务保存
String uuid = UUID.randomUUID().toString().replace("-", ""); return Mono.fromCallable(() -> QRCodeUtil.generateQrCodeBytes(jsonContent))
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); .flatMap(qrCodeBytes -> {
String fileName = "qr_" + uuid + "_" + timestamp + ".png"; String fileName = "qrcode_" + course.getId() + ".png";
String ossUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent, fileName); return fileService.saveBytes(qrCodeBytes, fileName, "image/png", "system");
})
.flatMap(sysFile -> {
String qrCodeUrl = "/api/files/" + sysFile.getId() + "/preview";
course.setQrCodePath(qrCodeUrl);
course.setQrCodePath(ossUrl);
// 更新数据库
return groupCourseRepository.update(course) return groupCourseRepository.update(course)
.doOnSuccess(updated -> logger.info("团课二维码补全成功 - id={}, name={}, ossUrl={}", .doOnSuccess(updated -> logger.info("团课二维码补全成功 - id={}, name={}, url={}",
updated.getId(), updated.getCourseName(), ossUrl)) updated.getId(), updated.getCourseName(), qrCodeUrl))
.doOnError(error -> logger.error("团课二维码补全失败(更新DB) - id={}, name={}, error: {}", .doOnError(error -> logger.error("团课二维码补全失败(更新DB) - id={}, name={}, error: {}",
course.getId(), course.getCourseName(), error.getMessage())); course.getId(), course.getCourseName(), error.getMessage()));
});
} catch (Exception e) { } catch (Exception e) {
logger.error("团课二维码补全失败(生成) - id={}, name={}, error: {}", logger.error("团课二维码补全失败(生成) - id={}, name={}, error: {}",
course.getId(), course.getCourseName(), e.getMessage(), e); course.getId(), course.getCourseName(), e.getMessage(), e);
@@ -1,24 +0,0 @@
package cn.novalon.gym.manage.groupcourse.repository;
import cn.novalon.gym.manage.groupcourse.domain.Banner;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface IBannerRepository {
Mono<Banner> findById(Long id);
Flux<Banner> findAll();
Flux<Banner> findAll(String sortBy, String sortOrder);
Flux<Banner> findAllActive();
Mono<Banner> save(Banner banner);
Mono<Banner> update(Banner banner);
Mono<Void> deleteById(Long id);
Mono<Banner> updateActiveStatus(Long id, Boolean isActive);
}
@@ -1,5 +1,7 @@
package cn.novalon.gym.manage.groupcourse.repository; 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 cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -29,4 +31,6 @@ public interface ICourseLabelRepository {
Mono<Void> removeLabelFromType(Long typeId, Long labelId); Mono<Void> removeLabelFromType(Long typeId, Long labelId);
Mono<Void> clearLabelsFromType(Long typeId); Mono<Void> clearLabelsFromType(Long typeId);
Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest);
} }
@@ -27,11 +27,14 @@ public interface IGroupCourseRepository {
Mono<Void> deleteById(Long id); Mono<Void> deleteById(Long id);
Mono<GroupCourse> restoreById(Long id);
Mono<GroupCourse> updateCurrentMembers(Long id, Integer delta);
Flux<GroupCourse> findByCourseType(Long courseType); Flux<GroupCourse> findByCourseType(Long courseType);
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query); 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);
} }
@@ -1,5 +1,7 @@
package cn.novalon.gym.manage.groupcourse.repository; 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 cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -25,4 +27,6 @@ public interface IGroupCourseTypeRepository {
Mono<GroupCourseType> update(GroupCourseType groupCourseType); Mono<GroupCourseType> update(GroupCourseType groupCourseType);
Mono<Void> deleteById(Long id); Mono<Void> deleteById(Long id);
Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest);
} }
@@ -1,113 +0,0 @@
package cn.novalon.gym.manage.groupcourse.repository.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.novalon.gym.manage.groupcourse.dao.BannerDao;
import cn.novalon.gym.manage.groupcourse.domain.Banner;
import cn.novalon.gym.manage.groupcourse.entity.BannerEntity;
import cn.novalon.gym.manage.groupcourse.repository.IBannerRepository;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
@Repository
public class BannerRepository implements IBannerRepository {
private final BannerDao bannerDao;
private final R2dbcEntityTemplate r2dbcEntityTemplate;
public BannerRepository(BannerDao bannerDao, R2dbcEntityTemplate r2dbcEntityTemplate) {
this.bannerDao = bannerDao;
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
}
@Override
public Mono<Banner> findById(Long id) {
return bannerDao.findByIdAndDeletedAtIsNull(id)
.map(this::toDomain);
}
@Override
public Flux<Banner> findAll() {
return bannerDao.findAllByDeletedAtIsNull()
.map(this::toDomain);
}
@Override
public Flux<Banner> findAll(String sortBy, String sortOrder) {
Sort.Direction direction = "asc".equalsIgnoreCase(sortOrder)
? Sort.Direction.ASC
: Sort.Direction.DESC;
Sort sort = Sort.by(direction, sortBy);
return bannerDao.findAllByDeletedAtIsNull(sort)
.map(this::toDomain);
}
@Override
public Flux<Banner> findAllActive() {
return bannerDao.findByIsActiveTrueAndDeletedAtIsNull(Sort.by(Sort.Direction.DESC, "sortOrder"))
.map(this::toDomain);
}
@Override
public Mono<Banner> save(Banner banner) {
BannerEntity entity = toEntity(banner);
entity.setCreatedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now());
if (entity.getSortOrder() == null) {
entity.setSortOrder(0);
}
if (entity.getIsActive() == null) {
entity.setIsActive(true);
}
return bannerDao.save(entity)
.map(this::toDomain);
}
@Override
public Mono<Banner> update(Banner banner) {
BannerEntity entity = toEntity(banner);
entity.setUpdatedAt(LocalDateTime.now());
return r2dbcEntityTemplate.update(entity)
.then(findById(banner.getId()));
}
@Override
public Mono<Void> deleteById(Long id) {
return bannerDao.softDelete(id, LocalDateTime.now())
.then();
}
@Override
public Mono<Banner> updateActiveStatus(Long id, Boolean isActive) {
return bannerDao.updateActiveStatus(id, isActive, LocalDateTime.now())
.flatMap(updated -> {
if (updated > 0) {
return findById(id);
}
return Mono.empty();
});
}
private Banner toDomain(BannerEntity entity) {
if (entity == null) return null;
Banner banner = new Banner();
BeanUtil.copyProperties(entity, banner);
return banner;
}
private BannerEntity toEntity(Banner domain) {
if (domain == null) return null;
BannerEntity entity = new BannerEntity();
BeanUtil.copyProperties(domain, entity);
if (domain.getId() != null) {
entity.markNotNew();
}
return entity;
}
}
@@ -1,5 +1,7 @@
package cn.novalon.gym.manage.groupcourse.repository.impl; 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.converter.GroupCourseConverter;
import cn.novalon.gym.manage.groupcourse.dao.CourseLabelDao; import cn.novalon.gym.manage.groupcourse.dao.CourseLabelDao;
import cn.novalon.gym.manage.groupcourse.dao.CourseTypeLabelDao; 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 cn.novalon.gym.manage.groupcourse.repository.ICourseLabelRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; 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.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List; import java.util.List;
@Repository @Repository
@@ -26,12 +33,14 @@ public class CourseLabelRepository implements ICourseLabelRepository {
private final CourseLabelDao courseLabelDao; private final CourseLabelDao courseLabelDao;
private final CourseTypeLabelDao courseTypeLabelDao; private final CourseTypeLabelDao courseTypeLabelDao;
private final GroupCourseConverter converter; private final GroupCourseConverter converter;
private final R2dbcEntityTemplate r2dbcEntityTemplate;
public CourseLabelRepository(CourseLabelDao courseLabelDao, CourseTypeLabelDao courseTypeLabelDao, public CourseLabelRepository(CourseLabelDao courseLabelDao, CourseTypeLabelDao courseTypeLabelDao,
GroupCourseConverter converter) { GroupCourseConverter converter, R2dbcEntityTemplate r2dbcEntityTemplate) {
this.courseLabelDao = courseLabelDao; this.courseLabelDao = courseLabelDao;
this.courseTypeLabelDao = courseTypeLabelDao; this.courseTypeLabelDao = courseTypeLabelDao;
this.converter = converter; this.converter = converter;
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
} }
@Override @Override
@@ -158,4 +167,41 @@ public class CourseLabelRepository implements ICourseLabelRepository {
} }
return entity; 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);
});
}
} }
@@ -97,38 +97,20 @@ public class GroupCourseRepository implements IGroupCourseRepository {
public Mono<PageResponse<GroupCourse>> findByPageAndNotDeleted(PageRequest pageRequest) { public Mono<PageResponse<GroupCourse>> findByPageAndNotDeleted(PageRequest pageRequest) {
int page = pageRequest.getPage(); int page = pageRequest.getPage();
int size = pageRequest.getSize(); int size = pageRequest.getSize();
String sort = pageRequest.getSort();
String order = pageRequest.getOrder();
Sort sortObj = Sort.unsorted(); return groupCourseDao.countByPageFiltered(r2dbcEntityTemplate.getDatabaseClient(), pageRequest)
if (sort != null && !sort.isEmpty()) { .flatMap(total -> {
sortObj = Sort.by(Sort.Direction.fromString(order), sort); if (total == 0) {
return Mono.just(new PageResponse<>(List.of(), 0, 0L, page, size));
} }
return groupCourseDao.findByPageFiltered(r2dbcEntityTemplate.getDatabaseClient(), pageRequest)
org.springframework.data.domain.PageRequest pageable = org.springframework.data.domain.PageRequest.of(page, size, sortObj);
return groupCourseDao.findAllByDeletedAtIsNull(sortObj)
.collectList()
.zipWith(groupCourseDao.findAllByDeletedAtIsNull().count())
.map(tuple -> {
List<GroupCourseEntity> allEntities = tuple.getT1();
long total = tuple.getT2();
int fromIndex = page * size;
int toIndex = Math.min(fromIndex + size, allEntities.size());
List<GroupCourse> courseList;
if (fromIndex < allEntities.size()) {
courseList = allEntities.subList(fromIndex, toIndex).stream()
.map(groupCourseConverter::toDomain) .map(groupCourseConverter::toDomain)
.toList(); .collectList()
} else { .map(courseList -> {
courseList = List.of();
}
int totalPages = (int) Math.ceil((double) total / size); int totalPages = (int) Math.ceil((double) total / size);
return new PageResponse<>(courseList, totalPages, total, page, size); return new PageResponse<>(courseList, totalPages, total, page, size);
}); });
});
} }
@Override @Override
@@ -137,8 +119,6 @@ public class GroupCourseRepository implements IGroupCourseRepository {
entity.setCreatedAt(LocalDateTime.now()); entity.setCreatedAt(LocalDateTime.now());
entity.setUpdatedAt(LocalDateTime.now()); entity.setUpdatedAt(LocalDateTime.now());
entity.setStatus(0L); entity.setStatus(0L);
entity.setCurrentMembers(0);
entity.setIsRecurring(groupCourse.getIsRecurring() != null ? groupCourse.getIsRecurring() : false);
return groupCourseDao.save(entity) return groupCourseDao.save(entity)
.map(groupCourseConverter::toDomain); .map(groupCourseConverter::toDomain);
@@ -148,7 +128,6 @@ public class GroupCourseRepository implements IGroupCourseRepository {
public Mono<GroupCourse> update(GroupCourse groupCourse) { public Mono<GroupCourse> update(GroupCourse groupCourse) {
GroupCourseEntity entity = groupCourseConverter.toEntity(groupCourse); GroupCourseEntity entity = groupCourseConverter.toEntity(groupCourse);
entity.setUpdatedAt(LocalDateTime.now()); entity.setUpdatedAt(LocalDateTime.now());
entity.setIsRecurring(groupCourse.getIsRecurring() != null ? groupCourse.getIsRecurring() : false);
return r2dbcEntityTemplate.update(entity) return r2dbcEntityTemplate.update(entity)
.then(findByIdAndDeletedAtIsNull(groupCourse.getId())); .then(findByIdAndDeletedAtIsNull(groupCourse.getId()));
@@ -171,28 +150,6 @@ public class GroupCourseRepository implements IGroupCourseRepository {
.then(); .then();
} }
@Override
public Mono<GroupCourse> restoreById(Long id) {
return groupCourseDao.restoreCourse(id, LocalDateTime.now())
.flatMap(updated -> {
if (updated > 0) {
return findByIdAndDeletedAtIsNull(id);
}
return Mono.error(new RuntimeException("团课恢复失败,可能该课程未被删除"));
});
}
@Override
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 @Override
public Flux<GroupCourse> findByCourseType(Long courseType) { public Flux<GroupCourse> findByCourseType(Long courseType) {
return groupCourseDao.findByCourseTypeAndDeletedAtIsNull(courseType) return groupCourseDao.findByCourseTypeAndDeletedAtIsNull(courseType)
@@ -221,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());
}
} }
@@ -1,5 +1,7 @@
package cn.novalon.gym.manage.groupcourse.repository.impl; 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.converter.GroupCourseConverter;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseTypeDao; import cn.novalon.gym.manage.groupcourse.dao.GroupCourseTypeDao;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType; 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 cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; 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.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Repository @Repository
@Transactional @Transactional
@@ -22,10 +30,13 @@ public class GroupCourseTypeRepository implements IGroupCourseTypeRepository {
private final GroupCourseTypeDao groupCourseTypeDao; private final GroupCourseTypeDao groupCourseTypeDao;
private final GroupCourseConverter converter; 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.groupCourseTypeDao = groupCourseTypeDao;
this.converter = converter; this.converter = converter;
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
} }
@Override @Override
@@ -105,20 +116,21 @@ public class GroupCourseTypeRepository implements IGroupCourseTypeRepository {
return groupCourseTypeDao.findByIdIsAndDeletedAtIsNull(groupCourseType.getId()) return groupCourseTypeDao.findByIdIsAndDeletedAtIsNull(groupCourseType.getId())
.switchIfEmpty(Mono.error(new RuntimeException("团课类型不存在"))) .switchIfEmpty(Mono.error(new RuntimeException("团课类型不存在")))
.flatMap(existing -> { .flatMap(existing -> {
String typeName = groupCourseType.getTypeName() != null existing.markNotNew();
? groupCourseType.getTypeName() : existing.getTypeName(); if (groupCourseType.getTypeName() != null) {
Integer baseDifficulty = groupCourseType.getBaseDifficulty() != null existing.setTypeName(groupCourseType.getTypeName());
? groupCourseType.getBaseDifficulty() : existing.getBaseDifficulty(); }
String description = groupCourseType.getDescription() != null if (groupCourseType.getBaseDifficulty() != null) {
? groupCourseType.getDescription() : existing.getDescription(); existing.setBaseDifficulty(groupCourseType.getBaseDifficulty());
String category = groupCourseType.getCategory() != null }
? groupCourseType.getCategory() : existing.getCategory(); if (groupCourseType.getDescription() != null) {
LocalDateTime now = LocalDateTime.now(); existing.setDescription(groupCourseType.getDescription());
}
return groupCourseTypeDao.updateFields( if (groupCourseType.getCategory() != null) {
groupCourseType.getId(), typeName, baseDifficulty, existing.setCategory(groupCourseType.getCategory());
description, category, now) }
.then(groupCourseTypeDao.findByIdIsAndDeletedAtIsNull(groupCourseType.getId())); existing.setUpdatedAt(LocalDateTime.now());
return groupCourseTypeDao.save(existing);
}) })
.map(converter::toGroupCourseType); .map(converter::toGroupCourseType);
} }
@@ -128,4 +140,46 @@ public class GroupCourseTypeRepository implements IGroupCourseTypeRepository {
return groupCourseTypeDao.softDelete(id, LocalDateTime.now()) return groupCourseTypeDao.softDelete(id, LocalDateTime.now())
.then(); .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);
});
}
} }
@@ -1,48 +0,0 @@
package cn.novalon.gym.manage.groupcourse.scheduler;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* 常态化团课自动续期定时任务
*
* 功能:定期检查已结束的常态化团课(is_recurring = TRUE 且 status = '2'),
* 自动重置状态为正常(status = '0'),并将课程时间推迟一周,重新开始。
*
* @date 2026-06-29
*/
@Component
public class GroupCourseRecurringScheduler {
private static final Logger logger = LoggerFactory.getLogger(GroupCourseRecurringScheduler.class);
private final GroupCourseDao groupCourseDao;
public GroupCourseRecurringScheduler(GroupCourseDao groupCourseDao) {
this.groupCourseDao = groupCourseDao;
}
/**
* 每分钟检查一次,将已结束的常态化团课重置为正常状态,
* 并将上课时间/下课时间各推迟一周
*/
@Scheduled(fixedRate = 60000)
public void renewRecurringCourses() {
logger.debug("定时任务开始检查常态化团课,续期已结束的课程");
groupCourseDao.renewRecurringCourses(LocalDateTime.now())
.subscribe(
count -> {
if (count > 0) {
logger.info("常态化团课续期完成,更新了 {} 条课程", count);
}
},
error -> logger.error("常态化团课续期定时任务执行失败:{}", error.getMessage(), error)
);
}
}
@@ -1,26 +0,0 @@
package cn.novalon.gym.manage.groupcourse.service;
import cn.novalon.gym.manage.groupcourse.domain.Banner;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface IBannerService {
Mono<Banner> findById(Long id);
Flux<Banner> findAll();
Flux<Banner> findAll(String sortBy, String sortOrder);
Flux<Banner> findAllActive();
Mono<Banner> create(Banner banner);
Mono<Banner> update(Long id, Banner banner);
Mono<Void> delete(Long id);
Mono<Banner> enable(Long id);
Mono<Banner> disable(Long id);
}
@@ -1,5 +1,7 @@
package cn.novalon.gym.manage.groupcourse.service; 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 cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -27,4 +29,6 @@ public interface ICourseLabelService {
Mono<Void> removeLabelFromType(Long typeId, Long labelId); Mono<Void> removeLabelFromType(Long typeId, Long labelId);
Mono<Void> clearLabelsFromType(Long typeId); Mono<Void> clearLabelsFromType(Long typeId);
Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest);
} }
@@ -17,10 +17,9 @@ public interface IGroupCourseBookingService {
* *
* @param courseId 团课ID * @param courseId 团课ID
* @param memberId 会员ID * @param memberId 会员ID
* @param memberCardRecordId 会员卡记录ID
* @return 预约记录 * @return 预约记录
*/ */
Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId, Long memberCardRecordId); Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId);
/** /**
* 取消预约 * 取消预约
@@ -62,14 +61,4 @@ public interface IGroupCourseBookingService {
* @return 处理的记录数 * @return 处理的记录数
*/ */
Mono<Integer> processAbsentMembers(); Mono<Integer> processAbsentMembers();
/**
* 扫码签到
* 用户扫描团课二维码签到将预约状态更新为已出席(2)
*
* @param courseId 团课ID
* @param memberId 会员ID
* @return 更新后的预约记录
*/
Mono<GroupCourseBooking> qrSignIn(Long courseId, Long memberId);
} }
@@ -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.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseDetail;
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto; import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
import cn.novalon.gym.manage.groupcourse.vo.GroupCourseVO;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.List;
public interface IGroupCourseService { public interface IGroupCourseService {
Mono<GroupCourse> findById(Long id); Mono<GroupCourse> findById(Long id);
Mono<GroupCourseDetail> findDetailById(Long id); Mono<GroupCourseDetail> findDetailById(Long id);
Flux<GroupCourse> findAll(); Flux<GroupCourse> findAll();
Flux<GroupCourse> findAll(boolean includeDeleted); Flux<GroupCourse> findAll(boolean includeDeleted);
Flux<GroupCourseVO> findAllAsVO(boolean includeDeleted);
Mono<PageResponse<GroupCourseVO>> findByPageAsVO(PageRequest pageRequest, boolean includeDeleted);
Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted); Mono<PageResponse<GroupCourse>> findByPage(PageRequest pageRequest, boolean includeDeleted);
@@ -27,7 +33,15 @@ public interface IGroupCourseService {
Mono<Void> delete(Long id); Mono<Void> delete(Long id);
Mono<GroupCourse> restore(Long id);
Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query); Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query);
/**
* 检查教练在指定时间段内是否有时间冲突的团课
* @param coachId 教练ID
* @param startTime 开始时间
* @param endTime 结束时间
* @param excludeCourseId 排除的课程ID编辑时排除自身
* @return 冲突的课程列表
*/
Mono<List<GroupCourse>> checkCoachConflict(Long coachId, LocalDateTime startTime, LocalDateTime endTime, Long excludeCourseId);
} }
@@ -1,5 +1,7 @@
package cn.novalon.gym.manage.groupcourse.service; 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 cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -29,4 +31,6 @@ public interface IGroupCourseTypeService {
* @return 分类名称列表 * @return 分类名称列表
*/ */
Flux<String> findCategories(); Flux<String> findCategories();
Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest);
} }
@@ -1,99 +0,0 @@
package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.groupcourse.domain.Banner;
import cn.novalon.gym.manage.groupcourse.repository.IBannerRepository;
import cn.novalon.gym.manage.groupcourse.service.IBannerService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Service
public class BannerService implements IBannerService {
private static final Logger logger = LoggerFactory.getLogger(BannerService.class);
private final IBannerRepository bannerRepository;
public BannerService(IBannerRepository bannerRepository) {
this.bannerRepository = bannerRepository;
}
@Override
public Mono<Banner> findById(Long id) {
return bannerRepository.findById(id);
}
@Override
public Flux<Banner> findAll() {
return bannerRepository.findAll();
}
@Override
public Flux<Banner> findAll(String sortBy, String sortOrder) {
return bannerRepository.findAll(sortBy, sortOrder);
}
@Override
public Flux<Banner> findAllActive() {
return bannerRepository.findAllActive();
}
@Override
public Mono<Banner> create(Banner banner) {
if (banner.getImageUrl() == null || banner.getImageUrl().isBlank()) {
return Mono.error(new RuntimeException("背景图不能为空"));
}
if (banner.getTitle() == null || banner.getTitle().isBlank()) {
return Mono.error(new RuntimeException("主标题不能为空"));
}
return bannerRepository.save(banner)
.doOnSuccess(r -> logger.info("轮播图创建成功 - id={}, title={}", r.getId(), r.getTitle()))
.doOnError(error -> logger.error("轮播图创建失败 - error: {}", error.getMessage()));
}
@Override
public Mono<Banner> update(Long id, Banner banner) {
return bannerRepository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在")))
.flatMap(existing -> {
if (banner.getImageUrl() != null) existing.setImageUrl(banner.getImageUrl());
if (banner.getTitle() != null) existing.setTitle(banner.getTitle());
if (banner.getSubtitle() != null) existing.setSubtitle(banner.getSubtitle());
if (banner.getDescription() != null) existing.setDescription(banner.getDescription());
if (banner.getSortOrder() != null) existing.setSortOrder(banner.getSortOrder());
if (banner.getIsActive() != null) existing.setIsActive(banner.getIsActive());
return bannerRepository.update(existing);
})
.doOnSuccess(r -> logger.info("轮播图更新成功 - id={}", id))
.doOnError(error -> logger.error("轮播图更新失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<Void> delete(Long id) {
return bannerRepository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在")))
.flatMap(banner -> bannerRepository.deleteById(id)
.doOnSuccess(v -> logger.info("轮播图删除成功 - id={}", id))
.doOnError(error -> logger.error("轮播图删除失败 - id={}, error: {}", id, error.getMessage())));
}
@Override
public Mono<Banner> enable(Long id) {
return bannerRepository.updateActiveStatus(id, true)
.switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在")))
.doOnSuccess(r -> logger.info("轮播图启用成功 - id={}", id))
.doOnError(error -> logger.error("轮播图启用失败 - id={}, error: {}", id, error.getMessage()));
}
@Override
public Mono<Banner> disable(Long id) {
return bannerRepository.updateActiveStatus(id, false)
.switchIfEmpty(Mono.error(new RuntimeException("轮播图不存在")))
.doOnSuccess(r -> logger.info("轮播图禁用成功 - id={}", id))
.doOnError(error -> logger.error("轮播图禁用失败 - id={}, error: {}", id, error.getMessage()));
}
}
@@ -1,5 +1,7 @@
package cn.novalon.gym.manage.groupcourse.service.impl; 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.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel; import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
@@ -109,4 +111,12 @@ public class CourseLabelService implements ICourseLabelService {
.doOnSuccess(v -> logger.info("清空类型标签成功 - typeId={}", typeId)) .doOnSuccess(v -> logger.info("清空类型标签成功 - typeId={}", typeId))
.doOnError(error -> logger.error("清空类型标签失败 - typeId={}, error: {}", typeId, error.getMessage())); .doOnError(error -> logger.error("清空类型标签失败 - typeId={}, error: {}", typeId, error.getMessage()));
} }
@Override
public Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest) {
return courseLabelRepository.findByPage(pageRequest)
.doOnSuccess(result -> logger.info("分页查询标签成功 - page={}, size={}, total={}",
pageRequest.getPage(), pageRequest.getSize(), result.getTotalElements()))
.doOnError(error -> logger.error("分页查询标签失败 - error: {}", error.getMessage()));
}
} }
@@ -3,19 +3,15 @@ package cn.novalon.gym.manage.groupcourse.service.impl;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse; import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking; import cn.novalon.gym.manage.groupcourse.domain.GroupCourseBooking;
import cn.novalon.gym.manage.groupcourse.event.BookingReminderEventPublisher; import cn.novalon.gym.manage.groupcourse.event.BookingReminderEventPublisher;
import cn.novalon.gym.manage.groupcourse.handler.BookingSagaHandler;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseBookingService;
import cn.novalon.gym.manage.groupcourse.util.OSSUtil;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.UUID; import java.util.UUID;
@@ -28,7 +24,6 @@ import java.util.UUID;
* - 取消预约需在课程开始前至少2小时 * - 取消预约需在课程开始前至少2小时
* - 每节课最多20人 * - 每节课最多20人
* - 预约成功后发送提醒 * - 预约成功后发送提醒
* - 预约成功后扣减权益
* *
* 技术要点 * 技术要点
* - 使用Redis缓存团课信息 * - 使用Redis缓存团课信息
@@ -47,8 +42,6 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
private final IGroupCourseRepository courseRepository; private final IGroupCourseRepository courseRepository;
private final GroupCourseRedisService redisService; private final GroupCourseRedisService redisService;
private final BookingReminderEventPublisher bookingReminderEventPublisher; private final BookingReminderEventPublisher bookingReminderEventPublisher;
private final BookingSagaHandler bookingSagaHandler;
private final DatabaseClient databaseClient;
// 预约提前时间限制分钟 // 预约提前时间限制分钟
private static final long BOOKING_MIN_ADVANCE_MINUTES = 30; private static final long BOOKING_MIN_ADVANCE_MINUTES = 30;
@@ -58,20 +51,16 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
public GroupCourseBookingService(IGroupCourseBookingRepository bookingRepository, public GroupCourseBookingService(IGroupCourseBookingRepository bookingRepository,
IGroupCourseRepository courseRepository, IGroupCourseRepository courseRepository,
GroupCourseRedisService redisService, GroupCourseRedisService redisService,
BookingReminderEventPublisher bookingReminderEventPublisher, BookingReminderEventPublisher bookingReminderEventPublisher) {
BookingSagaHandler bookingSagaHandler,
DatabaseClient databaseClient) {
this.bookingRepository = bookingRepository; this.bookingRepository = bookingRepository;
this.courseRepository = courseRepository; this.courseRepository = courseRepository;
this.redisService = redisService; this.redisService = redisService;
this.bookingReminderEventPublisher = bookingReminderEventPublisher; this.bookingReminderEventPublisher = bookingReminderEventPublisher;
this.bookingSagaHandler = bookingSagaHandler;
this.databaseClient = databaseClient;
} }
@Override @Override
public Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId, Long memberCardRecordId) { public Mono<GroupCourseBooking> bookCourse(Long courseId, Long memberId) {
logger.info("开始预约团课:courseId={}, memberId={}, memberCardRecordId={}", courseId, memberId, memberCardRecordId); logger.info("开始预约团课:courseId={}, memberId={}", courseId, memberId);
// 生成唯一请求ID用于分布式锁 // 生成唯一请求ID用于分布式锁
String requestId = UUID.randomUUID().toString(); String requestId = UUID.randomUUID().toString();
@@ -83,18 +72,26 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
return Mono.error(new RuntimeException("系统繁忙,请稍后重试")); return Mono.error(new RuntimeException("系统繁忙,请稍后重试"));
} }
// 2. 尝试从缓存获取课程信息如果缓存不存在则从数据库获取 // 2. 从缓存或数据库获取课程信息并用实时预约数覆盖currentMembers
return getCourseWithCache(courseId) return getCourseWithCache(courseId)
.flatMap(course ->
bookingRepository.countValidBookings(courseId)
.map(count -> {
course.setCurrentMembers(count.intValue());
return course;
})
.defaultIfEmpty(course)
)
.flatMap(course -> { .flatMap(course -> {
// 3. 验证课程状态 // 3. 验证课程状态
Long status = course.getStatus(); Long courseStatus = course.getStatus();
if (status == null || status != 0L) { if (courseStatus == null || courseStatus != 0L) {
String errorMessage; String errorMessage;
if (status == null) { if (courseStatus == null) {
errorMessage = "课程状态异常"; errorMessage = "课程状态异常";
} else if (status == 1L) { } else if (courseStatus == 1L) {
errorMessage = "课程已取消,无法预约"; errorMessage = "课程已取消,无法预约";
} else if (status == 2L) { } else if (courseStatus == 2L) {
errorMessage = "课程已结束,无法预约"; errorMessage = "课程已结束,无法预约";
} else { } else {
errorMessage = "课程状态不可预约"; errorMessage = "课程状态不可预约";
@@ -137,22 +134,15 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
// 7. 验证是否已预约 // 7. 验证是否已预约
return bookingRepository.findValidBooking(courseId, memberId) return bookingRepository.findValidBooking(courseId, memberId)
.flatMap(existingBooking -> { .flatMap(existingBooking ->
return releaseLockAndError(courseId, requestId, "您已预约该课程"); releaseLockAndError(courseId, requestId, "您已预约该课程")
}) )
.switchIfEmpty( .switchIfEmpty(
// 8. 使用Redis原子操作验证课程人数是否已满 // 8. 创建预约记录
validateAndIncrementBookingCount(courseId, course.getMaxMembers()) Mono.defer(() -> {
.flatMap(countValid -> {
if (countValid > course.getMaxMembers()) {
return releaseLockAndError(courseId, requestId, "课程已满");
}
// 9. 创建预约记录
GroupCourseBooking booking = new GroupCourseBooking(); GroupCourseBooking booking = new GroupCourseBooking();
booking.setCourseId(courseId); booking.setCourseId(courseId);
booking.setMemberId(memberId); booking.setMemberId(memberId);
booking.setMemberCardRecordId(memberCardRecordId);
booking.setBookingTime(LocalDateTime.now()); booking.setBookingTime(LocalDateTime.now());
booking.setStatus("0"); // 0-已预约 booking.setStatus("0"); // 0-已预约
@@ -162,13 +152,15 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
booking.setCourseEndTime(course.getEndTime()); booking.setCourseEndTime(course.getEndTime());
booking.setLocation(course.getLocation()); booking.setLocation(course.getLocation());
// 10. 使用Saga事务执行预约包含权益扣减 // 9. 保存预约记录
BigDecimal courseAmount = course.getStoredValueAmount() != null ? course.getStoredValueAmount() : BigDecimal.ZERO; return bookingRepository.save(booking)
return bookingSagaHandler.executeBooking(booking, memberCardRecordId, courseAmount) .flatMap(saved -> {
.flatMap(savedBooking -> { if (saved.getId() == null) {
// 11. 释放锁 return Mono.error(new RuntimeException("保存预约记录失败"));
}
// 10. 释放锁
return redisService.releaseLock(courseId, requestId) return redisService.releaseLock(courseId, requestId)
.then(Mono.just(savedBooking)); .then(Mono.just(saved));
}) })
.doOnSuccess(savedBooking -> { .doOnSuccess(savedBooking -> {
logger.info("预约成功:bookingId={}, courseId={}, memberId={}", logger.info("预约成功:bookingId={}, courseId={}, memberId={}",
@@ -180,12 +172,6 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
savedBooking.getCourseName(), savedBooking.getCourseName(),
savedBooking.getCourseStartTime().toString() savedBooking.getCourseStartTime().toString()
); );
})
.doOnError(error -> {
// 回滚Redis计数
redisService.decrementBookingCount(courseId).subscribe();
logger.error("预约失败:courseId={}, memberId={}, error={}",
courseId, memberId, error.getMessage());
}); });
}) })
); );
@@ -215,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));
}
/** /**
* 释放锁并返回错误 * 释放锁并返回错误
*/ */
@@ -297,19 +260,15 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
"需在课程开始前" + CANCEL_MIN_ADVANCE_HOURS + "小时取消"); "需在课程开始前" + CANCEL_MIN_ADVANCE_HOURS + "小时取消");
} }
// 5. 查询课程金额和已取消次数使用Saga事务执行取消预约 // 5. 更新预约状态为已取消
return courseRepository.findByIdAndDeletedAtIsNull(booking.getCourseId()) return bookingRepository.updateStatus(bookingId, "1")
.flatMap(course -> { .flatMap(rows -> {
BigDecimal courseAmount = course.getStoredValueAmount() != null ? course.getStoredValueAmount() : BigDecimal.valueOf(50); if (rows == 0) {
return bookingRepository.countCancelledByMemberId(memberId) return Mono.error(new RuntimeException("更新预约状态失败"));
.flatMap(cancelCount -> { }
return bookingSagaHandler.executeCancelBooking(bookingId, booking.getCourseId(), booking.getMemberCardRecordId(), memberId, courseAmount, cancelCount);
});
})
.flatMap(updatedBooking -> {
// 6. 释放锁 // 6. 释放锁
return redisService.releaseLock(bookingId, requestId) return redisService.releaseLock(bookingId, requestId)
.then(Mono.just(updatedBooking)); .then(bookingRepository.findById(bookingId));
}) })
.doOnSuccess(updatedBooking -> { .doOnSuccess(updatedBooking -> {
logger.info("取消预约成功:bookingId={}, memberId={}", bookingId, memberId); logger.info("取消预约成功:bookingId={}, memberId={}", bookingId, memberId);
@@ -336,18 +295,6 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
public Flux<GroupCourseBooking> getBookingsByMemberId(Long memberId) { public Flux<GroupCourseBooking> getBookingsByMemberId(Long memberId) {
logger.debug("查询会员预约记录:memberId={}", memberId); logger.debug("查询会员预约记录:memberId={}", memberId);
return bookingRepository.findByMemberId(memberId) return bookingRepository.findByMemberId(memberId)
.flatMap(booking -> {
// 从关联的 GroupCourse 获取封面图并转为预签名URL
if (booking.getCourseId() != null) {
return courseRepository.findByIdAndDeletedAtIsNull(booking.getCourseId())
.map(course -> {
booking.setCoverImage(OSSUtil.toCoverPresignedUrl(course.getCoverImage()));
return booking;
})
.defaultIfEmpty(booking);
}
return Mono.just(booking);
})
.doOnComplete(() -> logger.debug("查询完成:memberId={}", memberId)); .doOnComplete(() -> logger.debug("查询完成:memberId={}", memberId));
} }
@@ -364,78 +311,6 @@ public class GroupCourseBookingService implements IGroupCourseBookingService {
.doOnComplete(() -> logger.debug("查询完成:courseId={}", courseId)); .doOnComplete(() -> logger.debug("查询完成:courseId={}", courseId));
} }
@Override
public Mono<GroupCourseBooking> qrSignIn(Long courseId, Long memberId) {
logger.info("扫码签到:courseId={}, memberId={}", courseId, memberId);
return courseRepository.findByIdAndDeletedAtIsNull(courseId)
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在或已删除")))
.flatMap(course -> {
// 校验1团课状态必须为 0正常
Long status = course.getStatus();
if (status == null || status != 0L) {
String msg;
if (status == null) msg = "课程状态异常";
else if (status == 1L) msg = "课程已取消,无法签到";
else if (status == 2L) msg = "课程已结束,无法签到";
else msg = "课程状态不可签到";
return Mono.error(new RuntimeException(msg));
}
// 校验2用户是否预约了该课程状态为 0-已预约
return bookingRepository.findValidBooking(courseId, memberId)
.switchIfEmpty(Mono.error(new RuntimeException("您未预约此课程,无法签到")))
.flatMap(booking -> {
// 校验3预约状态必须为 0
if (!"0".equals(booking.getStatus())) {
String msg;
if ("1".equals(booking.getStatus())) msg = "预约已取消";
else if ("2".equals(booking.getStatus())) msg = "已签到,无需重复签到";
else msg = "预约状态异常";
return Mono.error(new RuntimeException(msg));
}
// 更新预约状态为 2已出席
return bookingRepository.updateStatus(booking.getId(), "2")
.then(Mono.defer(() -> {
// 同步写入签到记录供仪表盘统计
LocalDateTime now = LocalDateTime.now();
LocalDateTime todayStart = now.toLocalDate().atStartOfDay();
LocalDateTime todayEnd = todayStart.plusDays(1);
// 先检查今天是否已有成功签到记录避免重复
return databaseClient.sql(
"SELECT sign_in_status FROM sign_in_record WHERE member_id = :memberId AND sign_in_time >= :startTime AND sign_in_time < :endTime AND is_delete = false AND sign_in_status = 'SUCCESS' ORDER BY sign_in_time DESC LIMIT 1")
.bind("memberId", memberId)
.bind("startTime", todayStart)
.bind("endTime", todayEnd)
.map(row -> row.get("sign_in_status", String.class))
.one()
.flatMap(existing -> {
// 已有签到记录不重复插入
return bookingRepository.findById(booking.getId());
})
.switchIfEmpty(
// 无今日签到记录插入一条
databaseClient.sql(
"INSERT INTO sign_in_record (member_id, member_card_id, sign_in_time, sign_in_type, sign_in_status, source, created_at, updated_at, is_delete) " +
"VALUES (:memberId, :memberCardId, :signInTime, 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', NOW(), NOW(), false)")
.bind("memberId", memberId)
.bind("memberCardId", booking.getMemberCardRecordId())
.bind("signInTime", now)
.fetch()
.rowsUpdated()
.then(bookingRepository.findById(booking.getId()))
);
}));
});
})
.doOnSuccess(booking -> logger.info("扫码签到成功:bookingId={}, courseId={}, memberId={}",
booking.getId(), courseId, memberId))
.doOnError(error -> logger.error("扫码签到失败:courseId={}, memberId={}, error={}",
courseId, memberId, error.getMessage()));
}
@Override @Override
public Mono<Integer> processAbsentMembers() { public Mono<Integer> processAbsentMembers() {
logger.info("开始处理已开始课程但未到场会员的预约记录"); logger.info("开始处理已开始课程但未到场会员的预约记录");
@@ -5,7 +5,6 @@ import cn.novalon.gym.manage.groupcourse.domain.GroupCourseRecommend;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRecommendRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRecommendRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseRecommendService;
import cn.novalon.gym.manage.groupcourse.util.OSSUtil;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -135,8 +134,6 @@ public class GroupCourseRecommendService implements IGroupCourseRecommendService
return groupCourseRepository.findByIdAndDeletedAtIsNull(recommend.getCourseId()) return groupCourseRepository.findByIdAndDeletedAtIsNull(recommend.getCourseId())
.map(course -> { .map(course -> {
// OSS Key 转换为预签名URL前端可直接加载
course.setCoverImage(OSSUtil.toCoverPresignedUrl(course.getCoverImage()));
recommend.setGroupCourse(course); recommend.setGroupCourse(course);
return recommend; return recommend;
}) })
@@ -149,46 +149,4 @@ public class GroupCourseRedisService {
}) })
.defaultIfEmpty(false); .defaultIfEmpty(false);
} }
/**
* 获取课程预约人数缓存
*/
public Mono<Integer> getBookingCount(Long courseId) {
String key = "booking_count:" + courseId;
return reactiveRedisTemplate.opsForValue()
.get(key)
.map(obj -> (Integer) obj)
.defaultIfEmpty(0);
}
/**
* 增加课程预约人数
*/
public Mono<Long> incrementBookingCount(Long courseId) {
String key = "booking_count:" + courseId;
return reactiveRedisTemplate.opsForValue()
.increment(key)
.doOnSuccess(count -> logger.debug("预约人数增加:courseId={}, count={}", courseId, count));
}
/**
* 减少课程预约人数
*/
public Mono<Long> decrementBookingCount(Long courseId) {
String key = "booking_count:" + courseId;
return reactiveRedisTemplate.opsForValue()
.decrement(key)
.doOnSuccess(count -> logger.debug("预约人数减少:courseId={}, count={}", courseId, count));
}
/**
* 设置课程预约人数用于从数据库同步
*/
public Mono<Void> setBookingCount(Long courseId, Integer count) {
String key = "booking_count:" + courseId;
return reactiveRedisTemplate.opsForValue()
.set(key, count)
.doOnSuccess(result -> logger.debug("预约人数已设置:courseId={}, count={}", courseId, count))
.then();
}
} }
@@ -19,12 +19,15 @@ import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService; import cn.novalon.gym.manage.groupcourse.service.IGroupCourseService;
import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil; import cn.novalon.gym.manage.groupcourse.util.QRCodeUtil;
import cn.novalon.gym.manage.groupcourse.util.OSSUtil; import cn.novalon.gym.manage.groupcourse.vo.GroupCourseVO;
import cn.novalon.gym.manage.file.core.service.ISysFileService;
import cn.novalon.gym.manage.member.entity.MemberCard; import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.entity.MemberCardRecord; import cn.novalon.gym.manage.member.entity.MemberCardRecord;
import cn.novalon.gym.manage.member.enums.MemberCardType; import cn.novalon.gym.manage.member.enums.MemberCardType;
import cn.novalon.gym.manage.member.repository.MemberCardRepository; import cn.novalon.gym.manage.member.repository.MemberCardRepository;
import cn.novalon.gym.manage.member.service.IMemberCardRecordService; import cn.novalon.gym.manage.member.service.IMemberCardRecordService;
import cn.novalon.gym.manage.sys.core.domain.SysUser;
import cn.novalon.gym.manage.sys.core.repository.ISysUserRepository;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -33,10 +36,11 @@ import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
@Service @Service
@@ -53,6 +57,8 @@ public class GroupCourseService implements IGroupCourseService {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final GroupCourseStateMachine stateMachine; private final GroupCourseStateMachine stateMachine;
private final DatabaseClient databaseClient; private final DatabaseClient databaseClient;
private final ISysFileService fileService;
private final ISysUserRepository sysUserRepository;
private static final String CACHE_KEY_PREFIX = "group_course:page:"; private static final String CACHE_KEY_PREFIX = "group_course:page:";
private static final String CACHE_KEY_ID_PREFIX = "group_course:id:"; private static final String CACHE_KEY_ID_PREFIX = "group_course:id:";
@@ -70,7 +76,9 @@ public class GroupCourseService implements IGroupCourseService {
RedisUtil redisUtil, RedisUtil redisUtil,
ObjectMapper objectMapper, ObjectMapper objectMapper,
GroupCourseStateMachine stateMachine, GroupCourseStateMachine stateMachine,
DatabaseClient databaseClient){ DatabaseClient databaseClient,
ISysFileService fileService,
ISysUserRepository sysUserRepository){
this.groupCourseRepository = groupCourseRepository; this.groupCourseRepository = groupCourseRepository;
this.bookingRepository = bookingRepository; this.bookingRepository = bookingRepository;
this.groupCourseTypeRepository = groupCourseTypeRepository; this.groupCourseTypeRepository = groupCourseTypeRepository;
@@ -81,25 +89,28 @@ public class GroupCourseService implements IGroupCourseService {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.stateMachine = stateMachine; this.stateMachine = stateMachine;
this.databaseClient = databaseClient; this.databaseClient = databaseClient;
this.fileService = fileService;
this.sysUserRepository = sysUserRepository;
} }
@Override @Override
public Mono<GroupCourseDetail> findDetailById(Long id) { public Mono<GroupCourseDetail> findDetailById(Long id) {
String cacheKey = CACHE_KEY_DETAIL_PREFIX + 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 -> { .flatMap(cachedJson -> {
if (cachedJson != null) { if (cachedJson != null && !cachedJson.isEmpty()) {
try { try {
GroupCourseDetail detail = objectMapper.readValue(cachedJson, GroupCourseDetail.class); GroupCourseDetail detail = objectMapper.readValue(cachedJson, GroupCourseDetail.class);
logger.info("缓存命中 - findDetailById: id={}", id); logger.info("缓存命中 - findDetailById: id={}", id);
return Mono.just(detail); return Mono.<GroupCourseDetail>just(detail);
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
logger.warn("缓存解析失败,删除缓存 - id: {}, error: {}", id, e.getMessage()); logger.warn("缓存解析失败,删除缓存 - id: {}, error: {}", id, e.getMessage());
return redisUtil.delete(cacheKey).then(Mono.empty()); return redisUtil.delete(cacheKey).then(Mono.<GroupCourseDetail>empty());
} }
} }
return Mono.empty(); return Mono.<GroupCourseDetail>empty();
}) })
.switchIfEmpty( .switchIfEmpty(
groupCourseRepository.findByIdAndDeletedAtIsNull(id) groupCourseRepository.findByIdAndDeletedAtIsNull(id)
@@ -125,6 +136,8 @@ public class GroupCourseService implements IGroupCourseService {
}) })
.switchIfEmpty(Mono.just(buildDetail(course, null))); .switchIfEmpty(Mono.just(buildDetail(course, null)));
}) })
.flatMap(this::enrichCoachName)
.flatMap(this::enrichCurrentMembers)
.flatMap(detail -> { .flatMap(detail -> {
try { try {
String jsonData = objectMapper.writeValueAsString(detail); String jsonData = objectMapper.writeValueAsString(detail);
@@ -155,7 +168,7 @@ public class GroupCourseService implements IGroupCourseService {
detail.setCurrentMembers(course.getCurrentMembers()); detail.setCurrentMembers(course.getCurrentMembers());
detail.setStatus(course.getStatus()); detail.setStatus(course.getStatus());
detail.setLocation(course.getLocation()); detail.setLocation(course.getLocation());
detail.setCoverImage(OSSUtil.toCoverPresignedUrl(course.getCoverImage())); detail.setCoverImage(course.getCoverImage());
detail.setDescription(course.getDescription()); detail.setDescription(course.getDescription());
detail.setStoredValueAmount(course.getStoredValueAmount()); detail.setStoredValueAmount(course.getStoredValueAmount());
detail.setQrCodePath(course.getQrCodePath()); detail.setQrCodePath(course.getQrCodePath());
@@ -170,27 +183,73 @@ public class GroupCourseService implements IGroupCourseService {
return detail; return detail;
} }
/**
* 为团课详情对象填充教练名称
*/
private Mono<GroupCourseDetail> enrichCoachName(GroupCourseDetail detail) {
Long coachId = detail.getCoachId();
if (coachId == null) {
return Mono.just(detail);
}
return sysUserRepository.findByIdIncludingDeleted(coachId)
.map(user -> {
String name = user.getNickname() != null ? user.getNickname() : user.getUsername();
detail.setCoachName(name);
logger.debug("enrichCoachName: courseId={}, coachId={}, coachName={}", detail.getId(), coachId, name);
return detail;
})
.doOnNext(d -> {}) // 避免空doOnNext告警
.defaultIfEmpty(detail)
.doOnDiscard(GroupCourseDetail.class, d ->
logger.warn("enrichCoachName: coach not found, coachId={}, courseId={}", coachId, detail.getId())
);
}
/**
* 为团课详情对象填充实时预约人数
*/
private Mono<GroupCourseDetail> enrichCurrentMembers(GroupCourseDetail detail) {
return bookingRepository.countValidBookings(detail.getId())
.map(count -> {
detail.setCurrentMembers(count.intValue());
return detail;
})
.defaultIfEmpty(detail);
}
/**
* 为团课对象填充实时预约人数
*/
private Mono<GroupCourse> enrichCurrentMembers(GroupCourse course) {
return bookingRepository.countValidBookings(course.getId())
.map(count -> {
course.setCurrentMembers(count.intValue());
return course;
})
.defaultIfEmpty(course);
}
@Override @Override
public Mono<GroupCourse> findById(Long id) { public Mono<GroupCourse> findById(Long id) {
String cacheKey = CACHE_KEY_ID_PREFIX + id; String cacheKey = CACHE_KEY_ID_PREFIX + id;
return redisUtil.get(cacheKey, String.class) Mono<String> cachedMono = redisUtil.get(cacheKey, String.class);
return cachedMono
.flatMap(cachedJson -> { .flatMap(cachedJson -> {
if (cachedJson != null) { if (cachedJson != null && !cachedJson.isEmpty()) {
try { try {
GroupCourse groupCourse = objectMapper.readValue(cachedJson, GroupCourse.class); GroupCourse groupCourse = objectMapper.readValue(cachedJson, GroupCourse.class);
logger.info("缓存命中 - findById: id={}", id); logger.info("缓存命中 - findById: id={}", id);
return Mono.just(fillCoverPresignedUrl(groupCourse)); return Mono.<GroupCourse>just(groupCourse);
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
logger.warn("缓存解析失败,删除缓存 - id: {}, error: {}", id, e.getMessage()); logger.warn("缓存解析失败,删除缓存 - id: {}, error: {}", id, e.getMessage());
return redisUtil.delete(cacheKey).then(Mono.empty()); return redisUtil.delete(cacheKey).then(Mono.<GroupCourse>empty());
} }
} }
return Mono.empty(); return Mono.<GroupCourse>empty();
}) })
.switchIfEmpty( .switchIfEmpty(
groupCourseRepository.findByIdAndDeletedAtIsNull(id) groupCourseRepository.findByIdAndDeletedAtIsNull(id)
.map(this::fillCoverPresignedUrl)
.flatMap(groupCourse -> { .flatMap(groupCourse -> {
try { try {
String jsonData = objectMapper.writeValueAsString(groupCourse); String jsonData = objectMapper.writeValueAsString(groupCourse);
@@ -203,24 +262,63 @@ public class GroupCourseService implements IGroupCourseService {
} }
}) })
.doOnSubscribe(sub -> logger.debug("缓存未命中,查询数据库 - findById: id={}", id)) .doOnSubscribe(sub -> logger.debug("缓存未命中,查询数据库 - findById: id={}", id))
); )
.flatMap(this::enrichCurrentMembers);
} }
@Override @Override
public Flux<GroupCourse> findAll() { public Flux<GroupCourse> findAll() {
return groupCourseRepository.findAll() return groupCourseRepository.findAll();
.map(this::fillCoverPresignedUrl);
} }
@Override @Override
public Flux<GroupCourse> findAll(boolean includeDeleted) { public Flux<GroupCourse> findAll(boolean includeDeleted) {
Flux<GroupCourse> flux;
if(includeDeleted){ if(includeDeleted){
flux = groupCourseRepository.findAll(); return groupCourseRepository.findAll();
}else{ }else{
flux = groupCourseRepository.findByDeletedAtIsNull(); return groupCourseRepository.findByDeletedAtIsNull();
} }
return flux.map(this::fillCoverPresignedUrl); }
@Override
public Flux<GroupCourseVO> findAllAsVO(boolean includeDeleted) {
return findAll(includeDeleted)
.flatMap(this::enrichCurrentMembers)
.collectList()
.flatMapMany(courses -> {
if (courses.isEmpty()) {
logger.info("findAllAsVO: no courses found");
return Flux.empty();
}
var coachIds = courses.stream()
.map(GroupCourse::getCoachId)
.filter(id -> id != null)
.distinct()
.toList();
logger.info("findAllAsVO: {} courses, {} unique coach IDs: {}", courses.size(), coachIds.size(), coachIds);
if (coachIds.isEmpty()) {
logger.info("findAllAsVO: no coach IDs, returning VOs with null coachName");
return Flux.fromIterable(courses.stream()
.map(c -> GroupCourseVO.from(c, null))
.toList());
}
return Flux.fromIterable(coachIds)
.flatMap(sysUserRepository::findByIdIncludingDeleted)
.doOnNext(u -> logger.info("findAllAsVO: found coach id={}, nickname={}, username={}", u.getId(), u.getNickname(), u.getUsername()))
.collectMap(SysUser::getId, u -> u.getNickname() != null ? u.getNickname() : u.getUsername())
.doOnNext(m -> logger.info("findAllAsVO: coachNameMap size={}, keys={}", m.size(), m.keySet()))
.flatMapMany(coachNameMap ->
Flux.fromIterable(courses.stream()
.map(c -> {
String name = coachNameMap.get(c.getCoachId());
logger.debug("findAllAsVO: courseId={}, coachId={}, coachName={}", c.getId(), c.getCoachId(), name);
return GroupCourseVO.from(c, name);
})
.toList()));
});
} }
@Override @Override
@@ -230,18 +328,19 @@ public class GroupCourseService implements IGroupCourseService {
String sort = pageRequest.getSort(); String sort = pageRequest.getSort();
String order = pageRequest.getOrder(); String order = pageRequest.getOrder();
String keyword = pageRequest.getKeyword() != null ? pageRequest.getKeyword() : ""; String keyword = pageRequest.getKeyword() != null ? pageRequest.getKeyword() : "";
String status = pageRequest.getStatus() != null ? pageRequest.getStatus() : "";
String cacheKey = CACHE_KEY_PREFIX + page + ":" + size + ":" + includeDeleted + ":" + sort + ":" + order + ":" + keyword; String cacheKey = CACHE_KEY_PREFIX + page + ":" + size + ":" + includeDeleted + ":" + sort + ":" + order + ":" + keyword + ":" + status;
return redisUtil.get(cacheKey, String.class) Mono<String> cachedMono = redisUtil.get(cacheKey, String.class);
return cachedMono
.flatMap(cachedJson -> { .flatMap(cachedJson -> {
if (cachedJson != null) { if (cachedJson != null && !cachedJson.isEmpty()) {
try { try {
PageResponse<GroupCourse> pageResponse = objectMapper.readValue(cachedJson, PageResponse<GroupCourse> pageResponse = objectMapper.readValue(cachedJson,
objectMapper.getTypeFactory().constructParametricType(PageResponse.class, GroupCourse.class)); objectMapper.getTypeFactory().constructParametricType(PageResponse.class, GroupCourse.class));
logger.info("缓存命中 - findByPage: key={}", cacheKey); logger.info("缓存命中 - findByPage: key={}", cacheKey);
fillCoverPresignedUrl(pageResponse); return Mono.<PageResponse<GroupCourse>>just(pageResponse);
return Mono.just(pageResponse);
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
logger.warn("缓存解析失败,删除缓存 - key: {}, error: {}", cacheKey, e.getMessage()); logger.warn("缓存解析失败,删除缓存 - key: {}, error: {}", cacheKey, e.getMessage());
return redisUtil.delete(cacheKey).then(Mono.empty()); return redisUtil.delete(cacheKey).then(Mono.empty());
@@ -261,7 +360,6 @@ public class GroupCourseService implements IGroupCourseService {
return resultMono.flatMap(pageResponse -> { return resultMono.flatMap(pageResponse -> {
try { try {
fillCoverPresignedUrl(pageResponse);
String jsonData = objectMapper.writeValueAsString(pageResponse); String jsonData = objectMapper.writeValueAsString(pageResponse);
return redisUtil.setWithExpire(cacheKey, jsonData, CACHE_EXPIRE_SECONDS) return redisUtil.setWithExpire(cacheKey, jsonData, CACHE_EXPIRE_SECONDS)
.thenReturn(pageResponse) .thenReturn(pageResponse)
@@ -275,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 @Override
public Mono<GroupCourse> create(GroupCourse groupCourse) { public Mono<GroupCourse> create(GroupCourse groupCourse) {
return groupCourseRepository.save(groupCourse) return groupCourseRepository.save(groupCourse)
@@ -295,15 +460,21 @@ public class GroupCourseService implements IGroupCourseService {
String jsonContent = objectMapper.writeValueAsString(qrCodeContent); String jsonContent = objectMapper.writeValueAsString(qrCodeContent);
// 生成二维码并上传到阿里云OSS // 生成二维码字节数组通过统一文件服务保存
String qrCodeUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent); 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); course.setQrCodePath(qrCodeUrl);
logger.info("团课二维码上传到OSS成功 - id={}, qrCodeUrl={}", course.getId(), qrCodeUrl); logger.info("团课二维码已保存 - id={}, fileId={}, url={}", course.getId(), sysFile.getId(), qrCodeUrl);
// 更新团课信息保存二维码路径
return groupCourseRepository.update(course) return groupCourseRepository.update(course)
.doOnSuccess(updatedCourse -> logger.info("团课创建成功 - id={}, name={}", updatedCourse.getId(), updatedCourse.getCourseName())); .doOnSuccess(updatedCourse -> logger.info("团课创建成功 - id={}, name={}", updatedCourse.getId(), updatedCourse.getCourseName()));
});
} catch (Exception e) { } catch (Exception e) {
logger.error("团课二维码生成失败 - id={}, error: {}", course.getId(), e.getMessage(), e); logger.error("团课二维码生成失败 - id={}, error: {}", course.getId(), e.getMessage(), e);
// 即使二维码生成失败也返回成功创建的团课 // 即使二维码生成失败也返回成功创建的团课
@@ -355,9 +526,6 @@ public class GroupCourseService implements IGroupCourseService {
if (groupCourse.getQrCodePath() != null) { if (groupCourse.getQrCodePath() != null) {
existing.setQrCodePath(groupCourse.getQrCodePath()); existing.setQrCodePath(groupCourse.getQrCodePath());
} }
if (groupCourse.getIsRecurring() != null) {
existing.setIsRecurring(groupCourse.getIsRecurring());
}
return groupCourseRepository.update(existing); return groupCourseRepository.update(existing);
}) })
.doOnSuccess(course -> logger.info("团课更新成功 - id={}", id)) .doOnSuccess(course -> logger.info("团课更新成功 - id={}", id))
@@ -488,6 +656,15 @@ public class GroupCourseService implements IGroupCourseService {
public Mono<GroupCourse> signIn(Long courseId, Long memberId) { public Mono<GroupCourse> signIn(Long courseId, Long memberId) {
return groupCourseRepository.findByIdAndDeletedAtIsNull(courseId) return groupCourseRepository.findByIdAndDeletedAtIsNull(courseId)
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在或已删除"))) .switchIfEmpty(Mono.error(new RuntimeException("团课不存在或已删除")))
.flatMap(course ->
// 用实时预约人数覆盖currentMembers
bookingRepository.countValidBookings(courseId)
.map(count -> {
course.setCurrentMembers(count.intValue());
return course;
})
.defaultIfEmpty(course)
)
.flatMap(course -> { .flatMap(course -> {
// 校验1团课已取消 // 校验1团课已取消
if (course.getStatus().equals(CourseStatus.CANCELLED.getValue())) { if (course.getStatus().equals(CourseStatus.CANCELLED.getValue())) {
@@ -514,30 +691,13 @@ public class GroupCourseService implements IGroupCourseService {
return Mono.error(new RuntimeException("课程已满员,无法签到")); return Mono.error(new RuntimeException("课程已满员,无法签到"));
} }
// 校验5用户今日是否已到店签到直接查询sign_in_record表 // 校验5用户已预约此课程有效预约状态为0-已预约
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) return bookingRepository.findValidBooking(courseId, memberId)
.switchIfEmpty(Mono.error(new RuntimeException("您未预约此课程,无法签到"))) .switchIfEmpty(Mono.error(new RuntimeException("您未预约此")))
.flatMap(booking -> { .flatMap(booking -> {
return groupCourseRepository.updateCurrentMembers(courseId, 1) // 更新预约状态为已出席2不再手动计数
.flatMap(updatedCourse -> {
return bookingRepository.updateStatus(booking.getId(), "2") return bookingRepository.updateStatus(booking.getId(), "2")
.thenReturn(updatedCourse); .thenReturn(course);
});
});
}); });
}) })
.doOnSuccess(course -> logger.info("团课签到成功 - courseId={}, memberId={}", courseId, memberId)) .doOnSuccess(course -> logger.info("团课签到成功 - courseId={}, memberId={}", courseId, memberId))
@@ -547,10 +707,11 @@ public class GroupCourseService implements IGroupCourseService {
@Override @Override
public Mono<Void> delete(Long id) { public Mono<Void> delete(Long id) {
// 已取消或已结束的课程才能删除 // 只有已取消或已结束的课程才能删除
return groupCourseRepository.findByIdAndDeletedAtIsNull(id) return groupCourseRepository.findByIdAndDeletedAtIsNull(id)
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在"))) .switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
.flatMap(course -> { .flatMap(course -> {
// 检查课程状态是否为已取消状态码1或已结束状态码2
Long status = course.getStatus(); Long status = course.getStatus();
if (status == null || (!status.equals(CourseStatus.CANCELLED.getValue()) && !status.equals(CourseStatus.ENDED.getValue()))) { if (status == null || (!status.equals(CourseStatus.CANCELLED.getValue()) && !status.equals(CourseStatus.ENDED.getValue()))) {
return Mono.error(new RuntimeException("只有已取消或已结束的课程才能删除,当前状态: " + return Mono.error(new RuntimeException("只有已取消或已结束的课程才能删除,当前状态: " +
@@ -565,14 +726,6 @@ public class GroupCourseService implements IGroupCourseService {
}); });
} }
@Override
public Mono<GroupCourse> restore(Long id) {
return groupCourseRepository.restoreById(id)
.doOnSuccess(course -> logger.info("团课恢复成功 - id={}", id))
.flatMap(course -> clearCache().thenReturn(course))
.doOnError(error -> logger.error("团课恢复失败 - id={}, error: {}", id, error.getMessage()));
}
@Override @Override
public Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query) { public Mono<PageResponse<GroupCourse>> searchGroupCourses(GroupCourseQueryDto query) {
logger.info("多条件查询团课 - courseName={}, courseType={}, startDate={}, endDate={}, timePeriod={}, priceSort={}, remainingMost={}", logger.info("多条件查询团课 - courseName={}, courseType={}, startDate={}, endDate={}, timePeriod={}, priceSort={}, remainingMost={}",
@@ -580,11 +733,8 @@ public class GroupCourseService implements IGroupCourseService {
query.getTimePeriod(), query.getPriceSort(), query.getRemainingMost()); query.getTimePeriod(), query.getPriceSort(), query.getRemainingMost());
return groupCourseRepository.searchGroupCourses(query) return groupCourseRepository.searchGroupCourses(query)
.doOnSuccess(result -> { .doOnSuccess(result -> logger.info("多条件查询结果 - total={}, page={}, size={}",
fillCoverPresignedUrl(result); result.getTotalElements(), result.getCurrentPage(), result.getPageSize()))
logger.info("多条件查询结果 - total={}, page={}, size={}",
result.getTotalElements(), result.getCurrentPage(), result.getPageSize());
})
.doOnError(error -> logger.error("多条件查询失败 - error: {}", error.getMessage())); .doOnError(error -> logger.error("多条件查询失败 - error: {}", error.getMessage()));
} }
@@ -594,22 +744,32 @@ public class GroupCourseService implements IGroupCourseService {
.then(redisUtil.deleteByPattern(CACHE_KEY_DETAIL_PREFIX + "*")).then(); .then(redisUtil.deleteByPattern(CACHE_KEY_DETAIL_PREFIX + "*")).then();
} }
/** @Override
* 将单个 GroupCourse coverImage OSS Key 转换为预签名URL public Mono<List<GroupCourse>> checkCoachConflict(Long coachId, LocalDateTime startTime, LocalDateTime endTime, Long excludeCourseId) {
*/ if (coachId == null || startTime == null || endTime == null) {
private GroupCourse fillCoverPresignedUrl(GroupCourse course) { return Mono.just(Collections.emptyList());
if (course != null) {
course.setCoverImage(OSSUtil.toCoverPresignedUrl(course.getCoverImage()));
} }
return course; return groupCourseRepository.findByCoachId(coachId)
} .filter(course -> {
// 排除已取消的课程
/** if (course.getStatus() != null && course.getStatus().equals(CourseStatus.CANCELLED.getValue())) {
* 将分页结果中所有 GroupCourse coverImage OSS Key 转换为预签名URL return false;
*/
private void fillCoverPresignedUrl(PageResponse<GroupCourse> pageResponse) {
if (pageResponse != null && pageResponse.getContent() != null) {
pageResponse.getContent().forEach(this::fillCoverPresignedUrl);
} }
// 排除自身编辑时
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());
}
});
} }
} }
@@ -1,5 +1,7 @@
package cn.novalon.gym.manage.groupcourse.service.impl; 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.domain.GroupCourseType;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository; import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
@@ -72,16 +74,17 @@ public class GroupCourseTypeService implements IGroupCourseTypeService {
@Override @Override
public Mono<Void> delete(Long id) { public Mono<Void> delete(Long id) {
// 检查是否有团课依赖该类型
return groupCourseRepository.findByCourseType(id) return groupCourseRepository.findByCourseType(id)
.hasElements() .hasElements()
.flatMap(hasCourses -> { .flatMap(hasDependents -> {
if (hasCourses) { if (hasDependents) {
return Mono.<Void>error(new RuntimeException("该类型下存在关联团课,无法删除")); return Mono.<Void>error(new RuntimeException("该类型下存在团课,无法删除"));
} }
return groupCourseTypeRepository.deleteById(id); return groupCourseTypeRepository.deleteById(id)
})
.doOnSuccess(v -> logger.info("团课类型删除成功 - id={}", id)) .doOnSuccess(v -> logger.info("团课类型删除成功 - id={}", id))
.doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage())); .doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage()));
});
} }
@Override @Override
@@ -91,4 +94,12 @@ public class GroupCourseTypeService implements IGroupCourseTypeService {
.filter(category -> category != null && !category.isEmpty()) .filter(category -> category != null && !category.isEmpty())
.distinct(); .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()));
}
} }
@@ -1,193 +0,0 @@
package cn.novalon.gym.manage.groupcourse.util;
import com.aliyun.oss.HttpMethod;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.GeneratePresignedUrlRequest;
import com.aliyun.oss.model.PutObjectRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.InputStream;
import java.net.URL;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
/**
* 阿里云OSS工具类
*/
public class OSSUtil {
private static final Logger logger = LoggerFactory.getLogger(OSSUtil.class);
// OSS配置信息
private static final String ENDPOINT = "https://oss-cn-beijing.aliyuncs.com";
private static final String ACCESS_KEY_ID = "LTAI5t9wHCiH68Xjxg64Xx4Y";
private static final String ACCESS_KEY_SECRET = "isAfz1IFGAnV13LOIrVg19aPhY8aRq";
private static final String BUCKET_NAME = "ycc-filesaver";
// OSS访问地址前缀
private static final String OSS_URL_PREFIX = "https://" + BUCKET_NAME + "." + ENDPOINT + "/";
// 文件存储目录
private static final String QRCODE_DIR = "qrcode/";
private static final String COVER_DIR = "cover/";
// 预签名URL有效期
private static final long PRESIGN_EXPIRE_SECONDS = 300;
// 封面图预签名URL有效期- 1小时确保前端列表页足够展示
private static final long COVER_PRESIGN_EXPIRE_SECONDS = 3600;
/**
* 上传文件到阿里云OSS文件默认继承Bucket权限不设置ACL
*
* @param localFilePath 本地文件路径
* @param fileName 文件名不含路径
* @return OSS object key不含域名前缀
*/
public static String uploadToOSS(String localFilePath, String fileName) {
OSS ossClient = null;
try {
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
String datePath = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String ossFilePath = QRCODE_DIR + datePath + "/" + fileName;
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath));
ossClient.putObject(putObjectRequest);
logger.info("文件上传到OSS成功: localPath={}, ossKey={}", localFilePath, ossFilePath);
return ossFilePath;
} catch (Exception e) {
logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e);
throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e);
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
/**
* 上传文件到阿里云OSS自定义存储路径文件默认继承Bucket权限
*
* @param localFilePath 本地文件路径
* @param ossDirectory OSS存储目录
* @param fileName 文件名不含路径
* @return OSS object key不含域名前缀
*/
public static String uploadToOSS(String localFilePath, String ossDirectory, String fileName) {
OSS ossClient = null;
try {
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
String ossFilePath = ossDirectory + fileName;
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKET_NAME, ossFilePath, new File(localFilePath));
ossClient.putObject(putObjectRequest);
logger.info("文件上传到OSS成功: localPath={}, ossKey={}", localFilePath, ossFilePath);
return ossFilePath;
} catch (Exception e) {
logger.error("文件上传到OSS失败 - localPath: {}, error: {}", localFilePath, e.getMessage(), e);
throw new RuntimeException("文件上传到OSS失败: " + e.getMessage(), e);
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
/**
* 上传封面图到阿里云OSS使用InputStream文件默认继承Bucket权限
*
* @param inputStream 文件输入流
* @param fileName 文件名不含路径
* @return OSS object key不含域名前缀
*/
public static String uploadCoverToOSS(InputStream inputStream, String fileName) {
OSS ossClient = null;
try {
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
String datePath = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String ossFilePath = COVER_DIR + datePath + "/" + fileName;
ossClient.putObject(BUCKET_NAME, ossFilePath, inputStream);
logger.info("封面上传到OSS成功: fileName={}, ossKey={}", fileName, ossFilePath);
return ossFilePath;
} catch (Exception e) {
logger.error("封面上传到OSS失败 - fileName: {}, error: {}", fileName, e.getMessage(), e);
throw new RuntimeException("封面上传到OSS失败: " + e.getMessage(), e);
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
/**
* 生成OSS对象预签名URL临时访问链接
*
* @param ossKey OSS对象Key不含域名前缀
* @return 预签名URL
*/
public static String generatePresignedUrl(String ossKey) {
return generatePresignedUrl(ossKey, PRESIGN_EXPIRE_SECONDS);
}
/**
* 生成OSS对象预签名URL可指定有效期
*
* @param ossKey OSS对象Key不含域名前缀
* @param expireSeconds 有效期
* @return 预签名URL
*/
public static String generatePresignedUrl(String ossKey, long expireSeconds) {
OSS ossClient = null;
try {
ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY_ID, ACCESS_KEY_SECRET);
Date expiration = new Date(System.currentTimeMillis() + expireSeconds * 1000);
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(BUCKET_NAME, ossKey, HttpMethod.GET);
request.setExpiration(expiration);
URL signedUrl = ossClient.generatePresignedUrl(request);
return signedUrl.toString();
} catch (Exception e) {
logger.error("生成预签名URL失败 - ossKey: {}, error: {}", ossKey, e.getMessage(), e);
throw new RuntimeException("生成预签名URL失败: " + e.getMessage(), e);
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
/**
* 根据OSS Key拼接公开访问URL仅当Bucket为公共读时有效
*/
public static String getPublicUrl(String ossKey) {
return OSS_URL_PREFIX + ossKey;
}
/**
* 将OSS Key相对路径转换为封面图预签名URL1小时有效期
* 如果已经是HTTP(S)完整URL则直接返回
*
* @param ossKey OSS对象Key或完整URL
* @return 可访问的预签名URLossKey为空时返回null
*/
public static String toCoverPresignedUrl(String ossKey) {
if (ossKey == null || ossKey.isBlank()) {
return null;
}
if (ossKey.startsWith("http://") || ossKey.startsWith("https://")) {
return ossKey;
}
return generatePresignedUrl(ossKey, COVER_PRESIGN_EXPIRE_SECONDS);
}
}
@@ -10,46 +10,33 @@ import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException; 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.HashMap;
import java.util.Map; import java.util.Map;
import java.util.UUID;
/** /**
* 二维码生成工具类 * 二维码生成工具类
* 生成二维码的字节数组由调用方统一通过文件服务保存
*/ */
public class QRCodeUtil { public class QRCodeUtil {
private static final Logger logger = LoggerFactory.getLogger(QRCodeUtil.class); 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_WIDTH = 300;
private static final int QR_CODE_HEIGHT = 300; private static final int QR_CODE_HEIGHT = 300;
/** /**
* 生成二维码并保存到指定路径 * 生成二维码图片的字节数组
* *
* @param content 二维码内容 * @param content 二维码内容
* @param savePath 保存路径 * @return PNG格式的二维码图片字节数组
* @param fileName 文件名不含扩展名
* @return 生成的二维码文件完整路径
*/ */
public static String generateQRCode(String content, String savePath, String fileName) { public static byte[] generateQrCodeBytes(String content) {
try { try {
Path directory = Paths.get(savePath);
if (!Files.exists(directory)) {
Files.createDirectories(directory);
logger.info("创建二维码保存目录: {}", savePath);
}
Map<EncodeHintType, Object> hints = new HashMap<>(); Map<EncodeHintType, Object> hints = new HashMap<>();
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8"); hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H); hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
@@ -58,99 +45,21 @@ public class QRCodeUtil {
QRCodeWriter qrCodeWriter = new QRCodeWriter(); QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, QR_CODE_WIDTH, QR_CODE_HEIGHT, hints); BitMatrix bitMatrix = qrCodeWriter.encode(content, BarcodeFormat.QR_CODE, QR_CODE_WIDTH, QR_CODE_HEIGHT, hints);
String filePath = Paths.get(savePath, fileName + ".png").toString(); BufferedImage image = MatrixToImageWriter.toBufferedImage(bitMatrix);
Path path = Paths.get(filePath);
MatrixToImageWriter.writeToPath(bitMatrix, "PNG", path); ByteArrayOutputStream baos = new ByteArrayOutputStream();
logger.info("二维码生成成功: {}", filePath); ImageIO.write(image, "PNG", baos);
byte[] bytes = baos.toByteArray();
baos.close();
return filePath; logger.info("二维码字节数组生成成功, size={} bytes", bytes.length);
return bytes;
} catch (WriterException e) { } catch (WriterException e) {
logger.error("二维码生成失败 - WriterException: {}", e.getMessage(), e); logger.error("二维码生成失败 - WriterException: {}", e.getMessage(), e);
throw new RuntimeException("二维码生成失败: " + e.getMessage(), e); throw new RuntimeException("二维码生成失败: " + e.getMessage(), e);
} catch (IOException e) { } catch (IOException e) {
logger.error("二维码保存失败 - IOException: {}", e.getMessage(), e); logger.error("二维码输出失败 - IOException: {}", e.getMessage(), e);
throw new RuntimeException("二维码保存失败: " + 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);
} }
} }
} }
@@ -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; }
}
@@ -1,9 +1,6 @@
package cn.novalon.gym.manage.groupcourse.util; package cn.novalon.gym.manage.groupcourse.util;
import org.junit.jupiter.api.Test; 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.*; import static org.junit.jupiter.api.Assertions.*;
@@ -12,58 +9,41 @@ import static org.junit.jupiter.api.Assertions.*;
*/ */
class QRCodeUtilTest { class QRCodeUtilTest {
@TempDir
Path tempDir;
@Test @Test
void testGenerateQRCode() { void testGenerateQrCodeBytes() {
String content = "测试二维码内容"; String content = "测试二维码内容";
String qrCodePath = QRCodeUtil.generateQRCode(content); byte[] bytes = QRCodeUtil.generateQrCodeBytes(content);
assertNotNull(qrCodePath, "二维码路径不应为空"); assertNotNull(bytes, "二维码字节数组不应为空");
assertTrue(qrCodePath.endsWith(".png"), "二维码文件应为PNG格式"); assertTrue(bytes.length > 0, "二维码字节数组应包含数据");
assertTrue(qrCodePath.contains("D:\\Games\\exmp\\image"), "二维码应保存到指定路径");
System.out.println("生成的二维码路径: " + qrCodePath); System.out.println("生成的二维码字节大小: " + bytes.length + " bytes");
} }
@Test @Test
void testGenerateQRCodeWithCustomPath() { void testGenerateQrCodeBytesWithJsonContent() {
String content = "自定义路径测试"; String jsonContent = "{\"id\":1,\"courseName\":\"瑜伽课\",\"coachId\":100,\"startTime\":\"2026-07-14T10:00:00\"}";
String customPath = tempDir.toString();
String fileName = "test_qrcode";
String qrCodePath = QRCodeUtil.generateQRCode(content, customPath, fileName); byte[] bytes = QRCodeUtil.generateQrCodeBytes(jsonContent);
assertNotNull(qrCodePath, "二维码路径不应为空"); assertNotNull(bytes, "二维码字节数组不应为空");
assertTrue(qrCodePath.endsWith(".png"), "二维码文件应为PNG格式"); assertTrue(bytes.length > 0, "二维码字节数组应包含数据");
assertTrue(qrCodePath.contains(fileName), "二维码文件名应包含指定名称");
System.out.println("生成的二维码路径: " + qrCodePath); System.out.println("JSON内容二维码字节大小: " + bytes.length + " bytes");
} }
@Test @Test
void testGenerateQRCodeWithJsonContent() { void testGenerateQrCodeBytesWithLongContent() {
String jsonContent = "{\"id\":1,\"courseName\":\"瑜伽课\",\"coachId\":100,\"startTime\":\"2026-06-18T10:00:00\"}"; StringBuilder sb = new StringBuilder();
for (int i = 0; i < 5; i++) {
String qrCodePath = QRCodeUtil.generateQRCode(jsonContent); sb.append("这是第").append(i).append("行测试数据\n");
assertNotNull(qrCodePath, "二维码路径不应为空");
assertTrue(qrCodePath.endsWith(".png"), "二维码文件应为PNG格式");
System.out.println("JSON内容二维码路径: " + qrCodePath);
} }
@Test byte[] bytes = QRCodeUtil.generateQrCodeBytes(sb.toString());
void testGenerateQRCodeAndUploadToOSS() {
String jsonContent = "{\"id\":1,\"courseName\":\"瑜伽课\",\"coachId\":100,\"startTime\":\"2026-06-18T10:00:00\"}";
String ossUrl = QRCodeUtil.generateQRCodeAndUploadToOSS(jsonContent); assertNotNull(bytes, "二维码字节数组不应为空");
assertTrue(bytes.length > 0, "长内容二维码应正常生成");
assertNotNull(ossUrl, "OSS访问地址不应为空"); System.out.println("多行内容二维码字节大小: " + bytes.length + " bytes");
assertTrue(ossUrl.startsWith("qrcode/"), "OSS访问地址应以qrcode/开头");
assertTrue(ossUrl.endsWith(".png"), "OSS访问地址应为PNG格式");
System.out.println("上传到OSS的二维码地址: " + ossUrl);
} }
} }
@@ -16,6 +16,9 @@ import org.springframework.stereotype.Component;
@ConfigurationProperties(prefix = "wechat") @ConfigurationProperties(prefix = "wechat")
public class WechatProperties { public class WechatProperties {
// Mock模式true=使用模拟数据false=调用真实微信API
private Boolean mockEnabled;
// 小程序配置 // 小程序配置
private MiniApp miniapp = new MiniApp(); private MiniApp miniapp = new MiniApp();
@@ -1,59 +0,0 @@
package cn.novalon.gym.manage.member.dto;
import cn.novalon.gym.manage.member.enums.GenderEnum;
import lombok.Data;
import java.time.LocalDate;
/**
* 管理员编辑会员信息DTO含密码验证
*
* @author system
* @date 2026-06-24
*/
@Data
public class AdminEditMemberDto {
/**
* 管理员密码必填用于验证身份
*/
private String adminPassword;
/**
* 昵称
*/
private String nickname;
/**
* 性别
*/
private GenderEnum gender;
/**
* 生日
*/
private LocalDate birthday;
/**
* 地址
*/
private String address;
/**
* 头像
*/
private String avatar;
/**
* 转换为 UpdateMemberInfoDto
*/
public UpdateMemberInfoDto toUpdateMemberInfoDto() {
UpdateMemberInfoDto dto = new UpdateMemberInfoDto();
dto.setNickname(nickname);
dto.setGender(gender);
dto.setBirthday(birthday);
dto.setAddress(address);
dto.setAvatar(avatar);
return dto;
}
}
@@ -2,21 +2,16 @@ package cn.novalon.gym.manage.member.handler;
import cn.novalon.gym.manage.member.entity.MemberCard; import cn.novalon.gym.manage.member.entity.MemberCard;
import cn.novalon.gym.manage.member.service.IMemberCardService; import cn.novalon.gym.manage.member.service.IMemberCardService;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.util.Map;
/** /**
* 会员卡管理处理器 * 会员卡管理处理器
* *
@@ -29,13 +24,9 @@ import java.util.Map;
public class MemberCardHandler { public class MemberCardHandler {
private final IMemberCardService memberCardService; private final IMemberCardService memberCardService;
private final ISysUserService sysUserService;
private final AuthUtil authUtil;
public MemberCardHandler(IMemberCardService memberCardService, ISysUserService sysUserService, AuthUtil authUtil) { public MemberCardHandler(IMemberCardService memberCardService) {
this.memberCardService = memberCardService; this.memberCardService = memberCardService;
this.sysUserService = sysUserService;
this.authUtil = authUtil;
} }
@Operation(summary = "根据ID查询会员卡类型", description = "查询指定ID的会员卡类型详情") @Operation(summary = "根据ID查询会员卡类型", description = "查询指定ID的会员卡类型详情")
@@ -69,65 +60,32 @@ public class MemberCardHandler {
.flatMap(card -> ServerResponse.status(HttpStatus.CREATED).bodyValue(card)); .flatMap(card -> ServerResponse.status(HttpStatus.CREATED).bodyValue(card));
} }
@Operation(summary = "更新会员卡类型", description = "更新会员卡类型信息,需验证管理员密码") @Operation(summary = "更新会员卡类型", description = "更新会员卡类型信息")
public Mono<ServerResponse> updateMemberCard(ServerRequest request) { public Mono<ServerResponse> updateMemberCard(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null); return memberCardService.findByMemberCardIdAndDeletedAtIsNull(id)
.switchIfEmpty(Mono.error(new RuntimeException("会员卡不存在: " + id)))
if (adminPassword == null || adminPassword.isBlank()) { .zipWith(request.bodyToMono(MemberCard.class))
return ServerResponse.badRequest() .flatMap(tuple -> {
.contentType(MediaType.APPLICATION_JSON) MemberCard existing = tuple.getT1();
.bodyValue(Map.of("code", 400, "message", "管理员密码不能为空")) MemberCard body = tuple.getT2();
.flatMap(resp -> Mono.just(resp)); // 仅更新非 null 字段避免 null 覆盖已有值
} if (body.getMemberCardName() != null) existing.setMemberCardName(body.getMemberCardName());
if (body.getMemberCardType() != null) existing.setMemberCardType(body.getMemberCardType());
return sysUserService.verifyPassword(adminId, adminPassword) if (body.getMemberCardPrice() != null) existing.setMemberCardPrice(body.getMemberCardPrice());
.flatMap(valid -> { if (body.getMemberCardValidityDays() != null) existing.setMemberCardValidityDays(body.getMemberCardValidityDays());
if (!valid) { if (body.getMemberCardTotalTimes() != null) existing.setMemberCardTotalTimes(body.getMemberCardTotalTimes());
return ServerResponse.status(HttpStatus.FORBIDDEN) if (body.getMemberCardAmount() != null) existing.setMemberCardAmount(body.getMemberCardAmount());
.contentType(MediaType.APPLICATION_JSON) if (body.getMemberCardStatus() != null) existing.setMemberCardStatus(body.getMemberCardStatus());
.bodyValue(Map.of("code", 403, "message", "管理员密码错误")) if (body.getExtraConfig() != null) existing.setExtraConfig(body.getExtraConfig());
.flatMap(resp -> Mono.just(resp));
}
return request.bodyToMono(MemberCard.class)
.flatMap(body -> memberCardService.findByMemberCardIdAndDeletedAtIsNull(id)
.flatMap(existing -> {
existing.setMemberCardName(body.getMemberCardName());
existing.setMemberCardType(body.getMemberCardType());
existing.setMemberCardPrice(body.getMemberCardPrice());
existing.setMemberCardValidityDays(body.getMemberCardValidityDays());
existing.setMemberCardTotalTimes(body.getMemberCardTotalTimes());
existing.setMemberCardAmount(body.getMemberCardAmount());
existing.setMemberCardStatus(body.getMemberCardStatus());
return memberCardService.save(existing); return memberCardService.save(existing);
}) })
.flatMap(updated -> ServerResponse.ok().bodyValue(updated)) .flatMap(updated -> ServerResponse.ok().bodyValue(updated));
.switchIfEmpty(ServerResponse.notFound().build()));
});
} }
@Operation(summary = "删除会员卡类型", description = "逻辑删除会员卡类型,需验证管理员密码") @Operation(summary = "删除会员卡类型", description = "逻辑删除会员卡类型")
public Mono<ServerResponse> deleteMemberCard(ServerRequest request) { public Mono<ServerResponse> deleteMemberCard(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request);
Long id = Long.valueOf(request.pathVariable("id")); Long id = Long.valueOf(request.pathVariable("id"));
String adminPassword = request.queryParam("adminPassword").orElse(null);
if (adminPassword == null || adminPassword.isBlank()) {
return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "管理员密码不能为空"))
.flatMap(resp -> Mono.just(resp));
}
return sysUserService.verifyPassword(adminId, adminPassword)
.flatMap(valid -> {
if (!valid) {
return ServerResponse.status(HttpStatus.FORBIDDEN)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 403, "message", "管理员密码错误"))
.flatMap(resp -> Mono.just(resp));
}
return memberCardService.logicalDelete(id) return memberCardService.logicalDelete(id)
.flatMap(rows -> { .flatMap(rows -> {
if (rows > 0) { if (rows > 0) {
@@ -135,7 +93,6 @@ public class MemberCardHandler {
} }
return ServerResponse.notFound().build(); return ServerResponse.notFound().build();
}); });
});
} }
@Operation(summary = "购买会员卡", description = "会员购买会员卡,生成会员卡记录") @Operation(summary = "购买会员卡", description = "会员购买会员卡,生成会员卡记录")
@@ -1,7 +1,7 @@
package cn.novalon.gym.manage.member.handler; package cn.novalon.gym.manage.member.handler;
import cn.novalon.gym.manage.common.exception.NotFoundException; import cn.novalon.gym.manage.common.exception.NotFoundException;
import cn.novalon.gym.manage.member.dto.AdminEditMemberDto; import cn.novalon.gym.manage.member.config.WechatProperties;
import cn.novalon.gym.manage.member.dto.AdminUpdatePhoneDto; import cn.novalon.gym.manage.member.dto.AdminUpdatePhoneDto;
import cn.novalon.gym.manage.member.dto.SearchMemberDto; import cn.novalon.gym.manage.member.dto.SearchMemberDto;
import cn.novalon.gym.manage.member.dto.UpdateMemberInfoDto; import cn.novalon.gym.manage.member.dto.UpdateMemberInfoDto;
@@ -10,8 +10,8 @@ import cn.novalon.gym.manage.member.service.WechatAuthService;
import cn.novalon.gym.manage.member.service.WechatOfficialService; import cn.novalon.gym.manage.member.service.WechatOfficialService;
import cn.novalon.gym.manage.member.util.AesUtil; import cn.novalon.gym.manage.member.util.AesUtil;
import cn.novalon.gym.manage.member.util.WechatPhoneUtil; import cn.novalon.gym.manage.member.util.WechatPhoneUtil;
import cn.novalon.gym.manage.sys.core.service.ISysUserService;
import cn.novalon.gym.manage.sys.util.AuthUtil; import cn.novalon.gym.manage.sys.util.AuthUtil;
import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -24,8 +24,6 @@ import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.util.Map;
/** /**
* 会员信息处理器 * 会员信息处理器
* *
@@ -43,7 +41,6 @@ public class MemberHandler {
private final WechatAuthService wechatAuthService; private final WechatAuthService wechatAuthService;
private final WechatOfficialService wechatOfficialService; private final WechatOfficialService wechatOfficialService;
private final AuthUtil authUtil; private final AuthUtil authUtil;
private final ISysUserService sysUserService;
@Operation(summary = "获取会员信息", description = "根据当前登录用户获取会员基本信息") @Operation(summary = "获取会员信息", description = "根据当前登录用户获取会员基本信息")
public Mono<ServerResponse> getMemberInfo(ServerRequest request) { public Mono<ServerResponse> getMemberInfo(ServerRequest request) {
@@ -158,8 +155,8 @@ public class MemberHandler {
String decryptedPhone = AesUtil.decrypt(detail.getPhone()); String decryptedPhone = AesUtil.decrypt(detail.getPhone());
detail.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone)); detail.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
} catch (Exception e) { } catch (Exception e) {
log.error("手机号解密失败, memberId: {}", detail.getId(), e); log.warn("手机号解密失败(可能为明文存储), memberId: {}", detail.getId());
detail.setPhone(null); detail.setPhone(WechatPhoneUtil.maskPhone(detail.getPhone()));
} }
} }
return ServerResponse.ok() return ServerResponse.ok()
@@ -168,7 +165,7 @@ public class MemberHandler {
}); });
} }
@Operation(summary = "管理员编辑会员信息", description = "后台管理员编辑会员信息,需验证管理员密码") @Operation(summary = "管理员编辑会员信息", description = "后台管理员编辑会员信息")
public Mono<ServerResponse> adminUpdateMemberInfo(ServerRequest request) { public Mono<ServerResponse> adminUpdateMemberInfo(ServerRequest request) {
Long adminId = authUtil.getMemberIdOrThrow(request); Long adminId = authUtil.getMemberIdOrThrow(request);
@@ -177,30 +174,14 @@ public class MemberHandler {
long memberId = NumberUtils.toLong(memberIdStr, 0L); long memberId = NumberUtils.toLong(memberIdStr, 0L);
if(memberId <= 0L) throw new IllegalArgumentException("会员ID格式错误"); if(memberId <= 0L) throw new IllegalArgumentException("会员ID格式错误");
// TODO: 补充签到记录
log.info("前台编辑会员信息, adminId: {}, memberId: {}", adminId, memberId); log.info("前台编辑会员信息, adminId: {}, memberId: {}", adminId, memberId);
return request.bodyToMono(AdminEditMemberDto.class) return request.bodyToMono(UpdateMemberInfoDto.class)
.flatMap(dto -> { .flatMap(updateDto -> memberService.adminUpdateMemberInfo(memberId, updateDto))
if (dto.getAdminPassword() == null || dto.getAdminPassword().isBlank()) { .flatMap(detail -> ServerResponse.ok()
return ServerResponse.badRequest()
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 400, "message", "管理员密码不能为空")) .bodyValue(detail));
.flatMap(resp -> Mono.<ServerResponse>just(resp));
}
return sysUserService.verifyPassword(adminId, dto.getAdminPassword())
.flatMap(valid -> {
if (!valid) {
return ServerResponse.status(HttpStatus.FORBIDDEN)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("code", 403, "message", "管理员密码错误"))
.flatMap(resp -> Mono.<ServerResponse>just(resp));
}
return memberService.adminUpdateMemberInfo(memberId, dto.toUpdateMemberInfoDto())
.flatMap(result -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(result));
});
});
} }
@Operation(summary = "搜索会员列表", description = "后台管理员按关键词搜索会员,支持性别筛选和分页") @Operation(summary = "搜索会员列表", description = "后台管理员按关键词搜索会员,支持性别筛选和分页")
@@ -223,8 +204,8 @@ public class MemberHandler {
String decryptedPhone = AesUtil.decrypt(member.getPhone()); String decryptedPhone = AesUtil.decrypt(member.getPhone());
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone)); member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
} catch (Exception e) { } catch (Exception e) {
log.error("手机号解密失败, memberId: {}", member.getId(), e); log.warn("手机号解密失败(可能为明文存储), memberId: {}", member.getId());
member.setPhone(null); member.setPhone(WechatPhoneUtil.maskPhone(member.getPhone()));
} }
} }
return member; return member;
@@ -241,11 +222,13 @@ public class MemberHandler {
int pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1); int pageNum = NumberUtils.toInt(request.queryParam("pageNum").orElse("1"), 1);
int pageSize = NumberUtils.toInt(request.queryParam("pageSize").orElse("10"), 10); 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); log.info("前台查看会员列表, adminId: {}, pageNum: {}, pageSize: {}, sortField: {}, sortOrder: {}",
// TODO: 补充签到记录 adminId, pageNum, pageSize, sortField, sortOrder);
return memberService.findAll(pageNum, pageSize) return memberService.findAll(pageNum, pageSize, sortField, sortOrder)
.map(member -> { .map(member -> {
// 解密手机号 // 解密手机号
if (member.getPhone() != null && !member.getPhone().isEmpty()) { if (member.getPhone() != null && !member.getPhone().isEmpty()) {
@@ -253,8 +236,8 @@ public class MemberHandler {
String decryptedPhone = AesUtil.decrypt(member.getPhone()); String decryptedPhone = AesUtil.decrypt(member.getPhone());
member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone)); member.setPhone(WechatPhoneUtil.maskPhone(decryptedPhone));
} catch (Exception e) { } catch (Exception e) {
log.error("手机号解密失败, memberId: {}", member.getId(), e); log.warn("手机号解密失败(可能为明文存储), memberId: {}", member.getId());
member.setPhone(null); member.setPhone(WechatPhoneUtil.maskPhone(member.getPhone()));
} }
} }
return member; return member;
@@ -46,7 +46,7 @@ public interface MemberCardRepository extends R2dbcRepository<MemberCard, Long>
"AND (:type IS NULL OR member_card_type = :type) " + "AND (:type IS NULL OR member_card_type = :type) " +
"AND (:minPrice IS NULL OR member_card_price >= :minPrice) " + "AND (:minPrice IS NULL OR member_card_price >= :minPrice) " +
"AND (:maxPrice IS NULL OR member_card_price <= :maxPrice) " + "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, Flux<MemberCard> findWithConditions(Integer status, String name, String type,
Double minPrice, Double maxPrice, Pageable pageable); Double minPrice, Double maxPrice, Pageable pageable);
@@ -58,7 +58,7 @@ public interface MemberService {
* @param pageSize 页大小 * @param pageSize 页大小
* @return 所有会员信息 * @return 所有会员信息
*/ */
Flux<Member> findAll(Integer pageNum, Integer pageSize); Flux<Member> findAll(Integer pageNum, Integer pageSize, String sortField, String sortOrder);
/** /**
* 前台管理端获取会员详情含会员卡信息 * 前台管理端获取会员详情含会员卡信息
@@ -26,6 +26,7 @@ import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
@@ -157,6 +158,7 @@ public class MemberServiceImpl implements MemberService {
return MemberInfoVO.builder() return MemberInfoVO.builder()
.id(member.getId()) .id(member.getId())
.memberNo(member.getMemberNo())
.nickname(member.getNickname()) .nickname(member.getNickname())
.phone(maskedPhone) .phone(maskedPhone)
.gender(genderEnum) .gender(genderEnum)
@@ -165,6 +167,7 @@ public class MemberServiceImpl implements MemberService {
.avatar(member.getAvatar()) .avatar(member.getAvatar())
.hasPhone(phone != null) .hasPhone(phone != null)
.isSubscribed(member.getSubscribed() != null && member.getSubscribed()) .isSubscribed(member.getSubscribed() != null && member.getSubscribed())
.lastLoginAt(member.getLastLoginAt())
.build(); .build();
} }
@@ -230,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 @Override
public Flux<Member> findAll(Integer pageNum, Integer pageSize) { public Flux<Member> findAll(Integer pageNum, Integer pageSize, String sortField, String sortOrder) {
log.info("查询所有会员列表, pageNum: {}, pageSize: {}", pageNum, pageSize); 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( Pageable pageable = PageRequest.of(
pageNum - 1, pageNum - 1,
pageSize pageSize,
Sort.by(direction, dbField)
); );
return memberRepository.findAllBy(pageable); return memberRepository.findAllBy(pageable);
@@ -249,22 +270,13 @@ public class MemberServiceImpl implements MemberService {
String cacheKey = MEMBER_DETAIL_CACHE_PREFIX + memberId; String cacheKey = MEMBER_DETAIL_CACHE_PREFIX + memberId;
return redisUtil.get(cacheKey, MemberDetailVO.class) return redisUtil.get(cacheKey, MemberDetailVO.class)
.flatMap(cached -> { .filter(cached -> cached != null)
if (cached != null) { .switchIfEmpty(Mono.defer(() ->
log.debug("从缓存获取会员详情, memberId: {}", memberId); memberRepository.findById(memberId)
return Mono.just(cached); .switchIfEmpty(Mono.error(() -> {
} log.error("会员不存在: memberId={}", memberId);
// 缓存反序列化异常查数据库 throw new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
return queryMemberDetailFromDb(memberId, cacheKey); }))
})
.switchIfEmpty(Mono.defer(() -> {
// 缓存不存在查数据库
return queryMemberDetailFromDb(memberId, cacheKey);
}));
}
private Mono<MemberDetailVO> queryMemberDetailFromDb(Long memberId, String cacheKey) {
return memberRepository.findById(memberId)
.zipWith( .zipWith(
memberRepository.findCardRecordsWithCardInfoByMemberId(memberId) memberRepository.findCardRecordsWithCardInfoByMemberId(memberId)
.collectList(), .collectList(),
@@ -302,10 +314,7 @@ public class MemberServiceImpl implements MemberService {
) )
.flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS) .flatMap(vo -> redisUtil.setWithExpire(cacheKey, vo, CACHE_EXPIRE_SECONDS)
.then(Mono.just(vo))) .then(Mono.just(vo)))
.switchIfEmpty(Mono.error(() -> { ));
log.error("会员不存在: memberId={}", memberId);
return new NotFoundException(ErrorCode.NOT_FOUND_USER, "会员不存在");
}));
} }
@@ -43,12 +43,25 @@ public class WechatApiServiceImpl implements WechatApiService {
@Override @Override
public Mono<Map<String, String>> jsCode2Session(String code) { public Mono<Map<String, String>> jsCode2Session(String code) {
log.info("微信jsCode2Session API"); log.info("微信jsCode2Session API, code: {}", code);
log.info("信息 - AppID: {}, AppSecret {}",
// 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().getAppId(),
wechatProperties.getMiniapp().getAppSecret() != null ? wechatProperties.getMiniapp().getAppSecret() != null ?
wechatProperties.getMiniapp().getAppSecret().substring(0, Math.min(4, wechatProperties.getMiniapp().getAppSecret().length())) + "***" : "null"); wechatProperties.getMiniapp().getAppSecret().substring(0, Math.min(4, wechatProperties.getMiniapp().getAppSecret().length())) + "***" : "null");
log.info(" - code: {}", code);
return webClient.get() return webClient.get()
.uri(uriBuilder -> uriBuilder .uri(uriBuilder -> uriBuilder
@@ -152,6 +165,12 @@ public class WechatApiServiceImpl implements WechatApiService {
public Mono<String> getAccessToken(String appType) { public Mono<String> getAccessToken(String appType) {
log.debug("获取access_token, appType: {}", 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; String cacheKey = ACCESS_TOKEN_CACHE_PREFIX + appType;
return redisUtil.get(cacheKey, String.class) return redisUtil.get(cacheKey, String.class)
@@ -7,6 +7,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Date; import java.util.Date;
/** /**
@@ -25,6 +26,9 @@ public class MemberInfoVO {
// 会员 ID // 会员 ID
private Long id; private Long id;
// 会员编号
private String memberNo;
// 昵称 // 昵称
private String nickname; private String nickname;
@@ -48,4 +52,7 @@ public class MemberInfoVO {
// 是否已关注公众号 // 是否已关注公众号
private Boolean isSubscribed; private Boolean isSubscribed;
// 最后登录时间
private LocalDateTime lastLoginAt;
} }
@@ -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());
}
}
@@ -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; }
}
}
@@ -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);
}
}
@@ -1,54 +0,0 @@
package cn.novalon.gym.manage.payment.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 支付记录响应DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "支付记录")
public class PaymentRecordResponse {
@Schema(description = "订单ID")
private Long id;
@Schema(description = "订单号")
private String orderNo;
@Schema(description = "用户编号(会员ID)")
private Long memberId;
@Schema(description = "支付方式(ALIPAY/WECHAT)")
private String tradeType;
@Schema(description = "购买内容(商品描述)")
private String goodsDesc;
@Schema(description = "订单类型(MEMBER_CARD/GROUP_COURSE/GOODS)")
private String orderType;
@Schema(description = "支付金额(元)")
private BigDecimal transAmt;
@Schema(description = "支付状态(PENDING/SUCCESS/FAIL/CLOSED)")
private String payStatus;
@Schema(description = "支付时间")
private String payTime;
@Schema(description = "汇付流水号")
private String hfSeqId;
@Schema(description = "创建时间")
private String createdAt;
}
@@ -1,47 +0,0 @@
package cn.novalon.gym.manage.payment.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
* 营业额统计数据
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "营业额统计数据")
public class RevenueStatistics {
@Schema(description = "今日收入(元)")
private BigDecimal todayIncome;
@Schema(description = "今日退款(元)")
private BigDecimal todayRefund;
@Schema(description = "今日订单数")
private Long todayOrderCount;
@Schema(description = "当月收入(元)")
private BigDecimal monthIncome;
@Schema(description = "当月退款(元)")
private BigDecimal monthRefund;
@Schema(description = "当月订单数")
private Long monthOrderCount;
@Schema(description = "当年收入(元)")
private BigDecimal yearIncome;
@Schema(description = "当年退款(元)")
private BigDecimal yearRefund;
@Schema(description = "当年订单数")
private Long yearOrderCount;
}
@@ -1,74 +0,0 @@
package cn.novalon.gym.manage.payment.handler;
import cn.novalon.gym.manage.payment.dto.ApiResponse;
import cn.novalon.gym.manage.payment.service.PaymentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
/**
* 支付营业数据 Handler
*
* @author system
* @date 2026-06-29
*/
@Slf4j
@Component
@Tag(name = "支付营业数据", description = "营业额统计与支付记录查询")
public class PaymentRevenueHandler {
private final PaymentService paymentService;
public PaymentRevenueHandler(PaymentService paymentService) {
this.paymentService = paymentService;
}
@Operation(summary = "获取营业额统计", description = "获取今日、当月、当年的收入和退款情况")
public Mono<ServerResponse> getRevenueStatistics(ServerRequest request) {
log.info("[Revenue] 查询营业额统计");
return paymentService.getRevenueStatistics()
.flatMap(stats -> ServerResponse.ok()
.bodyValue(ApiResponse.success(stats)))
.onErrorResume(e -> {
log.error("[Revenue] 查询营业额统计失败", e);
return ServerResponse.ok()
.bodyValue(ApiResponse.error("查询营业额统计失败: " + e.getMessage()));
});
}
@Operation(summary = "查询支付记录", description = "分页查询用户支付记录,支持按会员ID、支付状态、支付方式筛选")
public Mono<ServerResponse> getPaymentRecords(ServerRequest request) {
String memberIdStr = request.queryParam("memberId").orElse(null);
Long memberId = null;
if (memberIdStr != null && !memberIdStr.isEmpty()) {
try {
memberId = Long.parseLong(memberIdStr);
} catch (NumberFormatException e) {
return ServerResponse.ok()
.bodyValue(ApiResponse.error("会员ID格式不正确"));
}
}
String payStatus = request.queryParam("payStatus").orElse(null);
String tradeType = request.queryParam("tradeType").orElse(null);
int page = Integer.parseInt(request.queryParam("page").orElse("1"));
int pageSize = Integer.parseInt(request.queryParam("pageSize").orElse("20"));
log.info("[Revenue] 查询支付记录: memberId={}, payStatus={}, tradeType={}, page={}, pageSize={}",
memberId, payStatus, tradeType, page, pageSize);
return paymentService.getPaymentRecords(memberId, payStatus, tradeType, page, pageSize)
.flatMap(result -> ServerResponse.ok()
.bodyValue(ApiResponse.success(result)))
.onErrorResume(e -> {
log.error("[Revenue] 查询支付记录失败", e);
return ServerResponse.ok()
.bodyValue(ApiResponse.error("查询支付记录失败: " + e.getMessage()));
});
}
}
@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Repository @Repository
@@ -39,24 +38,4 @@ public interface PaymentOrderRepository extends R2dbcRepository<PaymentOrder, Lo
Flux<PaymentOrder> findAllByDeletedAtIsNull(); Flux<PaymentOrder> findAllByDeletedAtIsNull();
Flux<PaymentOrder> findByMemberIdAndDeletedAtIsNull(Long memberId); Flux<PaymentOrder> findByMemberIdAndDeletedAtIsNull(Long memberId);
// ===== 营业额统计 =====
/** 统计指定时间范围内成功支付的金额总和 */
@Query("SELECT COALESCE(SUM(trans_amt), 0) FROM payment_order WHERE pay_status = 'SUCCESS' AND pay_time >= :startTime AND pay_time < :endTime AND deleted_at IS NULL")
Mono<BigDecimal> sumSuccessAmount(LocalDateTime startTime, LocalDateTime endTime);
/** 统计指定时间范围内的成功订单数 */
@Query("SELECT COUNT(*) FROM payment_order WHERE pay_status = 'SUCCESS' AND pay_time >= :startTime AND pay_time < :endTime AND deleted_at IS NULL")
Mono<Long> countSuccessOrders(LocalDateTime startTime, LocalDateTime endTime);
// ===== 支付记录分页查询 =====
/** 按条件分页查询支付记录 */
@Query("SELECT * FROM payment_order WHERE (:memberId IS NULL OR member_id = :memberId) AND (:payStatus IS NULL OR pay_status = :payStatus) AND (:tradeType IS NULL OR trade_type = :tradeType) AND deleted_at IS NULL ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
Flux<PaymentOrder> findPaymentRecords(Long memberId, String payStatus, String tradeType, int limit, int offset);
/** 按条件统计支付记录总数 */
@Query("SELECT COUNT(*) FROM payment_order WHERE (:memberId IS NULL OR member_id = :memberId) AND (:payStatus IS NULL OR pay_status = :payStatus) AND (:tradeType IS NULL OR trade_type = :tradeType) AND deleted_at IS NULL")
Mono<Long> countPaymentRecords(Long memberId, String payStatus, String tradeType);
} }
@@ -1,9 +1,7 @@
package cn.novalon.gym.manage.payment.service; package cn.novalon.gym.manage.payment.service;
import cn.novalon.gym.manage.payment.dto.CreatePaymentRequest; import cn.novalon.gym.manage.payment.dto.CreatePaymentRequest;
import cn.novalon.gym.manage.payment.dto.PaymentRecordResponse;
import cn.novalon.gym.manage.payment.dto.PaymentResponse; import cn.novalon.gym.manage.payment.dto.PaymentResponse;
import cn.novalon.gym.manage.payment.dto.RevenueStatistics;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -32,10 +30,4 @@ public interface PaymentService {
Mono<PaymentResponse> getPendingOrder(Long memberId, String orderType); Mono<PaymentResponse> getPendingOrder(Long memberId, String orderType);
Mono<Boolean> closeOrder(Long memberId, String orderId); Mono<Boolean> closeOrder(Long memberId, String orderId);
/** 获取营业额统计(今日/当月/当年) */
Mono<RevenueStatistics> getRevenueStatistics();
/** 分页查询支付记录 */
Mono<Map<String, Object>> getPaymentRecords(Long memberId, String payStatus, String tradeType, int page, int pageSize);
} }
@@ -3,9 +3,7 @@ package cn.novalon.gym.manage.payment.service.impl;
import cn.novalon.gym.manage.common.util.RedisUtil; import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.payment.config.HuifuProperties; import cn.novalon.gym.manage.payment.config.HuifuProperties;
import cn.novalon.gym.manage.payment.dto.CreatePaymentRequest; import cn.novalon.gym.manage.payment.dto.CreatePaymentRequest;
import cn.novalon.gym.manage.payment.dto.PaymentRecordResponse;
import cn.novalon.gym.manage.payment.dto.PaymentResponse; import cn.novalon.gym.manage.payment.dto.PaymentResponse;
import cn.novalon.gym.manage.payment.dto.RevenueStatistics;
import cn.novalon.gym.manage.payment.entity.PaymentOrder; import cn.novalon.gym.manage.payment.entity.PaymentOrder;
import cn.novalon.gym.manage.payment.repository.PaymentOrderRepository; import cn.novalon.gym.manage.payment.repository.PaymentOrderRepository;
import cn.novalon.gym.manage.payment.service.PaymentNotifyService; import cn.novalon.gym.manage.payment.service.PaymentNotifyService;
@@ -936,76 +934,4 @@ public class PaymentServiceImpl implements PaymentService {
}) })
.switchIfEmpty(Mono.just(false)); .switchIfEmpty(Mono.just(false));
} }
@Override
public Mono<RevenueStatistics> getRevenueStatistics() {
LocalDateTime now = LocalDateTime.now();
// 今日
LocalDateTime todayStart = now.toLocalDate().atStartOfDay();
LocalDateTime todayEnd = todayStart.plusDays(1);
// 当月
LocalDateTime monthStart = now.toLocalDate().withDayOfMonth(1).atStartOfDay();
LocalDateTime monthEnd = monthStart.plusMonths(1);
// 当年
LocalDateTime yearStart = now.toLocalDate().withDayOfYear(1).atStartOfDay();
LocalDateTime yearEnd = yearStart.plusYears(1);
Mono<BigDecimal> todayIncomeMono = paymentOrderRepository.sumSuccessAmount(todayStart, todayEnd);
Mono<Long> todayCountMono = paymentOrderRepository.countSuccessOrders(todayStart, todayEnd);
Mono<BigDecimal> monthIncomeMono = paymentOrderRepository.sumSuccessAmount(monthStart, monthEnd);
Mono<Long> monthCountMono = paymentOrderRepository.countSuccessOrders(monthStart, monthEnd);
Mono<BigDecimal> yearIncomeMono = paymentOrderRepository.sumSuccessAmount(yearStart, yearEnd);
Mono<Long> yearCountMono = paymentOrderRepository.countSuccessOrders(yearStart, yearEnd);
return Mono.zip(todayIncomeMono, todayCountMono, monthIncomeMono, monthCountMono, yearIncomeMono, yearCountMono)
.map(tuple -> RevenueStatistics.builder()
.todayIncome(tuple.getT1() != null ? tuple.getT1() : BigDecimal.ZERO)
.todayRefund(BigDecimal.ZERO) // 退款功能暂未实现
.todayOrderCount(tuple.getT2() != null ? tuple.getT2() : 0L)
.monthIncome(tuple.getT3() != null ? tuple.getT3() : BigDecimal.ZERO)
.monthRefund(BigDecimal.ZERO) // 退款功能暂未实现
.monthOrderCount(tuple.getT4() != null ? tuple.getT4() : 0L)
.yearIncome(tuple.getT5() != null ? tuple.getT5() : BigDecimal.ZERO)
.yearRefund(BigDecimal.ZERO) // 退款功能暂未实现
.yearOrderCount(tuple.getT6() != null ? tuple.getT6() : 0L)
.build());
}
@Override
public Mono<Map<String, Object>> getPaymentRecords(Long memberId, String payStatus, String tradeType, int page, int pageSize) {
int offset = (page - 1) * pageSize;
Mono<List<PaymentRecordResponse>> recordsMono = paymentOrderRepository
.findPaymentRecords(memberId, payStatus, tradeType, pageSize, offset)
.map(order -> PaymentRecordResponse.builder()
.id(order.getId())
.orderNo(order.getOrderNo())
.memberId(order.getMemberId())
.tradeType(order.getTradeType())
.goodsDesc(order.getGoodsDesc())
.orderType(order.getOrderType())
.transAmt(order.getTransAmt())
.payStatus(order.getPayStatus())
.payTime(order.getPayTime() != null
? order.getPayTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
: null)
.hfSeqId(order.getHfSeqId())
.createdAt(order.getCreatedAt() != null
? order.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
: null)
.build())
.collectList();
Mono<Long> totalMono = paymentOrderRepository.countPaymentRecords(memberId, payStatus, tradeType);
return Mono.zip(recordsMono, totalMono)
.map(tuple -> {
Map<String, Object> result = new HashMap<>();
result.put("records", tuple.getT1());
result.put("total", tuple.getT2());
result.put("page", page);
result.put("pageSize", pageSize);
return result;
});
}
} }
+2915 -1991
View File
File diff suppressed because it is too large Load Diff
@@ -7,21 +7,20 @@ import cn.novalon.gym.manage.file.handler.SysFileHandler;
import cn.novalon.gym.manage.auth.handler.PhoneAuthHandler; import cn.novalon.gym.manage.auth.handler.PhoneAuthHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseBookingHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseBookingHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseHandler;
import cn.novalon.gym.manage.groupcourse.handler.BannerHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseRecommendHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseRecommendHandler;
import cn.novalon.gym.manage.groupcourse.handler.GroupCourseTypeHandler; import cn.novalon.gym.manage.groupcourse.handler.GroupCourseTypeHandler;
import cn.novalon.gym.manage.groupcourse.handler.CourseLabelHandler; import cn.novalon.gym.manage.groupcourse.handler.CourseLabelHandler;
import cn.novalon.gym.manage.groupcourse.handler.CommonUploadHandler;
import cn.novalon.gym.manage.member.handler.MemberCardHandler; import cn.novalon.gym.manage.member.handler.MemberCardHandler;
import cn.novalon.gym.manage.member.handler.MemberCardRecordHandler; import cn.novalon.gym.manage.member.handler.MemberCardRecordHandler;
import cn.novalon.gym.manage.member.handler.MemberCardTransactionHandler; import cn.novalon.gym.manage.member.handler.MemberCardTransactionHandler;
import cn.novalon.gym.manage.member.handler.MemberHandler; import cn.novalon.gym.manage.member.handler.MemberHandler;
import cn.novalon.gym.manage.member.handler.MemberStoredCardHandler; import cn.novalon.gym.manage.member.handler.MemberStoredCardHandler;
import cn.novalon.gym.manage.member.handler.WechatAuthHandler; import cn.novalon.gym.manage.member.handler.WechatAuthHandler;
import cn.novalon.gym.manage.notify.handler.BannerHandler;
import cn.novalon.gym.manage.notify.handler.SysNoticeHandler; import cn.novalon.gym.manage.notify.handler.SysNoticeHandler;
import cn.novalon.gym.manage.notify.handler.SysUserMessageHandler; import cn.novalon.gym.manage.notify.handler.SysUserMessageHandler;
import cn.novalon.gym.manage.payment.handler.PaymentHandler; import cn.novalon.gym.manage.payment.handler.PaymentHandler;
import cn.novalon.gym.manage.payment.handler.PaymentRevenueHandler; import cn.novalon.gym.manage.app.handler.CoachHandler;
import cn.novalon.gym.manage.sys.handler.auth.PasswordDiagnosticHandler; import cn.novalon.gym.manage.sys.handler.auth.PasswordDiagnosticHandler;
import cn.novalon.gym.manage.sys.handler.auth.SysAuthHandler; import cn.novalon.gym.manage.sys.handler.auth.SysAuthHandler;
import cn.novalon.gym.manage.sys.handler.config.SysConfigHandler; import cn.novalon.gym.manage.sys.handler.config.SysConfigHandler;
@@ -66,6 +65,7 @@ public class SystemRouter {
SysAuthHandler authHandler, SysAuthHandler authHandler,
StatsHandler statsHandler, StatsHandler statsHandler,
SysDictHandler dictHandler, SysDictHandler dictHandler,
BannerHandler bannerHandler,
SysNoticeHandler noticeHandler, SysNoticeHandler noticeHandler,
SysUserMessageHandler messageHandler, SysUserMessageHandler messageHandler,
SysFileHandler fileHandler, SysFileHandler fileHandler,
@@ -82,13 +82,11 @@ public class SystemRouter {
GroupCourseRecommendHandler groupCourseRecommendHandler, GroupCourseRecommendHandler groupCourseRecommendHandler,
GroupCourseTypeHandler groupCourseTypeHandler, GroupCourseTypeHandler groupCourseTypeHandler,
CourseLabelHandler courseLabelHandler, CourseLabelHandler courseLabelHandler,
BannerHandler bannerHandler,
CheckInHandler checkInHandler, CheckInHandler checkInHandler,
DataStatisticsHandler dataStatisticsHandler, DataStatisticsHandler dataStatisticsHandler,
PhoneAuthHandler phoneAuthHandler, PhoneAuthHandler phoneAuthHandler,
PaymentHandler paymentHandler, PaymentHandler paymentHandler,
PaymentRevenueHandler paymentRevenueHandler, CoachHandler coachHandler) {
CommonUploadHandler commonUploadHandler) {
return route() return route()
// ========== 诊断路由 ========== // ========== 诊断路由 ==========
@@ -122,6 +120,13 @@ public class SystemRouter {
.GET("/api/users/{id}/roles", userHandler::getUserRoles) .GET("/api/users/{id}/roles", userHandler::getUserRoles)
.POST("/api/users/{id}/roles", userHandler::assignRoles) .POST("/api/users/{id}/roles", userHandler::assignRoles)
// ========== 教练路由 ==========
.GET("/api/coach/list", coachHandler::getAllCoaches)
.POST("/api/coach", coachHandler::createCoach)
.PUT("/api/coach/{id}", coachHandler::updateCoach)
.POST("/api/coach/{id}/disable", coachHandler::disableCoach)
.GET("/api/coach/{id}/courses", coachHandler::getCoachCourses)
// ========== 菜单路由 ========== // ========== 菜单路由 ==========
.GET("/api/menus", menuHandler::getAllMenus) .GET("/api/menus", menuHandler::getAllMenus)
.GET("/api/menus/tree", menuHandler::getMenuTree) .GET("/api/menus/tree", menuHandler::getMenuTree)
@@ -176,7 +181,6 @@ public class SystemRouter {
.POST("/api/auth/login", authHandler::login) .POST("/api/auth/login", authHandler::login)
.POST("/api/auth/register", authHandler::register) .POST("/api/auth/register", authHandler::register)
.POST("/api/auth/logout", authHandler::logout) .POST("/api/auth/logout", authHandler::logout)
.GET("/api/auth/me", authHandler::me)
// ========== 统计路由 ========== // ========== 统计路由 ==========
.GET("/api/stats/overview", statsHandler::getOverview) .GET("/api/stats/overview", statsHandler::getOverview)
@@ -195,6 +199,14 @@ public class SystemRouter {
.PUT("/api/dict/data/{id}", dictHandler::updateDictData) .PUT("/api/dict/data/{id}", dictHandler::updateDictData)
.DELETE("/api/dict/data/{id}", dictHandler::deleteDictData) .DELETE("/api/dict/data/{id}", dictHandler::deleteDictData)
// ========== 轮播图路由 ==========
.GET("/api/banners", bannerHandler::getAllBanners)
.GET("/api/banners/active", bannerHandler::getActiveBanners)
.GET("/api/banners/{id}", bannerHandler::getBannerById)
.POST("/api/banners", bannerHandler::createBanner)
.PUT("/api/banners/{id}", bannerHandler::updateBanner)
.DELETE("/api/banners/{id}", bannerHandler::deleteBanner)
// ========== 公告路由 ========== // ========== 公告路由 ==========
.GET("/api/notices", noticeHandler::getAllNotices) .GET("/api/notices", noticeHandler::getAllNotices)
.GET("/api/notices/{id}", noticeHandler::getNoticeById) .GET("/api/notices/{id}", noticeHandler::getNoticeById)
@@ -224,10 +236,6 @@ public class SystemRouter {
.GET("/api/files/preview/{fileName}", fileHandler::previewFileByName) .GET("/api/files/preview/{fileName}", fileHandler::previewFileByName)
.DELETE("/api/files/{id}", fileHandler::deleteFile) .DELETE("/api/files/{id}", fileHandler::deleteFile)
// ===== 通用文件上传OSS=====
.POST("/api/upload/image", commonUploadHandler::uploadImage)
.GET("/api/upload/presign", commonUploadHandler::presignUrl)
// ========== 权限路由 ========== // ========== 权限路由 ==========
.GET("/api/permissions", permissionHandler::getAllPermissions) .GET("/api/permissions", permissionHandler::getAllPermissions)
.GET("/api/permissions/{id}", permissionHandler::getPermissionById) .GET("/api/permissions/{id}", permissionHandler::getPermissionById)
@@ -319,6 +327,7 @@ public class SystemRouter {
.GET("/api/groupCourse/types/category/{category}", groupCourseTypeHandler::getGroupCourseTypesByCategory) .GET("/api/groupCourse/types/category/{category}", groupCourseTypeHandler::getGroupCourseTypesByCategory)
.GET("/api/groupCourse/types/{id}", groupCourseTypeHandler::getGroupCourseTypeById) .GET("/api/groupCourse/types/{id}", groupCourseTypeHandler::getGroupCourseTypeById)
.POST("/api/groupCourse/types", groupCourseTypeHandler::createGroupCourseType) .POST("/api/groupCourse/types", groupCourseTypeHandler::createGroupCourseType)
.POST("/api/groupCourse/types/page", groupCourseTypeHandler::getGroupCourseTypesByPage)
.PUT("/api/groupCourse/types/{id}", groupCourseTypeHandler::updateGroupCourseType) .PUT("/api/groupCourse/types/{id}", groupCourseTypeHandler::updateGroupCourseType)
.DELETE("/api/groupCourse/types/{id}", groupCourseTypeHandler::deleteGroupCourseType) .DELETE("/api/groupCourse/types/{id}", groupCourseTypeHandler::deleteGroupCourseType)
@@ -327,6 +336,7 @@ public class SystemRouter {
.GET("/api/groupCourse/labels/search", courseLabelHandler::searchLabels) .GET("/api/groupCourse/labels/search", courseLabelHandler::searchLabels)
.GET("/api/groupCourse/labels/{id}", courseLabelHandler::getLabelById) .GET("/api/groupCourse/labels/{id}", courseLabelHandler::getLabelById)
.GET("/api/groupCourse/types/{typeId}/labels", courseLabelHandler::getLabelsByTypeId) .GET("/api/groupCourse/types/{typeId}/labels", courseLabelHandler::getLabelsByTypeId)
.POST("/api/groupCourse/labels/page", courseLabelHandler::getLabelsByPage)
.POST("/api/groupCourse/labels", courseLabelHandler::createLabel) .POST("/api/groupCourse/labels", courseLabelHandler::createLabel)
.PUT("/api/groupCourse/labels/{id}", courseLabelHandler::updateLabel) .PUT("/api/groupCourse/labels/{id}", courseLabelHandler::updateLabel)
.DELETE("/api/groupCourse/labels/{id}", courseLabelHandler::deleteLabel) .DELETE("/api/groupCourse/labels/{id}", courseLabelHandler::deleteLabel)
@@ -352,28 +362,16 @@ public class SystemRouter {
.POST("/api/groupCourse/recommend/{id}/enable", groupCourseRecommendHandler::enableRecommendation) .POST("/api/groupCourse/recommend/{id}/enable", groupCourseRecommendHandler::enableRecommendation)
.POST("/api/groupCourse/recommend/{id}/disable", groupCourseRecommendHandler::disableRecommendation) .POST("/api/groupCourse/recommend/{id}/disable", groupCourseRecommendHandler::disableRecommendation)
// ========== 轮播图路由 ==========
.GET("/api/banner/list", bannerHandler::getAllBanners)
.GET("/api/banner/active", bannerHandler::getAllActiveBanners)
.GET("/api/banner/{id}", bannerHandler::getBannerById)
.POST("/api/banner", bannerHandler::createBanner)
.PUT("/api/banner/{id}", bannerHandler::updateBanner)
.DELETE("/api/banner/{id}", bannerHandler::deleteBanner)
.POST("/api/banner/{id}/enable", bannerHandler::enableBanner)
.POST("/api/banner/{id}/disable", bannerHandler::disableBanner)
// ===== 团课课程管理需要放在具体路由之后===== // ===== 团课课程管理需要放在具体路由之后=====
.GET("/api/groupCourse/{id}/qrcode", groupCourseHandler::getCourseQRCode)
.POST("/api/groupCourse/{courseId}/qrsignin", groupCourseBookingHandler::qrSignIn)
.GET("/api/groupCourse/{id}", groupCourseHandler::getGroupCourseById) .GET("/api/groupCourse/{id}", groupCourseHandler::getGroupCourseById)
.GET("/api/groupCourse/{id}/detail", groupCourseHandler::getGroupCourseDetailById) .GET("/api/groupCourse/{id}/detail", groupCourseHandler::getGroupCourseDetailById)
.POST("/api/groupCourse", groupCourseHandler::createGroupCourse) .POST("/api/groupCourse", groupCourseHandler::createGroupCourse)
.PUT("/api/groupCourse/{id}", groupCourseHandler::updateGroupCourse) .PUT("/api/groupCourse/{id}", groupCourseHandler::updateGroupCourse)
.DELETE("/api/groupCourse/{id}", groupCourseHandler::deleteGroupCourse) .DELETE("/api/groupCourse/{id}", groupCourseHandler::deleteGroupCourse)
.POST("/api/groupCourse/{id}/cancel", groupCourseHandler::cancelGroupCourse) .POST("/api/groupCourse/{id}/cancel", groupCourseHandler::cancelGroupCourse)
.POST("/api/groupCourse/{id}/restore", groupCourseHandler::restoreGroupCourse)
.POST("/api/groupCourse/signin/{memberId}", groupCourseHandler::signIn) .POST("/api/groupCourse/signin/{memberId}", groupCourseHandler::signIn)
.POST("/api/groupCourse/search", groupCourseHandler::searchGroupCourses) .POST("/api/groupCourse/search", groupCourseHandler::searchGroupCourses)
.POST("/api/groupCourse/check-coach-conflict", groupCourseHandler::checkCoachConflict)
// ========= 签到模块路由 ========== // ========= 签到模块路由 ==========
// ===== 签到核心功能 ===== // ===== 签到核心功能 =====
@@ -381,7 +379,6 @@ public class SystemRouter {
.GET("/api/checkIn/qrcode", checkInHandler::getQRCode) .GET("/api/checkIn/qrcode", checkInHandler::getQRCode)
// ===== 签到记录管理 ===== // ===== 签到记录管理 =====
.GET("/api/checkIn/records/export", checkInHandler::exportSignInRecords)
.GET("/api/checkIn/records", checkInHandler::getSignInRecords) .GET("/api/checkIn/records", checkInHandler::getSignInRecords)
.GET("/api/checkIn/records/{id}", checkInHandler::getSignInRecordById) .GET("/api/checkIn/records/{id}", checkInHandler::getSignInRecordById)
@@ -389,9 +386,8 @@ public class SystemRouter {
.GET("/api/checkIn/statistics", checkInHandler::getSignInStatistics) .GET("/api/checkIn/statistics", checkInHandler::getSignInStatistics)
.GET("/api/checkIn/daily-stats", checkInHandler::getDailySignInStats) .GET("/api/checkIn/daily-stats", checkInHandler::getDailySignInStats)
// ===== 管理员签到记录全员 ===== // ===== 签到数据导出 =====
.GET("/api/checkIn/admin/records", checkInHandler::getAllSignInRecords) .GET("/api/checkIn/records/export", checkInHandler::exportSignInRecords)
.GET("/api/checkIn/admin/statistics", checkInHandler::getAllSignInStatistics)
// ======================================== // ========================================
// ========== 数据统计模块路由 ============ // ========== 数据统计模块路由 ============
@@ -420,10 +416,6 @@ public class SystemRouter {
.POST("/api/payment/{orderId}/refund", paymentHandler::refundPayment) .POST("/api/payment/{orderId}/refund", paymentHandler::refundPayment)
.POST("/api/payment/{orderId}/close", paymentHandler::closeOrder) .POST("/api/payment/{orderId}/close", paymentHandler::closeOrder)
// ===== 支付营业数据 =====
.GET("/api/payment/revenue/statistics", paymentRevenueHandler::getRevenueStatistics)
.GET("/api/payment/revenue/records", paymentRevenueHandler::getPaymentRecords)
.build(); .build();
} }
} }
@@ -0,0 +1,102 @@
package cn.novalon.gym.manage.app.handler;
import cn.novalon.gym.manage.app.service.CoachService;
import cn.novalon.gym.manage.sys.core.domain.SysUser;
import cn.novalon.gym.manage.sys.dto.request.CoachCreateRequest;
import cn.novalon.gym.manage.sys.dto.request.CoachUpdateRequest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Validator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 教练处理器放在 manage-app 中避免模块循环依赖
*
* @author 张翔
* @date 2026-07-19
*/
@Component
@Tag(name = "教练管理", description = "教练相关操作")
public class CoachHandler {
private static final Logger logger = LoggerFactory.getLogger(CoachHandler.class);
private final CoachService coachService;
private final Validator validator;
public CoachHandler(CoachService coachService, Validator validator) {
this.coachService = coachService;
this.validator = validator;
}
@Operation(summary = "获取所有教练", description = "获取系统中所有教练列表")
public Mono<ServerResponse> getAllCoaches(ServerRequest request) {
return ServerResponse.ok()
.body(coachService.getAllCoaches(), SysUser.class);
}
@Operation(summary = "创建教练", description = "创建新教练(创建用户并分配教练角色)")
public Mono<ServerResponse> createCoach(ServerRequest request) {
return request.bodyToMono(CoachCreateRequest.class)
.flatMap(req -> {
var violations = validator.validate(req);
if (!violations.isEmpty()) {
Map<String, String> errors = new HashMap<>();
violations.forEach(v -> errors.put(v.getPropertyPath().toString(), v.getMessage()));
return ServerResponse.badRequest().bodyValue(errors);
}
return coachService.createCoach(
req.getUsername(), req.getPassword(),
req.getNickname(), req.getEmail(), req.getPhone())
.flatMap(user -> ServerResponse.status(HttpStatus.CREATED).bodyValue(user))
.onErrorResume(e -> {
logger.error("创建教练失败", e);
return ServerResponse.badRequest()
.bodyValue(Map.of("error", e.getMessage()));
});
});
}
@Operation(summary = "更新教练信息", description = "更新教练基本信息(昵称、邮箱、手机号)")
public Mono<ServerResponse> updateCoach(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(CoachUpdateRequest.class)
.flatMap(req -> coachService.updateCoach(id, req.getNickname(), req.getEmail(), req.getPhone())
.flatMap(user -> ServerResponse.ok().bodyValue(user))
.switchIfEmpty(ServerResponse.notFound().build())
.onErrorResume(e -> {
logger.error("更新教练失败", e);
return ServerResponse.badRequest()
.bodyValue(Map.of("error", e.getMessage()));
}));
}
@Operation(summary = "禁用教练", description = "禁用教练账号,自动取消其所有非进行中的团课")
public Mono<ServerResponse> disableCoach(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return coachService.disableCoach(id)
.then(ServerResponse.ok().bodyValue(Map.of("message", "教练已禁用")))
.onErrorResume(e -> {
logger.error("禁用教练失败: {}", e.getMessage());
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
});
}
@Operation(summary = "获取教练的团课", description = "获取指定教练教授的所有团课")
public Mono<ServerResponse> getCoachCourses(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return coachService.getCoachCourses(id)
.collectList()
.flatMap(courses -> ServerResponse.ok().bodyValue(courses))
.switchIfEmpty(ServerResponse.ok().bodyValue(List.of()));
}
}
@@ -0,0 +1,144 @@
package cn.novalon.gym.manage.app.service;
import cn.novalon.gym.manage.common.util.StatusConstants;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.sys.core.domain.SysRole;
import cn.novalon.gym.manage.sys.core.domain.SysUser;
import cn.novalon.gym.manage.sys.core.domain.UserRole;
import cn.novalon.gym.manage.sys.core.repository.ISysRoleRepository;
import cn.novalon.gym.manage.sys.core.repository.ISysUserRepository;
import cn.novalon.gym.manage.sys.core.repository.IUserRoleRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Sort;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
/**
* 教练服务跨模块编排放在 manage-app 中避免模块循环依赖
*
* @author 张翔
* @date 2026-07-19
*/
@Service
public class CoachService {
private static final Logger logger = LoggerFactory.getLogger(CoachService.class);
private static final String COACH_ROLE_NAME = "教练";
private final ISysUserRepository userRepository;
private final ISysRoleRepository roleRepository;
private final IUserRoleRepository userRoleRepository;
private final IGroupCourseRepository groupCourseRepository;
private final IGroupCourseBookingRepository bookingRepository;
private final PasswordEncoder passwordEncoder;
public CoachService(ISysUserRepository userRepository,
ISysRoleRepository roleRepository,
IUserRoleRepository userRoleRepository,
IGroupCourseRepository groupCourseRepository,
IGroupCourseBookingRepository bookingRepository,
PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.userRoleRepository = userRoleRepository;
this.groupCourseRepository = groupCourseRepository;
this.bookingRepository = bookingRepository;
this.passwordEncoder = passwordEncoder;
}
public Flux<SysUser> getAllCoaches() {
return getCoachRoleId()
.flatMapMany(roleId -> userRoleRepository.findByRoleId(roleId)
.map(UserRole::getUserId)
.collectList()
.flatMapMany(userIds -> {
if (userIds.isEmpty()) {
return Flux.empty();
}
return Flux.fromIterable(userIds)
.flatMap(userRepository::findById)
.filter(user -> user.getDeletedAt() == null);
}));
}
public Mono<SysUser> createCoach(String username, String password, String nickname, String email, String phone) {
return getCoachRoleId().flatMap(coachRoleId -> {
SysUser user = new SysUser();
user.generateId();
user.setUsername(username);
user.setPassword(passwordEncoder.encode(password));
user.setNickname(nickname);
user.setEmail(email);
user.setPhone(phone);
user.setStatus(StatusConstants.ENABLED);
return userRepository.save(user)
.flatMap(saved -> {
UserRole userRole = new UserRole();
userRole.setUserId(saved.getId());
userRole.setRoleId(coachRoleId);
return userRoleRepository.save(userRole).thenReturn(saved);
});
});
}
public Mono<SysUser> updateCoach(Long id, String nickname, String email, String phone) {
return userRepository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("教练不存在")))
.flatMap(user -> {
if (nickname != null) user.setNickname(nickname);
if (email != null) user.setEmail(email);
if (phone != null) user.setPhone(phone);
user.setUpdatedAt(LocalDateTime.now());
return userRepository.update(user);
});
}
@Transactional(transactionManager = "connectionFactoryTransactionManager")
public Mono<Void> disableCoach(Long id) {
return userRepository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("教练不存在")))
.flatMap(user ->
groupCourseRepository.countByCoachIdAndStatus(id, 3L)
.flatMap(inProgressCount -> {
if (inProgressCount > 0) {
return Mono.error(new RuntimeException(
"该教练有 " + inProgressCount + " 门正在进行中的团课,无法禁用"));
}
return groupCourseRepository.cancelCoursesByCoachIdExceptStatus(id, 3L)
.then();
})
.then(Mono.defer(() -> {
user.setStatus(StatusConstants.DISABLED);
user.setUpdatedAt(LocalDateTime.now());
return userRepository.update(user).then();
}))
);
}
public Flux<GroupCourse> getCoachCourses(Long coachId) {
return groupCourseRepository.findByCoachId(coachId, Sort.by(Sort.Direction.DESC, "startTime"))
.flatMap(course ->
bookingRepository.countValidBookings(course.getId())
.map(count -> {
course.setCurrentMembers(count.intValue());
return course;
})
.defaultIfEmpty(course)
);
}
public Mono<Long> getCoachRoleId() {
return roleRepository.findByRoleName(COACH_ROLE_NAME)
.map(SysRole::getId)
.switchIfEmpty(Mono.error(new RuntimeException("教练角色未找到,请先执行数据库迁移")));
}
}
@@ -1,18 +1,18 @@
# 微信配置(测试环境使用模拟数据) # 微信配置(测试环境使用模拟数据)
wechat: wechat:
# Mock模式:true=使用模拟数据(开发测试),false=调用真实微信API(生产环境) # Mock模式:true=使用模拟数据(开发测试),false=调用真实微信API(生产环境)
mock-enabled: false mock-enabled: true
miniapp: miniapp:
app-id: ${WECHAT_MINIAPP_APP_ID} app-id: ${WECHAT_MINIAPP_APP_ID:}
app-secret: ${WECHAT_MINIAPP_SECRET} app-secret: ${WECHAT_MINIAPP_SECRET:}
mp: mp:
app-id: ${WECHAT_MP_APP_ID} app-id: ${WECHAT_MP_APP_ID:}
app-secret: ${WECHAT_MP_SECRET} app-secret: ${WECHAT_MP_SECRET:}
token: ${WECHAT_MP_TOKEN} token: ${WECHAT_MP_TOKEN:}
aes-key: ${WECHAT_MP_AESKEY} aes-key: ${WECHAT_MP_AESKEY:}
callback-url: ${WECHAT_MP_CALLBACK_URL} callback-url: ${WECHAT_MP_CALLBACK_URL:}
# 手机号加密配置(AES-128-CBC16字节密钥和IVBase64编码) # 手机号加密配置(AES-128-CBC16字节密钥和IVBase64编码)
phone-encryption: phone-encryption:
-4
View File
@@ -60,10 +60,6 @@
<groupId>org.springframework.data</groupId> <groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId> <artifactId>spring-data-redis</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -3,6 +3,7 @@ package cn.novalon.gym.manage.common.config;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory;
@@ -11,6 +12,9 @@ import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSeriali
import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/** /**
* Redis 配置类响应式版本 * Redis 配置类响应式版本
* *
@@ -20,6 +24,24 @@ import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration @Configuration
public class RedisConfig { public class RedisConfig {
/**
* 创建支持 Java 8 时间类型与类型信息的 ObjectMapper用于 Redis JSON 序列化
*/
private ObjectMapper createRedisObjectMapper() {
ObjectMapper mapper = new ObjectMapper();
JavaTimeModule javaTimeModule = new JavaTimeModule();
javaTimeModule.addSerializer(LocalDateTime.class,
new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
mapper.registerModule(javaTimeModule);
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
// 启用默认类型信息确保反序列化时能还原为原始 Java 类型
mapper.activateDefaultTyping(
mapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL
);
return mapper;
}
/** /**
* 配置 ReactiveRedisTemplate * 配置 ReactiveRedisTemplate
*/ */
@@ -27,23 +49,16 @@ public class RedisConfig {
public ReactiveRedisTemplate<String, Object> reactiveRedisTemplate( public ReactiveRedisTemplate<String, Object> reactiveRedisTemplate(
ReactiveRedisConnectionFactory connectionFactory) { ReactiveRedisConnectionFactory connectionFactory) {
ObjectMapper objectMapper = new ObjectMapper(); GenericJackson2JsonRedisSerializer jsonSerializer =
objectMapper.registerModule(new JavaTimeModule()); new GenericJackson2JsonRedisSerializer(createRedisObjectMapper());
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
objectMapper.activateDefaultTyping(
objectMapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL
);
GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper);
// 配置序列化上下文 // 配置序列化上下文
RedisSerializationContext<String, Object> serializationContext = RedisSerializationContext<String, Object> serializationContext =
RedisSerializationContext.<String, Object>newSerializationContext() RedisSerializationContext.<String, Object>newSerializationContext()
.key(StringRedisSerializer.UTF_8) .key(StringRedisSerializer.UTF_8)
.value(serializer) .value(jsonSerializer)
.hashKey(StringRedisSerializer.UTF_8) .hashKey(StringRedisSerializer.UTF_8)
.hashValue(serializer) .hashValue(jsonSerializer)
.build(); .build();
return new ReactiveRedisTemplate<>(connectionFactory, serializationContext); return new ReactiveRedisTemplate<>(connectionFactory, serializationContext);
@@ -12,6 +12,8 @@ public class PageRequest {
private String sort = "id"; private String sort = "id";
private String order = "asc"; private String order = "asc";
private String keyword; private String keyword;
private String status;
private String category;
public int getPage() { public int getPage() {
return page; return page;
@@ -52,4 +54,20 @@ public class PageRequest {
public void setKeyword(String keyword) { public void setKeyword(String keyword) {
this.keyword = keyword; this.keyword = keyword;
} }
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
} }
@@ -1,5 +1,6 @@
package cn.novalon.gym.manage.common.util; package cn.novalon.gym.manage.common.util;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.ReactiveRedisTemplate; import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -13,6 +14,7 @@ import java.time.Duration;
* @author liwentao * @author liwentao
* @date 2026/5/15 * @date 2026/5/15
*/ */
@Slf4j
@Component @Component
public class RedisUtil { public class RedisUtil {
@@ -34,12 +36,19 @@ public class RedisUtil {
} }
/** /**
* 获取值 * 获取值带类型转换
* filter+cast 替代 map 避免 NPEonErrorResume 兼容旧缓存格式反序列化失败
*/ */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public <T> Mono<T> get(String key, Class<T> clazz) { public <T> Mono<T> get(String key, Class<T> clazz) {
return reactiveRedisTemplate.opsForValue().get(key) return reactiveRedisTemplate.opsForValue().get(key)
.map(obj -> clazz.isInstance(obj) ? (T) obj : null); .filter(clazz::isInstance)
.map(clazz::cast)
.onErrorResume(e -> {
// 旧缓存数据格式不兼容时静默跳过后续逻辑会 fallback DB 查询
log.warn("读取 Redis 缓存失败(可能为旧格式): key={}, error={}", key, e.getMessage());
return Mono.empty();
});
} }
/** /**
@@ -0,0 +1,73 @@
package cn.novalon.gym.manage.db.converter;
import cn.novalon.gym.manage.notify.core.domain.Banner;
import cn.novalon.gym.manage.db.entity.BannerEntity;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
/**
* 轮播图实体转换器
*
* @author 张翔
* @date 2026-07-18
*/
@Component
public class BannerConverter {
public Banner toDomain(BannerEntity entity) {
if (entity == null) {
return null;
}
Banner domain = new Banner();
domain.setId(entity.getId());
domain.setImageUrl(entity.getImageUrl());
domain.setTitle(entity.getTitle());
domain.setSubtitle(entity.getSubtitle());
domain.setSortOrder(entity.getSortOrder());
domain.setIsActive(entity.getIsActive());
domain.setLinkUrl(entity.getLinkUrl());
domain.setCreatedAt(entity.getCreatedAt());
domain.setUpdatedAt(entity.getUpdatedAt());
domain.setDeletedAt(entity.getDeletedAt());
return domain;
}
public BannerEntity toEntity(Banner domain) {
if (domain == null) {
return null;
}
BannerEntity entity = new BannerEntity();
entity.setId(domain.getId());
entity.setImageUrl(domain.getImageUrl());
entity.setTitle(domain.getTitle());
entity.setSubtitle(domain.getSubtitle());
entity.setSortOrder(domain.getSortOrder());
entity.setIsActive(domain.getIsActive());
entity.setLinkUrl(domain.getLinkUrl());
entity.setCreatedAt(domain.getCreatedAt());
entity.setUpdatedAt(domain.getUpdatedAt());
entity.setDeletedAt(domain.getDeletedAt());
return entity;
}
public List<Banner> toDomainList(List<BannerEntity> entities) {
if (entities == null) {
return null;
}
return entities.stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
public List<BannerEntity> toEntityList(List<Banner> domains) {
if (domains == null) {
return null;
}
return domains.stream()
.map(this::toEntity)
.collect(Collectors.toList());
}
}
@@ -0,0 +1,22 @@
package cn.novalon.gym.manage.db.dao;
import cn.novalon.gym.manage.db.entity.BannerEntity;
import org.springframework.data.domain.Sort;
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;
@Repository
public interface BannerDao extends R2dbcRepository<BannerEntity, Long> {
Flux<BannerEntity> findByDeletedAtIsNull();
Flux<BannerEntity> findByDeletedAtIsNull(Sort sort);
@Query("SELECT * FROM banner WHERE deleted_at IS NULL ORDER BY sort_order ASC")
Flux<BannerEntity> findActiveBanners();
Mono<Void> deleteByIdAndDeletedAtIsNull(Long id);
}
@@ -0,0 +1,127 @@
package cn.novalon.gym.manage.db.entity;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
/**
* 轮播图数据库实体类
*
* @author 张翔
* @date 2026-07-18
*/
@Table("banner")
public class BannerEntity {
@Id
private Long id;
@Column("image_url")
private String imageUrl;
@Column("title")
private String title;
@Column("subtitle")
private String subtitle;
@Column("sort_order")
private Integer sortOrder;
@Column("is_active")
private String isActive;
@Column("link_url")
private String linkUrl;
@Column("created_at")
private LocalDateTime createdAt;
@Column("updated_at")
private LocalDateTime updatedAt;
@Column("deleted_at")
private LocalDateTime deletedAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getImageUrl() {
return imageUrl;
}
public void setImageUrl(String imageUrl) {
this.imageUrl = imageUrl;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getSubtitle() {
return subtitle;
}
public void setSubtitle(String subtitle) {
this.subtitle = subtitle;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public String getIsActive() {
return isActive;
}
public void setIsActive(String isActive) {
this.isActive = isActive;
}
public String getLinkUrl() {
return linkUrl;
}
public void setLinkUrl(String linkUrl) {
this.linkUrl = linkUrl;
}
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;
}
public LocalDateTime getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(LocalDateTime deletedAt) {
this.deletedAt = deletedAt;
}
}
@@ -0,0 +1,59 @@
package cn.novalon.gym.manage.db.repository;
import cn.novalon.gym.manage.notify.core.domain.Banner;
import cn.novalon.gym.manage.notify.core.repository.IBannerRepository;
import cn.novalon.gym.manage.db.converter.BannerConverter;
import cn.novalon.gym.manage.db.dao.BannerDao;
import cn.novalon.gym.manage.db.entity.BannerEntity;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* 轮播图仓储实现类
*
* @author 张翔
* @date 2026-07-18
*/
@Repository
public class BannerRepository implements IBannerRepository {
private final BannerDao bannerDao;
private final BannerConverter bannerConverter;
public BannerRepository(BannerDao bannerDao, BannerConverter bannerConverter) {
this.bannerDao = bannerDao;
this.bannerConverter = bannerConverter;
}
@Override
public Flux<Banner> findByDeletedAtIsNull() {
return bannerDao.findByDeletedAtIsNull(Sort.by(Sort.Direction.ASC, "sortOrder"))
.map(bannerConverter::toDomain);
}
@Override
public Flux<Banner> findActiveBanners() {
return bannerDao.findActiveBanners()
.map(bannerConverter::toDomain);
}
@Override
public Mono<Banner> findById(Long id) {
return bannerDao.findById(id)
.map(bannerConverter::toDomain);
}
@Override
public Mono<Banner> save(Banner banner) {
BannerEntity entity = bannerConverter.toEntity(banner);
return bannerDao.save(entity)
.map(bannerConverter::toDomain);
}
@Override
public Mono<Void> deleteByIdAndDeletedAtIsNull(Long id) {
return bannerDao.deleteByIdAndDeletedAtIsNull(id);
}
}
@@ -0,0 +1,38 @@
-- ============================================
-- 团课推荐表
-- 版本: V10
-- 描述: 创建团课推荐表,用于首页推荐团课展示
-- ============================================
CREATE TABLE IF NOT EXISTS group_course_recommend (
id BIGSERIAL PRIMARY KEY,
course_id BIGINT NOT NULL,
recommend_title VARCHAR(200),
recommend_content TEXT,
recommend_reason VARCHAR(500),
priority INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_group_course_recommend_course_id ON group_course_recommend(course_id);
CREATE INDEX IF NOT EXISTS idx_group_course_recommend_priority ON group_course_recommend(priority);
CREATE INDEX IF NOT EXISTS idx_group_course_recommend_is_active ON group_course_recommend(is_active);
COMMENT ON TABLE group_course_recommend IS '团课推荐表';
COMMENT ON COLUMN group_course_recommend.id IS '主键ID';
COMMENT ON COLUMN group_course_recommend.course_id IS '团课ID(关联group_course.id';
COMMENT ON COLUMN group_course_recommend.recommend_title IS '推荐标题';
COMMENT ON COLUMN group_course_recommend.recommend_content IS '推荐内容';
COMMENT ON COLUMN group_course_recommend.recommend_reason IS '推荐理由';
COMMENT ON COLUMN group_course_recommend.priority IS '优先级(数字越大优先级越高)';
COMMENT ON COLUMN group_course_recommend.is_active IS '是否启用';
COMMENT ON COLUMN group_course_recommend.create_by IS '创建人';
COMMENT ON COLUMN group_course_recommend.update_by IS '更新人';
COMMENT ON COLUMN group_course_recommend.created_at IS '创建时间';
COMMENT ON COLUMN group_course_recommend.updated_at IS '更新时间';
COMMENT ON COLUMN group_course_recommend.deleted_at IS '删除时间(软删除)';

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