v65+v66 服务端:笔记/纪念日两处数据丢失 + 旅程车辆与节点类型 API

## v65:两处**静默数据丢失**(网页端凑巧没暴露,走 API 的客户端全中)
1. 笔记:`Note::create()` / `Note::update()` 读的是 **`blocks`**,而 `notes()`
   传的是 `body` ⇒ 新建的笔记**只有标题、一个字都没有**;`PATCH` 只改标题时
   更是把**整条正文的块全删掉**(`update()` 是整组替换)。
   ⇒ 新增 `noteBlocksIn()`:请求给 `blocks`/`body` 就按请求替换,
      都没给(只想改标题/标签)就**沿用库里的块**。
2. 纪念日:`Anniversary::find()` 的 SQL 不含 people,原来那个兜底
   `$cur['people'] ?? []` 恒为空 ⇒ **只改标题的 PATCH 会把关联人全清掉**。
   ⇒ 新增 `annivPeopleOf()` 真去查一次。

## v66:旅程的三项设置接口(端上原来无 API 可调)
- `vehicles`:GET / POST / PATCH / DELETE(`/api/v1/vehicles[/<id>]`)
  · `vehicleUsedCount()`:还有行程在用就不让删
  · `vehicleInput($cur)`:请求优先、缺了沿用库里(部分更新不把别的字段清空)
- `nodeTypes`:同上;列表走 `NodeType::all()`(内部自愈补内置类型)
  · 内置类型不让删(`unprocessable` 422 + 服务端原话,不是 500)
- 两条路由**与 trips 平级**(`case 'vehicles':` / `case 'nodetypes':`)——
  `Api::dispatch()` 只解析 `/<res>/<id>/<sub>` **3 段**,写成 `trips/vehicles`
  会被当成列表请求
- `/ping` 的 features 加 `trips_vehicles` / `trips_nodetypes`(端上可探测老服务端)

## 验证
- `php -l app/ApiHandler.php` 无错
- `familybook_push.sh`:部署完成;线上核对 `vehicles: 1` / `nodetypes: 1`,
  无令牌打 `/vehicles` 回 **401**(不是 404 ⇒ 路由真的存在)
- local smoke:v43…v66 全部版本套件逐个绿(含几何/交互/DAV/vCard)
- ⚠️ 流水线最后三步因**工具问题**假红,已修工具并单独复跑全绿(与本次代码无关):
  · `_fb_login_api.py` 收尾删自己的 273 文件副本,撞上执行环境的批量删除钩子
    ⇒ 进程被砍、退出码 1。`familybook_push.sh` 现在 `export FB_NO_PURGE=1`
    (副本改名挪走不真删)⇒ 复跑 **30 / 0**
  · `_fb_v57_falsify.py` 只拷 `ets/`,而守卫 v66 起要读 `resources/…/form_config.json`
    ⇒ 副本基线不绿。改成拷整个 `src/main` ⇒ 复跑 **12 / 0**
  · 另两条单独复跑:设备会话接口 **56 / 0**、设备管理网页渲染 **23 / 0**
