界面(用户 2026-10-12 第 1~5 条) 1. 新建日程「日历本」并入 .cs 自绘下拉,颜色画进候选项,三字与下拉同行。 2. 新建日程「开始/结束」改「日期范围:」,删旧日期控件; 重复相关三行仅选重复时出现;删「(含第一次)」与「这段时间算忙碌」。 3. 条目浮层加「地点」、去掉「状态」行;首页红线按分钟定位(原只取整小时); 去掉左侧红点只留红线。 4. 任务页删掉状态说明提示(STATUS_HINT 三值清空)。 5. 新建任务:标题在前树框在后;截止时间改最新日历控件;负责人默认自己; 删冗余提示;「上面要做什么」→「任务名称」;清理括号说明。 移动端 API(用户第 6 条:导航模块除外,其余全量) - 新增 Bearer 令牌鉴权(api_tokens 表,Schema v32),与登录口令 / DAV 口令三套分立。 - Api.php 内核(鉴权/取参/信封/分页/分发 + _method 覆写)+ ApiHandler.php 17 条资源 + ApiToken.php + api.php 入口。 - 路由:router.php / .htaccess / index.php 三处 /api/ 兜底(部署脚本自动追加线上规则)。 - 个人设置新增「App 接口令牌」卡;docs/API.md 接口文档; docs/API-QUESTION-LIST.md 自主决策与待确认缺口清单。 验证:本地 e2e 1510/0、v43 真机 33/0、v43 反向 9/0、 v44 真机 41/0、v44 API 63/0、v44 反向 19/0;全流水线 PUSH_RC=0。
411 lines
16 KiB
Markdown
411 lines
16 KiB
Markdown
# 家书 FamilyBook · 移动端 API(v1)
|
||
|
||
> 面向前端(iOS / Android / 小程序)与任何第三方程序。
|
||
> 所有写操作与网页共用同一套业务类,权限规则完全一致 —— **App 里建的东西与网页里建的完全一样**。
|
||
|
||
---
|
||
|
||
## 1. 总览
|
||
|
||
| 项目 | 说明 |
|
||
|---|---|
|
||
| 根地址 | `https://<你的站点>/api/v1` |
|
||
| 鉴权 | `Authorization: Bearer <token>`(见 §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`(入口)。 |