v9: 观影对齐 MovieLog —— 瀑布流/影院管理/评价评论/起止时间

- events 起止时间精确到秒(starts_at DATE→DATETIME + ends_at),_datetime.php 复用
- 影院独立成表(cinemas,地方/名称/地址/主题色 + 50 色板),movie_details 归一为 cinema_id
- 评价模型 MovieLog 同款:movie_reviews 每账号每片一条(1-10 分+正文+感受 key),
  movie_review_comments 两级平铺评论(回复的回复规范化到顶层、跨评价回复拦截)
- 感受目录迁到 MovieReview::CATALOG(12 种,Emoji 11.0 老码位)
- 观影列表改瀑布流(CSS grid 海报墙),详情页五大块 + 灯箱 + 评价区
- 升级路径:v8 老数据自动归一(影院三段按 (family,place,name) 去重建行、
  评价回填、中文感受反查英文 key),回填全部幂等;老库补 cinema_id 列+索引+外键
- 修三 bug:建表顺序(cinemas 必须在 movie_details 前,否则全新安装 1215)、
  layout 导航用键名当路由(影院链接指向 r=cinemas)、
  _datetime.php 分钟循环变量 $m 覆盖宿主模板的观影记录(编辑页简介/备注回填被清空)
This commit is contained in:
2026-10-01 00:12:04 +08:00
parent ff353b1eee
commit 746f02581e
13 changed files with 2001 additions and 356 deletions
+205 -13
View File
@@ -42,8 +42,15 @@ final class Schema
* 影院三段)+ event_attendees(参与人)+ event_photos(现场照片)。
* MovieLog 的 219 部观影记录整体迁入(source_ref='movielog:movie:<id>',
* 幂等重跑的锚点)。日历本/CalDAV 是后续阶段,events 先不带 calendar_id。
* 9 → **观影对齐 MovieLog(用户五点反馈)**:① 列表改瀑布流海报墙;
* ② events.starts_at 升 DATETIME + 新增 ends_at(观影起止精确到秒,
* 复用 _datetime.php 控件);③ 新建 cinemas(所在地方/名称/地址/主题色),
* movie_details 只留 cinema_id(三段列迁移后删掉);④ 评分评论照搬
* MovieLog 模型:movie_reviews(每账号每片一条,1-10 分 + 正文 + 感受)
* + movie_review_comments(两级平铺讨论),movie_details 旧的单条
* rating/review/feelings 迁进 movie_reviews 后删列。
*/
public const VERSION = '8';
public const VERSION = '9';
/** @return string[] */
public static function statements(): array
@@ -206,7 +213,8 @@ CREATE TABLE IF NOT EXISTS `events` (
`creator_id` INT UNSIGNED DEFAULT NULL COMMENT '谁记的(persons.id);系统迁移可为 NULL',
`kind` ENUM('movie') NOT NULL COMMENT '事件类型:movie=观影(trip/任务等是后续阶段的卫星表)',
`title` VARCHAR(200) NOT NULL COMMENT '事件标题(观影=片名)',
`starts_at` DATE DEFAULT NULL COMMENT '发生日期(观影=观影日期;精确到时刻的需求等日历阶段再升 DATETIME)',
`starts_at` DATETIME DEFAULT NULL COMMENT '发生时刻(观影=开场时间,精确到秒)',
`ends_at` DATETIME DEFAULT NULL COMMENT '结束时刻(观影=散场时间,精确到秒)',
`location` VARCHAR(200) DEFAULT NULL COMMENT '地点(观影=影院名)',
`note` TEXT DEFAULT NULL COMMENT '备注',
`source_ref` VARCHAR(64) DEFAULT NULL COMMENT '外部来源引用(如 movielog:movie:123)——迁移幂等重跑的锚点',
@@ -218,22 +226,77 @@ CREATE TABLE IF NOT EXISTS `events` (
CONSTRAINT `fk_event_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_event_creator` FOREIGN KEY (`creator_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一事件:观影/旅行/任务…共同点是都有时间、有人参与、要上日历'
SQL,
// ⚠️ cinemas 必须排在 movie_details **之前**:后者的 fk_mdet_cinema
// 引用 cinemas.id,MariaDB 默认 FOREIGN_KEY_CHECKS=1,
// 顺序反了全新安装会在 movie_details 上报 1215、
// 整个建表循环从那里断掉(线上老库 IF NOT EXISTS 跳过、炸不出来,
// 只有全新安装/测试库会 500 —— 2026-09-30 e2e 抓到)。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `cinemas` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`place` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '所在地方(城市 / 商圈)',
`name` VARCHAR(100) NOT NULL COMMENT '影院名称',
`address` VARCHAR(200) DEFAULT NULL COMMENT '详细地址',
`color` CHAR(7) NOT NULL DEFAULT '#888888' COMMENT '展示用主题色(列表/详情里影院名着色)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_cinema` (`family_id`, `place`, `name`),
KEY `idx_cinema_family` (`family_id`),
CONSTRAINT `fk_cinema_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='影院(独立管理,MovieLog 同款:地方/名称/地址/主题色)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `movie_details` (
`event_id` INT UNSIGNED NOT NULL,
`info` TEXT DEFAULT NULL COMMENT '简介(豆瓣抓取的剧情简介等)',
`poster_key` VARCHAR(255) DEFAULT NULL COMMENT '海报(存储相对键,如 posters/xxx.jpg)',
`ticket_key` VARCHAR(255) DEFAULT NULL COMMENT '票根照片(存储相对键)',
`rating` TINYINT DEFAULT NULL COMMENT '评分 0-10(.MovieLog 10 分制原样保留)',
`review` TEXT DEFAULT NULL COMMENT '影评正文',
`feelings` VARCHAR(255) DEFAULT NULL COMMENT '观后感受标签,JSON 数组(如 ["温暖","激动"])',
`cinema_place` VARCHAR(100) DEFAULT NULL COMMENT '影院所在商圈/位置',
`cinema_name` VARCHAR(100) DEFAULT NULL COMMENT '影院名(与 events.location 冗余存一份,详情页直接用)',
`cinema_address` VARCHAR(200) DEFAULT NULL COMMENT '影院地址',
`event_id` INT UNSIGNED NOT NULL,
`info` TEXT DEFAULT NULL COMMENT '简介(豆瓣抓取的剧情简介等)',
`poster_key` VARCHAR(255) DEFAULT NULL COMMENT '海报(存储相对键,如 posters/xxx.jpg)',
`ticket_key` VARCHAR(255) DEFAULT NULL COMMENT '票根照片(存储相对键)',
`cinema_id` INT UNSIGNED DEFAULT NULL COMMENT '看的哪家影院(cinemas.id)',
PRIMARY KEY (`event_id`),
CONSTRAINT `fk_mdet_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE
KEY `idx_mdet_cinema` (`cinema_id`),
CONSTRAINT `fk_mdet_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_mdet_cinema` FOREIGN KEY (`cinema_id`) REFERENCES `cinemas` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='观影事件的特有数据(一对一卫星表)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `movie_reviews` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`event_id` INT UNSIGNED NOT NULL,
`person_id` INT UNSIGNED NOT NULL COMMENT '评价人(登录账号背后的人);与观影人无关,没去看的也能评',
`rating` TINYINT NOT NULL COMMENT '评分 1-10(录入校验 1-10;迁移兜底可存 0)',
`content` TEXT NOT NULL COMMENT '评价正文',
`feelings` VARCHAR(255) DEFAULT NULL COMMENT '观后感受 key 的 JSON 数组(目录见 MovieReview::CATALOG)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_review_event_person` (`event_id`, `person_id`),
KEY `idx_review_event` (`event_id`),
KEY `idx_review_person` (`person_id`),
CONSTRAINT `fk_review_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_review_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_review_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='观影评价:一部电影 × 一个账号 至多一条(MovieLog 同款)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `movie_review_comments` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`review_id` INT UNSIGNED NOT NULL,
`person_id` INT UNSIGNED NOT NULL COMMENT '评论人(登录账号背后的人)',
`parent_id` INT UNSIGNED DEFAULT NULL COMMENT 'NULL=顶层评论;非空=所属顶层评论 id(回复的回复会被规范化到顶层,页面永远只有两层)',
`reply_to_person_id` INT UNSIGNED DEFAULT NULL COMMENT '被回复的人(显示「回复 @谁」用;存人不存评论 id,对方删评不留悬空)',
`content` TEXT NOT NULL,
`feelings` VARCHAR(255) DEFAULT NULL COMMENT '感受 key 的 JSON 数组',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_mrc_review` (`review_id`),
KEY `idx_mrc_parent` (`parent_id`),
CONSTRAINT `fk_mrc_review` FOREIGN KEY (`review_id`) REFERENCES `movie_reviews` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_mrc_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_mrc_replyto` FOREIGN KEY (`reply_to_person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评价下的讨论(两级平铺,MovieLog 同款)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `event_attendees` (
@@ -412,6 +475,44 @@ SQL,
}
});
// ---- 观影对齐 MovieLog(版本 9)----
// starts_at DATE → DATETIME:MODIFY 是幂等的,老数据的时间部分自动 00:00:00。
$db->exec("ALTER TABLE `events` MODIFY COLUMN `starts_at`
DATETIME DEFAULT NULL COMMENT '发生时刻(观影=开场时间,精确到秒)'");
self::addColumn($db, 'events', 'ends_at',
"ADD COLUMN `ends_at` DATETIME DEFAULT NULL COMMENT '结束时刻(观影=散场时间,精确到秒)' AFTER `starts_at`");
// 旧 movie_details 的「影院三段 + rating/review/feelings」搬进 cinemas /
// movie_reviews,然后删列。⚠️ 回填+删列是同一条升级路径,**都不能进 tryStep**:
// 回填是删列的唯一前提,半途失败还继续删 = 直接丢数据;让异常炸出来、
// 下次请求重跑(回填都是幂等写法,重跑安全)。
// 判据用「旧列还在不在」:新装库(建表语句已是新结构)天然跳过。
//
// ⚠️ 老库的 movie_details **没有 cinema_id**(v8 及以前影院三段就在自己身上),
// 上面的 CREATE TABLE IF NOT EXISTS 对已存在的表是空操作、补不了列 ——
// 必须显式加列,否则下面的回填 UPDATE 直接炸 1054(2026-09-30 升级专测抓到,
// 线上老库走同一条路径也会炸在那里,升级永远跑不完)。
self::addColumn($db, 'movie_details', 'cinema_id',
"ADD COLUMN `cinema_id` INT UNSIGNED DEFAULT NULL COMMENT '看的哪家影院(cinemas.id)' AFTER `ticket_key`");
if (!self::hasIndex($db, 'movie_details', 'idx_mdet_cinema')) {
$db->exec('ALTER TABLE `movie_details` ADD KEY `idx_mdet_cinema` (`cinema_id`)');
}
// 外键给「删影院 ⇒ 观影记录的 cinema_id 置空」兜底;老库缺了补上。
// 容错跑:就算哪家老库有脏数据加不上 FK,也不能挡住下面的回填与删列。
self::tryStep('老库补 movie_details→cinemas 外键', static function () use ($db) {
if (!self::hasForeignKey($db, 'movie_details', 'fk_mdet_cinema')) {
$db->exec('ALTER TABLE `movie_details`
ADD CONSTRAINT `fk_mdet_cinema` FOREIGN KEY (`cinema_id`) REFERENCES `cinemas` (`id`) ON DELETE SET NULL');
}
});
if (self::hasColumn($db, 'movie_details', 'cinema_name')) {
self::backfillCinemasFromDetails($db);
self::backfillMovieReviewsFromDetails($db);
foreach (['rating', 'review', 'feelings', 'cinema_place', 'cinema_name', 'cinema_address'] as $col) {
self::dropColumnIfExists($db, 'movie_details', $col);
}
}
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
if (self::$stepErrors) {
@@ -486,6 +587,85 @@ SQL,
}
}
/**
* v9:把旧 movie_details 的影院三段(place/name/address)归一进 cinemas,
* 去重键 = (family_id, place, name),再回填 movie_details.cinema_id。
* 幂等:影院按唯一键查过再插;cinema_id 覆盖写。
*/
private static function backfillCinemasFromDetails(\PDO $db): void
{
$rows = $db->query(
'SELECT d.event_id, e.family_id, d.cinema_place, d.cinema_name, d.cinema_address
FROM movie_details d JOIN events e ON e.id = d.event_id
WHERE d.cinema_name IS NOT NULL AND d.cinema_name <> \'\''
)->fetchAll();
if (!$rows) {
return;
}
$chk = $db->prepare('SELECT id FROM cinemas WHERE family_id = ? AND place = ? AND name = ?');
$ins = $db->prepare('INSERT INTO cinemas (family_id, place, name, address, color) VALUES (?, ?, ?, ?, "#888888")');
$upd = $db->prepare('UPDATE movie_details SET cinema_id = ? WHERE event_id = ?');
foreach ($rows as $r) {
$place = trim((string)($r['cinema_place'] ?? ''));
$name = trim((string)$r['cinema_name']);
$chk->execute([(int)$r['family_id'], $place, $name]);
$cid = $chk->fetchColumn();
if ($cid === false) {
$addr = trim((string)($r['cinema_address'] ?? ''));
$ins->execute([(int)$r['family_id'], $place, $name, $addr !== '' ? $addr : null]);
$cid = (int)$db->lastInsertId();
}
$upd->execute([(int)$cid, (int)$r['event_id']]);
}
}
/**
* v9:把旧 movie_details 的单条 rating/review/feelings 迁成 movie_reviews
* (评价人 = 记这条事件的人;creator 缺失时退而求其次用该家第一个账号)。
* v8 感受存的是中文标签,这里反查回 MovieLog 的英文 key;认不出的丢掉。
* 幂等:uk_review_event_person 撞了就跳过。
*/
private static function backfillMovieReviewsFromDetails(\PDO $db): void
{
$rows = $db->query(
'SELECT d.event_id, e.family_id, e.creator_id, d.rating, d.review, d.feelings
FROM movie_details d JOIN events e ON e.id = d.event_id
WHERE d.rating IS NOT NULL OR (d.review IS NOT NULL AND d.review <> \'\')'
)->fetchAll();
if (!$rows) {
return;
}
$firstPerson = $db->prepare(
'SELECT a.person_id FROM accounts a JOIN persons p ON p.id = a.person_id
WHERE p.family_id = ? AND a.status = "active" ORDER BY a.id LIMIT 1'
);
$chk = $db->prepare('SELECT COUNT(*) FROM movie_reviews WHERE event_id = ? AND person_id = ?');
$ins = $db->prepare(
'INSERT INTO movie_reviews (family_id, event_id, person_id, rating, content, feelings)
VALUES (?, ?, ?, ?, ?, ?)'
);
foreach ($rows as $r) {
$pid = $r['creator_id'] !== null ? (int)$r['creator_id'] : 0;
if ($pid <= 0) {
$firstPerson->execute([(int)$r['family_id']]);
$pid = (int)$firstPerson->fetchColumn();
}
if ($pid <= 0) {
continue; // 全家连一个账号都没有:评价挂不上人,只能不动它
}
$chk->execute([(int)$r['event_id'], $pid]);
if ((int)$chk->fetchColumn() > 0) {
continue;
}
$rating = $r['rating'] !== null ? max(0, min(10, (int)$r['rating'])) : 0;
$feel = \App\MovieReview::labelsToKeysJson((string)($r['feelings'] ?? ''));
$ins->execute([
(int)$r['family_id'], (int)$r['event_id'], $pid, $rating,
trim((string)($r['review'] ?? '')), $feel,
]);
}
}
/** 列不存在才加 */
private static function addColumn(\PDO $db, string $table, string $column, string $alter): void
{
@@ -562,6 +742,18 @@ SQL,
return (int)$st->fetchColumn() > 0;
}
/** 老库补外键前先查它在不在(STATISTICS 里看不到约束名,得查 TABLE_CONSTRAINTS) */
private static function hasForeignKey(\PDO $db, string $table, string $name): bool
{
$st = $db->prepare(
"SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = ?
AND CONSTRAINT_TYPE = 'FOREIGN KEY'"
);
$st->execute([$table, $name]);
return (int)$st->fetchColumn() > 0;
}
private static function dropColumnIfExists(\PDO $db, string $table, string $column): void
{
if (self::hasColumn($db, $table, $column)) {