# Roadmap

本文档记录 `home-automation` 在 `v1.0.3` 之后的下一阶段规划。这一阶段不是小修补，而是几次较大的结构性改动：单库化、前端重写、以及远期的移动端试水。

> 每个里程碑的**可执行原子任务**展开在 [`docs/design/`](./design/README.md)：M1 [`m1-db-consolidation.md`](./design/m1-db-consolidation.md)、M2 [`m2-frontend-v2.md`](./design/m2-frontend-v2.md)、M3 [`m3-token-mobile.md`](./design/m3-token-mobile.md)、M4 [`m4-login-hardening.md`](./design/m4-login-hardening.md)、M5 [`m5-iot-energy.md`](./design/m5-iot-energy.md)、M6 [`m6-tibber-dynamic-energy.md`](./design/m6-tibber-dynamic-energy.md)、M7 [`m7-meter-epochs-archival.md`](./design/m7-meter-epochs-archival.md)。这些文档为 Orchestrator→Implementer→Reviewer 的多模型流水线设计。

## 当前基线（v1.0.3）

- FastAPI + 服务端 Jinja 模板页面（目前只有 `/login`、`/config`）
- 三个独立 SQLite 库：
  - App DB：`sqlite:///./data/app.db`
  - Location DB：`sqlite:///./data/locationRecorder.db`
  - Poo DB：`sqlite:///./data/pooRecorder.db`
- 三条独立 Alembic 链：`alembic_app/`、`alembic_location/`、`alembic_poo/`
- 单 admin 鉴权（Argon2 + server-side session cookie）
- Public IPv4 monitor、SMTP 通知、Location / Poo recorder、Home Assistant in/out、TickTick OAuth
- 数据可视化目前由 Grafana provisioning 承担（仅 location / poo dashboard）
- 已有 OpenAPI 导出脚本：`scripts/export_openapi.py`

## 本阶段正式退役的架构约束

`docs/architecture-overview.md` 里有几条当时刻意写死的约束，这一阶段明确退役：

- **“不引入前后端分离”** → 退役。本阶段改为 React SPA（仍由 FastAPI 同源托管，但渲染移到客户端）。
- **“三个独立 DB 不合并”** → 退役。本阶段把 location / poo 合并进 `app.db`。
- **Grafana 作为可视化方案** → 退役。可视化由 React 前端自己承担（热力图、地图等）。

保持不变的约束：

- 继续使用 **SQLite**，本阶段不上 Postgres。
- 不引入 Notion。

## 里程碑总览

| 里程碑 | 主题 | 一句话 |
| --- | --- | --- |
| **M1** ✅ | 单库化地基 | 把三库合并成单一 `app.db`，清理散落数据层，删掉 Grafana |
| **M2** ✅ | 前端 v2 | React SPA 取代 Jinja，承载 config + 可视化 + 记录增删改 |
| **M4** ✅ | 登录加固 | 防爆破/指数退避 + CLI 逃生通道 + 可选 TOTP 二次验证（**先于 M5**） |
| **M5** ✅ | IoT / 能耗采集 | 通用 Modbus 采集（YAML profile + JSON readings）+ MQTT/HA Discovery + 前端侧边栏 + Energy 视图 |
| **M6** ✅ | 通用电价层 + DSMR 接入 + 实时电费计算 | 通用电价层（manual/tibber profile + 合同版本）+ DSMR 实时电表接入 + 每 15min 寄存器差×价计量电费（不可变快照）+ 日/月/年汇总 + 反哺 HA Energy + 前端合同/价格/费用视图 |
| **M7** ✅ | 电表生命周期 / 换表归档 | 引入 Meter epoch，计费永不跨表算 delta，跨表/无表/异常 delta 一律降级，累计按当前表归零，追溯换表可重算，Meter CRUD API + 前端管理 UI |
| **M3** | 开放与移动端（远期试水） | token 鉴权 + React Native 移动端 |

