From 3459eea8f3267a2d997f99aac4c357a6f7a0d30d Mon Sep 17 00:00:00 2001 From: laoyang Date: Mon, 19 Oct 2026 23:41:15 +0800 Subject: [PATCH] =?UTF-8?q?v64=EF=BC=9A=E8=A7=82=E5=BD=B1=E8=B1=86?= =?UTF-8?q?=E7=93=A3=E8=87=AA=E5=8A=A8=E8=8E=B7=E5=8F=96=20+=20=E6=B5=B7?= =?UTF-8?q?=E6=8A=A5/=E5=A4=9A=E5=9B=BE=E4=B8=8A=E4=BC=A0=EF=BC=88?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E7=AB=AF=20API=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 端上「记一场」要与网页端同步实现(用户 2026-10-19 六条), 服务端这一半只加**移动端入口**,网页端(index.php 那条路由)一行没动。 - ApiHandler:新增 `/movies/douban` 子路由(action=suggest / action=thumb / 默认抓详情+海报落盘),逻辑与网页端 `movies/douban` 逐条对齐; 新增 `/movies//poster` 与 `/movies//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 已含两条新能力 --- app/ApiHandler.php | 266 +++++++++++++++++++++++++++++++++++++++++++++ app/Douban.php | 33 ++++++ app/Movie.php | 62 +++++++++++ 3 files changed, 361 insertions(+) diff --git a/app/ApiHandler.php b/app/ApiHandler.php index 8cb4868..287bb6b 100644 --- a/app/ApiHandler.php +++ b/app/ApiHandler.php @@ -53,6 +53,16 @@ final class ApiHandler 'movies_stats' => 1, 'movies_report' => 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//poster|photos`(multipart 上传)。 + 端上可据此探测:老服务端没有这两条时不显示「自动获取」与传图入口。 */ + 'movies_douban' => 1, + 'movie_media' => 1, // v58:同步可以"先问一句哪些模块变了",只拉变了的 // (见 `changes()` 的说明;老端上没有这个标志也不会坏) '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) { $m = Movie::find($fam, $id, $pid); 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 ($method === 'GET') { $rv = MovieReview::mine($id, $pid); @@ -1878,6 +2089,50 @@ final class ApiHandler Api::list(array_slice(array_values($list), $off, $per), count($list)); } + /** + * 把 `$_FILES['name']` 归一成「一个文件一个数组」的列表(支持 `name[]` 多文件)。 + * + * 🔴 v64:端上「记一场」允许多张观影照片(用户:「允许上传多张图片」)。 + * PHP 对 `` 给的 + * `$_FILES['photo']` 是**按字段竖切**的(`name`/`type`/`tmp_name`/… 各一个数组), + * 每个 `Upload::image()` 要的是**横切**的一行。 + * ⇒ 这里做一次转置,别在业务分支里手写索引。 + * + * ⚠️ 单文件与多文件两种形态都要认(单文件时 `name` 是字符串,不是数组)—— + * 端上第一次传 1 张、第二次传 3 张,走的是同一段代码。 + * ⚠️ `UPLOAD_ERR_NO_FILE` 的那种"空槽"要**丢掉**: + * 文件多选控件在用户取消选择时会留一个空槽,交给 `Upload::image` + * 会被判成"没传图"(error 空串)从而静默少一张 —— 不如这里就过滤掉。 + * + * @return array + */ + 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` 非空时做"只改给了的字段"的回退) */ 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), 'attendees' => $has('attendees') ? Api::ints('attendees') : array_map( 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'] ?? ''), ]; } diff --git a/app/Douban.php b/app/Douban.php index 9432b24..b6b0d48 100644 --- a/app/Douban.php +++ b/app/Douban.php @@ -327,6 +327,14 @@ final class Douban 'episode' => $episode, 'kind' => self::kindLabel($type, $episode), '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) { break; @@ -381,6 +389,31 @@ final class Douban 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 与格式识别 ---------------- private static function searchEndpoint(string $q): string diff --git a/app/Movie.php b/app/Movie.php index e0548dd..5244183 100644 --- a/app/Movie.php +++ b/app/Movie.php @@ -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 {