新增到课签到时间窗口与迟到签到时间窗口配置,优化教练评分机制(未测试)
This commit is contained in:
@@ -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 → 写入 Redis(TTL=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 迁移 |
|
||||
|
||||
---
|
||||
|
||||
## 已知问题与修复记录
|
||||
|
||||
### 修复 1:Flyway 版本冲突
|
||||
|
||||
原始版本使用了 V25/V26,与已有迁移冲突。最终使用 V29(建表)/ V30(菜单)。
|
||||
|
||||
### 修复 2:LocalDateTime 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%);满员率防御除零 |
|
||||
@@ -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>
|
||||
+58
@@ -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;
|
||||
}
|
||||
}
|
||||
+98
@@ -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; }
|
||||
}
|
||||
+94
@@ -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()));
|
||||
});
|
||||
}
|
||||
}
|
||||
+79
@@ -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();
|
||||
}
|
||||
}
|
||||
+27
@@ -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);
|
||||
}
|
||||
+240
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+25
-24
@@ -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),记录违规
|
||||
*/
|
||||
|
||||
+33
-48
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+9
-9
@@ -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
|
||||
""")
|
||||
|
||||
+16
-6
@@ -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)
|
||||
|
||||
+10
-2
@@ -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;
|
||||
}
|
||||
// 排除自身(编辑时)
|
||||
|
||||
@@ -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>
|
||||
|
||||
+9
@@ -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)
|
||||
|
||||
+9
@@ -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";
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
+107
@@ -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; }
|
||||
}
|
||||
+26
-26
@@ -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 = '搏击操';
|
||||
|
||||
|
||||
|
||||
+50
@@ -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);
|
||||
+40
@@ -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));
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
-- ============================================
|
||||
|
||||
@@ -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}`),
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user