排序原则：**先清地基，再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上；M4 是公网安全加固，在 M5 IoT 集成之前先堵住裸密码这个洞；M5 在安全基座就绪后再做 IoT 接入。

---

## M1 — 单库化地基（✅ 已完成）

### 目标

把 location / poo 两个独立库合并进 `app.db`，借机清理项目早期散落各处的数据访问代码，并移除 Grafana。

### 范围

- **Alembic 收敛为单链（app 链）**：location / poo 的表此后纳入 app 链管理；`alembic_location/`、`alembic_poo/` 退出活跃使用（保留在 git 历史）。
- **新建表（schema only）**：在 app 链上加一条 upgrade revision，把原来两个旧库里的表**原样**建到 `app.db` 中。Alembic **不需要知道任何旧数据**——它只负责把 app DB 往上升一个版本、建出这两张新表。
- **数据搬迁交给独立脚本**：`scripts/migrate_legacy_data.py`（见下方“迁移策略”），手动跑一次。
- **配置层收敛**：去掉 `LOCATION_DATABASE_URL` / `POO_DATABASE_URL`，统一到 `APP_DATABASE_URL`。
- **开启 SQLite WAL**：单文件 + Web + APScheduler 并发写入，开 WAL 更稳。
- **删除 Grafana**：移除 compose 中的 grafana service、`grafana/provisioning/`、`grafana/dashboards/`。直接删除，不再 re-point datasource。
- **更新文档**：README、architecture-overview 同步反映单库现实。

### 注意

- **可视化空窗可接受**：M1 删掉 Grafana 后、到 M2 React 可视化落地之前会有一段没有可视化面板的时间。已确认可以接受。
- **历史数据是第一优先级，绝不能丢**（见“数据安全原则”）。

---

## 迁移策略（M1 核心）

职责拆分得很清楚：**Alembic 管 schema，脚本管数据。**

### Alembic revision（只建结构）

- 一条 app 链上的 upgrade revision，建出与旧库**完全相同**的表结构。
- 确定性、与环境无关：在生产机、CI、全新部署上都一样地建空表，不依赖任何旧文件是否存在。
- 本步**只原样挪表，不顺手改 schema**。任何表结构清理留到之后一条单独的 migration 去做——不可替代的历史数据，一次只承担一种风险。

### 数据搬迁脚本（`scripts/migrate_legacy_data.py`）

- 把旧 `locationRecorder.db` / `pooRecorder.db` 里的行，拷进 `app.db` 的新表（SQLite `ATTACH DATABASE` 或单独连接均可）。
- **幂等**：重复运行不会重复插入。
- **搬完对账**：逐表核对源 / 目标行数，对不上就报错中止。
- 只在生产机上**手动跑一次**，不进 Alembic 永久链路（避免把一次性历史搬迁焊死进每次全新建库都要跑的链路里）。

### 旧库的“撤掉”

- “撤掉旧库” = ① 配置不再指向它们 + ② 文件**归档保留**。
- **绝不**在任何脚本 / migration 里 `os.remove` 旧文件——那不可逆，且踩数据安全红线。
- 真正的删除是**人工、最后、确认无误之后**单独的一步。

---

## 数据安全原则

历史数据（location / poo 记录）是这个项目里最不可替代的东西，迁移期间一律按以下原则：

1. **迁移前先归档**旧 `.db` 文件一份。
2. **先在副本上演练**：把每日备份恢复到一个 scratch 目录，在副本上跑完整迁移、核对行数无误，再对真实库动手。
3. **脚本幂等 + 行数对账**，对不上立即中止。
4. **旧文件只读归档、绝不自动删除**，删除是事后人工动作。

---

## M2 — 前端 v2（React SPA）✅ 已完成

### 目标

用 React SPA 取代现有 Jinja 页面，由 FastAPI 同源托管（同一容器、同一 origin）。这一步合并了“前端重写为 React”和“前端做厚”两件原本分开的事——它们本质是同一坨活。

