feat(n11): add base64 document staging from e-Court
เพิ่มทางเข้าเอกสารสำหรับ e-Court ที่ไม่มี URL ให้ดึง — POST /api/note/files/stage (x-api-key DOCVER_API_KEY) ส่งเนื้อไฟล์ PDF/รูปเป็น base64 ระบบเก็บใน RAM staging (TTL sliding 8 ชม. + LRU + cap 100MB/ไฟล์ 256MB รวม — ห้ามเขียน disk/DB/MinIO แม้แต่ไบต์เดียวตามโจทย์) ส่วน annotation/bookmark เก็บถาวรตามระบบเดิม: - lib/staged-docs.ts: Map บน globalThis + sentinel "staged:<uuid>.<ext>" ใน pdf_url (ext จาก magic bytes — ไม่ต้อง migration) + hasStagedDoc แยกจาก getStagedDoc - lib/ecourt-fid.ts: external_id เก็บ HMAC-SHA256(pepper, fid) แทน fid ดิบ (env FID_HASH_PEPPER fail-closed) + แปลง row เก่าด้วย npm run migrate:ecourt-fid - route stage: อ่าน body แบบ stream นับไบต์ + idle 60 วิ/total 10 นาที = 408 + concurrency default 1 = 429 + base64 strict + magic bytes กำหนด ext เอง - registerStagedFile: ลำดับกัน race 23505 — stage ใต้ id ก่อน insert โดนชนแล้ว re-stage ใต้ id ผู้ชนะ + try/finally คืน RAM ผู้แพ้ทุกทางออก - ทางอ่าน 4 จุด: /api/note/pdf + export route (410 gate ไทย), resolvePublicPdfPath guard PdfPathError, PDFViewer โหลดผ่าน route + objectURL (gate ครบทุกชนิดไฟล์) - session route (D5): ลิงก์ fid-only ตัดสิน staging ตั้งแต่แลก token — มี bytes ได้ fileId / หมดอายุ fileError "พ้นช่วงจัดเตรียม" / token ออกทุกกรณี - Swagger UI: endpoint ใหม่ + คู่มือ retry (408/429/5xx ยิงซ้ำได้ stage ซ้ำปลอดภัย) - MockStageForm บน /note/dev ทดสอบ 3 ขั้นในหน้าเดียว (ต้องถือ API key จริง) Verify ครบตาม docs/PLAN-STAGED.md: tsc สะอาด + vitest 59 เคสใหม่ผ่าน + E2E browser จริง (textLayer ไทยเรียงถูก / export ฝัง annotation / restart ได้ fileError ตั้งแต่ bootstrap / RAM 100MB: peak WS +580MB commit จริง +92MB) — ผ่าน code review รอบ 7 แก้ครบทุก finding Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
2e951f99f4
commit
a02e983ba8
34 changed files with 2396 additions and 62 deletions
|
|
@ -156,6 +156,22 @@ import {
|
|||
type Tool,
|
||||
} from "./toolbar-tools";
|
||||
|
||||
/** N11: error จาก /api/note/pdf ของเอกสาร staging (410 พ้นช่วงจัดเตรียม) — โชว์ข้อความ
|
||||
* gate ไทยจาก route ตรง ๆ แทนข้อความทั่วไป (เอกสาร staging ไม่มีไฟล์ดิบให้ fallback) */
|
||||
class StagedGateError extends Error {}
|
||||
|
||||
/** อ่านข้อความ error (JSON {"error"}) จาก response ของ route — ไม่ใช่ JSON = ข้อความ
|
||||
* ทั่วไปพร้อมรหัสสถานะ */
|
||||
async function stagedGateMessage(res: Response): Promise<string> {
|
||||
try {
|
||||
const body = (await res.json()) as { error?: unknown };
|
||||
if (typeof body.error === "string" && body.error !== "") return body.error;
|
||||
} catch {
|
||||
// ไม่ใช่ JSON — ใช้ข้อความทั่วไป
|
||||
}
|
||||
return `โหลดเอกสารไม่สำเร็จ (HTTP ${res.status})`;
|
||||
}
|
||||
|
||||
/** tool ที่ลากคลุมข้อความใน textLayer ได้ (ตัวที่ textLayer ยังรับ pointer อยู่) —
|
||||
* ทั้งหมดซ่อน native selection แล้ววาดของเราเองแทน: highlight = preview มาร์กสด,
|
||||
* select/comment/textbox/stamp = selection สีฟ้าสะอาด (กรองแนวเส้นทางลาก) */
|
||||
|
|
@ -1741,24 +1757,43 @@ export default function PDFViewer({
|
|||
// URL จริงที่ browser ใช้โหลดไฟล์ (fetch/img.src ตรง — ต้องเติม basePath เอง)
|
||||
// N8: รูปรีโมต (ลิงก์ e-Court) โหลดผ่าน /api/note/pdf ของเราแทนทางตรง — กัน CORS
|
||||
// ของปลายทาง + ผ่าน allowlist/จำกัดขนาดเดียวกับ PDF (route คืน bytes รูป + content-type)
|
||||
const fileUrl = isHttpUrl(file.pdfUrl)
|
||||
? withBasePath(`/api/note/pdf?fileId=${file.id}`)
|
||||
: withBasePath(file.pdfUrl);
|
||||
// N11: รูป staging (sentinel staged:) ก็โหลดผ่าน route เดียวกัน — sentinel เป็น
|
||||
// URI ที่ไม่มี static path ให้โหลดตรง (bytes อยู่ใน RAM ของ server)
|
||||
const fileUrl =
|
||||
isHttpUrl(file.pdfUrl) || file.pdfUrl.startsWith("staged:")
|
||||
? withBasePath(`/api/note/pdf?fileId=${file.id}`)
|
||||
: withBasePath(file.pdfUrl);
|
||||
(async () => {
|
||||
let pages: { src: string | null; width: number; height: number }[];
|
||||
if (imageFormatFromUrl(file.pdfUrl) === "tiff") {
|
||||
// N3: TIFF หลายเฟรม = หลายหน้า — decode ครั้งเดียวต่อการเปิดไฟล์
|
||||
// (utif2 dynamic import ภายใน decodeTiffPages) แล้วเก็บ blob URL ไว้ใช้ต่อ
|
||||
const buffer = await (await fetch(fileUrl)).arrayBuffer();
|
||||
// N11: staged หมดอายุ = 410 gate จาก route — โชว์ข้อความนั้นแทนทิ้งให้ decode พัง
|
||||
const res = await fetch(fileUrl);
|
||||
if (!res.ok) throw new StagedGateError(await stagedGateMessage(res));
|
||||
const buffer = await res.arrayBuffer();
|
||||
pages = await decodeTiffPages(buffer, IMAGE_PAGE_WIDTH);
|
||||
for (const p of pages) if (p.src) objectUrls.push(p.src);
|
||||
} else {
|
||||
// N11: รูปที่มาทาง route (รูปรีโมต N8 / staged N11) = fetch เองก่อน จะได้จับ
|
||||
// 410 gate ตอน staging หมดอายุเป็นข้อความไทยจาก server (decode จาก URL ตรง
|
||||
// ได้แค่ error กลาง ๆ ที่ชี้ทางแก้ผิด) — ได้ bytes แล้วผลิต object URL เก็บไว้
|
||||
// ใช้ต่อ (fetch ครั้งเดียวต่อการเปิดไฟล์: virtualization remount หน้าไม่
|
||||
// ยิง route ซ้ำ ไม่ touch TTL รอบใหม่) · รูป static ในเครื่องใช้ path ตรง
|
||||
// ตามเดิม (มี browser cache ของ static อยู่แล้ว)
|
||||
let imgSrc = fileUrl;
|
||||
if (isHttpUrl(file.pdfUrl) || file.pdfUrl.startsWith("staged:")) {
|
||||
const res = await fetch(fileUrl);
|
||||
if (!res.ok) throw new StagedGateError(await stagedGateMessage(res));
|
||||
imgSrc = URL.createObjectURL(await res.blob());
|
||||
objectUrls.push(imgSrc);
|
||||
}
|
||||
const img = new Image();
|
||||
img.src = fileUrl;
|
||||
img.src = imgSrc;
|
||||
await img.decode();
|
||||
const factor = IMAGE_PAGE_WIDTH / img.naturalWidth;
|
||||
pages = [
|
||||
{ src: fileUrl, width: IMAGE_PAGE_WIDTH, height: img.naturalHeight * factor },
|
||||
{ src: imgSrc, width: IMAGE_PAGE_WIDTH, height: img.naturalHeight * factor },
|
||||
];
|
||||
}
|
||||
if (cancelled) {
|
||||
|
|
@ -1771,7 +1806,12 @@ export default function PDFViewer({
|
|||
setPageSizes(pages.map((p) => ({ width: p.width, height: p.height })));
|
||||
})().catch((err) => {
|
||||
console.error("โหลดรูปไม่สำเร็จ", err);
|
||||
if (!cancelled) setLoadError("โหลดรูปไม่สำเร็จ — ไฟล์อาจเสียหรือรูปแบบไม่รองรับ");
|
||||
if (!cancelled)
|
||||
setLoadError(
|
||||
err instanceof StagedGateError
|
||||
? err.message
|
||||
: "โหลดรูปไม่สำเร็จ — ไฟล์อาจเสียหรือรูปแบบไม่รองรับ",
|
||||
);
|
||||
objectUrls.forEach((u) => URL.revokeObjectURL(u));
|
||||
});
|
||||
return () => {
|
||||
|
|
@ -1786,13 +1826,20 @@ export default function PDFViewer({
|
|||
pdfjs.GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs");
|
||||
// โหลดผ่าน /api/note/pdf ที่ซ่อม ToUnicode จาก ActualText ของ PDF ไทยที่ cmap เพี้ยน
|
||||
// (ข้อความถูกทั้ง select/copy/ค้นหา) — endpoint fail ก็ถอยไปไฟล์ดิบตามเดิม
|
||||
// (เอกสาร staging N11 ไม่มีไฟล์ดิบให้ถอย — 410 gate จาก route แสดงตรง ๆ)
|
||||
let data: ArrayBuffer;
|
||||
try {
|
||||
if (file.pdfUrl.startsWith("staged:")) {
|
||||
const res = await fetch(withBasePath(`/api/note/pdf?fileId=${file.id}`));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (!res.ok) throw new StagedGateError(await stagedGateMessage(res));
|
||||
data = await res.arrayBuffer();
|
||||
} catch {
|
||||
data = await (await fetch(withBasePath(file.pdfUrl))).arrayBuffer();
|
||||
} else {
|
||||
try {
|
||||
const res = await fetch(withBasePath(`/api/note/pdf?fileId=${file.id}`));
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
data = await res.arrayBuffer();
|
||||
} catch {
|
||||
data = await (await fetch(withBasePath(file.pdfUrl))).arrayBuffer();
|
||||
}
|
||||
}
|
||||
const doc = await pdfjs.getDocument({ data }).promise;
|
||||
loadedDoc = doc;
|
||||
|
|
@ -1816,7 +1863,12 @@ export default function PDFViewer({
|
|||
setPageSizes(sizes);
|
||||
})().catch((err) => {
|
||||
console.error("โหลดเอกสารไม่สำเร็จ", err);
|
||||
if (!cancelled) setLoadError("โหลดเอกสารไม่สำเร็จ — ไฟล์อาจเสียหรือไม่ใช่ PDF ที่รองรับ");
|
||||
if (!cancelled)
|
||||
setLoadError(
|
||||
err instanceof StagedGateError
|
||||
? err.message
|
||||
: "โหลดเอกสารไม่สำเร็จ — ไฟล์อาจเสียหรือไม่ใช่ PDF ที่รองรับ",
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue