เพิ่มทางเข้าเอกสารสำหรับ 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>
89 lines
5.2 KiB
JavaScript
89 lines
5.2 KiB
JavaScript
// แปลง 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();
|
|
}
|