prepare($sql); $st->execute($a); $v = $st->fetchColumn(); return $v === false || $v === null ? '' : (string)$v; }; $span = $db->prepare( "SELECT MIN(e.starts_at) AS f, MAX(e.starts_at) AS l FROM events e WHERE e.family_id = ? AND e.kind = 'movie' AND e.starts_at IS NOT NULL" ); $span->execute([$familyId]); $row = $span->fetch() ?: ['f' => null, 'l' => null]; return [ 'movies' => (int)$one("SELECT COUNT(*) FROM events WHERE family_id = ? AND kind = 'movie'", [$familyId]), 'cinemas' => (int)$one('SELECT COUNT(*) FROM cinemas WHERE family_id = ?', [$familyId]), 'persons' => (int)$one( "SELECT COUNT(DISTINCT a.person_id) FROM event_attendees a JOIN events e ON e.id = a.event_id AND e.kind = 'movie' WHERE e.family_id = ?", [$familyId]), 'viewership' => (int)$one( "SELECT COUNT(*) FROM event_attendees a JOIN events e ON e.id = a.event_id AND e.kind = 'movie' WHERE e.family_id = ?", [$familyId]), 'first_date' => $row['f'] !== null ? substr((string)$row['f'], 0, 10) : null, 'last_date' => $row['l'] !== null ? substr((string)$row['l'], 0, 10) : null, ]; } /** 按影院:每家看过多少部(0 部的也列出来,便于发现重复建的影院) */ public static function byCinema(int $familyId): array { $st = Database::get()->prepare( "SELECT c.id, c.name, c.place, c.color, COUNT(e.id) AS cnt FROM cinemas c LEFT JOIN movie_details d ON d.cinema_id = c.id LEFT JOIN events e ON e.id = d.event_id AND e.kind = 'movie' AND e.family_id = c.family_id WHERE c.family_id = ? GROUP BY c.id, c.name, c.place, c.color ORDER BY cnt DESC, c.name" ); $st->execute([$familyId]); return $st->fetchAll(); } /** 按观影人:参与了多少场(带头像,供统计页画条;名称按「我叫他什么」显示) */ public static function byPerson(int $familyId, int $viewerId = 0): array { $st = Database::get()->prepare( "SELECT p.id, p.name, p.avatar, n.nickname AS my_nickname, COUNT(e.id) AS cnt FROM persons p LEFT JOIN event_attendees a ON a.person_id = p.id LEFT JOIN events e ON e.id = a.event_id AND e.kind = 'movie' AND e.family_id = ? LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ? WHERE p.family_id = ? AND p.kind = 'member' GROUP BY p.id, p.name, p.avatar, n.nickname ORDER BY cnt DESC, p.id" ); $st->execute([$familyId, $viewerId, $familyId]); return $st->fetchAll(); } /** 最近 n 个月(只列有记录的月份,按时间倒序) */ public static function byMonth(int $familyId, int $limit = 12): array { // ⚠️ LIMIT 不能走绑定参数:非模拟预处理下 PDO 会把它当字符串送过去, // MySQL 报 `LIMIT '12'` 语法错。已 (int) 转,无注入面。 $st = Database::get()->prepare( "SELECT DATE_FORMAT(e.starts_at, '%Y-%m') AS ym, COUNT(*) AS cnt FROM events e WHERE e.family_id = ? AND e.kind = 'movie' AND e.starts_at IS NOT NULL GROUP BY ym ORDER BY ym DESC LIMIT " . (int)$limit ); $st->execute([$familyId]); return $st->fetchAll(); } /** 按年 */ public static function byYear(int $familyId): array { $st = Database::get()->prepare( "SELECT YEAR(e.starts_at) AS y, COUNT(*) AS cnt FROM events e WHERE e.family_id = ? AND e.kind = 'movie' AND e.starts_at IS NOT NULL GROUP BY y ORDER BY y DESC" ); $st->execute([$familyId]); return $st->fetchAll(); } /** 有观影记录的年份(倒序),年度报告的年份下拉用 */ public static function years(int $familyId): array { $st = Database::get()->prepare( "SELECT DISTINCT YEAR(e.starts_at) AS y FROM events e WHERE e.family_id = ? AND e.kind = 'movie' AND e.starts_at IS NOT NULL ORDER BY y DESC" ); $st->execute([$familyId]); return array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN)); } /** * 高级搜索:片名 / 影院 / 观影人 / 日期范围 组合过滤。 * 条件全空时返回 [](页面据此提示「先设置条件」)。 */ public static function search(int $familyId, array $p, int $viewerId = 0): array { $title = trim((string)($p['title'] ?? '')); $cid = (int)($p['cinema_id'] ?? 0); $pid = (int)($p['person_id'] ?? 0); $from = self::dateOrNull((string)($p['date_from'] ?? '')); $to = self::dateOrNull((string)($p['date_to'] ?? '')); if ($title === '' && $cid <= 0 && $pid <= 0 && $from === null && $to === null) { return []; } $sql = "SELECT e.id, e.title, e.starts_at, e.ends_at, d.poster_key, c.name AS cinema_name, c.color AS cinema_color, (SELECT GROUP_CONCAT(p2.name ORDER BY a2.position, p2.id SEPARATOR '、') FROM event_attendees a2 JOIN persons p2 ON p2.id = a2.person_id WHERE a2.event_id = e.id) AS attendee_names 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.kind = 'movie'"; $bind = [$familyId]; if ($title !== '') { $sql .= ' AND e.title LIKE ?'; $bind[] = '%' . $title . '%'; } if ($cid > 0) { $sql .= ' AND d.cinema_id = ?'; $bind[] = $cid; } if ($pid > 0) { $sql .= ' AND EXISTS (SELECT 1 FROM event_attendees a3 WHERE a3.event_id = e.id AND a3.person_id = ?)'; $bind[] = $pid; } if ($from !== null) { $sql .= ' AND e.starts_at >= ?'; $bind[] = $from . ' 00:00:00'; } if ($to !== null) { $sql .= ' AND e.starts_at <= ?'; $bind[] = $to . ' 23:59:59'; } $sql .= ' ORDER BY e.starts_at DESC, e.id DESC'; $st = Database::get()->prepare($sql); $st->execute($bind); $rows = $st->fetchAll(); MovieReview::attachStats($rows); Movie::attachAttendees($rows, $viewerId); return $rows; } /** * 年度报告:以**登录者**为中心,只统计他参与过的场次。 * 结构与 MovieLog::getAnnualStats 一致 —— 页面模板按同样的键取用。 */ public static function annual(int $familyId, int $personId, int $year): array { $db = Database::get(); $y = (int)$year; $blank = [ 'total' => 0, 'days' => 0, 'top_cinema' => '无', 'top_cinema_count' => 0, 'top_person' => '无', 'top_person_count' => 0, 'first_date' => null, 'last_date' => null, 'most_viewers_movie' => '无', 'most_viewers_count' => 0, 'month_distribution' => array_fill(1, 12, 0), 'weekday_distribution' => ['日' => 0, '一' => 0, '二' => 0, '三' => 0, '四' => 0, '五' => 0, '六' => 0], 'avg_per_month' => 0, 'avg_days_between' => 0, 'keywords' => [], 'recent_movies' => [], 'my_movies' => [], 'common_viewers' => [], ]; if ($personId <= 0) { return $blank; } // 「我参与过的场次」这个子查询被下面反复用 —— 写成 EXISTS 而不是 IN 大列表 $mine = "EXISTS (SELECT 1 FROM event_attendees am WHERE am.event_id = e.id AND am.person_id = ?)"; $base = "FROM events e WHERE e.family_id = ? AND e.kind = 'movie' AND YEAR(e.starts_at) = ? AND {$mine}"; $args = static fn(array $extra = []): array => array_merge([$familyId, $y, $personId], $extra); $one = static function (string $sql, array $a) use ($db): string { $st = $db->prepare($sql); $st->execute($a); $v = $st->fetchColumn(); return $v === false || $v === null ? '' : (string)$v; }; $out = $blank; $out['total'] = (int)$one("SELECT COUNT(*) {$base}", $args()); $out['days'] = (int)$one( "SELECT COUNT(DISTINCT DATE(e.starts_at)) {$base} AND e.starts_at IS NOT NULL", $args()); // 最常去的影院 $st = $db->prepare( "SELECT c.name AS n, COUNT(*) AS c FROM events e JOIN movie_details d ON d.event_id = e.id JOIN cinemas c ON c.id = d.cinema_id WHERE e.family_id = ? AND e.kind = 'movie' AND YEAR(e.starts_at) = ? AND {$mine} GROUP BY c.id, c.name ORDER BY c DESC LIMIT 1" ); $st->execute($args()); if ($r = $st->fetch()) { $out['top_cinema'] = (string)$r['n']; $out['top_cinema_count'] = (int)$r['c']; } // 一起看过最多场的人 $st = $db->prepare( "SELECT p.id, p.name, p.avatar, COUNT(*) AS c FROM event_attendees a JOIN persons p ON p.id = a.person_id WHERE a.event_id IN ( SELECT e2.id FROM events e2 WHERE e2.family_id = ? AND e2.kind = 'movie' AND YEAR(e2.starts_at) = ? AND EXISTS (SELECT 1 FROM event_attendees am WHERE am.event_id = e2.id AND am.person_id = ?)) AND a.person_id <> ? GROUP BY p.id, p.name, p.avatar ORDER BY c DESC" ); $st->execute([$familyId, $y, $personId, $personId]); $out['common_viewers'] = $st->fetchAll(); if ($out['common_viewers']) { $out['top_person'] = (string)$out['common_viewers'][0]['name']; $out['top_person_count'] = (int)$out['common_viewers'][0]['c']; } // 时间跨度 $st = $db->prepare("SELECT MIN(e.starts_at) AS f, MAX(e.starts_at) AS l {$base} AND e.starts_at IS NOT NULL"); $st->execute($args()); if ($r = $st->fetch()) { $out['first_date'] = $r['f'] !== null ? substr((string)$r['f'], 0, 10) : null; $out['last_date'] = $r['l'] !== null ? substr((string)$r['l'], 0, 10) : null; } // 同场人最多的一部 $st = $db->prepare( "SELECT e.title AS t, (SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS c {$base} ORDER BY c DESC, e.id DESC LIMIT 1" ); $st->execute($args()); if ($r = $st->fetch()) { $out['most_viewers_movie'] = (string)$r['t']; $out['most_viewers_count'] = (int)$r['c']; } // 月份 / 星期分布 $st = $db->prepare( "SELECT MONTH(e.starts_at) AS m, COUNT(*) AS c {$base} AND e.starts_at IS NOT NULL GROUP BY m" ); $st->execute($args()); foreach ($st->fetchAll() as $r) { $out['month_distribution'][(int)$r['m']] = (int)$r['c']; } $st = $db->prepare( "SELECT DAYOFWEEK(e.starts_at) AS w, COUNT(*) AS c {$base} AND e.starts_at IS NOT NULL GROUP BY w" ); $st->execute($args()); $wmap = [1 => '日', 2 => '一', 3 => '二', 4 => '三', 5 => '四', 6 => '五', 7 => '六']; foreach ($st->fetchAll() as $r) { $k = $wmap[(int)$r['w']] ?? null; if ($k !== null) { $out['weekday_distribution'][$k] = (int)$r['c']; } } // 频率 $out['avg_per_month'] = $out['total'] > 0 ? round($out['total'] / 12, 1) : 0; $out['avg_days_between'] = $out['total'] > 0 ? round(365 / $out['total'], 1) : 0; // 关键词(从备注里分词,取前 5) $st = $db->prepare("SELECT e.note {$base} AND e.note IS NOT NULL AND e.note <> ''"); $st->execute($args()); $wc = []; foreach ($st->fetchAll(PDO::FETCH_COLUMN) as $text) { $words = preg_split('/[\s,,。.!!??、;;::]+/u', (string)$text, -1, PREG_SPLIT_NO_EMPTY); foreach ($words as $w) { $w = trim($w); if (mb_strlen($w, 'UTF-8') >= 2) { $wc[$w] = ($wc[$w] ?? 0) + 1; } } } arsort($wc); $out['keywords'] = array_slice(array_keys($wc), 0, 5); // 最近 5 部(只要片名) $st = $db->prepare("SELECT e.title {$base} ORDER BY e.starts_at DESC, e.id DESC LIMIT 5"); $st->execute($args()); $out['recent_movies'] = array_map('strval', $st->fetchAll(PDO::FETCH_COLUMN)); // 我的观影清单(含海报 + 同场的人) $st = $db->prepare( "SELECT e.id, e.title, e.starts_at, d.poster_key FROM events e LEFT JOIN movie_details d ON d.event_id = e.id WHERE e.family_id = ? AND e.kind = 'movie' AND YEAR(e.starts_at) = ? AND {$mine} ORDER BY e.starts_at DESC, e.id DESC" ); $st->execute($args()); $list = $st->fetchAll(); $co = $db->prepare( "SELECT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname FROM event_attendees a JOIN persons p ON p.id = a.person_id LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ? WHERE a.event_id = ? AND a.person_id <> ? ORDER BY a.position, p.id" ); foreach ($list as &$mv) { $co->execute([$personId, (int)$mv['id'], $personId]); $mv['viewers'] = $co->fetchAll(); } unset($mv); $out['my_movies'] = $list; return $out; } /** 'Y-m-d' 校验;不合法回 null */ private static function dateOrNull(string $v): ?string { $v = trim($v); return preg_match('/^\d{4}-\d{2}-\d{2}$/', $v) ? $v : null; } }