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:
+285
-7
@@ -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 · 增量同步:各模块的「变更指纹」
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|||||||
Reference in New Issue
Block a user