Files

399 lines
16 KiB
PHP
Raw Permalink Normal View History

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