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

@ -687,6 +687,146 @@ export const openApiSpec = {
},
},
},
"/api/note/files/stage": {
post: {
tags: ["Judge's Note"],
summary: "ส่งเนื้อไฟล์เอกสาร (PDF/รูป) เข้า RAM staging ด้วย base64 (N11)",
description:
"ทางเข้าเอกสารที่ e-Court **ไม่มี URL ให้ดึง** (เอกสารภายใน/สร้างสด — ต่างจาก " +
"N8 ที่ส่ง `fileUrl`) — ส่งเนื้อไฟล์มาเป็น base64 ระบบเก็บใน **RAM ชั่วคราว** " +
"(TTL 8 ชม. นับจากการเข้าใช้ล่าสุด — ห้ามเก็บไฟล์ถาวรทุกรูปแบบตามโจทย์) " +
"annotation/bookmark เก็บถาวรใน DB ตามระบบเดิม\n\n" +
"ได้ `{fileId}` แล้วเอา `fid` **เดิม** ไป mint login token ที่ " +
"`POST /api/note/auth/token` → ลิงก์ `/note?t=<access_token>` (ไม่ต้องมี " +
"`fileUrl`) ให้ผู้ใช้คลิกตามเส้นทางเดิม\n\n" +
"- auth: header `x-api-key` (env `DOCVER_API_KEY` — ตัวเดียวกับทางเข้า mint)\n" +
"- `data`: **base64 มาตรฐานต่อเนื่อง** — ห้าม whitespace/line break และไม่รับ " +
"base64url (`-`/`_`)\n" +
"- ขนาด: เนื้อไฟล์ ≤ 100MB (base64 ยาว ~134MB) — ระบบตรวจ magic bytes เอง " +
"(PDF/JPEG/PNG/TIFF) และใช้ผลตรวจเป็นชนิดไฟล์ ไม่เชื่อค่าที่ประกาศ\n" +
"- การ stage ด้วย `fid` เดิมซ้ำ = **replace เนื้อล่าสุด** (row เดิม " +
"annotation ครบ — พิกัดอาจคลาดถ้าเลย์เอาต์ขยับ เช่นเดียวกับ N8)\n\n" +
"**คู่มือ retry:** `408`/`429`/`5xx` = **ยิงซ้ำได้** (429 ตาม header " +
"`Retry-After`, ที่เหลือ backoff) และการ stage ซ้ำ **ปลอดภัย** — `fid` เดิม " +
"แทนที่ของเดิม ไม่เกิดรายการซ้ำ ไม่เสียอะไร · `400`/`413` = แก้ request " +
"ก่อนยิงใหม่ · อัปโหลดช้าถูกตัดที่ 60 วิว่างเปล่า/10 นาทีรวม (408) — " +
"ลองส่งใหม่บนเครือข่ายที่ดีกว่า",
security: [{ ApiKeyAuth: [] }],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["fid", "data"],
properties: {
fid: {
type: "string",
description:
"รหัสเอกสารของ e-Court (1–200 ตัวอักษร, ห้ามอักขระ & = + %) — ใช้ mint login token " +
"ต่อด้วย fid เดิม · ฝั่งระบบนี้เก็บเป็น hash (ไม่มี fid ดิบใน DB) แต่ผู้เรียกยังส่ง " +
"fid ดิบมาก่อน hash ทุกเส้น — contract ไม่เปลี่ยน",
},
data: {
type: "string",
description:
"เนื้อไฟล์ (.pdf/.jpg/.png/.tif) เข้ารหัส base64 มาตรฐานต่อเนื่อง — เนื้อไฟล์ ≤ 100MB",
},
typeId: {
type: "string",
description:
"รหัสกลุ่มเอกสาร N9 (ทางเลือก) — ส่งมา = ย้ายกลุ่มตามค่าล่าสุด ไม่ส่ง = คงเดิม · " +
"⚠️ ของเอกสาร staging รับทางเส้นนี้**ทางเดียว** — typeId ที่แนบมากับ body ของ " +
"`POST /api/note/auth/session` (ตอนแลกลิงก์ fid-only) ไม่ถูกใช้กับกลุ่มของไฟล์",
},
title: {
type: "string",
description:
"ชื่อแสดง (ทางเลือก, ≤ 200 ตัวอักษร) — อัปเดตได้เฉพาะเมื่อชื่อปัจจุบันยังเป็นค่า default " +
'"เอกสารจาก e-Court" (ครั้งแรกที่ส่งชื่อจริงชนะแล้วล็อก)',
},
},
},
examples: {
"stage คำสั่งศาล PDF": {
value: {
fid: "F123",
data: "JVBERi0xLjQK…",
title: "คำสั่งศาลที่ 2501/2569",
typeId: "GROUP-01",
},
},
},
},
},
},
responses: {
200: {
description:
"ลงทะเบียนสำเร็จ — `fileId` คือ id ของ row เอกสาร (ผู้ใช้เข้าผ่านลิงก์ fid ตามปกติ)",
content: {
"application/json": {
schema: {
type: "object",
properties: {
fileId: {
type: "string",
description: "uuid ของ row เอกสาร (sentinel staged:<uuid>.<ext> ใน pdf_url)",
},
},
},
},
},
},
400: {
description:
"body ผิด (field ที่ไม่รู้จัก / base64 เสีย / magic bytes ไม่ใช่ PDF หรือรูปที่รองรับ / fid มีอักขระ & = + %)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
401: {
description:
"ไม่ได้ส่ง header x-api-key หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY — ข้อความเดียวกันทุกสาเหตุ)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
408: {
description:
"อ่าน body ช้าเกินกำหนด (ไม่มี chunk 60 วิ / เกิน 10 นาทีรวม) หรือการเชื่อมต่อขาด — ยิงซ้ำได้",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
413: {
description:
"ไฟล์ใหญ่เกินขนาดที่รองรับ (เนื้อไฟล์เกิน 100MB / base64 เกิน ~140MB) หรือใหญ่เกินโควตารวม — แก้ไฟล์ก่อนส่งใหม่",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
429: {
description:
"มีการ stage เอกสารอื่นกำลังดำเนินการ (จำกัด concurrent) — ยิงซ้ำตาม header Retry-After",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
500: { $ref: "#/components/responses/ServerError" },
},
},
},
"/api/documents/token": {
post: {
tags: ["documents"],
@ -1033,9 +1173,10 @@ export const openApiSpec = {
name: "x-api-key",
description:
"x-api-key ของ N10 = env EXTERNAL_API_KEY · ของการขอ token กลุ่ม documents = env " +
"DOCVER_API_KEY (คนละค่ากับของ N10 — กดปุ่ม Authorize ใส่ครั้งเดียวแล้วใช้ได้ทุก " +
"request ของกลุ่มที่ key ตรง) · GET /api/documents/latest พิเศษ: รับได้ทั้งสองค่า " +
"(DOCVER_API_KEY **หรือ** EXTERNAL_API_KEY ตัวใดตัวหนึ่งตรง = ผ่าน)",
"DOCVER_API_KEY · ของกลุ่ม Judge's Note (mint login-token + stage เอกสาร base64) " +
"= env DOCVER_API_KEY เช่นกัน (คนละค่ากับของ N10 — กดปุ่ม Authorize ใส่ครั้งเดียว " +
"แล้วใช้ได้ทุก request ของกลุ่มที่ key ตรง) · GET /api/documents/latest พิเศษ: " +
"รับได้ทั้งสองค่า (DOCVER_API_KEY **หรือ** EXTERNAL_API_KEY ตัวใดตัวหนึ่งตรง = ผ่าน)",
},
DocverBearer: {
type: "http",
@ -1241,7 +1382,10 @@ export const openApiSpec = {
externalFileId: {
type: "string",
nullable: true,
description: "id ของ e-Court (null = ไฟล์ url:-fallback หรืออัปโหลดในกลุ่ม)",
description:
"ค่า hash ของ fileId (HMAC-SHA256 ทางเดียว — แปลงกลับเป็น fid เดิมไม่ได้ " +
"2026-11-04: ระบบเก็บ hash แทน fid ดิบ; ค่านี้คงเดิมทุกครั้งที่ถามด้วย fid เดียวกัน " +
"จึงใช้เทียบความเหมือนได้อย่างเดียว · null = ไฟล์ url:-fallback หรืออัปโหลดในกลุ่ม)",
},
title: { type: "string" },
typeId: { type: "string", nullable: true },