', * 重跑迁移按它先删后插,不会出现一半新一半旧的中间态。 */ declare(strict_types=1); namespace App; final class Movie { /** 观后感受标签(MovieLog 迁移时把英文键映射成中文,这里就是唯一清单) */ public const FEELINGS = ['温暖', '激动', '欢乐', '深思', '伤感', '震撼', '治愈', '无聊']; /** MovieLog 的英文感受键 → 中文标签(没见过的键原样保留) */ public const FEELING_MAP = [ 'warm' => '温暖', 'excited' => '激动', 'thoughtful' => '深思', 'sad' => '伤感', 'funny' => '欢乐', 'happy' => '欢乐', 'boring' => '无聊', 'moved' => '治愈', 'shocked' => '震撼', ]; /** @return array 按年份倒序的全部观影记录(时间线用) */ public static function all(int $familyId): array { $st = Database::get()->prepare( 'SELECT e.id, e.title, e.starts_at, e.location, e.note, d.poster_key, d.ticket_key, d.rating, d.cinema_name, d.cinema_place, (SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS attendee_count, (SELECT GROUP_CONCAT(p.name ORDER BY a.position, p.id SEPARATOR "、") FROM event_attendees a JOIN persons p ON p.id = a.person_id WHERE a.event_id = e.id) AS attendee_names FROM events e LEFT JOIN movie_details d ON d.event_id = e.id WHERE e.family_id = ? AND e.kind = "movie" ORDER BY e.starts_at DESC, e.id DESC' ); $st->execute([$familyId]); return $st->fetchAll(); } /** 一条完整记录(详情/编辑页用):事件 + 卫星 + 同行人 + 照片 */ public static function find(int $familyId, int $id): ?array { $st = Database::get()->prepare( 'SELECT e.*, d.info, d.poster_key, d.ticket_key, d.rating, d.review, d.feelings, d.cinema_place, d.cinema_name, d.cinema_address FROM events e LEFT JOIN movie_details d ON d.event_id = e.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); $row['photos'] = self::photos($id); return $row; } /** @return array 同行的人 */ public static function attendees(int $eventId): array { $st = Database::get()->prepare( 'SELECT p.id, p.name, p.avatar FROM event_attendees a JOIN persons p ON p.id = a.person_id WHERE a.event_id = ? ORDER BY a.position, p.id' ); $st->execute([$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), location, note, info, rating, review, * feelings(array), cinema_place, cinema_name, cinema_address, * 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, location, note, source_ref) VALUES (?, ?, "movie", ?, ?, ?, ?, ?)' )->execute([ $familyId, $creatorId, (string)$d['title'], self::dateOrNull($d['starts_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 = ?, location = ?, note = ? WHERE id = ? AND family_id = ?' )->execute([ (string)$d['title'], self::dateOrNull($d['starts_at'] ?? ''), self::locOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''), $id, $familyId, ]); self::saveDetails($id, $d); self::saveAttendees($id, $d['attendees'] ?? []); // photos 由单独的「删除某张」路由维护,编辑保存不整组替换 } /** 卫星表 upsert(编辑时可能还没建过这行) */ private static function saveDetails(int $eventId, array $d): void { $db = Database::get(); $feel = array_values(array_filter(array_map( static fn($f) => mb_substr(trim((string)$f), 0, 20, 'UTF-8'), (array)($d['feelings'] ?? []) ), static fn($f) => $f !== '')); $row = [ self::textOrNull($d['info'] ?? ''), self::keyOrNull($d['poster_key'] ?? ''), self::keyOrNull($d['ticket_key'] ?? ''), isset($d['rating']) && $d['rating'] !== '' ? max(0, min(10, (int)$d['rating'])) : null, self::textOrNull($d['review'] ?? ''), $feel ? json_encode($feel, JSON_UNESCAPED_UNICODE) : null, self::locOrNull($d['cinema_place'] ?? ''), self::locOrNull($d['cinema_name'] ?? ''), self::textOrNull($d['cinema_address'] ?? ''), ]; $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=?, rating=?, review=?, feelings=?, cinema_place=?, cinema_name=?, cinema_address=? WHERE event_id=?' )->execute([...$row, $eventId]); } else { $db->prepare( 'INSERT INTO movie_details (info, poster_key, ticket_key, rating, review, feelings, cinema_place, cinema_name, cinema_address, 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); } } // ---------------- 小工具 ---------------- private static function dateOrNull(string $v): ?string { $v = trim($v); if ($v === '' || !preg_match('/^\d{4}-\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; } }