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

@ -7,12 +7,17 @@
// · 400 body ผิด · 401 {error, reason: expired|invalid} · 500 secret ไม่ตั้ง
// · audit log เฉพาะ userid (strip อักขระควบคุม) — ห้าม log token/query ดิบ
import { NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { files } from "@/db/schema";
import { addinTicketSecret, verifyAddinTicket } from "@/lib/addin-ticket";
import { issueDocverToken, verifyDocverLoginToken } from "@/lib/docver-token";
import { ecourtExternalId } from "@/lib/ecourt-fid";
import { registerExternalFile } from "@/lib/external-import";
import { parseSessionExchangeBody } from "@/lib/note-auth";
import { NOTE_SESSION_TTL } from "@/lib/note-token";
import { hasStagedDoc, isStagedSource } from "@/lib/staged-docs";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@ -81,6 +86,30 @@ export async function POST(request: Request): Promise<Response> {
});
if (result.ok) fileId = result.fileId;
else fileError = result.error;
} else {
// N11 (PLAN-STAGED.md D5): ลิงก์ fid-only (เอกสาร staging — เนื้อส่งมาทาง POST
// /api/note/files/stage แล้ว ลิงก์มีแค่ t=) — เช็คตั้งแต่ตอนแลก token เลย ไม่ปล่อย
// เข้า viewer ไปเจอ 410 เอง · ใช้ hasStagedDoc (ไม่ยืด TTL ไม่ถือ Buffer — การอ่าน
// จริงเกิดที่ viewer) · เอกสาร N8 ที่รู้จักแล้ว (pdf_url เป็น URL/path ปกติ) เข้าได้
// ทางเดียวกัน · ผู้ใช้ได้ token ตามปกติทุกกรณี
try {
const [row] = await db
.select()
.from(files)
.where(eq(files.externalId, ecourtExternalId(identity.fid)))
.limit(1);
if (!row) {
fileError = "เอกสารยังไม่ถูกส่งเข้าระบบ — กลับ e-Court กดลิงก์ใหม่";
} else if (isStagedSource(row.pdfUrl)) {
if (hasStagedDoc(row.pdfUrl)) fileId = row.id;
else fileError = "เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่";
} else {
fileId = row.id;
}
} catch (err) {
console.error("[sso] ค้นเอกสารของ fid ล้ม", err);
fileError = "ค้นเอกสารไม่สำเร็จ — ลองอีกครั้ง";
}
}
const access_token = issueDocverToken(