From 42aff02687fc3d6a1dda1d2c7bfe011703e6dd42 Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 28 Oct 2026 22:47:38 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=BB=E4=B9=A6=E4=B8=8A=E4=BC=A0+=E5=9C=A8?= =?UTF-8?q?=E7=BA=BF=E9=98=85=E8=AF=BB+=E7=AC=94=E8=AE=B0=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=EF=BC=9A=E8=A1=A5=20books/{upload,chapters,chapter,im?= =?UTF-8?q?port=5Fnotes}=20#28?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户原话(2026-10-28):「鸿蒙端对齐网页端」。读书端上只有书架(状态/评分/进度/ 笔记),上传解析、在线读、笔记导入都在网页端。 补四个 API 端点(模型 BookParser / Book::chapters/chapter/replaceChapters / BookNoteImport 早已齐,纯粹没暴露): · POST /books/upload 上传 EPUB/TXT 等 → 存文件 → BookParser 解析 → 建书 + replaceChapters + EPUB 自带封面 · GET /books//chapters 章节列表(无正文) · GET /books//chapter?seq=N 单章正文 · POST /books/import_notes 粘贴读书笔记原文 → 解析 → 导入(不信前端结果) 判据:_i28_book_api.py 11/0(上传建书+落章节 · 章节列表 · 单章正文 · 笔记导入),_i28_falsify.py 注掉 upload 分支变红。 --- app/ApiHandler.php | 125 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/app/ApiHandler.php b/app/ApiHandler.php index 6ea1ade..2d586cb 100644 --- a/app/ApiHandler.php +++ b/app/ApiHandler.php @@ -2999,9 +2999,134 @@ final class ApiHandler private function books(int $id, string $sub, string $method): void { [$fam, $pid] = Api::me(); + + /* ──────────────────────────────────────────────────────────────── + 🔴 工单 #28(用户 2026-10-28「鸿蒙端对齐网页端」):读书上传 + 在线阅读 + 笔记导入。 + + 端上原来只有书架(状态/评分/进度/笔记),上传解析、在线读、笔记导入都在网页端。 + + 这里补三个端点(模型 `BookParser` / `Book::chapters/chapter/replaceChapters` / + `BookNoteImport` 早已齐,纯粹是**没暴露 API**): + POST /api/v1/books/upload 上传 EPUB/TXT → 解析 → 建书 + 章节 + GET /api/v1/books//chapters 章节列表(无正文) + GET /api/v1/books//chapter?seq=N 单章正文 + POST /api/v1/books/import_notes 粘贴读书笔记原文 → 解析 → 导入 + + ⚠️ 上传与导入都是 `$id=0`(家族级操作,不针对已有书)⇒ 放 `$id>0` 之前。 + ⚠️ 文件存 key 与 Web 端同一套(`books/` + 随机 hex + 扩展名), + `Storage::putFile` 落盘、`BookParser::parse` 解析。 + ──────────────────────────────────────────────────────────────── */ + if ($sub === 'upload') { + Api::requireWrite(); + if ($method !== 'POST') { Api::fail('不支持的方法。', 'method_not_allowed', 405); } + $file = $_FILES['file'] ?? null; + if (!is_array($file)) { + Api::fail('没有收到文件。', 'unprocessable', 422); + } + if (!array_key_exists('error', $file) || is_array($file['error'])) { + Api::fail('上传参数不对。', 'unprocessable', 422); + } + $origName = (string)($file['name'] ?? ''); + $tmp = (string)($file['tmp_name'] ?? ''); + $ext = strtolower((string)pathinfo($origName, PATHINFO_EXTENSION)); + if (!in_array($ext, Book::FILE_EXT, true)) { + Api::fail('这个格式不支持(' . implode(' / ', Book::FILE_EXT) . ')。', 'unprocessable', 422); + } + if ($tmp === '' || !is_file($tmp)) { + Api::fail('文件没有传上来。', 'unprocessable', 422); + } + $safeExt = preg_replace('/[^a-z0-9]/', '', $ext) ?? ''; + $newKey = 'books/' . bin2hex(random_bytes(12)) . ($safeExt !== '' ? '.' . $safeExt : ''); + $fino = new \finfo(FILEINFO_MIME_TYPE); + $mime = (string)$fino->file($tmp); + $res = \App\Storage\Storage::putFile( + $fam, $tmp, $newKey, $mime !== '' ? $mime : 'application/octet-stream'); + if (empty($res['ok'])) { + Api::fail((string)($res['error'] ?? '文件没存下来'), 'unprocessable', 422); + } + + $p = BookParser::parse($tmp, $ext, $origName); + $meta = (is_array($p['meta'] ?? null) ? $p['meta'] : []); + $titleIn = trim((string)($meta['title'] ?? '')); + if ($titleIn === '') { + $titleIn = trim((string)pathinfo($origName, PATHINFO_FILENAME)); + } + if ($titleIn === '') { + // 存好了但读不出书名——不能白传,回一个错误让人填 + \App\Storage\Storage::delete($fam, $newKey); + Api::fail('读不出书名,请手填后再传。', 'unprocessable', 422); + } + $d = [ + 'title' => $titleIn, + 'author' => (string)($meta['author'] ?? ''), + 'publisher' => (string)($meta['publisher'] ?? ''), + 'language' => (string)($meta['language'] ?? ''), + 'file_key' => $newKey, + 'file_name' => $origName, + 'file_format' => $ext, + 'file_size' => (int)@filesize($tmp), + 'status' => 'reading', + ]; + $newId = Book::create($fam, $pid, $d); + $chapters = (is_array($p['chapters'] ?? null) ? $p['chapters'] : []); + if ($chapters) { + Book::replaceChapters($newId, $chapters); + } + // 封面:EPUB 自带封面优先(走 Upload::image 同一条管线) + if (!empty($p['cover']['tmp'])) { + $ctmp = (string)$p['cover']['tmp']; + $fake = [ + 'name' => 'cover', 'type' => (string)($p['cover']['mime'] ?? 'image/jpeg'), + 'tmp_name' => $ctmp, 'error' => UPLOAD_ERR_OK, 'size' => (int)@filesize($ctmp), + ]; + $upc = Upload::image($fam, $fake, 'bookcover', ''); + if ($upc['ok']) { + Book::update($fam, $newId, ['cover_key' => (string)$upc['file']]); + } + @unlink($ctmp); + } + Api::ok([ + 'id' => $newId, + 'book' => Book::find($fam, $newId), + 'chapters' => count($chapters), + 'note' => (string)($p['note'] ?? ''), + ], 201); + } + + if ($sub === 'import_notes') { + Api::requireWrite(); + if ($method !== 'POST') { Api::fail('不支持的方法。', 'method_not_allowed', 405); } + $raw = Api::str('raw', '', 200000); + if ($raw === '') { Api::fail('要导入的笔记不能为空。', 'unprocessable', 422); } + $source = BookNoteImport::sourceOf(Api::str('source', 'weread')); + /* ⚠️ 与 Web 端 notes_import_save 同口径:**重新解析**,不信前端传的结果 */ + $parsed = BookNoteImport::parse($raw); + $res = BookNoteImport::import($fam, $pid, $parsed, $source); + if ((int)$res['added'] === 0 && (int)$res['created'] === 0) { + Api::fail('这段里没认出能导入的笔记。检查一下是不是把书名行也贴上了。', 'unprocessable', 422); + } + Api::ok([ + 'created' => (int)$res['created'], + 'added' => (int)$res['added'], + 'titles' => $res['titles'], + ]); + } + if ($id > 0) { $bk = Book::find($fam, $id); if (!$bk) { Api::notFound('这本书'); } + + if ($sub === 'chapters' && $method === 'GET') { + Api::ok(['items' => Book::chapters($id)]); + } + if ($sub === 'chapter' && $method === 'GET') { + $seq = Api::int('seq'); + if ($seq <= 0) { Api::fail('要读哪一章?缺 `seq`。', 'unprocessable', 422); } + $ch = Book::chapter($id, $seq); + if (!$ch) { Api::notFound('这一章'); } + Api::ok($ch); + } + if ($sub === 'notes' && $method === 'GET') { Api::ok(Book::notes($id, $pid)); } if ($sub === 'notes' && $method === 'POST') { Api::requireWrite();