Files
gym-manage/gym-manage-api/docs/adr/0002-coach-time-config.md
T

9.5 KiB
Raw Blame History

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-coachgym-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 → 写入 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 请求体:

{
  "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
  • minDurationmaxDuration:若同时非空,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.domaincreatedAt/updatedAt 存入 Redis 后反序列化失败(DB 格式 yyyy-MM-dd HH:mm:ssT 分隔符,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))