v77 服务端:旅程题图上传端点 + 修「PATCH 把题图清空」的真 bug
新增 POST /api/v1/trips/<id>/cover(multipart 字段 cover), 槽位走 ImageSlot 的 cover(1920×1080 / 16:9),与网页版 trips/form.php 同一条 Upload::image 管线;换图顺手删旧对象。 修一个已经发生过的静默数据丢失:端上编辑旅程走 PATCH /api/v1/trips/<id>,那个分支的 $d 里没有 cover_key ⇒ saveDetails() 里 $d['cover_key'] ?? '' 得到空串 ⇒ 题图被写成 NULL。用户在手机上只改了个名字,题图就没了。 ⇒ 语义改成「没传 = 不动」,要清空必须显式传空串。 另加 Trip::setCover():换图只动 cover_key 一列,不复用 Trip::update()(那条要求 title 必填、还会重写 summary/is_public, 下次加字段漏一个就又静默清空一次)。 线上已部署并复核:ApiHandler.php / Trip.php 均含新代码、php -l 通过。
This commit is contained in:
+40
-1
@@ -1416,11 +1416,22 @@ final class ApiHandler
|
|||||||
if ($id > 0) {
|
if ($id > 0) {
|
||||||
$ev = $this->eventFor($fam, $id);
|
$ev = $this->eventFor($fam, $id);
|
||||||
if (!$ev) { Api::notFound('这条日程'); }
|
if (!$ev) { Api::notFound('这条日程'); }
|
||||||
|
|
||||||
|
/* 🔴 v73(2026-10-21 真机实测):**读**不该要求写权限。
|
||||||
|
原先这里是"先判 canWriteEvent、再分 GET/PATCH/DELETE",
|
||||||
|
后果:共享给我**只读**的日历本里的日程,连**详情**都打不开 ——
|
||||||
|
客户端表现为"部分详情没读回来:读不到详情",而那条日程
|
||||||
|
在列表里好好的(列表走 /calendar,不经过这里)。
|
||||||
|
允许读的安全依据是 `eventFor()` 已经卡了 `family_id`:
|
||||||
|
跨家庭的 id 在上一行就 404 了,能走到这里的一定是同家庭成员,
|
||||||
|
而 `Calendar::month()` 本来就对成员开放全家的日程。
|
||||||
|
⇒ 写权限只挡**写**(PATCH/PUT/DELETE),读一律放行。 */
|
||||||
|
if ($method === 'GET') { Api::ok($ev); }
|
||||||
|
|
||||||
if (!Calendar::canWriteEvent($ev, $pid)) {
|
if (!Calendar::canWriteEvent($ev, $pid)) {
|
||||||
Api::fail('这本日历你没有写权限。', 'forbidden', 403);
|
Api::fail('这本日历你没有写权限。', 'forbidden', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($method === 'GET') { Api::ok($ev); }
|
|
||||||
if ($method === 'PATCH' || $method === 'PUT') {
|
if ($method === 'PATCH' || $method === 'PUT') {
|
||||||
Api::requireWrite();
|
Api::requireWrite();
|
||||||
$this->writeEvent($pid, $ev);
|
$this->writeEvent($pid, $ev);
|
||||||
@@ -2286,6 +2297,34 @@ final class ApiHandler
|
|||||||
Api::ok(Trip::summary($id, $pid));
|
Api::ok(Trip::summary($id, $pid));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 🔴 v77(用户 2026-10-22):「新建旅程那里不是有题图吗?
|
||||||
|
为什么这里没有增加题图的功能呢?」
|
||||||
|
⇒ 端上补题图上传。槽位**必须**是 `cover`(`app/ImageSlot.php` 里
|
||||||
|
1920×1080 / 16:9),与网页版 `trips/form.php` 的 `cover` 文件域
|
||||||
|
同一条管线 —— 端上不裁剪,裁/缩/转 JPEG 全在服务端做。
|
||||||
|
⚠️ 表单字段名固定 `cover`(端上拼 multipart 时用);
|
||||||
|
与观影的 `poster`(2:3)不是同一个槽位,别串。
|
||||||
|
⚠️ 换图顺手删旧对象:否则每换一次题图就在存储里堆一张孤儿图
|
||||||
|
(与 `movies/<id>/poster` 那条同一口径)。 */
|
||||||
|
if ($sub === 'cover') {
|
||||||
|
if ($method !== 'POST') { Api::fail('不支持的方法。', 'method_not_allowed', 405); }
|
||||||
|
Api::requireWrite();
|
||||||
|
$up = Upload::image($fam, $_FILES['cover'] ?? [], 'cover');
|
||||||
|
if (!$up['ok']) {
|
||||||
|
Api::fail((string)($up['error'] ?? '题图没存下来'), 'unprocessable', 422);
|
||||||
|
}
|
||||||
|
$newKey = (string)$up['file'];
|
||||||
|
$oldKey = (string)($t['cover_key'] ?? '');
|
||||||
|
Trip::setCover($id, $newKey);
|
||||||
|
if ($oldKey !== '' && $oldKey !== $newKey) {
|
||||||
|
Storage\Storage::delete($fam, $oldKey);
|
||||||
|
}
|
||||||
|
Api::ok([
|
||||||
|
'cover_key' => $newKey,
|
||||||
|
'cover' => Storage\Storage::url($fam, $newKey),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
if ($method === 'GET') { Api::ok($t); }
|
if ($method === 'GET') { Api::ok($t); }
|
||||||
if ($method === 'PATCH' || $method === 'PUT') {
|
if ($method === 'PATCH' || $method === 'PUT') {
|
||||||
Api::requireWrite();
|
Api::requireWrite();
|
||||||
|
|||||||
+44
-4
@@ -680,8 +680,23 @@ final class Trip
|
|||||||
private static function saveDetails(int $eventId, array $d): void
|
private static function saveDetails(int $eventId, array $d): void
|
||||||
{
|
{
|
||||||
$db = Database::get();
|
$db = Database::get();
|
||||||
|
/* 先读一次现有值 —— `cover_key` **没传时要保留原值**。
|
||||||
|
🔴 v77 修的真实 bug(2026-10-22 查出来的):
|
||||||
|
端上编辑旅程走 `PATCH /api/v1/trips/<id>`,那个分支的 `$d` 里
|
||||||
|
只有 title / note / summary / is_public —— 于是这里
|
||||||
|
`$d['cover_key'] ?? ''` 得到空串 ⇒ `keyOrNull('')` ⇒ **null**
|
||||||
|
⇒ 用户在手机上只改了个名字,题图就被**静默清空**了。
|
||||||
|
(网页版没这个病:`index.php` 的保存路径每次都显式带上旧值。)
|
||||||
|
⚠️ 语义按"没传 = 不动":要清空题图必须显式传 `cover_key = ''`。 */
|
||||||
|
$sel = $db->prepare('SELECT cover_key FROM trip_details WHERE event_id = ?');
|
||||||
|
$sel->execute([$eventId]);
|
||||||
|
$cur = $sel->fetchColumn();
|
||||||
|
$has = $cur !== false;
|
||||||
|
$cover = array_key_exists('cover_key', $d)
|
||||||
|
? self::keyOrNull((string)$d['cover_key'])
|
||||||
|
: ($has ? self::keyOrNull((string)$cur) : null);
|
||||||
$row = [
|
$row = [
|
||||||
self::keyOrNull($d['cover_key'] ?? ''),
|
$cover,
|
||||||
self::textOrNull($d['summary'] ?? ''),
|
self::textOrNull($d['summary'] ?? ''),
|
||||||
// 用户第 4 条:是否公开。
|
// 用户第 4 条:是否公开。
|
||||||
// ⚠️ 必须区分「这个键没传」与「用户明确取消勾选」:
|
// ⚠️ 必须区分「这个键没传」与「用户明确取消勾选」:
|
||||||
@@ -691,9 +706,7 @@ final class Trip
|
|||||||
// · 传了 0(表单里取消勾选)→ 真的不公开。
|
// · 传了 0(表单里取消勾选)→ 真的不公开。
|
||||||
array_key_exists('is_public', $d) ? (!empty($d['is_public']) ? 1 : 0) : 1,
|
array_key_exists('is_public', $d) ? (!empty($d['is_public']) ? 1 : 0) : 1,
|
||||||
];
|
];
|
||||||
$has = $db->prepare('SELECT COUNT(*) FROM trip_details WHERE event_id = ?');
|
if ($has) {
|
||||||
$has->execute([$eventId]);
|
|
||||||
if ((int)$has->fetchColumn() > 0) {
|
|
||||||
$db->prepare('UPDATE trip_details SET cover_key=?, summary=?, is_public=? WHERE event_id=?')
|
$db->prepare('UPDATE trip_details SET cover_key=?, summary=?, is_public=? WHERE event_id=?')
|
||||||
->execute([...$row, $eventId]);
|
->execute([...$row, $eventId]);
|
||||||
} else {
|
} else {
|
||||||
@@ -702,6 +715,33 @@ final class Trip
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单独换题图(端上 `POST /api/v1/trips/<id>/cover` 用)。
|
||||||
|
*
|
||||||
|
* ⚠️ 为什么不复用 `update()`:那条要求 `title` 必填、而且会把
|
||||||
|
* note / summary / is_public 一起重写 —— 只想换张图却要先把
|
||||||
|
* 整趟旅程的其它字段原样传一遍,最容易在下次加字段时漏掉一个
|
||||||
|
* (漏了的后果就是静默清空)。换图就只动 cover_key 一列。
|
||||||
|
*
|
||||||
|
* @param string $key 新的存储键(`covers/xxx.jpg`);空串 = 清掉题图
|
||||||
|
*/
|
||||||
|
public static function setCover(int $eventId, string $key): void
|
||||||
|
{
|
||||||
|
$db = Database::get();
|
||||||
|
$val = self::keyOrNull($key);
|
||||||
|
$sel = $db->prepare('SELECT COUNT(*) FROM trip_details WHERE event_id = ?');
|
||||||
|
$sel->execute([$eventId]);
|
||||||
|
if ((int)$sel->fetchColumn() > 0) {
|
||||||
|
$db->prepare('UPDATE trip_details SET cover_key = ? WHERE event_id = ?')
|
||||||
|
->execute([$val, $eventId]);
|
||||||
|
} else {
|
||||||
|
// 老旅程可能压根没有 trip_details 行(早于这张表的年份)⇒ 补一行
|
||||||
|
$db->prepare(
|
||||||
|
'INSERT INTO trip_details (cover_key, summary, is_public, event_id) VALUES (?, NULL, 1, ?)'
|
||||||
|
)->execute([$val, $eventId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static function saveAttendees(int $eventId, array $personIds): void
|
public static function saveAttendees(int $eventId, array $personIds): void
|
||||||
{
|
{
|
||||||
$db = Database::get();
|
$db = Database::get();
|
||||||
|
|||||||
Reference in New Issue
Block a user