', * 重跑迁移按它先查再插,不会出现一半新一半旧的中间态。 */ declare(strict_types=1); namespace App; final class Movie { /** @return 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 { $st = Database::get()->prepare( 'SELECT id, `key` 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]); } } /** 删一张现场照片(连带删存储对象) */ public static function deletePhoto(int $familyId, int $eventId, int $photoId): void { $st = Database::get()->prepare('SELECT `key` FROM event_photos WHERE id = ? AND event_id = ?'); $st->execute([$photoId, $eventId]); $key = (string)($st->fetchColumn() ?: ''); Database::get()->prepare('DELETE FROM event_photos WHERE id = ? AND event_id = ?') ->execute([$photoId, $eventId]); if ($key !== '') { Storage\Storage::delete($familyId, $key); } } /** 删除整条观影记录(events 级联带走卫星/同行人/照片行;存储对象手动清) */ public static function destroy(int $familyId, int $id): void { $keys = []; foreach (self::photos($id) as $p) { $keys[] = (string)$p['key']; } $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; } }