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
+266
View File
@@ -53,6 +53,16 @@ final class ApiHandler
'movies_stats' => 1, 'movies_stats' => 1,
'movies_report' => 1, 'movies_report' => 1,
'movies_search' => 1, 'movies_search' => 1,
/* 🔴 v64(用户 2026-10-19):端上「记一场」要与网页端**同步实现**
—— 片名联想 + 一键获取简介与海报(豆瓣),海报/观影照片多图上传。
⚠️ 这两件事网页端早就有(`movies/douban` 路由 + `movies/create`
的 poster/photo 槽位),缺的只是**移动端入口**:
网页那条走 `index.php?r=movies/douban`,App 打不到。
⇒ `movies_douban` = `/movies/douban`(suggest / 抓取 / 缩略图代理);
`movie_media` = `/movies/<id>/poster|photos`(multipart 上传)。
端上可据此探测:老服务端没有这两条时不显示「自动获取」与传图入口。 */
'movies_douban' => 1,
'movie_media' => 1,
// v58:同步可以"先问一句哪些模块变了",只拉变了的 // v58:同步可以"先问一句哪些模块变了",只拉变了的
// (见 `changes()` 的说明;老端上没有这个标志也不会坏) // (见 `changes()` 的说明;老端上没有这个标志也不会坏)
'changes' => 1, 'changes' => 1,
@@ -1815,10 +1825,211 @@ final class ApiHandler
]); ]);
} }
/* ────────────────────────────────────────────────────────────────
🔴 v64(用户 2026-10-19)**豆瓣自动补充**(片名联想 / 抓取详情+海报 / 缩略图代理)
用户原话:「添加电影这里,**输入片名可以通过豆瓣的地址自动获取简介和海报**。
这个在网页端是直接实现了的,我们的手机端也必须同步实现。」
⇒ 网页端那条路由(`index.php` 的 `movies/douban`)**一行没改、逻辑照搬**,
只是换一个 App 打得到的入口。三件事与网页**逐条对齐**:
· `action=suggest` → 片名候选(下拉联想);
· `action=thumb` → 代理豆瓣缩略图(**回图片字节,不是 JSON 信封**);
· 不带 action → 抓详情 + 海报落盘(poster 槽位管线),回 JSON。
⚠️ 为什么缩略图必须由服务端代取:豆瓣有防盗链,客户端直连一律 403。
`Douban::thumb()` 里已经带 Referer 了。
⚠️ 为什么用 query 传 action 而不是 `/movies/douban/thumb`:
`Api::dispatch()` 只解析「资源 / id / 子动作」两层,
三段路径里第三段会被丢掉。query 是最省事、也不跟路由打架的形态。
⚠️ **路径必须是 `movies/douban`**:`Api::dispatch()` 取 `parts[1]` 当子动作,
非数字时 `$id === 0` ⇒ 正好落在 $id>0 分支**之前**,不会被当成"id=0 的详情"。
──────────────────────────────────────────────────────────────── */
if ($sub === 'douban' && $id === 0) {
$act = Api::str('action');
if ($act === 'suggest') {
if ($method !== 'GET') { Api::fail('不支持的方法。', 'method_not_allowed', 405); }
$res = Douban::suggest(Api::str('q'));
if (!$res['ok']) {
Api::fail((string)($res['error'] ?? '搜索失败'), 'upstream', 502);
}
$items = (array)($res['items'] ?? []);
/* ⚠️ 逐个把候选缩略图换成 **API 形式**的代理地址。
端上拿不到 `index.php?r=` 那个网页入口(见 `Douban::apiThumbUrl`)。
⚠️ 原始地址只在过了白名单时才下发(见 `buildSuggestItems` 的 `img`)。 */
foreach ($items as &$it) {
$rawImg = (string)($it['img'] ?? '');
$it['thumb'] = $rawImg !== '' ? Douban::apiThumbUrl($rawImg) : '';
unset($it['img']);
}
unset($it);
Api::ok(['items' => array_values($items)]);
}
if ($act === 'thumb') {
if ($method !== 'GET') { Api::fail('不支持的方法。', 'method_not_allowed', 405); }
$img = Douban::thumb(Api::str('u'));
if ($img === null) {
Api::fail('取不到这张图。', 'not_found', 404);
}
// ⚠️ 这里**故意不走 Api 信封**:调用方是 `Image()`,它要的是图片字节。
// 与 `media.php` 同一口径(那个也不走信封)。
if (!headers_sent()) {
header('Content-Type: ' . $img['mime']);
header('Content-Length: ' . strlen($img['body']));
header('Cache-Control: public, max-age=86400');
}
echo $img['body'];
exit;
}
if ($act !== '') {
Api::fail('不支持的 action。', 'bad_request', 400);
}
// 默认:按 id 或片名抓详情 + 海报落盘
if ($method !== 'POST' && $method !== 'GET') {
Api::fail('不支持的方法。', 'method_not_allowed', 405);
}
Api::requireWrite();
$idDb = Api::str('id'); // ⚠️ 豆瓣条目 id(**字符串**,不是本函数参数里那个数字 id)
$qDb = Api::str('q');
if ($idDb !== '') {
$resDb = Douban::fetchById($idDb);
} elseif ($qDb !== '') {
$resDb = Douban::fetchByTitle($qDb);
} else {
Api::fail('请输入电影名称。', 'unprocessable', 422);
}
if (!$resDb['ok']) {
Api::fail((string)($resDb['error'] ?? '抓取失败'), 'upstream', 502);
}
// 海报:下载字节 → poster 槽位管线(裁 2:3、缩放、转 JPEG、Storage 统一出口)
// ⚠️ 与网页那条路由**同一套**(`Upload::image`),端上不做任何裁剪 ——
// 服务端保证落库的一定是 2:3。
$posterKey = '';
$posterShow = '';
if ((string)($resDb['poster_url'] ?? '') !== '') {
$bytes = Douban::downloadPoster((string)$resDb['poster_url']);
if ($bytes !== '') {
$tmp = tempnam(sys_get_temp_dir(), 'fbdb');
if ($tmp !== false) {
file_put_contents($tmp, $bytes);
$up = Upload::image($fam, ['error' => UPLOAD_ERR_OK, 'tmp_name' => $tmp], 'poster');
@unlink($tmp);
if ($up['ok']) {
$posterKey = (string)$up['file'];
$posterShow = Storage\Storage::url($fam, $posterKey);
}
}
}
}
Api::ok([
'source' => 'douban',
'title' => (string)($resDb['title'] ?? ''),
'meta' => $resDb['meta'],
/* 拼好的简介(含导演/主演/年份/类型/片长/国家/评分 + 剧情原文)。
在服务端拼、由端上填空 —— 与网页端**同一份输出**:
这段文本会**落库**(movie_details.info),两端拼法必须一致。 */
'summary' => Douban::summaryText(
(array)$resDb['meta'],
(string)($resDb['title'] ?? ''),
true
),
'poster_key' => $posterKey,
'poster' => $posterShow,
]);
}
/* ────────────────────────────────────────────────────────────────
🔴 v64:**海报 / 观影照片上传**(端上「记一场」多图)
用户原话:「**允许上传多张图片,包括海报。**」
⇒ 网页端是靠 `movies/create` 的 multipart 表单一次性带上
(`poster` 单张 + `photo[]` 多张,见 index.php 那段)。
但 App 这边"先 JSON 建记录、再逐个传图"更稳:
· JSON 建记录失败时不会留下半张孤儿图;
· 图片传失败也**不挡记录**(与网页同一取舍:一条记录因为一张
5MB 原图存不进去,看着像功能坏了);
· 一次传一张,进度好做、重试粒度小。
⚠️ 图片走**旧值保留**语义:传一张海报就是"换掉旧海报并删旧对象"
(与网页 `movies/update` 里那段一致);照片是**追加**,不替换
(替换会把用户前几次传的全冲掉)。
⚠️ 槽位固定:海报 `poster`(2:3 裁)、照片 `photo`(free 等比缩)。
槽位决定比例与输出尺寸,由 `Upload::image` 在服务端保证。
⚠️ 必须是 `$id > 0`(记录已存在)—— 所以这个分支放在下面那一段里。
──────────────────────────────────────────────────────────────── */
if (($sub === 'poster' || $sub === 'photos') && $id === 0) {
Api::fail('要指定是哪一条观影记录。', 'bad_request', 400);
}
if ($id > 0) { if ($id > 0) {
$m = Movie::find($fam, $id, $pid); $m = Movie::find($fam, $id, $pid);
if (!$m) { Api::notFound('这条观影记录'); } if (!$m) { Api::notFound('这条观影记录'); }
if ($sub === 'poster' || $sub === 'photos') {
if ($method !== 'POST') { Api::fail('不支持的方法。', 'method_not_allowed', 405); }
Api::requireWrite();
if ($sub === 'poster') {
// ⚠️ 表单字段名固定 `poster`(与网页端同名)——端上拼 multipart 时用
$up = Upload::image($fam, $_FILES['poster'] ?? [], 'poster');
if (!$up['ok']) {
Api::fail((string)($up['error'] ?? '海报没存下来'), 'unprocessable', 422);
}
$newKey = (string)$up['file'];
$oldKey = (string)($m['poster_key'] ?? '');
Movie::setPoster($id, $newKey);
// 换图顺手删旧对象(与网页 movies/update 同一口径,否则堆孤儿)
if ($oldKey !== '' && $oldKey !== $newKey) {
Storage\Storage::delete($fam, $oldKey);
}
Api::ok([
'poster_key' => $newKey,
'poster' => Storage\Storage::url($fam, $newKey),
]);
}
// ---- 观影照片(可多张;字段 `photo[]`,一次传几张都行)----
$files = self::normalizeFiles('photo');
if ($files === []) {
Api::fail('没有收到图片。', 'unprocessable', 422);
}
$added = [];
$warns = [];
foreach ($files as $f) {
$up = Upload::image($fam, $f, 'photo');
if ($up['ok']) {
$added[] = (string)$up['file'];
} elseif ((string)($up['error'] ?? '') !== '') {
$warns[] = (string)$up['error'];
}
}
if ($added === []) {
Api::fail($warns ? implode(';', $warns) : '这几张都没存下来',
'unprocessable', 422);
}
/* ⚠️ **追加**而不是替换:`Movie::addPhotos()` 保留库里已有的那些。
(替换的实现会把用户上一次传的照片全删掉 —— 与"多图"的直觉相反。)
⚠️ 只把这次**新增成功**的那些传进去,不碰旧的。 */
Movie::addPhotos($id, $added);
Api::ok([
'added' => $added,
'photos' => array_map(
static fn(array $p): array => [
'id' => (int)$p['id'],
'key' => (string)$p['key'],
'url' => Storage\Storage::url($fam, (string)$p['key']),
],
Movie::photos($id)
),
'warns' => $warns,
]);
}
if ($sub === 'comments') { if ($sub === 'comments') {
if ($method === 'GET') { if ($method === 'GET') {
$rv = MovieReview::mine($id, $pid); $rv = MovieReview::mine($id, $pid);
@@ -1878,6 +2089,50 @@ final class ApiHandler
Api::list(array_slice(array_values($list), $off, $per), count($list)); Api::list(array_slice(array_values($list), $off, $per), count($list));
} }
/**
* 把 `$_FILES['name']` 归一成「一个文件一个数组」的列表(支持 `name[]` 多文件)。
*
* 🔴 v64:端上「记一场」允许多张观影照片(用户:「允许上传多张图片」)。
* PHP 对 `<input type="file" name="photo[]" multiple>` 给的
* `$_FILES['photo']` 是**按字段竖切**的(`name`/`type`/`tmp_name`/… 各一个数组),
* 每个 `Upload::image()` 要的是**横切**的一行。
* ⇒ 这里做一次转置,别在业务分支里手写索引。
*
* ⚠️ 单文件与多文件两种形态都要认(单文件时 `name` 是字符串,不是数组)——
* 端上第一次传 1 张、第二次传 3 张,走的是同一段代码。
* ⚠️ `UPLOAD_ERR_NO_FILE` 的那种"空槽"要**丢掉**:
* 文件多选控件在用户取消选择时会留一个空槽,交给 `Upload::image`
* 会被判成"没传图"(error 空串)从而静默少一张 —— 不如这里就过滤掉。
*
* @return array<int,array{name:string,type:string,tmp_name:string,error:int,size:int}>
*/
private static function normalizeFiles(string $field): array
{
if (!isset($_FILES[$field]) || !is_array($_FILES[$field])) {
return [];
}
$f = $_FILES[$field];
// 单文件形态:直接包一层
if (!is_array($f['name'])) {
return (int)($f['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE ? [] : [$f];
}
$out = [];
foreach (array_keys($f['name']) as $i) {
$one = [
'name' => (string)($f['name'][$i] ?? ''),
'type' => (string)($f['type'][$i] ?? ''),
'tmp_name' => (string)($f['tmp_name'][$i] ?? ''),
'error' => (int)($f['error'][$i] ?? UPLOAD_ERR_NO_FILE),
'size' => (int)($f['size'][$i] ?? 0),
];
if ($one['error'] === UPLOAD_ERR_NO_FILE) {
continue;
}
$out[] = $one;
}
return $out;
}
/** 观影写入字段(新建与更新共用;`$cur` 非空时做"只改给了的字段"的回退) */ /** 观影写入字段(新建与更新共用;`$cur` 非空时做"只改给了的字段"的回退) */
private function movieData(?array $cur): array private function movieData(?array $cur): array
{ {
@@ -1899,6 +2154,17 @@ final class ApiHandler
'cinema_id' => $has('cinema_id') ? Api::int('cinema_id') : (int)($cur['cinema_id'] ?? 0), 'cinema_id' => $has('cinema_id') ? Api::int('cinema_id') : (int)($cur['cinema_id'] ?? 0),
'attendees' => $has('attendees') ? Api::ints('attendees') : array_map( 'attendees' => $has('attendees') ? Api::ints('attendees') : array_map(
static fn($a) => (int)$a['id'], (array)($cur['attendees'] ?? [])), static fn($a) => (int)$a['id'], (array)($cur['attendees'] ?? [])),
/* 🔴 v64:**豆瓣抓来的海报键**(端上不传文件,只把这个键带上)。
⚠️ 与网页端 `movies/create` 里那段同一个契约(隐藏字段 `poster_key`),
但校验放在**这里**(模型入口)而不是各调用点 ——
键必须形如 `posters/xxx.jpg`,否则手搓请求能让一行记录指向
别的家庭的存储对象。
⚠️ 只在"有人给了这个字段"时改(`$has` 语义):改别的字段时
顺带把海报清空是最不能接受的那种数据丢失。 */
'poster_key' => $has('poster_key')
? (preg_match('#^posters/[A-Za-z0-9._-]+$#', Api::str('poster_key'))
? Api::str('poster_key') : '')
: (string)($cur['poster_key'] ?? ''),
]; ];
} }
+33
View File
@@ -327,6 +327,14 @@ final class Douban
'episode' => $episode, 'episode' => $episode,
'kind' => self::kindLabel($type, $episode), 'kind' => self::kindLabel($type, $episode),
'thumb' => $img !== '' ? self::thumbProxyUrl($img) : '', 'thumb' => $img !== '' ? self::thumbProxyUrl($img) : '',
/* 🔴 v64:**原始**豆瓣图片地址(已过白名单)也带上。
⚠️ 为什么需要它:`thumb` 是**网页路由**的代理地址
(`index.php?r=movies/douban&action=thumb&u=…`),
手机端拿不到 `index.php?r=` 这种入口 —— 它只会打 `/api/v1/…`。
端上要用 `apiThumbUrl()` 把这个原始地址重新拼成 **API 形式**。
⚠️ 只有过白名单的才给(没过的一律空串)——
否则等于把"服务端代取任意 URL"的 SSRF 跳板下发给客户端。 */
'img' => self::imageUrlAllowed($img) ? $img : '',
]; ];
if (count($items) >= $limit) { if (count($items) >= $limit) {
break; break;
@@ -381,6 +389,31 @@ final class Douban
return 'index.php?r=movies/douban&action=thumb&u=' . rawurlencode($img); return 'index.php?r=movies/douban&action=thumb&u=' . rawurlencode($img);
} }
/**
* 豆瓣图片地址 → **移动端 API** 代理地址(`/api/v1/movies/douban?action=thumb&u=…`)。
*
* 🔴 v64:端上只能打 `/api/v1/…`,拿不到 `index.php?r=` 那种网页入口
* (见 `buildSuggestItems` 里 `img` 字段的说明)。
* ⇒ 拼法在这里**定义一次**,端上不自己拼串 ——
* 免得以后接口路径一改,两端各改一份还对不齐。
*
* ⚠️ 与网页路由**同构**(那边是 `movies/douban&action=thumb`):
* `Api::dispatch()` 只解析到「资源 / id / 子动作」两层,
* `/movies/douban/thumb` 这种三段路径它认不出来(会把 thumb 当子动作丢掉)。
* 用 query 传 action 是最省事、也不会跟现有路由打架的形态。
*
* ⚠️ 返回值是**相对路径**,端上要自己补服务器基址并追加 `&token=`
* (`Image()` 加不了请求头,见 `media.php` 的说明)。
* 这样同一个地址在"用户换服务器"后仍然有效。
*/
public static function apiThumbUrl(string $img): string
{
if (!self::imageUrlAllowed($img)) {
return '';
}
return '/api/v1/movies/douban?action=thumb&u=' . rawurlencode($img);
}
// ---------------- HTTP 与格式识别 ---------------- // ---------------- HTTP 与格式识别 ----------------
private static function searchEndpoint(string $q): string private static function searchEndpoint(string $q): string
+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 public static function deletePhoto(int $familyId, int $eventId, int $photoId): void
{ {