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