> 备注：React 是一次 agentic programming 试水。之前只手写过 Vue、没手写过 React，这一轮想全程靠 agent、尽量不读代码地把它做出来。OpenAPI 导出 → 生成类型化 TS client 作为 agent 的契约护栏，正好服务这个目标。

### 范围

- **React SPA**，FastAPI 挂载打包后的静态产物（同源，省掉 CORS）。
- **Config 界面**：取代现有 Jinja config 页。
- **数据可视化**：热力图、地图等，接管原先 Grafana 干的事。
- **按需展示 DB 数据**（例如 poo 记录）。
- **记录的小幅增删改**：用于修正不准确的记录。

### 后端配套

- **补一套 JSON API**：SPA 是客户端渲染，需要后端提供 config 读写、数据查询、记录 CRUD 等 JSON 端点。（同源不等于不需要 API——API 是“客户端怎么拿数据”，与文件托管在哪无关。）
- **鉴权**：浏览器面向的新端点（含记录 CRUD）复用现有 session cookie 保护。
- **类型化 client**：用 `scripts/export_openapi.py` 的输出生成 TS client。

### 鉴权边界（与 M3 衔接）

- 现在那个”裸 API 记小狗日志”的 ingestion 端点（设备 / 脚本调用，非浏览器）**维持现状到 M3**。
- M2 新增的、浏览器调用的 CRUD 端点，用 session 保护即可，本步不引入 token。

> **M2 已完成**（M2-T01 至 M2-T13 全部 done）。Jinja 模板已移除，React SPA 同源托管，多阶段 Docker 构建通过，所有校验闸门绿。

---

## M4 — 登录加固（✅ 已完成，排在 M5 之前）

### 目标

给暴露在公网的单 admin 登录做纵深防御，先于 M5 IoT 集成关闭暴力枚举和单因子风险。

### 范围

- **防爆破 / 指数退避**：失败登录按双键（IP + username）指数增长延迟，成功即清零。退避是延迟（429 + Retry-After），不是永久封号。全局开关 `AUTH_LOGIN_THROTTLE_ENABLED`（CONFIG_FIELDS）；反代后需开 `AUTH_TRUST_FORWARDED_FOR`（`.env` 部署级）。
- **CLI 逃生通道**：`python -m scripts.admin_cli` 直连本地 DB，无需 HTTP 服务、无需任何已存凭据；支持重置密码、解锁退避、关停/重发 TOTP、查看用户。
- **可选 TOTP 二次验证**：admin 可自选启用；启用后两步登录（密码 + 6 位动态码或一次性恢复码）；不启用维持纯密码。CLI `disable-totp` 是连恢复码都丢了时的最终逃生口。

> 详细设计与任务卡：[`docs/design/m4-login-hardening.md`](./design/m4-login-hardening.md)

---

## M5 — IoT 集成与能耗采集（✅ 已完成）

### 目标

给后端接入家庭 IoT 生态，建立通用 Modbus 设备采集链路（首个领域：能耗），接入 MQTT + Home Assistant Discovery，并重构前端为侧边导航并新增 Energy 视图。

### 范围

