新增到课签到时间窗口与迟到签到时间窗口配置,优化教练评分机制(未测试)
This commit is contained in:
@@ -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))
|
||||
```
|
||||
Reference in New Issue
Block a user