Files
FamilyBook/app/Movie.php
T
laoyang ff353b1eee v8:观影落地(MovieLog 整体迁入统一事件原语)+ 三处 UI 撤除
- Schema v8:events / movie_details / event_attendees / event_photos 四张表,
  uk_event_source(family_id, source_ref) 作迁移幂等锚
- Movie 模型:时间线/详情/增删改、同行人整组替换、照片逐张管理、感受英→中映射
- ImageSlot 新增 poster(2:3) / ticket(free) / photo(free) 三槽位;
  free 槽位不裁剪、整图等比缩进框(只缩不放大)
- OssDriver::copyObject:同桶 CopyObject(手写 V1 签名,x-oss-* 头按字典序进签名串)
- 路由:movies 时间线/详情/新增/编辑/删除/补照片/删照片;同行人按家庭过滤;
  访客只读(requireWrite)
- UI 撤除:首页「我的一句话」、家谱页「补充我的亲属关系 / 看整个名册」两个按钮;
  顶栏加「观影」入口
- 修潜伏 bug:ImageSlot::parseRatio 的正则用 / 作分隔符,字符类里的 / 把模式
  提前截断(Unknown modifier ']')—— ratio() 只在上传 POST 里被调到,一直没被观察到
- 线上数据迁移(临时 token 门禁页,用完即删):MovieLog 219 条观影 → events,
  14 位观影人新增进名册(带 MovieLog 头像),431 个 OSS 对象同桶拷贝
  (movielog/uploads/<dir>/x → family/<dir>/x),review/feelings/影院三段/照片全量搬
2026-09-30 20:22:46 +08:00

282 lines
11 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 = 现场照片(可多张)
*
* 图片一律存**相对键**(如 posters/xxx.jpg),地址由 media.php 代理 ——
* 本地与 OSS 的差别被 Storage 吃掉,这里永远不碰完整 URL。
*
* source_ref 是迁移幂等的锚点:MovieLog 整体迁入时写成 'movielog:movie:<id>',
* 重跑迁移按它先删后插,不会出现一半新一半旧的中间态。
*/
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<int,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<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。
* $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;
}
}