- **两层数据模型（协议与部署分离）**：YAML profile（随代码走，声明协议知识：寄存器/解码/key/unit/ha_component）+ `modbus_device`（部署层 DB 行：friendly_name/host/port/unit_id/profile/poll_interval/enabled）+ `modbus_reading`（通用遥测：device_id + recorded_at + JSON payload）。多设备可共享同一 profile。
- **通用 Modbus-TCP 采集**：pymodbus，APScheduler 后台 job 轮询所有 enabled 设备；per-device `last_poll_at`/`last_poll_ok`；全局开关 `MODBUS_POLLING_ENABLED`（CONFIG_FIELDS）。首个 profile：SDM120 单相电表。
- **手工 CLI 试读**：`scripts/modbus_cli`（`read`/`probe` 两个只读子命令），供受控手工验证，不进自动化。
- **MQTT + HA Discovery**：paho-mqtt 长连接；通用 expose 框架（provider 动态产出可暴露实体目录，元数据从 YAML profile 派生）；`exposed_entity_toggle` 表存逐 key 开关（默认不暴露）；每设备 = 一个 HA device，各量 = sensor entity + online binary_sensor；`unique_id` 锚定于设备 `uuid`（稳定，不随改名变）；配置变更可重连重发 discovery。
- **前端侧边栏**：`AppShell` 侧边导航（Home / Records / Energy / Config + 主题切换 + 注销），移动端可折叠，当前路由高亮。
- **Energy 视图**（`/energy`）：设备 CRUD；最新读数卡片（标签/单位取自 profile metrics 端点）；Recharts 走势图（时间范围 + limit）。
- **Config 页 Accordion**：各大 section 折叠/展开；「Home Assistant Expose」面板勾选可暴露实体 + 连接状态 + 重新发布按钮。
- **API**（`/api/modbus/*` + `/api/expose` + `/api/config/mqtt/test`）：完整 CRUD + readings + metrics + test + expose 勾选 + 重发 discovery。
- `pytest`/`ruff`/`export_openapi` + 前端 `lint/typecheck/test/build` 全绿，`openapi/` 已入库。

### 命名决策（已锁定）

存储/采集/API 全部使用通用 `modbus_*`（`/api/modbus/devices`），**不锁死"电表"**；面向用户的领域呈现叫 **Energy**。接入新 Modbus 设备型号只需新增 YAML profile，无需改表/改 API。

> 详细设计与任务卡：[`docs/design/m5-iot-energy.md`](./design/m5-iot-energy.md)

---

## M6 — 通用电价层 + DSMR 实时数据 + 实时买卖电费计算（✅ 已完成）

### 目标

在 M5 IoT 基建之上，把"电价合同"与"DSMR 实时电表数据"接起来，按 **15 分钟**周期算出**实际买电支出 / 卖电收入**，自己落库留底（不可变、可审计），并通过 MQTT + HA Discovery 反哺 Home Assistant 的 Energy 仪表盘。

### 关键能力

- **通用电价层（两层模型）**：仓库内 YAML profile 定结构（`manual` 固定/双费率、`tibber` 动态电价）；`EnergyContract`（+ 版本）存 UI 可改的数值；price strategy 按 `kind` 出价。一次激活一个合同。改价 = 加新版本，旧版本保留（审计链）。
- **DSMR 实时数据接入**：扩 `MqttManager` 订阅 `dsmr/json`（每秒一帧），整帧 JSON blob 按 10 秒降采样落库（电、气、各相全存；字段 allowlist 不做，blob 天然容纳未来字段）。
- **实时买卖电费计算（两层）**：每 15 分钟用电表累计寄存器差值（`delivered_1/2`、`returned_1/2`）× 当时合同的买/卖价算计量电费（不可变、快照价）。买价：tibber = API `total`（含税全包），manual = `energy_buy_档 + energy_tax`；卖价：tibber = `total − energy_tax − sell_adjust`，manual = `sell_档`（无能源税）。双费率：`_1`=dal/低、`_2`=normal/高（NL 惯例）。日/月/年汇总再加固定费（按月→天）减 heffingskorting（按年→天）。不做净计量。卖价残差（sell_adjust）及能源税等当前数值在真实账单确认后钉死。
- **反哺 Home Assistant**：`_energy_cost_provider` 发 `buy_price_now`、`sell_price_now`、`import_cost_total`（`total_increasing`）、`export_revenue_total`（`total_increasing`）—— 直接挂 HA Energy 仪表盘。
- **Tibber 动态电价**：httpx GraphQL 客户端抓 `QUARTER_HOURLY` 15 分钟价（已 demo 验证 `total=energy+tax`）；启动 + 每小时抓取今明两天价格、幂等 upsert `tibber_price`（hourly trigger，确保每日刷新且可补重试）；仅当 active 合同 kind=tibber 且 token 存在时运行。
- **前端**：Energy 视图新增三个 Tab——Contracts（合同管理，表单按 profile 结构渲染）、Prices（15 分钟价格曲线）、Costs（费用走势/明细/汇总卡片）；Config 页 Tibber 测试三态入口。