This commit is contained in:
2026-10-20 18:32:06 +08:00
parent 3459eea8f3
commit 1bc313adc6
+285 -7
View File
@@ -61,6 +61,11 @@ final class ApiHandler
⇒ `movies_douban` = `/movies/douban`(suggest / 抓取 / 缩略图代理); ⇒ `movies_douban` = `/movies/douban`(suggest / 抓取 / 缩略图代理);
`movie_media` = `/movies/<id>/poster|photos`(multipart 上传)。 `movie_media` = `/movies/<id>/poster|photos`(multipart 上传)。
端上可据此探测:老服务端没有这两条时不显示「自动获取」与传图入口。 */ 端上可据此探测:老服务端没有这两条时不显示「自动获取」与传图入口。 */
/* 🔴 v66:旅程「车辆管理 / 节点类型」两条 CRUD 接口。
端上据此探测:老服务端没有它们时,设置菜单里那两项
只是说明,不显示增删改按钮。 */
'trips_vehicles' => 1,
'trips_nodetypes' => 1,
'movies_douban' => 1, 'movies_douban' => 1,
'movie_media' => 1, 'movie_media' => 1,
// v58:同步可以"先问一句哪些模块变了",只拉变了的 // v58:同步可以"先问一句哪些模块变了",只拉变了的
@@ -97,6 +102,12 @@ final class ApiHandler
case 'movies': $this->movies($id, $sub, $method); return; case 'movies': $this->movies($id, $sub, $method); return;
case 'cinemas': $this->cinemas($id, $method); return; case 'cinemas': $this->cinemas($id, $method); return;
case 'trips': $this->trips($id, $sub, $method); return; case 'trips': $this->trips($id, $sub, $method); return;
/* 🔴 v66:旅程三项设置里的两项(第三项「出行方式」是枚举常量,没有接口)。
⚠️ 必须与 `trips` 平级,不能做成 `trips/vehicles` 子路由 ——
`Api::dispatch()` 只解析三段(`/<res>/<id>/<sub>`),
四段的 `/trips/vehicles` 会被当成「trips 的第 0 条」而落到列表。 */
case 'vehicles': $this->vehicles($id, $method); return;
case 'nodetypes': $this->nodeTypes($id, $method); return;
case 'ledger': $this->ledger($id, $sub, $method); return; case 'ledger': $this->ledger($id, $sub, $method); return;
case 'resources': $this->resources($id, $method); return; case 'resources': $this->resources($id, $method); return;
case 'books': $this->books($id, $sub, $method); return; case 'books': $this->books($id, $sub, $method); return;
@@ -1599,7 +1610,11 @@ final class ApiHandler
if ($id > 0) { if ($id > 0) {
$a = Anniversary::find($fam, $id); $a = Anniversary::find($fam, $id);
if (!$a) { Api::notFound('这条纪念日'); } if (!$a) { Api::notFound('这条纪念日'); }
if ($method === 'GET') { Api::ok($a); } if ($method === 'GET') {
// 单条也要带上关联人,否则端上"编辑"没法回填(见 annivData 的说明)
$a['person_ids'] = Anniversary::peopleOf($a);
Api::ok($a);
}
if ($method === 'DELETE') { if ($method === 'DELETE') {
Api::requireWrite(); Api::requireWrite();
Anniversary::remove($fam, $id); Anniversary::remove($fam, $id);
@@ -1621,7 +1636,18 @@ final class ApiHandler
Api::ok(Anniversary::find($fam, $newId), 201); Api::ok(Anniversary::find($fam, $newId), 201);
} }
Api::ok(Anniversary::all($fam, $pid)); /* 列表:自定义的每行补一个 `person_ids`。
⚠️ `Anniversary::list()` 是 `SELECT * FROM anniversaries` —— 不含关联人,
端上就只能显示"没有关联人",编辑时也回填不了。
(派生出来的生日行 `source` 是 `birthday_*`,人就在 `person_id` 里,不用补。) */
$rows = Anniversary::all($fam, $pid);
foreach ($rows as &$r) {
if ((string)($r['source'] ?? 'custom') === 'custom') {
$r['person_ids'] = Anniversary::peopleOf($r);
}
}
unset($r);
Api::ok($rows);
} }
/** 纪念日字段(契约与 days/save 路由一致) */ /** 纪念日字段(契约与 days/save 路由一致) */
@@ -1639,10 +1665,38 @@ final class ApiHandler
'kind' => $has('kind') ? Api::str('kind', '', 40) : (string)($cur['kind'] ?? ''), 'kind' => $has('kind') ? Api::str('kind', '', 40) : (string)($cur['kind'] ?? ''),
'note' => $has('note') ? Api::str('note', '') : (string)($cur['note'] ?? ''), 'note' => $has('note') ? Api::str('note', '') : (string)($cur['note'] ?? ''),
'person_ids' => $has('person_ids') ? Api::ints('person_ids') 'person_ids' => $has('person_ids') ? Api::ints('person_ids')
: array_map(static fn($p) => (int)$p['id'], (array)($cur['people'] ?? [])), : $this->annivPeopleOf($cur),
]; ];
} }
/**
* 现有纪念日的关联人(PATCH 未带 `person_ids` 时的兜底)。
*
* 🔴🔴 2026-10-20 修的数据丢失:原来写的是 `$cur['people'] ?? []`,
* 而 `$cur` 来自 `Anniversary::find()` —— 那条 SQL 是 `SELECT * FROM anniversaries`,
* **根本不含 `people` 这个键** ⇒ 兜底恒为 `[]` ⇒
* 「只改个标题」的 PATCH 会把这条纪念日的关联人**全部清空**。
* 网页端没暴露,是因为网页表单每次都会把选人框整份提交上来;
* 而 App 端做「编辑」时很自然只发改动的那几个字段 —— 一改就丢人。
* 这里改成真去 `anniversary_people` 读一次。
*
* ⚠️ `peopleOf()` 自己带静态缓存,同一请求里反复读同一 id 不会反复查库。
*
* @param array|null $cur 当前记录(新建时为 null)
* @return int[] 关联人 person id
*/
private function annivPeopleOf(?array $cur): array
{
if (!$cur) {
return [];
}
// 调用方**已经**把 people 填进来的话,优先用它的(省一次查询)
if (is_array($cur['people'] ?? null) && $cur['people']) {
return array_map(static fn($p) => (int)($p['id'] ?? 0), $cur['people']);
}
return Anniversary::peopleOf($cur);
}
/* ═══════════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════════
任务 任务
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
@@ -2391,6 +2445,181 @@ final class ApiHandler
Api::str('sort', 'used'))); Api::str('sort', 'used')));
} }
/* ═══════════════════════════════════════════════════════════════
旅程三项设置(v66,用户 2026-10-20)
用户原话:「旅程的……车辆管理、出行方式、节点设置,这些东西都还没完成。」
网页端早就有这两块(`trips/vehicles.php` / `trips/types.php`),
表与模型(`Vehicle` / `NodeType` 的 `save/destroy`)也齐 ——
**缺的只是 API 路由**,所以端上那三个菜单项一直点不开。
⚠️ 「出行方式」**没有**这一对接口:它是枚举常量(`Trip::TRAVEL_MODES`),
加一种要改代码,不存在"用户自己管理"这回事。
⚠️ 这里**不复制**模型的校验逻辑:名称必填、重名、内置不可删、
还有 N 段行程在用……全在 `Vehicle` / `NodeType` 里(网页端同一份)。
接口只负责把 `ok=false` 的 `error` 原样回给端上 —— 那句话已经是人话。
═══════════════════════════════════════════════════════════════ */
/**
* `/api/v1/vehicles` —— 出行用的车(自家 / 租的 / 借的)。
*
* ⚠️ 与财务里 `kind='car'` 的**账目**是两回事(服务端 `Vehicle` 文件头写明):
* 那条账目记的是"这台车值多少钱",没有车牌车型;用户 2026-10-05 明确
* 要求选车下拉里**不要**混进财务的车。
*/
private function vehicles(int $id, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
if ($method === 'GET') {
$v = Vehicle::find($fam, $id);
if (!$v) { Api::notFound('这辆车'); }
$v['used_count'] = $this->vehicleUsedCount($id);
Api::ok($v);
}
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
$cur = Vehicle::find($fam, $id);
if (!$cur) { Api::notFound('这辆车'); }
$r = Vehicle::save($fam, $this->vehicleInput($cur), $id);
if (empty($r['ok'])) { Api::fail((string)($r['error'] ?? '保存失败。'), 'unprocessable', 422); }
$v = Vehicle::find($fam, $id) ?: [];
$v['used_count'] = $this->vehicleUsedCount($id);
Api::ok($v);
}
if ($method === 'DELETE') {
Api::requireWrite();
$r = Vehicle::destroy($fam, $id);
if (empty($r['ok'])) {
/* 422 而不是 400:这是"现在不能删"(还有行程在用),
不是"请求写错了" —— 端上据此原样显示服务端那句话。 */
Api::fail((string)($r['error'] ?? '删除失败。'), 'unprocessable', 422);
}
Api::ok(['deleted' => $id]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$r = Vehicle::save($fam, $this->vehicleInput([]));
if (empty($r['ok'])) { Api::fail((string)($r['error'] ?? '保存失败。'), 'unprocessable', 422); }
$newId = (int)($r['id'] ?? 0);
$v = Vehicle::find($fam, $newId) ?: [];
$v['used_count'] = $this->vehicleUsedCount($newId);
Api::ok($v, 201);
}
$out = [];
foreach (Vehicle::all($fam) as $v) {
$v['used_count'] = $this->vehicleUsedCount((int)$v['id']);
$out[] = $v;
}
Api::ok($out);
}
/** 一辆车被多少段行程引用(端上据此决定"删除"能不能点) */
private function vehicleUsedCount(int $vehicleId): int
{
if ($vehicleId <= 0) { return 0; }
try {
$st = Database::get()->prepare('SELECT COUNT(*) FROM trip_nodes WHERE vehicle_id = ?');
$st->execute([$vehicleId]);
return (int)$st->fetchColumn();
} catch (\Throwable $ex) {
return 0;
}
}
/**
* 车字段的取值:**请求里有就用请求的,没有就沿用库里那份**。
*
* ⚠️ 为什么不能像 `resources` 那样直接 `Api::str('plate', $cur['plate'])` 一路写死:
* 那样每多一个字段就要多抄一行,而且 PATCH 只改一个字段时会把别的冲成空。
* 这里逐键取,语义与页面表单提交一致(`Vehicle::save` 自己会 trim/截断)。
*/
private function vehicleInput(array $cur): array
{
return [
'plate' => Api::param('plate', (string)($cur['plate'] ?? '')),
'name' => Api::param('name', (string)($cur['name'] ?? '')),
'vtype' => Api::param('vtype', (string)($cur['vtype'] ?? 'own')),
'vendor' => Api::param('vendor', (string)($cur['vendor'] ?? '')),
'order_no' => Api::param('order_no', (string)($cur['order_no'] ?? '')),
'is_default' => Api::param('is_default', (int)($cur['is_default'] ?? 0)),
'note' => Api::param('note', (string)($cur['note'] ?? '')),
];
}
/**
* `/api/v1/nodetypes` —— 行程节点类型(游玩 / 用餐 / 赶路…,可自定义)。
*
* 🔴 改名会**级联**到已有行程(`trip_nodes.node_type` 存的是名字,不是 id)——
* 这条在 `NodeType::save` 里做,接口不要重复实现。
*/
private function nodeTypes(int $id, string $method): void
{
[$fam, $pid] = Api::me();
if ($id > 0) {
if ($method === 'GET') {
$t = NodeType::find($fam, $id);
if (!$t) { Api::notFound('这个类型'); }
$t['used_count'] = NodeType::usedCount($fam, (string)$t['name']);
Api::ok($t);
}
if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite();
$cur = NodeType::find($fam, $id);
if (!$cur) { Api::notFound('这个类型'); }
$r = NodeType::save($fam, $this->nodeTypeInput($cur), $id);
if (empty($r['ok'])) { Api::fail((string)($r['error'] ?? '保存失败。'), 'unprocessable', 422); }
$t = NodeType::find($fam, $id) ?: [];
$t['used_count'] = NodeType::usedCount($fam, (string)($t['name'] ?? ''));
Api::ok($t);
}
if ($method === 'DELETE') {
Api::requireWrite();
$r = NodeType::destroy($fam, $id);
if (empty($r['ok'])) {
// 内置类型 / 还有行程在用 —— 两种都是"现在不能删",不是请求写错
Api::fail((string)($r['error'] ?? '删除失败。'), 'unprocessable', 422);
}
Api::ok(['deleted' => $id]);
}
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
if ($method === 'POST') {
Api::requireWrite();
$r = NodeType::save($fam, $this->nodeTypeInput([]));
if (empty($r['ok'])) { Api::fail((string)($r['error'] ?? '保存失败。'), 'unprocessable', 422); }
$newId = (int)($r['id'] ?? 0);
$t = NodeType::find($fam, $newId) ?: [];
$t['used_count'] = 0;
Api::ok($t, 201);
}
/* ⚠️ `NodeType::all()` 内部会**自愈地**补内置类型(`seedDefaults` /
`seedFromTripLog`),所以这里调它就是"保证列表一定不为空"。 */
$out = [];
foreach (NodeType::all($fam) as $t) {
$t['used_count'] = NodeType::usedCount($fam, (string)$t['name']);
$out[] = $t;
}
Api::ok($out);
}
/** 节点类型字段的取值(同 `vehicleInput`:请求优先、缺了沿用库里) */
private function nodeTypeInput(array $cur): array
{
return [
'name' => Api::param('name', (string)($cur['name'] ?? '')),
'icon' => Api::param('icon', (string)($cur['icon'] ?? '')),
];
}
private function books(int $id, string $sub, string $method): void private function books(int $id, string $sub, string $method): void
{ {
[$fam, $pid] = Api::me(); [$fam, $pid] = Api::me();
@@ -2457,9 +2686,21 @@ final class ApiHandler
if ($method === 'GET') { Api::ok($n); } if ($method === 'GET') { Api::ok($n); }
if ($method === 'PATCH' || $method === 'PUT') { if ($method === 'PATCH' || $method === 'PUT') {
Api::requireWrite(); Api::requireWrite();
/* 🔴🔴 v65(2026-10-20 修的数据丢失):`Note::update()` 会把
`cleanBlocks($d['blocks'] ?? [])` **整组替换**写回(先 DELETE 再 INSERT),
而原来这里传的是 `'body' => …` —— `Note` 根本不读 `body`,只读 `blocks`
⇒ 请求里的正文被忽略、`blocks` 恒为 `[]` ⇒
**只改个标题就会把整条笔记的正文全部删光**。
这里改成:
· 请求给了 `blocks` / `body` → 按请求替换;
· 都没给(只想改标题/标签)→ **沿用库里的块**。 */
$blocks = $this->noteBlocksIn();
if ($blocks === null) {
$blocks = $n['blocks'];
}
Note::update($fam, $id, $pid, [ Note::update($fam, $id, $pid, [
'title' => Api::str('title', (string)$n['title'], 200), 'title' => Api::str('title', (string)$n['title'], 200),
'body' => Api::str('body', (string)($n['body'] ?? '')), 'blocks' => $blocks,
'tags' => Api::param('tags', $n['tags'] ?? ''), 'tags' => Api::param('tags', $n['tags'] ?? ''),
]); ]);
Api::ok(Note::find($fam, $id, $pid)); Api::ok(Note::find($fam, $id, $pid));
@@ -2476,10 +2717,13 @@ final class ApiHandler
$title = Api::str('title', '', 200); $title = Api::str('title', '', 200);
if ($title === '') { Api::fail('笔记得有个标题。', 'unprocessable', 422); } if ($title === '') { Api::fail('笔记得有个标题。', 'unprocessable', 422); }
$newId = Note::create($fam, $pid, [ $newId = Note::create($fam, $pid, [
'title' => $title, 'title' => $title,
'body' => Api::str('body', ''), /* 🔴 v65:同上,`Note::create()` 读的是 `blocks`。
'tags' => Api::param('tags', []), 原来传 `'body'` ⇒ 新建出来的笔记**只有标题、一个字都没有**。 */
'blocks' => $this->noteBlocksIn() ?? [],
'tags' => Api::param('tags', []),
]); ]);
if ($newId <= 0) { Api::fail('笔记没保存下来。', 'unprocessable', 422); }
Api::ok(Note::find($fam, $newId, $pid), 201); Api::ok(Note::find($fam, $newId, $pid), 201);
} }
[$per, $off] = Api::page(50); [$per, $off] = Api::page(50);
@@ -2489,6 +2733,40 @@ final class ApiHandler
]); ]);
} }
/**
* 请求里的「正文」→ `note_blocks` 数组。
*
* 🔴🔴 为什么要有这个函数(2026-10-20 修的一处**数据丢失**):
* `Note::create()` / `Note::update()` 读的键是 **`blocks`**
* (`note_blocks` 表,一条笔记由若干块组成:text / image / …),
* 而本文件的 `notes()` 原来传的是 **`body`** —— `Note` 压根不看这个键。
* 后果:
* · `POST /notes`:正文被丢掉,新笔记只有标题;
* · `PATCH /notes/<id>`:`blocks` 恒为 `[]`,而 `update()` 是
* **整组替换**(先 `DELETE FROM note_blocks`)⇒ **整条笔记的正文被删光**。
* 网页端没暴露,是因为网页表单提交的是 `blocks[]`;只有走 API 的客户端会中招。
*
* 兼容两种写法:
* · `blocks`:数组,原样交给 `Note::cleanBlocks()` 清洗(网页表单那种);
* · `body`:**纯文本**,整段包成一个 `text` 块(App 端最自然的写法)。
*
* @return array|null 请求里**根本没提**正文时返回 null
* —— 调用方据此区分"要清空"和"没改",
* 对 null 一律沿用库里的块(见 `notes()` 的 PATCH 分支)。
*/
private function noteBlocksIn(): ?array
{
$body = Api::body();
if (array_key_exists('blocks', $body) && is_array($body['blocks'])) {
return $body['blocks'];
}
if (array_key_exists('body', $body) || array_key_exists('body', $_POST)) {
$txt = (string)Api::str('body', '');
return $txt === '' ? [] : [['kind' => 'text', 'content' => $txt]];
}
return null;
}
/* ═══════════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════════
v58 · 增量同步:各模块的「变更指纹」 v58 · 增量同步:各模块的「变更指纹」
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */