# 进度台 API 完整使用指南

依据 `app/main.py` 的路由、`app/models.py` 的请求模型和 `app/repository.py` 的实际行为编写。涵盖登录、创建任务、写入进度、读取统计、日程、复盘、备份和账户管理。

## 1. 地址与工具

网站根地址记为BASE，不要预先加 `/api`。当前本地测试为 `http://127.0.0.1:8001`，默认Uvicorn端口为8000，服务器可使用实际部署域名 `https://todolist.qingruxu.xyz`。任务列表完整地址是 `BASE + /api/tasks`。

JSON请求发送 `Content-Type: application/json`。日期为 `YYYY-MM-DD`，月份为 `YYYY-MM`；省略参考日期时以北京时间当天为准。可在 `/docs` 查看Swagger，或读取 `/openapi.json`；部分账户接口的dict请求体字段需对照本文。本文网页版为 `/manual/api.html`。

## 2. 认证和CSRF

| 方式 | 读取内容 | 写入内容 |
| --- | --- | --- |
| 普通账户会话（推荐） | 登录返回的Cookie | Cookie + `X-CSRF-Token` |
| 普通账户Bearer | `Authorization: Bearer <账户完整token>` | Bearer + `ppm_csrf` Cookie + 同值 `X-CSRF-Token` |
| 专用Agent | 部署配置的 `PPM_API_TOKEN` | 仅两个Agent接口，不要求CSRF |

**账户token与PPM_API_TOKEN是两套凭证。** 账户token用于普通内容接口；Agent单独校验部署级token，不能拿Agent token去新增普通任务或填写每日记录。

账户模式中，每次密码登录、验证码登录或token轮换都会替换账户唯一token，使旧token及使用它的Cookie失效。脚本登录同一账号可能导致先前网页会话需要重新登录；当前没有独立的长期API key。

账户资料、TOTP设置、管理员账号管理的处理函数直接读取 `ppm_account` Cookie，这些接口请使用Cookie会话，仅Bearer会失败。同时携带两者时中间件优先读取账户Cookie，过期Cookie会遮盖有效Bearer。

旧单密码会话模式使用 `ppm_session`，登录可只提交password，账户Bearer不适用。当前默认账户模式需要username/password；`PPM_ALLOW_NO_AUTH=true`是开发模式开关，不用于服务器公开部署。

### 2.1 密码登录

```http
POST /api/auth/login
Content-Type: application/json

{"username":"你的用户名","password":"你的密码"}
```

成功响应包含authenticated、csrfToken，账户模式还包含username/theme/完整token，设置ppm_account和ppm_csrf Cookie。保存Cookie，在普通POST/PUT/DELETE中发送 `X-CSRF-Token: <csrfToken>`；GET无需此头。登录、注册、验证码登录无需先提供CSRF。

浏览器同源fetch自动管理Cookie；Python示例使用CookieJar；curl以 `-c` 保存Cookie、`-b` 发送Cookie。不要打印或公开登录响应。

### 2.2 Bearer方式（curl）

下方为Bash写法，占位符需替换。Windows使用 `curl.exe`，避免PowerShell同名别名；JSON写入UTF-8文件后以 `--data-binary @文件` 发送，避免中文和引号转义问题。

```bash
# 读取任务，无需CSRF
curl -H "Authorization: Bearer <账户完整token>" \
  "http://127.0.0.1:8001/api/tasks"
# 获取CSRF，同时保存ppm_csrf Cookie
curl -c csrf-cookies.txt "http://127.0.0.1:8001/api/auth/status"
# task.json使用第4节请求字段；csrf值取上一步的csrfToken
curl -b csrf-cookies.txt -H "Authorization: Bearer <账户完整token>" \
  -H "X-CSRF-Token: <csrfToken>" -H "Content-Type: application/json" \
  --data-binary @task.json "http://127.0.0.1:8001/api/tasks"
```

账户模式的auth/status只根据Cookie判断authenticated，仅有Bearer时显示false不代表Bearer失效。CSRF Cookie与请求头必须来自同一次获取，Cookie文件视为凭证保存。

## 3. 可直接运行的完整示例

[examples/api_quickstart.py](../examples/api_quickstart.py)只依赖Python标准库，密码/验证码交互输入，不写入源码或命令历史。它会实际创建示例任务、当天记录、日程和图表，**不是只读测试**。重复执行会创建新的示例资源。