### 命名分层（延续 M5）

存储/采集/计算层中性命名（`dsmr_*` / `tibber_price` / `energy_cost_period` / `energy_contract`）；面向用户并入既有 **Energy** 视图。

### 新增 5 张表（单库 app 链，migration `20260623_11_energy_tables`）

| 表 | 关键列 | 说明 |
| --- | --- | --- |
| `dsmr_reading` | `recorded_at`(idx)、`source_id`(unique)、`payload`(JSON) | 整帧 DSMR telegram blob，10 秒降采样 |
| `energy_contract` | `name`、`kind`、`active`、`currency`、时间戳 | 合同头；一次一个 active |
| `energy_contract_version` | `contract_id`(FK)、`effective_from`、`effective_to`(null)、`values`(JSON)、`created_at` | 版本/时段；改价加新版本、旧版本保留 |
| `tibber_price` | `starts_at`(unique)、`resolution`、`energy/tax/total`、`level`、`currency`、`fetched_at` | 15 分钟价缓存，不可变 |
| `energy_cost_period` | `period_start`(unique)、`d1/d2/r1/r2_kwh`、`import_cost/export_revenue/net_cost`、`currency`、`pricing`(JSON 快照)、`contract_version_id`(FK)、`degraded`、`computed_at` | 每 15 分钟计量电费，不可变（快照价+版本） |

### 不新增依赖

httpx / paho-mqtt / pyyaml / apscheduler 均为 M5 已有依赖，M6 复用，不新增任何 Python 包。

> 详细设计与任务卡：[`docs/design/m6-tibber-dynamic-energy.md`](./design/m6-tibber-dynamic-energy.md)

---

## M7 — 电表生命周期 / 换表归档（✅ 已完成）

### 目标

让计费系统正确处理**电表更换**这一必然事件：荷兰 2G 智能电表退网后，电网公司会把表换成 4G 表（同址换表）；搬家继承新表也是同类。引入显式的 **Meter（电表）epoch** 概念，标记"某时刻起属于哪一块物理表"，使引擎**永不跨表算 delta**，并让累计量按表归零、历史可追溯可查。

### 关键能力

- **Meter epoch 数据模型**：一条 `meter` 记录 = 一块物理电表的一段安装期 `[started_at, ended_at)`。换表 = 关闭旧 active 表（`ended_at=T`）+ 新建 `started_at=T` 的表。每 `commodity` 至多一个 active 表；`commodity` 字段预留 `gas`/`heating`。
- **计费永不跨表**：`compute_period` 先查 t0/t1 两端的 `meter_at`；无表覆盖或跨表边界 → **降级**（成本置 0），绝不产负成本或巨额假成本。
- **delta 护栏**：任一寄存器 delta `< 0` 或 `> _MAX_DELTA_KWH`（100 kWh/15min，远超住宅用量）→ 降级。兜底表重置 / DSMR 回绕 / 数据毛刺，与换表无关的异常也一并防住。
- **累计按当前表归零（D2）**：`expose.py` 累计 `import_cost_total`/`export_revenue_total` 锚点 = 当前 active electricity meter 的 `started_at`；换表后累计从零起新序列，不维护跨表偏移。
- **追溯换表可重算**：`started_at` 可在过去；PATCH 修正日期后，触发受影响窗口 `recompute_range` 重判跨表周期归属。
- **Meter CRUD API + 前端管理 UI**：`GET/POST/PATCH /api/energy/meters`（+ 追溯 recompute）；Energy 页新增 Meters tab，展示电表时间线，支持"换表/继承"表单与编辑。

### 新增表与列（单库 app 链，migration `20260625_13_meter_table`）

| 表/列 | 关键设计 |
| --- | --- |
| `meter` | `id`、`label`、`commodity`（默认 `electricity`）、`started_at`、`ended_at`（null=active）、`reason`（`initial`/`meter_swap`/`home_move`/`other`）、`note`、`created_at` |
| `energy_cost_period.meter_id` | nullable FK → `meter.id`；记录每周期归属，便于审计/按表查询/归档 |

迁移含回填：有历史数据时自动创建一条 `reason="initial"` 的初始表，并回填现有 `energy_cost_period.meter_id`；幂等 + 对账（非降级周期回填后 `meter_id IS NULL` 数必须为 0）。

### 已锁定决策摘要

- **D1**：Meter 不绑定 home，`label` 编址 + `commodity` 区分品类。
- **D2**：换表后累计量归零（锚当前表起点），不维护跨表偏移。
- **D3**：合同与电表是独立时间线，合同不引用电表；同址换表时合同自动沿用。
- **D4**：`started_at` 可在过去；有效计费起点 = `max(started_at, 数据起点)`。
- **D5**：跨表边界周期判 degraded（换表常伴随长时间无数据，可接受）。
- **D6**：负/异常大 delta → degraded，通用兜底。
- **D7**：gas/heating 计费不在本里程碑；`commodity` 字段为未来扩展预留。

**已知行为（可接受）**：累计 `_total` 是 `state_class: total`，换表归零时 HA 长期统计可能在换表那一刻记一次性负 blip。已与用户确认先这样、观察后按需处理（`last_reset` 信号见设计文档 §9 留痕，本里程碑不做）。

> 详细设计与任务卡：[`docs/design/m7-meter-epochs-archival.md`](./design/m7-meter-epochs-archival.md)
>
> 模块概念说明：[`docs/meter-epochs.md`](./meter-epochs.md)

---

## M3 — 开放与移动端（远期试水）

### 目标

引入 token 鉴权并做一个 React Native 移动端。**明确是很远期、低投入的试水**——先把 React 前端做出来，之后才会碰移动端，且主要是想试试没做过的 React / React Native。

### 范围

- **OAuth-lite token 签发**：移动端在内置浏览器里用账号密码登录，走一遍类 OAuth 流程，服务端签发一个 bearer token 给 app 存起来使用。（本质是没有第三方的 Authorization Code 简化版。）
- **React Native 移动端**：试水性质。
- **给 ingestion 端点上 token**：把 M2 暂时维持裸奔的设备端点收口到 token 鉴权下。

### 为什么放最后

- 移动端是这一阶段最远期、最不确定的部分。
- token 主要是移动端的前置条件；Web 端 React 用现有 session cookie 即可，不需要为它提前引入 token。

## 下一阶段：已确定要做（尚未拆解为任务卡）

> 这些是 M5 之后**已经定下来要做**的方向——区别于下面的 Future Ideas（仅备忘、未必做）。这里只记到 roadmap 粒度：确定**做什么、为什么**；具体排期、依赖与原子任务，等动手时再展开成 `docs/design/` 的任务卡。**先后顺序未定**，具体排期等动手时再定。

### 1. 前端优化

**动机**：M2 的 React SPA 先把功能跑通，性能 / 体验层面的打磨还没做。这一项**确定要做，但具体优化什么还没定**。

**范围（待定）**：方向先留空，想清楚再细化。可能的候选（仅占位、非承诺）：打包体积与代码分割（M2/M5 构建已提示存在 > 500 kB 的单 chunk）、首屏加载、热力图 / 地图的渲染性能、移动端适配、可访问性等。等确定具体目标后再拆任务卡。

### 2. 设置页生成 Long-lived Token（供 API 调用）

