Files
laoyang 075ac6bbeb v44 第 44 批:界面 5 条 + 移动端 API v1 全量
界面(用户 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。
2026-10-12 15:31:03 +08:00

411 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 家书 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`(入口)。