读书上传+在线阅读+笔记导入:补 books/{upload,chapters,chapter,import_notes} #28

用户原话(2026-10-28):「鸿蒙端对齐网页端」。读书端上只有书架(状态/评分/进度/
笔记),上传解析、在线读、笔记导入都在网页端。

补四个 API 端点(模型 BookParser / Book::chapters/chapter/replaceChapters /
BookNoteImport 早已齐,纯粹没暴露):
· POST /books/upload       上传 EPUB/TXT 等 → 存文件 → BookParser 解析 →
                            建书 + replaceChapters + EPUB 自带封面
· GET  /books/<id>/chapters  章节列表(无正文)
· GET  /books/<id>/chapter?seq=N  单章正文
· POST /books/import_notes  粘贴读书笔记原文 → 解析 → 导入(不信前端结果)

判据:_i28_book_api.py 11/0(上传建书+落章节 · 章节列表 · 单章正文 ·
笔记导入),_i28_falsify.py 注掉 upload 分支变红。
This commit is contained in:
2026-10-28 22:47:38 +08:00
parent b6dc60452a
commit 42aff02687
+125
View File
@@ -2999,9 +2999,134 @@ final class ApiHandler
private function books(int $id, string $sub, string $method): void private function books(int $id, string $sub, string $method): void
{ {
[$fam, $pid] = Api::me(); [$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/<id>/chapters 章节列表(无正文)
GET /api/v1/books/<id>/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) { if ($id > 0) {
$bk = Book::find($fam, $id); $bk = Book::find($fam, $id);
if (!$bk) { Api::notFound('这本书'); } 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 === 'GET') { Api::ok(Book::notes($id, $pid)); }
if ($sub === 'notes' && $method === 'POST') { if ($sub === 'notes' && $method === 'POST') {
Api::requireWrite(); Api::requireWrite();