**动机**：浏览器端走 session cookie 即可，但**脚本 / 设备 / 外部程序调用 API** 需要一种长期有效、可随身携带的凭据。在设置页加一组功能，由 admin **手动签发 long-lived token**，之后用它来调 API。

**本次明确的首要目标 = 给现在裸奔的 ingestion 端点上鉴权**（2026-06-27 与用户确认）：

- `POST /location/record`（`app/api/routes/location.py:18`）——位置记录上报。**目前无任何鉴权**。当前数据经 Home Assistant 转发进来，上 token 后 **HA 侧需携带该 token**；也可由其他客户端直接上报。
- `POST /poo/record`（`app/api/routes/poo.py:21`）+ `GET /poo/latest`（`poo.py:57`）——小狗排便记录上报 / 最新查询。**目前无任何鉴权**。
- 这些是设备 / 脚本（非浏览器）端点，session cookie 不适用，正是 long-lived token 的用武之地。（浏览器 CRUD `/api/data/*` 已由 session 保护，不在此列。）

**范围（粗略，待细化）**：

- 设置页新增「API Token」区：生成 / 命名 / 吊销 long-lived token；明文只在**生成时展示一次**，此后只存哈希。
- 后端支持用该 token 鉴权访问 API（与现有 session cookie 并存，互不影响）；给上述 ingestion 端点加 token 鉴权依赖。
- 与 [M3](#m3--开放与移动端远期试水) 的 token 主题相关，但**这条是 Web 设置页手动签发的 PAT 风格**，不依赖移动端 OAuth 流程；两者实现时可复用同一套 token 存储 / 校验。
- 与下面第 3 条「Session 滑动续期」同属 Authentication 主题（一个是设备/脚本的长期凭据，一个是浏览器短会话体验），实现时鉴权层可一并梳理。

### 3. Session 滑动自动续期（Authentication）

**动机**（2026-06-27 与用户确认）：当前 session 是**绝对过期**——登录即定死、活动不续期，满 TTL 必须重新登录，体验割裂。希望改成**滑动续期（sliding / rolling）**：只要用户还在活动就自动延长，提供"在用就不掉线"的体验。

**现状（实现起点，便于快速拾起）**：

- TTL 默认 **12 小时**：`auth_session_ttl_hours`（`app/config.py:38`；配置页 `app/services/config_page.py:45` 可运行时改）。
- 登录时**一次性写死**：`create_session` 设 `expires_at = now + ttl`（`app/services/auth.py:94`）+ cookie `max_age = ttl`（`app/api/routes/api/session.py:153`）。
- 每请求**只读校验、从不延长**：`get_authenticated_session`（`app/services/auth.py:103`）只判断 `expires_at <= now`，过期时仅顺手标 `revoked`。`set_cookie` 只在登录路由调用一次，**无 per-request 中间件**。→ 所以是绝对过期，不是滑动。

**设计要点（待写设计文档时展开）**：

- 校验通过时 bump `expires_at = now + ttl` 并**重发 cookie**（滑动窗口）。
- **写节流**：不要每个请求都写 DB——仅当剩余寿命已过半（或距上次续期 > N 分钟）才续期，避免高频写放大。
- **绝对寿命硬顶**：除滑动 TTL 外再设 `created_at + max_lifetime` 上限，防止"永不过期"的会话（安全考量）。
- 新增配置项：滑动 TTL、绝对寿命上限、续期节流阈值。
- 注意：改动只对**新逻辑生效**，已存在 session 的 `expires_at` 行为按新校验路径走即可；上线前过校验闸门。

## Future Ideas（暂不排期，想到先记下）

> 这里收集**还没排进里程碑、也还没决定要不要做**的想法。不是承诺、也没有先后顺序；想做时再从这里捞出来——先升进上面的「下一阶段」，再细化成 `docs/design/` 的任务卡。

_（暂无条目。）_
