2026-09-30 20:22:46 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 观影记录(统一事件原语的第一个落地种类)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 归一模型(见 familybook.md 的核心设计):
|
2026-10-01 00:12:04 +08:00
|
|
|
|
* events = 一切记录的共同骨架(标题 / 起止时刻 / 地点 / 来源引用)
|
|
|
|
|
|
* movie_details = 观影特有的卫星表(海报 / 票根 / 关联影院)
|
2026-09-30 20:22:46 +08:00
|
|
|
|
* attendees = 同行观影的人(persons.id)
|
|
|
|
|
|
* photos = 现场照片(可多张)
|
2026-10-01 00:12:04 +08:00
|
|
|
|
* reviews = 评价与讨论(movie_reviews / movie_review_comments,见 MovieReview)
|
|
|
|
|
|
* cinemas = 影院独立管理(见 Cinema)
|
2026-09-30 20:22:46 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 图片一律存**相对键**(如 posters/xxx.jpg),地址由 media.php 代理 ——
|
|
|
|
|
|
* 本地与 OSS 的差别被 Storage 吃掉,这里永远不碰完整 URL。
|
|
|
|
|
|
*
|
|
|
|
|
|
* source_ref 是迁移幂等的锚点:MovieLog 整体迁入时写成 'movielog:movie:<id>',
|
2026-10-01 00:12:04 +08:00
|
|
|
|
* 重跑迁移按它先查再插,不会出现一半新一半旧的中间态。
|
2026-09-30 20:22:46 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace App;
|
|
|
|
|
|
|
|
|
|
|
|
final class Movie
|
|
|
|
|
|
{
|
2026-10-01 00:12:04 +08:00
|
|
|
|
/** @return array<int,array> 观影记录,按开场时刻倒序(瀑布流用)。
|
|
|
|
|
|
* $keyword 非空时搜:片名 / 影院名 / 同行人名(LIKE)。 */
|
|
|
|
|
|
public static function all(int $familyId, string $keyword = ''): array
|
2026-09-30 20:22:46 +08:00
|
|
|
|
{
|
2026-10-01 00:12:04 +08:00
|
|
|
|
$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,
|
2026-09-30 20:22:46 +08:00
|
|
|
|
(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
|
2026-10-01 00:12:04 +08:00
|
|
|
|
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);
|
|
|
|
|
|
return $rows;
|
2026-09-30 20:22:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-01 00:12:04 +08:00
|
|
|
|
/** 一条完整记录(详情/编辑页用):事件 + 卫星 + 影院 + 同行人 + 照片 */
|
2026-09-30 20:22:46 +08:00
|
|
|
|
public static function find(int $familyId, int $id): ?array
|
|
|
|
|
|
{
|
|
|
|
|
|
$st = Database::get()->prepare(
|
2026-10-01 10:21:45 +08:00
|
|
|
|
'SELECT e.*, d.info, d.poster_key, d.ticket_key, d.cinema_id, d.douban_meta,
|
2026-10-01 00:12:04 +08:00
|
|
|
|
c.place AS cinema_place, c.name AS cinema_name,
|
|
|
|
|
|
c.address AS cinema_address, c.color AS cinema_color
|
2026-09-30 20:22:46 +08:00
|
|
|
|
FROM events e
|
|
|
|
|
|
LEFT JOIN movie_details d ON d.event_id = e.id
|
2026-10-01 00:12:04 +08:00
|
|
|
|
LEFT JOIN cinemas c ON c.id = d.cinema_id
|
2026-09-30 20:22:46 +08:00
|
|
|
|
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<int,array{id:int,name:string,avatar:?string}> 同行的人 */
|
|
|
|
|
|
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。
|
2026-10-01 00:12:04 +08:00
|
|
|
|
* $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[])
|
2026-09-30 20:22:46 +08:00
|
|
|
|
*/
|
|
|
|
|
|
public static function create(int $familyId, ?int $creatorId, array $d): int
|
|
|
|
|
|
{
|
|
|
|
|
|
$db = Database::get();
|
|
|
|
|
|
$db->prepare(
|
2026-10-01 00:12:04 +08:00
|
|
|
|
'INSERT INTO events (family_id, creator_id, kind, title, starts_at, ends_at, location, note, source_ref)
|
|
|
|
|
|
VALUES (?, ?, "movie", ?, ?, ?, ?, ?, ?)'
|
2026-09-30 20:22:46 +08:00
|
|
|
|
)->execute([
|
2026-10-01 00:12:04 +08:00
|
|
|
|
$familyId, $creatorId, (string)$d['title'],
|
|
|
|
|
|
self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''),
|
2026-09-30 20:22:46 +08:00
|
|
|
|
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(
|
2026-10-01 00:12:04 +08:00
|
|
|
|
'UPDATE events SET title = ?, starts_at = ?, ends_at = ?, location = ?, note = ?
|
|
|
|
|
|
WHERE id = ? AND family_id = ?'
|
2026-09-30 20:22:46 +08:00
|
|
|
|
)->execute([
|
2026-10-01 00:12:04 +08:00
|
|
|
|
(string)$d['title'],
|
|
|
|
|
|
self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''),
|
2026-09-30 20:22:46 +08:00
|
|
|
|
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
|
|
|
|
|
|
{
|
2026-10-01 00:12:04 +08:00
|
|
|
|
$db = Database::get();
|
|
|
|
|
|
$cid = (int)($d['cinema_id'] ?? 0);
|
2026-10-01 10:21:45 +08:00
|
|
|
|
// 豆瓣元数据:进来是数组(表单提交的 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;
|
2026-10-01 00:12:04 +08:00
|
|
|
|
$row = [
|
2026-09-30 20:22:46 +08:00
|
|
|
|
self::textOrNull($d['info'] ?? ''),
|
|
|
|
|
|
self::keyOrNull($d['poster_key'] ?? ''),
|
|
|
|
|
|
self::keyOrNull($d['ticket_key'] ?? ''),
|
2026-10-01 00:12:04 +08:00
|
|
|
|
$cid > 0 ? $cid : null,
|
2026-10-01 10:21:45 +08:00
|
|
|
|
$meta,
|
2026-09-30 20:22:46 +08:00
|
|
|
|
];
|
|
|
|
|
|
$has = $db->prepare('SELECT COUNT(*) FROM movie_details WHERE event_id = ?');
|
|
|
|
|
|
$has->execute([$eventId]);
|
|
|
|
|
|
if ((int)$has->fetchColumn() > 0) {
|
2026-10-01 10:21:45 +08:00
|
|
|
|
$db->prepare('UPDATE movie_details SET info=?, poster_key=?, ticket_key=?, cinema_id=?, douban_meta=? WHERE event_id=?')
|
2026-10-01 00:12:04 +08:00
|
|
|
|
->execute([...$row, $eventId]);
|
2026-09-30 20:22:46 +08:00
|
|
|
|
} else {
|
2026-10-01 10:21:45 +08:00
|
|
|
|
$db->prepare('INSERT INTO movie_details (info, poster_key, ticket_key, cinema_id, douban_meta, event_id) VALUES (?,?,?,?,?,?)')
|
2026-10-01 00:12:04 +08:00
|
|
|
|
->execute([...$row, $eventId]);
|
2026-09-30 20:22:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 整组替换同行人(与 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-01 00:12:04 +08:00
|
|
|
|
// ---------------- 展示格式化 ----------------
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 观影时刻的人话展示:
|
|
|
|
|
|
* 只有日期(时间恰好 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 20:22:46 +08:00
|
|
|
|
// ---------------- 小工具 ----------------
|
|
|
|
|
|
|
2026-10-01 00:12:04 +08:00
|
|
|
|
/** 'Y-m-d H:i:s' 校验(秒级精确);空串/不合法回 NULL */
|
|
|
|
|
|
private static function dtOrNull(string $v): ?string
|
2026-09-30 20:22:46 +08:00
|
|
|
|
{
|
|
|
|
|
|
$v = trim($v);
|
2026-10-01 00:12:04 +08:00
|
|
|
|
if ($v === '' || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $v)) {
|
2026-09-30 20:22:46 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|