diff --git a/app/ApiHandler.php b/app/ApiHandler.php index 287bb6b..2e425ea 100644 --- a/app/ApiHandler.php +++ b/app/ApiHandler.php @@ -61,6 +61,11 @@ final class ApiHandler ⇒ `movies_douban` = `/movies/douban`(suggest / 抓取 / 缩略图代理); `movie_media` = `/movies//poster|photos`(multipart 上传)。 端上可据此探测:老服务端没有这两条时不显示「自动获取」与传图入口。 */ + /* 🔴 v66:旅程「车辆管理 / 节点类型」两条 CRUD 接口。 + 端上据此探测:老服务端没有它们时,设置菜单里那两项 + 只是说明,不显示增删改按钮。 */ + 'trips_vehicles' => 1, + 'trips_nodetypes' => 1, 'movies_douban' => 1, 'movie_media' => 1, // v58:同步可以"先问一句哪些模块变了",只拉变了的 @@ -97,6 +102,12 @@ final class ApiHandler case 'movies': $this->movies($id, $sub, $method); return; case 'cinemas': $this->cinemas($id, $method); return; case 'trips': $this->trips($id, $sub, $method); return; + /* 🔴 v66:旅程三项设置里的两项(第三项「出行方式」是枚举常量,没有接口)。 + ⚠️ 必须与 `trips` 平级,不能做成 `trips/vehicles` 子路由 —— + `Api::dispatch()` 只解析三段(`///`), + 四段的 `/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 'resources': $this->resources($id, $method); return; case 'books': $this->books($id, $sub, $method); return; @@ -1599,7 +1610,11 @@ final class ApiHandler if ($id > 0) { $a = Anniversary::find($fam, $id); 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') { Api::requireWrite(); Anniversary::remove($fam, $id); @@ -1621,7 +1636,18 @@ final class ApiHandler 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 路由一致) */ @@ -1639,10 +1665,38 @@ final class ApiHandler 'kind' => $has('kind') ? Api::str('kind', '', 40) : (string)($cur['kind'] ?? ''), 'note' => $has('note') ? Api::str('note', '') : (string)($cur['note'] ?? ''), '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'))); } + /* ═══════════════════════════════════════════════════════════════ + 旅程三项设置(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 { [$fam, $pid] = Api::me(); @@ -2457,9 +2686,21 @@ final class ApiHandler if ($method === 'GET') { Api::ok($n); } if ($method === 'PATCH' || $method === 'PUT') { 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, [ 'title' => Api::str('title', (string)$n['title'], 200), - 'body' => Api::str('body', (string)($n['body'] ?? '')), + 'blocks' => $blocks, 'tags' => Api::param('tags', $n['tags'] ?? ''), ]); Api::ok(Note::find($fam, $id, $pid)); @@ -2476,10 +2717,13 @@ final class ApiHandler $title = Api::str('title', '', 200); if ($title === '') { Api::fail('笔记得有个标题。', 'unprocessable', 422); } $newId = Note::create($fam, $pid, [ - 'title' => $title, - 'body' => Api::str('body', ''), - 'tags' => Api::param('tags', []), + 'title' => $title, + /* 🔴 v65:同上,`Note::create()` 读的是 `blocks`。 + 原来传 `'body'` ⇒ 新建出来的笔记**只有标题、一个字都没有**。 */ + 'blocks' => $this->noteBlocksIn() ?? [], + 'tags' => Api::param('tags', []), ]); + if ($newId <= 0) { Api::fail('笔记没保存下来。', 'unprocessable', 422); } Api::ok(Note::find($fam, $newId, $pid), 201); } [$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/`:`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 · 增量同步:各模块的「变更指纹」 ═══════════════════════════════════════════════════════════════ */