```powershell
python examples/api_quickstart.py --base http://127.0.0.1:8001 --username 你的用户名
python examples/api_quickstart.py --base https://todolist.qingruxu.xyz --username 你的用户名
# 已绑定验证器也可使用验证码登录
python examples/api_quickstart.py --base http://127.0.0.1:8001 --username 你的用户名 --totp
```

输出新建资源ID、记录版本、指标汇总和复盘数值，不输出认证token。刷新网页即可看到内容；示例任务、日程和图表可以在界面管理。

以下Python片段共享client，创建自己的脚本并从项目根目录运行。先初始化，再复制所需片段：

```python
from getpass import getpass
from urllib.parse import urlencode
from examples.api_quickstart import ApiClient

client = ApiClient("http://127.0.0.1:8001")
client.login("你的用户名", getpass("密码："))
```

request(method, path, body)自动携带Cookie及CSRF；失败抛出包含HTTP状态及detail的异常。

## 4. 新增、读取和修改任务

```python
task = client.request("POST", "/api/tasks", {
    "name": "阅读计划", "description": "每周阅读并记录学习时间",
    "startDate": None, "endDate": None, "priority": 3, "state": "active",
    "metrics": [
        {"name": "阅读打卡", "kind": "checkin", "period": "day", "target": 1},
        {"name": "阅读页数", "kind": "quantity", "period": "week", "target": 100, "unit": "页"},
        {"name": "学习时长", "kind": "duration", "period": "week", "target": 7200, "durationUnit": "hour"}
    ]
})
task_id = task["taskId"]
checkin_id, quantity_id, duration_id = [m["metricId"] for m in task["metrics"]]
```

也可将请求保存为task.json供curl调用，Python的None改成JSON的null。创建返回201，直接返回任务对象，不包在items内；保存taskId与每个metricId。

| 字段 | 约束/含义 |
| --- | --- |
| name / description | 名称1–120字符；说明最多2000字符 |
| startDate / endDate | 可省略或null；都有值时截止不能早于开始；无日期显示开放边界 |
| priority / state | 优先级1–5默认3；状态active或paused |
| metrics | 1–20个指标，指标名称1–60字符 |
| kind | checkin打卡 / quantity数量 / duration时长 |
| period | day / week / month / lifetime，默认day |
| target | 正整数或null；打卡未设目标自动为1 |
| unit | 数量单位，最多20字符；时长自动清空此字段 |
| durationUnit | second / minute / hour，仅输入与显示口径，默认minute |

**时长target与记录value始终是整数秒。** durationUnit为hour不会让API自动乘3600；2小时目标填写7200，1小时30分记录填写5400。数量为非负整数，打卡只能记录0或1。

```python
listing = client.request("GET", "/api/tasks")  # {version, items}
task = client.request("GET", f"/api/tasks/{task_id}")
fields = ("name", "description", "startDate", "endDate", "priority", "state", "metrics")
update = {key: task[key] for key in fields}
update["name"] = "阅读与笔记计划"
update["expectedVersion"] = task["version"]
task = client.request("PUT", f"/api/tasks/{task_id}", update)
```

PUT是完整属性更新，不能只发送新名称，也不要提交taskId/revision/时间戳等只读字段，否则未知字段校验失败。已有指标保留metricId；新指标省略ID。省略旧指标会移除其定义，不是保持原值。

## 5. 填写进度、补录与读取统计

### 5.1 写入一天的完整记录

```python
day = "2026-10-01"  # 换成需要记录或补录的日期
path = f"/api/tasks/{task_id}/daily/{day}"
old = client.request("GET", path)
record = client.request("PUT", path, {
    "expectedVersion": old["version"], "note": "读了18页，学习1小时30分。",
    "completedAt": f"{day}T20:30:00+08:00",
    "values": [
        {"metricId": checkin_id, "value": 1},
        {"metricId": quantity_id, "value": 18},
        {"metricId": duration_id, "value": 5400}
    ]
})
```

换成过去日期就是补录。不存在的日记录GET仍返回200、version为0、values为空；首次写入expectedVersion为0，后续使用该日期记录的版本。

**PUT替换当天记录，不追加增量。** 先记录18页，再读5页，应提交23页。values、note、completedAt都替换原内容；省略其他指标会移除其该天数值，省略心得/时间会使用空字符串/null。只改一个指标时先读并合并：

```python
old = client.request("GET", path)
values = dict(old["values"])
values[quantity_id] = 23
record = client.request("PUT", path, {
    "expectedVersion": old["version"], "note": old["note"], "completedAt": old["completedAt"],
    "values": [{"metricId": key, "value": value} for key, value in values.items()]
})
```

