diff --git a/gym-manage-api/docs/adr/0001-coach-performance-statistics.md b/gym-manage-api/docs/adr/0001-coach-performance-statistics.md index 903e280..0697111 100644 --- a/gym-manage-api/docs/adr/0001-coach-performance-statistics.md +++ b/gym-manage-api/docs/adr/0001-coach-performance-statistics.md @@ -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` +- 优点:相对于最差教练扣分 +- 缺点:依赖数据集中的最大值,若所有教练都无违规则无意义 diff --git a/gym-manage-api/docs/adr/0002-coach-time-config.md b/gym-manage-api/docs/adr/0002-coach-time-config.md new file mode 100644 index 0000000..f65ef0f --- /dev/null +++ b/gym-manage-api/docs/adr/0002-coach-time-config.md @@ -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)) +``` diff --git a/gym-manage-api/docs/coach-performance-design.md b/gym-manage-api/docs/coach-performance-design.md new file mode 100644 index 0000000..7f75871 --- /dev/null +++ b/gym-manage-api/docs/coach-performance-design.md @@ -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 +2. Flux: countCompletedCoursesByCoach() ─→ Map ┐ +3. Flux: countAttendedStudentsByCoach() ─→ Map │ +4. Flux: countTotalBookingsByCoach() ─→ Map ├─ Mono.zip +5. Flux: getFillRateDetailByCoach() ─→ Map> │ +6. Flux: countViolationsByCoach() ─→ Map ┘ + │ + flatMapMany: 逐教练计算指标 + │ + sorted: 按综合评分降序 + │ + Flux +``` + +--- + +## 七、边界情况处理 + +| 场景 | 行为 | +|------|------| +| 教练无任何已完成课程 | 所有指标为 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%);满员率防御除零 | diff --git a/gym-manage-api/gym-coach-config/pom.xml b/gym-manage-api/gym-coach-config/pom.xml new file mode 100644 index 0000000..bef3b7b --- /dev/null +++ b/gym-manage-api/gym-coach-config/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + + cn.novalon.gym.manage + gym-manage-api + 1.0.0 + + + gym-coach-config + jar + + Gym Coach Config + Coach Time Rule Configuration Module - Configurable coach lateness/absence thresholds + + + + cn.novalon.gym.manage + manage-common + ${project.version} + + + cn.novalon.gym.manage + manage-db + ${project.version} + + + cn.novalon.gym.manage + manage-sys + ${project.version} + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.data + spring-data-commons + + + org.springdoc + springdoc-openapi-starter-webflux-ui + + + org.projectlombok + lombok + provided + + + diff --git a/gym-manage-api/gym-coach-config/src/main/java/cn/novalon/gym/manage/coachconfig/converter/CoachTimeRuleConverter.java b/gym-manage-api/gym-coach-config/src/main/java/cn/novalon/gym/manage/coachconfig/converter/CoachTimeRuleConverter.java new file mode 100644 index 0000000..d216ac7 --- /dev/null +++ b/gym-manage-api/gym-coach-config/src/main/java/cn/novalon/gym/manage/coachconfig/converter/CoachTimeRuleConverter.java @@ -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; + } +} diff --git a/gym-manage-api/gym-coach-config/src/main/java/cn/novalon/gym/manage/coachconfig/domain/CoachTimeRule.java b/gym-manage-api/gym-coach-config/src/main/java/cn/novalon/gym/manage/coachconfig/domain/CoachTimeRule.java new file mode 100644 index 0000000..8d46489 --- /dev/null +++ b/gym-manage-api/gym-coach-config/src/main/java/cn/novalon/gym/manage/coachconfig/domain/CoachTimeRule.java @@ -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; } +} diff --git a/gym-manage-api/gym-coach-config/src/main/java/cn/novalon/gym/manage/coachconfig/handler/CoachTimeRuleHandler.java b/gym-manage-api/gym-coach-config/src/main/java/cn/novalon/gym/manage/coachconfig/handler/CoachTimeRuleHandler.java new file mode 100644 index 0000000..1e152d6 --- /dev/null +++ b/gym-manage-api/gym-coach-config/src/main/java/cn/novalon/gym/manage/coachconfig/handler/CoachTimeRuleHandler.java @@ -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 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 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 createRule(ServerRequest request) { + return request.bodyToMono(CoachTimeRule.class) + .flatMap(rule -> service.createRule(rule) + .flatMap(saved -> { + Map 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 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 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())); + }); + } +} diff --git a/gym-manage-api/gym-coach-config/src/main/java/cn/novalon/gym/manage/coachconfig/repository/CoachTimeRuleRepository.java b/gym-manage-api/gym-coach-config/src/main/java/cn/novalon/gym/manage/coachconfig/repository/CoachTimeRuleRepository.java new file mode 100644 index 0000000..275b53d --- /dev/null +++ b/gym-manage-api/gym-coach-config/src/main/java/cn/novalon/gym/manage/coachconfig/repository/CoachTimeRuleRepository.java @@ -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 findByStatusAndDeletedAtIsNull(String status) { + return dao.findByStatusAndDeletedAtIsNull(status, Sort.by(Sort.Direction.ASC, "sort_order")) + .map(converter::toDomain); + } + + @Override + public Flux findByDeletedAtIsNull() { + return dao.findByDeletedAtIsNull() + .map(converter::toDomain); + } + + @Override + public Mono findById(Long id) { + return dao.findByIdAndDeletedAtIsNull(id) + .map(converter::toDomain); + } + + @Override + public Mono 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 deleteById(Long id) { + return dao.findByIdAndDeletedAtIsNull(id) + .flatMap(entity -> { + entity.setDeletedAt(LocalDateTime.now()); + return dao.save(entity); + }) + .then(); + } + + @Override + public Mono 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(); + } +} diff --git a/gym-manage-api/gym-coach-config/src/main/java/cn/novalon/gym/manage/coachconfig/repository/ICoachTimeRuleRepository.java b/gym-manage-api/gym-coach-config/src/main/java/cn/novalon/gym/manage/coachconfig/repository/ICoachTimeRuleRepository.java new file mode 100644 index 0000000..f108b5d --- /dev/null +++ b/gym-manage-api/gym-coach-config/src/main/java/cn/novalon/gym/manage/coachconfig/repository/ICoachTimeRuleRepository.java @@ -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 findByStatusAndDeletedAtIsNull(String status); + + Flux findByDeletedAtIsNull(); + + Mono findById(Long id); + + Mono save(CoachTimeRule rule); + + Mono deleteById(Long id); + + /** 将除指定 id 之外的所有启用默认规则设为非默认(excludeId 为 null 表示清除全部) */ + Mono unsetOtherDefaults(Long excludeId); +} diff --git a/gym-manage-api/gym-coach-config/src/main/java/cn/novalon/gym/manage/coachconfig/service/CoachTimeRuleService.java b/gym-manage-api/gym-coach-config/src/main/java/cn/novalon/gym/manage/coachconfig/service/CoachTimeRuleService.java new file mode 100644 index 0000000..d097916 --- /dev/null +++ b/gym-manage-api/gym-coach-config/src/main/java/cn/novalon/gym/manage/coachconfig/service/CoachTimeRuleService.java @@ -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 matchRule(long courseDurationMinutes) { + return getActiveRules() + .collectList() + .map(rules -> doMatch(rules, courseDurationMinutes)); + } + + private CoachTimeRule doMatch(List rules, long courseDurationMinutes) { + // 过滤出匹配的规则 + List 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 getActiveRules() { + return redisUtil.get(RedisKeyConstants.COACH_TIME_RULES) + .flatMapMany(cached -> { + @SuppressWarnings("unchecked") + List list = (List) 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 getAllRules() { + return repository.findByDeletedAtIsNull(); + } + + public Mono getRuleById(Long id) { + return repository.findById(id) + .switchIfEmpty(Mono.error(new RuntimeException("规则不存在"))); + } + + public Mono 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 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 deleteRule(Long id) { + return repository.deleteById(id) + .doOnSuccess(v -> invalidateCache()); + } + + // ==================== 校验 ==================== + + /** + * 校验规则字段合法性 + */ + private Mono 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(); + } +} diff --git a/gym-manage-api/gym-coach/pom.xml b/gym-manage-api/gym-coach/pom.xml index 011f5f9..0e04e11 100644 --- a/gym-manage-api/gym-coach/pom.xml +++ b/gym-manage-api/gym-coach/pom.xml @@ -37,6 +37,11 @@ gym-groupCourse ${project.version} + + cn.novalon.gym.manage + gym-coach-config + ${project.version} + org.springframework.boot spring-boot-starter-webflux diff --git a/gym-manage-api/gym-coach/src/main/java/cn/novalon/gym/manage/coach/scheduler/CoachCourseScheduler.java b/gym-manage-api/gym-coach/src/main/java/cn/novalon/gym/manage/coach/scheduler/CoachCourseScheduler.java index f301e68..0eff9cb 100644 --- a/gym-manage-api/gym-coach/src/main/java/cn/novalon/gym/manage/coach/scheduler/CoachCourseScheduler.java +++ b/gym-manage-api/gym-coach/src/main/java/cn/novalon/gym/manage/coach/scheduler/CoachCourseScheduler.java @@ -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 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 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),记录违规 */ diff --git a/gym-manage-api/gym-coach/src/main/java/cn/novalon/gym/manage/coach/service/CoachCourseService.java b/gym-manage-api/gym-coach/src/main/java/cn/novalon/gym/manage/coach/service/CoachCourseService.java index 18fb3b6..84cb4a9 100644 --- a/gym-manage-api/gym-coach/src/main/java/cn/novalon/gym/manage/coach/service/CoachCourseService.java +++ b/gym-manage-api/gym-coach/src/main/java/cn/novalon/gym/manage/coach/service/CoachCourseService.java @@ -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 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 handleLongCourseStart(GroupCourseEntity course, LocalDateTime now) { + private Mono 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 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 doStartCourse(GroupCourseEntity course, LocalDateTime now, @@ -260,7 +238,7 @@ public class CoachCourseService { /** * 教练手动结课 * 可在 IN_PROGRESS(3) 或 COACH_LATE(7) 状态下结课 - * 必须在标注结课时间 + 10分钟内 + * 必须在标注结课时间 + endGrace 分钟内 */ public Mono 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)); + }); }); } diff --git a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/dao/DataStatisticsDao.java b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/dao/DataStatisticsDao.java index 41fba81..657a5d2 100644 --- a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/dao/DataStatisticsDao.java +++ b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/dao/DataStatisticsDao.java @@ -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> 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> 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 """) diff --git a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/service/impl/DataStatisticsServiceImpl.java b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/service/impl/DataStatisticsServiceImpl.java index 6fe6a83..1c7fc79 100644 --- a/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/service/impl/DataStatisticsServiceImpl.java +++ b/gym-manage-api/gym-dataCount/src/main/java/cn/novalon/gym/manage/datacount/service/impl/DataStatisticsServiceImpl.java @@ -574,8 +574,10 @@ public class DataStatisticsServiceImpl implements IDataStatisticsService { Map> fillRateMap = tuple.getT5(); Map violationsMap = tuple.getT6(); - // 计算最大授课量(用于归一化) - long maxCourses = coursesMap.values().stream().mapToLong(Long::longValue).max().orElse(1L); + // 授课量百分位排名归一化 + List sortedCourses = coursesMap.values().stream() + .sorted().collect(Collectors.toList()); + int totalCoaches = sortedCourses.size(); List 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) diff --git a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseService.java b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseService.java index ba348c8..2a30763 100644 --- a/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseService.java +++ b/gym-manage-api/gym-groupCourse/src/main/java/cn/novalon/gym/manage/groupcourse/service/impl/GroupCourseService.java @@ -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; } // 排除自身(编辑时) diff --git a/gym-manage-api/manage-app/pom.xml b/gym-manage-api/manage-app/pom.xml index fb789f0..f4a07f5 100644 --- a/gym-manage-api/manage-app/pom.xml +++ b/gym-manage-api/manage-app/pom.xml @@ -169,16 +169,22 @@ 1.0.0 compile + + cn.novalon.gym.manage + gym-coach-config + ${project.version} + compile + cn.novalon.gym.manage gym-coach - 1.0.0 + ${project.version} compile cn.novalon.gym.manage gym-brand - 1.0.0 + ${project.version} compile diff --git a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java index 9835f81..8450ee6 100644 --- a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java +++ b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java @@ -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) diff --git a/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/constant/RedisKeyConstants.java b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/constant/RedisKeyConstants.java index 46d7c61..f93f345 100644 --- a/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/constant/RedisKeyConstants.java +++ b/gym-manage-api/manage-common/src/main/java/cn/novalon/gym/manage/common/constant/RedisKeyConstants.java @@ -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"; } \ No newline at end of file diff --git a/gym-manage-api/manage-db/src/main/java/cn/novalon/gym/manage/db/dao/CoachTimeRuleDao.java b/gym-manage-api/manage-db/src/main/java/cn/novalon/gym/manage/db/dao/CoachTimeRuleDao.java new file mode 100644 index 0000000..930ddfa --- /dev/null +++ b/gym-manage-api/manage-db/src/main/java/cn/novalon/gym/manage/db/dao/CoachTimeRuleDao.java @@ -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 { + + Flux findByStatusAndDeletedAtIsNull(String status); + + Flux findByStatusAndDeletedAtIsNull(String status, Sort sort); + + Flux findByDeletedAtIsNull(); + + Mono findByIdAndDeletedAtIsNull(Long id); + + Flux findByIsDefaultTrueAndStatusAndDeletedAtIsNull(String status); +} diff --git a/gym-manage-api/manage-db/src/main/java/cn/novalon/gym/manage/db/entity/CoachTimeRuleEntity.java b/gym-manage-api/manage-db/src/main/java/cn/novalon/gym/manage/db/entity/CoachTimeRuleEntity.java new file mode 100644 index 0000000..d4a1c96 --- /dev/null +++ b/gym-manage-api/manage-db/src/main/java/cn/novalon/gym/manage/db/entity/CoachTimeRuleEntity.java @@ -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; } +} diff --git a/gym-manage-api/manage-db/src/main/resources/db/migration/V18__Insert_group_course_data.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V18__Insert_group_course_data.sql index 0075075..bc70ee2 100644 --- a/gym-manage-api/manage-db/src/main/resources/db/migration/V18__Insert_group_course_data.sql +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V18__Insert_group_course_data.sql @@ -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 = '搏击操'; diff --git a/gym-manage-api/manage-db/src/main/resources/db/migration/V29__Create_coach_time_rule.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V29__Create_coach_time_rule.sql new file mode 100644 index 0000000..78ede7e --- /dev/null +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V29__Create_coach_time_rule.sql @@ -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); diff --git a/gym-manage-api/manage-db/src/main/resources/db/migration/V30__Add_coach_time_rule_menu.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V30__Add_coach_time_rule_menu.sql new file mode 100644 index 0000000..6dfb3ce --- /dev/null +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V30__Add_coach_time_rule_menu.sql @@ -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)); diff --git a/gym-manage-api/pom.xml b/gym-manage-api/pom.xml index 83de77b..995f002 100644 --- a/gym-manage-api/pom.xml +++ b/gym-manage-api/pom.xml @@ -49,6 +49,7 @@ gym-auth gym-payment gym-coach + gym-coach-config gym-brand diff --git a/gym-manage-web/e2e/fixtures/seed-for-e2e.sql b/gym-manage-web/e2e/fixtures/seed-for-e2e.sql index 8028d21..37f9103 100644 --- a/gym-manage-web/e2e/fixtures/seed-for-e2e.sql +++ b/gym-manage-web/e2e/fixtures/seed-for-e2e.sql @@ -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; -- ============================================ diff --git a/gym-manage-web/src/api/coach.api.ts b/gym-manage-web/src/api/coach.api.ts index 2a293da..a3bc904 100644 --- a/gym-manage-web/src/api/coach.api.ts +++ b/gym-manage-web/src/api/coach.api.ts @@ -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(`/coach/${id}/violations`), } + +export const coachTimeRuleApi = { + /** 获取所有时间规则 */ + getAll: () => + request.get('/coach/time-rules'), + + /** 获取单条规则 */ + getById: (id: number) => + request.get(`/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}`), +} diff --git a/gym-manage-web/src/router/index.ts b/gym-manage-web/src/router/index.ts index c2ce049..6551ba0 100644 --- a/gym-manage-web/src/router/index.ts +++ b/gym-manage-web/src/router/index.ts @@ -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', diff --git a/gym-manage-web/src/stores/permission.ts b/gym-manage-web/src/stores/permission.ts index f832bd6..ffb3207 100644 --- a/gym-manage-web/src/stores/permission.ts +++ b/gym-manage-web/src/stores/permission.ts @@ -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' } diff --git a/gym-manage-web/src/utils/permission.ts b/gym-manage-web/src/utils/permission.ts index 2a34322..0d78eaa 100644 --- a/gym-manage-web/src/utils/permission.ts +++ b/gym-manage-web/src/utils/permission.ts @@ -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 { diff --git a/gym-manage-web/src/views/coach/CoachTimeRuleManagement.vue b/gym-manage-web/src/views/coach/CoachTimeRuleManagement.vue new file mode 100644 index 0000000..ef969bf --- /dev/null +++ b/gym-manage-web/src/views/coach/CoachTimeRuleManagement.vue @@ -0,0 +1,366 @@ + + + + +