'想读', 'reading' => '在读', 'done' => '读完', 'paused' => '搁置', ]; /** 笔记种类 */ public const NOTE_KINDS = [ 'quote' => '划线', 'note' => '笔记', 'review' => '书评', ]; /** 笔记来源 */ public const NOTE_SOURCES = [ 'manual' => '本站', 'weread' => '微信读书', 'douban' => '豆瓣', 'other' => '其它', ]; /** 上传图书允许的格式(解析能力见 BookParser) */ public const FILE_EXT = ['epub', 'txt', 'pdf', 'mobi', 'azw3', 'azw', 'doc', 'docx']; // ---------------- 读 ---------------- /** * 书架。 * * @param array $f 过滤:status / category / q(书名作者标签)/ owner(谁的书) */ public static function all(int $familyId, array $f = []): array { $sql = 'SELECT b.*, p.name AS owner_name, p.avatar AS owner_avatar, p.avatar_color AS owner_color, (SELECT COUNT(*) FROM book_notes n WHERE n.book_id = b.id) AS note_count FROM books b LEFT JOIN persons p ON p.id = b.owner_id WHERE b.family_id = ?'; $args = [$familyId]; $st = (string)($f['status'] ?? ''); if ($st !== '' && isset(self::STATUS[$st])) { $sql .= ' AND b.status = ?'; $args[] = $st; } $cat = trim((string)($f['category'] ?? '')); if ($cat !== '') { $sql .= ' AND b.category = ?'; $args[] = $cat; } $owner = (int)($f['owner'] ?? 0); if ($owner > 0) { $sql .= ' AND b.owner_id = ?'; $args[] = $owner; } $q = trim((string)($f['q'] ?? '')); if ($q !== '') { $like = '%' . $q . '%'; $sql .= ' AND (b.title LIKE ? OR b.author LIKE ? OR b.subtitle LIKE ? OR b.publisher LIKE ? OR b.tags LIKE ? OR b.isbn LIKE ?)'; array_push($args, $like, $like, $like, $like, $like, $like); } // 在读的排最前(那是"手头正在看什么"),其次是最近更新过的 $sql .= ' ORDER BY FIELD(b.status, "reading", "wish", "paused", "done"), b.updated_at DESC, b.id DESC'; $st2 = Database::get()->prepare($sql); $st2->execute($args); return $st2->fetchAll(); } /** * 各状态的数量(书架页的筛选条用;'_all' 是总数)。 * * ⚠️ 与筛选条件**无关**:这里要的是"这个状态一共有几本", * 不是"当前筛选下有几本"(否则点进"在读"后,其它状态的数字全变 0, * 筛选条就成了自我实现的死路 —— 再也点不回去)。 */ public static function statusCounts(int $familyId): array { $out = ['_all' => 0]; foreach (self::STATUS as $k => $v) { $out[$k] = 0; } $st = Database::get()->prepare( 'SELECT status, COUNT(*) AS n FROM books WHERE family_id = ? GROUP BY status' ); $st->execute([$familyId]); foreach ($st->fetchAll() as $r) { $out['_all'] += (int)$r['n']; $k = (string)$r['status']; if (isset($out[$k])) { $out[$k] = (int)$r['n']; } } return $out; } /** 用过的分类(筛选用) */ public static function categories(int $familyId): array { $st = Database::get()->prepare( 'SELECT category AS name, COUNT(*) AS n FROM books WHERE family_id = ? AND category IS NOT NULL AND category <> "" GROUP BY category ORDER BY n DESC, category' ); $st->execute([$familyId]); return $st->fetchAll(); } public static function find(int $familyId, int $id): ?array { $st = Database::get()->prepare( 'SELECT b.*, p.name AS owner_name, p.avatar AS owner_avatar, p.avatar_color AS owner_color FROM books b LEFT JOIN persons p ON p.id = b.owner_id WHERE b.family_id = ? AND b.id = ?' ); $st->execute([$familyId, $id]); $r = $st->fetch(); return $r ?: null; } /** * 章节目录(**不取正文** —— 一本书的正文有几 MB,列表页取它就是自杀)。 */ public static function chapters(int $bookId): array { $st = Database::get()->prepare( 'SELECT id, seq, title, words FROM book_chapters WHERE book_id = ? ORDER BY seq' ); $st->execute([$bookId]); return $st->fetchAll(); } /** 一章(含正文) */ public static function chapter(int $bookId, int $seq): ?array { $st = Database::get()->prepare( 'SELECT * FROM book_chapters WHERE book_id = ? AND seq = ?' ); $st->execute([$bookId, $seq]); $r = $st->fetch(); return $r ?: null; } /** 整本正文(导出/统计字数用;量很大,只在必要时调) */ public static function chapterCount(int $bookId): int { $st = Database::get()->prepare('SELECT COUNT(*) FROM book_chapters WHERE book_id = ?'); $st->execute([$bookId]); return (int)$st->fetchColumn(); } // ---------------- 写 ---------------- /** @return int 新书 id */ public static function create(int $familyId, ?int $creatorId, array $d): int { $db = Database::get(); $db->prepare( 'INSERT INTO books (family_id, owner_id, creator_id, title, subtitle, author, translator, publisher, published, isbn, language, category, tags, summary, cover_key, file_key, file_name, file_format, file_size, pages, words, status, rating, started_at, finished_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)' )->execute([ $familyId, self::ownerId($familyId, (int)($d['owner_id'] ?? 0)), $creatorId ?: null, self::title($d['title'] ?? ''), self::txt($d['subtitle'] ?? ''), self::txt($d['author'] ?? ''), self::txt($d['translator'] ?? ''), self::txt($d['publisher'] ?? ''), self::txt($d['published'] ?? ''), self::txt($d['isbn'] ?? ''), self::txt($d['language'] ?? ''), self::txt($d['category'] ?? ''), self::txt($d['tags'] ?? ''), self::longText($d['summary'] ?? ''), self::key($d['cover_key'] ?? ''), self::key($d['file_key'] ?? ''), self::txt($d['file_name'] ?? ''), self::fmt($d['file_format'] ?? ''), self::intOrNull($d['file_size'] ?? 0), self::intOrNull($d['pages'] ?? 0), self::intOrNull($d['words'] ?? 0), self::status($d['status'] ?? ''), self::rating($d['rating'] ?? null), self::dateOrNull($d['started_at'] ?? ''), self::dateOrNull($d['finished_at'] ?? ''), ]); return (int)$db->lastInsertId(); } public static function update(int $familyId, int $id, array $d): void { $old = self::find($familyId, $id); if (!$old) { return; } $status = self::status($d['status'] ?? ''); // ⚠️ 状态与"读完那天"必须一起维护: // · 变成 done 且还没记过 → 记今天; // · 从 done 退回来 → **清空**(留着会让"今年读完几本"一直虚高)。 $fin = self::dateOrNull($d['finished_at'] ?? '') ?? ((string)$old['finished_at'] ?: null); if ($status === 'done' && $fin === null) { $fin = date('Y-m-d'); } elseif ($status !== 'done') { $fin = null; } $db = Database::get(); $db->prepare( 'UPDATE books SET owner_id=?, title=?, subtitle=?, author=?, translator=?, publisher=?, published=?, isbn=?, language=?, category=?, tags=?, summary=?, cover_key=?, file_key=?, file_name=?, file_format=?, file_size=?, pages=?, words=?, status=?, rating=?, started_at=?, finished_at=? WHERE id=? AND family_id=?' )->execute([ self::ownerId($familyId, (int)($d['owner_id'] ?? 0)), self::title($d['title'] ?? ''), self::txt($d['subtitle'] ?? ''), self::txt($d['author'] ?? ''), self::txt($d['translator'] ?? ''), self::txt($d['publisher'] ?? ''), self::txt($d['published'] ?? ''), self::txt($d['isbn'] ?? ''), self::txt($d['language'] ?? ''), self::txt($d['category'] ?? ''), self::txt($d['tags'] ?? ''), self::longText($d['summary'] ?? ''), self::key($d['cover_key'] ?? ''), self::key($d['file_key'] ?? ''), self::txt($d['file_name'] ?? ''), self::fmt($d['file_format'] ?? ''), self::intOrNull($d['file_size'] ?? 0), self::intOrNull($d['pages'] ?? 0), self::intOrNull($d['words'] ?? 0), $status, self::rating($d['rating'] ?? null), self::dateOrNull($d['started_at'] ?? ''), $fin, $id, $familyId, ]); } /** * 改状态(列表上的一键:想读 → 在读 → 读完)。 * * @return string 改完之后的状态 */ public static function setStatus(int $familyId, int $id, string $status): string { $old = self::find($familyId, $id); if (!$old) { return ''; } $status = self::status($status); $db = Database::get(); if ($status === 'done') { $db->prepare( 'UPDATE books SET status=?, finished_at=COALESCE(finished_at, ?), started_at=COALESCE(started_at, ?) WHERE id=? AND family_id=?' )->execute([$status, date('Y-m-d'), date('Y-m-d'), $id, $familyId]); } elseif ($status === 'reading') { $db->prepare( 'UPDATE books SET status=?, finished_at=NULL, started_at=COALESCE(started_at, ?) WHERE id=? AND family_id=?' )->execute([$status, date('Y-m-d'), $id, $familyId]); } else { $db->prepare('UPDATE books SET status=?, finished_at=NULL WHERE id=? AND family_id=?') ->execute([$status, $id, $familyId]); } return $status; } /** 读到第几章(阅读器翻页时记) */ public static function setProgress(int $familyId, int $id, int $seq): void { Database::get()->prepare( 'UPDATE books SET progress = ? WHERE id = ? AND family_id = ?' )->execute([max(0, $seq), $id, $familyId]); } public static function destroy(int $familyId, int $id): void { $b = self::find($familyId, $id); if (!$b) { return; } // 文件与封面是自己存的,数据库级联删不掉存储对象 if (!empty($b['file_key'])) { \App\Storage\Storage::delete($familyId, (string)$b['file_key']); } if (!empty($b['cover_key'])) { \App\Storage\Storage::delete($familyId, (string)$b['cover_key']); } Database::get()->prepare('DELETE FROM books WHERE id = ? AND family_id = ?')->execute([$id, $familyId]); } /** 整组替换章节(重新解析 / 重新上传文件时用) */ public static function replaceChapters(int $bookId, array $chapters): int { $db = Database::get(); $db->prepare('DELETE FROM book_chapters WHERE book_id = ?')->execute([$bookId]); $st = $db->prepare( 'INSERT INTO book_chapters (book_id, seq, title, content, words) VALUES (?,?,?,?,?)' ); $seq = 0; $words = 0; foreach ($chapters as $c) { $seq++; $body = (string)($c['content'] ?? ''); $w = self::countWords($body); $words += $w; $st->execute([$bookId, $seq, self::txt($c['title'] ?? ''), $body, $w]); } $db->prepare('UPDATE books SET chapter_count = ?, words = ? WHERE id = ?') ->execute([$seq, $words > 0 ? $words : null, $bookId]); return $seq; } // ---------------- 笔记 ---------------- /** * 一本书的笔记(按时间正序,**全部人**的都给)。 * * ⚠️ 昵称必须按**看的人**($viewerId)取,不能写成 `nk.viewer_id = n.person_id` * —— 那是"这人对自己的称呼",页面上会冒出一个跟谁都不对应的名字。 * (nicknames 是 (viewer_id, target_id) 二元组,缺了 viewer 就取错人。) * * 笔记不按人过滤:笔记是"读的人留下的字",家庭里没必要互相藏。 * * @param int $viewerId 谁在看(决定"我怎么称呼这条笔记的作者") */ public static function notes(int $bookId, int $viewerId = 0): array { $st = Database::get()->prepare( 'SELECT n.*, p.name AS person_name, p.avatar, p.avatar_color, nk.nickname AS my_nickname FROM book_notes n LEFT JOIN persons p ON p.id = n.person_id LEFT JOIN nicknames nk ON nk.target_id = n.person_id AND nk.viewer_id = ? WHERE n.book_id = ? ORDER BY n.created_at, n.id' ); $st->execute([$viewerId, $bookId]); return $st->fetchAll(); } /** @return int 笔记 id */ public static function addNote(int $familyId, int $bookId, ?int $personId, array $d): int { $content = trim((string)($d['content'] ?? '')); if ($content === '') { return 0; } $db = Database::get(); $db->prepare( 'INSERT INTO book_notes (book_id, person_id, kind, chapter, loc, content, source) VALUES (?,?,?,?,?,?,?)' )->execute([ $bookId, $personId > 0 ? $personId : null, isset(self::NOTE_KINDS[(string)($d['kind'] ?? '')]) ? (string)$d['kind'] : 'note', self::txt($d['chapter'] ?? ''), self::txt($d['loc'] ?? ''), mb_substr($content, 0, 8000, 'UTF-8'), self::source($d['source'] ?? ''), ]); return (int)$db->lastInsertId(); } public static function deleteNote(int $familyId, int $noteId, int $personId): void { // 只能删自己写的(导入的无主笔记谁都能删 —— 不然它永远留在那儿) $st = Database::get()->prepare( 'SELECT n.id FROM book_notes n JOIN books b ON b.id = n.book_id WHERE n.id = ? AND b.family_id = ? AND (n.person_id IS NULL OR n.person_id = ?)' ); $st->execute([$noteId, $familyId, $personId]); if ($st->fetchColumn() === false) { return; } Database::get()->prepare('DELETE FROM book_notes WHERE id = ?')->execute([$noteId]); } // ---------------- 统计 ---------------- /** * 书架的整体数字。 * * ⚠️ 读完的书按 **finished_at 的年份**分桶(不是 created_at): * "今年读完了几本"问的是读完的时间,加进书架的时间没有意义 * (很多人是年底一次性把看过的书补进去的)。 */ public static function stats(int $familyId): array { $db = Database::get(); $byStatus = []; foreach (self::STATUS as $k => $v) { $byStatus[$k] = 0; } $st = $db->prepare('SELECT status, COUNT(*) AS n FROM books WHERE family_id = ? GROUP BY status'); $st->execute([$familyId]); foreach ($st->fetchAll() as $r) { $byStatus[(string)$r['status']] = (int)$r['n']; } $one = static function (string $sql) use ($db, $familyId): int { $s = $db->prepare($sql); $s->execute([$familyId]); return (int)$s->fetchColumn(); }; $out = [ 'total' => array_sum($byStatus), 'status' => $byStatus, 'pages' => $one('SELECT COALESCE(SUM(pages),0) FROM books WHERE family_id = ? AND status = "done"'), 'words' => $one('SELECT COALESCE(SUM(words),0) FROM books WHERE family_id = ? AND status = "done"'), 'rated' => $one('SELECT COUNT(*) FROM books WHERE family_id = ? AND rating IS NOT NULL'), 'avgRate' => 0.0, 'notes' => $one('SELECT COUNT(*) FROM book_notes n JOIN books b ON b.id = n.book_id WHERE b.family_id = ?'), ]; $sum = $one('SELECT COALESCE(SUM(rating),0) FROM books WHERE family_id = ? AND rating IS NOT NULL'); $out['avgRate'] = $out['rated'] > 0 ? round($sum / $out['rated'], 1) : 0.0; // 按年读完(近 8 年) $st = $db->prepare( 'SELECT YEAR(finished_at) AS y, COUNT(*) AS n FROM books WHERE family_id = ? AND status = "done" AND finished_at IS NOT NULL GROUP BY YEAR(finished_at) ORDER BY y' ); $st->execute([$familyId]); $out['byYear'] = $st->fetchAll(); // 评分分布(0-10) $dist = array_fill(0, 11, 0); $st = $db->prepare( 'SELECT rating, COUNT(*) AS n FROM books WHERE family_id = ? AND rating IS NOT NULL GROUP BY rating' ); $st->execute([$familyId]); foreach ($st->fetchAll() as $r) { $dist[max(0, min(10, (int)$r['rating']))] = (int)$r['n']; } $out['ratingDist'] = $dist; // 读得最多的作者 / 分类 $st = $db->prepare( 'SELECT author AS name, COUNT(*) AS n FROM books WHERE family_id = ? AND author IS NOT NULL AND author <> "" GROUP BY author ORDER BY n DESC, author LIMIT 8' ); $st->execute([$familyId]); $out['topAuthors'] = $st->fetchAll(); $st = $db->prepare( 'SELECT category AS name, COUNT(*) AS n FROM books WHERE family_id = ? AND category IS NOT NULL AND category <> "" GROUP BY category ORDER BY n DESC, category LIMIT 8' ); $st->execute([$familyId]); $out['topCategories'] = $st->fetchAll(); return $out; } // ---------------- 展示 ---------------- public static function statusWord(string $k): string { return self::STATUS[$k] ?? '想读'; } public static function normalizeStatus(string $v): string { return self::status($v); } public static function kindWord(string $k): string { return self::NOTE_KINDS[$k] ?? '笔记'; } public static function sourceWord(string $k): string { return self::NOTE_SOURCES[$k] ?? (string)$k; } /** 文件大小的"人话" */ public static function sizeWord($bytes): string { $b = (int)$bytes; if ($b <= 0) { return ''; } if ($b < 1024) { return $b . ' B'; } if ($b < 1024 * 1024) { return round($b / 1024, 1) . ' KB'; } return round($b / 1024 / 1024, 1) . ' MB'; } /** 字数的"人话"(按中文习惯说"万字") */ public static function wordsWord($words): string { $w = (int)$words; if ($w <= 0) { return ''; } if ($w < 10000) { return number_format($w) . ' 字'; } return round($w / 10000, 1) . ' 万字'; } /** 中英混排的字数(中文按字、西文按词)—— 与 BookParser 同一口径 */ public static function countWords(string $s): int { $s = trim($s); if ($s === '') { return 0; } $cn = preg_match_all('/[\x{4e00}-\x{9fff}]/u', $s); $noCn = preg_replace('/[\x{4e00}-\x{9fff}]/u', ' ', $s); $en = preg_match_all('/[A-Za-z0-9]+/u', $noCn); return (int)$cn + (int)$en; } // ---------------- 内部 ---------------- private static function ownerId(int $familyId, int $id): ?int { if ($id <= 0) { return null; } $st = Database::get()->prepare('SELECT id FROM persons WHERE id = ? AND family_id = ?'); $st->execute([$id, $familyId]); return $st->fetchColumn() === false ? null : $id; } private static function status(string $v): string { return isset(self::STATUS[$v]) ? $v : 'wish'; } private static function source(string $v): string { $v = trim($v); return $v !== '' ? mb_substr($v, 0, 40, 'UTF-8') : 'manual'; } private static function title(string $v): string { return mb_substr(trim($v), 0, 300, 'UTF-8'); } private static function txt(string $v): ?string { $v = trim($v); return $v !== '' ? $v : null; } private static function longText(string $v): ?string { $v = trim($v); return $v !== '' ? mb_substr($v, 0, 20000, 'UTF-8') : null; } private static function key(string $v): ?string { $v = ltrim(trim($v), '/'); if ($v === '' || strpos($v, '..') !== false || strpos($v, "\0") !== false) { return null; } return $v; } private static function fmt(string $v): ?string { $v = strtolower(trim($v)); $v = preg_replace('/[^a-z0-9]/', '', $v) ?? ''; return $v !== '' ? mb_substr($v, 0, 20, 'UTF-8') : null; } private static function rating($v): ?int { if ($v === null || $v === '') { return null; } $n = (int)$v; return ($n >= 0 && $n <= 10) ? $n : null; } private static function intOrNull($v): ?int { $n = (int)$v; return $n > 0 ? $n : null; } private static function dateOrNull(string $v): ?string { $v = trim($v); return preg_match('/^\d{4}-\d{2}-\d{2}$/', $v) ? $v : null; } }