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
|
|
@ -13,7 +13,7 @@
|
|||
## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-22 · ปรับรอบสอง 2026-09-24 · รอบสาม 2026-09-25 · รอบสี่ 2026-09-25 · รอบห้า 2026-09-25 · รอบหก 2026-09-25 · รอบเจ็ด 2026-09-25 · รอบแปด 2026-09-25 · รอบเก้า 2026-09-25)
|
||||
|
||||
1. **API Key เดียวใน env** `EXTERNAL_API_KEY` — ผู้เรียกคือ backend e-Court ตัวเดียว เปลี่ยน key = แก้ env แล้ว redeploy (ไม่ทำตาราง key ใน DB / UI จัดการ key)
|
||||
2. **ระบุเอกสารด้วย fileId ของ e-Court** (ค้น `external_id = "ecourt:<fileId>"`) — ผู้เรียกรู้จัก id ของตัวเอง ไม่ต้องรู้ UUID ของเรา
|
||||
2. **ระบุเอกสารด้วย fileId ของ e-Court** (ค้น `external_id = "ecourt:" + hash ของ fileId` — lib/ecourt-fid 2026-11-04: เก็บ HMAC-SHA256 แทน fid ดิบ) — ผู้เรียกรู้จัก id ของตัวเอง ไม่ต้องรู้ UUID ของเรา และยังส่ง fid ดิบมาเหมือนเดิม (เรา hash ตอน query)
|
||||
3. **POST เส้นเดียว + JSON body** `POST /api/external/documents` — รอบแรกเป็น 2 endpoints แบบ GET query-param (ตาม pattern ของบ้าน), **รอบสอง 2026-09-24 ตามคำขอผู้ใช้รวมเป็นเส้นเดียวถามด้วย body** `{fileId | typeId, filter}` — GET ที่แนบ body ไม่ standard จึงใช้ POST (ยัง read-only เหมือนเดิม) · response รูปเดียวเสมอ (`{documents: [...]}`) ผู้เรียกจัดการ contract เดียว · **รอบสาม 2026-09-25 ตามคำขอผู้ใช้: ส่ง `fileId`+`typeId` พร้อมกันได้ = รวมผล OR** (เดิม 400 กำกวม) และ**ยุบชั้น `document` ใน response** ให้ field เอกสารอยู่ระดับเดียวกับ `annotations`/`bookmarks` (ดูง่ายขึ้น) · **รอบสี่ 2026-09-25 ตามคำขอผู้ใช้: เปลี่ยนชื่อ endpoint เป็น `documents`** (เดิม `query` — ตั้งชื่อตาม resource ที่คืน ตรงกับ response `{documents}`) · **รอบห้า 2026-09-25 ตามคำขอผู้ใช้: ส่ง `fileId`+`typeId` พร้อมกัน = AND** เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (แทน OR รวมผลของรอบสาม — ทดสอบจริงแล้ว OR คืนทั้งกลุ่ม ไม่ใช่สิ่งที่ผู้เรียกต้องการ) · ยังไม่มีผู้เรียกจริงจึงแทนที่ขาด (ถอด GET เดิมทั้งสองออก)
|
||||
4. **`filter.type` เลือกส่วนที่เอา**: `"bookmarks"` | `"annotations"` — ไม่ส่ง `filter` หรือ `type` เป็นค่าว่าง = ทั้งสอง ("เอาทั้งหมด" — กฎเดียวกับ fileId ว่าง = ไม่ได้ส่ง, คำขอผู้ใช้ 2026-09-24) — ส่วนที่ไม่เลือกคืนเป็น array ว่าง (contract คงที่ ง่ายต่อผู้บริโภค) · **รอบหก 2026-09-25 เพิ่มมิติตัวกรอง**: `filter.userId` (กรองตามเจ้าของรายการ) + `filter.annotationTypes` (ชนิดของ annotation — เดิมชื่อ `types` เปลี่ยนรอบแปด) — ดู D2
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ Response 200 เป็นรูปเดียวเสมอ (bundle ยุบ
|
|||
"documents": [ // ถามด้วย fileId = ยาว 1 ชิ้น (ไม่เคยเปิดในระบบ = 404) · ถามด้วย typeId = ทุกไฟล์ในกลุ่มเรียง files.createdAt (กลุ่มว่าง = []) · ส่งทั้งคู่ = เฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (ยาว 1 ชิ้น เช่นเดียวกับถาม fileId)
|
||||
{
|
||||
"id": "<uuid ของเรา>",
|
||||
"externalFileId": "<fileId ของ e-Court>", // null ได้ — เอกสารในกลุ่มที่ไม่มี fileId ของ e-Court (url: fallback ของ N8 / อัปโหลด N4 พร้อมช่องกลุ่ม — mirror กฎ typeId: null)
|
||||
"externalFileId": "<hash ของ fileId (HMAC ทางเดียว 2026-11-04 — คงเดิมทุกครั้งที่ถามด้วย fid เดียวกัน ใช้เทียบความเหมือนได้อย่างเดียว)>", // null ได้ — เอกสารในกลุ่มที่ไม่มี fileId ของ e-Court (url: fallback ของ N8 / อัปโหลด N4 พร้อมช่องกลุ่ม — mirror กฎ typeId: null)
|
||||
"title": "…",
|
||||
"typeId": "…", // null ได้ (ไม่มีกลุ่ม)
|
||||
"createdAt": "<ISO string>",
|
||||
|
|
@ -75,7 +75,7 @@ Response 200 เป็นรูปเดียวเสมอ (bundle ยุบ
|
|||
|
||||
- read-only ทั้งหมด: **ไม่เขียน DB ไม่ publish SSE ไม่ `revalidatePath`** — snapshot ณ ตอนเรียก (คนละ context กับ Server Action ใน invariant #4)
|
||||
- ไม่ใส่ CORS headers (server-to-server — browser ไม่ควรเรียก; ไม่เปิด `Access-Control-Allow-Origin`)
|
||||
- ค้น `fileId`: `where eq(files.externalId, "ecourt:" + fileId)` — ไฟล์ที่ลงทะเบียนแบบ fallback `"url:<href>"` (ลิงก์ไม่มี fileId) ค้นด้วยวิธีนี้ไม่ได้ = 404 (ยอมรับ — สัญญาลิงก์ N8 แนะนำให้ส่ง fileId เสมอ)
|
||||
- ค้น `fileId`: `where eq(files.externalId, ecourtExternalId(fileId))` (hash — lib/ecourt-fid 2026-11-04) — ไฟล์ที่ลงทะเบียนแบบ fallback `"url:<href>"` (ลิงก์ไม่มี fileId) ค้นด้วยวิธีนี้ไม่ได้ = 404 (ยอมรับ — สัญญาลิงก์ N8 แนะนำให้ส่ง fileId เสมอ)
|
||||
- **REDACTION รวมใน `annotations` เมื่อเลือก include** — ผู้ถือ key เห็นตำแหน่งกล่องปิดพราง (DB เก็บแค่พิกัด ไม่มีเนื้อหาใต้กล่อง) — ระบุไว้ในสเปกว่า key นี้เชื่อถือได้ระดับ backend e-Court เท่านั้น
|
||||
- annotation ทุกสถานะรวมหมด (resolved ดูจาก field `resolved` ได้) ทุกผู้ใช้ (userId/userName อยู่ใน DTO)
|
||||
- route เรียก `requireExternalApiKey` เป็นอย่างแรกก่อนแตะ DB
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue