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

This commit is contained in:
2026-07-26 16:10:23 +08:00
parent 86b7555943
commit c2f11727fe
31 changed files with 2069 additions and 138 deletions
@@ -1,6 +1,6 @@
# ADR-0001: 教练业绩统计功能设计
**日期**: 2026-07-22
**日期**: 2026-07-22(初版)/ 2026-07-26(修订)
**状态**: 已决定
**决策者**: 通过 grill-with-docs 追问明确
@@ -19,16 +19,18 @@
**选择**: 在 `gym-dataCount` 模块中新增 CoachPerformance 相关的 Handler + Service + DAO,而非新建独立模块。
**理由**:
- `gym-dataCount` 模块已有成熟的统计架构(DatabaseClient + Reactive + Redis 缓存 + 时间范围推导)
- `gym-dataCount` 模块已有成熟的统计架构(DatabaseClient + Reactive + 时间范围推导)
- 现有 `DataStatisticsDao` 已有教练相关的 SQL 聚合查询,可直接复用
- 避免模块膨胀,将"统计"职责收敛在一个模块中
- `manage-app` 已依赖 `gym-dataCount`,路由注册零成本
**替代方案被拒绝**: 新建 `gym-coach-performance` 独立模块。理由:功能规模不足以支撑独立模块,且会引入额外的模块间依赖管理成本。
---
### 2. 数据源:完全基于团课预约数据
**选择**: 业绩统计的"出席人次"和"出勤率"完全基于 `group_course_booking`status='2'=已出席),而非 `sign_in_record` 签到表。
**选择**: 业绩统计的"出席人次"和"出勤率"完全基于 `group_course_booking` 表,而非 `sign_in_record` 签到表。
**理由**:
- `sign_in_record` 表中没有 `coach_id` 字段,签到只关联会员(member_id),不关联教练
@@ -37,6 +39,8 @@
**风险**: 如果未来签到记录需要关联教练(例如一对一的私教签到),需要重新评估此决策。
---
### 3. 授课量定义:仅计入已完成课程
**选择**: 只统计 `status IN (2, 6)` 的课程(已结束 + 自动结束)。
@@ -45,23 +49,75 @@
- 所有非取消课程:会包含教练缺席(status=5)的课程,不应算作业绩
- 所有排课:会包含已取消的课程,不能反映真实工作量
### 4. 满员率:按出席人数计算
---
**选择**: 满员率 = 各课程(出席人数 / max_members)的平均值。
### 4. 时间基准:以课程结束时间为准
**选择**: 所有时间范围过滤均使用 `group_course.end_time`,而非 `start_time`
**理由**: 课程可能跨统计周期边界(如月末 23:00 开课、次月 01:00 结束)。以开始时间为准会导致跨月课程被错误归因到上月。以结束时间为准更符合"这个月完成了哪些课程"的直观理解。
**变更历史**2026-07-26: 从 `start_time` 改为 `end_time`
---
### 5. 出席人次口径:参与型状态
**选择**: 出席人次统计 `booking.status IN ('2', '4', '5')`,即已出席(2) + 教练缺席(4) + 迟到(5)。
**拒绝的定义**: 仅统计 status='2'(已出席)。理由:教练缺席和迟到同样意味着学员到达了现场(或至少尝试了参与),应计入出席人次;实际缺席责任在教练而非学员。
**变更历史**2026-07-26: 从仅 `status='2'` 扩展为 `IN ('2','4','5')`
---
### 6. 出勤率分母:仅已预约
**选择**: 出勤率分母仅统计 `booking.status = '0'`(已预约),而非 `status != '1'`(所有非取消)。
**理由**:
- status='3'(学员缺席)不应出现在分母中——学员预约后无故缺席,既不应计入分子也不应计入分母,因为这既非教练的功劳也非教练的责任
- 出勤率语义变为"在已预约的学员中,实际参与的比例"
- 排除了预约后取消(status='1')和学员缺席(status='3')的噪声
**变更历史**2026-07-26: 从 `status != '1'` 改为 `status = '0'`
---
### 7. 满员率:按出席人数计算 + 防御除零
**选择**: 满员率 = 各已完成课程(出席人数 / max_members)的平均值,其中出席人数按 status IN ('2','4','5') 统计。`max_members = 0` 的课程被跳过不参与计算。
**拒绝的定义**: 按预约人数(current_members)计算。理由:预约了但没来的学员不能算"满员",出席人数更真实地反映了课程实际到场情况。
### 5. 综合评分权重:授课量 40% + 出勤率 30% + 满员率 30%
**变更历史**2026-07-26): 满员率明细的出席人数口径从 `status='2'` 扩展为 `IN ('2','4','5')`,与出席人次保持一致。
**选择**: 授课量占比最高,体现工作量;出勤率和满员率体现教学质量。
---
**归一化规则**: 授课量按所有教练中最大值归一化到 0-100。这样即使只有少数教练开课多,评分也能合理分布。
### 8. 综合评分
**拒绝的替代方案**:
- 三指标等权重(33/33/34):弱化了工作量差异
- 授课量 50%:过度强调数量而忽视质量
**最终选择**2026-07-26 修订):
### 6. 不包含学员留存率
| 指标 | 权重 | 归一化方式 |
|------|------|-----------|
| 授课量 | 35% | 百分位排名(授课量排序,小于当前教练的教练数 / (总教练数-1) * 100 |
| 出勤率 | 25% | 原始百分比(0-100 |
| 满员率 | 25% | 原始百分比(0-100 |
| 违规扣分 | 15% | 线性扣分:max(0, 100 - 违规次数 * 20) |
```
综合评分 = 授课量归一化分 * 0.35 + 出勤率 * 0.25 + 满员率 * 0.25 + 违规分 * 0.15
```
**公式变更历史**:
- 初版(2026-07-22: `授课量归一化(最大值归一化) * 0.4 + 出勤率 * 0.3 + 满员率 * 0.3`,违规仅展示不参与评分
- 修订(2026-07-26): 授课量归一化改为百分位排名,违规纳入评分,权重重新分配
**拒绝的替代方案**: 详见设计文档 `docs/coach-performance-design.md`
---
### 9. 不包含学员留存率
**选择**: 首版不计算学员留存率。
@@ -69,11 +125,30 @@
---
### 10. 不引入 Redis 缓存
**选择**: 教练业绩统计数据不进行 Redis 缓存,每次请求实时计算。
**理由**: 业绩数据需要准实时性,缓存可能导致教练查看时数据滞后;且当前教练数量级下,6 条聚合查询的响应时间可接受。
---
### 11. getCoachPerformanceById 复用全量查询
**选择**: 查询单个教练业绩时,内部调用 `getCoachPerformanceList` 获取全量后过滤。暂不新增按教练 ID 的单独 DAO 方法。
**理由**: 当前教练数量有限,全量查询后再过滤的性能损耗可接受,优先保持代码简洁。
**风险**: 教练数量增长后需要重新评估,届时可新增按 coach_id 直查的 DAO 方法。
---
## 影响
### 后端变更
- `gym-dataCount` 模块新增:`CoachPerformance` domain、`CoachPerformanceHandler``CoachPerformanceDao`
- `manage-app``SystemRouter` 中新增 2 条路由
- `gym-dataCount` 模块新增:`CoachPerformance` domain、`CoachPerformanceHandler``DataStatisticsDao`(教练业绩相关方法)
- `manage-app``SystemRouter` 中新增 3 条路由
- `DataStatisticsServiceImpl` 新增 `getCoachPerformanceList``getCoachPerformanceById``calculateFillRate` 方法
### 前端变更
- `StatisticsDashboard.vue` 新增"教练业绩"Tab
@@ -96,3 +171,28 @@
新建 `gym-coach-performance` 独立 Maven 模块。
- 优点:职责隔离清晰
- 缺点:模块碎片化,增加编译和依赖管理成本
### 方案 C:授课量最大值归一化(已拒绝,初版方案)
`normalizedCourses = courses / maxCourses * 100`
- 优点:数学简洁
- 缺点:若有一位教练授课量远超其他,中游教练得分被严重压缩;鼓励"互卷"而非"达标"
### 方案 D:授课量对数归一化(已拒绝)
`normalizedCourses = ln(courses + 1) / ln(maxCourses + 1) * 100`
- 优点:自然压制极端值
- 缺点:解释性弱,非技术人员难以理解评分含义
### 方案 E:授课量固定目标归一化(已拒绝)
`normalizedCourses = min(courses / target * 100, 100)`target 可配置
- 优点:变成"达标制",不受其他教练影响
- 缺点:target 值需要根据实际数据校准,设置不当会全员满分或全员不及格
### 方案 F:违规阶梯扣分(已拒绝)
0次=100, 1次=70, 2次=40, 3次=10, >=4次=0
- 优点:首次违规惩罚重,有威慑力
- 缺点:阶梯粒度太粗,第 1 次和第 2 次违规之间差距 30 分,过于激进
### 方案 G:违规归一化扣分(已拒绝)
`violationScore = (1 - violations / maxViolations) * 100`
- 优点:相对于最差教练扣分
- 缺点:依赖数据集中的最大值,若所有教练都无违规则无意义
@@ -0,0 +1,214 @@
# ADR-0002: 教练迟到/缺席时间判定可配置化
**日期**: 2026-07-26
**状态**: 已决定
**决策者**: 通过 grill-with-docs 追问明确
---
## 背景
当前教练开课/结课/迟到/缺席的时间阈值全部硬编码在代码中:
| 硬编码值 | 位置 | 含义 |
|----------|------|------|
| 60 分钟 | CoachCourseService + CoachCourseScheduler | 长/短课时分界线 |
| 10 分钟 | CoachCourseService L213 | 长课正常开课窗口 |
| 30 分钟 | CoachCourseService L217, Scheduler L118 | 长课迟到/缺席截止线 |
| 10% | CoachCourseService L229 | 短课正常开课比例 |
| 25% | CoachCourseService L230, Scheduler L120 | 短课迟到/缺席比例 |
| 10 分钟 | CoachCourseService L283, Scheduler L34 | 结课宽限期 |
业务方要求:
1. **前端统一传入绝对值**(分钟),短课时比例也由前端换算后传入
2. 支持**按课程时长区间**匹配不同规则
3. 配置存储在**数据库**中
4. **热更新**——修改配置后无需重启即生效
5. 配置缺失/非法时使用**硬编码值兜底**
---
## 决策
### 1. 架构:新建 `gym-coach-config` 独立模块
**选择**: 创建新模块 `gym-coach-config`,封装时间规则配置的完整功能链。
**理由**:
- 将可配置化逻辑从 `gym-coach` 中解耦,符合单一职责原则
- `gym-coach-config` 提供规则 CRUD + 规则匹配服务,是纯"配置域"
- `gym-coach``gym-coach-config` 之间通过依赖注入协作,`gym-coach` 依赖 `gym-coach-config`
- 后续若其他模块(如签到、预约)也需要时间阈值配置化,可直接复用
**替代方案被拒绝**:
- 放在 `gym-coach` 模块内:配置逻辑和业务逻辑耦合,违反职责分离
- 放在 `manage-sys` 的字典模块:字典是通用 key-value 对,无法支撑规则匹配(需范围查询 + 优先级排序)
### 2. 数据模型:`coach_time_rule` 表
采用规则表设计,每条规则定义了一个课程时长区间及其对应的时间阈值:
```sql
CREATE TABLE coach_time_rule (
id BIGSERIAL PRIMARY KEY,
min_duration INTEGER, -- 课程时长下限(分钟),NULL 表示无下限
max_duration INTEGER, -- 课程时长上限(分钟),NULL 表示无上限
normal_window INTEGER NOT NULL, -- 正常开课窗口(分钟)
late_window INTEGER NOT NULL, -- 迟到/缺席截止窗口(分钟)
end_grace INTEGER NOT NULL, -- 结课宽限期(分钟)
is_default BOOLEAN DEFAULT FALSE, -- 是否默认规则
sort_order INTEGER DEFAULT 0, -- 优先级
status CHAR(1) DEFAULT '1',
remark VARCHAR(500),
create_by VARCHAR(64),
update_by VARCHAR(64),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
deleted_at TIMESTAMP
);
```
**示例数据**:
| id | min_duration | max_duration | normal_window | late_window | end_grace | is_default | 说明 |
|----|-------------|-------------|---------------|-------------|-----------|------------|------|
| 1 | NULL | NULL | 10 | 30 | 10 | true | 默认规则:原长课逻辑 |
| 2 | NULL | 59 | 1 | 15 | 5 | false | 短课(<60分钟):最小1分钟正常,15分钟迟到 |
### 3. 规则匹配策略
```
对于一门课程(时长 = endTime - startTime 的分钟数):
1. 从 Redis 缓存中获取所有启用规则(status='1', deleted_at IS NULL
2. 过滤出 minDuration <= courseDuration <= maxDuration 的规则
3. 选择范围最精确的规则 —— 即 (maxDuration - minDuration) 最小的那条
4. 若无匹配规则,使用 is_default=true 的默认规则
5. 若默认规则也不存在,使用硬编码兜底值
```
### 4. 热更新机制
```
┌──────────┐ POST/PUT/DELETE ┌──────────────────┐
│ 前端 │ ──────────────────> │ CoachTimeRuleHandler │
└──────────┘ └────────┬─────────┘
┌──────▼──────┐
│ DB 更新 │
└──────┬──────┘
┌──────▼──────┐
│ 删除 Redis │
│ key: │
│ coach:time: │
│ rules │
└──────┬──────┘
┌──────────────┐ 下次开课/调度器触发时 ┌──▼───────────┐
│ 业务代码 │ <────────────────── │ Redis Miss │
│ (Service/ │ │ → 从 DB 加载 │
│ Scheduler) │ │ → 写入 Redis │
└──────────────┘ └──────────────┘
```
- 写操作(创建/更新/删除规则)→ 更新 DB → 立即删除 Redis 缓存 key
- 读操作 → 先查 Redis → 未命中则查 DB → 写入 RedisTTL=300s,兜底)
- 每次业务调用(开课/调度器)都实时从 CoachTimeRuleService 获取最新规则,不缓存本地变量
### 5. 兜底策略
| 场景 | 行为 |
|------|------|
| 所有规则被删除 | 使用硬编码默认值(原逻辑:长课 10/30,短课 10%/25%,结课 10 |
| 单条规则中值为 null/负数 | 该字段使用硬编码兜底值 |
| Redis 不可用 | 降级为每次查 DB |
| DB 不可用 | 使用硬编码兜底值 |
### 6. API 设计
```
GET /api/coach/time-rules -- 获取所有规则列表
GET /api/coach/time-rules/{id} -- 获取单条规则
POST /api/coach/time-rules -- 创建规则
PUT /api/coach/time-rules/{id} -- 更新规则
DELETE /api/coach/time-rules/{id} -- 删除规则
```
POST/PUT 请求体:
```json
{
"minDuration": 60, // 可选,null 表示无下限
"maxDuration": null, // 可选,null 表示无上限
"normalWindow": 10, // 必填,正常开课窗口(分钟)
"lateWindow": 30, // 必填,迟到/缺席截止窗口(分钟)
"endGrace": 10, // 必填,结课宽限期(分钟)
"isDefault": true, // 是否设为默认规则
"sortOrder": 0,
"remark": "默认规则"
}
```
### 7. API 校验规则
后端在 Handler 层对前端传入的值做合法性校验:
- `normalWindow`:必须 >= 1 且 <= 1440(一天内)
- `lateWindow`:必须 >= `normalWindow` 且 <= 1440
- `endGrace`:必须 >= 0 且 <= 1440
- `minDuration``maxDuration`:若同时非空,`maxDuration` 必须 >= `minDuration`
- 若前端传入非法值,返回 HTTP 400 + 具体错误信息;不落库
### 8. 模块依赖关系
```
manage-app
├── gym-coach (依赖 gym-coach-config)
│ └── CoachCourseService → 注入 CoachTimeRuleService 获取规则
│ └── CoachCourseScheduler → 注入 CoachTimeRuleService 获取规则
└── gym-coach-config (新模块)
├── handler/CoachTimeRuleHandler -- HTTP 处理器
├── service/CoachTimeRuleService -- 规则匹配 + 缓存
├── domain/CoachTimeRule -- 领域对象
├── repository/ICoachTimeRuleRepository -- 仓储接口
└── router -- 路由注册
manage-db
├── entity/CoachTimeRuleEntity -- DB 实体
├── dao/CoachTimeRuleDao -- DAO (R2DBC)
└── migration/V29__Create_coach_time_rule.sql
```
---
## 影响范围
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `pom.xml` | 新增 | 添加 `gym-coach-config` 模块 |
| `gym-coach/pom.xml` | 修改 | 添加 `gym-coach-config` 依赖 |
| `CoachCourseService.java` | 修改 | 注入 `CoachTimeRuleService`,替换硬编码阈值 |
| `CoachCourseScheduler.java` | 修改 | 注入 `CoachTimeRuleService`,替换硬编码阈值 |
| `SystemRouter.java` | 修改 | 注册新路由 |
| 新建模块文件 | 新增 | 约 8-10 个 Java 文件 + 1 个 SQL 迁移 |
---
## 已知问题与修复记录
### 修复 1Flyway 版本冲突
原始版本使用了 V25/V26,与已有迁移冲突。最终使用 V29(建表)/ V30(菜单)。
### 修复 2LocalDateTime Redis 反序列化
`CoachTimeRule.domain``createdAt`/`updatedAt` 存入 Redis 后反序列化失败(DB 格式 `yyyy-MM-dd HH:mm:ss``T` 分隔符,Jackson 默认期望 ISO 格式)。已添加 `@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")`
### 修复 3:默认规则回退未做区间匹配校验
**问题**:当有区间限制的规则(如 `minDuration=30`)被标记为 `isDefault=true`,或原始默认规则被修改了区间时,不匹配该区间的课程时长(如 20 分钟)会被错误应用该规则的阈值。
**修复**`doMatch()` 中回退到默认规则时,增加 `r.matches(courseDurationMinutes)` 校验。若默认规则也不匹配,继续回退到 `buildFallbackRule` 兜底。
```diff
- .filter(r -> Boolean.TRUE.equals(r.getIsDefault()))
+ .filter(r -> Boolean.TRUE.equals(r.getIsDefault()) && r.matches(courseDurationMinutes))
```