expectedVersion虽可省略，建议始终提供以防覆盖并发修改。填写0是已保存零投入，与未记录不同。保存后日记录version增加；心得最多4000字符；completedAt为ISO日期时间或null，建议明确+08:00。没有单独删除某日记录的API。

### 5.2 读取进度与历史

```python
all_progress = client.request("GET", "/api/summary?" + urlencode({"date": day}))
progress = client.request("GET", f"/api/tasks/{task_id}/summary?" + urlencode({"date": day}))
for metric in progress["tasks"][task_id]["metrics"]:
    print(metric["name"], metric["value"], metric["target"], metric["percent"],
          metric["windowStart"], metric["windowEnd"])
history = client.request("GET", f"/api/tasks/{task_id}/history?start=2026-09-01&end=2026-10-01")
```

汇总为 `{date, tasks: {taskId: {...}}}`，单任务也是这个结构。metrics分别给出值、目标、百分比、窗口，时长仍为秒。日/周/月按各自窗口计算，周从周一开始；累计以任务开始日为下界，没有开始日期视为不限起始。晚于参考日期的事实不计入。

无目标percent为null，有目标时最大100%，真实超额仍在value中。recentValue/previousValue是参考日期结束的近7天/前7天投入。任务级progress是兼容字段（有目标指标百分比平均值），当前UI使用独立指标，不建议跨指标比较平均值。

today.recorded表示参考日是否已保存，不表示所有目标完成。history含records及任务属性revisions。查询跨度最多730天（end-start），无分页。汇总读取原始事实，补录后无需手动归档来更新统计。

## 6. 日程的新增、读取、完成与修改

```python
schedule = client.request("POST", "/api/schedules", {
    "title": "整理阅读笔记", "description": "提炼三个要点", "date": day,
    "time": "20:30", "recurrence": "daily", "priority": 3, "state": "active"
})
schedule_id = schedule["scheduleId"]
daily_schedules = client.request("GET", f"/api/schedules/today?date={day}")
calendar = client.request("GET", f"/api/schedules/calendar?month={day[:7]}")
schedule = client.request("POST", f"/api/schedules/{schedule_id}/complete", {
    "date": day, "completed": True, "expectedVersion": schedule["version"]
})
schedule = client.request("POST", f"/api/schedules/{schedule_id}/complete", {
    "date": day, "completed": False, "expectedVersion": schedule["version"]
})  # 取消该天完成
```

名称1–120字符，说明最多2000字符；date必填，time为HH:MM或null（全天）。recurrence为none/daily/weekly/monthly，state为active/archived。只有active日程进入日历；每周按开始日星期重复，每月按同一日号重复，缺少该日号的月份不出现。

GET schedules是定义，today/calendar按日期展开。完成记录按日期保存，不会完成所有重复实例；完成接口返回原始日程和新version，completed字段由展开接口提供。PUT修改需提交全部输入字段与expectedVersion，不能包含scheduleId/completions等只读字段。日程完成与任务打卡独立，不会自动写入任务进度。

## 7. 图表和复盘

```python
chart = client.request("POST", "/api/charts", {
    "title": "学习时长", "taskId": task_id, "metricId": duration_id, "days": 30, "yMax": None
})
chart_id = chart["chartId"]
chart_data = client.request("GET", f"/api/charts/{chart_id}/data?start=2026-09-01&end=2026-10-01")
review = client.request("GET", "/api/review/data?" + urlencode({
    "start": "2026-09-01", "end": "2026-10-01", "taskId": task_id, "metricId": duration_id
}))
notes = client.request("GET", "/api/review/notes?start=2026-09-01&end=2026-10-01")
```

图表days为7–365，yMax为显示单位的正整数参考上限或null；图表存配置，删除图表不删除事实。PUT提交title/taskId/metricId/days/yMax及expectedVersion。图表DELETE直接删除，无图表回收站或版本参数。

图表数据默认以ref或今天结束、按配置days取范围，start/end可覆盖，end优先于ref。review/data不需要先创建图表，start/end必填，taskId/metricId可选；具体指标建议同时提供所属taskId。

连续数据点含date/value/recorded/recordedCount；无记录value为0，recorded区分缺失与零投入。指定metricId时是原始指标量，时长仍为秒；未指定时是当天有非零指标投入的任务数，不是不同指标相加，也不表示所有目标完成。recordedCount为筛选范围内当天有事实记录的任务数。

心得按日期倒序返回days[].records[]，默认近30天，**不接受taskId或文本搜索参数**，这些筛选由客户端处理。历史/心得/复盘跨度上限730天（差值），图表配置days上限365天。页面筛选不会修改图表配置。

## 8. 版本号、排序、删除与恢复

| 操作 | expectedVersion来源 | 传在哪里 |
| --- | --- | --- |
| 任务/日程/图表PUT | 对应资源version | JSON |
| 任务/日程软删除、恢复 | 对应资源最新version | 查询参数 |
| 日记录PUT | 对应日期记录version，首次0 | JSON |
| 日程完成/取消 | 日程version | JSON |
| 任务排序 | GET tasks的顶层列表version | JSON |

每次更新使用响应的新版本，409时重新读取并核对，不要只替换版本号强行覆盖。

```python
listing = client.request("GET", "/api/tasks")
client.request("PUT", "/api/tasks/reorder/all", {
    "taskIds": [t["taskId"] for t in listing["items"]][::-1], "expectedVersion": listing["version"]
})  # 必须包含全部未删除任务，含挂起任务
task = client.request("GET", f"/api/tasks/{task_id}")
deleted = client.request("DELETE", f"/api/tasks/{task_id}?expectedVersion={task['version']}")
task = client.request("POST", f"/api/tasks/{task_id}/restore?expectedVersion={deleted['version']}", {})
```

任务/日程DELETE软删除，读取回收站带includeDeleted=true。永久删除须先软删除，再POST `/api/tasks/{id}/purge` 或 `/api/schedules/{id}/purge`，请求 `{"confirmation":"完整资源ID"}`。任务永久删除会清理其事实与修订，不可恢复。

## 9. 备份、导入、统计与归档

```python
import json
from pathlib import Path
backup = client.request("GET", "/api/export")
Path("progress-export.json").write_text(json.dumps(backup, ensure_ascii=False, indent=2), encoding="utf-8")
stats = client.request("GET", "/api/admin/stats")
archive = client.request("POST", "/api/admin/archive", {})
```

导出含meta/tasks/revisions/daily/archives/schedules/scheduleRevisions/charts等业务字段，不包含账户数据库、密码、token或TOTP密钥。旧备份缺少图表字段仍可导入。admin/stats、archive、import操作当前账户自己的数据，不要求账户管理员身份。

导入整体替换当前账户数据，不追加或合并。先导出目标备份，再明确执行下方代码。接口不是文件上传表单，直接把完整备份JSON作为请求体：

```python
# 确认要覆盖当前账户的数据后执行
payload = json.loads(Path("progress-export.json").read_text(encoding="utf-8"))
result = client.request("POST", "/api/admin/import", payload)
```

## 10. 注册、账户资料与验证码

注册先GET `/api/auth/challenge`，获取challengeId/question；用户解答后5分钟内POST `/api/auth/register`：

```json
{"username":"new-user","password":"至少12个字符的密码","challengeId":"返回的ID","challengeAnswer":"计算结果字符串"}
```

挑战一次性，失败后重新获取；用户名至少3字符，密码至少12字符。成功返回完整token，但**不会设置登录Cookie**，即使返回authenticated=true也不代表已有浏览器会话；继续调用登录接口。

| 方法与路径 | 字段或用途 |
| --- | --- |
| GET `/api/auth/status` | 启用状态、Cookie认证状态、csrfToken；账户模式设置CSRF Cookie |
| POST `/api/auth/logout` | Cookie+CSRF；账户模式清除客户端Cookie，不撤销已发出的Bearer token |
| GET `/api/account/me` | userId、username、tokenPrefix、isAdmin、theme，不返回完整token |
| PUT `/api/account/me` | username/password/theme可选；密码至少12字符；不用expectedVersion |
| POST `/api/account/token/rotate` | 返回新完整token，旧token立即失效；当前不更新登录Cookie，需重新处理会话 |
| POST `/api/auth/totp/login` | username/code，code为6位字符串；设置Cookie并返回csrfToken，不返回完整token |
| POST `/api/account/totp/setup` | password为当前密码，返回secret/otpauth URI/PNG二维码Data URI |
| POST `/api/account/totp/confirm` | code为6位字符串，5分钟内确认 |
| POST `/api/account/totp/disable` | password为当前密码，解除绑定 |

theme支持studio/pixel/cartoon/rick-morty/rose。TOTP采用SHA-1、6位、30秒；确认绑定的验证码已消费，不可立即用同一码登录，已使用的验证码禁止重放。错误有频率限制，secret/uri/二维码均为凭证，勿记录日志。详见[动态验证码登录](动态验证码登录.md)。

## 11. 管理其他账户（仅管理员）

