เพิ่มทางเข้าเอกสารสำหรับ 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>
48 KiB
แผน N11 — เอกสาร base64 จาก e-Court ผ่าน RAM staging (ไม่เก็บไฟล์)
สร้าง 2026-11-04 ตามข้อตกลงกับผู้ใช้ · แก้รอบ 2 2026-11-04 ตาม code review (กัน race insert, body แบบ stream + concurrency, ผู้อ่าน
pdf_urlครบชุด, hasStaged ตั้งแต่ bootstrap, deploy single-process) · แก้รอบ 3 2026-11-04 (slot timeout/คืน slot ใน finally, คู่มือ retry 429 ให้ e-Court, test sentinel×file-kind, proxy timeout,hasStagedDocไม่ touch) · แก้รอบ 4 2026-11-04 (test 408 ด้วย fake timers + 413 ไฟล์เดี่ยวเกิน total cap, เลข RAM D7 ตรงกับ D1, เคลียร์ idle+total timer ใน finally) · implement แล้ว 2026-11-04 (ข้อเพิ่มระหว่าง implement: cap ต่อไฟล์อ่านจาก envSTAGED_MAX_FILE_BYTESdefault 100MB — ให้ test ปรับได้โดยไม่ต้องอัด 140MB จริง) · แก้รอบ 5 2026-11-04 หลังทดสอบจริง: regex/^[A-Za-z0-9+/]+={0,2}$/ในdecodeBase64Strictล้น stack บน dev server (RangeError ที่ base64 ~140MB — quantifier ย้อนกลับลึก V8 บางสถานะ fallback interpreter) → เปลี่ยนเป็น negated-class scan/[^A-Za-z0-9+/=]/+ ตรวจ=ท้ายชุดด้วย indexOf — เดินเชิงเส้นไม่มี backtracking semantics เดิมทุกประการ + regression test payload ใหญ่ · แก้รอบ 6 2026-11-04 ตามคำขอผู้ใช้: external_id เก็บ hash ของ fid แทน fid ดิบ —lib/ecourt-fid.ts(HMAC-SHA256 + envFID_HASH_PEPPERfail-closed) เป็นจุดเดียวของการสร้าง/เทียบทุกทาง (stage/N8/session D5/N10) + default title เลิกฝัง fid (ECOURT_DEFAULT_TITLE) + row เก่าแปลงด้วยnpm run migrate:ecourt-fid(หยุดแอปก่อนรัน — ดูแผน has-fid-swift-widget) · contract ฝั่ง e-Court ไม่เปลี่ยน (ยังส่ง fid ดิบมา) · externalFileId ของ N10 กลายเป็นค่า hash one-way · แก้รอบ 7 2026-11-05 ตาม code review (⚠️ 1 + 💡 5): รูป staged jpg/png ทาง history ตอน staging หมดอายุได้ gate message ไทยแล้ว (branch non-TIFF ของ PDFViewer เปลี่ยนเป็น fetch เอง → เช็ค!res.ok→StagedGateErrorเหมือน PDF/TIFF branch + ผลิต object URL — แก้พร้อมข้อ re-fetch ทุก virtualization remount เพราะ blob ใน memory ไม่ยิง route ซ้ำ) · cleanup candidateId ใน 23505 path สม่ำเสมอ (try/finally ครอบ branch — select/put รอบสองโยนก็คืน RAM ผู้แพ้) · หมายเหตุ Swagger: typeId ของ staging รับทาง stage route ทางเดียว (ที่แนบกับ session ลิงก์ fid-only ไม่ถูกใช้) + แก้ค่า default title ใน spec ให้ตรงค่าจริง "เอกสารจาก e-Court" · ลบ dead comment "GET has-check" ของ stageJson · ยอมรับตามแผน: entry กำพร้าจาก DB ล้มหลัง insert 23505 ตอนนี้ถูก finally ครอบแล้ว เหลือเคส route 500 กลางคันที่ TTL กินเอง (ต่ำมาก) — verify รอบ 7 ผ่าน:tscสะอาด + vitest 59/59 + E2E รูป staged (stage PNG → viewer render ผ่าน objectURL ไม่ยิง route ซ้ำ → restart → ทาง history แสดง "เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่" ไม่ใช่ generic error) · verify ข้อ 8–11 ค้างทดสอบด้วยมือ — ดูหัวข้อ Verify · เกี่ยวข้อง: N8 (docs/PLAN-ECOURT.md), N3 (ไฟล์รูปภาพ), PLAN-SSO.md (ลิงก์ e-Court ฉบับ token)
Context
ทางเข้าเอกสารปัจจุบัน (N8) ผูกกับ ?fileUrl= — e-Court ต้องมี URL สาธารณะที่ server เราดึงได้ (allowlist admincourt.go.th) กรณีที่ e-Court มีเอกสารที่ไม่มี URL ให้ดึง (เอกสารภายใน/สร้างสด) ต้องส่งเนื้อไฟล์มาเป็น base64 ผ่าน server-to-server POST แทน — โจทย์กำหนดว่า ห้ามเก็บไฟล์ถาวรทุกรูปแบบ (ไม่เขียน disk/DB/MinIO แม้แต่ไบต์เดียว) แต่ annotation/bookmark เก็บถาวรปกติ ตามระบบเดิม
การตัดสินใจ (ยืนยันกับผู้ใช้ 2026-11-04)
- annotation เก็บถาวร → เอกสาร staging ต้องมี
filesrow จริง (FK ของ annotations/bookmarks) — staging เป็นแค่ "แหล่ง bytes ชั่วคราว" ทดแทนpdf_urlที่ fetch ได้ - base64 มาทาง e-Court เท่านั้น (POST body) — ฝังใน URL ลิงก์ไม่ได้ (PDF 1MB = base64 ~1.4MB โดน header limit ทันที)
- ไม่มี dropzone/ลากไฟล์บนแอป — ผู้พิพากษาเปิดเอกสารผ่าน e-Court ทางเดียว (ตัดทางเลือก
local:<hash>ทั้งหมดที่เคยพิจารณา) - แอปมีหน้าที่แค่ "รองรับได้" — เปิด endpoint + contract ให้ e-Court เรียก ไม่รับผิดชอบ flow ฝั่ง e-Court (เรียกตอนไหน/กี่ครั้ง) · contract ต้องเข้า Swagger UI (
lib/openapi.ts) เหมือนกลุ่มอื่น - เก็บ bytes ใน RAM ของ process เท่านั้น (globalThis แบบ
lib/annotate-events.ts) — sliding TTL + LRU + cap · ห้ามใช้ MinIO/disk แม้แต่แบบมี TTL (ความหมาย "ไม่เก็บไฟล์" เคร่งครัด) · รันได้เฉพาะ single process — ดู D7 deploy - auth ใช้
x-api-keyเทียบ envDOCVER_API_KEYผ่านrequireDocverApiKey(lib/docver-token.ts— timing-safe แล้ว: SHA-256 ทั้งสองฝั่ง +timingSafeEqual, fail-closed + ข้อความ 401 เดียวกันทั้งไม่ใส่/ผิด) — กลุ่มเดียวกับทางเข้า mint ของ e-Court (POST /api/note/auth/token) · (ทางเลือกEXTERNAL_API_KEYของ N10 เป็นฝั่งอ่าน ไม่ใช้กับฝั่งเขียน) - ไม่มี schema migration — ไม่เพิ่ม column: ใช้ sentinel ใน
pdf_urlคือstaged:<uuid>.<ext>(มี extension ติดไปเสมอ) เพื่อให้fileKindFromUrl/imageFormatFromUrl/imageContentTypeFromUrlทำงานทุกจุดโดยไม่แก้ (extension คือ single source of truth ของชนิดไฟล์ตาม N3)
Design
D1 — Endpoint: POST /api/note/files/stage
ผู้เรียก: backend e-Court (server-to-server, ไม่มี CORS, ไม่มี session ของแอป)
- Auth ก่อนแตะอะไรทั้งสิ้น:
requireDocverApiKey(req)— env ไม่ตั้ง/ว่าง = 401 fail closed - Body (JSON, allowlist):
| field | จำเป็น | เกณฑ์ |
|---|---|---|
fid |
✅ | รหัสเอกสาร e-Court — เกณฑ์เดียวกับ parseLoginTokenBody (cap 200, ห้ามอักขระ & = + %) — เป็น dedupe key ecourt:<hash ของ fid> ผ่าน lib/ecourt-fid.ts (2026-11-04 รอบหลัง: external_id เก็บ HMAC-SHA256(pepper, fid) ไม่มี fid ดิบใน DB — env FID_HASH_PEPPER ค่าคงที่ตลอดชีพ) |
data |
✅ | base64 มาตรฐานต่อเนื่อง — ห้ามมี whitespace/line break, ไม่รับ base64url (-/_) → validate ด้วย scan บน string ต้นทาง (regex ครั้งเดียว ไม่สร้างสำเนาตัด whitespace) + len % 4 === 0 + คำนวณความยาวที่ควรได้จากสูตร len/4×3 − padding แล้วเทียบกับ Buffer.length หลัง decode — Node Buffer.from(b64) ยืดหยุ่นเกิน (ได้ garbage เงียบ ๆ) การเทียบความยาวเป็นตัวตัดสิน |
typeId |
— | กลุ่มเอกสาร N9 — normalize ผ่าน normDocumentGroup เหมือน N8 (ส่งมา = refresh, ไม่ส่ง = คงเดิม) |
title |
— | ชื่อแสดง — trim/cap 200 · insert ครั้งแรกไม่ส่ง = ECOURT_DEFAULT_TITLE ("เอกสารจาก e-Court" — ไม่ฝัง fid, 2026-11-04) · re-POST อัปเดต title ได้เฉพาะเมื่อ title ปัจจุบันยังเป็นค่า default (ครั้งแรกที่ส่ง title จริงชนะ แล้วล็อก — กันค้างค่า default ตลอดชีพเมื่อครั้งแรกลืมส่ง) |
- อ่าน body แบบ stream + นับไบต์ (Content-Length เพียงพอไม่ได้ — chunked transfer ไม่มี header): ตรวจ
Content-Lengthเป็น fast-path (เกิน → 413 ทันที) แล้วอ่านreq.bodyด้วยReadableStreamreader สะสมนับไบต์ ตัดที่ base64 payload ≤ ~140MB (100MB เนื้อไฟล์ — ตัวเลขจาก envSTAGED_MAX_FILE_BYTESdefault 100MB, base64 cap =4×ceil(n/3)+8KBenvelope headroom — ปรับ env ได้เพื่อ test/ops) → เกิน = 413 ก่อนแลกทั้งก้อน - จำกัด concurrent request ของ endpoint นี้: counter บน globalThis (ครอบตั้งแต่ก่อนอ่าน body จนจบ decode) — env
STAGED_MAX_CONCURRENTdefault 1 → เกิน = 429Retry-After: 5— กัน peak RAM หลายสำเนาบวมเกิน cap ตอนยิงพร้อมกัน · คืน slot ในfinallyทุกทางออก (สำเร็จ/error/client ตัดการเชื่อมต่อ — ผูกreq.signalabort) — ลืมจุดนี้ = ไฟล์ 100MB บนเน็ตช้ากิน slot นานเป็นนาที จน request ถัดไป 429 ตลอด - Read timeout ของ body (กัน slot ค้างแบบที่สอง): idle 60 วิ (ไม่มี chunk มาเลย — เน็ตช้าแต่ยังไหลไม่ตัด) และ total 10 นาที ต่อการอ่านทั้งก้อน (100MB ใน 10 นาที ≈ 170KB/s — ช้ากว่านี้ตัดให้ retry) → เกินอย่างใด = 408
{"error":"อัปโหลดช้าเกินกำหนดหรือการเชื่อมต่อขาด — ลองส่งใหม่"}· ตอน implement เคลียร์ทั้ง idle และ total timer ในfinallyเสมอ — ลืมแล้ว timer ค้างหลัง request จบ (โดยเฉพาะ Next dev hot-reload ที่สะสม) - Peak RAM ต่อ request (เอกสาร 100MB เต็ม): chunk สะสม ~140MB + text ต่อจาก decode ~140MB + string
dataหลังJSON.parse~134MB +Buffer100MB — ชั่วคราว ≈ 420–520MB (ปลดอ้างอิงทิ้งทีละชั้นหลังใช้เสร็จ = ปลายต่ำ ~420MB, GC ไม่ทันปล่อย = ~520MB; concurrency 1) — container ที่รัน standalone ควรมี memory ≥ 1.5–2GB · วัดจริงแล้ว 2026-11-05 (dev servernext devบน Windows, ยิง POST 100MB เต็ม + วัด WorkingSet/PrivateMemory ของ process ทุก 150ms ระหว่าง request — ไม่ใช่process.memoryUsage()ใน route เพราะไม่อยากแต้โค้ด production): สำเร็จ 200 ใน 4.5 วิ, peak WorkingSet +580MB จาก baseline (826→1406MB; สูงกว่าประมาณการเล็กน้อยเพราะ WS นับ heap เดิมที่ถูก touch ซ้ำด้วย) ขณะที่ PrivateMemory (commit จริง) เพิ่มแค่ +92MB — แปลว่า memory ใหม่ที่ commit จริงต่อ request ≈ staging resident 100MB เท่านั้น ส่วนใหญ่เป็นการ reuse heap ที่ V8 commit ค้างไว้ + GC คืนแบบ lazy (หลังจบ +5s WS ยังค้าง +438MB เหนือ baseline) · สรุปสำหรับ D7: headroom ≥1.5–2GB ยังเพียงพอ (ตัวเลขนี้คือnext devซึ่ง baseline สูง ~826MB อยู่แล้ว — production standalone baseline ต่ำกว่ามาก ให้วัดซ้ำตอน deploy ถ้าต้องการตัวเลข production จริง) - ตรวจ magic bytes กำหนดชนิดเอง ไม่เชื่อ client ประกาศ: PDF (
isPdfBytesของlib/document-versioning.ts) / jpegFF D8 FF/ png89 50 4E 47/ tiffII*·MM\0→ ไม่รู้จัก = 400 "ไฟล์ไม่ใช่ PDF หรือรูปภาพที่รองรับ" → ext ของ sentinel มาจากผลตรวจนี้ (pdf/jpg/png/tif) · ไฟล์เดี่ยวใหญ่กว่าSTAGED_MAX_TOTAL_BYTES= 413 ที่ตัว stage เลย (ไม่ไล่ evict เพื่อนแล้วก็ยังไม่พอ) - ลำดับงาน (กัน race insert — ดู D3): resolve id ก่อน → stage ใต้ id นั้น → เขียน DB → โดน
23505→ re-stage ใต้ id ผู้ชนะ + ลบของผู้แพ้ · ผลบวกของลำดับนี้: การตรวจ cap เกิดก่อนแตะ DB (ไม่สร้าง row ค้างไม่มี bytes) และ DB พังหลัง stage = entry กำพร้าใน RAM ที่ TTL กินเอง ไม่มีอะไรต้องเก็บกวาด (ตรงข้าม N4 ที่ไฟล์เขียนแล้วต้องลบ) - ตอบ:
{fileId}(uuid ของ row) · e-Court เอา fid เดิมไป mint login token ตามเส้นเดิม → ลิงก์เข้าแอปเหมือนวันนี้ - ข้อผิดพลาด: JSON
{"error": <ไทย>}ทั้งหก 401/400/408/413/429/500 (patterndocverError) · ห้าม log bytes/key ดิบ — log ได้เฉพาะ fid + ขนาด - ไม่ publish SSE / ไม่
revalidatePath— รายการไฟล์ไม่ sync ผ่าน SSE (เหมือน N8/N4) + เป็น route handler ไม่ใช่ Server Action (carve-out invariant #4 — ต้องสลักใน CLAUDE.md ตอน implement)
D2 — RAM staging: lib/staged-docs.ts (pure — unit test ได้ไม่ต้อง DB)
MapบนglobalThis(pattern เดียวกับannotate-events.ts/db/index.ts— dev hot-reload จะไม่ duplicate) key = fileId (uuid) ค่า ={ bytes: Buffer, ext, createdAt, lastAccessAt }- Sentinel URL:
stagedSourceUrl(fileId, ext)→staged:<uuid>.<ext>·parseStagedSource(url)→{ fileId, ext } | null(validate uuid + ext ∈ whitelist — ทั้งคู่ server generate เอง การ validate ซ้ำเป็น defense) ·isStagedSource(url) - TTL แบบ sliding จาก last access: expire เมื่อ
now − lastAccessAt > TTL— ทุกgetที่สำเร็จ = touch (ระหว่าง annotate ไม่มีการอ่าน bytes — ตั้ง defaultSTAGED_DOC_TTL_SECONDS = 28800(8 ชม. เท่าNOTE_SESSION_TTL) ให้ครอบ session ทั้งก้อน, env ปรับได้) - Cap + LRU: ต่อไฟล์ 100MB (ที่ D1 ตัดไว้แล้ว) · รวมทุกไฟล์ default 256MB (
STAGED_MAX_TOTAL_BYTES) · แทรกแล้วเกิน cap = ไล่ evict รายการที่lastAccessAtเก่าสุด (LRU) จนคืนใต้ cap — re-POST fid เดิม = replace ช่องเดิม (ไม่งอกใหม่ต่อครั้ง) · config guard: module init ถ้า total cap < 100MB =console.warnหนึ่งครั้ง (ไฟล์เดี่ยวใหญ่สุดจะโดน 413 ตลอด — ปรับ env หรือตั้งใจใช้กับไฟล์เล็กก็ได้) getStagedDoc(url)เทียบ ext ของ sentinel กับ ext ของ entry ด้วย — ไม่ตรง (row เขียนค้างระหว่างอัปเดตไม่สำเร็จ) = miss เหมือนหมดอายุ → ทางอ่านได้ 410 gate เสมอ ไม่มี sentinel โกหกhasStagedDoc(url)แยกจากgetStagedDoc— เช็ค "มีอยู่ไหม" (expire + ext เทียบชุดเดียวกัน) โดย ไม่ copy ไม่ touch TTL — สำหรับผู้ที่แค่ตัดสินใจไม่ได้อ่าน bytes จริง อย่าง D5 ตอนแลก token (ไม่ยืดอายุ/ถือ Buffer เปล่า) ·getStagedDoc= ผู้อ่านจริง (touch + คืน Buffer) — semantics สองตัวแยกกันชัดเจน- Lazy sweep ไม่มี timer: ตรวจ expire ตอน get + เก็บกวาด entry หมดอายุตอน put (กัน
setIntervalค้างใน dev — บริบทเดียวกับ rule ของannotate-events) - single-process — ข้อจำกัดเดียวกับ SSE pub/sub (invariant #11) — ขยายที่ D7
D3 — ลงทะเบียน row: registerStagedFile ใน lib/external-import.ts (กัน race 23505)
สัญญา registerStagedFile(rawFid, bytes, ext, title?, typeId?) — trusted โดย construction (ผู้เรียกผ่าน requireDocverApiKey ที่ route แล้ว) — bytes/ext คือของที่ผ่าน magic-byte check แล้ว:
externalId = ecourtExternalId(fid)(="ecourt:" + HMAC-SHA256(pepper, fid)— 2026-11-04 รอบหลัง: ไม่มี fid ดิบใน DB) → select ด้วยexternal_id- เจอ →
winnerId = row.id→putStagedDoc(winnerId, bytes, ext)(replace — last-write-wins ตาม semantics "re-POST = เนื้อล่าสุด") - ไม่เจอ →
candidateId = randomUUID()→putStagedDoc(candidateId, …)→ insert row ด้วยcandidateId- insert โดน
23505(คู่ขนาน fid เดียวกัน) → select ใหม่ได้winnerIdของฝ่ายชนะ →putStagedDoc(winnerId, bytes, ext)ทับ (bytes ของเราแทนของฝ่ายชนะ — last-write-wins เหมือนข้อ 2) →deleteStagedDoc(candidateId)คืน RAM →winnerIdใช้ต่อ - insert ผ่าน →
winnerId = candidateId
- insert โดน
- update row เมื่อจำเป็น (รวมกันเป็น patch เดียว):
pdfUrlsentinel เปลี่ยน (ext ต่าง) /typeIdเมื่อส่งมา /titleเมื่อส่งมาและค่าปัจจุบันยังเป็นค่า defaultECOURT_DEFAULT_TITLE - คืน
{ fileId: winnerId }— ไม่มีทางเกิด "row ชี้ sentinel ที่ไม่มี bytes": ทุก path จบด้วย staging ใต้ id เดียวกับ row สุดท้ายที่คืน
- ผสาน flow กับ N8 แบบเดิม: fid เดิมเปลี่ยนวิธีส่ง (URL ↔ base64) =
pdf_urlเปลี่ยนตามรอบล่าสุด สองทางอยู่ร่วมกันได้ · หลัก "id เปลี่ยน = ไฟล์ใหม่" ใช้เหมือนเดิมทุกประการ
D4 — ทางอ่าน bytes: inventory ผู้อ่าน pdf_url ทั้ง repo (grep 2026-11-04) + แก้ 4 จุด
ผู้อ่านทั้งหมดใน repo:
| จุด | ประเภท | อะไรทำ |
|---|---|---|
app/api/note/pdf/route.ts:31 |
อ่าน bytes | แก้ — เติม branch staging ก่อน isRemoteSource |
app/api/note/annotations/export/route.ts:227 |
อ่าน bytes | แก้ — จุดเดียวกัน |
components/pdf/PDFViewer.tsx:1744,1795 |
อ่าน bytes (ฝั่ง client) | แก้ — branch รูป + ตัด fallback |
lib/server-pdf.ts (readPublicPdf) |
อ่าน path | แก้ — guard ปฏิเสธ staged: ชัด ๆ |
app/api/note/files/[id]/bootstrap/route.ts:73 |
ส่งต่อให้ client | ไม่แตะ — viewer ใช้แค่ derive ชนิด/เลือกทางโหลด (แก้แล้วใน PDFViewer) |
components/pdf/PDFViewer.tsx:3220, app/note/groups/[typeId]/page.tsx:98, utils/file-kind.ts |
derive-only | ไม่แตะ — ext ใน sentinel ทำให้ทำงานเอง (ตรวจ extOf() แล้ว: ตัด ?# → segment ท้าย → lastIndexOf(".") — sentinel เข้ากันดี แต่ล็อกด้วย test ตาม Verify ข้อ 2 กัน implementation เปลี่ยนวิธี parse ภายหลัง) |
scripts/seed-samples.mjs |
query | ไม่แตะ — จับเฉพาะ pdf_url like '/samples/%' |
app/api/note/files/open/route.ts |
ทางเข้า | ไม่แตะ — รับ path ในเครื่อง /… อย่างเดียว sentinel เข้าไม่ได้ |
N10 lib/external-api.ts |
query | ไม่แตะ — ไม่ select ฟิลด์ pdf_url |
app/note/uploads/[name]/route.ts |
เสิร์ฟไฟล์อัปโหลดเดิม | ไม่แตะ — อ่านชื่อจาก path param ไม่ใช่ pdf_url |
การแก้ 4 จุด:
app/api/note/pdf/route.ts— ก่อนisRemoteSource:isStagedSource(file.pdfUrl)→getStagedDoc(file.pdfUrl)→ miss/หมดอายุ/ext ไม่ตรง = 410{"error":"เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่"}· ได้ bytes แล้วไหลเข้า branch เดิมทั้งหมด (ext ใน sentinel ทำให้fileKindFromUrlแยกรูป/PDF +imageContentTypeFromUrlถูก) → การซ่อม ToUnicode ไทยทำงานกับ staging อัตโนมัติ ·fixPdfActualTextไม่ mutate input (pdf-libload→saveสร้างชุด bytes ใหม่ — ตรวจแล้วlib/pdf-actual-text.ts:136-198) ดังนั้น Buffer ใน Map ปลอดภัยแม้ถูกอ่านพร้อมกันหลาย requestapp/api/note/annotations/export/route.ts(F8) — จุดเดียวกัน (isRemoteSource ? … : readPublicPdf→ เติม staged ก่อนหน้า) → export ฝัง annotation ใช้ได้เลย · TIFF/EXIF ผ่านimageDocFromBytesตาม N3PDFViewer.tsx— (ก) branch รูปภาพ:1744:isHttpUrl(pdfUrl)→ เพิ่ม|| pdfUrl.startsWith("staged:")ให้โหลดผ่าน/api/note/pdf?fileId=(PDF branch ไม่ต้องแตะ — fetch ผ่าน route นี้อยู่แล้ว) · (ข) ตัด fallbackfetch(withBasePath(file.pdfUrl))เมื่อstaged:(โหลดจาก sentinel ตรง ๆ ไม่มีความหมาย) — ให้ error จาก route (410) ลอยขึ้นเป็นข้อความ gate ไทยแทน garbage fetchlib/server-pdf.ts—resolvePublicPdfPathเพิ่ม guard ต้นฟังก์ชัน:pdfUrl.startsWith("staged:")→throw new PdfPathError("เอกสารนี้ไม่มีไฟล์บนดิสก์ (staging พ้นช่วงจัดเตรียม)")— ผลตรวจโค้ดจริง: วันนี้"staged:x.pdf"ผ่าน guard (path.resolve(publicDir, …)ตกใน publicDir) แล้วตายด้วย ENOENT เป็น 500 ดิบ (Windows อาจ EINVAL เพราะ colon ในชื่อ) — เป็น defense in depth ให้ทุก caller ในอนาคตที่ลืมแยกทาง
D5 — Session route รับลิงก์ fid-only + เช็ค staging ตั้งแต่ต้น (app/api/note/auth/session/route.ts)
ตอนนี้ parsed.fileUrl !== undefined เท่านั้นที่ลงทะเบียน — เอกสาร staging ลิงก์มีแค่ t= (fid อยู่ใน credential): เติม branch fileUrl ไม่มี + มี fid → select row ecourt:<hash ของ fid> (lib/ecourt-fid):
- เจอ +
pdfUrlเป็น staged +hasStagedDoc= true → คืนfileId(ไม่ refresh อะไร — ใช้hasStagedDocไม่ใช่getStagedDocเพราะตอนแลก token แค่ตัดสินใจ ไม่ต้องถือ Buffer/ยืด TTL — การอ่านจริงเกิดที่ viewer อีกที) - เจอ + เป็น staged +
hasStagedDoc= false (restart/TTL หมด/โดน evict แล้ว) →fileError: "เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่"ตั้งแต่ตอนแลก token (ไม่ปล่อยเข้า viewer ไปเจอ 410 เอง) - เจอ +
pdfUrlเป็น URL/fileUrl ปกติ → คืนfileIdตามเดิม (เอกสาร N8 แบบเดิม) - ไม่เจอ →
fileError: "เอกสารยังไม่ถูกส่งเข้าระบบ — กลับ e-Court กดลิงก์ใหม่"(คู่ขนานข้อความ N8) - ผู้ใช้ได้ token ตามปกติทุกกรณี · ทางเข้าจาก history/bookmark (ไม่ผ่าน session exchange) ยังเจอ 410 gate ที่ viewer — ยอมรับ
D6 — เอกสาร contract
lib/openapi.ts เพิ่ม POST /api/note/files/stage (path เต็ม /api/…, servers เติมแค่ basePath ตาม invariant #12) — ระบุใน description: auth x-api-key, base64 มาตรฐานต่อเนื่อง, cap 100MB เนื้อไฟล์ (base64 ~134MB), error 401/400/408/413/429 + คู่มือ retry ให้ e-Court ชัด ๆ: 408/429/5xx = ยิงซ้ำ (429 ตาม Retry-After, ที่เหลือ backoff) และ stage ยิงซ้ำได้ปลอดภัย (fid เดิม = replace ไม่เกิดซ้ำซ้อน ไม่เสียอะไร) — ไม่เขียนไว้ = e-Court เจอ 429 แล้วเลิกเงียบ ๆ = เอกสารตกหล่น → ปรากฏใน Swagger UI /api/swagger พร้อมกลุ่ม template/N10/docver
D8 — เครื่องมือทดสอบบน /note/dev (เพิ่มหลัง implement ตามคำขอผู้ใช้ 2026-11-04)
การ์ด "จำลอง e-Court — ส่งเนื้อไฟล์เข้า staging (base64)" (components/MockStageForm.tsx) ให้ dev ของ e-Court กดทดสอบ contract จริงทั้ง 3 ขั้นในหน้าเดียว: เลือกไฟล์ (FileReader→base64 ต่อเนื่อง, ตัด >100MB ก่อนยิง) → POST /api/note/files/stage → POST /api/note/auth/token ด้วย fid เดิม → ได้ลิงก์ fid-only + ปุ่มเปิด · ไม่ใช่ dropzone ของแอป (การตัดสินใจข้อ 3 ยังครบ): อยู่ใต้หน้า /note/dev ที่เปิดด้วย NOTE_SSO_DEV_MINT=1 อย่างเดียว + ต้องใส่ DOCVER_API_KEY จริงทุกครั้งจึงไม่เป็นประตูหลัง (เหมือน MockEcourtForm)
D7 — Deploy (ข้อจำกัดที่ต้องรู้ก่อนขึ้น production)
- Single process เท่านั้น: staging กับ SSE pub/sub อยู่บน RAM ของ process เดียว —
next start/ standalonenode server.jsหนึ่ง process · ห้าม PM2 cluster mode / หลาย replica / serverless (Vercel) — พังเงียบ: request ตก instance คนละตัวได้ 410 สุ่ม ๆ / SSE event หายบางตัว ไม่มี error ชัดเจนให้เห็น - IIS เป็น proxy หน้าแอป (deploy ปัจจุบัน): default
maxAllowedContentLength= 30MB — ตั้งเป็น ≥ 140MB ใน web.config ของ IIS ไม่งั้น 413 มาจาก IIS เป็นอังกฤษ ก่อน request ถึงแอป (เกิดเฉพาะเอกสารใหญ่ — ทดสอบตอน deploy ด้วยไฟล์จริง) · ข้อสองของขั้นตอน deploy: ถ้ามี ARR/reverse proxy ซ้อนหน้า IIS อีกชั้น ต้องขยับ request timeout ของ proxy ด้วย — upload 100MB บนเน็ตช้ากินเวลาเป็นนาที (ARR default 120 วิ ตัดกลางทางได้) ตั้งให้ ≥ total read timeout ของแอป (D1 = 10 นาที) · Next.js route handler เองไม่มี body limit (เฉพาะ Server Actions ที่มี default 1MB — endpoint นี้เป็น route handler ไม่เกี่ยว) - Memory: กราฟ peak ที่ D1 (≈ 420–520MB ชั่วคราว + staging resident ≤256MB) — เครื่อง/คอนเทนเนอร์ควรมี headroom ≥ 1.5–2GB
Edge cases
- เปิดลิงก์ก่อน e-Court POST ไฟล์ (และ row ไม่เคยมี) → D5 ให้ fileError · token ออกตามปกติ
- TTL หมด/evict โดน LRU/process restart กลางวัน → ทางเข้าลิงก์: D5 ตีตั้งแต่แลก token · ทางเข้า history: 410 gate ไทยที่ viewer/export · annotation/bookmark/หน้าสรุปอยู่ครบ (ผูก fileId ใน DB ไม่เกี่ยวกับ staging) · แก้ด้วยการเข้าใหม่จาก e-Court (= POST สดทุกครั้งตาม flow ปกติ)
- POST fid เดียวกันสองครั้งพร้อมกันครั้งแรก — D3 ลำดับ stage→insert→(23505)→re-stage ใต้ id ผู้ชนะ+ลบของผู้แพ้ → ไม่มี row ชี้ sentinel โดนใส่ · bytes สุดท้าย = ของ request ที่เสร็จทีหลัง (last-write-wins ตาม semantics "re-POST = เนื้อล่าสุด")
- e-Court re-POST fid เดิมด้วยเนื้อใหม่ → staging replace + row เดิม → annotation เดิม (อาจคลาดตำแหน่งถ้าเลย์เอาต์ขยับ — known risk เดียวกับ N8 ข้อ "e-Court แก้เนื้อไฟล์")
- fid สลับ base64 → URL (ลิงก์ N8 รอบหลัง) →
pdf_urlเปลี่ยนเป็น URL แต่ entry เก่ายังค้างใน RAM จน TTL กินเอง — ไม่มีใครอ่าน (row ไม่ชี้แล้ว) ไม่เสียหาย แค่กิน quota ชั่วคราว - base64 เสีย (มี whitespace/base64url)/ไม่ใช่ PDF·รูป → 400 ก่อนแตะ staging · เกิน 100MB เนื้อ/140MB base64 → 413 (Content-Length ถ้ามี, นับ stream ถ้า chunked) · ยิงพร้อมกันเกิน
STAGED_MAX_CONCURRENT→ 429 +Retry-After· client ตัดการเชื่อมต่อ/อ่าน body ช้าเกิน timeout → abort + คืน slot ทันทีในfinally→ 408 — staging ยังไม่ถูกสร้างเพราะยังไม่ถึงขั้น stage - หลายเอกสารจนเกิน total cap → LRU evict รายการเก่าสุดเงียบ ๆ (ผู้ใช้เจอ gate เมื่อกลับไปเปิด — ทางกลับคือเปิดจาก e-Court ใหม่) · ไฟล์เดี่ยว > total cap → 413 ที่ stage
- ผู้ถือ
DOCVER_API_KEYยิงอัด RAM ไล่ evict เอกสารคนอื่น (LRU) เป็น DoS — ความเสี่ยงที่รับไว้: ผู้ถือ key = backend e-Court ซึ่งเชื่อถือได้ระดับเดียวกับผู้ถืออำนาจ mint login token อยู่แล้ว (ทำอันตรายได้มากกว่านี้โดยไม่ต้องพึ่ง staging) + concurrency 1 ถ่วงอัตรายิง · auth เอง timing-safe แล้ว (lib/docver-token.ts— SHA-256 +timingSafeEqual) /api/note/pdfไม่มี auth (status quo) → staged bytes อ่านได้ด้วย fileId (uuid ที่ยากจะเดา) — ความเสี่ยงที่รับไว้เหมือน known risk ข้อ 3 ของ N8 ไม่เพิ่มหรือลด- รูป TIFF หลายเฟรม → ผ่าน pdf route →
utif2decode ฝั่ง client ตาม N3 เดิม
Verify (เกณฑ์ตอน implement)
สถานะหลัง implement 2026-11-04: ข้อ 1–7 ผ่าน (
npx tsc --noEmitสะอาด + vitest 102 เคสของชุด N11/SSO/token ผ่านครบ,npm testทั้ง repo 349/350 — ตัวที่ไม่ผ่านเป็น worker-startup timeout ของtests/wordaddin/metadataService.test.tsซึ่งผ่านตอนรันเดี่ยว = flake ของ environment ไม่เกี่ยวกับงานนี้) · E2E บน dev server จริง (แยก port 3001) ผ่านหลัก ๆ ของข้อ 8: stage ไฟล์ 100MB (base64 139.8M ตัวอักษร) → 200 ใน ~3.4 วินาที + audit log ครบ · ไฟล์เกิน cap 58 ไบต์ → 413 ตาม spec · mint + session fid-only (D5) → fileId ไม่มี fileError ·/api/note/pdf?fileId=คืน bytes ครบ 100MB (หัว%PDF-1.7, ความยาวตรงต้นฉบับ — server ตอบใน ~4 วินาที) · restart server → session ได้fileError"พ้นช่วงจัดเตรียม" + token ยังออก + ทาง history ได้ 410 JSON ไทยสถานะวันที่ 2026-11-05: ครบทุกข้อ (1–11) ผ่าน — ส่วนที่ค้างเมื่อวานทดสอบด้วย Playwright (browser จริง) บน dev server port 3000:
- ข้อ 8 เต็ม: stage
thai_letterspacing.pdf(fide2e-staged-thai-01) → mint → เปิดลิงก์ fid-only/note?t=…ผ่าน Playwright → bootstrap แลก token + redirect เข้า viewer · textLayer ไทย 73 spans เรียงถูกทั้งหมด — "ฉะนั้น" ติดคำเดียว ไม่มี fake space กลางคำ (ตัวอย่างบั๊กของ patch-pdf-worker) = ToUnicode fix + patch fake-space ทำงานกับ staged bytes ผ่าน/api/note/pdf· มาร์ก highlight คลุมข้อความจริง (keyboard H + drag บน textLayer) + วาดเส้น (keyboard D) + bookmark (keyboard B) — N10 ยืนยันเก็บถาวรครบ (annotations=2, bookmarks=1) · export PDF มี annotation ฝังจริง (pdf-lib อ่านกลับ: rect มาร์ก 200×20pt + เส้น stroke แดง 2.38w ตำแหน่งตรงที่ลาก + outline "คั่นหน้าทดสอบ staging" อ่านไทยครบ) · restart dev server → ลิงก์ใหม่ (mint ใหม่) ได้แบนเนอร์ "เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่" ตั้งแต่ bootstrap ไม่เข้า viewer + token ยังออก (note-auth มี) · ทาง history/api/note/pdf= 410 JSON ไทย · หน้าสรุปยังครบ Annotation 2 + Bookmark 1 (ชื่อ default ของ highlight = ข้อความที่คลุม — ข้อความไทยจาก staged bytes ถูกต้องอีกชั้น) · re-POST fid เดิมหลัง restart ได้ fileId เดิม (4c618a71-…) = replace ช่องเดียวตาม D3- ข้อ 9:
node scripts/make-login-link.mjs --fid e2e-staged-thai-01(ไม่มี fileUrl, หลัง re-stage) → เปิดลิงก์ → เข้า viewer ได้ + session เก็บใน localStorage + textLayer 73 spans ครบ- ข้อ 10:
GET /api/swaggerมี/api/note/files/stageใน paths พร้อมคู่มือ retry ("คู่มือ retry"/Retry-Afterปรากฏในหน้า)- ข้อ 11: วัดแบบภายนอก (WorkingSet64/PrivateMemorySize64 ของ process ทุก 150ms ระหว่าง POST — ไม่แต้โค้ด production): ไฟล์ 100MB เต็ม → 200 ใน 4.5 วิ, peak WS +580MB / Private commit +92MB — บันทึกวิเคราะห์แล้วที่ D1 (headroom ≥1.5–2GB ยังเพียงพอ)
หมายเหตุ environment ระหว่างทดสอบ: dev server สองตัวรันคู่กันใน repo เดียว (ตัวค้างจากก่อนหน้า + ตัวใหม่) ทำให้
.nextถูกเขียนทับกันเองจน route manifest เพี้ยน (POST บาง route ตอบ 404 ชั่วขณะแล้วหายเอง) — แก้ด้วยการฆ่าทั้งคู่ + ลบ.next+ รันตัวเดียว แล้วอาการหาย ไม่ใช่ bug ของโค้ด
tests/lib/staged-docs.test.ts(pure): put/get กลับตัว · TTL sliding (touch แล้วไม่หมด/ปล่อยแล้วหมด) ·hasStagedDocไม่ touch (lastAccessAtไม่ขยับ) ไม่ copy · LRU evict ตาม total cap · replace ช่องเดียวกันต่อ fileId · get miss เมื่อ ext ไม่ตรง · ไฟล์เดี่ยว > total cap → put ปฏิเสธ ·parseStagedSourceปฏิเสธ uuid/ext เพี้ยน · magic bytes แยก pdf/jpg/png/tif ถูกtests/utils/file-kind.test.ts— sentinel ครบ 4 ext เข้า helper ทั้ง 3 ตัว:staged:<uuid>.{pdf,jpg,png,tif}→fileKindFromUrl= "pdf"/"image",imageFormatFromUrl= null/jpeg/png/tiff,imageContentTypeFromUrl= image/jpeg, image/png, image/tiff (แก้ระหว่าง implement: sentinel pdf ไม่ผ่านimageContentTypeFromUrlในทางปกติ — route ตอบapplication/pdfเองตอนเป็น PDF, helper คืนapplication/octet-streamสำหรับ non-image ตาม status quo ซึ่งไม่แตะตาม D4) — ล็อกสมมติฐานของ D4 กัน helper เปลี่ยนวิธี parse ภายหลังtests/lib/server-pdf.test.ts—resolvePublicPdfPath("staged:<uuid>.pdf")โยนPdfPathError(ไม่ใช่ ENOENT ทะลุเป็น 500)tests/lib/external-import-staged.test.ts—vi.mock("@/db"): เจอ row → replace staging + patch ตามเงื่อนไข (ext ต่าง/typeId ส่งมา/title ยังเป็น default) · ไม่เจอ → insert ด้วย candidateId · insert โดน 23505 → re-stage ใต้ winnerId + ลบ candidateId + คืน winnerId (assert ลำดับการเรียก)- Route tests (
POSThandler เรียกตรง + mock db/staged): 401 ไม่มี key/env ว่าง · 400 base64 มี whitespace/เป็น base64url/ความยาวไม่ลงตัว/magic ไม่รู้จัก/fid มี&· 413 body เกิน cap (ทั้งมีและไม่มี Content-Length) · 413 ไฟล์เดี่ยวใหญ่กว่าSTAGED_MAX_TOTAL_BYTES(mock store ที่ตั้ง cap ต่ำกว่าขนาดไฟล์) · 429 concurrent · 408 ด้วย fake timers: stream หยุดนิ่งครบ idle 60 วิ และ total ครบ 10 นาที → 408 + คืน slot · mock stream โยน/req.signalabort กลางทาง → ได้ error และ counter คืนในfinally(request ถัดไปไม่โดน 429) · happy path ได้{fileId}+ staging มี bytes ใต้ id นั้น - D5 tests ครบ 4 branch (session route + mock db/staged): เจอ +
hasStagedDoctrue → ได้fileId· เจอ + false →fileError"พ้นช่วงจัดเตรียม" · เจอ +pdfUrlเป็น URL ปกติ (เอกสาร N8) → ได้fileId· ไม่เจอ →fileError"ยังไม่ถูกส่งเข้าระบบ" tscผ่าน + vitest ชุดเดิมไม่แตก- curl/PowerShell: POST stage ด้วย sample ไทย (
thai_letterspacing.pdffixture) → เปิด viewer → textLayer ไทยเรียงถูก (พิสูจน์ว่า ToUnicode fix ทำงานกับ staged bytes) → มาร์ก/วาด + bookmark → export ได้ไฟล์มี annotation → restart dev server → เปิดใหม่: ทางลิงก์ได้ fileError ตั้งแต่ bootstrap (D5) · annotation ยังอยู่ครบบนหน้าสรุป - ลิงก์จริงเชื่อมกัน:
make-login-link.mjs --fid <เดิม>(ไม่มี fileUrl) →/notebootstrap → เข้า viewer ได้ (D5) - Swagger UI
/api/swaggerเห็น endpoint ใหม่ + try it out ได้ (รวมคำอธิบาย retry) - วัด peak RAM จริง (
process.memoryUsage()) กับไฟล์ใกล้ 100MB แล้วบันทึกตัวเลขกลับ D1
Sync เอกสาร (ตอน implement)
- CLAUDE.md: invariant #4 carve-out เพิ่ม
POST /api/note/files/stage(route handler เขียนfilesผ่านregisterStagedFile— ไม่ publish SSE/revalidatePathเหตุผลเดียวกับ N8) · โครงสร้างไฟล์เพิ่มlib/staged-docs.ts+ route + อ้างอิงแผนนี้ · ทางเข้าเอกสารใน SSO section กล่าวถึง base64 channel · (พบระหว่างตรวจ: CLAUDE.md บอกrequireDocverApiKeyอยู่lib/addin-ticket.tsแต่จริงอยู่lib/docver-token.ts:172— แก้ drift นี้ด้วย) - PLAN-FEAT.md §7: เพิ่มบรรทัด N11
- PLAN-SSO.md: flow ลิงก์ fid-only + fileError ของ staging (D5)
Out of scope
- dropzone/ลากไฟล์บนแอป + key
local:<hash>(ตัดตามการตัดสินใจข้อ 3) - cache bytes ฝั่ง client (IndexedDB) และการ re-stage อัตโนมัติ — ทางกลับคือเปิดใหม่จาก e-Court
- staging ข้าม instance (Redis/LISTEN-NOTIFY) · รับ .docx หรือชนิดอื่นนอกเหนือ PDF/รูปของ N3 · auth ของ
/api/note/pdf(known risk ตาม status quo) · หน้าจัดการ/ลบรายการ staging · ปรับmaxAllowedContentLengthของ IIS ให้เอง (บันทึกไว้ในขั้นตอน deploy ของผู้ดูแลเครื่อง)