Files
FamilyBook/app/Movie.php
T
laoyang c6e4191bb3 观影照片支持视频:转 H.264 + 抽首帧封面 (#84)
用户:确认一下观影照片是不是允许上传视频,如果不允许需要修改,
要求能上传视频、视频需要转码,参考旅程中的行程做法。

事实核查:原来不允许 —— POST /movies/<id>/photos 走 Upload::image(),
那条管线只收 JPG/PNG/GIF/WebP(要裁剪缩转运 JPEG),传视频直接拒。
旅程走 Attachment::save(),内部对 video/* 调 Video::toH264()。

Schema VERSION 35→36:
- event_photos 加 kind / thumb_key / mime 三列;
  kind 默认 image —— 老数据全是照片,默认值即正确,不需要回填 UPDATE;
- 建表语句同步改(新库直接是新结构)。

Video.php:
- 新增 poster() 抽首帧。取第 1 秒(手机视频开头常是黑场/对焦),
  宽 480 足够列表缩略;失败返回空 path 且**不阻断上传**。

ApiHandler.php 上传分支按 MIME 分流:
- 视频 → toH264 转码 + poster 抽封面 + Storage 落盘 videos/ 与 thumbs/;
  转码失败不阻断(存原视频,至少能下载后用本地播放器看);
- 图片 → 仍走 Upload::image(),裁剪压缩行为完全不变。
- 返回值加了 kind / thumb / mime,端上靠它画播放按钮。

Movie.php:
- photos() 带出 kind/thumb_key/mime;
- addPhotos() 兼容裸键数组与结构化数组,只有一条 INSERT 路径;
- deletePhoto() / destroy() 补删 thumb_key,否则删视频留孤儿封面。

验证(服务器实测):造 H.265 视频 → needsTranscode=true,toH264 成功
转成 h264/yuv420p,poster 抽出 13KB 封面;完整上传后接口返回 5 条
(4 image + 1 video 带 thumb)。迁移已跑(35→36),字段与数据核实无误,
测试视频及封面已清理,videos/ 与 thumbs/ 目录确认为空。
2026-11-06 10:53:02 +08:00

477 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 观影记录(统一事件原语的第一个落地种类)。
*
* 归一模型(见 familybook.md 的核心设计):
* events = 一切记录的共同骨架(标题 / 起止时刻 / 地点 / 来源引用)
* movie_details = 观影特有的卫星表(海报 / 票根 / 关联影院)
* attendees = 同行观影的人(persons.id)
* photos = 现场照片(可多张)
* reviews = 评价与讨论(movie_reviews / movie_review_comments,见 MovieReview)
* cinemas = 影院独立管理(见 Cinema)
*
* 图片一律存**相对键**(如 posters/xxx.jpg),地址由 media.php 代理 ——
* 本地与 OSS 的差别被 Storage 吃掉,这里永远不碰完整 URL。
*
* source_ref 是迁移幂等的锚点:MovieLog 整体迁入时写成 'movielog:movie:<id>',
* 重跑迁移按它先查再插,不会出现一半新一半旧的中间态。
*/
declare(strict_types=1);
namespace App;
final class Movie
{
/** @return array<int,array> 观影记录,按开场时刻倒序(瀑布流用)。
* $keyword 非空时搜:片名 / 影院名 / 同行人名(LIKE)。
*
* ⚠️ 列表上的「观影人」是**每人一个标签**(头像 + 底色 + 称呼),
* 不再拼成一串名字 —— 所以这里用 attachAttendees() 补出 `attendees`,
* 而不是 SQL 里 GROUP_CONCAT 出一个 attendee_names 字符串。 */
public static function all(int $familyId, string $keyword = '', int $viewerId = 0): array
{
$sql =
'SELECT e.id, e.title, e.starts_at, e.ends_at, e.note,
d.poster_key, c.name AS cinema_name, c.color AS cinema_color,
(SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS attendee_count
FROM events e
LEFT JOIN movie_details d ON d.event_id = e.id
LEFT JOIN cinemas c ON c.id = d.cinema_id
WHERE e.family_id = ? AND e.kind = "movie"';
$bind = [$familyId];
$kw = trim($keyword);
if ($kw !== '') {
$sql .= ' AND (e.title LIKE ? OR c.name LIKE ? OR e.location LIKE ?
OR EXISTS (SELECT 1 FROM event_attendees a JOIN persons p ON p.id = a.person_id
WHERE a.event_id = e.id AND p.name LIKE ?))';
$like = '%' . $kw . '%';
array_push($bind, $like, $like, $like, $like);
}
$sql .= ' ORDER BY e.starts_at DESC, e.id DESC';
$st = Database::get()->prepare($sql);
$st->execute($bind);
$rows = $st->fetchAll();
MovieReview::attachStats($rows);
self::attachAttendees($rows, $viewerId);
return $rows;
}
/**
* 给一批记录补上 `attendees`(每人一条:id / name / my_nickname / avatar /
* avatar_color)。**一次查询**补完,不逐条查 —— 瀑布流一页几十部电影,
* 逐条查就是几十次往返(N+1)。
*/
public static function attachAttendees(array &$rows, int $viewerId = 0): void
{
if (!$rows) {
return;
}
$ids = [];
foreach ($rows as $r) {
$ids[] = (int)$r['id'];
}
$ids = array_values(array_unique($ids));
$in = implode(',', array_fill(0, count($ids), '?'));
$st = Database::get()->prepare(
"SELECT a.event_id, p.id, p.name, p.avatar, p.avatar_color,
n.nickname AS my_nickname
FROM event_attendees a
JOIN persons p ON p.id = a.person_id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE a.event_id IN ($in)
ORDER BY a.position, p.id"
);
$st->execute(array_merge([$viewerId], $ids));
$map = [];
foreach ($st->fetchAll() as $r) {
$map[(int)$r['event_id']][] = $r;
}
foreach ($rows as $k => $r) {
$rows[$k]['attendees'] = $map[(int)$r['id']] ?? [];
}
}
/** 一条完整记录(详情/编辑页用):事件 + 卫星 + 影院 + 同行人 + 照片 */
public static function find(int $familyId, int $id, int $viewerId = 0): ?array
{
$st = Database::get()->prepare(
'SELECT e.*, d.info, d.poster_key, d.ticket_key, d.cinema_id, d.douban_meta,
c.place AS cinema_place, c.name AS cinema_name,
c.address AS cinema_address, c.color AS cinema_color
FROM events e
LEFT JOIN movie_details d ON d.event_id = e.id
LEFT JOIN cinemas c ON c.id = d.cinema_id
WHERE e.family_id = ? AND e.id = ? AND e.kind = "movie"'
);
$st->execute([$familyId, $id]);
$row = $st->fetch();
if (!$row) {
return null;
}
$row['attendees'] = self::attendees($id, $viewerId);
$row['photos'] = self::photos($id);
return $row;
}
/**
* 同行的人。带 `my_nickname` 与 `avatar_color` —— 页面上要按「我叫他什么」
* 显示、并用他自己的底色画出标签(见 helpers 的 person_name / person_bg)。
*/
public static function attendees(int $eventId, int $viewerId = 0): array
{
$st = Database::get()->prepare(
'SELECT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname
FROM event_attendees a JOIN persons p ON p.id = a.person_id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE a.event_id = ?
ORDER BY a.position, p.id'
);
$st->execute([$viewerId, $eventId]);
return $st->fetchAll();
}
public static function photos(int $eventId): array
{
/* 🔴 2026-11-06:带上 `kind` / `thumb_key` / `mime`(观影照片支持视频)。
⚠️ 用 `COALESCE` 兜底 `kind`:极老的库(迁移没跑过)可能没这列,
但这种情形会直接报 1054 —— 迁移一定会先跑,这里不加 COALESCE。
真正需要兜底的是**值为 NULL** 的历史行(迁移加列时默认 'image',
但手写过 INSERT 的行可能是 NULL)。 */
$st = Database::get()->prepare(
"SELECT id, `key`,
COALESCE(`kind`, 'image') AS kind,
`thumb_key`, `mime`
FROM event_photos WHERE event_id = ? ORDER BY position, id"
);
$st->execute([$eventId]);
return $st->fetchAll();
}
/**
* 新建一条观影记录,返回事件 id。
* $d 字段:title, starts_at(Y-m-d H:i:s), ends_at, location, note, info,
* cinema_id(int|null), poster_key, ticket_key, attendees(int[]), photos(string[])
*/
public static function create(int $familyId, ?int $creatorId, array $d): int
{
$db = Database::get();
$db->prepare(
'INSERT INTO events (family_id, creator_id, kind, title, starts_at, ends_at, location, note, source_ref)
VALUES (?, ?, "movie", ?, ?, ?, ?, ?, ?)'
)->execute([
$familyId, $creatorId, (string)$d['title'],
self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''),
self::locOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''),
$d['source_ref'] ?? null,
]);
$id = (int)$db->lastInsertId();
self::saveDetails($id, $d);
self::saveAttendees($id, $d['attendees'] ?? []);
self::savePhotos($id, $d['photos'] ?? []);
return $id;
}
public static function update(int $familyId, int $id, array $d): void
{
$db = Database::get();
$db->prepare(
'UPDATE events SET title = ?, starts_at = ?, ends_at = ?, location = ?, note = ?
WHERE id = ? AND family_id = ?'
)->execute([
(string)$d['title'],
self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''),
self::locOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''),
$id, $familyId,
]);
self::saveDetails($id, $d);
self::saveAttendees($id, $d['attendees'] ?? []);
/* 🔴 v35:原来这里只留一句"photos 由单独的删除路由维护,编辑保存不整组替换",
于是**根本没写 photos** —— 而 movies/update 路由明明算好了 `$d['photos']`
(库里已有的 + 这次新选上来的)传进来,**被整段忽略**。
症状正是用户报的「上传的附件现在都没有保存」「上传两张只保存一张」。
⇒ updated 时一并写回(路由已经保证 `$d['photos']` = 已有 + 新增,
不是空数组;真的没传时路由会给 `$d['photos']` 缺席,
`?? []` 也不会误清空 —— 见 movies/update 分支的条件)。 */
if (array_key_exists('photos', $d)) {
self::savePhotos($id, $d['photos']);
}
}
/** 卫星表 upsert(编辑时可能还没建过这行) */
private static function saveDetails(int $eventId, array $d): void
{
$db = Database::get();
$cid = (int)($d['cinema_id'] ?? 0);
// 豆瓣元数据:进来是数组(表单提交的 JSON 已由路由解码校验),存成 JSON 文本;
// 空数组/缺省 = 不写(保持 NULL,详情页就不渲染豆瓣块)
$meta = isset($d['douban_meta']) && is_array($d['douban_meta']) && $d['douban_meta'] !== []
? json_encode($d['douban_meta'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
: null;
$row = [
self::textOrNull($d['info'] ?? ''),
self::keyOrNull($d['poster_key'] ?? ''),
self::keyOrNull($d['ticket_key'] ?? ''),
$cid > 0 ? $cid : null,
$meta,
];
$has = $db->prepare('SELECT COUNT(*) FROM movie_details WHERE event_id = ?');
$has->execute([$eventId]);
if ((int)$has->fetchColumn() > 0) {
$db->prepare('UPDATE movie_details SET info=?, poster_key=?, ticket_key=?, cinema_id=?, douban_meta=? WHERE event_id=?')
->execute([...$row, $eventId]);
} else {
$db->prepare('INSERT INTO movie_details (info, poster_key, ticket_key, cinema_id, douban_meta, event_id) VALUES (?,?,?,?,?,?)')
->execute([...$row, $eventId]);
}
}
/** 整组替换同行人(与 Person::replaceContacts 同一思路:一行不剩、一行不多) */
public static function saveAttendees(int $eventId, array $personIds): void
{
$db = Database::get();
$db->prepare('DELETE FROM event_attendees WHERE event_id = ?')->execute([$eventId]);
$ins = $db->prepare(
'INSERT IGNORE INTO event_attendees (event_id, person_id, position) VALUES (?, ?, ?)'
);
$pos = 0;
foreach ($personIds as $pid) {
$pid = (int)$pid;
if ($pid <= 0) {
continue;
}
$pos += 10;
$ins->execute([$eventId, $pid, $pos]);
}
}
/** 整组替换现场照片(导入用;表单的「删一张」走 deletePhoto) */
public static function savePhotos(int $eventId, array $keys): void
{
$db = Database::get();
$db->prepare('DELETE FROM event_photos WHERE event_id = ?')->execute([$eventId]);
$ins = $db->prepare('INSERT INTO event_photos (event_id, `key`, position) VALUES (?, ?, ?)');
$pos = 0;
foreach ($keys as $k) {
$k = trim((string)$k);
if ($k === '') {
continue;
}
$pos += 10;
$ins->execute([$eventId, $k, $pos]);
}
}
/**
* **只设海报**(不动其它字段)。
*
* 🔴 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
{
/* ⚠️ 兼容两种入参(2026-11-06 起):
· `['a.jpg', 'b.jpg']` —— 老调用方(网页表单、端上旧版)
· `[['key'=>'a.mp4','kind'=>'video','thumb_key'=>'…','mime'=>'video/mp4']]`
—— 新调用方(带类型信息)
统一归一成"每项一个数组",下面只有一条 INSERT 路径,
不会出现"有的走老分支有的走新分支"那种分叉。 */
$rows = [];
foreach ($keys as $item) {
if (is_array($item)) {
$k = trim((string)($item['key'] ?? ''));
if ($k === '') {
continue;
}
$rows[] = [
'key' => $k,
'kind' => ((string)($item['kind'] ?? 'image')) === 'video' ? 'video' : 'image',
'thumb_key' => (string)($item['thumb_key'] ?? ''),
'mime' => (string)($item['mime'] ?? ''),
];
} else {
$k = trim((string)$item);
if ($k === '') {
continue;
}
$rows[] = ['key' => $k, 'kind' => 'image', 'thumb_key' => '', 'mime' => ''];
}
}
if ($rows === []) {
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`, kind, thumb_key, mime, position)
VALUES (?, ?, ?, ?, ?, ?)'
);
foreach ($rows as $r) {
$pos += 10;
$ins->execute([
$eventId, $r['key'], $r['kind'],
$r['thumb_key'] !== '' ? $r['thumb_key'] : null,
$r['mime'] !== '' ? $r['mime'] : null,
$pos,
]);
}
}
/** 删一张现场照片(连带删存储对象) */
public static function deletePhoto(int $familyId, int $eventId, int $photoId): void
{
/* 🔴 2026-11-06:视频的**封面**也要一起删(`thumb_key`),
否则每删一个视频就在存储里留一张孤儿封面。 */
$st = Database::get()->prepare(
'SELECT `key`, `thumb_key` FROM event_photos WHERE id = ? AND event_id = ?'
);
$st->execute([$photoId, $eventId]);
$row = $st->fetch();
$key = $row ? (string)($row['key'] ?? '') : '';
$thumb = $row ? (string)($row['thumb_key'] ?? '') : '';
Database::get()->prepare('DELETE FROM event_photos WHERE id = ? AND event_id = ?')
->execute([$photoId, $eventId]);
if ($key !== '') {
Storage\Storage::delete($familyId, $key);
}
if ($thumb !== '' && $thumb !== $key) {
Storage\Storage::delete($familyId, $thumb);
}
}
/** 删除整条观影记录(events 级联带走卫星/同行人/照片行;存储对象手动清) */
public static function destroy(int $familyId, int $id): void
{
$keys = [];
foreach (self::photos($id) as $p) {
$keys[] = (string)$p['key'];
/* 视频的封面也要清(2026-11-06) */
$tk = (string)($p['thumb_key'] ?? '');
if ($tk !== '') {
$keys[] = $tk;
}
}
$st = Database::get()->prepare(
'SELECT poster_key, ticket_key FROM movie_details WHERE event_id = ?'
);
$st->execute([$id]);
if ($row = $st->fetch()) {
foreach ([$row['poster_key'], $row['ticket_key']] as $k) {
if ($k) {
$keys[] = (string)$k;
}
}
}
Database::get()->prepare('DELETE FROM events WHERE id = ? AND family_id = ? AND kind = "movie"')
->execute([$id, $familyId]);
foreach ($keys as $k) {
Storage\Storage::delete($familyId, $k);
}
}
// ---------------- 展示格式化 ----------------
/**
* 观影时刻的人话展示:
* 只有日期(时间恰好 00:00:00 且无散场)→「2026年09月30日」;
* 有开场时刻 →「2026年09月30日 19:30:00」;有散场 → 追加「 – 21:45:12」。
* 两个都空 →「日期待补」。
*/
public static function formatWatch(?string $start, ?string $end): string
{
if ($start === null || $start === '') {
return '日期待补';
}
$ts = strtotime($start);
if ($ts === false) {
return '日期待补';
}
$out = date('Y年m月d日', $ts);
$midnight = substr((string)$start, 11) === '00:00:00';
if (!$midnight) {
$out .= ' ' . date('H:i:s', $ts);
}
if ($end !== null && $end !== '') {
$ets = strtotime($end);
if ($ets !== false) {
if (date('Y-m-d', $ets) !== date('Y-m-d', $ts)) {
$out = date('Y年m月d日', $ts) . ($midnight ? '' : ' ' . date('H:i:s', $ts))
. ' – ' . date('Y年m月d日 H:i:s', $ets);
} else {
$out .= ' – ' . date('H:i:s', $ets);
}
}
}
return $out;
}
// ---------------- 小工具 ----------------
/** 'Y-m-d H:i:s' 校验(秒级精确);空串/不合法回 NULL */
private static function dtOrNull(string $v): ?string
{
$v = trim($v);
if ($v === '' || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $v)) {
return null;
}
return $v;
}
private static function locOrNull(string $v): ?string
{
$v = mb_substr(trim($v), 0, 200, 'UTF-8');
return $v !== '' ? $v : null;
}
private static function textOrNull(string $v): ?string
{
$v = trim($v);
return $v !== '' ? $v : null;
}
/** 存储键只留安全字符(相对键,不带路径穿越),空串回 NULL */
private static function keyOrNull(string $v): ?string
{
$v = ltrim(trim($v), '/');
if ($v === '' || strpos($v, '..') !== false || strpos($v, "\0") !== false) {
return null;
}
return $v;
}
}