Files
FamilyBook/app/Movie.php
T
laoyang 835f2846f2 取消「票根」概念,并入观影照片(数据迁移 220 条)(#87)
用户:现在不是已经没有票根的概念了吗?全部都换成观影照片了。别管是票根
还是观影照片,都在一起。现在只分两类:一类是海报,一类是观影照片。
海报是来自豆瓣的,这个不用管它,主要就是观影照片,所有的都在观影照片里边。

事实核查:概念取消了,数据没取消。网页端 v35 只在**模板层**把它们画进
同一个网格,数据层仍是两条路:票根在 movie_details.ticket_key(单张槽位),
不在 event_photos 里 ⇒ 没有 id ⇒ 删不掉,端上得写"票根另算"特判。

线上数据:有票根 220 条,其中 209 条只有票根、没有任何照片;
event_photos 总数才 14 —— 也就是说「观影照片」里装的几乎全是票根。

① 数据迁移(已执行,220/220 成功,失败 0):
   逐条把 ticket_key 插进 event_photos(position 取比现有最小值再小 10,
   ⇒ 排第一,因为票根是"这场电影的凭证",用户认知里排最前),然后置 NULL。
   event_photos 14 → 234,旧票根残留 0。
   ⚠️ 幂等:靠"该 key 已在该 event 的 event_photos 里"判断,重复跑不插重复行;
   ⚠️ 不删存储对象:只搬引用,图片原地不动,失败也不丢图。

② 同一段逻辑写进 Schema VERSION 36→37,让新库/重装库走同一套,
   不靠"记得手工跑脚本"。对线上是空操作(已验证 234→234)。

③ 服务端停止读写票根字段(字段保留作历史审计,代码不再碰):
   - Movie::find 不再 SELECT ticket_key;
   - Movie::saveDetails UPSERT 去掉 ticket_key;
   - Movie::destroy 不再单独查/删票根对象(已并入 photos 循环);
   - index.php:上传槽位 [poster,ticket] → [poster],
     stage_ticket 不再认领,media_del 白名单只剩 poster;
   - form.php:删票根特殊格子 + mv-del-ticket 表单 + $mHasTicket;
   - show.php:删票根格子 + 灯箱 ticket 项 + $hasTicket。

验证:find(1,1)/find(1,224) 已不返回 ticket_key,photos 3/4 条票根在首位;
直接 include 模板无 Fatal,form.php 里 mv-del-ticket / stage_ticket 各 0 次;
6 个文件 md5 逐个核对一致、php -l 全过、已上线且升级到 37。
2026-11-06 11:25:47 +08:00

488 lines
21 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
{
/* 🔴 2026-11-06:**不再 SELECT `ticket_key`**。
用户要求"只分两类:海报、观影照片",旧票根已经一次性迁进
`event_photos`(见迁移脚本与 Schema VERSION 37 的说明),
字段本身也置 NULL 了。继续查它出来只会让上层又长出第三个概念。 */
$st = Database::get()->prepare(
'SELECT e.*, d.info, d.poster_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, attendees(int[]), photos(string[])
* ⚠️ 2026-11-06:`ticket_key` 已取消(票根并入 photos)。
*/
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'] ?? ''),
$cid > 0 ? $cid : null,
$meta,
];
$has = $db->prepare('SELECT COUNT(*) FROM movie_details WHERE event_id = ?');
$has->execute([$eventId]);
/* 🔴 2026-11-06:`ticket_key` 从这条 UPSERT 里**移除**了。
票根概念已取消(迁进 `event_photos`),再写它等于把它复活。
⚠️ 列还留在表里(不清字段,留作历史审计),但代码不再碰它;
迁移已把存量置 NULL,所以它现在是全 NULL 的死列。 */
if ((int)$has->fetchColumn() > 0) {
$db->prepare('UPDATE movie_details SET info=?, poster_key=?, cinema_id=?, douban_meta=? WHERE event_id=?')
->execute([...$row, $eventId]);
} else {
$db->prepare('INSERT INTO movie_details (info, poster_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;
}
}
/* 🔴 2026-11-06:票根已并入 `event_photos`,上面的 photos() 循环就把
它收集进来了 —— 原来这里还要单独查一次 `ticket_key` 并删对象,
现在**不需要且不该要**(字段已置 NULL,查出来也是空)。
⚠️ 海报仍单独存在 `movie_details.poster_key`,所以要留这一段。 */
$st = Database::get()->prepare(
'SELECT poster_key FROM movie_details WHERE event_id = ?'
);
$st->execute([$id]);
if ($row = $st->fetch()) {
$k = (string)($row['poster_key'] ?? '');
if ($k !== '') {
$keys[] = $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;
}
}