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,89 @@
// แปลง files.external_id ของเอกสาร e-Court จาก fid ดิบ ("ecourt:<fid>") เป็น hash
// ("ecourt:" + HMAC-SHA256(pepper, fid)) — รันครั้งเดียวตอน roll การเปลี่ยนของ
// lib/ecourt-fid.ts (2026-11-04) + เก็บ title default ที่ฝัง fid ให้เป็นค่ากลางใหม่
//
// ทำเป็น Node script ไม่ใช่ SQL ใน drizzle/ เพราะ pepper ต้องมาจาก env ของเครื่อง
// (ห้าม commit ค่าลับลง migration) · สูตร hash ต้องตรงกับ lib/ecourt-fid.ts เป๊ะ
// (HMAC-SHA256 key=pepper บน fid.trim() → hex — script .mjs import TS ไม่ได้จึงมี
// สำเนาสูตรตรงนี้ แก้ที่ใดต้องแก้อีกทางคู่กัน)
//
// idempotent — รันซ้ำได้: แถวที่ tail เป็น hex 64 แล้วถูกข้าม
// · idempotent ไม่รวม "ข้ามแถวชน" — ถ้า target hash ที่คำนวณไปชนกับแถวอื่น (มีการ
// roll ใหม่ค้างก่อน migration) จะพิมพ์ warning แล้วข้าม ห้าม merge/delete เอง
//
// รัน: npm run migrate:ecourt-fid (node --env-file=.env ตาม pattern ของ seed-samples)
// ⚠️ ระหว่างรันต้องหยุด process ของแอปก่อน — โค้ดเก่าที่ยังเขียน plaintext กลับมา
// หลัง migration จะ insert แถวคู่เงียบ ๆ (ดูลำดับ roll ในแผน)
import { createHmac } from "node:crypto";
import postgres from "postgres";
const ECOURT_PREFIX = "ecourt:";
const ECOURT_DEFAULT_TITLE = "เอกสารจาก e-Court";
const HEX64 = /^[0-9a-f]{64}$/;
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) throw new Error("ต้องตั้ง DATABASE_URL ก่อนรัน (node --env-file=.env)");
const pepper = process.env.FID_HASH_PEPPER?.trim();
if (!pepper) {
throw new Error(
"ต้องตั้ง FID_HASH_PEPPER ก่อน (ค่าคงที่ตลอดชีพ — ห้ามเปลี่ยนหลังมีข้อมูล, 32+ ตัวสุ่ม)",
);
}
const ecourtExternalId = (fid) =>
ECOURT_PREFIX + createHmac("sha256", pepper).update(fid.trim()).digest("hex");
const sql = postgres(dbUrl, { prepare: false, max: 1 });
try {
const rows = await sql`select id, title, external_id from files where external_id like ${ECOURT_PREFIX + "%"}`;
// target ทุกแถวก่อนเพื่อตรวจชน (แถวที่ hash แล้ว target = ตัวมันเอง = ไม่ชนใคร)
const targets = new Map(); // id → target external_id
for (const row of rows) {
const fid = row.external_id.slice(ECOURT_PREFIX.length);
targets.set(row.id, HEX64.test(fid) ? row.external_id : ecourtExternalId(fid));
}
const ownerByTarget = new Map();
for (const [id, target] of targets) {
const prev = ownerByTarget.get(target);
if (prev !== undefined && prev !== id) {
console.warn(`[fid-hash] ตรวจพบแถวชน: ${prev} และ ${id} hash ไปที่ค่าเดียวกัน — ข้ามทั้งคู่ (ตรวจข้อมูลก่อนรันใหม่)`);
targets.delete(prev);
targets.delete(id);
} else {
ownerByTarget.set(target, id);
}
}
let converted = 0;
let titlesFixed = 0;
let skipped = 0;
for (const row of rows) {
const target = targets.get(row.id);
if (target === undefined) {
skipped += 1;
continue;
}
if (target === row.external_id) {
skipped += 1; // hash แล้ว (idempotent)
continue;
}
const fid = row.external_id.slice(ECOURT_PREFIX.length);
// title default เก่าสองรูปแบบที่ฝัง fid ดิบ: "เอกสาร <fid>" (N11) และ fid เปล่า (N8
// fallback ของ titleFromExternalUrl ยุคเก่า) — เก็บเป็นค่ากลางใหม่
const isDefaultTitle = row.title === `เอกสาร ${fid}` || row.title === fid;
const newTitle = isDefaultTitle ? ECOURT_DEFAULT_TITLE : row.title;
await sql`update files set external_id = ${target}, title = ${newTitle} where id = ${row.id}`;
converted += 1;
if (isDefaultTitle) titlesFixed += 1;
}
const leftovers =
await sql`select count(*)::int as n from files where external_id like ${ECOURT_PREFIX + "%"} and external_id !~ ${ECOURT_PREFIX + "[0-9a-f]{64}$"}`;
console.log(
`[fid-hash] เสร็จ: แปลง ${converted} แถว (แก้ title default ${titlesFixed}) · ข้าม ${skipped} แถว (hash แล้ว/ชน) · เหลือ plaintext ${leftovers[0].n} แถว (ต้องเป็น 0)`,
);
if (leftovers[0].n !== 0) process.exitCode = 1;
} finally {
await sql.end();
}