Files
laoyang 275bd9d80f 评价区改成「按钮展开表单」,评论支持两级回复
页面默认不出现任何输入框,要写什么就点对应那个按钮:

- detail.php:评价区的「写评价 / 修改我的评价 / 评论 / 回复」都做成按钮,
  用原生 <details>/<summary> 折叠,点开才展开表单,提交后整页刷新自动收起。
  零 JS、键盘可用。没评价过只在顶部有「写评价」;评价过则该按钮消失,改在自己
  那条评价下面出现「修改我的评价」。一条评价都没有时显示「还没有评价,等你来评一个。」
- lib/reviews.php:评论改两级平铺。parent_id 为空 = 顶层评论,非空 = 对某条评论的
  回复;写「回复的回复」时由 addReviewComment 规范化回顶层评论,所以库里最多两层、
  页面永远只缩进一次,来回多少轮都不会越缩越窄。被回复者另存 reply_to_admin_id
  (存「人」不存评论 id,对方那条被删也不会悬空),页面用「回复 @某某」标出。
  deleteReviewComment 删顶层评论时连带清掉它的回复。
- lib/db.php:review_comments 增 parent_id / reply_to_admin_id 两列 + 幂等迁移,
  并给 parent_id 建索引。**修一个会让老库升级白屏的顺序 bug**:新列上的 CREATE INDEX
  必须排在 ALTER TABLE 补列之后 —— 建表用的是 CREATE TABLE IF NOT EXISTS,对线上
  已存在的旧表不生效,索引跑在补列之前会报 no such column: parent_id,整个 initDB
  挂掉。全新安装看不出来,是 e2e 的「造旧库再跑一遍初始化」专测抓到的。
- README.md:命名由「评价与讨论」改为「评价与评论」,补上按钮交互、两级平铺、
  回复 @某某 与新表字段的说明。

回归:reviews_e2e 84/84、fetch_movie 76/76、storage 196/196;A/B 47 个响应中
仅详情页相关 6 个有差异,且经折叠区块比对确认差异全部限定在评价区内。
线上 PHP 7.4.30 / 8.1.7 双版本迁移与写链路(顶层评论→回复→回复的回复→清理)全部通过。
2026-09-22 16:09:48 +08:00

419 lines
17 KiB
PHP
Raw Permalink 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
// ============================================
// 文件: lib/reviews.php
// 说明: 电影评价 + 评价下的讨论(评论)。
//
// 评价人是「登录账号」(admins),与观影人(persons / viewers)无关:
// 没一起去看这部电影的人,只要有账号,也能打分、写评价、参与讨论。
//
// 数据模型:
// reviews 一部电影 × 一个账号 至多一条(rating 1-10 + 正文)
// review_comments 挂在某条评价下的讨论;可带多个「观后感受」
//
// 观后感受只存 key(如 happy / sad),emoji 与文案统一在本文件的
// feelingCatalog() 里维护,改文案 / 换 emoji 都不需要动数据库。
// ============================================
// 评价正文 / 评论正文的最大字数(UTF-8 字符数)
define('REVIEW_CONTENT_MAX', 5000);
define('REVIEW_COMMENT_MAX', 1000);
// 一条评论最多能选几种感受
define('REVIEW_FEELING_MAX', 6);
// ===== 观后感受目录 =====
// key => [emoji, 中文名]。评价时可多选,每种感受一个专属 emoji。
// 注意:emoji 只选 Emoji 11.0(2018)及以前的老码位——更新的字形在较旧的
// Windows / Android 系统 emoji 字体里可能没有,页面上会显示成空白或方框。
// (下面注释一律用码位描述,不直接写 emoji 字符,免得在缺字形的环境里注释也是方框。)
// 2026-09:「感动」原本用 U+1F979(含泪微笑,Emoji 14.0 / 2021 新增),在
// Emoji 12 时代的系统字体里缺字形,已换成 U+1F496(闪亮的心,Emoji 1.0 / 2010)。
function feelingCatalog() {
static $catalog = null;
if ($catalog === null) {
$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' => '困惑'],
];
}
return $catalog;
}
// 取某一种感受的 emoji + 文案;key 不存在时返回 null
function feelingInfo($key) {
$catalog = feelingCatalog();
return isset($catalog[$key]) ? $catalog[$key] : null;
}
// ===== 文本长度(按 UTF-8 字符计;没有 mbstring 时退化为字节数) =====
function reviewTextLength($str) {
return function_exists('mb_strlen') ? mb_strlen((string)$str, 'UTF-8') : strlen((string)$str);
}
// ===== 感受的校验 / 存取 =====
// 把表单提交(数组)或库里取出的数组,整理成合法的感受 key 列表:
// 过滤不在目录里的值、去重、截断到上限,并保持目录里的顺序。
function normalizeFeelings($raw, $max = REVIEW_FEELING_MAX) {
$catalog = feelingCatalog();
$picked = [];
if (is_array($raw)) {
foreach ($raw as $key) {
$key = is_string($key) ? trim($key) : '';
if ($key !== '' && isset($catalog[$key]) && !in_array($key, $picked, true)) {
$picked[] = $key;
}
}
}
// 保持目录顺序,读出来展示时才不会跟着勾选顺序乱跳
$ordered = [];
foreach ($catalog as $key => $info) {
if (in_array($key, $picked, true)) $ordered[] = $key;
}
return array_slice($ordered, 0, max(0, (int)$max));
}
// 解析库里 feelings 字段(JSON 数组字符串)→ 合法的 key 列表
function parseStoredFeelings($json) {
if ($json === null || $json === '') return [];
$keys = json_decode((string)$json, true);
if (!is_array($keys)) return [];
return normalizeFeelings($keys);
}
// 感受列表 → 入库用的 JSON 字符串
function feelingsToJson(array $keys) {
return json_encode(array_values($keys), JSON_UNESCAPED_UNICODE);
}
// ===== 评分文案 =====
// 1-10 分给一句词,详情页打分时实时显示,评价列表里也用它做 title。
function ratingWord($rating) {
$r = (int)$rating;
if ($r <= 2) return '很失望';
if ($r <= 4) return '不太行';
if ($r <= 6) return '还行';
if ($r <= 8) return '不错';
return '很棒';
}
// ===== 读取 =====
// 某部电影的全部评价(新在前),并把讨论一次性挂到每条评价的 comments 上
function getMovieReviews($movie_id) {
$db = getDB();
$stmt = $db->prepare("
SELECT r.*, a.username, a.avatar
FROM reviews r
LEFT JOIN admins a ON r.admin_id = a.id
WHERE r.movie_id = ?
ORDER BY r.created_at DESC, r.id DESC
");
$stmt->execute([$movie_id]);
$reviews = $stmt->fetchAll();
foreach ($reviews as &$review) {
// 评价本身带的观后感受(JSON 字符串 → key 列表)
$review['feelings'] = parseStoredFeelings($review['feelings'] ?? '');
}
unset($review);
attachCommentsToReviews($reviews);
return $reviews;
}
// 给一批评价挂上 comments(一次查询,避免每条评价查一次库)。
//
// 评论是**两级结构**:顶层评论(parent_id 为空)直接挂在评价下;
// 对评论的回复统一平铺在它所属的顶层评论的 replies 里——页面上不再逐层缩进,
// 只用 reply_to_username 标出「回复 @谁」。这样无论来回回复多少轮,
// 视觉上永远只有两层,页面也干净。
function attachCommentsToReviews(array &$reviews) {
if (empty($reviews)) return;
$ids = array_column($reviews, 'id');
$ids = array_values(array_filter($ids, function ($v) { return (int)$v > 0; }));
if (empty($ids)) return;
$db = getDB();
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare("
SELECT c.*, a.username, a.avatar, ra.username AS reply_to_username
FROM review_comments c
LEFT JOIN admins a ON c.admin_id = a.id
LEFT JOIN admins ra ON c.reply_to_admin_id = ra.id
WHERE c.review_id IN ($placeholders)
ORDER BY c.created_at ASC, c.id ASC
");
$stmt->execute($ids);
$byId = [];
foreach ($stmt->fetchAll() as $row) {
$row['feelings'] = parseStoredFeelings($row['feelings'] ?? '');
// 老数据里这两列可能还不存在(库刚迁移)或为 0,统一成「空 = null」
$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' => [回复…]], … ]
foreach ($byId as $row) {
if ($row['parent_id'] !== null) continue;
$grouped[(int)$row['review_id']][] = ['comment' => $row, 'replies' => []];
}
// 顶层评论 id => 它在 $grouped 里的下标,便于把回复挂上去
$index = [];
foreach ($grouped as $rvId => $list) {
foreach ($list as $i => $item) {
$index[$rvId][(int)$item['comment']['id']] = $i;
}
}
// 再挂回复:沿 parent 链向上找到所属的顶层评论。
// 正常写入时 parent_id 一定就是顶层评论 id(addReviewComment 会规范化),
// 这里多走几步只是容忍被手工改坏的数据,最多 20 步防成环。
foreach ($byId as $row) {
if ($row['parent_id'] === null) continue;
$rvId = (int)$row['review_id'];
$root = (int)$row['parent_id'];
for ($step = 0; $step < 20 && isset($byId[$root]) && $byId[$root]['parent_id'] !== null; $step++) {
$root = $byId[$root]['parent_id'];
}
if (isset($index[$rvId][$root])) {
$grouped[$rvId][$index[$rvId][$root]]['replies'][] = $row;
}
// 找不到所属顶层(顶层已被删)的回复直接丢掉,不让它变成挂在评价上的孤儿
}
foreach ($reviews as &$review) {
$review['comments'] = $grouped[(int)$review['id']] ?? [];
}
unset($review);
}
// 当前账号在这部电影上的评价(没有则 null)
function getMyReview($movie_id, $admin_id) {
if ((int)$admin_id <= 0) return null;
$db = getDB();
$stmt = $db->prepare("SELECT * FROM reviews WHERE movie_id = ? AND admin_id = ?");
$stmt->execute([$movie_id, $admin_id]);
$row = $stmt->fetch();
if ($row) {
$row['feelings'] = parseStoredFeelings($row['feelings'] ?? '');
}
return $row ?: null;
}
// 取单条评价(用于校验评论挂在的评价确实属于这部电影)
function getReviewById($review_id) {
$db = getDB();
$stmt = $db->prepare("SELECT * FROM reviews WHERE id = ?");
$stmt->execute([$review_id]);
$row = $stmt->fetch();
return $row ?: null;
}
// 取单条评论(用于校验「回复的目标」存在、且属于同一条评价)
function getReviewCommentById($comment_id) {
if ((int)$comment_id <= 0) return null;
$db = getDB();
$stmt = $db->prepare("SELECT * FROM review_comments WHERE id = ?");
$stmt->execute([(int)$comment_id]);
// 用 fetchAll 取:只取一行就放着不读完,游标会一直挂着共享读锁(本项目踩过)
$rows = $stmt->fetchAll();
return $rows ? $rows[0] : null;
}
// 某部电影的评分概况:['count' => n, 'avg' => 7.5]
// 没人评价时 avg 为 null,展示时不要显示 0 分。
function reviewStats($movie_id) {
$db = getDB();
$stmt = $db->prepare("SELECT COUNT(*) AS c, AVG(rating) AS a FROM reviews WHERE movie_id = ?");
$stmt->execute([$movie_id]);
$row = $stmt->fetch();
$count = (int)($row['c'] ?? 0);
return [
'count' => $count,
'avg' => $count > 0 ? round((float)$row['a'], 1) : null,
];
}
// 给电影列表批量挂上评分概况(列表页展示均分用,一次查询搞定)
function attachReviewStatsToMovies(array &$movies) {
if (empty($movies)) return;
$ids = array_values(array_unique(array_filter(array_column($movies, 'id'), function ($v) { return (int)$v > 0; })));
if (empty($ids)) return;
$db = getDB();
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare("
SELECT movie_id, COUNT(*) AS c, AVG(rating) AS a
FROM reviews WHERE movie_id IN ($placeholders) GROUP BY movie_id
");
$stmt->execute($ids);
$map = [];
foreach ($stmt->fetchAll() as $row) {
$map[(int)$row['movie_id']] = [
'count' => (int)$row['c'],
'avg' => round((float)$row['a'], 1),
];
}
foreach ($movies as &$movie) {
$movie['review_count'] = $map[(int)$movie['id']]['count'] ?? 0;
$movie['review_avg'] = $map[(int)$movie['id']]['avg'] ?? null;
}
unset($movie);
}
// ===== 写入 =====
/**
* 发表 / 修改评价。一个账号对一部电影只保留一条:已有则更新,没有则插入。
* $feelings 是评价人自己选的观后感受 key 数组(可以为空)。
* 调用前请自行校验 rating 在 1-10、content 非空且未超长。
* 成功返回 true。
*/
function saveReview($movie_id, $admin_id, $rating, $content, array $feelings = []) {
$db = getDB();
$rating = (int)$rating;
$content = trim((string)$content);
$feelings = normalizeFeelings($feelings);
if ($rating < 1 || $rating > 10 || $content === '' || (int)$admin_id <= 0) {
return false;
}
try {
$db->beginTransaction();
$stmt = $db->prepare("SELECT id FROM reviews WHERE movie_id = ? AND admin_id = ?");
$stmt->execute([$movie_id, $admin_id]);
$existing = $stmt->fetch();
if ($existing) {
$stmt = $db->prepare("
UPDATE reviews SET rating = ?, content = ?, feelings = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
");
$stmt->execute([$rating, $content, feelingsToJson($feelings), $existing['id']]);
} else {
$stmt = $db->prepare("
INSERT INTO reviews (movie_id, admin_id, rating, content, feelings)
VALUES (?, ?, ?, ?, ?)
");
$stmt->execute([$movie_id, $admin_id, $rating, $content, feelingsToJson($feelings)]);
}
$db->commit();
return true;
} catch (PDOException $e) {
if ($db->inTransaction()) $db->rollBack();
return false;
}
}
// 删除自己的评价(连同下面的讨论一起删)。只能删自己的,删别人的返回 false。
function deleteReview($review_id, $admin_id) {
$db = getDB();
$stmt = $db->prepare("DELETE FROM reviews WHERE id = ? AND admin_id = ?");
$stmt->execute([$review_id, $admin_id]);
if ($stmt->rowCount() <= 0) return false;
// 外键级联在本项目里没有开启(SQLite 默认关闭 PRAGMA foreign_keys),
// 所以评论要手动清一遍,删掉评价后不能留下孤儿讨论。
$stmt = $db->prepare("DELETE FROM review_comments WHERE review_id = ?");
$stmt->execute([$review_id]);
return true;
}
/**
* 在一条评价下发表评论,或回复某条评论。$feelings 是感受 key 数组(可为空)。
*
* $parent_id = 0 → 直接评论这条评价(成为顶层评论)
* $parent_id > 0 → 回复该 id 所指的那条评论(顶层评论或某条回复都行)
*
* 为了维持「两级平铺」的结构,回复的回复会被**规范化**到同一条顶层评论之下,
* 所以库里任何一条评论的 parent_id 要么为空、要么指向一条顶层评论,永远不会更深。
*
* 返回新评论 id,失败返回 false。
*/
function addReviewComment($review_id, $admin_id, $content, array $feelings = [], $parent_id = 0) {
$db = getDB();
$content = trim((string)$content);
$review = getReviewById($review_id);
if (!$review || $content === '' || (int)$admin_id <= 0) {
return false;
}
$parent_id = (int)$parent_id;
$replyTo = null;
if ($parent_id > 0) {
$parent = getReviewCommentById($parent_id);
// 只能回复「同一条评价下」的评论,防止拿别的评价的 comment_id 跨过去
if (!$parent || (int)$parent['review_id'] !== (int)$review_id) {
return false;
}
// 回复的回复:挂到那条顶层评论下,@ 的仍然是真正被回复的那个人
if ($parent['parent_id'] !== null && (int)$parent['parent_id'] > 0) {
$parent_id = (int)$parent['parent_id'];
}
$replyTo = (int)$parent['admin_id'];
}
$stmt = $db->prepare("
INSERT INTO review_comments (review_id, admin_id, parent_id, reply_to_admin_id, content, feelings)
VALUES (?, ?, ?, ?, ?, ?)
");
$ok = $stmt->execute([
$review_id,
$admin_id,
$parent_id > 0 ? $parent_id : null,
$replyTo,
$content,
feelingsToJson(normalizeFeelings($feelings)),
]);
return $ok ? (int)$db->lastInsertId() : false;
}
// 删除自己的评论;只能删自己的。
// 删的是顶层评论时,连它下面的回复一起删——回复在页面上只存在于某条顶层评论之下,
// 留着就会变成挂在评价上的孤儿。
function deleteReviewComment($comment_id, $admin_id) {
$db = getDB();
$stmt = $db->prepare("DELETE FROM review_comments WHERE id = ? AND admin_id = ?");
$stmt->execute([(int)$comment_id, (int)$admin_id]);
if ($stmt->rowCount() <= 0) return false;
$stmt = $db->prepare("DELETE FROM review_comments WHERE parent_id = ?");
$stmt->execute([(int)$comment_id]);
return true;
}
/**
* 删除某部电影的全部评价与讨论(删电影时调用;级联未开启,需手动清)。
*
* $db 可以传入调用方已经打开的连接:delete.php 手里那条连接上还有没读完的
* 游标(持有共享读锁),此时另开一条连接去 DELETE 会撞上 PDO 默认的
* 60 秒 busy timeout,表现成「页面卡死」。复用同一条连接就不会互等。
*/
function deleteReviewsOfMovie($movie_id, $db = null) {
if (!($db instanceof PDO)) {
$db = getDB();
}
$stmt = $db->prepare("SELECT id FROM reviews WHERE movie_id = ?");
$stmt->execute([$movie_id]);
$ids = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (empty($ids)) return;
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $db->prepare("DELETE FROM review_comments WHERE review_id IN ($placeholders)");
$stmt->execute($ids);
$stmt = $db->prepare("DELETE FROM reviews WHERE movie_id = ?");
$stmt->execute([$movie_id]);
}