v64:观影豆瓣自动获取 + 海报/多图上传(移动端 API)

端上「记一场」要与网页端同步实现(用户 2026-10-19 六条),
服务端这一半只加**移动端入口**,网页端(index.php 那条路由)一行没动。

- ApiHandler:新增 `/movies/douban` 子路由(action=suggest / action=thumb /
  默认抓详情+海报落盘),逻辑与网页端 `movies/douban` 逐条对齐;
  新增 `/movies/<id>/poster` 与 `/movies/<id>/photos`(multipart 上传),
  字段名与端上一致(poster / photo);
  新增 `normalizeFiles()` 把 `photo[]` 的多文件从"按字段竖切"转成"一行一个";
  `movieData()` 认 `poster_key` 并**校验形态**(只接受 posters/xxx 形式);
  features 声明 movies_douban / movie_media(端上可探测老服务端)。

- Douban:新增 `apiThumbUrl()` —— 端上打不到 `index.php?r=...`,
  把豆瓣图片代理换成 `/api/v1/...` 形式(拼法只此一处);
  联想候选带上**过白名单后**的原始图片地址(未过白名单给空串,不下发 SSRF 跳板)。

- Movie:新增 `setPoster()`(单字段更新 —— 端上是"先建记录再传图"两段式,
  用 update() 要拼一份完整草稿,漏一个字段就静默覆盖用户刚填的内容)与
  `addPhotos()`(**追加**语义,与网页表单用的 savePhotos() 替换语义并存不混用)。

验证:接口 e2e 57/0(自带豆瓣 mock,离线可跑)· 反向验证 18/0 ·
本地全量 e2e 1521/0 · 线上部署 + 站点自检 19 条全过 +
`/api/v1/movies/douban` 401(路由通)· ping.features 已含两条新能力
This commit is contained in:
2026-10-19 23:41:15 +08:00
parent 2f7dc6ca23
commit 3459eea8f3
3 changed files with 361 additions and 0 deletions
+62
View File
@@ -254,6 +254,68 @@ final class Movie
}
}
/**
* **只设海报**(不动其它字段)。
*
* 🔴 v64(用户 2026-10-19):端上「记一场」是"先建记录、再传海报"两段式
* (见 `ApiHandler::movies()` 里 poster 分支的说明)。那种写法不能用
* `Movie::update()` —— 它要求把 title/starts_at 等**全部字段**一起传,
* 第二段手里只有一张图,硬拼一份"看起来没变"的完整草稿,
* 任何一处漏传都会**静默覆盖**用户刚填的内容。
* ⇒ 单字段更新走单字段的 SQL,风险最小。
*
* ⚠️ 卫星行可能还不存在(新建的记录若 info 等全空,`saveDetails` 也会插一行,
* 所以正常情况下有;但历史数据/手工插的 events 可能没有)⇒ 用 UPSERT 语义,
* 不存在就补一行,免得海报"设置了但存不进去"。
*/
public static function setPoster(int $eventId, string $key): void
{
$db = Database::get();
$st = $db->prepare('SELECT COUNT(*) FROM movie_details WHERE event_id = ?');
$st->execute([$eventId]);
if ((int)$st->fetchColumn() > 0) {
$db->prepare('UPDATE movie_details SET poster_key = ? WHERE event_id = ?')
->execute([self::keyOrNull($key), $eventId]);
return;
}
$db->prepare('INSERT INTO movie_details (poster_key, event_id) VALUES (?, ?)')
->execute([self::keyOrNull($key), $eventId]);
}
/**
* **追加**现场照片(保留库里已有的)。
*
* 🔴 v64:与 `savePhotos()`(整组替换)**必须分开**。
* 端上是"建完记录一张张补传"的,每次只带这一次新增的键 ——
* 若走替换实现,用户传第 2 张时第 1 张就被删了。
* ⚠️ 网页表单那条路仍然用 `savePhotos()`(它一次性带全量),别改那边。
*
* @param string[] $keys 这次新增的相对键(空串会被跳过)
*/
public static function addPhotos(int $eventId, array $keys): void
{
$clean = [];
foreach ($keys as $k) {
$k = trim((string)$k);
if ($k !== '') {
$clean[] = $k;
}
}
if ($clean === []) {
return;
}
$db = Database::get();
// position 接着已有的往后排(否则新照片会插到最前面,与"追加"的直觉相反)
$st = $db->prepare('SELECT COALESCE(MAX(position), 0) FROM event_photos WHERE event_id = ?');
$st->execute([$eventId]);
$pos = (int)$st->fetchColumn();
$ins = $db->prepare('INSERT INTO event_photos (event_id, `key`, position) VALUES (?, ?, ?)');
foreach ($clean as $k) {
$pos += 10;
$ins->execute([$eventId, $k, $pos]);
}
}
/** 删一张现场照片(连带删存储对象) */
public static function deletePhoto(int $familyId, int $eventId, int $photoId): void
{