新增到课签到时间窗口与迟到签到时间窗口配置,优化教练评分机制(未测试)

This commit is contained in:
2026-07-26 16:10:23 +08:00
parent 86b7555943
commit c2f11727fe
31 changed files with 2069 additions and 138 deletions
@@ -1,6 +1,6 @@
# ADR-0001: 教练业绩统计功能设计
**日期**: 2026-07-22
**日期**: 2026-07-22(初版)/ 2026-07-26(修订)
**状态**: 已决定
**决策者**: 通过 grill-with-docs 追问明确
@@ -19,16 +19,18 @@
**选择**: 在 `gym-dataCount` 模块中新增 CoachPerformance 相关的 Handler + Service + DAO,而非新建独立模块。
**理由**:
- `gym-dataCount` 模块已有成熟的统计架构(DatabaseClient + Reactive + Redis 缓存 + 时间范围推导)
- `gym-dataCount` 模块已有成熟的统计架构(DatabaseClient + Reactive + 时间范围推导)
- 现有 `DataStatisticsDao` 已有教练相关的 SQL 聚合查询,可直接复用
- 避免模块膨胀,将"统计"职责收敛在一个模块中
- `manage-app` 已依赖 `gym-dataCount`,路由注册零成本
**替代方案被拒绝**: 新建 `gym-coach-performance` 独立模块。理由:功能规模不足以支撑独立模块,且会引入额外的模块间依赖管理成本。
---
### 2. 数据源:完全基于团课预约数据
**选择**: 业绩统计的"出席人次"和"出勤率"完全基于 `group_course_booking`status='2'=已出席),而非 `sign_in_record` 签到表。
**选择**: 业绩统计的"出席人次"和"出勤率"完全基于 `group_course_booking` 表,而非 `sign_in_record` 签到表。
**理由**:
- `sign_in_record` 表中没有 `coach_id` 字段,签到只关联会员(member_id),不关联教练
@@ -37,6 +39,8 @@
**风险**: 如果未来签到记录需要关联教练(例如一对一的私教签到),需要重新评估此决策。
---
### 3. 授课量定义:仅计入已完成课程
**选择**: 只统计 `status IN (2, 6)` 的课程(已结束 + 自动结束)。
@@ -45,23 +49,75 @@
- 所有非取消课程:会包含教练缺席(status=5)的课程,不应算作业绩
- 所有排课:会包含已取消的课程,不能反映真实工作量
### 4. 满员率:按出席人数计算
---
**选择**: 满员率 = 各课程(出席人数 / max_members)的平均值。
### 4. 时间基准:以课程结束时间为准
**选择**: 所有时间范围过滤均使用 `group_course.end_time`,而非 `start_time`
**理由**: 课程可能跨统计周期边界(如月末 23:00 开课、次月 01:00 结束)。以开始时间为准会导致跨月课程被错误归因到上月。以结束时间为准更符合"这个月完成了哪些课程"的直观理解。
**变更历史**2026-07-26: 从 `start_time` 改为 `end_time`
---
### 5. 出席人次口径:参与型状态
**选择**: 出席人次统计 `booking.status IN ('2', '4', '5')`,即已出席(2) + 教练缺席(4) + 迟到(5)。
**拒绝的定义**: 仅统计 status='2'(已出席)。理由:教练缺席和迟到同样意味着学员到达了现场(或至少尝试了参与),应计入出席人次;实际缺席责任在教练而非学员。
**变更历史**2026-07-26: 从仅 `status='2'` 扩展为 `IN ('2','4','5')`
---
### 6. 出勤率分母:仅已预约
**选择**: 出勤率分母仅统计 `booking.status = '0'`(已预约),而非 `status != '1'`(所有非取消)。
**理由**:
- status='3'(学员缺席)不应出现在分母中——学员预约后无故缺席,既不应计入分子也不应计入分母,因为这既非教练的功劳也非教练的责任
- 出勤率语义变为"在已预约的学员中,实际参与的比例"
- 排除了预约后取消(status='1')和学员缺席(status='3')的噪声
**变更历史**2026-07-26: 从 `status != '1'` 改为 `status = '0'`
---
### 7. 满员率:按出席人数计算 + 防御除零
**选择**: 满员率 = 各已完成课程(出席人数 / max_members)的平均值,其中出席人数按 status IN ('2','4','5') 统计。`max_members = 0` 的课程被跳过不参与计算。
**拒绝的定义**: 按预约人数(current_members)计算。理由:预约了但没来的学员不能算"满员",出席人数更真实地反映了课程实际到场情况。
### 5. 综合评分权重:授课量 40% + 出勤率 30% + 满员率 30%
**变更历史**2026-07-26): 满员率明细的出席人数口径从 `status='2'` 扩展为 `IN ('2','4','5')`,与出席人次保持一致。
**选择**: 授课量占比最高,体现工作量;出勤率和满员率体现教学质量。
---
**归一化规则**: 授课量按所有教练中最大值归一化到 0-100。这样即使只有少数教练开课多,评分也能合理分布。
### 8. 综合评分
**拒绝的替代方案**:
- 三指标等权重(33/33/34):弱化了工作量差异
- 授课量 50%:过度强调数量而忽视质量
**最终选择**2026-07-26 修订):
### 6. 不包含学员留存率
| 指标 | 权重 | 归一化方式 |
|------|------|-----------|
| 授课量 | 35% | 百分位排名(授课量排序,小于当前教练的教练数 / (总教练数-1) * 100 |
| 出勤率 | 25% | 原始百分比(0-100 |
| 满员率 | 25% | 原始百分比(0-100 |
| 违规扣分 | 15% | 线性扣分:max(0, 100 - 违规次数 * 20) |
```
综合评分 = 授课量归一化分 * 0.35 + 出勤率 * 0.25 + 满员率 * 0.25 + 违规分 * 0.15
```
**公式变更历史**:
- 初版(2026-07-22: `授课量归一化(最大值归一化) * 0.4 + 出勤率 * 0.3 + 满员率 * 0.3`,违规仅展示不参与评分
- 修订(2026-07-26): 授课量归一化改为百分位排名,违规纳入评分,权重重新分配
**拒绝的替代方案**: 详见设计文档 `docs/coach-performance-design.md`
---
### 9. 不包含学员留存率
**选择**: 首版不计算学员留存率。
@@ -69,11 +125,30 @@
---
### 10. 不引入 Redis 缓存
**选择**: 教练业绩统计数据不进行 Redis 缓存,每次请求实时计算。
**理由**: 业绩数据需要准实时性,缓存可能导致教练查看时数据滞后;且当前教练数量级下,6 条聚合查询的响应时间可接受。
---
### 11. getCoachPerformanceById 复用全量查询
**选择**: 查询单个教练业绩时,内部调用 `getCoachPerformanceList` 获取全量后过滤。暂不新增按教练 ID 的单独 DAO 方法。
**理由**: 当前教练数量有限,全量查询后再过滤的性能损耗可接受,优先保持代码简洁。
**风险**: 教练数量增长后需要重新评估,届时可新增按 coach_id 直查的 DAO 方法。
---
## 影响
### 后端变更
- `gym-dataCount` 模块新增:`CoachPerformance` domain、`CoachPerformanceHandler``CoachPerformanceDao`
- `manage-app``SystemRouter` 中新增 2 条路由
- `gym-dataCount` 模块新增:`CoachPerformance` domain、`CoachPerformanceHandler``DataStatisticsDao`(教练业绩相关方法)
- `manage-app``SystemRouter` 中新增 3 条路由
- `DataStatisticsServiceImpl` 新增 `getCoachPerformanceList``getCoachPerformanceById``calculateFillRate` 方法
### 前端变更
- `StatisticsDashboard.vue` 新增"教练业绩"Tab
@@ -96,3 +171,28 @@
新建 `gym-coach-performance` 独立 Maven 模块。
- 优点:职责隔离清晰
- 缺点:模块碎片化,增加编译和依赖管理成本
### 方案 C:授课量最大值归一化(已拒绝,初版方案)
`normalizedCourses = courses / maxCourses * 100`
- 优点:数学简洁
- 缺点:若有一位教练授课量远超其他,中游教练得分被严重压缩;鼓励"互卷"而非"达标"
### 方案 D:授课量对数归一化(已拒绝)
`normalizedCourses = ln(courses + 1) / ln(maxCourses + 1) * 100`
- 优点:自然压制极端值
- 缺点:解释性弱,非技术人员难以理解评分含义
### 方案 E:授课量固定目标归一化(已拒绝)
`normalizedCourses = min(courses / target * 100, 100)`target 可配置
- 优点:变成"达标制",不受其他教练影响
- 缺点:target 值需要根据实际数据校准,设置不当会全员满分或全员不及格
### 方案 F:违规阶梯扣分(已拒绝)
0次=100, 1次=70, 2次=40, 3次=10, >=4次=0
- 优点:首次违规惩罚重,有威慑力
- 缺点:阶梯粒度太粗,第 1 次和第 2 次违规之间差距 30 分,过于激进
### 方案 G:违规归一化扣分(已拒绝)
`violationScore = (1 - violations / maxViolations) * 100`
- 优点:相对于最差教练扣分
- 缺点:依赖数据集中的最大值,若所有教练都无违规则无意义
@@ -0,0 +1,214 @@
# ADR-0002: 教练迟到/缺席时间判定可配置化
**日期**: 2026-07-26
**状态**: 已决定
**决策者**: 通过 grill-with-docs 追问明确
---
## 背景
当前教练开课/结课/迟到/缺席的时间阈值全部硬编码在代码中:
| 硬编码值 | 位置 | 含义 |
|----------|------|------|
| 60 分钟 | CoachCourseService + CoachCourseScheduler | 长/短课时分界线 |
| 10 分钟 | CoachCourseService L213 | 长课正常开课窗口 |
| 30 分钟 | CoachCourseService L217, Scheduler L118 | 长课迟到/缺席截止线 |
| 10% | CoachCourseService L229 | 短课正常开课比例 |
| 25% | CoachCourseService L230, Scheduler L120 | 短课迟到/缺席比例 |
| 10 分钟 | CoachCourseService L283, Scheduler L34 | 结课宽限期 |
业务方要求:
1. **前端统一传入绝对值**(分钟),短课时比例也由前端换算后传入
2. 支持**按课程时长区间**匹配不同规则
3. 配置存储在**数据库**中
4. **热更新**——修改配置后无需重启即生效
5. 配置缺失/非法时使用**硬编码值兜底**
---
## 决策
### 1. 架构:新建 `gym-coach-config` 独立模块
**选择**: 创建新模块 `gym-coach-config`,封装时间规则配置的完整功能链。
**理由**:
- 将可配置化逻辑从 `gym-coach` 中解耦,符合单一职责原则
- `gym-coach-config` 提供规则 CRUD + 规则匹配服务,是纯"配置域"
- `gym-coach``gym-coach-config` 之间通过依赖注入协作,`gym-coach` 依赖 `gym-coach-config`
- 后续若其他模块(如签到、预约)也需要时间阈值配置化,可直接复用
**替代方案被拒绝**:
- 放在 `gym-coach` 模块内:配置逻辑和业务逻辑耦合,违反职责分离
- 放在 `manage-sys` 的字典模块:字典是通用 key-value 对,无法支撑规则匹配(需范围查询 + 优先级排序)
### 2. 数据模型:`coach_time_rule` 表
采用规则表设计,每条规则定义了一个课程时长区间及其对应的时间阈值:
```sql
CREATE TABLE coach_time_rule (
id BIGSERIAL PRIMARY KEY,
min_duration INTEGER, -- 课程时长下限(分钟),NULL 表示无下限
max_duration INTEGER, -- 课程时长上限(分钟),NULL 表示无上限
normal_window INTEGER NOT NULL, -- 正常开课窗口(分钟)
late_window INTEGER NOT NULL, -- 迟到/缺席截止窗口(分钟)
end_grace INTEGER NOT NULL, -- 结课宽限期(分钟)
is_default BOOLEAN DEFAULT FALSE, -- 是否默认规则
sort_order INTEGER DEFAULT 0, -- 优先级
status CHAR(1) DEFAULT '1',
remark VARCHAR(500),
create_by VARCHAR(64),
update_by VARCHAR(64),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
deleted_at TIMESTAMP
);
```
**示例数据**:
| id | min_duration | max_duration | normal_window | late_window | end_grace | is_default | 说明 |
|----|-------------|-------------|---------------|-------------|-----------|------------|------|
| 1 | NULL | NULL | 10 | 30 | 10 | true | 默认规则:原长课逻辑 |
| 2 | NULL | 59 | 1 | 15 | 5 | false | 短课(<60分钟):最小1分钟正常,15分钟迟到 |
### 3. 规则匹配策略
```
对于一门课程(时长 = endTime - startTime 的分钟数):
1. 从 Redis 缓存中获取所有启用规则(status='1', deleted_at IS NULL
2. 过滤出 minDuration <= courseDuration <= maxDuration 的规则
3. 选择范围最精确的规则 —— 即 (maxDuration - minDuration) 最小的那条
4. 若无匹配规则,使用 is_default=true 的默认规则
5. 若默认规则也不存在,使用硬编码兜底值
```
### 4. 热更新机制
```
┌──────────┐ POST/PUT/DELETE ┌──────────────────┐
│ 前端 │ ──────────────────> │ CoachTimeRuleHandler │
└──────────┘ └────────┬─────────┘
┌──────▼──────┐
│ DB 更新 │
└──────┬──────┘
┌──────▼──────┐
│ 删除 Redis │
│ key: │
│ coach:time: │
│ rules │
└──────┬──────┘
┌──────────────┐ 下次开课/调度器触发时 ┌──▼───────────┐
│ 业务代码 │ <────────────────── │ Redis Miss │
│ (Service/ │ │ → 从 DB 加载 │
│ Scheduler) │ │ → 写入 Redis │
└──────────────┘ └──────────────┘
```
- 写操作(创建/更新/删除规则)→ 更新 DB → 立即删除 Redis 缓存 key
- 读操作 → 先查 Redis → 未命中则查 DB → 写入 RedisTTL=300s,兜底)
- 每次业务调用(开课/调度器)都实时从 CoachTimeRuleService 获取最新规则,不缓存本地变量
### 5. 兜底策略
| 场景 | 行为 |
|------|------|
| 所有规则被删除 | 使用硬编码默认值(原逻辑:长课 10/30,短课 10%/25%,结课 10 |
| 单条规则中值为 null/负数 | 该字段使用硬编码兜底值 |
| Redis 不可用 | 降级为每次查 DB |
| DB 不可用 | 使用硬编码兜底值 |
### 6. API 设计
```
GET /api/coach/time-rules -- 获取所有规则列表
GET /api/coach/time-rules/{id} -- 获取单条规则
POST /api/coach/time-rules -- 创建规则
PUT /api/coach/time-rules/{id} -- 更新规则
DELETE /api/coach/time-rules/{id} -- 删除规则
```
POST/PUT 请求体:
```json
{
"minDuration": 60, // 可选,null 表示无下限
"maxDuration": null, // 可选,null 表示无上限
"normalWindow": 10, // 必填,正常开课窗口(分钟)
"lateWindow": 30, // 必填,迟到/缺席截止窗口(分钟)
"endGrace": 10, // 必填,结课宽限期(分钟)
"isDefault": true, // 是否设为默认规则
"sortOrder": 0,
"remark": "默认规则"
}
```
### 7. API 校验规则
后端在 Handler 层对前端传入的值做合法性校验:
- `normalWindow`:必须 >= 1 且 <= 1440(一天内)
- `lateWindow`:必须 >= `normalWindow` 且 <= 1440
- `endGrace`:必须 >= 0 且 <= 1440
- `minDuration``maxDuration`:若同时非空,`maxDuration` 必须 >= `minDuration`
- 若前端传入非法值,返回 HTTP 400 + 具体错误信息;不落库
### 8. 模块依赖关系
```
manage-app
├── gym-coach (依赖 gym-coach-config)
│ └── CoachCourseService → 注入 CoachTimeRuleService 获取规则
│ └── CoachCourseScheduler → 注入 CoachTimeRuleService 获取规则
└── gym-coach-config (新模块)
├── handler/CoachTimeRuleHandler -- HTTP 处理器
├── service/CoachTimeRuleService -- 规则匹配 + 缓存
├── domain/CoachTimeRule -- 领域对象
├── repository/ICoachTimeRuleRepository -- 仓储接口
└── router -- 路由注册
manage-db
├── entity/CoachTimeRuleEntity -- DB 实体
├── dao/CoachTimeRuleDao -- DAO (R2DBC)
└── migration/V29__Create_coach_time_rule.sql
```
---
## 影响范围
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `pom.xml` | 新增 | 添加 `gym-coach-config` 模块 |
| `gym-coach/pom.xml` | 修改 | 添加 `gym-coach-config` 依赖 |
| `CoachCourseService.java` | 修改 | 注入 `CoachTimeRuleService`,替换硬编码阈值 |
| `CoachCourseScheduler.java` | 修改 | 注入 `CoachTimeRuleService`,替换硬编码阈值 |
| `SystemRouter.java` | 修改 | 注册新路由 |
| 新建模块文件 | 新增 | 约 8-10 个 Java 文件 + 1 个 SQL 迁移 |
---
## 已知问题与修复记录
### 修复 1Flyway 版本冲突
原始版本使用了 V25/V26,与已有迁移冲突。最终使用 V29(建表)/ V30(菜单)。
### 修复 2LocalDateTime Redis 反序列化
`CoachTimeRule.domain``createdAt`/`updatedAt` 存入 Redis 后反序列化失败(DB 格式 `yyyy-MM-dd HH:mm:ss``T` 分隔符,Jackson 默认期望 ISO 格式)。已添加 `@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")`
### 修复 3:默认规则回退未做区间匹配校验
**问题**:当有区间限制的规则(如 `minDuration=30`)被标记为 `isDefault=true`,或原始默认规则被修改了区间时,不匹配该区间的课程时长(如 20 分钟)会被错误应用该规则的阈值。
**修复**`doMatch()` 中回退到默认规则时,增加 `r.matches(courseDurationMinutes)` 校验。若默认规则也不匹配,继续回退到 `buildFallbackRule` 兜底。
```diff
- .filter(r -> Boolean.TRUE.equals(r.getIsDefault()))
+ .filter(r -> Boolean.TRUE.equals(r.getIsDefault()) && r.matches(courseDurationMinutes))
```
@@ -0,0 +1,294 @@
# 教练业绩统计设计文档
**版本**: v2.0
**日期**: 2026-07-26
**状态**: 已确定
---
## 一、功能概述
教练业绩统计为体育馆管理系统提供按教练维度的绩效评估,帮助管理者横向对比教练表现、激励教练提升教学质量。
### 核心能力
- **教练排行榜**:按综合评分降序排列所有教练
- **教练详情**:查看单个教练的六项指标详情
- **自查看板**:教练查看自己的业绩表现
---
## 二、指标体系
### 2.1 六项指标总览
| 序号 | 指标 | 类型 | 含义 | 数据源 |
|------|------|------|------|--------|
| 1 | 授课量 | 基础指标 | 统计周期内已完成的团课数量 | `group_course.status IN ('2','6')` |
| 2 | 出席人次 | 基础指标 | 学员实际参与的人次 | `group_course_booking.status IN ('2','4','5')` |
| 3 | 总预约数 | 基础指标 | 学员预约该教练课程的次数(仅已预约状态) | `group_course_booking.status = '0'` |
| 4 | 出勤率 | 派生指标 | 出席人次 / 总预约数 * 100 | 指标2 + 指标3 |
| 5 | 满员率 | 派生指标 | 各课程出席人数/满员上限的平均值 | `group_course.max_members` + 指标2明细 |
| 6 | 违规次数 | 基础指标 | 统计周期内违规记录数 | `coach_violation` |
| 7 | 综合评分 | 派生指标 | 加权综合得分(详见第三章) | 指标1-6 |
### 2.2 指标口径详解
#### 授课量
```
SELECT coach_id, COUNT(*) FROM group_course
WHERE end_time >= :startTime AND end_time < :endTime
AND status IN ('2', '6') AND deleted_at IS NULL
GROUP BY coach_id
```
- **status='2'**: 教练手动结课
- **status='6'**: 系统自动结课
- **排除**: 已取消(status='1')、教练缺席(status='5')的课程
#### 出席人次
```
SELECT gc.coach_id, COUNT(*) FROM group_course_booking b
INNER JOIN group_course gc ON b.course_id = gc.id
WHERE b.status IN ('2', '4', '5') AND b.deleted_at IS NULL
AND gc.deleted_at IS NULL
AND gc.end_time >= :startTime AND gc.end_time < :endTime
GROUP BY gc.coach_id
```
- **status='2'**: 已出席 — 学员正常到课
- **status='4'**: 教练缺席 — 教练未到,学员仍需记录
- **status='5'**: 迟到 — 学员迟到但仍到场参与
> **设计意图**: 教练缺席和迟到时,学员仍到达了现场(或尝试参与),责任在教练而非学员,故计入出席人次。学员无故缺席(status='3')不计入,因其既非教练功劳也非教练责任。
#### 总预约数
```
SELECT gc.coach_id, COUNT(*) FROM group_course_booking b
INNER JOIN group_course gc ON b.course_id = gc.id
WHERE b.status = '0' AND b.deleted_at IS NULL
AND gc.deleted_at IS NULL
AND gc.end_time >= :startTime AND gc.end_time < :endTime
GROUP BY gc.coach_id
```
- **仅 status='0'(已预约)**: 作为出勤率分母,表示"承诺来上课的学员"。
#### 满员率
```
SELECT gc.coach_id, gc.max_members, COUNT(b.id) AS attended
FROM group_course gc
LEFT JOIN group_course_booking b ON gc.id = b.course_id
AND b.status IN ('2','4','5') AND b.deleted_at IS NULL
WHERE gc.end_time >= :startTime AND gc.end_time < :endTime
AND gc.status IN ('2', '6') AND gc.deleted_at IS NULL
GROUP BY gc.coach_id, gc.id, gc.max_members
```
- 对每个已完成课程,计算 `出席人数 / max_members`
- 所有课程的比值取平均值
- `max_members = 0` 的课程被跳过(除零防御)
#### 违规次数
```
SELECT coach_id, COUNT(*) FROM coach_violation
WHERE violation_time >= :startTime AND violation_time < :endTime
AND deleted_at IS NULL
GROUP BY coach_id
```
- 违规类型: `COACH_LATE`(迟到)、`COACH_ABSENT`(缺席)、`NOT_MANUAL_END`(未手动结课)
#### 时间基准
所有指标均基于 `group_course.end_time` 过滤时间范围。跨月课程归属于结束时间所在的月份。
---
## 三、综合评分算法
### 3.1 最终公式
```
综合评分 = 授课量归一化分 * 0.35
+ 出勤率 * 0.25
+ 满员率 * 0.25
+ 违规分 * 0.15
```
### 3.2 授课量归一化:百分位排名法(方案 B)
对于教练数为 N 的集合:
1. 将所有教练按授课量升序排列
2. 统计授课量严格小于当前教练的教练数 `C_fewer`
3. `normalizedCourses = C_fewer / (N - 1) * 100`N=1 时取 100
**示例**4 位教练):
| 教练 | 授课量 | 小于其的教练数 | 归一化分 |
|------|--------|---------------|---------|
| A | 20 | 3 | 100.0 |
| B | 15 | 2 | 66.7 |
| C | 10 | 1 | 33.3 |
| D | 5 | 0 | 0.0 |
**设计意图**: 百分位排名在"相对比较"和"公平性"之间取得平衡。授课量最大的教练得满分,最少的得 0 分,中间按排名线性分布。不受极端值影响——即使第一名开 100 节课、第二名只开 20 节,第二名的排名分数依然是 `2/3 * 100 ≈ 66.7`
### 3.3 违规分:线性扣分法(方案 A)
```
violationScore = max(0, 100 - violations * 20)
```
| 违规次数 | 违规分 |
|----------|--------|
| 0 | 100 |
| 1 | 80 |
| 2 | 60 |
| 3 | 40 |
| 4 | 20 |
| 5+ | 0 |
**设计意图**: 线性扣分简单直观,每次违规固定扣 20 分,累计 5 次后清零。在 15% 的权重下,每次违规对综合评分的影响约为 `20 * 0.15 = 3 分`
### 3.4 出勤率 & 满员率
这两项直接使用原始百分比(0-100),无需归一化——它们天然在 0-100 范围内且具有绝对含义。
```
出勤率 = 出席人次 / 总预约数 * 100(分母为 0 时取 0)
满员率 = avg(单个课程出席人数 / max_members) * 100(跳过 max_members=0 的课程)
```
---
## 四、方案选择记录
### 4.1 授课量归一化方案
| 方案 | 公式 | 优点 | 缺点 | 决定 |
|------|------|------|------|------|
| **B: 百分位排名** | `C_fewer / (N-1) * 100` | 直观、不受极端值影响 | 对教练总数敏感(N<3 时分布粗糙) | **采纳** |
| A: 最大值归一化 | `courses / max(courses) * 100` | 数学简洁 | 极端值压缩中游得分 | 初版方案,已废弃 |
| C: 对数归一化 | `ln(x+1)/ln(m+1)*100` | 压制极端值 | 解释性弱 | 已拒绝 |
| D: 固定目标 | `min(x/target*100, 100)` | 达标制、不互卷 | target 难校准 | 已拒绝 |
### 4.2 违规扣分方案
| 方案 | 公式 | 优点 | 缺点 | 决定 |
|------|------|------|------|------|
| **A: 线性扣分** | `max(0, 100 - v*20)` | 简单直白,每次等量扣分 | 多次违规后惩罚不再加剧 | **采纳** |
| B: 阶梯扣分 | 0→100, 1→70, 2→40, 3→10 | 首次违规惩罚重,有威慑力 | 第 1 到第 2 次差距 30 分,太激进 | 已拒绝 |
| C: 归一化扣分 | `(1 - v/max)*100` | 相对最差教练 | 依赖数据集,全员无违规则无意义 | 已拒绝 |
### 4.3 权重分配方案
| 方案 | 授课量 | 出勤率 | 满员率 | 违规 | 决定 |
|------|--------|--------|--------|------|------|
| **A: 轻违规** | 35% | 25% | 25% | 15% | **采纳** |
| B: 中违规 | 30% | 25% | 25% | 20% | 已拒绝 |
| C: 重违规 | 30% | 23% | 22% | 25% | 已拒绝 |
### 4.4 出勤率口径方案
| 方案 | 分子 | 分母 | 决定 |
|------|------|------|------|
| **当前** | status IN ('2','4','5') | status='0' | **采纳** |
| 初版 | status='2' | status!='1' | 已废弃 |
| 替代方案1 | status='2' | status IN ('0','2','3') | 已拒绝(无故缺席应排除) |
| 替代方案2 | status='2' | status IN ('0','2') | 已拒绝(不能区分取消预约) |
---
## 五、数据模型
### 5.1 API 响应模型
```java
public class CoachPerformance {
Long coachId; // 教练ID
String coachName; // 教练昵称
String avatar; // 头像URL
Long completedCourses; // 授课量
Long attendedStudents; // 出席人次
Long totalBookings; // 总预约数
Double attendanceRate; // 出勤率 (%)
Double fillRate; // 满员率 (%)
Long violationCount; // 违规次数
Double compositeScore; // 综合评分
}
```
### 5.2 API 接口
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/datacount/coach-performance/ranking` | 全部教练业绩排行榜 |
| GET | `/api/datacount/coach-performance/{coachId}` | 单个教练业绩详情 |
| GET | `/api/datacount/coach-performance/mine?coachId=` | 教练自查看板 |
**查询参数**: `statType`, `periodType`DAY/WEEK/MONTH/LAST_30_DAYS/LAST_90_DAYS/YEAR, `startTime`, `endTime`
---
## 六、代码架构
```
CoachPerformanceHandler ── Reactive Router Function,解析请求
IDataStatisticsService ── 接口定义
DataStatisticsServiceImpl ── 6 并行查询 + 聚合计算
DataStatisticsDao ── DatabaseClient SQL 聚合
┌───┼───┬───┬───┬───┐
▼ ▼ ▼ ▼ ▼ ▼
sys_user group_course group_course_booking coach_violation
```
### 查询执行流程
```
1. Flux: getAllCoachesWithInfo() ─→ Map<coachId, 基本信息>
2. Flux: countCompletedCoursesByCoach() ─→ Map<coachId, 授课量> ┐
3. Flux: countAttendedStudentsByCoach() ─→ Map<coachId, 出席人次> │
4. Flux: countTotalBookingsByCoach() ─→ Map<coachId, 总预约数> ├─ Mono.zip
5. Flux: getFillRateDetailByCoach() ─→ Map<coachId, List<明细>> │
6. Flux: countViolationsByCoach() ─→ Map<coachId, 违规次数> ┘
flatMapMany: 逐教练计算指标
sorted: 按综合评分降序
Flux<CoachPerformance>
```
---
## 七、边界情况处理
| 场景 | 行为 |
|------|------|
| 教练无任何已完成课程 | 所有指标为 0,综合评分 = 0 + 0 + 0 + 15 = **15**(违规分满分 100 * 0.15 |
| 教练有课程但无人预约 | 授课量 > 0,出勤率/满员率 = 0 |
| 课程 max_members = 0 | 该课程跳过,不参与满员率计算 |
| 仅有 1 位教练 | 百分位排名直接返回 100 |
| 所有教练授课量相同 | 所有教练 `C_fewer = 0`,授课量归一化分均为 0 |
| 跨月课程 | 以 end_time 所在月份归类 |
| 查询单个教练不存在 | 返回零值 `CoachPerformance`coachName="未知教练" |
---
## 八、变更历史
| 日期 | 版本 | 变更内容 |
|------|------|---------|
| 2026-07-22 | v1.0 | 初版:最大值归一化 + 三维度评分(4:3:3),违规仅展示 |
| 2026-07-26 | v2.0 | 时间基准改为 end_time;出席人次扩展为(2,4,5);出勤率分母改为仅 status='0';授课量归一化改为百分位排名;违规纳入综合评分(权重 15%);满员率防御除零 |
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-manage-api</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>gym-coach-config</artifactId>
<packaging>jar</packaging>
<name>Gym Coach Config</name>
<description>Coach Time Rule Configuration Module - Configurable coach lateness/absence thresholds</description>
<dependencies>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-db</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-sys</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-commons</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,58 @@
package cn.novalon.gym.manage.coachconfig.converter;
import cn.novalon.gym.manage.coachconfig.domain.CoachTimeRule;
import cn.novalon.gym.manage.db.entity.CoachTimeRuleEntity;
import org.springframework.stereotype.Component;
/**
* 教练时间规则实体转换器
*
* @author 张翔
* @date 2026-07-26
*/
@Component
public class CoachTimeRuleConverter {
public CoachTimeRule toDomain(CoachTimeRuleEntity entity) {
if (entity == null) {
return null;
}
CoachTimeRule domain = new CoachTimeRule();
domain.setId(entity.getId());
domain.setMinDuration(entity.getMinDuration());
domain.setMaxDuration(entity.getMaxDuration());
domain.setNormalWindow(entity.getNormalWindow());
domain.setLateWindow(entity.getLateWindow());
domain.setEndGrace(entity.getEndGrace());
domain.setIsDefault(entity.getIsDefault());
domain.setSortOrder(entity.getSortOrder());
domain.setStatus(entity.getStatus());
domain.setRemark(entity.getRemark());
domain.setCreatedAt(entity.getCreatedAt());
domain.setUpdatedAt(entity.getUpdatedAt());
return domain;
}
public CoachTimeRuleEntity toEntity(CoachTimeRule domain) {
if (domain == null) {
return null;
}
CoachTimeRuleEntity entity = new CoachTimeRuleEntity();
entity.setId(domain.getId());
entity.setMinDuration(domain.getMinDuration());
entity.setMaxDuration(domain.getMaxDuration());
entity.setNormalWindow(domain.getNormalWindow());
entity.setLateWindow(domain.getLateWindow());
entity.setEndGrace(domain.getEndGrace());
entity.setIsDefault(domain.getIsDefault());
entity.setSortOrder(domain.getSortOrder());
entity.setStatus(domain.getStatus());
entity.setRemark(domain.getRemark());
entity.setCreateBy(domain.getCreateBy());
entity.setUpdateBy(domain.getUpdateBy());
entity.setCreatedAt(domain.getCreatedAt());
entity.setUpdatedAt(domain.getUpdatedAt());
return entity;
}
}
@@ -0,0 +1,98 @@
package cn.novalon.gym.manage.coachconfig.domain;
import com.fasterxml.jackson.annotation.JsonFormat;
import java.time.LocalDateTime;
/**
* 教练时间规则领域对象
*
* @author 张翔
* @date 2026-07-26
*/
public class CoachTimeRule {
private Long id;
private Integer minDuration;
private Integer maxDuration;
private Integer normalWindow;
private Integer lateWindow;
private Integer endGrace;
private Boolean isDefault;
private Integer sortOrder;
private String status;
private String remark;
private String createBy;
private String updateBy;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updatedAt;
/**
* 判断该规则是否匹配给定课程时长
*/
public boolean matches(long courseDurationMinutes) {
boolean aboveMin = minDuration == null || courseDurationMinutes >= minDuration;
boolean belowMax = maxDuration == null || courseDurationMinutes <= maxDuration;
return aboveMin && belowMax;
}
/**
* 返回规则区间的宽度(用于精确匹配排序),无边界时返回 Integer.MAX_VALUE
*/
public int rangeWidth() {
if (minDuration == null && maxDuration == null) {
return Integer.MAX_VALUE;
}
if (minDuration == null) {
return maxDuration;
}
if (maxDuration == null) {
return Integer.MAX_VALUE - minDuration;
}
return maxDuration - minDuration;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Integer getMinDuration() { return minDuration; }
public void setMinDuration(Integer minDuration) { this.minDuration = minDuration; }
public Integer getMaxDuration() { return maxDuration; }
public void setMaxDuration(Integer maxDuration) { this.maxDuration = maxDuration; }
public Integer getNormalWindow() { return normalWindow; }
public void setNormalWindow(Integer normalWindow) { this.normalWindow = normalWindow; }
public Integer getLateWindow() { return lateWindow; }
public void setLateWindow(Integer lateWindow) { this.lateWindow = lateWindow; }
public Integer getEndGrace() { return endGrace; }
public void setEndGrace(Integer endGrace) { this.endGrace = endGrace; }
public Boolean getIsDefault() { return isDefault; }
public void setIsDefault(Boolean isDefault) { this.isDefault = isDefault; }
public Integer getSortOrder() { return sortOrder; }
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getRemark() { return remark; }
public void setRemark(String remark) { this.remark = remark; }
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; }
}
@@ -0,0 +1,94 @@
package cn.novalon.gym.manage.coachconfig.handler;
import cn.novalon.gym.manage.coachconfig.domain.CoachTimeRule;
import cn.novalon.gym.manage.coachconfig.service.CoachTimeRuleService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.Map;
/**
* 教练时间规则 HTTP 处理器
*
* @author 张翔
* @date 2026-07-26
*/
@Component
@Tag(name = "教练时间规则配置", description = "教练迟到/缺席时间阈值的可配置化管理")
public class CoachTimeRuleHandler {
private static final Logger logger = LoggerFactory.getLogger(CoachTimeRuleHandler.class);
private final CoachTimeRuleService service;
public CoachTimeRuleHandler(CoachTimeRuleService service) {
this.service = service;
}
@Operation(summary = "获取所有规则", description = "获取所有启用和停用的教练时间规则")
public Mono<ServerResponse> getAllRules(ServerRequest request) {
return service.getAllRules()
.collectList()
.flatMap(rules -> ServerResponse.ok().bodyValue(rules))
.onErrorResume(e -> {
logger.error("获取规则列表失败: {}", e.getMessage());
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
});
}
@Operation(summary = "获取单条规则", description = "根据ID获取教练时间规则")
public Mono<ServerResponse> getRuleById(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return service.getRuleById(id)
.flatMap(rule -> ServerResponse.ok().bodyValue(rule))
.onErrorResume(e -> {
logger.error("获取规则失败: {}", e.getMessage());
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
});
}
@Operation(summary = "创建规则", description = "创建一条新的教练时间规则,创建后立即生效")
public Mono<ServerResponse> createRule(ServerRequest request) {
return request.bodyToMono(CoachTimeRule.class)
.flatMap(rule -> service.createRule(rule)
.flatMap(saved -> {
Map<String, Object> result = new HashMap<>();
result.put("message", "规则创建成功");
result.put("id", saved.getId());
return ServerResponse.ok().bodyValue(result);
}))
.onErrorResume(e -> {
logger.error("创建规则失败: {}", e.getMessage());
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
});
}
@Operation(summary = "更新规则", description = "更新教练时间规则,更新后立即生效")
public Mono<ServerResponse> updateRule(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return request.bodyToMono(CoachTimeRule.class)
.flatMap(rule -> service.updateRule(id, rule)
.flatMap(updated -> 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> deleteRule(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return service.deleteRule(id)
.then(ServerResponse.ok().bodyValue(Map.of("message", "规则删除成功")))
.onErrorResume(e -> {
logger.error("删除规则失败: {}", e.getMessage());
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
});
}
}
@@ -0,0 +1,79 @@
package cn.novalon.gym.manage.coachconfig.repository;
import cn.novalon.gym.manage.coachconfig.domain.CoachTimeRule;
import cn.novalon.gym.manage.coachconfig.converter.CoachTimeRuleConverter;
import cn.novalon.gym.manage.db.dao.CoachTimeRuleDao;
import cn.novalon.gym.manage.db.entity.CoachTimeRuleEntity;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
/**
* 教练时间规则仓储实现类
*
* @author 张翔
* @date 2026-07-26
*/
@Repository
public class CoachTimeRuleRepository implements ICoachTimeRuleRepository {
private final CoachTimeRuleDao dao;
private final CoachTimeRuleConverter converter;
public CoachTimeRuleRepository(CoachTimeRuleDao dao, CoachTimeRuleConverter converter) {
this.dao = dao;
this.converter = converter;
}
@Override
public Flux<CoachTimeRule> findByStatusAndDeletedAtIsNull(String status) {
return dao.findByStatusAndDeletedAtIsNull(status, Sort.by(Sort.Direction.ASC, "sort_order"))
.map(converter::toDomain);
}
@Override
public Flux<CoachTimeRule> findByDeletedAtIsNull() {
return dao.findByDeletedAtIsNull()
.map(converter::toDomain);
}
@Override
public Mono<CoachTimeRule> findById(Long id) {
return dao.findByIdAndDeletedAtIsNull(id)
.map(converter::toDomain);
}
@Override
public Mono<CoachTimeRule> save(CoachTimeRule rule) {
CoachTimeRuleEntity entity = converter.toEntity(rule);
entity.setCreatedAt(rule.getId() == null ? LocalDateTime.now() : entity.getCreatedAt());
entity.setUpdatedAt(LocalDateTime.now());
return dao.save(entity)
.map(converter::toDomain);
}
@Override
public Mono<Void> deleteById(Long id) {
return dao.findByIdAndDeletedAtIsNull(id)
.flatMap(entity -> {
entity.setDeletedAt(LocalDateTime.now());
return dao.save(entity);
})
.then();
}
@Override
public Mono<Void> unsetOtherDefaults(Long excludeId) {
return dao.findByIsDefaultTrueAndStatusAndDeletedAtIsNull("1")
.filter(entity -> excludeId == null || !entity.getId().equals(excludeId))
.flatMap(entity -> {
entity.setIsDefault(false);
entity.setUpdatedAt(LocalDateTime.now());
return dao.save(entity);
})
.then();
}
}
@@ -0,0 +1,27 @@
package cn.novalon.gym.manage.coachconfig.repository;
import cn.novalon.gym.manage.coachconfig.domain.CoachTimeRule;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* 教练时间规则仓储接口
*
* @author 张翔
* @date 2026-07-26
*/
public interface ICoachTimeRuleRepository {
Flux<CoachTimeRule> findByStatusAndDeletedAtIsNull(String status);
Flux<CoachTimeRule> findByDeletedAtIsNull();
Mono<CoachTimeRule> findById(Long id);
Mono<CoachTimeRule> save(CoachTimeRule rule);
Mono<Void> deleteById(Long id);
/** 将除指定 id 之外的所有启用默认规则设为非默认(excludeId 为 null 表示清除全部) */
Mono<Void> unsetOtherDefaults(Long excludeId);
}
@@ -0,0 +1,240 @@
package cn.novalon.gym.manage.coachconfig.service;
import cn.novalon.gym.manage.coachconfig.domain.CoachTimeRule;
import cn.novalon.gym.manage.coachconfig.repository.ICoachTimeRuleRepository;
import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
import cn.novalon.gym.manage.common.util.RedisUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.Comparator;
import java.util.List;
/**
* 教练时间规则服务
*
* 提供规则 CRUD、缓存管理、以及基于课程时长的规则匹配。
* 每次写操作后立即清除 Redis 缓存,读操作使用 Cache-Aside 模式。
*
* @author 张翔
* @date 2026-07-26
*/
@Service
public class CoachTimeRuleService {
private static final Logger logger = LoggerFactory.getLogger(CoachTimeRuleService.class);
/** 硬编码兜底值:长课时(>=60分钟)默认正常窗口 */
private static final int FALLBACK_LONG_NORMAL_WINDOW = 10;
/** 硬编码兜底值:长课时(>=60分钟)默认迟到/缺席窗口 */
private static final int FALLBACK_LONG_LATE_WINDOW = 30;
/** 硬编码兜底值:短课时(<60分钟)默认正常窗口比例 */
private static final double FALLBACK_SHORT_NORMAL_RATIO = 0.10;
/** 硬编码兜底值:短课时(<60分钟)默认迟到/缺席窗口比例 */
private static final double FALLBACK_SHORT_LATE_RATIO = 0.25;
/** 硬编码兜底值:结课宽限期 */
private static final int FALLBACK_END_GRACE = 10;
/** 长/短课时分界线 */
private static final long ONE_HOUR_MINUTES = 60;
/** Redis 缓存 TTL(秒) */
private static final long CACHE_TTL_SECONDS = 300;
private final ICoachTimeRuleRepository repository;
private final RedisUtil redisUtil;
public CoachTimeRuleService(ICoachTimeRuleRepository repository, RedisUtil redisUtil) {
this.repository = repository;
this.redisUtil = redisUtil;
}
// ==================== 规则匹配 ====================
/**
* 根据课程时长(分钟)匹配最精确的时间规则。
* 匹配逻辑:
* 1. 从缓存/DB 获取所有启用规则
* 2. 过滤出 courseDuration 在 [minDuration, maxDuration] 范围内的规则
* 3. 选择区间范围最小的(最精确匹配)
* 4. 无匹配时使用 isDefault=true 的默认规则
* 5. 全部无匹配时使用硬编码兜底值
*/
public Mono<CoachTimeRule> matchRule(long courseDurationMinutes) {
return getActiveRules()
.collectList()
.map(rules -> doMatch(rules, courseDurationMinutes));
}
private CoachTimeRule doMatch(List<CoachTimeRule> rules, long courseDurationMinutes) {
// 过滤出匹配的规则
List<CoachTimeRule> matched = rules.stream()
.filter(r -> r.matches(courseDurationMinutes))
.toList();
if (!matched.isEmpty()) {
// 选择范围最精确(区间宽度最小)的规则
return matched.stream()
.min(Comparator.comparingInt(CoachTimeRule::rangeWidth))
.orElseThrow();
}
// 无匹配,查找默认规则(同时也须匹配区间,防止有区间限制的默认规则覆盖不匹配的课程时长)
CoachTimeRule defaultRule = rules.stream()
.filter(r -> Boolean.TRUE.equals(r.getIsDefault()) && r.matches(courseDurationMinutes))
.findFirst()
.orElse(null);
if (defaultRule != null) {
logger.debug("无精确匹配规则,回退到默认规则 id={}", defaultRule.getId());
return defaultRule;
}
// 兜底:返回硬编码默认值构造的虚拟规则
logger.warn("无匹配规则且默认规则也不匹配课程时长{}分钟,使用硬编码兜底值", courseDurationMinutes);
return buildFallbackRule(courseDurationMinutes);
}
/**
* 构造硬编码兜底规则(不持久化,仅在内存中使用)
*/
private CoachTimeRule buildFallbackRule(long courseDurationMinutes) {
CoachTimeRule fallback = new CoachTimeRule();
fallback.setEndGrace(FALLBACK_END_GRACE);
if (courseDurationMinutes >= ONE_HOUR_MINUTES) {
fallback.setNormalWindow(FALLBACK_LONG_NORMAL_WINDOW);
fallback.setLateWindow(FALLBACK_LONG_LATE_WINDOW);
} else {
fallback.setNormalWindow(Math.max(1, (int) (courseDurationMinutes * FALLBACK_SHORT_NORMAL_RATIO)));
fallback.setLateWindow(Math.max(1, (int) (courseDurationMinutes * FALLBACK_SHORT_LATE_RATIO)));
}
return fallback;
}
// ==================== 缓存管理 ====================
/**
* 获取所有启用规则(带 Redis 缓存)
*/
private Flux<CoachTimeRule> getActiveRules() {
return redisUtil.get(RedisKeyConstants.COACH_TIME_RULES)
.flatMapMany(cached -> {
@SuppressWarnings("unchecked")
List<CoachTimeRule> list = (List<CoachTimeRule>) cached;
logger.debug("从 Redis 缓存加载教练时间规则,共 {} 条", list.size());
return Flux.fromIterable(list);
})
.switchIfEmpty(Flux.defer(() -> {
logger.debug("Redis 缓存未命中,从 DB 加载教练时间规则");
return repository.findByStatusAndDeletedAtIsNull("1")
.collectList()
.flatMapMany(list -> {
redisUtil.setWithExpire(RedisKeyConstants.COACH_TIME_RULES, list, CACHE_TTL_SECONDS)
.subscribe(
ok -> logger.debug("教练时间规则已写入 Redis 缓存,共 {} 条", list.size()),
err -> logger.warn("教练时间规则写入 Redis 缓存失败: {}", err.getMessage())
);
return Flux.fromIterable(list);
});
}));
}
/**
* 写操作后清除缓存(热更新入口)
*/
private void invalidateCache() {
redisUtil.delete(RedisKeyConstants.COACH_TIME_RULES)
.subscribe(
count -> logger.info("教练时间规则缓存已清除"),
err -> logger.warn("教练时间规则缓存清除失败: {}", err.getMessage())
);
}
// ==================== CRUD ====================
public Flux<CoachTimeRule> getAllRules() {
return repository.findByDeletedAtIsNull();
}
public Mono<CoachTimeRule> getRuleById(Long id) {
return repository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("规则不存在")));
}
public Mono<CoachTimeRule> createRule(CoachTimeRule rule) {
return validateRule(rule)
.then(Mono.defer(() -> {
if (Boolean.TRUE.equals(rule.getIsDefault())) {
return repository.unsetOtherDefaults(null).then(repository.save(rule));
}
return repository.save(rule);
}))
.doOnSuccess(r -> invalidateCache());
}
public Mono<CoachTimeRule> updateRule(Long id, CoachTimeRule rule) {
return repository.findById(id)
.switchIfEmpty(Mono.error(new RuntimeException("规则不存在")))
.flatMap(existing -> {
existing.setMinDuration(rule.getMinDuration());
existing.setMaxDuration(rule.getMaxDuration());
existing.setNormalWindow(rule.getNormalWindow());
existing.setLateWindow(rule.getLateWindow());
existing.setEndGrace(rule.getEndGrace());
existing.setIsDefault(rule.getIsDefault());
existing.setSortOrder(rule.getSortOrder());
existing.setStatus(rule.getStatus());
existing.setRemark(rule.getRemark());
existing.setUpdateBy(rule.getUpdateBy());
return validateRule(existing)
.then(Mono.defer(() -> {
if (Boolean.TRUE.equals(existing.getIsDefault())) {
return repository.unsetOtherDefaults(id).then(repository.save(existing));
}
return repository.save(existing);
}));
})
.doOnSuccess(r -> invalidateCache());
}
public Mono<Void> deleteRule(Long id) {
return repository.deleteById(id)
.doOnSuccess(v -> invalidateCache());
}
// ==================== 校验 ====================
/**
* 校验规则字段合法性
*/
private Mono<Void> validateRule(CoachTimeRule rule) {
Integer normalWindow = rule.getNormalWindow();
if (normalWindow == null || normalWindow < 1 || normalWindow > 1440) {
return Mono.error(new RuntimeException("正常开课窗口必须为 1-1440 之间的整数"));
}
Integer lateWindow = rule.getLateWindow();
if (lateWindow == null || lateWindow < normalWindow || lateWindow > 1440) {
return Mono.error(new RuntimeException("迟到/缺席窗口必须 >= 正常开课窗口(" + normalWindow + ")且 <= 1440"));
}
Integer endGrace = rule.getEndGrace();
if (endGrace == null || endGrace < 0 || endGrace > 1440) {
return Mono.error(new RuntimeException("结课宽限期必须为 0-1440 之间的整数"));
}
// 默认规则作为兜底,不允许设置时长区间
if (Boolean.TRUE.equals(rule.getIsDefault())) {
if (rule.getMinDuration() != null || rule.getMaxDuration() != null) {
return Mono.error(new RuntimeException("默认规则作为兜底规则,不允许设置时长区间"));
}
rule.setMinDuration(null);
rule.setMaxDuration(null);
}
// 区间合法性:若 minDuration 和 maxDuration 同时非空,则 maxDuration >= minDuration
Integer minDur = rule.getMinDuration();
Integer maxDur = rule.getMaxDuration();
if (minDur != null && maxDur != null && maxDur < minDur) {
return Mono.error(new RuntimeException("时长上限必须 >= 时长下限"));
}
return Mono.empty();
}
}
+5
View File
@@ -37,6 +37,11 @@
<artifactId>gym-groupCourse</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-coach-config</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
@@ -1,6 +1,7 @@
package cn.novalon.gym.manage.coach.scheduler;
import cn.novalon.gym.manage.coach.enums.ViolationReason;
import cn.novalon.gym.manage.coachconfig.service.CoachTimeRuleService;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseBookingDao;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
@@ -30,22 +31,23 @@ import java.time.LocalDateTime;
public class CoachCourseScheduler {
private static final Logger logger = LoggerFactory.getLogger(CoachCourseScheduler.class);
private static final long ONE_HOUR_MINUTES = 60;
private static final long END_GRACE_MINUTES = 10;
private final GroupCourseDao groupCourseDao;
private final GroupCourseBookingDao groupCourseBookingDao;
private final DatabaseClient databaseClient;
private final RedisUtil redisUtil;
private final CoachTimeRuleService timeRuleService;
public CoachCourseScheduler(GroupCourseDao groupCourseDao,
GroupCourseBookingDao groupCourseBookingDao,
DatabaseClient databaseClient,
RedisUtil redisUtil) {
RedisUtil redisUtil,
CoachTimeRuleService timeRuleService) {
this.groupCourseDao = groupCourseDao;
this.groupCourseBookingDao = groupCourseBookingDao;
this.databaseClient = databaseClient;
this.redisUtil = redisUtil;
this.timeRuleService = timeRuleService;
}
/**
@@ -89,39 +91,38 @@ public class CoachCourseScheduler {
*/
private Mono<Long> processAbsentCourses(LocalDateTime now) {
return groupCourseDao.findByStatusAndStartTimeBefore(databaseClient, "0", now)
.filter(course -> isAbsentThresholdExceeded(course, now))
.flatMap(course -> markAsCoachAbsent(course, now))
.flatMap(course -> {
long courseDurationMinutes = Duration.between(course.getStartTime(), course.getEndTime()).toMinutes();
return timeRuleService.matchRule(courseDurationMinutes)
.filter(rule -> {
long minutesSinceStart = Duration.between(course.getStartTime(), now).toMinutes();
return minutesSinceStart > rule.getLateWindow();
})
.flatMap(rule -> markAsCoachAbsent(course, now));
})
.count();
}
/**
* 处理自动结课:status IN ('3','7') 且 end_time + 10分钟 已过
* 处理自动结课:status IN ('3','7') 且 end_time + endGrace 已过
*/
private Mono<Long> processAutoEndCourses(LocalDateTime now) {
LocalDateTime endThreshold = now.minusMinutes(END_GRACE_MINUTES);
return groupCourseDao.findByStatusInAndEndTimeBefore(databaseClient,
new String[]{String.valueOf(CourseStatus.IN_PROGRESS.getValue()),
String.valueOf(CourseStatus.COACH_LATE.getValue())},
endThreshold)
.flatMap(course -> markAsAutoEnded(course, now))
now)
.flatMap(course -> {
long courseDurationMinutes = Duration.between(course.getStartTime(), course.getEndTime()).toMinutes();
return timeRuleService.matchRule(courseDurationMinutes)
.filter(rule -> {
long minutesAfterEnd = Duration.between(course.getEndTime(), now).toMinutes();
return minutesAfterEnd > rule.getEndGrace();
})
.flatMap(rule -> markAsAutoEnded(course, now));
})
.count();
}
/**
* 判断课程是否已过缺席阈值
*/
private boolean isAbsentThresholdExceeded(GroupCourseEntity course, LocalDateTime now) {
long courseDurationMinutes = Duration.between(course.getStartTime(), course.getEndTime()).toMinutes();
long minutesSinceStart = Duration.between(course.getStartTime(), now).toMinutes();
if (courseDurationMinutes >= ONE_HOUR_MINUTES) {
return minutesSinceStart > 30;
} else {
long thresholdB = Math.max(1, (long) (courseDurationMinutes * 0.25));
return minutesSinceStart > thresholdB;
}
}
/**
* 标记课程为教练缺席(5),更新预约记录为教练缺席(4),记录违规
*/
@@ -3,6 +3,8 @@ package cn.novalon.gym.manage.coach.service;
import cn.novalon.gym.manage.coach.dao.CoachViolationDao;
import cn.novalon.gym.manage.coach.entity.CoachViolationEntity;
import cn.novalon.gym.manage.coach.enums.ViolationReason;
import cn.novalon.gym.manage.coachconfig.domain.CoachTimeRule;
import cn.novalon.gym.manage.coachconfig.service.CoachTimeRuleService;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.common.util.StatusConstants;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseBookingDao;
@@ -43,7 +45,6 @@ public class CoachCourseService {
private static final Logger logger = LoggerFactory.getLogger(CoachCourseService.class);
private static final String COACH_ROLE_NAME = "教练";
private static final long ONE_HOUR_MINUTES = 60;
private final ISysUserRepository userRepository;
private final ISysRoleRepository roleRepository;
@@ -56,6 +57,7 @@ public class CoachCourseService {
private final DatabaseClient databaseClient;
private final PasswordEncoder passwordEncoder;
private final RedisUtil redisUtil;
private final CoachTimeRuleService timeRuleService;
public CoachCourseService(ISysUserRepository userRepository,
ISysRoleRepository roleRepository,
@@ -67,7 +69,8 @@ public class CoachCourseService {
CoachViolationDao violationDao,
DatabaseClient databaseClient,
PasswordEncoder passwordEncoder,
RedisUtil redisUtil) {
RedisUtil redisUtil,
CoachTimeRuleService timeRuleService) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.userRoleRepository = userRoleRepository;
@@ -79,6 +82,7 @@ public class CoachCourseService {
this.databaseClient = databaseClient;
this.passwordEncoder = passwordEncoder;
this.redisUtil = redisUtil;
this.timeRuleService = timeRuleService;
}
// ==================== 教练管理(从原 CoachService 迁移) ====================
@@ -176,9 +180,7 @@ public class CoachCourseService {
/**
* 教练手动开课
* 判定逻辑
* - 长课时(>=1h): 10分钟内正常,10~30分钟迟到,>30分钟拒绝
* - 短课时(<1h): 10%时长内正常,10%~25%迟到,>25%拒绝
* 通过 CoachTimeRuleService 匹配规则获取时间阈值,替代原硬编码逻辑
*/
public Mono<GroupCourseEntity> startCourse(Long courseId, Long coachId) {
return groupCourseDao.findByIdIsAndDeletedAtIsNull(courseId)
@@ -196,53 +198,29 @@ public class CoachCourseService {
LocalDateTime now = LocalDateTime.now();
long courseDurationMinutes = Duration.between(course.getStartTime(), course.getEndTime()).toMinutes();
if (courseDurationMinutes >= ONE_HOUR_MINUTES) {
return handleLongCourseStart(course, now);
} else {
return handleShortCourseStart(course, now, courseDurationMinutes);
}
return timeRuleService.matchRule(courseDurationMinutes)
.flatMap(rule -> doStartCourseWithRule(course, now, rule));
});
}
private Mono<GroupCourseEntity> handleLongCourseStart(GroupCourseEntity course, LocalDateTime now) {
private Mono<GroupCourseEntity> doStartCourseWithRule(GroupCourseEntity course, LocalDateTime now,
CoachTimeRule rule) {
long minutesSinceStart = Duration.between(course.getStartTime(), now).toMinutes();
if (minutesSinceStart < 0) {
return Mono.error(new RuntimeException("课程尚未到开课时间"));
}
if (minutesSinceStart <= 10) {
if (minutesSinceStart <= rule.getNormalWindow()) {
// 正常开课
return doStartCourse(course, now, CourseStatus.IN_PROGRESS, null);
}
if (minutesSinceStart <= 30) {
if (minutesSinceStart <= rule.getLateWindow()) {
// 教练迟到
return recordViolation(course.getCoachId(), course.getId(), now, ViolationReason.COACH_LATE)
.then(doStartCourse(course, now, CourseStatus.COACH_LATE, ViolationReason.COACH_LATE));
}
// >30分钟,拒绝(调度器应已标记为缺席)
return Mono.error(new RuntimeException("已超过开课时间30分钟,无法开课"));
}
private Mono<GroupCourseEntity> handleShortCourseStart(GroupCourseEntity course, LocalDateTime now,
long courseDurationMinutes) {
long minutesSinceStart = Duration.between(course.getStartTime(), now).toMinutes();
long thresholdA = Math.max(1, (long) (courseDurationMinutes * 0.10));
long thresholdB = Math.max(1, (long) (courseDurationMinutes * 0.25));
if (minutesSinceStart < 0) {
return Mono.error(new RuntimeException("课程尚未到开课时间"));
}
if (minutesSinceStart <= thresholdA) {
// 正常开课
return doStartCourse(course, now, CourseStatus.IN_PROGRESS, null);
}
if (minutesSinceStart <= thresholdB) {
// 教练迟到
return recordViolation(course.getCoachId(), course.getId(), now, ViolationReason.COACH_LATE)
.then(doStartCourse(course, now, CourseStatus.COACH_LATE, ViolationReason.COACH_LATE));
}
// >thresholdB,拒绝
return Mono.error(new RuntimeException("已超过开课时间,无法开课"));
// 超过 lateWindow,拒绝
return Mono.error(new RuntimeException("已超过开课时间" + rule.getLateWindow() + "分钟,无法开课"));
}
private Mono<GroupCourseEntity> doStartCourse(GroupCourseEntity course, LocalDateTime now,
@@ -260,7 +238,7 @@ public class CoachCourseService {
/**
* 教练手动结课
* 可在 IN_PROGRESS(3) 或 COACH_LATE(7) 状态下结课
* 必须在标注结课时间 + 10分钟内
* 必须在标注结课时间 + endGrace 分钟内
*/
public Mono<GroupCourseEntity> endCourse(Long courseId, Long coachId) {
return groupCourseDao.findByIdIsAndDeletedAtIsNull(courseId)
@@ -278,18 +256,25 @@ public class CoachCourseService {
}
LocalDateTime now = LocalDateTime.now();
long minutesAfterEnd = Duration.between(course.getEndTime(), now).toMinutes();
long courseDurationMinutes = Duration.between(course.getStartTime(), course.getEndTime()).toMinutes();
if (minutesAfterEnd > 10) {
return Mono.error(new RuntimeException("已超过结课时间10分钟,请等待系统自动结课"));
}
return timeRuleService.matchRule(courseDurationMinutes)
.flatMap(rule -> {
long minutesAfterEnd = Duration.between(course.getEndTime(), now).toMinutes();
course.setStatus(CourseStatus.ENDED.getValue());
course.setActualEndTime(now);
course.setUpdatedAt(now);
return groupCourseDao.updateEndInfo(course.getId(), String.valueOf(CourseStatus.ENDED.getValue()), now, now)
.then(groupCourseDao.findByIdIsAndDeletedAtIsNull(course.getId()))
.flatMap(entity -> invalidateStatisticsCache().thenReturn(entity));
if (minutesAfterEnd > rule.getEndGrace()) {
return Mono.error(new RuntimeException(
"已超过结课时间" + rule.getEndGrace() + "分钟,请等待系统自动结课"));
}
course.setStatus(CourseStatus.ENDED.getValue());
course.setActualEndTime(now);
course.setUpdatedAt(now);
return groupCourseDao.updateEndInfo(course.getId(),
String.valueOf(CourseStatus.ENDED.getValue()), now, now)
.then(groupCourseDao.findByIdIsAndDeletedAtIsNull(course.getId()))
.flatMap(entity -> invalidateStatisticsCache().thenReturn(entity));
});
});
}
@@ -271,7 +271,7 @@ public class DataStatisticsDao {
return databaseClient.sql("""
SELECT coach_id, COUNT(*) AS count
FROM group_course
WHERE start_time >= :startTime AND start_time < :endTime
WHERE end_time >= :startTime AND end_time < :endTime
AND status IN ('2', '6') AND deleted_at IS NULL
GROUP BY coach_id
""")
@@ -282,16 +282,16 @@ public class DataStatisticsDao {
}
/**
* 按教练统计出席人次(通过团课关联,booking.status='2'
* 按教练统计出席人次(booking.status IN ('2','4','5'):已出席 + 教练缺席 + 迟到
*/
public reactor.core.publisher.Flux<java.util.Map<String, Object>> countAttendedStudentsByCoach(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("""
SELECT gc.coach_id, COUNT(*) AS count
FROM group_course_booking b
INNER JOIN group_course gc ON b.course_id = gc.id
WHERE b.status = '2' AND b.deleted_at IS NULL
WHERE b.status IN ('2', '4', '5') AND b.deleted_at IS NULL
AND gc.deleted_at IS NULL
AND gc.start_time >= :startTime AND gc.start_time < :endTime
AND gc.end_time >= :startTime AND gc.end_time < :endTime
GROUP BY gc.coach_id
""")
.bind("startTime", startTime)
@@ -301,16 +301,16 @@ public class DataStatisticsDao {
}
/**
* 按教练统计总非取消预约数(用于计算出勤率分母)
* 按教练统计总预约数(仅 status='0' 已预约,用于出勤率分母)
*/
public reactor.core.publisher.Flux<java.util.Map<String, Object>> countTotalBookingsByCoach(LocalDateTime startTime, LocalDateTime endTime) {
return databaseClient.sql("""
SELECT gc.coach_id, COUNT(*) AS count
FROM group_course_booking b
INNER JOIN group_course gc ON b.course_id = gc.id
WHERE b.status != '1' AND b.deleted_at IS NULL
WHERE b.status = '0' AND b.deleted_at IS NULL
AND gc.deleted_at IS NULL
AND gc.start_time >= :startTime AND gc.start_time < :endTime
AND gc.end_time >= :startTime AND gc.end_time < :endTime
GROUP BY gc.coach_id
""")
.bind("startTime", startTime)
@@ -326,8 +326,8 @@ public class DataStatisticsDao {
return databaseClient.sql("""
SELECT gc.coach_id, gc.max_members, COUNT(b.id) AS attended
FROM group_course gc
LEFT JOIN group_course_booking b ON gc.id = b.course_id AND b.status = '2' AND b.deleted_at IS NULL
WHERE gc.start_time >= :startTime AND gc.start_time < :endTime
LEFT JOIN group_course_booking b ON gc.id = b.course_id AND b.status IN ('2','4','5') AND b.deleted_at IS NULL
WHERE gc.end_time >= :startTime AND gc.end_time < :endTime
AND gc.status IN ('2', '6') AND gc.deleted_at IS NULL
GROUP BY gc.coach_id, gc.id, gc.max_members
""")
@@ -574,8 +574,10 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
Map<Long, Collection<FillRateItem>> fillRateMap = tuple.getT5();
Map<Long, Long> violationsMap = tuple.getT6();
// 计算最大授课量(用于归一化
long maxCourses = coursesMap.values().stream().mapToLong(Long::longValue).max().orElse(1L);
// 授课量百分位排名归一化
List<Long> sortedCourses = coursesMap.values().stream()
.sorted().collect(Collectors.toList());
int totalCoaches = sortedCourses.size();
List<CoachPerformance> performances = coaches.keySet().stream()
.map(coachId -> {
@@ -588,10 +590,18 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService {
double attendanceRate = totalBookings > 0
? (double) attended / totalBookings * 100 : 0;
double fillRate = calculateFillRate(fillRateMap.getOrDefault(coachId, List.of()));
double normalizedCourses = maxCourses > 0
? (double) courses / maxCourses * 100 : 0;
double compositeScore = normalizedCourses * 0.4
+ attendanceRate * 0.3 + fillRate * 0.3;
// 百分位排名:授课量小于当前教练的教练数 / (总教练数-1) * 100
long coachesWithFewer = sortedCourses.stream().filter(v -> v < courses).count();
double normalizedCourses = totalCoaches > 1
? (double) coachesWithFewer / (totalCoaches - 1) * 100 : 100;
// 违规扣分:每次违规扣20分,最低0分
double violationScore = Math.max(0, 100 - violations * 20);
double compositeScore = normalizedCourses * 0.35
+ attendanceRate * 0.25 + fillRate * 0.25
+ violationScore * 0.15;
return CoachPerformance.builder()
.coachId(coachId)
@@ -753,8 +753,16 @@ public class GroupCourseService implements IGroupCourseService {
}
return groupCourseRepository.findByCoachId(coachId)
.filter(course -> {
// 排除已取消的课程
if (course.getStatus() != null && course.getStatus().equals(CourseStatus.CANCELLED.getValue())) {
// 排除已软删除的课程
if (course.getDeletedAt() != null) {
return false;
}
// 仅检查以下有效状态的课程:0-正常, 3-进行中, 7-教练迟到
Long status = course.getStatus();
if (status == null ||
(!status.equals(CourseStatus.NORMAL.getValue()) &&
!status.equals(CourseStatus.IN_PROGRESS.getValue()) &&
!status.equals(CourseStatus.COACH_LATE.getValue()))) {
return false;
}
// 排除自身(编辑时)
+8 -2
View File
@@ -169,16 +169,22 @@
<version>1.0.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-coach-config</artifactId>
<version>${project.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-coach</artifactId>
<version>1.0.0</version>
<version>${project.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-brand</artifactId>
<version>1.0.0</version>
<version>${project.version}</version>
<scope>compile</scope>
</dependency>
</dependencies>
@@ -23,6 +23,7 @@ import cn.novalon.gym.manage.payment.handler.PaymentHandler;
import cn.novalon.gym.manage.brand.handler.BrandConfigHandler;
import cn.novalon.gym.manage.coach.handler.CoachCourseHandler;
import cn.novalon.gym.manage.coach.handler.CoachHandler;
import cn.novalon.gym.manage.coachconfig.handler.CoachTimeRuleHandler;
import cn.novalon.gym.manage.datacount.handler.CoachPerformanceHandler;
import cn.novalon.gym.manage.sys.handler.auth.PasswordDiagnosticHandler;
import cn.novalon.gym.manage.sys.handler.auth.SysAuthHandler;
@@ -92,6 +93,7 @@ public class SystemRouter {
BrandConfigHandler brandConfigHandler,
CoachHandler coachHandler,
CoachCourseHandler coachCourseHandler,
CoachTimeRuleHandler coachTimeRuleHandler,
CoachPerformanceHandler coachPerformanceHandler) {
return route()
@@ -137,6 +139,13 @@ public class SystemRouter {
.POST("/api/coach/courses/{courseId}/start", coachCourseHandler::startCourse)
.POST("/api/coach/courses/{courseId}/end", coachCourseHandler::endCourse)
// ========== 教练时间规则配置路由 ==========
.GET("/api/coach/time-rules", coachTimeRuleHandler::getAllRules)
.GET("/api/coach/time-rules/{id}", coachTimeRuleHandler::getRuleById)
.POST("/api/coach/time-rules", coachTimeRuleHandler::createRule)
.PUT("/api/coach/time-rules/{id}", coachTimeRuleHandler::updateRule)
.DELETE("/api/coach/time-rules/{id}", coachTimeRuleHandler::deleteRule)
// ========== 菜单路由 ==========
.GET("/api/menus", menuHandler::getAllMenus)
.GET("/api/menus/tree", menuHandler::getMenuTree)
@@ -70,4 +70,13 @@ public final class RedisKeyConstants {
* 用途:存储登录/注册等场景的短信验证码
*/
public static final String SMS_CODE = "sms:code:";
// ==================== 教练配置模块 ====================
/**
* 教练时间规则缓存
* 格式:coach:time:rules
* 用途:缓存所有启用的教练时间规则列表
*/
public static final String COACH_TIME_RULES = "coach:time:rules";
}
@@ -0,0 +1,28 @@
package cn.novalon.gym.manage.db.dao;
import cn.novalon.gym.manage.db.entity.CoachTimeRuleEntity;
import org.springframework.data.domain.Sort;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* 教练时间规则 DAO
*
* @author 张翔
* @date 2026-07-26
*/
@Repository
public interface CoachTimeRuleDao extends R2dbcRepository<CoachTimeRuleEntity, Long> {
Flux<CoachTimeRuleEntity> findByStatusAndDeletedAtIsNull(String status);
Flux<CoachTimeRuleEntity> findByStatusAndDeletedAtIsNull(String status, Sort sort);
Flux<CoachTimeRuleEntity> findByDeletedAtIsNull();
Mono<CoachTimeRuleEntity> findByIdAndDeletedAtIsNull(Long id);
Flux<CoachTimeRuleEntity> findByIsDefaultTrueAndStatusAndDeletedAtIsNull(String status);
}
@@ -0,0 +1,107 @@
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-26
*/
@Table("coach_time_rule")
public class CoachTimeRuleEntity {
@Id
private Long id;
@Column("min_duration")
private Integer minDuration;
@Column("max_duration")
private Integer maxDuration;
@Column("normal_window")
private Integer normalWindow;
@Column("late_window")
private Integer lateWindow;
@Column("end_grace")
private Integer endGrace;
@Column("is_default")
private Boolean isDefault;
@Column("sort_order")
private Integer sortOrder;
@Column("status")
private String status;
@Column("remark")
private String remark;
@Column("create_by")
private String createBy;
@Column("update_by")
private String updateBy;
@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 Integer getMinDuration() { return minDuration; }
public void setMinDuration(Integer minDuration) { this.minDuration = minDuration; }
public Integer getMaxDuration() { return maxDuration; }
public void setMaxDuration(Integer maxDuration) { this.maxDuration = maxDuration; }
public Integer getNormalWindow() { return normalWindow; }
public void setNormalWindow(Integer normalWindow) { this.normalWindow = normalWindow; }
public Integer getLateWindow() { return lateWindow; }
public void setLateWindow(Integer lateWindow) { this.lateWindow = lateWindow; }
public Integer getEndGrace() { return endGrace; }
public void setEndGrace(Integer endGrace) { this.endGrace = endGrace; }
public Boolean getIsDefault() { return isDefault; }
public void setIsDefault(Boolean isDefault) { this.isDefault = isDefault; }
public Integer getSortOrder() { return sortOrder; }
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getRemark() { return remark; }
public void setRemark(String remark) { this.remark = remark; }
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; }
public LocalDateTime getDeletedAt() { return deletedAt; }
public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; }
}
@@ -66,121 +66,121 @@ ON CONFLICT DO NOTHING;
-- ============================================
-- 4. 团课课程数据(5个类型 × 5个课程 = 25个)
-- start_time / end_time 分布在 2026-07-22 至 2026-07-28
-- start_time / end_time 分布在 2026-07-26 至 2026-08-01
-- ============================================
-- ---------- 瑜伽课程(5个)----------
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '哈他瑜伽入门', gt.id, '2026-07-22 08:00:00'::TIMESTAMP, '2026-07-22 09:00:00'::TIMESTAMP, 25, 12, '0', '瑜伽室A', '/static/course/yoga_hatha.jpg', '经典哈他瑜伽,从基础体式入手,配合呼吸引导,帮助初学者建立正确的瑜伽练习基础,感受身心的和谐统一。', 88.00, 'system'
SELECT '哈他瑜伽入门', gt.id, '2026-07-26 08:00:00'::TIMESTAMP, '2026-07-26 09:00:00'::TIMESTAMP, 25, 12, '0', '瑜伽室A', '/static/course/yoga_hatha.jpg', '经典哈他瑜伽,从基础体式入手,配合呼吸引导,帮助初学者建立正确的瑜伽练习基础,感受身心的和谐统一。', 88.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '瑜伽';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '流瑜伽', gt.id, '2026-07-24 10:00:00'::TIMESTAMP, '2026-07-24 11:00:00'::TIMESTAMP, 20, 18, '0', '瑜伽室A', '/static/course/yoga_flow.jpg', '体式之间流畅串联,如行云流水般一气呵成。以拜日式为根基,结合站姿、平衡和扭转,提升力量与柔韧的整合能力。', 98.00, 'system'
SELECT '流瑜伽', gt.id, '2026-07-28 10:00:00'::TIMESTAMP, '2026-07-28 11:00:00'::TIMESTAMP, 20, 18, '0', '瑜伽室A', '/static/course/yoga_flow.jpg', '体式之间流畅串联,如行云流水般一气呵成。以拜日式为根基,结合站姿、平衡和扭转,提升力量与柔韧的整合能力。', 98.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '瑜伽';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '阴瑜伽', gt.id, '2026-07-26 18:00:00'::TIMESTAMP, '2026-07-26 19:15:00'::TIMESTAMP, 20, 8, '0', '瑜伽室B', '/static/course/yoga_yin.jpg', '阴瑜伽以长时间保持体式为特点,深度伸展筋膜和结缔组织,帮助你释放身体深层紧张,缓解一周的工作疲劳。适合所有级别。', 88.00, 'system'
SELECT '阴瑜伽', gt.id, '2026-07-30 18:00:00'::TIMESTAMP, '2026-07-30 19:15:00'::TIMESTAMP, 20, 8, '0', '瑜伽室B', '/static/course/yoga_yin.jpg', '阴瑜伽以长时间保持体式为特点,深度伸展筋膜和结缔组织,帮助你释放身体深层紧张,缓解一周的工作疲劳。适合所有级别。', 88.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '瑜伽';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '阿斯汤加瑜伽', gt.id, '2026-07-23 07:00:00'::TIMESTAMP, '2026-07-23 08:30:00'::TIMESTAMP, 15, 10, '0', '瑜伽室A', '/static/course/yoga_ashtanga.jpg', '阿斯汤加瑜伽以固定的体式序列和独特的呼吸法为核心,节奏紧凑,强度较高,能有效提升力量、柔韧和专注力。建议有一定瑜伽基础者参加。', 128.00, 'system'
SELECT '阿斯汤加瑜伽', gt.id, '2026-07-27 07:00:00'::TIMESTAMP, '2026-07-27 08:30:00'::TIMESTAMP, 15, 10, '0', '瑜伽室A', '/static/course/yoga_ashtanga.jpg', '阿斯汤加瑜伽以固定的体式序列和独特的呼吸法为核心,节奏紧凑,强度较高,能有效提升力量、柔韧和专注力。建议有一定瑜伽基础者参加。', 128.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '瑜伽';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '空中瑜伽', gt.id, '2026-07-25 19:00:00'::TIMESTAMP, '2026-07-25 20:00:00'::TIMESTAMP, 12, 12, '0', '瑜伽室B', '/static/course/yoga_aerial.jpg', '利用悬挂的瑜伽吊床完成各种体式,借助重力让脊柱自然延展,体验"飞翔"般的自由感。深受女性学员喜爱,名额有限请提前预约。', 128.00, 'system'
SELECT '空中瑜伽', gt.id, '2026-07-29 19:00:00'::TIMESTAMP, '2026-07-29 20:00:00'::TIMESTAMP, 12, 12, '0', '瑜伽室B', '/static/course/yoga_aerial.jpg', '利用悬挂的瑜伽吊床完成各种体式,借助重力让脊柱自然延展,体验"飞翔"般的自由感。深受女性学员喜爱,名额有限请提前预约。', 128.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '瑜伽';
-- ---------- 动感单车课程(5个)----------
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '初级燃脂骑行', gt.id, '2026-07-22 19:00:00'::TIMESTAMP, '2026-07-22 19:45:00'::TIMESTAMP, 30, 22, '0', '单车房', '/static/course/spin_beginner.jpg', '适合新手的入门级骑行课程,教练将带领你掌握正确的骑行姿势和阻力调节技巧。在动感音乐中轻松燃烧300-400卡路里。', 58.00, 'system'
SELECT '初级燃脂骑行', gt.id, '2026-07-26 19:00:00'::TIMESTAMP, '2026-07-26 19:45:00'::TIMESTAMP, 30, 22, '0', '单车房', '/static/course/spin_beginner.jpg', '适合新手的入门级骑行课程,教练将带领你掌握正确的骑行姿势和阻力调节技巧。在动感音乐中轻松燃烧300-400卡路里。', 58.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '动感单车';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '节奏爬坡', gt.id, '2026-07-24 18:30:00'::TIMESTAMP, '2026-07-24 19:15:00'::TIMESTAMP, 30, 15, '0', '单车房', '/static/course/spin_climb.jpg', '模拟山路爬坡骑行,通过逐渐增加阻力来挑战你的耐力和意志力。每一段爬坡后都有短暂的平路冲刺,节奏感十足,大汗淋漓的畅快体验。', 68.00, 'system'
SELECT '节奏爬坡', gt.id, '2026-07-28 18:30:00'::TIMESTAMP, '2026-07-28 19:15:00'::TIMESTAMP, 30, 15, '0', '单车房', '/static/course/spin_climb.jpg', '模拟山路爬坡骑行,通过逐渐增加阻力来挑战你的耐力和意志力。每一段爬坡后都有短暂的平路冲刺,节奏感十足,大汗淋漓的畅快体验。', 68.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '动感单车';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '极速冲刺', gt.id, '2026-07-26 20:00:00'::TIMESTAMP, '2026-07-26 20:45:00'::TIMESTAMP, 25, 25, '0', '单车房', '/static/course/spin_sprint.jpg', '高强度冲刺为主题的骑行课,多组30秒极限冲刺搭配短暂恢复,彻底引爆你的心肺极限。周五夜间的爆款课程,约满速度极快!', 68.00, 'system'
SELECT '极速冲刺', gt.id, '2026-07-30 20:00:00'::TIMESTAMP, '2026-07-30 20:45:00'::TIMESTAMP, 25, 25, '0', '单车房', '/static/course/spin_sprint.jpg', '高强度冲刺为主题的骑行课,多组30秒极限冲刺搭配短暂恢复,彻底引爆你的心肺极限。周五夜间的爆款课程,约满速度极快!', 68.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '动感单车';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '耐力骑行', gt.id, '2026-07-27 10:00:00'::TIMESTAMP, '2026-07-27 11:00:00'::TIMESTAMP, 30, 18, '0', '单车房', '/static/course/spin_endurance.jpg', '60分钟中低强度耐力骑行,适合周末早晨唤醒身体。在稳定的节奏中持续燃脂,配以舒缓的收尾拉伸,开启元气满满的周末。', 58.00, 'system'
SELECT '耐力骑行', gt.id, '2026-07-31 10:00:00'::TIMESTAMP, '2026-07-31 11:00:00'::TIMESTAMP, 30, 18, '0', '单车房', '/static/course/spin_endurance.jpg', '60分钟中低强度耐力骑行,适合周末早晨唤醒身体。在稳定的节奏中持续燃脂,配以舒缓的收尾拉伸,开启元气满满的周末。', 58.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '动感单车';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '音乐主题骑行', gt.id, '2026-07-23 19:30:00'::TIMESTAMP, '2026-07-23 20:15:00'::TIMESTAMP, 30, 20, '0', '单车房', '/static/course/spin_music.jpg', '以热门流行音乐为主题的骑行派对!每首歌曲对应一套骑行组合,跟着节拍加速、爬坡、冲刺,在音乐中忘记疲惫。', 68.00, 'system'
SELECT '音乐主题骑行', gt.id, '2026-07-27 19:30:00'::TIMESTAMP, '2026-07-27 20:15:00'::TIMESTAMP, 30, 20, '0', '单车房', '/static/course/spin_music.jpg', '以热门流行音乐为主题的骑行派对!每首歌曲对应一套骑行组合,跟着节拍加速、爬坡、冲刺,在音乐中忘记疲惫。', 68.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '动感单车';
-- ---------- HIIT训练课程(5个)----------
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT 'Tabata燃脂', gt.id, '2026-07-22 07:00:00'::TIMESTAMP, '2026-07-22 07:30:00'::TIMESTAMP, 15, 8, '0', '多功能训练区', '/static/course/hiit_tabata.jpg', '经典的Tabata模式:20秒全力运动 + 10秒休息,循环8轮共4分钟,搭配热身和拉伸,30分钟全身燃爆。早晨空腹训练燃脂效果加倍!', 98.00, 'system'
SELECT 'Tabata燃脂', gt.id, '2026-07-26 07:00:00'::TIMESTAMP, '2026-07-26 07:30:00'::TIMESTAMP, 15, 8, '0', '多功能训练区', '/static/course/hiit_tabata.jpg', '经典的Tabata模式:20秒全力运动 + 10秒休息,循环8轮共4分钟,搭配热身和拉伸,30分钟全身燃爆。早晨空腹训练燃脂效果加倍!', 98.00, 'system'
FROM group_course_type gt WHERE gt.type_name = 'HIIT训练';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '全身循环训练', gt.id, '2026-07-24 12:00:00'::TIMESTAMP, '2026-07-24 12:45:00'::TIMESTAMP, 15, 12, '0', '多功能训练区', '/static/course/hiit_circuit.jpg', '午间燃脂利器!8个动作站点轮流进行,涵盖深蹲跳、波比跳、壶铃摇摆、登山跑等,每个动作45秒,休息15秒,3轮循环让你全身湿透。', 98.00, 'system'
SELECT '全身循环训练', gt.id, '2026-07-28 12:00:00'::TIMESTAMP, '2026-07-28 12:45:00'::TIMESTAMP, 15, 12, '0', '多功能训练区', '/static/course/hiit_circuit.jpg', '午间燃脂利器!8个动作站点轮流进行,涵盖深蹲跳、波比跳、壶铃摇摆、登山跑等,每个动作45秒,休息15秒,3轮循环让你全身湿透。', 98.00, 'system'
FROM group_course_type gt WHERE gt.type_name = 'HIIT训练';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '核心爆发力', gt.id, '2026-07-26 17:30:00'::TIMESTAMP, '2026-07-26 18:15:00'::TIMESTAMP, 12, 5, '0', '多功能训练区', '/static/course/hiit_core.jpg', '专注于核心肌群的HIIT训练,结合药球砸地、悬垂举腿、俄罗斯转体和平板支撑变式,打造钢铁般的核心力量,提升所有运动表现。', 108.00, 'system'
SELECT '核心爆发力', gt.id, '2026-07-30 17:30:00'::TIMESTAMP, '2026-07-30 18:15:00'::TIMESTAMP, 12, 5, '0', '多功能训练区', '/static/course/hiit_core.jpg', '专注于核心肌群的HIIT训练,结合药球砸地、悬垂举腿、俄罗斯转体和平板支撑变式,打造钢铁般的核心力量,提升所有运动表现。', 108.00, 'system'
FROM group_course_type gt WHERE gt.type_name = 'HIIT训练';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '战绳挑战', gt.id, '2026-07-27 08:00:00'::TIMESTAMP, '2026-07-27 08:45:00'::TIMESTAMP, 10, 10, '0', '户外训练区', '/static/course/hiit_battle.jpg', '风靡全球的战绳训练!通过波浪、猛击、旋转等动作模式激活全身肌群,30秒一组高强度间歇,燃脂同时雕刻上肢线条。名额有限,已约满!', 128.00, 'system'
SELECT '战绳挑战', gt.id, '2026-07-31 08:00:00'::TIMESTAMP, '2026-07-31 08:45:00'::TIMESTAMP, 10, 10, '0', '户外训练区', '/static/course/hiit_battle.jpg', '风靡全球的战绳训练!通过波浪、猛击、旋转等动作模式激活全身肌群,30秒一组高强度间歇,燃脂同时雕刻上肢线条。名额有限,已约满!', 128.00, 'system'
FROM group_course_type gt WHERE gt.type_name = 'HIIT训练';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '药球HIIT', gt.id, '2026-07-28 10:00:00'::TIMESTAMP, '2026-07-28 10:45:00'::TIMESTAMP, 12, 6, '0', '多功能训练区', '/static/course/hiit_medball.jpg', '以药球为主要工具的HIIT训练。药球砸地、旋转投掷、过头抛接等动作兼具力量和爆发力训练,动作多样不枯燥,周日上午的爆汗之选。', 108.00, 'system'
SELECT '药球HIIT', gt.id, '2026-08-01 10:00:00'::TIMESTAMP, '2026-08-01 10:45:00'::TIMESTAMP, 12, 6, '0', '多功能训练区', '/static/course/hiit_medball.jpg', '以药球为主要工具的HIIT训练。药球砸地、旋转投掷、过头抛接等动作兼具力量和爆发力训练,动作多样不枯燥,周日上午的爆汗之选。', 108.00, 'system'
FROM group_course_type gt WHERE gt.type_name = 'HIIT训练';
-- ---------- 普拉提课程(5个)----------
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '垫上普拉提', gt.id, '2026-07-23 10:00:00'::TIMESTAMP, '2026-07-23 11:00:00'::TIMESTAMP, 20, 14, '0', '普拉提室', '/static/course/pilates_mat.jpg', '经典垫上普拉提,通过精准的动作控制激活深层核心肌群。从呼吸到骨盆稳定,从脊柱逐节卷动到四肢协调,一节课改善你的身体感知。', 108.00, 'system'
SELECT '垫上普拉提', gt.id, '2026-07-27 10:00:00'::TIMESTAMP, '2026-07-27 11:00:00'::TIMESTAMP, 20, 14, '0', '普拉提室', '/static/course/pilates_mat.jpg', '经典垫上普拉提,通过精准的动作控制激活深层核心肌群。从呼吸到骨盆稳定,从脊柱逐节卷动到四肢协调,一节课改善你的身体感知。', 108.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '普拉提';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '器械普拉提', gt.id, '2026-07-25 09:00:00'::TIMESTAMP, '2026-07-25 10:00:00'::TIMESTAMP, 10, 8, '0', '普拉提室', '/static/course/pilates_reformer.jpg', '使用专业普拉提核心床(Reformer)进行训练,弹簧阻力提供精准的负荷控制,适合需要针对性改善体态、康复训练或追求高效塑形的学员。', 158.00, 'system'
SELECT '器械普拉提', gt.id, '2026-07-29 09:00:00'::TIMESTAMP, '2026-07-29 10:00:00'::TIMESTAMP, 10, 8, '0', '普拉提室', '/static/course/pilates_reformer.jpg', '使用专业普拉提核心床(Reformer)进行训练,弹簧阻力提供精准的负荷控制,适合需要针对性改善体态、康复训练或追求高效塑形的学员。', 158.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '普拉提';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '核心塑形', gt.id, '2026-07-27 14:00:00'::TIMESTAMP, '2026-07-27 15:00:00'::TIMESTAMP, 15, 9, '0', '普拉提室', '/static/course/pilates_sculpt.jpg', '专注于腹部、腰部和臀部的普拉提塑形课程。百次拍击、剪刀腿、肩桥等经典动作持续刺激目标肌群,打造紧致核心线条。', 128.00, 'system'
SELECT '核心塑形', gt.id, '2026-07-31 14:00:00'::TIMESTAMP, '2026-07-31 15:00:00'::TIMESTAMP, 15, 9, '0', '普拉提室', '/static/course/pilates_sculpt.jpg', '专注于腹部、腰部和臀部的普拉提塑形课程。百次拍击、剪刀腿、肩桥等经典动作持续刺激目标肌群,打造紧致核心线条。', 128.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '普拉提';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '脊柱健康', gt.id, '2026-07-22 16:00:00'::TIMESTAMP, '2026-07-22 17:00:00'::TIMESTAMP, 15, 6, '0', '普拉提室', '/static/course/pilates_spine.jpg', '专为久坐办公人群设计,通过普拉提脊柱逐节运动改善驼背、圆肩等不良体态。融合猫牛式、脊柱旋转和游泳式,给你的脊柱一次深度保养。', 108.00, 'system'
SELECT '脊柱健康', gt.id, '2026-07-26 16:00:00'::TIMESTAMP, '2026-07-26 17:00:00'::TIMESTAMP, 15, 6, '0', '普拉提室', '/static/course/pilates_spine.jpg', '专为久坐办公人群设计,通过普拉提脊柱逐节运动改善驼背、圆肩等不良体态。融合猫牛式、脊柱旋转和游泳式,给你的脊柱一次深度保养。', 108.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '普拉提';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '产后恢复普拉提', gt.id, '2026-07-24 11:00:00'::TIMESTAMP, '2026-07-24 12:00:00'::TIMESTAMP, 10, 4, '0', '普拉提室', '/static/course/pilates_postnatal.jpg', '专为产后妈妈设计的温和修复课程,重点激活盆底肌和腹横肌,循序渐进恢复核心力量。小班教学,教练一对一关注每位学员的姿势质量。', 158.00, 'system'
SELECT '产后恢复普拉提', gt.id, '2026-07-28 11:00:00'::TIMESTAMP, '2026-07-28 12:00:00'::TIMESTAMP, 10, 4, '0', '普拉提室', '/static/course/pilates_postnatal.jpg', '专为产后妈妈设计的温和修复课程,重点激活盆底肌和腹横肌,循序渐进恢复核心力量。小班教学,教练一对一关注每位学员的姿势质量。', 158.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '普拉提';
-- ---------- 搏击操课程(5个)----------
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '有氧搏击基础', gt.id, '2026-07-23 18:00:00'::TIMESTAMP, '2026-07-23 19:00:00'::TIMESTAMP, 25, 20, '0', '操厅A', '/static/course/boxing_basic.jpg', '零基础友好的搏击操入门课。从直拳、摆拳、勾拳到前踢、侧踹,教练分步讲解每个动作的要领,配合动感音乐串联成完整的搏击组合。', 68.00, 'system'
SELECT '有氧搏击基础', gt.id, '2026-07-27 18:00:00'::TIMESTAMP, '2026-07-27 19:00:00'::TIMESTAMP, 25, 20, '0', '操厅A', '/static/course/boxing_basic.jpg', '零基础友好的搏击操入门课。从直拳、摆拳、勾拳到前踢、侧踹,教练分步讲解每个动作的要领,配合动感音乐串联成完整的搏击组合。', 68.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '搏击操';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT 'Kickboxing进阶', gt.id, '2026-07-25 19:00:00'::TIMESTAMP, '2026-07-25 20:00:00'::TIMESTAMP, 20, 15, '0', '操厅A', '/static/course/boxing_kickboxing.jpg', '融合踢拳技术的进阶搏击课程,增加连击组合、闪躲步法和膝肘技术。强度较基础课明显提升,汗如雨下的同时释放工作压力,宣泄效果一流。', 88.00, 'system'
SELECT 'Kickboxing进阶', gt.id, '2026-07-29 19:00:00'::TIMESTAMP, '2026-07-29 20:00:00'::TIMESTAMP, 20, 15, '0', '操厅A', '/static/course/boxing_kickboxing.jpg', '融合踢拳技术的进阶搏击课程,增加连击组合、闪躲步法和膝肘技术。强度较基础课明显提升,汗如雨下的同时释放工作压力,宣泄效果一流。', 88.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '搏击操';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '综合格斗体能', gt.id, '2026-07-27 16:00:00'::TIMESTAMP, '2026-07-27 17:00:00'::TIMESTAMP, 20, 12, '0', '操厅A', '/static/course/boxing_mma.jpg', '融合拳击、泰拳、摔跤元素的综合体能训练。沙袋击打、地面对抗和爆发力训练交替进行,全面提升力量、速度和耐力。周六下午的硬核之选!', 98.00, 'system'
SELECT '综合格斗体能', gt.id, '2026-07-31 16:00:00'::TIMESTAMP, '2026-07-31 17:00:00'::TIMESTAMP, 20, 12, '0', '操厅A', '/static/course/boxing_mma.jpg', '融合拳击、泰拳、摔跤元素的综合体能训练。沙袋击打、地面对抗和爆发力训练交替进行,全面提升力量、速度和耐力。周六下午的硬核之选!', 98.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '搏击操';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '燃脂拳击', gt.id, '2026-07-22 12:00:00'::TIMESTAMP, '2026-07-22 13:00:00'::TIMESTAMP, 25, 16, '0', '操厅A', '/static/course/boxing_fatburn.jpg', '午间燃脂拳击课。空击组合与沙袋击打间歇进行,心率维持在高燃脂区间。一节课可消耗500-700卡路里,中午来打拳比喝咖啡更提神!', 68.00, 'system'
SELECT '燃脂拳击', gt.id, '2026-07-26 12:00:00'::TIMESTAMP, '2026-07-26 13:00:00'::TIMESTAMP, 25, 16, '0', '操厅A', '/static/course/boxing_fatburn.jpg', '午间燃脂拳击课。空击组合与沙袋击打间歇进行,心率维持在高燃脂区间。一节课可消耗500-700卡路里,中午来打拳比喝咖啡更提神!', 68.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '搏击操';
INSERT INTO group_course (course_name, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by)
SELECT '泰拳基础', gt.id, '2026-07-28 15:00:00'::TIMESTAMP, '2026-07-28 16:00:00'::TIMESTAMP, 15, 7, '0', '操厅A', '/static/course/boxing_muaythai.jpg', '学习泰拳的八肢艺术(双拳、双腿、双膝、双肘),从基本站架到肘膝组合,感受泰拳的刚猛魅力。小班教学保证动作纠正质量。', 98.00, 'system'
SELECT '泰拳基础', gt.id, '2026-08-01 15:00:00'::TIMESTAMP, '2026-08-01 16:00:00'::TIMESTAMP, 15, 7, '0', '操厅A', '/static/course/boxing_muaythai.jpg', '学习泰拳的八肢艺术(双拳、双腿、双膝、双肘),从基本站架到肘膝组合,感受泰拳的刚猛魅力。小班教学保证动作纠正质量。', 98.00, 'system'
FROM group_course_type gt WHERE gt.type_name = '搏击操';
@@ -0,0 +1,50 @@
-- ============================================
-- 教练时间规则配置表
-- 版本: V29
-- 描述: 创建教练迟到/缺席时间规则配置表,支持按课程时长区间匹配不同阈值
-- ============================================
CREATE TABLE IF NOT EXISTS coach_time_rule (
id BIGSERIAL PRIMARY KEY,
min_duration INTEGER,
max_duration INTEGER,
normal_window INTEGER NOT NULL,
late_window INTEGER NOT NULL,
end_grace INTEGER NOT NULL,
is_default BOOLEAN DEFAULT FALSE,
sort_order INTEGER DEFAULT 0,
status CHAR(1) DEFAULT '1',
remark VARCHAR(500),
create_by VARCHAR(50),
update_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
COMMENT ON TABLE coach_time_rule IS '教练时间规则配置表';
COMMENT ON COLUMN coach_time_rule.id IS '主键ID';
COMMENT ON COLUMN coach_time_rule.min_duration IS '课程时长下限(分钟),NULL表示无下限';
COMMENT ON COLUMN coach_time_rule.max_duration IS '课程时长上限(分钟),NULL表示无上限';
COMMENT ON COLUMN coach_time_rule.normal_window IS '正常开课窗口(分钟),课程开始后此时间内开课为正常';
COMMENT ON COLUMN coach_time_rule.late_window IS '迟到/缺席截止窗口(分钟),超过此时间判定为迟到或拒绝开课';
COMMENT ON COLUMN coach_time_rule.end_grace IS '结课宽限期(分钟),课程结束后此时间内允许手动结课';
COMMENT ON COLUMN coach_time_rule.is_default IS '是否默认规则,无匹配规则时使用';
COMMENT ON COLUMN coach_time_rule.sort_order IS '排序优先级';
COMMENT ON COLUMN coach_time_rule.status IS '状态(0=停用 1=启用)';
COMMENT ON COLUMN coach_time_rule.remark IS '备注';
COMMENT ON COLUMN coach_time_rule.create_by IS '创建人';
COMMENT ON COLUMN coach_time_rule.update_by IS '更新人';
COMMENT ON COLUMN coach_time_rule.created_at IS '创建时间';
COMMENT ON COLUMN coach_time_rule.updated_at IS '更新时间';
COMMENT ON COLUMN coach_time_rule.deleted_at IS '删除时间(软删除)';
-- 插入默认规则:等同于原硬编码逻辑(长课时:10分钟正常/30分钟迟到/10分钟结课宽限)
INSERT INTO coach_time_rule (min_duration, max_duration, normal_window, late_window, end_grace, is_default, sort_order, status, create_by, update_by, created_at, updated_at)
VALUES (NULL, NULL, 10, 30, 10, TRUE, 0, '1', 'system', 'system', NOW(), NOW());
-- 索引
CREATE INDEX IF NOT EXISTS idx_coach_time_rule_status ON coach_time_rule(status);
CREATE INDEX IF NOT EXISTS idx_coach_time_rule_is_default ON coach_time_rule(is_default);
CREATE INDEX IF NOT EXISTS idx_coach_time_rule_deleted_at ON coach_time_rule(deleted_at);
CREATE INDEX IF NOT EXISTS idx_coach_time_rule_duration_range ON coach_time_rule(min_duration, max_duration);
@@ -0,0 +1,40 @@
-- ============================================
-- V30: 教练时间规则配置 - 菜单/权限/角色分配
-- ============================================
-- ============================================
-- 1. 时间规则配置权限
-- ============================================
INSERT INTO sys_permission (permission_name, permission_code, resource, action, description, status, create_by, update_by, created_at, updated_at) VALUES
('时间规则查看', 'system:coach:timeRule:view', '/api/coach/time-rules', 'GET', '查看教练时间规则列表', 1, 'system', 'system', NOW(), NOW()),
('时间规则创建', 'system:coach:timeRule:create', '/api/coach/time-rules', 'POST', '创建教练时间规则', 1, 'system', 'system', NOW(), NOW()),
('时间规则编辑', 'system:coach:timeRule:edit', '/api/coach/time-rules', 'PUT', '编辑教练时间规则', 1, 'system', 'system', NOW(), NOW()),
('时间规则删除', 'system:coach:timeRule:delete', '/api/coach/time-rules', 'DELETE', '删除教练时间规则', 1, 'system', 'system', NOW(), NOW());
-- ============================================
-- 2. 为超级管理员角色分配时间规则权限
-- ============================================
INSERT INTO sys_role_permission (role_id, permission_id, create_by, update_by, created_at, updated_at)
SELECT 1, id, 'system', 'system', NOW(), NOW() FROM sys_permission
WHERE permission_code LIKE 'system:coach:timeRule:%'
AND id NOT IN (SELECT permission_id FROM sys_role_permission WHERE role_id = 1);
-- ============================================
-- 3. 时间规则配置菜单(顶级菜单)
-- ============================================
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
(25, '时间规则配置', 0, 10, 'C', 'system:coach:timeRule:list', 'coach/time-rule/index', 1, NOW(), NOW());
-- 时间规则配置按钮权限
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
(251, '时间规则查询', 25, 1, 'F', 'system:coach:timeRule:query', NULL, 1, NOW(), NOW()),
(252, '时间规则新增', 25, 2, 'F', 'system:coach:timeRule:add', NULL, 1, NOW(), NOW()),
(253, '时间规则修改', 25, 3, 'F', 'system:coach:timeRule:edit', NULL, 1, NOW(), NOW()),
(254, '时间规则删除', 25, 4, 'F', 'system:coach:timeRule:remove', NULL, 1, NOW(), NOW());
-- ============================================
-- 4. 重置序列
-- ============================================
SELECT setval('sys_menu_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_menu));
SELECT setval('sys_permission_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_permission));
SELECT setval('sys_role_permission_id_seq',(SELECT COALESCE(MAX(id), 1) FROM sys_role_permission));
+1
View File
@@ -49,6 +49,7 @@
<module>gym-auth</module>
<module>gym-payment</module>
<module>gym-coach</module>
<module>gym-coach-config</module>
<module>gym-brand</module>
</modules>
+7 -7
View File
@@ -118,22 +118,22 @@ WHERE NOT EXISTS (SELECT 1 FROM course_type_label WHERE type_id = 103 AND label_
-- 晨间瑜伽 | A教室 | 明天 08:00-09:00 | 教练100
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, update_by, created_at, updated_at)
SELECT 100, '晨间瑜伽', 100, 100, '2026-07-23 08:00:00'::TIMESTAMP, '2026-07-23 09:00:00'::TIMESTAMP, 20, 5, '0', 'A教室', '/static/course/e2e_yoga.jpg', 'E2E测试-晨间瑜伽课程,温和唤醒身体', 88.00, 'system', 'system', NOW(), NOW()
SELECT 100, '晨间瑜伽', 100, 100, '2026-07-27 08:00:00'::TIMESTAMP, '2026-07-27 09:00:00'::TIMESTAMP, 20, 5, '0', 'A教室', '/static/course/e2e_yoga.jpg', 'E2E测试-晨间瑜伽课程,温和唤醒身体', 88.00, 'system', 'system', NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM group_course WHERE id = 100);
-- 动感燃脂 | 单车房 | 后天 19:00-20:00 | 教练101
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, update_by, created_at, updated_at)
SELECT 101, '动感燃脂', 101, 101, '2026-07-24 19:00:00'::TIMESTAMP, '2026-07-24 20:00:00'::TIMESTAMP, 25, 10, '0', '单车房', '/static/course/e2e_spin.jpg', 'E2E测试-动感单车燃脂课程', 68.00, 'system', 'system', NOW(), NOW()
SELECT 101, '动感燃脂', 101, 101, '2026-07-28 19:00:00'::TIMESTAMP, '2026-07-28 20:00:00'::TIMESTAMP, 25, 10, '0', '单车房', '/static/course/e2e_spin.jpg', 'E2E测试-动感单车燃脂课程', 68.00, 'system', 'system', NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM group_course WHERE id = 101);
-- 有氧搏击 | B教室 | 大后天 10:00-11:00 | 教练100
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, update_by, created_at, updated_at)
SELECT 102, '有氧搏击', 100, 102, '2026-07-25 10:00:00'::TIMESTAMP, '2026-07-25 11:00:00'::TIMESTAMP, 20, 8, '0', 'B教室', '/static/course/e2e_boxing.jpg', 'E2E测试-有氧搏击操课程', 68.00, 'system', 'system', NOW(), NOW()
SELECT 102, '有氧搏击', 100, 102, '2026-07-29 10:00:00'::TIMESTAMP, '2026-07-29 11:00:00'::TIMESTAMP, 20, 8, '0', 'B教室', '/static/course/e2e_boxing.jpg', 'E2E测试-有氧搏击操课程', 68.00, 'system', 'system', NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM group_course WHERE id = 102);
-- 普拉提入门 | 瑜伽房 | 4天后 14:00-15:00 | 教练101
INSERT INTO group_course (id, course_name, coach_id, course_type, start_time, end_time, max_members, current_members, status, location, cover_image, description, stored_value_amount, create_by, update_by, created_at, updated_at)
SELECT 103, '普拉提入门', 101, 103, '2026-07-26 14:00:00'::TIMESTAMP, '2026-07-26 15:00:00'::TIMESTAMP, 15, 3, '0', '瑜伽房', '/static/course/e2e_pilates.jpg', 'E2E测试-普拉提入门课程,适合初学者', 108.00, 'system', 'system', NOW(), NOW()
SELECT 103, '普拉提入门', 101, 103, '2026-07-30 14:00:00'::TIMESTAMP, '2026-07-30 15:00:00'::TIMESTAMP, 15, 3, '0', '瑜伽房', '/static/course/e2e_pilates.jpg', 'E2E测试-普拉提入门课程,适合初学者', 108.00, 'system', 'system', NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM group_course WHERE id = 103);
-- ============================================
@@ -158,9 +158,9 @@ ON CONFLICT (member_no) DO UPDATE SET
INSERT INTO sign_in_record (id, member_id, sign_in_time, sign_in_type, sign_in_status, source, created_at, updated_at)
VALUES
(100, 100, '2026-07-22 08:30:00'::TIMESTAMP, 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', NOW(), NOW()),
(101, 101, '2026-07-22 09:00:00'::TIMESTAMP, 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', NOW(), NOW()),
(102, 100, '2026-07-22 18:00:00'::TIMESTAMP, 'MANUAL', 'SUCCESS', 'PC_BACKEND', NOW(), NOW())
(100, 100, '2026-07-26 08:30:00'::TIMESTAMP, 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', NOW(), NOW()),
(101, 101, '2026-07-26 09:00:00'::TIMESTAMP, 'QR_CODE', 'SUCCESS', 'MINI_PROGRAM', NOW(), NOW()),
(102, 100, '2026-07-26 18:00:00'::TIMESTAMP, 'MANUAL', 'SUCCESS', 'PC_BACKEND', NOW(), NOW())
ON CONFLICT (id) DO NOTHING;
-- ============================================
+37
View File
@@ -42,6 +42,21 @@ export interface ViolationRecord {
created_at: string
}
export interface CoachTimeRule {
id?: number
minDuration?: number | null
maxDuration?: number | null
normalWindow: number
lateWindow: number
endGrace: number
isDefault?: boolean
sortOrder?: number
status?: string
remark?: string
createdAt?: string
updatedAt?: string
}
export const coachApi = {
/** 获取所有教练列表 */
getAll: () =>
@@ -71,3 +86,25 @@ export const coachApi = {
getViolations: (id: string) =>
request.get<ViolationRecord[]>(`/coach/${id}/violations`),
}
export const coachTimeRuleApi = {
/** 获取所有时间规则 */
getAll: () =>
request.get<CoachTimeRule[]>('/coach/time-rules'),
/** 获取单条规则 */
getById: (id: number) =>
request.get<CoachTimeRule>(`/coach/time-rules/${id}`),
/** 创建规则 */
create: (data: CoachTimeRule) =>
request.post<{ message: string; id: number }>('/coach/time-rules', data),
/** 更新规则 */
update: (id: number, data: CoachTimeRule) =>
request.put<{ message: string }>(`/coach/time-rules/${id}`, data),
/** 删除规则 */
delete: (id: number) =>
request.delete<{ message: string }>(`/coach/time-rules/${id}`),
}
+6
View File
@@ -46,6 +46,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/system/CoachManagement.vue'),
meta: { title: '教练管理' }
},
{
path: 'coach/time-rules',
name: 'CoachTimeRuleManagement',
component: () => import('@/views/coach/CoachTimeRuleManagement.vue'),
meta: { title: '时间规则配置' }
},
{
path: 'roles',
name: 'RoleManagement',
+2
View File
@@ -48,6 +48,7 @@ function transformMenuData(backendMenus: BackendMenuItem[]): MenuItem[] {
'statistics/dashboard/index': '/statistics',
'banner/index': '/banners',
'brand/index': '/brand',
'coach/time-rule/index': '/coach/time-rules',
}
const filteredMenus = backendMenus.filter(menu => menu.menuType !== 'F')
@@ -113,6 +114,7 @@ function getMenuIcon(menuName: string): string {
'数据统计': 'DataAnalysis',
'轮播图管理': 'Picture',
'品牌定制': 'Brush',
'时间规则配置': 'Timer',
}
return iconMap[menuName] || 'Document'
}
+4
View File
@@ -36,6 +36,10 @@ const permissionMapping: PermissionMapping = {
'POST /brand': 'brand:config:list',
'PUT /brand': 'brand:config:list',
'DELETE /brand': 'brand:config:list',
'GET /coach/time-rules': 'system:coach:timeRule:list',
'POST /coach/time-rules': 'system:coach:timeRule:add',
'PUT /coach/time-rules': 'system:coach:timeRule:edit',
'DELETE /coach/time-rules': 'system:coach:timeRule:remove',
}
export function checkApiPermission(method: string, url: string): boolean {
@@ -0,0 +1,366 @@
<template>
<div class="time-rule-management">
<el-card>
<template #header>
<div class="card-title">
<span>教练时间规则配置</span>
<el-button type="primary" @click="handleAdd">
新增规则
</el-button>
</div>
</template>
<el-alert
type="info"
:closable="false"
style="margin-bottom: 16px"
>
<template #title>
规则匹配逻辑系统根据课程时长分钟自动匹配区间最精确的规则若课程时长在 [时长下限, 时长上限] 范围内则应用该规则的阈值默认规则兜底规则不能设置时长区间且全局仅允许一个默认规则设为默认时将自动取消其他默认规则未匹配到任何规则时使用默认规则或系统硬编码兜底值
</template>
</el-alert>
<el-table
v-loading="loading"
:data="dataSource"
style="width: 100%"
empty-text="暂无规则配置将使用系统默认值"
>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column label="时长下限(分)" width="110">
<template #default="{ row }">
{{ row.minDuration ?? '无限制' }}
</template>
</el-table-column>
<el-table-column label="时长上限(分)" width="110">
<template #default="{ row }">
{{ row.maxDuration ?? '无限制' }}
</template>
</el-table-column>
<el-table-column label="正常开课窗口(分)" width="140">
<template #default="{ row }">
<el-tag type="success" effect="plain">{{ row.normalWindow }}</el-tag>
</template>
</el-table-column>
<el-table-column label="迟到/缺席窗口(分)" width="150">
<template #default="{ row }">
<el-tag type="warning" effect="plain">{{ row.lateWindow }}</el-tag>
</template>
</el-table-column>
<el-table-column label="结课宽限期(分)" width="120">
<template #default="{ row }">
<el-tag type="info" effect="plain">{{ row.endGrace }}</el-tag>
</template>
</el-table-column>
<el-table-column label="默认" width="70">
<template #default="{ row }">
<el-tag v-if="row.isDefault" type="primary" size="small">默认</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="70">
<template #default="{ row }">
<el-tag
:type="row.status === '1' ? 'success' : 'danger'"
effect="dark"
>
{{ row.status === '1' ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
v-model="modalVisible"
:title="modalTitle"
width="550px"
@closed="resetForm"
>
<el-form
ref="formRef"
:model="formState"
:rules="rules"
label-width="140px"
>
<el-form-item label="时长下限(分钟)" prop="minDuration">
<el-input-number
v-model="formState.minDuration"
:min="1"
:max="1440"
:disabled="formState.isDefault"
placeholder="留空表示无下限"
style="width: 100%"
controls-position="right"
/>
<div class="form-tip">{{ formState.isDefault ? '默认规则为兜底规则,不限制时长区间' : '课程时长 >= 此值时才应用本规则' }}</div>
</el-form-item>
<el-form-item label="时长上限(分钟)" prop="maxDuration">
<el-input-number
v-model="formState.maxDuration"
:min="1"
:max="1440"
:disabled="formState.isDefault"
placeholder="留空表示无上限"
style="width: 100%"
controls-position="right"
/>
<div class="form-tip">{{ formState.isDefault ? '默认规则为兜底规则,不限制时长区间' : '课程时长 <= 此值时才应用本规则' }}</div>
</el-form-item>
<el-form-item label="正常开课窗口(分钟)" prop="normalWindow">
<el-input-number
v-model="formState.normalWindow"
:min="1"
:max="1440"
style="width: 100%"
controls-position="right"
/>
<div class="form-tip">开课时间后此分钟内开课视为正常</div>
</el-form-item>
<el-form-item label="迟到/缺席窗口(分钟)" prop="lateWindow">
<el-input-number
v-model="formState.lateWindow"
:min="1"
:max="1440"
style="width: 100%"
controls-position="right"
/>
<div class="form-tip">超过正常窗口但在此时间内视为迟到超过此值视为缺席</div>
</el-form-item>
<el-form-item label="结课宽限期(分钟)" prop="endGrace">
<el-input-number
v-model="formState.endGrace"
:min="0"
:max="1440"
style="width: 100%"
controls-position="right"
/>
<div class="form-tip">课程结束后此分钟内允许手动结课超时自动结课</div>
</el-form-item>
<el-form-item label="设为默认规则">
<el-switch
v-model="formState.isDefault"
active-text=""
inactive-text=""
@change="onDefaultChange"
/>
<div class="form-tip form-tip-warn">
设为此规则的默认规则前旧默认规则会自动取消默认规则不能设置时长区间
</div>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="formState.status">
<el-radio value="1">启用</el-radio>
<el-radio value="0">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="formState.remark"
type="textarea"
:rows="2"
placeholder="规则说明(选填)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="modalVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleModalOk">
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { coachTimeRuleApi, type CoachTimeRule } from '@/api/coach.api'
const loading = ref(false)
const submitting = ref(false)
const dataSource = ref<CoachTimeRule[]>([])
const modalVisible = ref(false)
const modalTitle = ref('')
const formRef = ref<FormInstance>()
const defaultForm: CoachTimeRule = {
minDuration: undefined,
maxDuration: undefined,
normalWindow: 10,
lateWindow: 30,
endGrace: 10,
isDefault: false,
status: '1',
remark: '',
}
const formState = reactive<CoachTimeRule>({ ...defaultForm })
const validateWindowOrder = (_rule: any, _value: any, callback: any) => {
if (formState.normalWindow != null && formState.lateWindow != null) {
if (formState.lateWindow < formState.normalWindow) {
callback(new Error('迟到窗口必须 >= 正常开课窗口'))
return
}
}
callback()
}
const validateDurationRange = (_rule: any, _value: any, callback: any) => {
if (formState.minDuration != null && formState.maxDuration != null) {
if (formState.maxDuration < formState.minDuration) {
callback(new Error('时长上限必须 >= 时长下限'))
return
}
}
callback()
}
const rules: FormRules = {
normalWindow: [
{ required: true, message: '请输入正常开课窗口', trigger: 'blur' },
{ validator: validateWindowOrder, trigger: 'change' },
],
lateWindow: [
{ required: true, message: '请输入迟到/缺席窗口', trigger: 'blur' },
{ validator: validateWindowOrder, trigger: 'change' },
],
endGrace: [
{ required: true, message: '请输入结课宽限期', trigger: 'blur' },
],
minDuration: [
{ validator: validateDurationRange, trigger: 'change' },
],
maxDuration: [
{ validator: validateDurationRange, trigger: 'change' },
],
}
const resetForm = () => {
Object.assign(formState, { ...defaultForm, id: undefined })
formRef.value?.resetFields()
}
const fetchData = async () => {
loading.value = true
try {
dataSource.value = await coachTimeRuleApi.getAll()
} catch {
ElMessage.error('获取规则列表失败')
} finally {
loading.value = false
}
}
const handleAdd = () => {
modalTitle.value = '新增规则'
resetForm()
modalVisible.value = true
}
const handleEdit = (row: CoachTimeRule) => {
modalTitle.value = '编辑规则'
Object.assign(formState, {
id: row.id,
minDuration: row.minDuration ?? undefined,
maxDuration: row.maxDuration ?? undefined,
normalWindow: row.normalWindow,
lateWindow: row.lateWindow,
endGrace: row.endGrace,
isDefault: row.isDefault ?? false,
status: row.status ?? '1',
remark: row.remark ?? '',
})
modalVisible.value = true
}
const onDefaultChange = (val: boolean) => {
if (val) {
formState.minDuration = undefined
formState.maxDuration = undefined
}
}
const handleDelete = async (row: CoachTimeRule) => {
try {
await ElMessageBox.confirm('确定要删除该规则吗?删除后立即生效。', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await coachTimeRuleApi.delete(row.id!)
ElMessage.success('删除成功,已立即生效')
fetchData()
} catch {
// 用户取消
}
}
const handleModalOk = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
const payload: CoachTimeRule = {
minDuration: formState.minDuration ?? null,
maxDuration: formState.maxDuration ?? null,
normalWindow: formState.normalWindow,
lateWindow: formState.lateWindow,
endGrace: formState.endGrace,
isDefault: formState.isDefault,
sortOrder: 0,
status: formState.status,
remark: formState.remark,
}
if (formState.id) {
await coachTimeRuleApi.update(formState.id, payload)
ElMessage.success('更新成功,已立即生效')
} else {
await coachTimeRuleApi.create(payload)
ElMessage.success('创建成功,已立即生效')
}
modalVisible.value = false
fetchData()
} catch (err: any) {
const msg = err?.response?.data?.error || '操作失败'
ElMessage.error(msg)
} finally {
submitting.value = false
}
}
onMounted(() => fetchData())
</script>
<style scoped lang="css">
.time-rule-management .card-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
line-height: 1.4;
}
.form-tip-warn {
color: #e6a23c;
}
</style>