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

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
@@ -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))
```