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:
@@ -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'] ?? ''),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user