[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 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; } }