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/影院三段/照片全量搬
This commit is contained in:
+281
@@ -0,0 +1,281 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user