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:
Your Name 2026-11-05 12:53:56 +07:00
parent 2e951f99f4
commit a02e983ba8
34 changed files with 2396 additions and 62 deletions

View file

@ -8,9 +8,16 @@ import { eq } from "drizzle-orm";
import { db } from "@/db";
import { files } from "@/db/schema";
import { ecourtExternalId, isEcourtExternalId } from "@/lib/ecourt-fid";
import {
deleteStagedDoc,
putStagedDoc,
stagedSourceUrl,
type StagedExt,
} from "@/lib/staged-docs";
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
import {
externalIdFor,
ECOURT_DEFAULT_TITLE,
isHttpUrl,
normDocumentGroup,
titleFromExternalUrl,
@ -84,8 +91,9 @@ export async function registerExternalFile(
const checked = validateExternalFileUrl(trimmed);
if (!checked.ok) return { ok: false, error: URL_ERROR_TEXT[checked.error] };
sourceUrl = checked.url.href;
title = titleFromExternalUrl(checked.url, rawFileId);
externalId = externalIdFor(rawFileId, checked.url);
title = titleFromExternalUrl(checked.url);
const id = rawFileId?.trim();
externalId = id ? ecourtExternalId(id) : `url:${checked.url.href}`;
} else if (trimmed.startsWith("/")) {
const local = validateLocalPath(trimmed);
if (!local.ok) return local;
@ -94,7 +102,7 @@ export async function registerExternalFile(
title =
base.slice(0, base.length - local.ext.length).trim().slice(0, 200) || "เอกสาร";
const id = rawFileId?.trim();
externalId = id ? `ecourt:${id}` : `url:${local.path}`;
externalId = id ? ecourtExternalId(id) : `url:${local.path}`;
} else {
return {
ok: false,
@ -102,12 +110,13 @@ export async function registerExternalFile(
};
}
// แถว ecourt:<fileId> (เอกสารของ e-Court) สร้าง/แก้ได้เฉพาะลิงก์ที่ผ่าน SSO route
// (proof cookie ที่เซ็นครอบ param ชุดนี้ — review 2026-10-30): /note?fileUrl&fileId
// แถว ecourt:<hash ของ fid> (เอกสารของ e-Court) สร้าง/แก้ได้เฉพาะลิงก์ที่ผ่าน SSO
// route (proof cookie ที่เซ็นครอบ param ชุดนี้ — review 2026-10-30): /note?fileUrl&fileId
// พิมพ์เองโดยผู้มี session ใด ๆ เคยใช้เขียน/refresh pdf_url ของเอกสารคนอื่นให้ชี้
// PDF ที่ตัวเองเลือก — ตอนนี้เปิดดูของที่รู้จักแล้วได้อย่างเดียว (แถว url:… ของ
// samples/dev ไม่เกี่ยว — key คือ URL ตัวมันเอง)
const ecourtRow = externalId.startsWith("ecourt:");
// samples/dev ไม่เกี่ยว — key คือ URL ตัวมันเอง) · external_id เก็บ hash ของ fid
// ผ่าน lib/ecourt-fid.ts (ไม่มี fid ดิบใน DB — 2026-11-04)
const ecourtRow = isEcourtExternalId(externalId);
const trusted = opts?.trusted === true;
const [existing] = await db
@ -158,3 +167,91 @@ export async function registerExternalFile(
}
return { ok: true, fileId: id };
}
/** N11 (PLAN-STAGED.md D3) — ลงทะเบียนเอกสาร base64 จาก e-Court: bytes เก็บใน RAM
* staging (lib/staged-docs.ts) ใต้ id ของ row สุดท้ายที่คืน จึงไม่มีทางเกิด
* "row ชี้ sentinel ที่ไม่มี bytes" · ผู้เรียกต้องผ่าน requireDocverApiKey +
* magic-byte check (ext มาจากผลตรวจ) มาก่อนแล้ว — trusted by construction
* · ลำดับ: resolve id ก่อน → stage ใต้ id นั้น → เขียน DB (ตรวจ cap เกิดก่อนแตะ DB —
* DB พังหลัง stage = entry กำพร้าใน RAM ที่ TTL กินเอง ไม่มีอะไรต้องเก็บกวาด)
* · insert ชน 23505 (คู่ขนาน fid เดียวกัน) = re-stage ใต้ id ผู้ชนะ + ลบของผู้แพ้
* (bytes สุดท้าย = request ที่เสร็จทีหลัง — last-write-wins ตาม semantics
* "re-POST = เนื้อล่าสุด") · โยน error ของ DB ต่อให้ route ตอบ 500 · ไม่ publish
* SSE / ไม่ revalidatePath (รายการไฟล์ไม่ sync ผ่าน SSE — carve-out invariant #4) */
export async function registerStagedFile(
fid: string,
bytes: Buffer,
ext: StagedExt,
opts?: { title?: string; typeId?: string },
): Promise<{ fileId: string }> {
const externalId = ecourtExternalId(fid);
// typeId: ส่งมา = refresh ย้ายกลุ่ม, ไม่ส่ง = คงเดิมห้ามเคลียร์ (เกณฑ์เดียวกับ N8)
const typeId = normDocumentGroup(opts?.typeId);
const title = opts?.title ?? null;
const [existing] = await db
.select()
.from(files)
.where(eq(files.externalId, externalId))
.limit(1);
let winnerId: string;
// row ปัจจุบันของผู้ชนะ — มีค่าเฉพาะเมื่อเป็น row เดิม (insert ใหม่ครบทุกค่าแล้ว
// ไม่ต้อง patch)
let current: typeof files.$inferSelect | undefined;
if (existing) {
winnerId = existing.id;
current = existing;
putStagedDoc(winnerId, bytes, ext); // replace ช่องเดิม — re-POST = เนื้อล่าสุด
} else {
const candidateId = randomUUID();
putStagedDoc(candidateId, bytes, ext);
try {
await db.insert(files).values({
id: candidateId,
title: title ?? ECOURT_DEFAULT_TITLE,
pdfUrl: stagedSourceUrl(candidateId, ext),
externalId,
typeId,
});
winnerId = candidateId;
} catch (err) {
if ((err as { code?: string })?.code !== "23505") throw err;
// คู่ขนาน fid เดียวกันชนะไปก่อน — หา row ผู้ชนะ แล้ว staging ทับด้วย bytes
// ของเรา (last-write-wins) + คืน RAM ของผู้แพ้ — finally ครอบทุกทางออกของ
// branch นี้ (select/put รอบสองโยนก็ห้ามทิ้ง candidate ไว้กิน total cap จน
// TTL หมด — code review 2026-11-05)
try {
const [row] = await db
.select()
.from(files)
.where(eq(files.externalId, externalId))
.limit(1);
if (!row) {
// ผู้ชนะหายไปกลางคัน (แทบเป็นไปไม่ได้) — โยน error 23505 ต่อ (review ข้อ 3)
throw err;
}
putStagedDoc(row.id, bytes, ext);
winnerId = row.id;
current = row;
} finally {
deleteStagedDoc(candidateId);
}
}
}
if (current) {
const sentinel = stagedSourceUrl(winnerId, ext);
const patch: { pdfUrl?: string; typeId?: string; title?: string } = {};
if (current.pdfUrl !== sentinel) patch.pdfUrl = sentinel;
if (typeId !== null && current.typeId !== typeId) patch.typeId = typeId;
// title ปลดล็อกเฉพาะค่าปัจจุบันยังเป็น default (ECOURT_DEFAULT_TITLE — ไม่ฝัง
// fid) — ครั้งแรกที่ส่ง title จริงชนะแล้วล็อก (กันค้างชื่อ default ตลอดชีพเมื่อ
// ครั้งแรกลืมส่ง)
if (title !== null && current.title === ECOURT_DEFAULT_TITLE) patch.title = title;
if (Object.keys(patch).length > 0) {
await db.update(files).set(patch).where(eq(files.id, winnerId));
}
}
return { fileId: winnerId };
}