215 lines
9.5 KiB
Markdown
215 lines
9.5 KiB
Markdown
# 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))
|
||
```
|