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

@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__clearStagedDocsForTest,
detectStagedExt,
getStagedDoc,
hasStagedDoc,
parseStagedSource,
putStagedDoc,
stagedSourceUrl,
StagedCapError,
} from "@/lib/staged-docs";
// PLAN-STAGED.md D2 — RAM staging (pure module ไม่ต้องตั้ง DATABASE_URL)
// ใช้ fake timers คุม Date.now ของ TTL sliding + env stub ปรับ cap ต่อเคส
// (store เก็บบน globalThis จึงต้อง __clearStagedDocsForTest ทุกเคส)
const ID_A = "0d7e6a52-9a71-4d40-bd1c-b2e1a44ab133";
const ID_B = "11111111-2222-4333-8444-555555555555";
const ID_C = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
beforeEach(() => {
__clearStagedDocsForTest();
});
afterEach(() => {
vi.unstubAllEnvs();
vi.useRealTimers();
});
describe("sentinel: stagedSourceUrl/parseStagedSource", () => {
it("สร้าง + แกะกลับได้ครบทุก ext", () => {
for (const ext of ["pdf", "jpg", "png", "tif"] as const) {
const url = stagedSourceUrl(ID_A, ext);
expect(url).toBe(`staged:${ID_A}.${ext}`);
expect(parseStagedSource(url)).toEqual({ fileId: ID_A, ext });
}
});
it("ปฏิเสธ uuid/ext เพี้ยนและค่าที่ไม่ใช่ sentinel", () => {
expect(parseStagedSource(`staged:not-a-uuid.pdf`)).toBeNull();
expect(parseStagedSource(`staged:${ID_A}.exe`)).toBeNull();
expect(parseStagedSource(`staged:${ID_A}`)).toBeNull();
expect(parseStagedSource(`staged:.pdf`)).toBeNull();
expect(parseStagedSource(`/samples/${ID_A}.pdf`)).toBeNull();
expect(parseStagedSource("https://x.go.th/a.pdf")).toBeNull();
expect(parseStagedSource("")).toBeNull();
});
});
describe("detectStagedExt — magic bytes", () => {
it("แยก pdf/jpg/png/tif (ทั้งสอง endian) ถูก", () => {
expect(detectStagedExt(Buffer.from("%PDF-1.7\n..."))).toBe("pdf");
expect(detectStagedExt(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]))).toBe("jpg");
expect(detectStagedExt(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d]))).toBe("png");
expect(detectStagedExt(Buffer.from([0x49, 0x49, 0x2a, 0x00, 0x00]))).toBe("tif");
expect(detectStagedExt(Buffer.from([0x4d, 0x4d, 0x00, 0x2a, 0x00]))).toBe("tif");
});
it("ไม่รู้จัก = null (รวมไฟล์ว่าง/GIF/ตัด magic สั้นเกิน)", () => {
expect(detectStagedExt(Buffer.alloc(0))).toBeNull();
expect(detectStagedExt(Buffer.from("GIF89a"))).toBeNull();
expect(detectStagedExt(Buffer.from([0x89, 0x50]))).toBeNull();
expect(detectStagedExt(Buffer.from("%PD"))).toBeNull();
});
});
describe("put/get ตัวตน", () => {
it("put แล้ว get ได้ bytes เดิม (อ่านซ้ำก็ได้)", () => {
const bytes = Buffer.from("%PDF-1.4 เอกสารทดสอบ");
putStagedDoc(ID_A, bytes, "pdf");
expect(getStagedDoc(`staged:${ID_A}.pdf`)).toEqual(bytes);
expect(getStagedDoc(`staged:${ID_A}.pdf`)).toEqual(bytes);
expect(hasStagedDoc(`staged:${ID_A}.pdf`)).toBe(true);
});
it("get ด้วย ext ที่ไม่ตรง entry = miss แต่ไม่ทำลาย entry เดิม", () => {
const bytes = Buffer.from("%PDF-1.4");
putStagedDoc(ID_A, bytes, "pdf");
expect(getStagedDoc(`staged:${ID_A}.jpg`)).toBeNull();
expect(getStagedDoc(`staged:${ID_A}.pdf`)).toEqual(bytes);
});
it("replace ช่องเดิมต่อ fileId — ไม่งอก entry ใหม่ต่อครั้ง", () => {
vi.stubEnv("STAGED_MAX_TOTAL_BYTES", "1000");
putStagedDoc(ID_B, Buffer.alloc(500, 1), "pdf");
putStagedDoc(ID_B, Buffer.alloc(300, 2), "pdf");
// ถ้างอกเป็น 2 entry รวม 800 + ID_C 400 = 1200 > 1000 จะโดน evict — แทนที่แล้ว
// รวม 300 + 400 = 700 ทุกตัวรอด
putStagedDoc(ID_C, Buffer.alloc(400, 3), "pdf");
expect(getStagedDoc(`staged:${ID_B}.pdf`)).toEqual(Buffer.alloc(300, 2));
expect(getStagedDoc(`staged:${ID_C}.pdf`)).toEqual(Buffer.alloc(400, 3));
});
});
describe("TTL sliding", () => {
it("get ที่สำเร็จ = touch — ใช้งานต่อเนื่องไม่หมดอายุ / ปล่อยเลยหมดแล้วถูกเก็บกวาด", () => {
vi.useFakeTimers();
vi.stubEnv("STAGED_DOC_TTL_SECONDS", "10");
const bytes = Buffer.from("%PDF-1.4");
putStagedDoc(ID_A, bytes, "pdf");
vi.advanceTimersByTime(9_000);
expect(getStagedDoc(`staged:${ID_A}.pdf`)).toEqual(bytes); // touch ที่ 9 วิ
vi.advanceTimersByTime(9_000); // 18 วินับจาก put แต่ 9 วินับจาก touch ล่าสุด
expect(getStagedDoc(`staged:${ID_A}.pdf`)).toEqual(bytes);
vi.advanceTimersByTime(11_000); // ไม่มีใคร touch 11 วิ = พ้น TTL
expect(getStagedDoc(`staged:${ID_A}.pdf`)).toBeNull();
expect(hasStagedDoc(`staged:${ID_A}.pdf`)).toBe(false);
// หมดอายุแล้ว get เก็บกวาดทิ้ง — put ใหม่ได้ตามปกติ
putStagedDoc(ID_A, bytes, "pdf");
expect(hasStagedDoc(`staged:${ID_A}.pdf`)).toBe(true);
});
it("hasStagedDoc ไม่ touch TTL (lastAccessAt ไม่ขยับ)", () => {
vi.useFakeTimers();
vi.stubEnv("STAGED_DOC_TTL_SECONDS", "10");
const bytes = Buffer.from("%PDF-1.4");
putStagedDoc(ID_A, bytes, "pdf");
vi.advanceTimersByTime(8_000);
expect(hasStagedDoc(`staged:${ID_A}.pdf`)).toBe(true);
// อีก 5 วิ: รวม 13 วิจาก put — ถ้า has() แอบ touch ตัวนี้จะยังไม่หมดอายุ (5 < 10)
vi.advanceTimersByTime(5_000);
expect(hasStagedDoc(`staged:${ID_A}.pdf`)).toBe(false);
expect(getStagedDoc(`staged:${ID_A}.pdf`)).toBeNull();
});
});
describe("cap + LRU", () => {
it("เกิน total cap = evict รายการ lastAccessAt เก่าสุด (ผู้เพิ่งใช้รอด)", () => {
vi.useFakeTimers();
vi.stubEnv("STAGED_MAX_TOTAL_BYTES", "1000");
putStagedDoc(ID_A, Buffer.alloc(400), "pdf"); // t=0
vi.advanceTimersByTime(1_000);
putStagedDoc(ID_B, Buffer.alloc(400), "pdf"); // t=1s
vi.advanceTimersByTime(1_000);
expect(getStagedDoc(`staged:${ID_A}.pdf`)).toEqual(Buffer.alloc(400)); // A ถูก touch ล่าสุด
putStagedDoc(ID_C, Buffer.alloc(400), "pdf"); // รวม 1200 > 1000 → evict B (เก่าสุด)
expect(hasStagedDoc(`staged:${ID_B}.pdf`)).toBe(false);
expect(hasStagedDoc(`staged:${ID_A}.pdf`)).toBe(true);
expect(hasStagedDoc(`staged:${ID_C}.pdf`)).toBe(true);
});
it("ไฟล์เดี่ยวใหญ่กว่า total cap = put ปฏิเสธ (StagedCapError)", () => {
vi.stubEnv("STAGED_MAX_TOTAL_BYTES", "1000");
expect(() => putStagedDoc(ID_A, Buffer.alloc(1001), "pdf")).toThrow(StagedCapError);
expect(hasStagedDoc(`staged:${ID_A}.pdf`)).toBe(false);
});
});