# 家书 FamilyBook · 移动端 API(v1) > 面向前端(iOS / Android / 小程序)与任何第三方程序。 > 所有写操作与网页共用同一套业务类,权限规则完全一致 —— **App 里建的东西与网页里建的完全一样**。 --- ## 1. 总览 | 项目 | 说明 | |---|---| | 根地址 | `https://<你的站点>/api/v1` | | 鉴权 | `Authorization: Bearer `(见 §2) | | 请求体 | `Content-Type: application/json`(也接受 `application/x-www-form-urlencoded`) | | 响应 | JSON 信封,`Content-Type: application/json; charset=utf-8` | | 时间 | 请求里**两种格式都收**:`2026-11-03 14:30` 与 `2026-11-03T14:30:00+08:00`(ISO 8601);响应里统一是 `YYYY-MM-DD HH:MM:SS`(**服务器本地时区**) | | 分页 | 列表接口支持 `?page=1&per_page=50`;带 `paginate=1` 时返回 `{items,total,page,per_page}`,不带则返回裸数组 | ### 1.1 响应信封 成功: ```json { "ok": true, "data": <任意> } ``` 失败: ```json { "ok": false, "error": "人话错误信息", "code": "bad_request" } ``` | HTTP | `code` | 含义 | |---|---|---| | 200 | — | 成功 | | 201 | — | 已创建(`data` 是新资源) | | 400 | `bad_request` | 请求本身不合法 | | 401 | `unauthorized` | 缺令牌 / 令牌无效、过期、被吊销 / 账号停用 | | 403 | `forbidden` | 令牌有效但没权限(如只读账号写数据) | | 404 | `not_found` | 资源不存在,或者不属于你(**刻意不区分**,不泄露"别人家有没有这个 id") | | 405 | `method_not_allowed` | 该资源不支持这个方法 | | 409 | `conflict` | 唯一性冲突(如同地方同名影院) | | 422 | `unprocessable` | 字段校验不过(缺必填、格式错) | | 503 | `not_installed` | 站点还没安装 | > ⚠️ **一定要按 `ok` 字段判成败**,不要只看 HTTP 码 —— 用 201/200 区分"新建/更新"是约定,但业务成败以 `ok` 为准。 ### 1.2 方法模拟(给不方便发 PATCH/DELETE 的客户端) ``` POST /api/v1/tasks/12 { ... } X-HTTP-Method-Override: DELETE ``` 或把 `_method` 放进请求体: ``` POST /api/v1/tasks/12 { "_method": "DELETE" } ``` 两者等价于 `DELETE /api/v1/tasks/12`。 ### 1.3 连通性自检(**不需要令牌**) ``` GET /api/v1/ping ``` ```json { "ok": true, "data": { "service": "familybook", "api": "v1", "time": "2026-10-12T08:20:07+08:00" } } ``` --- ## 2. 鉴权:拿到并保管令牌 ### 2.1 在网页上签发 **个人设置 → App 接口令牌**(`/index.php?r=settings/me#api`): 1. 填一个设备名(如"我的手机"); 2. 点「生成新令牌」; 3. **明文只显示这一次**,立刻复制到 App 里(库里只存 sha256,取不回来); 4. 随时可以逐枚「吊销」,或「吊销全部」。 ### 2.2 客户端怎么带 ``` Authorization: Bearer fb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` - 令牌以 `fb_` 开头,43 位 base64url(约 256 bit 熵)。 - ⚠️ **只走请求头**,不支持 `?token=` 这种 query 形式 —— URL 会进日志、浏览器历史、Referer,把令牌放进去等于半公开。 - ⚠️ 令牌与 **登录密码**、**DAV 口令**是三样东西,互不代用(吊销 DAV 口令不会影响 App;反之亦然)。 ### 2.3 典型错误 | 症状 | `code` | 处理 | |---|---|---| | 没带 `Authorization` | `unauthorized` | 检查请求头 | | 令牌拼错 / 已被吊销 / 已过期 | `unauthorized` | 让用户重新登录(App 应清掉本地令牌并回到登录态) | | 账号被管理员停用 | `unauthorized` | 提示用户联系家人 | | 只读(guest)账号在写 | `forbidden` | App 侧按 `GET /me` 返回的 `role` 隐藏写入口 | --- ## 3. 接口一览 | 资源 | 路径 | 说明 | |---|---|---| | 我 | `/me` | 当前身份、家庭、模块主题色 | | 令牌 | `/tokens[/{id}]` | 查看 / 吊销自己的令牌 | | 首页 | `/home` | 聚合:今日日程 + 待办 + 未读数 | | 名册 | `/people[/{id}]` | 家庭成员与联系人 | | 通知 | `/notifications` | 未读、列表、标记已读、清空 | | 日程 | `/calendar`、`/calendar/books`、`/events[/{id}]` | 月视图、日历本、日程增删改(含重复三档) | | 纪念日 | `/days[/{id}]` | 生日、结婚纪念日… | | 任务 | `/tasks[/{id}]` | 看板 / 状态 / 已完成三种视图;状态、搬看板、评论 | | 观影 | `/movies[/{id}]`、`/cinemas[/{id}]` | 观影记录、评价、影评评论、影院 | | 旅程 | `/trips[/{id}]`、`/trips/{id}/nodes` | 旅程与行程节点 | | 记账 | `/ledger[/{id}]`、`/ledger/{id}/entries` | 账本与账目 | | 资源 | `/resources[/{id}]` | 证件、账号等资料 | | 读书 | `/books[/{id}]`、`/books/{id}/notes` | 书与读书笔记 | | 笔记 | `/notes[/{id}]` | 随手记(带标签) | > ⚠️ **不含「导航」模块** —— 那个模块本身就是给网页外链用的,App 侧用不到(用户 2026-10-12 明确)。 --- ## 4. 逐个接口 ### 4.1 `GET /me` ```json { "ok": true, "data": { "id": 1, "name": "老杨", "avatar": "https://.../media.php?k=avatars%2F1.jpg", "role": "管理员", "family": { "id": 1, "name": "杨家" }, "colors": { "calendar": "#4A7C82", "tasks": "#A84A28", "...": "..." } } } ``` `colors` 是模块主题色 —— App 顶栏 / 分类色块直接用它,不必自己维护一张表。 ### 4.2 `GET /tokens` · `DELETE /tokens/{id}` · `DELETE /tokens` ```json { "ok": true, "data": [ { "id": 3, "label": "我的手机", "created_at": "2026-10-12 08:00:00", "last_used_at": "2026-10-12 08:20:00", "expires_at": null } ] } ``` `DELETE /tokens/3` 吊销单枚;`DELETE /tokens`(不带 id)吊销全部,返回 `{"revoked": 2}`。 ### 4.3 `GET /home` ```json { "ok": true, "data": { "greeting": "早上好,老杨", "date": "2026-10-12", "weekday": "星期一", "today_events": [ /* 与 /calendar 同形状,只含今天 */ ], "tasks": [ /* 进行中的 + 已过期的待办 */ ], "unread": 2 } } ``` ### 4.4 名册 `/people` | 方法 | 路径 | 说明 | |---|---|---| | GET | `/people?q=&kind=member\|contact` | 列表(`q` 搜姓名/拼音/电话) | | POST | `/people` | 新建:`name`(必填)、`kind`、`gender`、`note`、`phone` | | GET | `/people/{id}` | 详情 | | PATCH | `/people/{id}` | 改:`name`/`gender`/`birthday`/`note`/`phone`(**只改给了的**) | | DELETE | `/people/{id}` | 删除 | ### 4.5 通知 `/notifications` | 方法 | 路径 | 说明 | |---|---|---| | GET | `/notifications` | `{unread, items}` | | POST | `/notifications/read` | 全部标记已读,返回 `{marked:N}` | | DELETE | `/notifications/{id}` | 删一条 | | DELETE | `/notifications` | 清空全部 | ### 4.6 日程 **日历本** ``` GET /calendar/books ``` 返回我可见的日历本(带 `perm`:`owner` / `write` / `read`、`kind`:`custom`/`movie`/`trip`/`lunar`/…)。 > ⚠️ `kind != custom` 的是**派生只读本**,不能往里写日程。 **视图** ``` GET /calendar?from=2026-11-01&to=2026-11-30 ``` 返回区间内所有条目(自定义日程 + 观影 + 行程节点 + 农历/节假日/纪念日), 每条带 `kind` / `ref_id` / `date` / `time` / `end_time` / `all_day` / `color` / `calname` / `href`。 > `kind=lunar` 的是农历刻度,列表里通常应过滤掉(首页就是这么做的)。 **新建** ``` POST /events { "calendar_id": 3, "title": "家长会", "starts_at": "2026-11-03T14:30:00+08:00", "ends_at": "2026-11-03 16:00", "all_day": false, "location": "学校三楼会议室", "note": "带成绩单", "reminder_minutes": [5, 30], "status": "confirmed", "freq": "weekly", "rep_end": "count", "rep_count": 3 } ``` 返回 201 + 新日程(含生成的 `rrule`)。`reminder_minutes` 只认 `5/10/15/30/60/120/240/480/720/1440`(分钟),其余忽略。 > **重复日程**既可以给 `freq` 三件套,也可以直接给 RFC 5545 原文: > `"rrule": "FREQ=WEEKLY;BYDAY=MO,WE;COUNT=6"`。 > `freq` 取值:`daily` / `weekly` / `monthly` / `yearly` / `weekday`(传空串=不重复)。 > `rep_end` 取值:`never`(一直)/ `count`(配 `rep_count`)/ `until`(配 `rep_until`)。 **改 / 删(重复日程的三档范围)** ``` PATCH /events/{id}?scope=once&occ=2026-11-10 DELETE /events/{id}?scope=future&occ=2026-11-10 ``` | `scope` | 含义 | |---|---| | `all`(默认) | 整条系列 | | `once` | 只这一次(改=拆出独立一条;删=把这一天标记为跳过) | | `future` | 这一次及以后(老系列截断,改的话再从这天起建新系列) | > ⚠️ `once` / `future` **必须带 `occ`**(`YYYY-MM-DD`,用户点开的是哪一天)—— > 重复日程每一天都是同一条记录 id,没有 `occ` 就定位不到"这一次"。 ### 4.7 纪念日 `/days` | 方法 | 路径 | 字段 | |---|---|---| | GET | `/days` | 全部(含名册生日派生的那几条) | | POST | `/days` | `title`、`repeat_type`(`solar`/`lunar`/`once`)、`month`、`day`、`is_leap`、`kind`、`note`、`person_ids` | | GET | `/days/{id}` | 详情 | | PATCH | `/days/{id}` | 同上,只改给了的 | | DELETE | `/days/{id}` | 删除 | ### 4.8 任务 `/tasks` **三种视图** ``` GET /tasks # 看板视图(默认) GET /tasks?view=status&status=doing # 跨看板按状态(todo/doing/done) GET /tasks?view=done&category_id=2 # 已完成(可带 keyword) ``` 看板视图返回: ```json { "ok": true, "data": { "boards": [ { "id": 1, "name": "家务", "color": "#A84A28", "tasks": [ { "id": 9, "title": "...", "status": "todo", "due_at": "...", "children": [ /* 子任务 */ ], "child_total": 3, "child_done": 1 } ] } ], "categories": [ { "id": 1, "name": "装修" } ], "status": { "todo": "待办", "doing": "进行中", "done": "已完成" }, "repeat": { "": "不循环", "day": "每天", "...": "..." } } } ``` **写操作** | 方法 | 路径 | 字段 | |---|---|---| | POST | `/tasks` | `title`(必填)、`note`、`due_at`、`repeat_rule`(`''`/`day`/`week`/`month`/`year`)、`parent_id`、`assignee_id`(**默认=你**)、`category_id`、`board_id`、`status`、`shares`(共享给谁的 id 数组) | | PATCH | `/tasks/{id}` | 同上去改;**状态不在这里改** | | POST | `/tasks/{id}/status` | `status`(`todo`/`doing`/`done`)、`position`(可选) | | POST | `/tasks/{id}/move` | `board_id`、`before`(排在这条之前;0=排到末尾) | | DELETE | `/tasks/{id}` | 删除 | | GET / POST | `/tasks/{id}/comments` | 评论列表 / 发评论(`content`) | > ⚠️ **状态只能走 `/status` 子动作**(与网页一致)—— 那条路还要处理"循环任务的下一次也排上"。 > 在 `PATCH` 里传 `status` 会被忽略。 ### 4.9 观影 | 方法 | 路径 | 字段 | |---|---|---| | GET | `/movies?keyword=&page=&per_page=` | 列表 | | POST | `/movies` | `title`(必填)、`starts_at`(必填)、`ends_at`、`location`、`note`、`info`(简介)、`cinema_id`、`attendees`(同行人的 id 数组) | | GET | `/movies/{id}` | 详情(含影院、同行人、海报、我的评价) | | PATCH | `/movies/{id}` | 只改给了的字段 | | DELETE | `/movies/{id}` | 删除 | | POST | `/movies/{id}/review` | 写/改我的评价:`rating`(1–10)、`content`、`feelings`(数组) | | GET | `/movies/{id}/comments` | 影评下的评论 | | POST | `/movies/{id}/comments` | 发评论:`content`、`parent_id`、`target_person` | | GET | `/cinemas` | 影院列表 | | POST | `/cinemas` | `name`(必填)、`place`、`address`、`color`(`#RRGGBB`) | | DELETE | `/cinemas/{id}` | 删除 | ### 4.10 旅程 | 方法 | 路径 | 字段 | |---|---|---| | GET | `/trips?keyword=` | 活动列表 | | POST | `/trips` | `title`(必填)、`note`、`summary`、`is_public` | | GET | `/trips/{id}` | 详情 | | PATCH | `/trips/{id}` | `title`/`note`/`summary`/`is_public` | | DELETE | `/trips/{id}` | 删除(连带各段行程) | | GET | `/trips/{id}/nodes` | 行程节点列表 | | POST | `/trips/{id}/nodes` | 加一段:`title`、`starts_at`、`ends_at`、`place`、`detail`、`node_type`、`status`、`category`、`stage`(新建才认)、`members`、`travel_mode`、`vehicle_id`、`line_name`、`carrier`、`trip_no` | | GET | `/trips/{id}/summary` | 起止汇总、已展示/未成行的段数 | > ⚠️ **编辑行程不能改 `stage`** —— 阶段只由"新建"和详情页那几个显式动作决定 > (与网页同一条纪律,防止"改着改着实际行程变回计划")。 ### 4.11 记账 | 方法 | 路径 | 字段 | |---|---|---| | GET | `/ledger` | 我的账本(含共享给我的) | | POST | `/ledger` | `name`(留空自动命名) | | GET | `/ledger/{id}` | 账本 + 汇总 | | DELETE | `/ledger/{id}` | 删除 | | GET | `/ledger/{id}/entries` | `{items, summary}` | | POST | `/ledger/{id}/entries` | `kind`、`amount`(**元**,字符串或数字)、`title`、`note`、`person_id` | `kind` 取值(与网页一致): | `kind` | 名称 | 方向 | |---|---|---| | `deposit` / `fund` / `stock` / `insurance` / `house` / `car` / `cash` | 存款/基金/股票/保险/房子/车子/现金 | 资产 | | `loan` / `credit` / `owe` / `other` | 贷款/信用卡/欠款/其它 | 负债 | > `amount` 传**元**(如 `"38.50"`)—— 服务端自己转分,不要预先 ×100。 ### 4.12 资源 `/resources` | 方法 | 路径 | 字段 | |---|---|---| | GET | `/resources?owner_id=&keyword=&sort=used` | 列表 | | POST | `/resources` | `title`(必填)、`content`、`category` | | GET | `/resources/{id}` | 详情 | | PATCH | `/resources/{id}` | `title`/`content`/`category` | | DELETE | `/resources/{id}` | 删除 | ### 4.13 读书 | 方法 | 路径 | 字段 | |---|---|---| | GET | `/books?status=&category=&owner_id=&keyword=` | 列表 | | POST | `/books` | `title`(必填)、`author`、`category`、`status` | | GET | `/books/{id}` | 详情(含章节) | | PATCH | `/books/{id}` | `status`(`wish`/`reading`/`done`/`paused`)、`current_page`、`rating` | | DELETE | `/books/{id}` | 删除 | | GET | `/books/{id}/notes` | 读书笔记 | | POST | `/books/{id}/notes` | `content`(必填)、`kind`(`quote`/`note`/…)、`chapter`、`loc` | ### 4.14 笔记 | 方法 | 路径 | 字段 | |---|---|---| | GET | `/notes?keyword=&tag=&page=&per_page=` | `{tags, items}`(`tags` 是标签云,供筛选栏) | | POST | `/notes` | `title`(必填)、`body`、`tags`(字符串或数组) | | GET | `/notes/{id}` | 详情 | | PATCH | `/notes/{id}` | `title`/`body`/`tags` | | DELETE | `/notes/{id}` | 删除 | --- ## 5. 给 App 的开发建议 1. **启动顺序**:`GET /ping`(探连通)→ 本地有令牌就 `GET /me`(探令牌是否还有效)→ 无效则回登录/配置页。 2. **令牌存储**:iOS 存 Keychain,Android 存 EncryptedSharedPreferences;**不要**放进 `UserDefaults` / 明文文件。 3. **多设备**:每台设备一枚令牌(`label` 写设备名),这样"手机丢了"只吊销一枚,不影响平板。 4. **错误码处理**: - 遇到 `401` → 清本地令牌、回到配置页; - 遇到 `403` 且 `GET /me` 的 `role` 是只读 → 隐藏写入口,别让用户白点。 5. **时间**:响应里的时间戳是**服务器时区**的 `YYYY-MM-DD HH:MM:SS`(不是 UTC、没有时区后缀)。 若设备时区与服务器不同,请按"这是本地时间"解析(`DateTimeFormatter` 不套 UTC)。 6. **列表刷新**:`/calendar` 按区间取(月视图一次一个月);其余列表支持 `page`/`per_page`。 7. **图片**:接口返回的 `avatar` / `poster` 等都是**完整可访问 URL**,直接给图片组件用。 --- ## 6. 版本与兼容 - 版本号写在路径里(`/api/v1`)。**不兼容的改动会开 v2**,v1 在可见期内保持兼容。 - 新增字段不算破坏性改动(客户端应忽略不认识的键)。 - 本文件是接口的**唯一权威来源**;实现见 `app/Api.php`(内核)、`app/ApiHandler.php`(资源路由)、`api.php`(入口)。