要求管理员账户ppm_account Cookie，写操作还需CSRF：

| 方法与路径 | 用途 |
| --- | --- |
| GET `/api/admin/accounts` | 列表中user_id用作路径参数 |
| PUT `/api/admin/accounts/{user_id}` | username和/或password，密码至少12字符 |
| POST `/api/admin/accounts/{user_id}/token/rotate` | 轮换指定用户token，返回完整新token |
| GET `/api/admin/accounts/{user_id}/resources` | 指定用户资源统计 |
| DELETE `/api/admin/accounts/{user_id}` | 永久删除用户和其目录数据；不能删除当前管理员或admin；无版本号/回收站 |

## 12. 专用Agent API

服务启动环境设置独立PPM_API_TOKEN，再重启进程/容器。Compose需把变量传入容器，只写.env但Compose未引用不会生效；未配置返回503。

```bash
curl -H "Authorization: Bearer <PPM_API_TOKEN配置值>" \
  "http://127.0.0.1:8001/api/agent/overview?date=2026-10-01"
curl -H "Authorization: Bearer <PPM_API_TOKEN配置值>" \
  -H "Content-Type: application/json" --data-binary @schedule.json \
  "http://127.0.0.1:8001/api/agent/schedules"
```

schedule.json使用第6节日程字段。overview返回指定日期的日程和任务进度；Agent没有创建任务或填写进度接口，这些使用普通账户API。

独立Agent客户端不发送账户Cookie，默认操作根仓库（当前默认管理员数据）。携带有效账户Cookie时中间件会切换该账户仓库，但仍须部署级Agent token。不要把部署级token分发给普通用户。

## 13. 内容接口速查与错误处理

| 方法与路径 | 功能 / 参数 |
| --- | --- |
| GET `/api/health` | 公开健康检查 |
| GET `/api/layouts/{page}` | home/task-detail/review，只读布局，无布局写接口 |
| GET / POST `/api/tasks` | 列表/创建；GET支持includeDeleted |
| GET / PUT / DELETE `/api/tasks/{id}` | 读/完整更新/软删；GET支持includeDeleted |
| POST `/api/tasks/{id}/restore`、`/purge` | 恢复/永久删除 |
| PUT `/api/tasks/reorder/all` | 全量ID排序 |
| GET / PUT `/api/tasks/{id}/daily/{date}` | 读/替换当天事实 |
| GET `/api/tasks/{id}/history` | start/end，默认近365天，含修订 |
| GET `/api/summary`、`/api/tasks/{id}/summary` | date参考日期，全部/单任务 |
| GET / POST `/api/schedules` | 定义列表/创建；GET支持includeDeleted |
| GET `/api/schedules/today`、`/calendar` | date展开/month月历 |
| GET / PUT / DELETE `/api/schedules/{id}` | 读/完整更新/软删；GET支持includeDeleted |
| POST `/api/schedules/{id}/complete`、`/restore`、`/purge` | 完成/恢复/永久删除 |
| GET / POST `/api/charts` | 列表/创建配置 |
| GET / PUT / DELETE `/api/charts/{id}` | 读/完整更新/直接删除 |
| GET `/api/charts/{id}/data` | ref/start/end |
| GET `/api/review/data` | start/end必填，taskId/metricId可选 |
| GET `/api/review/notes` | start/end，默认近30天，任务/文本客户端过滤 |
| GET `/api/export` | 业务JSON备份 |
| GET `/api/admin/stats` | 当前账户统计 |
| POST `/api/admin/archive`、`/api/admin/import` | 补归档/整体替换 |

创建任务/日程/图表返回201，读取/更新一般200。错误通常 `{"detail":"说明"}`，模型校验detail可能为数组，要保留字段定位。

| 状态 | 处理 |
| --- | --- |
| 401 | 检查凭证/过期Cookie/登录或轮换导致旧token失效 |
| 403 | 检查CSRF同值Cookie和头，或管理员权限/绑定密码 |
| 404 | 资源不存在、软删未includeDeleted，或当前模式未启用功能 |
| 409 | 版本/用户名/绑定状态冲突，先读最新状态 |
| 422 | 日期、必填/未知字段、整数值、确认ID不合法 |
| 429 | 登录/注册/绑定失败频率限制，按说明等待 |
| 503 | Agent token未配置 |

成功后才使用返回值；有副作用POST不要无条件重试，避免重复创建。列表无分页、服务器文本搜索或批量事实写入接口。生产调用使用HTTPS；导出虽然不含账户凭证，仍可能包含私人说明与心得。
