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:
+110
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
/**
|
||||
* 影院(v9,MovieLog 同款独立管理):所在地方 / 名称 / 详细地址 / 主题色。
|
||||
*
|
||||
* 观影记录通过 movie_details.cinema_id 关联(外键 ON DELETE SET NULL:
|
||||
* 删影院不停掉历史记录,只是它们的影院信息置空 —— 与 MovieLog 行为一致)。
|
||||
* 去重键 (family_id, place, name):同一家影院不会存两份。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Cinema
|
||||
{
|
||||
/** 50 色预设板(MovieLog cinemas.php 同一套) */
|
||||
public const PRESET_COLORS = [
|
||||
'#e74c3c', '#c0392b', '#e67e22', '#d35400', '#f39c12',
|
||||
'#f1c40f', '#f7dc6f', '#f5b041', '#f4d03f', '#f9e79f',
|
||||
'#f8c471', '#2ecc71', '#27ae60', '#1abc9c', '#16a085',
|
||||
'#58d68d', '#48c9b0', '#45b39d', '#82e0aa', '#a3e4d7',
|
||||
'#3498db', '#2980b9', '#2e86c1', '#5dade2', '#85c1e9',
|
||||
'#5499c7', '#aed6f1', '#9b59b6', '#8e44ad', '#af7ac5',
|
||||
'#bb8fce', '#d2b4de', '#e84393', '#fd79a8', '#e91e63',
|
||||
'#f06292', '#95a5a6', '#7f8c8d', '#bdc3c7', '#d5dbdb',
|
||||
'#aab7b8', '#99a3a4', '#a67c52', '#8d6e63', '#d4a574',
|
||||
'#c9a87c', '#00b894', '#0984e3', '#6c5ce7', '#fd79a8',
|
||||
];
|
||||
|
||||
/** @return array<int,array> 按地方、名称排序的全部影院 */
|
||||
public static function all(int $familyId): array
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT c.*, (SELECT COUNT(*) FROM movie_details d WHERE d.cinema_id = c.id) AS movie_count
|
||||
FROM cinemas c WHERE c.family_id = ? ORDER BY c.place, c.name'
|
||||
);
|
||||
$st->execute([$familyId]);
|
||||
return $st->fetchAll();
|
||||
}
|
||||
|
||||
public static function find(int $familyId, int $id): ?array
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT * FROM cinemas WHERE id = ? AND family_id = ?'
|
||||
);
|
||||
$st->execute([$id, $familyId]);
|
||||
return $st->fetch() ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建/更新影院。$place/$name/$address 已由调用方裁剪;
|
||||
* 返回 ''(成功)或错误文案。同名(含同地方)判重靠 uk_cinema。
|
||||
*/
|
||||
public static function save(int $familyId, int $id, string $place, string $name,
|
||||
?string $address, string $color): string
|
||||
{
|
||||
if ($name === '') {
|
||||
return '影院名称不能为空';
|
||||
}
|
||||
if (mb_strlen($name, 'UTF-8') > 100) {
|
||||
return '影院名称太长了';
|
||||
}
|
||||
$place = mb_substr($place, 0, 100, 'UTF-8');
|
||||
$address = $address !== null ? mb_substr($address, 0, 200, 'UTF-8') : null;
|
||||
$db = Database::get();
|
||||
if ($id > 0) {
|
||||
$st = $db->prepare(
|
||||
'SELECT COUNT(*) FROM cinemas
|
||||
WHERE family_id = ? AND place = ? AND name = ? AND id <> ?'
|
||||
);
|
||||
$st->execute([$familyId, $place, $name, $id]);
|
||||
if ((int)$st->fetchColumn() > 0) {
|
||||
return '已经有同地方同名的影院了';
|
||||
}
|
||||
$db->prepare('UPDATE cinemas SET place = ?, name = ?, address = ?, color = ? WHERE id = ? AND family_id = ?')
|
||||
->execute([$place, $name, $address, $color, $id, $familyId]);
|
||||
} else {
|
||||
$st = $db->prepare(
|
||||
'SELECT COUNT(*) FROM cinemas WHERE family_id = ? AND place = ? AND name = ?'
|
||||
);
|
||||
$st->execute([$familyId, $place, $name]);
|
||||
if ((int)$st->fetchColumn() > 0) {
|
||||
return '已经有同地方同名的影院了';
|
||||
}
|
||||
$db->prepare('INSERT INTO cinemas (family_id, place, name, address, color) VALUES (?, ?, ?, ?, ?)')
|
||||
->execute([$familyId, $place, $name, $address, $color]);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 删除影院;关联的观影记录 cinema_id 置空(外键级联),历史记录本身保留 */
|
||||
public static function destroy(int $familyId, int $id): void
|
||||
{
|
||||
Database::get()->prepare('DELETE FROM cinemas WHERE id = ? AND family_id = ?')
|
||||
->execute([$id, $familyId]);
|
||||
}
|
||||
|
||||
/** 把表单提交的颜色规范化成 #rrggbb(非法值回默认灰) */
|
||||
public static function normalizeColor(string $raw): string
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if (preg_match('/^#([0-9a-fA-F]{6})$/', $raw, $m)) {
|
||||
return '#' . strtolower($m[1]);
|
||||
}
|
||||
if (preg_match('/^([0-9a-fA-F]{6})$/', $raw, $m)) {
|
||||
return '#' . strtolower($m[1]);
|
||||
}
|
||||
return '#888888';
|
||||
}
|
||||
}
|
||||
+90
-63
@@ -3,16 +3,18 @@
|
||||
* 观影记录(统一事件原语的第一个落地种类)。
|
||||
*
|
||||
* 归一模型(见 familybook.md 的核心设计):
|
||||
* events = 一切记录的共同骨架(标题 / 日期 / 地点 / 来源引用)
|
||||
* movie_details = 观影特有的卫星表(海报 / 票根 / 评分 / 影评 / 影院三段)
|
||||
* events = 一切记录的共同骨架(标题 / 起止时刻 / 地点 / 来源引用)
|
||||
* movie_details = 观影特有的卫星表(海报 / 票根 / 关联影院)
|
||||
* attendees = 同行观影的人(persons.id)
|
||||
* photos = 现场照片(可多张)
|
||||
* reviews = 评价与讨论(movie_reviews / movie_review_comments,见 MovieReview)
|
||||
* cinemas = 影院独立管理(见 Cinema)
|
||||
*
|
||||
* 图片一律存**相对键**(如 posters/xxx.jpg),地址由 media.php 代理 ——
|
||||
* 本地与 OSS 的差别被 Storage 吃掉,这里永远不碰完整 URL。
|
||||
*
|
||||
* source_ref 是迁移幂等的锚点:MovieLog 整体迁入时写成 'movielog:movie:<id>',
|
||||
* 重跑迁移按它先删后插,不会出现一半新一半旧的中间态。
|
||||
* 重跑迁移按它先查再插,不会出现一半新一半旧的中间态。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
@@ -21,49 +23,48 @@ 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
|
||||
/** @return array<int,array> 观影记录,按开场时刻倒序(瀑布流用)。
|
||||
* $keyword 非空时搜:片名 / 影院名 / 同行人名(LIKE)。 */
|
||||
public static function all(int $familyId, string $keyword = ''): 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,
|
||||
$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,
|
||||
(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();
|
||||
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;
|
||||
}
|
||||
|
||||
/** 一条完整记录(详情/编辑页用):事件 + 卫星 + 同行人 + 照片 */
|
||||
/** 一条完整记录(详情/编辑页用):事件 + 卫星 + 影院 + 同行人 + 照片 */
|
||||
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
|
||||
'SELECT e.*, d.info, d.poster_key, d.ticket_key, d.cinema_id,
|
||||
c.place AS cinema_place, c.name AS cinema_name,
|
||||
c.address AS cinema_address, c.color AS cinema_color
|
||||
FROM events e
|
||||
LEFT JOIN movie_details d ON d.event_id = e.id
|
||||
LEFT JOIN cinemas c ON c.id = d.cinema_id
|
||||
WHERE e.family_id = ? AND e.id = ? AND e.kind = "movie"'
|
||||
);
|
||||
$st->execute([$familyId, $id]);
|
||||
@@ -100,18 +101,18 @@ final class Movie
|
||||
|
||||
/**
|
||||
* 新建一条观影记录,返回事件 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[])
|
||||
* $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[])
|
||||
*/
|
||||
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", ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO events (family_id, creator_id, kind, title, starts_at, ends_at, location, note, source_ref)
|
||||
VALUES (?, ?, "movie", ?, ?, ?, ?, ?, ?)'
|
||||
)->execute([
|
||||
$familyId, $creatorId, (string)$d['title'], self::dateOrNull($d['starts_at'] ?? ''),
|
||||
$familyId, $creatorId, (string)$d['title'],
|
||||
self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''),
|
||||
self::locOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''),
|
||||
$d['source_ref'] ?? null,
|
||||
]);
|
||||
@@ -126,9 +127,11 @@ final class Movie
|
||||
{
|
||||
$db = Database::get();
|
||||
$db->prepare(
|
||||
'UPDATE events SET title = ?, starts_at = ?, location = ?, note = ? WHERE id = ? AND family_id = ?'
|
||||
'UPDATE events SET title = ?, starts_at = ?, ends_at = ?, location = ?, note = ?
|
||||
WHERE id = ? AND family_id = ?'
|
||||
)->execute([
|
||||
(string)$d['title'], self::dateOrNull($d['starts_at'] ?? ''),
|
||||
(string)$d['title'],
|
||||
self::dtOrNull($d['starts_at'] ?? ''), self::dtOrNull($d['ends_at'] ?? ''),
|
||||
self::locOrNull($d['location'] ?? ''), self::textOrNull($d['note'] ?? ''),
|
||||
$id, $familyId,
|
||||
]);
|
||||
@@ -140,35 +143,22 @@ final class Movie
|
||||
/** 卫星表 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 = [
|
||||
$db = Database::get();
|
||||
$cid = (int)($d['cinema_id'] ?? 0);
|
||||
$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'] ?? ''),
|
||||
$cid > 0 ? $cid : null,
|
||||
];
|
||||
$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]);
|
||||
$db->prepare('UPDATE movie_details SET info=?, poster_key=?, ticket_key=?, cinema_id=? 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]);
|
||||
$db->prepare('INSERT INTO movie_details (info, poster_key, ticket_key, cinema_id, event_id) VALUES (?,?,?,?,?)')
|
||||
->execute([...$row, $eventId]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,12 +236,49 @@ final class Movie
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 展示格式化 ----------------
|
||||
|
||||
/**
|
||||
* 观影时刻的人话展示:
|
||||
* 只有日期(时间恰好 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;
|
||||
}
|
||||
|
||||
// ---------------- 小工具 ----------------
|
||||
|
||||
private static function dateOrNull(string $v): ?string
|
||||
/** 'Y-m-d H:i:s' 校验(秒级精确);空串/不合法回 NULL */
|
||||
private static function dtOrNull(string $v): ?string
|
||||
{
|
||||
$v = trim($v);
|
||||
if ($v === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $v)) {
|
||||
if ($v === '' || !preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $v)) {
|
||||
return null;
|
||||
}
|
||||
return $v;
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
<?php
|
||||
/**
|
||||
* 观影评价与讨论(v9,模型照搬 MovieLog 的 lib/reviews.php)。
|
||||
*
|
||||
* 数据模型:
|
||||
* movie_reviews 一部电影 × 一个账号 至多一条(rating 1-10 + 正文 + 感受)
|
||||
* movie_review_comments 挂在评价下的讨论,两级平铺:
|
||||
* parent_id 为空 → 顶层评论;非空 → 指向「所属顶层评论」的 id
|
||||
* (回复的回复会被规范化到顶层,页面永远只有两层),
|
||||
* reply_to_person_id 记「被回复的人」,显示「回复 @谁」。
|
||||
*
|
||||
* 评价人是「登录账号背后的人」(persons.id),与同行观影人无关:
|
||||
* 没一起去看的人只要有账号,也能打分、写评价并参与讨论。
|
||||
*
|
||||
* 感受只存 key(如 warm / sad),emoji 与文案统一在 CATALOG 里维护,
|
||||
* 改文案 / 换 emoji 都不需要动数据。emoji 只选 Emoji 11.0(2018)及以前的老码位
|
||||
* —— 更新的字形在旧系统字体里会显示成方框。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class MovieReview
|
||||
{
|
||||
public const CONTENT_MAX = 5000; // 评价正文最大字数(UTF-8 字符)
|
||||
public const COMMENT_MAX = 1000; // 评论正文最大字数
|
||||
public const FEELING_MAX = 6; // 一条评价/评论最多能选几种感受
|
||||
|
||||
/** 观后感受目录:key => [emoji, 中文标签] */
|
||||
public const CATALOG = [
|
||||
'happy' => ['emoji' => '😄', 'label' => '开心'],
|
||||
'moved' => ['emoji' => '💖', 'label' => '感动'],
|
||||
'warm' => ['emoji' => '🥰', 'label' => '温暖'],
|
||||
'excited' => ['emoji' => '🤩', 'label' => '激动'],
|
||||
'thoughtful' => ['emoji' => '🤔', 'label' => '深思'],
|
||||
'tense' => ['emoji' => '😰', 'label' => '紧张'],
|
||||
'scared' => ['emoji' => '😨', 'label' => '恐惧'],
|
||||
'surprised' => ['emoji' => '😲', 'label' => '震惊'],
|
||||
'sad' => ['emoji' => '😢', 'label' => '难受'],
|
||||
'angry' => ['emoji' => '😠', 'label' => '气愤'],
|
||||
'bored' => ['emoji' => '😴', 'label' => '无聊'],
|
||||
'confused' => ['emoji' => '😵', 'label' => '困惑'],
|
||||
];
|
||||
|
||||
/** v8 时代 movie_details.feelings 存的中文标签 → 目录 key(迁移用) */
|
||||
private const LABEL_TO_KEY = [
|
||||
'温暖' => 'warm', '激动' => 'excited', '欢乐' => 'happy',
|
||||
'深思' => 'thoughtful', '伤感' => 'sad', '震撼' => 'surprised',
|
||||
'治愈' => 'moved', '无聊' => 'bored', '开心' => 'happy',
|
||||
'感动' => 'moved', '紧张' => 'tense', '恐惧' => 'scared',
|
||||
'震惊' => 'surprised', '难受' => 'sad', '气愤' => 'angry',
|
||||
'困惑' => 'confused',
|
||||
];
|
||||
|
||||
/** @return array<int,string> 1-10 分各给一句词(打分实时提示 / title 用) */
|
||||
public static function ratingWords(): array
|
||||
{
|
||||
return array_map(static fn($r) => self::ratingWord($r), range(1, 10));
|
||||
}
|
||||
|
||||
public static function ratingWord(int $rating): string
|
||||
{
|
||||
return match (true) {
|
||||
$rating <= 2 => '很失望',
|
||||
$rating <= 4 => '不太行',
|
||||
$rating <= 6 => '还行',
|
||||
$rating <= 8 => '不错',
|
||||
default => '很棒',
|
||||
};
|
||||
}
|
||||
|
||||
/** 把表单提交(数组)或库里取出的数组整理成合法 key 列表:过滤、去重、截断、按目录顺序 */
|
||||
public static function normalizeFeelings(array $raw): array
|
||||
{
|
||||
$picked = [];
|
||||
foreach ($raw as $key) {
|
||||
$key = is_string($key) ? trim($key) : '';
|
||||
if ($key !== '' && isset(self::CATALOG[$key]) && !in_array($key, $picked, true)) {
|
||||
$picked[] = $key;
|
||||
}
|
||||
}
|
||||
$ordered = [];
|
||||
foreach (array_keys(self::CATALOG) as $key) {
|
||||
if (in_array($key, $picked, true)) {
|
||||
$ordered[] = $key;
|
||||
}
|
||||
}
|
||||
return array_slice($ordered, 0, self::FEELING_MAX);
|
||||
}
|
||||
|
||||
/** 库里 feelings JSON → 合法 key 列表 */
|
||||
public static function parseFeelings(?string $json): array
|
||||
{
|
||||
if ($json === null || $json === '') {
|
||||
return [];
|
||||
}
|
||||
$keys = json_decode($json, true);
|
||||
return is_array($keys) ? self::normalizeFeelings($keys) : [];
|
||||
}
|
||||
|
||||
public static function feelingsToJson(array $keys): string
|
||||
{
|
||||
return json_encode(array_values($keys), JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/** v8 迁移用:旧中文标签 JSON → 新 key JSON(认不出的丢掉) */
|
||||
public static function labelsToKeysJson(string $json): ?string
|
||||
{
|
||||
if (trim($json) === '') {
|
||||
return null;
|
||||
}
|
||||
$labels = json_decode($json, true);
|
||||
if (!is_array($labels)) {
|
||||
return null;
|
||||
}
|
||||
$keys = [];
|
||||
foreach ($labels as $label) {
|
||||
$k = self::LABEL_TO_KEY[trim((string)$label)] ?? null;
|
||||
if ($k !== null && !in_array($k, $keys, true)) {
|
||||
$keys[] = $k;
|
||||
}
|
||||
}
|
||||
return $keys ? self::feelingsToJson(self::normalizeFeelings($keys)) : null;
|
||||
}
|
||||
|
||||
// ---------------- 读取 ----------------
|
||||
|
||||
/** 某部电影的全部评价(新在前),讨论按两级结构挂好 */
|
||||
public static function forEvent(int $eventId): array
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT r.*, p.name AS person_name, p.avatar AS person_avatar
|
||||
FROM movie_reviews r JOIN persons p ON p.id = r.person_id
|
||||
WHERE r.event_id = ?
|
||||
ORDER BY r.created_at DESC, r.id DESC'
|
||||
);
|
||||
$st->execute([$eventId]);
|
||||
$reviews = $st->fetchAll();
|
||||
foreach ($reviews as &$rv) {
|
||||
$rv['feelings'] = self::parseFeelings($rv['feelings']);
|
||||
}
|
||||
unset($rv);
|
||||
self::attachComments($reviews);
|
||||
return $reviews;
|
||||
}
|
||||
|
||||
/** 给一批评价挂上 comments(一次查询);两级平铺:回复全部归到所属顶层评论下 */
|
||||
private static function attachComments(array &$reviews): void
|
||||
{
|
||||
if (!$reviews) {
|
||||
return;
|
||||
}
|
||||
$ids = array_values(array_filter(array_column($reviews, 'id'), static fn($v) => (int)$v > 0));
|
||||
if (!$ids) {
|
||||
return;
|
||||
}
|
||||
$ph = implode(',', array_fill(0, count($ids), '?'));
|
||||
$st = Database::get()->prepare(
|
||||
"SELECT c.*, p.name AS person_name, p.avatar AS person_avatar,
|
||||
rp.name AS reply_to_name
|
||||
FROM movie_review_comments c
|
||||
JOIN persons p ON p.id = c.person_id
|
||||
LEFT JOIN persons rp ON rp.id = c.reply_to_person_id
|
||||
WHERE c.review_id IN ($ph)
|
||||
ORDER BY c.created_at ASC, c.id ASC"
|
||||
);
|
||||
$st->execute($ids);
|
||||
|
||||
$byId = [];
|
||||
foreach ($st->fetchAll() as $row) {
|
||||
$row['feelings'] = self::parseFeelings($row['feelings']);
|
||||
$row['parent_id'] = ($row['parent_id'] === null || (int)$row['parent_id'] <= 0)
|
||||
? null : (int)$row['parent_id'];
|
||||
$byId[(int)$row['id']] = $row;
|
||||
}
|
||||
|
||||
// 顶层评论按评价分桶(保持时间顺序)
|
||||
$grouped = []; // review_id => [ ['comment'=>…, 'replies'=>[…]], … ]
|
||||
$index = []; // review_id => [顶层评论 id => 下标]
|
||||
foreach ($byId as $row) {
|
||||
if ($row['parent_id'] !== null) {
|
||||
continue;
|
||||
}
|
||||
$rvId = (int)$row['review_id'];
|
||||
$grouped[$rvId][] = ['comment' => $row, 'replies' => []];
|
||||
$index[$rvId][(int)$row['id']] = count($grouped[$rvId]) - 1;
|
||||
}
|
||||
// 挂回复:沿 parent 链向上找所属顶层评论(容忍手工改坏的数据,最多 20 步防成环)
|
||||
foreach ($byId as $row) {
|
||||
if ($row['parent_id'] === null) {
|
||||
continue;
|
||||
}
|
||||
$rvId = (int)$row['review_id'];
|
||||
$root = (int)$row['parent_id'];
|
||||
for ($i = 0; $i < 20 && isset($byId[$root]) && $byId[$root]['parent_id'] !== null; $i++) {
|
||||
$root = (int)$byId[$root]['parent_id'];
|
||||
}
|
||||
if (isset($index[$rvId][$root])) {
|
||||
$grouped[$rvId][$index[$rvId][$root]]['replies'][] = $row;
|
||||
}
|
||||
// 所属顶层已被删的回复直接丢掉,不让它变成挂在评价上的孤儿
|
||||
}
|
||||
|
||||
foreach ($reviews as &$rv) {
|
||||
$rv['comments'] = $grouped[(int)$rv['id']] ?? [];
|
||||
}
|
||||
unset($rv);
|
||||
}
|
||||
|
||||
/** 评分概况:['count' => n, 'avg' => 7.5];没人评价时 avg 为 null(展示别显示 0 分) */
|
||||
public static function stats(int $eventId): array
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT COUNT(*) AS c, AVG(rating) AS a FROM movie_reviews WHERE event_id = ?'
|
||||
);
|
||||
$st->execute([$eventId]);
|
||||
$row = $st->fetch();
|
||||
$n = (int)($row['c'] ?? 0);
|
||||
return ['count' => $n, 'avg' => $n > 0 ? round((float)$row['a'], 1) : null];
|
||||
}
|
||||
|
||||
/** 给瀑布流的行批量挂上评分概况(一次查询) */
|
||||
public static function attachStats(array &$rows): void
|
||||
{
|
||||
if (!$rows) {
|
||||
return;
|
||||
}
|
||||
$ids = array_values(array_unique(array_filter(
|
||||
array_column($rows, 'id'),
|
||||
static fn($v) => (int)$v > 0
|
||||
)));
|
||||
if (!$ids) {
|
||||
return;
|
||||
}
|
||||
$ph = implode(',', array_fill(0, count($ids), '?'));
|
||||
$st = Database::get()->prepare(
|
||||
"SELECT event_id, COUNT(*) AS c, AVG(rating) AS a
|
||||
FROM movie_reviews WHERE event_id IN ($ph) GROUP BY event_id"
|
||||
);
|
||||
$st->execute($ids);
|
||||
$map = [];
|
||||
foreach ($st->fetchAll() as $row) {
|
||||
$map[(int)$row['event_id']] = [
|
||||
'count' => (int)$row['c'],
|
||||
'avg' => round((float)$row['a'], 1),
|
||||
];
|
||||
}
|
||||
foreach ($rows as &$r) {
|
||||
$r['review_count'] = $map[(int)$r['id']]['count'] ?? 0;
|
||||
$r['review_avg'] = $map[(int)$r['id']]['avg'] ?? null;
|
||||
}
|
||||
unset($r);
|
||||
}
|
||||
|
||||
/** 当前账号在这部电影上的评价(没有则 null) */
|
||||
public static function mine(int $eventId, int $personId): ?array
|
||||
{
|
||||
if ($personId <= 0) {
|
||||
return null;
|
||||
}
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT * FROM movie_reviews WHERE event_id = ? AND person_id = ?'
|
||||
);
|
||||
$st->execute([$eventId, $personId]);
|
||||
$row = $st->fetch();
|
||||
if ($row) {
|
||||
$row['feelings'] = self::parseFeelings($row['feelings']);
|
||||
}
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public static function reviewById(int $reviewId): ?array
|
||||
{
|
||||
$st = Database::get()->prepare('SELECT * FROM movie_reviews WHERE id = ?');
|
||||
$st->execute([$reviewId]);
|
||||
return $st->fetch() ?: null;
|
||||
}
|
||||
|
||||
public static function commentById(int $commentId): ?array
|
||||
{
|
||||
if ($commentId <= 0) {
|
||||
return null;
|
||||
}
|
||||
$st = Database::get()->prepare('SELECT * FROM movie_review_comments WHERE id = ?');
|
||||
$st->execute([$commentId]);
|
||||
return $st->fetch() ?: null;
|
||||
}
|
||||
|
||||
// ---------------- 写入 ----------------
|
||||
|
||||
/**
|
||||
* 发表/修改评价:每人对每部电影只有一条,已有则更新。
|
||||
* 校验:rating 1-10、正文非空未超长。成功返回 true,失败返回错误文案。
|
||||
*/
|
||||
public static function save(int $eventId, int $familyId, int $personId, int $rating,
|
||||
string $content, array $feelings, bool $isUpdate): string
|
||||
{
|
||||
if ($rating < 1 || $rating > 10) {
|
||||
return '请先选一个 1-10 的评分';
|
||||
}
|
||||
$content = trim($content);
|
||||
if ($content === '') {
|
||||
return '评价内容不能为空';
|
||||
}
|
||||
if (mb_strlen($content, 'UTF-8') > self::CONTENT_MAX) {
|
||||
return '评价内容太长了(最多 ' . self::CONTENT_MAX . ' 字)';
|
||||
}
|
||||
$feel = self::feelingsToJson(self::normalizeFeelings($feelings));
|
||||
$db = Database::get();
|
||||
if ($isUpdate) {
|
||||
$db->prepare(
|
||||
'UPDATE movie_reviews SET rating = ?, content = ?, feelings = ? WHERE id = ?'
|
||||
)->execute([$rating, $content, $feel, (int)self::mine($eventId, $personId)['id']]);
|
||||
} else {
|
||||
$db->prepare(
|
||||
'INSERT INTO movie_reviews (family_id, event_id, person_id, rating, content, feelings)
|
||||
VALUES (?, ?, ?, ?, ?, ?)'
|
||||
)->execute([$familyId, $eventId, $personId, $rating, $content, $feel]);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 删除自己的评价(下面的讨论随外键级联一起删)。只能删自己的。 */
|
||||
public static function deleteMine(int $reviewId, int $personId): bool
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
'DELETE FROM movie_reviews WHERE id = ? AND person_id = ?'
|
||||
);
|
||||
$st->execute([$reviewId, $personId]);
|
||||
return $st->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在一条评价下评论,或回复某条评论。parent_id=0 → 顶层;>0 → 回复那条。
|
||||
* 回复的回复规范化到同一条顶层评论之下(库里 parent_id 永远指向顶层)。
|
||||
* 成功返回新评论 id,失败返回错误文案。
|
||||
*/
|
||||
public static function addComment(int $reviewId, int $personId, string $content,
|
||||
array $feelings, int $parentId): int|string
|
||||
{
|
||||
$content = trim($content);
|
||||
if ($content === '') {
|
||||
return $parentId > 0 ? '回复内容不能为空' : '评论内容不能为空';
|
||||
}
|
||||
if (mb_strlen($content, 'UTF-8') > self::COMMENT_MAX) {
|
||||
return ($parentId > 0 ? '回复' : '评论') . '内容太长了(最多 ' . self::COMMENT_MAX . ' 字)';
|
||||
}
|
||||
$review = self::reviewById($reviewId);
|
||||
if (!$review) {
|
||||
return '这条评价不存在';
|
||||
}
|
||||
|
||||
$parent = null;
|
||||
$replyTo = null;
|
||||
if ($parentId > 0) {
|
||||
$parent = self::commentById($parentId);
|
||||
// 只能回复「同一条评价下」的评论,防止拿别的评价的 comment_id 跨过去
|
||||
if (!$parent || (int)$parent['review_id'] !== $reviewId) {
|
||||
return '要回复的那条评论不存在';
|
||||
}
|
||||
$replyTo = (int)$parent['person_id'];
|
||||
if ($parent['parent_id'] !== null && (int)$parent['parent_id'] > 0) {
|
||||
$parentId = (int)$parent['parent_id']; // 回复的回复挂到顶层
|
||||
}
|
||||
}
|
||||
|
||||
$db = Database::get();
|
||||
$db->prepare(
|
||||
'INSERT INTO movie_review_comments
|
||||
(review_id, person_id, parent_id, reply_to_person_id, content, feelings)
|
||||
VALUES (?, ?, ?, ?, ?, ?)'
|
||||
)->execute([
|
||||
$reviewId, $personId,
|
||||
$parentId > 0 ? $parentId : null,
|
||||
$replyTo,
|
||||
$content,
|
||||
self::feelingsToJson(self::normalizeFeelings($feelings)),
|
||||
]);
|
||||
return (int)$db->lastInsertId();
|
||||
}
|
||||
|
||||
/** 删除自己的评论;删顶层时连回复一起删(回复只存在于顶层之下,留着就是孤儿) */
|
||||
public static function deleteComment(int $commentId, int $personId): bool
|
||||
{
|
||||
$db = Database::get();
|
||||
$st = $db->prepare(
|
||||
'DELETE FROM movie_review_comments WHERE id = ? AND person_id = ?'
|
||||
);
|
||||
$st->execute([$commentId, $personId]);
|
||||
if ($st->rowCount() <= 0) {
|
||||
return false;
|
||||
}
|
||||
$db->prepare('DELETE FROM movie_review_comments WHERE parent_id = ?')
|
||||
->execute([$commentId]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+205
-13
@@ -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)) {
|
||||
|
||||
+5
-5
@@ -101,16 +101,16 @@ function read_dt_input(string $name): ?string
|
||||
return sprintf('%s %02d:%02d:%02d', $date, $h, $i, $s);
|
||||
}
|
||||
|
||||
/** 站内链接(统一走 query-string 路由,不依赖 Apache 重写) */
|
||||
function url(string $route = 'home', array $query = []): string
|
||||
/** 站内链接(统一走 query-string 路由,不依赖 Apache 重写);$anchor 是页面内锚点(不带 #) */
|
||||
function url(string $route = 'home', array $query = [], ?string $anchor = null): string
|
||||
{
|
||||
$q = array_merge(['r' => $route], $query);
|
||||
return 'index.php?' . http_build_query($q);
|
||||
return 'index.php?' . http_build_query($q) . ($anchor !== null && $anchor !== '' ? '#' . rawurlencode($anchor) : '');
|
||||
}
|
||||
|
||||
function redirect(string $route = 'home', array $query = []): void
|
||||
function redirect(string $route = 'home', array $query = [], ?string $anchor = null): void
|
||||
{
|
||||
header('Location: ' . url($route, $query));
|
||||
header('Location: ' . url($route, $query, $anchor));
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user