9.5 KiB
9.5 KiB
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. 架构:新建 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 表
采用规则表设计,每条规则定义了一个课程时长区间及其对应的时间阈值:
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 请求体:
{
"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且 <= 1440endGrace:必须 >= 0 且 <= 1440minDuration和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 兜底。
- .filter(r -> Boolean.TRUE.equals(r.getIsDefault()))
+ .filter(r -> Boolean.TRUE.equals(r.getIsDefault()) && r.matches(courseDurationMinutes))