动机 / 用户故事
TimeFlow 将客户端日程保存在 SQLite,同时将账号日程持久化到云端 PostgreSQL。
当用户重新安装应用、清除本地数据或在新设备登录时,本地业务数据库可能为空,但云端仍保留该账号的日程和周期例外。如果登录后只能进入空日历,而无法从云端重新取得完整数据,用户会误以为历史日程已经丢失。
用户需要的是一条明确、可验证的恢复数据入口:
用户完成登录后,如果客户端确认本地业务数据为空,可以使用当前登录账号的 Bearer Token 请求云端完整日程快照,再由客户端将快照恢复到 SQLite。
客户端将 HTTP 全量快照应用为 SQLite 权威状态的能力,已经由 PR #225 实现并合并。
本 Issue 负责补齐这条链路的上游服务端接口:
Bearer Token
→ GET /api/v1/schedule/snapshot
→ 账号完整 CloudScheduleSnapshot
→ PR #225:applyFullScheduleSnapshotToSqlite()
→ SQLite 日程恢复
本 Issue 只交付后端 HTTP 数据出口,不实现客户端空库判断、接口调用、快照落库或登录后页面编排。
目标用户
重新安装 TimeFlow 后登录原账号的 Android 用户。
清除应用数据后需要恢复历史日程的用户。
在另一台设备登录并需要取得云端日程的用户。
云端已有日程,但当前设备本地业务数据库为空的用户。
当前状态与依赖关系
已完成的下游客户端能力
PR #225 已实现:
定义 ApplyFullScheduleSnapshotCommand。
通过 applyFullScheduleSnapshotToSqlite() 应用 HTTP 全量快照。
将全量快照视为指定账号的完整云端投影。
删除快照中不存在的本地云端日程。
删除快照中不存在的本地周期例外。
接受两个空数组,并将其解释为该账号没有云端日程。
隔离不同账号的 SQLite 日程和周期例外。
拒绝账号不匹配、字段非法或引用不完整的快照。
在 SQLite 事务失败时回滚全部恢复操作。
保留较新的本地 revision 以及对应周期例外。
保留 next_trigger_at、snoozed_until、geofence_armed 和 sync_status 等设备本地运行状态。
保持 WebSocket 增量快照应用行为不变。
本 Issue 与 PR #225 的关系
本 Issue 是 PR #225 的上游服务端配套能力:
虽然 PR #225 已经合并,但端到端恢复能力仍依赖本 Issue 提供后端接口。
本期范围
1. 提供受认证保护的账号快照接口
新增接口:
GET /api/v1/schedule/snapshot
Authorization: Bearer <access_token>
接口约束:
请求方法固定为 GET。
请求不包含 body。
契约不定义任何 query 参数。
复用现有 Bearer Token 认证依赖。
账号身份只能来自已经验证的 Token sub。
不允许从请求体、query 或其他业务字段取得账号身份。
即使调用方附带 account_id,服务端也不得读取或使用它。
缺少 Token 时返回 401 AUTH_REQUIRED。
Token 无效、过期或声明不匹配时返回 401 AUTH_INVALID_TOKEN。
例如:
GET /api/v1/schedule/snapshot?account_id=acc_attacker
Authorization: Bearer <token-for-acc_001>
服务端只能查询 acc_001,不得查询 acc_attacker。
2. 返回完整账号级云端快照
成功响应顶层必须且只能包含:
{
"schedules" : [],
"occurrence_overrides" : []
}
其中:
schedules 包含认证账号全部 active 和 deleted 日程。
occurrence_overrides 包含这些日程的全部周期实例例外。
不允许返回其他账号的日程。
周期例外只能引用同一响应中的当前账号日程。
两个集合必须在同一个 ScheduleUnitOfWork 中完成查询。
两个集合必须一起组装并一起返回。
任一查询、校验或序列化步骤失败时,不得返回部分快照。
完整响应示例:
{
"schedules" : [
{
"id" : " schedule_001" ,
"account_id" : " acc_001" ,
"schedule_type" : " time" ,
"schedule_kind" : " recurring" ,
"title" : " 每周例会" ,
"is_all_day" : false ,
"start_time" : " 2026-08-17T01:00:00+00:00" ,
"end_time" : " 2026-08-17T02:00:00+00:00" ,
"timezone" : " Asia/Shanghai" ,
"recurrence_rule" : " FREQ=WEEKLY;BYDAY=MO" ,
"location_name" : null ,
"latitude" : null ,
"longitude" : null ,
"reminder_type" : " before_start" ,
"reminder_trigger_at" : null ,
"reminder_offset_minutes" : 10 ,
"reminder_strength" : " medium" ,
"reminder_disposition_state" : null ,
"status" : " active" ,
"revision" : 3 ,
"created_at" : " 2026-08-01T03:00:00+00:00" ,
"updated_at" : " 2026-08-10T06:00:00+00:00" ,
"deleted_at" : null
}
],
"occurrence_overrides" : [
{
"id" : " override_001" ,
"schedule_id" : " schedule_001" ,
"occurrence_start" : " 2026-08-24T01:00:00+00:00" ,
"action" : " cancel" ,
"replacement_schedule_id" : null ,
"created_at" : " 2026-08-20T04:00:00+00:00" ,
"updated_at" : " 2026-08-20T04:00:00+00:00"
}
]
}
3. 日程响应字段
每条日程必须返回以下字段:
id
account_id
schedule_type
schedule_kind
title
is_all_day
start_time
end_time
timezone
recurrence_rule
location_name
latitude
longitude
reminder_type
reminder_trigger_at
reminder_offset_minutes
reminder_strength
reminder_disposition_state
status
revision
created_at
updated_at
deleted_at
HTTP 传输模型必须验证:
id 非空。
account_id 非空。
account_id 等于认证 Token 对应账号。
title 非空且最长 255 个 Unicode code point。
revision >= 1。
纬度存在时范围为 -90~90。
经度存在时范围为 -180~180。
created_at 和 updated_at 必须是带时区的 RFC 3339 时间。
start_time、end_time、reminder_trigger_at 和 deleted_at 存在时必须带时区。
同一响应中不得出现重复日程 ID。
HTTP 层只增加传输层、账号归属和响应完整性校验。
以下规则继续由现有日程业务层维护,不在 HTTP 层复制第二套实现:
时间日程和位置日程的创建规则。
全天日程规则。
起止时间业务关系。
IANA 时区业务校验。
周期规则语义。
提醒字段组合规则。
active 和 deleted 状态转换规则。
4. 周期例外响应字段
每条周期例外必须返回:
id
schedule_id
occurrence_start
action
replacement_schedule_id
created_at
updated_at
HTTP 传输层必须验证:
周期例外 ID 非空。
同一响应中不得出现重复周期例外 ID。
同一 schedule_id + occurrence_start 不得出现重复例外。
occurrence_start 必须是带时区的 RFC 3339 时间。
created_at 和 updated_at 必须是带时区的 RFC 3339 时间。
schedule_id 必须引用同一响应中的日程。
被引用的父日程必须是 recurring 日程。
cancel 操作的 replacement_schedule_id 必须为 null。
replace 操作的 replacement_schedule_id 必须引用同一响应中的日程。
不得返回缺少父日程的周期例外。
不得返回跨账号引用。
不得返回错误的 replacement 引用。
5. 稳定排序
响应顺序必须稳定,不能依赖数据库未声明的默认顺序。
日程按以下顺序返回:
start_time 升序;
created_at 升序;
id 升序;
start_time = null 的日程排在最后。
周期例外按以下顺序返回:
occurrence_start 升序;
id 升序。
排序继续复用现有 Schedule Repository 行为,不在 HTTP 层维护第二套排序或 SQL。
6. 空账号行为
认证账号没有任何云端日程时,接口返回:
200 OK
Content-Type: application/json
{
"schedules" : [],
"occurrence_overrides" : []
}
空账号是合法业务状态:
不返回 404。
不增加 has_data。
不增加 is_new_account。
不通过账号 created_at 推断新用户。
不新增初始化标记。
不新增同步状态。
不判断客户端是否应该调用该接口。
7. 空快照与 PR #225 的权威状态语义
PR #225 会将 HTTP 全量快照作为指定账号的权威云端状态应用到 SQLite。
因此,以下两种结果必须严格区分:
200 + 两个空数组
= 服务端已经确认该账号在云端没有日程
= 客户端可以将空快照作为权威状态应用
500 SCHEDULE_SNAPSHOT_INTERNAL_ERROR
= 服务端无法确认账号的完整云端状态
= 客户端不得将其解释为空快照
服务端不得在以下情况返回 200 空数组:
数据库连接失败。
日程查询失败。
周期例外查询失败。
账号归属校验失败。
引用完整性校验失败。
时间字段校验失败。
响应序列化失败。
只取得部分查询结果。
如果服务端错误地把查询异常降级成空快照,PR #225 可能按照权威状态语义清理该账号的本地云端投影。因此这是本接口必须防止的数据安全问题。
8. 统一错误处理
查询、校验或序列化失败时统一返回:
500 Internal Server Error
Content-Type: application/json
{
"error" : {
"code" : " SCHEDULE_SNAPSHOT_INTERNAL_ERROR" ,
"message" : " Schedule snapshot unavailable"
}
}
错误响应和日志不得泄露:
Bearer Token 或 JWT。
JWT 密钥、签名或声明细节。
数据库连接串。
SQL 语句。
数据库异常原文。
损坏字段的原始值。
其他账号 ID。
其他账号的日程内容。
内部日志只记录安全事件标识、异常类型和经过脱敏的栈位置。
9. 业务查询边界
业务层新增账号快照查询结果:
@dataclass (frozen = True , slots = True )
class AccountScheduleSnapshot :
schedules : tuple [ScheduleSnapshot , ...]
occurrence_overrides : tuple [ScheduleOccurrenceOverrideSnapshot , ...]
新增只读端口:
class ScheduleSnapshotReader (Protocol ):
def get_account_snapshot (
self ,
* ,
account_id : str ,
) -> AccountScheduleSnapshot : ...
新增查询服务:
class ScheduleSnapshotQueryService :
def __init__ (
self ,
unit_of_work_factory : ScheduleUnitOfWorkFactory ,
) -> None : ...
def get_account_snapshot (
self ,
* ,
account_id : str ,
) -> AccountScheduleSnapshot : ...
查询服务必须:
拒绝空白 account_id。
复用现有 ScheduleUnitOfWorkFactory。
调用 list_schedules(account_id=..., include_deleted=True)。
调用 list_occurrence_overrides(account_id=...)。
在同一个 UoW 中读取两个集合。
保持只读,不执行 commit()。
不依赖 FastAPI。
不依赖 Pydantic。
不依赖 SQLAlchemy。
不编写第二套 SQL。
10. HTTP 网关边界
新增路由工厂:
def create_schedule_snapshot_router (
reader : ScheduleSnapshotReader ,
authenticated_account : AuthenticatedAccountDependency ,
) -> APIRouter : ...
HTTP 网关负责:
使用现有认证依赖取得认证账号。
使用认证账号调用 ScheduleSnapshotReader。
定义严格响应模型。
校验账号归属。
校验带时区的时间字段。
校验重复 ID。
校验周期例外父日程和 replacement 引用。
映射统一的 500 错误。
在 OpenAPI 中记录 401 和 500 响应。
复用现有 AuthErrorEnvelope。
不解析第二次 JWT。
不直接导入 timeflow.data。
不读取 query 中的 account_id。
11. 组合根装配
create_app() 增加可替换的快照读取端口:
def create_app (
* ,
audio_sink : AudioSink | None = None ,
auth_access : AuthAccess | None = None ,
access_token_service : AccessTokenService | None = None ,
engine : Engine | None = None ,
auth_rate_limiter : AuthRateLimiter | None = None ,
schedule_snapshot_reader : ScheduleSnapshotReader | None = None ,
) -> FastAPI : ...
生产装配必须:
复用现有数据库 Session Factory。
使用 SqlAlchemyScheduleUnitOfWork 构建真实查询服务。
复用认证接口使用的同一个 Token 服务。
复用 create_authenticated_account_dependency()。
注册 /api/v1/schedule/snapshot。
允许测试注入 ScheduleSnapshotReader 替身。
只在组合根中连接业务端口与数据适配器。
预计文件范围
新增或修改:
文件
操作
职责
backend/src/timeflow/business/calendar/snapshot.py
新增
定义账号快照结果、读取端口和查询服务
backend/src/timeflow/business/calendar/__init__.py
修改
导出快照查询公共接口
backend/src/timeflow/gateway/http/schedule_snapshot.py
新增
定义响应模型、校验、路由和错误映射
backend/src/timeflow/gateway/http/__init__.py
修改
导出快照 HTTP 接口
backend/src/timeflow/main.py
修改
装配查询服务、认证依赖和路由
backend/tests/business/calendar/test_schedule_snapshot.py
新增
查询服务测试
backend/tests/gateway/http/test_schedule_snapshot.py
新增
HTTP 契约和安全测试
backend/tests/test_app_wiring.py
修改
组合根和共享 Token 服务测试
backend/tests/test_auth_integration.py
修改
真实 PostgreSQL 集成测试
明确不修改:
frontend/
backend/alembic/
backend/src/timeflow/data/models.py
backend/src/timeflow/data/repositories/schedule.py
明确不做
不修改 PR fix(schedule): apply HTTP full snapshot as authoritative SQLite state #225 已实现的 SQLite 全量快照应用逻辑。
不在本 Issue 中实现客户端接口调用。
不判断客户端 SQLite 是否为空。
不把 HTTP 响应写入客户端 SQLite。
不实现登录成功后的恢复编排。
不实现日历刷新。
不增加手动恢复按钮。
不修改 AppRoot 页面门禁。
不实现增量同步。
不实现分页快照。
不实现游标同步。
不实现冲突合并。
不新增 has_data。
不新增 is_new_account。
不新增初始化标记。
不新增同步状态。
不新增或修改 PostgreSQL 模型。
不新增或修改 Alembic 迁移。
不修改 Schedule 字段。
不修改现有日程业务规则。
不新增第二套 SQL 或 Repository。
不读取客户端本地数据。
不让 query 中的 account_id 覆盖 Token 身份。
不返回其他账号的任何数据。
不在单条数据损坏时跳过该记录并返回剩余数据。
不返回部分快照。
关键决策与依据
决策一:提供账号级全量快照,不实现增量恢复
备选方案:提供分页、游标或基于 revision 的增量接口。
选择方案:返回认证账号当前完整的日程和周期例外。
选择理由:本接口服务于本地业务数据为空时的基线恢复。客户端缺少完整基线时不能安全应用增量数据。
决策二:账号身份只来自 Token sub
备选方案:从 query 或请求体读取 account_id。
选择方案:只使用认证依赖验证后的 Token sub。
选择理由:调用方可以伪造请求参数,不能将其作为可信账号身份来源。
决策三:复用现有 Schedule Repository 和 UoW
备选方案:为快照接口编写专用 SQL。
选择方案:复用 list_schedules(include_deleted=True) 和 list_occurrence_overrides()。
选择理由:现有 Repository 已维护账号隔离、软删除范围和稳定排序;复制查询会形成第二套数据语义。
决策四:两个集合在同一个 UoW 中读取
备选方案:分别读取日程和周期例外,并在部分失败时返回成功部分。
选择方案:在同一个 ScheduleUnitOfWork 中读取并组装两个集合。
选择理由:客户端恢复需要内部一致的完整快照,不能接受两个集合来自不同读取边界或只返回其中一部分。
决策五:空账号使用两个空数组表达
备选方案:返回 404、特殊新用户响应或额外状态字段。
选择方案:返回 200 和两个空数组。
选择理由:没有云端日程是合法业务状态,不需要增加账号分类或同步状态。
决策六:完整快照失败时不得降级为空结果
决策七:HTTP 层只补充传输和跨记录约束
备选方案:在 HTTP 层复制全部日程业务规则。
选择方案:HTTP 层只验证账号归属、时间格式、字段边界、重复 ID 和周期例外引用。
选择理由:日程创建、修改、周期和提醒组合规则已经由现有业务层维护,复制规则会造成语义漂移。
决策八:认证依赖继续复用现有实现
备选方案:快照路由自行解析 JWT。
选择方案:复用 create_authenticated_account_dependency() 和现有认证错误处理器。
选择理由:避免重复认证实现,并确保 /auth/access 与快照接口使用同一 Token 语义。
基本概念
云端全量快照 :认证账号当前全部日程和周期例外组成的完整只读集合。
认证账号 :Bearer Token 验证成功后,从 Token sub 取得的账号身份。
快照读取器 :业务层暴露的账号级只读端口。
完整结果 :日程和周期例外在同一 UoW 中查询、一起校验并一起返回。
部分快照 :只返回其中一个集合,或者跳过损坏记录后返回剩余数据;本接口禁止这种行为。
空账号 :云端确认不存在该账号日程和周期例外的合法状态。
权威状态 :PR fix(schedule): apply HTTP full snapshot as authoritative SQLite state #225 将 HTTP 全量快照解释为该账号当前完整云端投影。
设备本地运行状态 :next_trigger_at、snoozed_until、geofence_armed 和 sync_status 等不属于云端快照的数据。
与其他 Proposal、Issue 和 PR 的关系
待后续客户端 Issue 决策
以下内容不阻塞本 Issue:
客户端在登录流程的哪个阶段检查本地业务数据库。
如何定义“本地业务数据为空”。
由哪个应用服务发起 HTTP 快照请求。
快照落库失败后的重试策略。
快照恢复失败时如何向用户提示。
本地已有业务数据时是否允许手动恢复。
完成恢复后如何刷新日历页面。
恢复后如何重新调度本地提醒。
服务端不得根据这些客户端决策改变本期请求或响应结构。
接口示例
已有云端数据
请求:
GET /api/v1/schedule/snapshot
Authorization: Bearer <valid-token>
响应:
200 OK
Content-Type: application/json
{
"schedules" : [
{
"id" : " schedule_001" ,
"account_id" : " acc_001" ,
"schedule_type" : " time" ,
"schedule_kind" : " once" ,
"title" : " Cloud schedule" ,
"is_all_day" : false ,
"start_time" : " 2026-08-17T01:00:00+00:00" ,
"end_time" : null ,
"timezone" : " Asia/Shanghai" ,
"recurrence_rule" : null ,
"location_name" : null ,
"latitude" : null ,
"longitude" : null ,
"reminder_type" : null ,
"reminder_trigger_at" : null ,
"reminder_offset_minutes" : null ,
"reminder_strength" : null ,
"reminder_disposition_state" : null ,
"status" : " active" ,
"revision" : 1 ,
"created_at" : " 2026-08-14T03:00:00+00:00" ,
"updated_at" : " 2026-08-14T03:00:00+00:00" ,
"deleted_at" : null
}
],
"occurrence_overrides" : []
}
空账号
200 OK
Content-Type: application/json
{
"schedules" : [],
"occurrence_overrides" : []
}
缺少 Token
401 Unauthorized
Content-Type: application/json
{
"error" : {
"code" : " AUTH_REQUIRED" ,
"message" : " Authentication required"
}
}
Token 无效
401 Unauthorized
Content-Type: application/json
{
"error" : {
"code" : " AUTH_INVALID_TOKEN" ,
"message" : " Invalid access token"
}
}
查询、校验或序列化失败
500 Internal Server Error
Content-Type: application/json
{
"error" : {
"code" : " SCHEDULE_SNAPSHOT_INTERNAL_ERROR" ,
"message" : " Schedule snapshot unavailable"
}
}
测试要求
业务查询测试
必须验证:
在同一个 UoW 中读取两个集合。
查询使用认证账号 ID。
list_schedules() 使用 include_deleted=True。
list_occurrence_overrides() 读取账号全部周期例外。
查询过程不执行 commit()。
空账号返回两个空 tuple。
空白账号 ID 被拒绝。
业务层不依赖 FastAPI、Pydantic 或 SQLAlchemy。
HTTP 路由测试
必须验证:
有效 Token 返回完整响应结构。
query 中伪造的 account_id 不改变查询账号。
缺少 Token 时不调用读取器。
无效 Token 时不调用读取器。
active 和 deleted 日程同时返回。
空账号返回 200 和两个空数组。
跨账号日程触发脱敏 500。
重复日程 ID 触发脱敏 500。
重复周期例外 ID 触发脱敏 500。
重复 schedule_id + occurrence_start 触发脱敏 500。
缺失父日程触发脱敏 500。
父日程不是 recurring 时触发脱敏 500。
错误 replacement 引用触发脱敏 500。
revision < 1 触发脱敏 500。
无时区 created_at 触发脱敏 500。
无时区 updated_at 触发脱敏 500。
无时区 occurrence_start 触发脱敏 500。
数据库异常内容不出现在响应中。
数据库连接串不出现在日志中。
查询失败不会返回空快照或部分快照。
组合根测试
必须验证:
create_app() 允许注入 schedule_snapshot_reader。
快照路由复用应用中的 Token 服务。
测试注入读取器时不会意外创建数据库连接。
OpenAPI 中存在 /api/v1/schedule/snapshot。
OpenAPI 记录成功、401 和 500 响应。
PostgreSQL 集成测试
必须验证:
通过认证接口取得真实 Bearer Token。
使用真实 PostgreSQL 创建账号日程。
有效 Token 可以取得该账号的日程。
空账号返回两个空数组。
响应中不存在其他账号数据。
质量门禁
从 backend/ 执行:
需要保证以下检查全部通过:
uv run ruff check .
uv run ruff format --check .
uv run mypy
uv run pytest
uv run alembic heads
要求:
Ruff 无错误。
格式检查无差异。
mypy 无错误。
pytest 全部通过。
分支覆盖率不低于仓库要求。
架构检查通过。
Alembic 只有一个 head。
范围检查:
git diff --check upstream/main...HEAD
git diff --name-only upstream/main...HEAD
不得出现:
frontend/
backend/alembic/
backend/src/timeflow/data/models.py
backend/src/timeflow/data/repositories/schedule.py
新的客户端 SQLite 检测
新的恢复按钮
新的 AppRoot 恢复编排
后续验收方向
动机 / 用户故事
TimeFlow 将客户端日程保存在 SQLite,同时将账号日程持久化到云端 PostgreSQL。
当用户重新安装应用、清除本地数据或在新设备登录时,本地业务数据库可能为空,但云端仍保留该账号的日程和周期例外。如果登录后只能进入空日历,而无法从云端重新取得完整数据,用户会误以为历史日程已经丢失。
用户需要的是一条明确、可验证的恢复数据入口:
客户端将 HTTP 全量快照应用为 SQLite 权威状态的能力,已经由 PR #225 实现并合并。
本 Issue 负责补齐这条链路的上游服务端接口:
本 Issue 只交付后端 HTTP 数据出口,不实现客户端空库判断、接口调用、快照落库或登录后页面编排。
目标用户
当前状态与依赖关系
已完成的下游客户端能力
PR #225 已实现:
ApplyFullScheduleSnapshotCommand。applyFullScheduleSnapshotToSqlite()应用 HTTP 全量快照。next_trigger_at、snoozed_until、geofence_armed和sync_status等设备本地运行状态。本 Issue 与 PR #225 的关系
本 Issue 是 PR #225 的上游服务端配套能力:
虽然 PR #225 已经合并,但端到端恢复能力仍依赖本 Issue 提供后端接口。
本期范围
1. 提供受认证保护的账号快照接口
新增接口:
接口约束:
GET。sub。account_id,服务端也不得读取或使用它。401 AUTH_REQUIRED。401 AUTH_INVALID_TOKEN。例如:
服务端只能查询
acc_001,不得查询acc_attacker。2. 返回完整账号级云端快照
成功响应顶层必须且只能包含:
{ "schedules": [], "occurrence_overrides": [] }其中:
schedules包含认证账号全部active和deleted日程。occurrence_overrides包含这些日程的全部周期实例例外。ScheduleUnitOfWork中完成查询。完整响应示例:
{ "schedules": [ { "id": "schedule_001", "account_id": "acc_001", "schedule_type": "time", "schedule_kind": "recurring", "title": "每周例会", "is_all_day": false, "start_time": "2026-08-17T01:00:00+00:00", "end_time": "2026-08-17T02:00:00+00:00", "timezone": "Asia/Shanghai", "recurrence_rule": "FREQ=WEEKLY;BYDAY=MO", "location_name": null, "latitude": null, "longitude": null, "reminder_type": "before_start", "reminder_trigger_at": null, "reminder_offset_minutes": 10, "reminder_strength": "medium", "reminder_disposition_state": null, "status": "active", "revision": 3, "created_at": "2026-08-01T03:00:00+00:00", "updated_at": "2026-08-10T06:00:00+00:00", "deleted_at": null } ], "occurrence_overrides": [ { "id": "override_001", "schedule_id": "schedule_001", "occurrence_start": "2026-08-24T01:00:00+00:00", "action": "cancel", "replacement_schedule_id": null, "created_at": "2026-08-20T04:00:00+00:00", "updated_at": "2026-08-20T04:00:00+00:00" } ] }3. 日程响应字段
每条日程必须返回以下字段:
idaccount_idschedule_typeschedule_kindtitleis_all_daystart_timeend_timetimezonerecurrence_rulelocation_namelatitudelongitudereminder_typereminder_trigger_atreminder_offset_minutesreminder_strengthreminder_disposition_statestatusrevisioncreated_atupdated_atdeleted_atHTTP 传输模型必须验证:
id非空。account_id非空。account_id等于认证 Token 对应账号。title非空且最长 255 个 Unicode code point。revision >= 1。-90~90。-180~180。created_at和updated_at必须是带时区的 RFC 3339 时间。start_time、end_time、reminder_trigger_at和deleted_at存在时必须带时区。HTTP 层只增加传输层、账号归属和响应完整性校验。
以下规则继续由现有日程业务层维护,不在 HTTP 层复制第二套实现:
4. 周期例外响应字段
每条周期例外必须返回:
idschedule_idoccurrence_startactionreplacement_schedule_idcreated_atupdated_atHTTP 传输层必须验证:
schedule_id + occurrence_start不得出现重复例外。occurrence_start必须是带时区的 RFC 3339 时间。created_at和updated_at必须是带时区的 RFC 3339 时间。schedule_id必须引用同一响应中的日程。cancel操作的replacement_schedule_id必须为null。replace操作的replacement_schedule_id必须引用同一响应中的日程。5. 稳定排序
响应顺序必须稳定,不能依赖数据库未声明的默认顺序。
日程按以下顺序返回:
start_time升序;created_at升序;id升序;start_time = null的日程排在最后。周期例外按以下顺序返回:
occurrence_start升序;id升序。排序继续复用现有 Schedule Repository 行为,不在 HTTP 层维护第二套排序或 SQL。
6. 空账号行为
认证账号没有任何云端日程时,接口返回:
{ "schedules": [], "occurrence_overrides": [] }空账号是合法业务状态:
has_data。is_new_account。created_at推断新用户。7. 空快照与 PR #225 的权威状态语义
PR #225 会将 HTTP 全量快照作为指定账号的权威云端状态应用到 SQLite。
因此,以下两种结果必须严格区分:
服务端不得在以下情况返回
200空数组:如果服务端错误地把查询异常降级成空快照,PR #225 可能按照权威状态语义清理该账号的本地云端投影。因此这是本接口必须防止的数据安全问题。
8. 统一错误处理
查询、校验或序列化失败时统一返回:
{ "error": { "code": "SCHEDULE_SNAPSHOT_INTERNAL_ERROR", "message": "Schedule snapshot unavailable" } }错误响应和日志不得泄露:
内部日志只记录安全事件标识、异常类型和经过脱敏的栈位置。
9. 业务查询边界
业务层新增账号快照查询结果:
新增只读端口:
新增查询服务:
查询服务必须:
account_id。ScheduleUnitOfWorkFactory。list_schedules(account_id=..., include_deleted=True)。list_occurrence_overrides(account_id=...)。commit()。10. HTTP 网关边界
新增路由工厂:
HTTP 网关负责:
ScheduleSnapshotReader。AuthErrorEnvelope。timeflow.data。account_id。11. 组合根装配
create_app()增加可替换的快照读取端口:生产装配必须:
SqlAlchemyScheduleUnitOfWork构建真实查询服务。create_authenticated_account_dependency()。/api/v1/schedule/snapshot。ScheduleSnapshotReader替身。预计文件范围
新增或修改:
backend/src/timeflow/business/calendar/snapshot.pybackend/src/timeflow/business/calendar/__init__.pybackend/src/timeflow/gateway/http/schedule_snapshot.pybackend/src/timeflow/gateway/http/__init__.pybackend/src/timeflow/main.pybackend/tests/business/calendar/test_schedule_snapshot.pybackend/tests/gateway/http/test_schedule_snapshot.pybackend/tests/test_app_wiring.pybackend/tests/test_auth_integration.py明确不修改:
frontend/backend/alembic/backend/src/timeflow/data/models.pybackend/src/timeflow/data/repositories/schedule.py明确不做
AppRoot页面门禁。has_data。is_new_account。account_id覆盖 Token 身份。关键决策与依据
决策一:提供账号级全量快照,不实现增量恢复
决策二:账号身份只来自 Token
subaccount_id。sub。决策三:复用现有 Schedule Repository 和 UoW
list_schedules(include_deleted=True)和list_occurrence_overrides()。决策四:两个集合在同一个 UoW 中读取
ScheduleUnitOfWork中读取并组装两个集合。决策五:空账号使用两个空数组表达
200和两个空数组。决策六:完整快照失败时不得降级为空结果
决策七:HTTP 层只补充传输和跨记录约束
决策八:认证依赖继续复用现有实现
create_authenticated_account_dependency()和现有认证错误处理器。/auth/access与快照接口使用同一 Token 语义。基本概念
sub取得的账号身份。next_trigger_at、snoozed_until、geofence_armed和sync_status等不属于云端快照的数据。与其他 Proposal、Issue 和 PR 的关系
待后续客户端 Issue 决策
以下内容不阻塞本 Issue:
服务端不得根据这些客户端决策改变本期请求或响应结构。
接口示例
已有云端数据
请求:
响应:
{ "schedules": [ { "id": "schedule_001", "account_id": "acc_001", "schedule_type": "time", "schedule_kind": "once", "title": "Cloud schedule", "is_all_day": false, "start_time": "2026-08-17T01:00:00+00:00", "end_time": null, "timezone": "Asia/Shanghai", "recurrence_rule": null, "location_name": null, "latitude": null, "longitude": null, "reminder_type": null, "reminder_trigger_at": null, "reminder_offset_minutes": null, "reminder_strength": null, "reminder_disposition_state": null, "status": "active", "revision": 1, "created_at": "2026-08-14T03:00:00+00:00", "updated_at": "2026-08-14T03:00:00+00:00", "deleted_at": null } ], "occurrence_overrides": [] }空账号
{ "schedules": [], "occurrence_overrides": [] }缺少 Token
{ "error": { "code": "AUTH_REQUIRED", "message": "Authentication required" } }Token 无效
{ "error": { "code": "AUTH_INVALID_TOKEN", "message": "Invalid access token" } }查询、校验或序列化失败
{ "error": { "code": "SCHEDULE_SNAPSHOT_INTERNAL_ERROR", "message": "Schedule snapshot unavailable" } }测试要求
业务查询测试
必须验证:
list_schedules()使用include_deleted=True。list_occurrence_overrides()读取账号全部周期例外。commit()。HTTP 路由测试
必须验证:
account_id不改变查询账号。200和两个空数组。schedule_id + occurrence_start触发脱敏 500。revision < 1触发脱敏 500。created_at触发脱敏 500。updated_at触发脱敏 500。occurrence_start触发脱敏 500。组合根测试
必须验证:
create_app()允许注入schedule_snapshot_reader。/api/v1/schedule/snapshot。PostgreSQL 集成测试
必须验证:
质量门禁
从
backend/执行:需要保证以下检查全部通过:
要求:
范围检查:
不得出现:
frontend/backend/alembic/backend/src/timeflow/data/models.pybackend/src/timeflow/data/repositories/schedule.pyAppRoot恢复编排后续验收方向
GET /api/v1/schedule/snapshot已注册并受 Bearer Token 保护subaccount_id不会改变查询账号schedules和occurrence_overrides200和两个空数组200空快照CloudScheduleSnapshot一致