const fs = require("fs"); const fsp = require("fs/promises"); const path = require("path"); const crypto = require("crypto"); const { ObjectId } = require("mongodb"); const dayjs = require("dayjs"); const axios = require("axios"); const puppeteer = require("puppeteer-core"); const { decryptDiagnosticRecordFields } = require("../diagnostic-record/format"); let browserPromise; let activeJobs = 0; const waiters = []; const cachedPdfJobs = new Map(); const PDF_TEMPLATE_VERSION = "v7"; const MAX_SIGNATURE_IMAGE_BYTES = 1024 * 1024; const SIGNATURE_DOWNLOAD_TIMEOUT_MS = 5000; const PRESCRIPTION_SEAL_IMAGE_URL = process.env.CONFIG_PRESCRIPTION_SEAL_IMAGE_URL || "https://hlwyy.hnkhhospital.com/upload-files/other/stamp.png"; const signatureMimeTypes = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", }; function resolveDefaultPdfRoot( moduleDirectory = __dirname, isBuilt = process.env.IS_BUILDED === "YES" ) { const serviceRoot = isBuilt ? moduleDirectory : path.resolve(moduleDirectory, "../.."); return path.resolve(serviceRoot, "../private-files/prescriptions"); } function getPdfRoot() { return path.resolve( process.env.CONFIG_PRESCRIPTION_PDF_ROOT || resolveDefaultPdfRoot() ); } async function initializeStorage() { const pdfRoot = getPdfRoot(); await fsp.mkdir(pdfRoot, { recursive: true }); await fsp.access(pdfRoot, fs.constants.R_OK | fs.constants.W_OK); return pdfRoot; } function maxConcurrency() { const value = Number.parseInt(process.env.PRESCRIPTION_PDF_MAX_CONCURRENCY, 10); return Number.isInteger(value) && value > 0 ? value : 2; } async function acquireSlot() { if (activeJobs < maxConcurrency()) { activeJobs += 1; return; } await new Promise((resolve) => waiters.push(resolve)); activeJobs += 1; } function releaseSlot() { activeJobs -= 1; const next = waiters.shift(); if (next) next(); } function getExecutablePath() { const platformCandidates = process.platform === "linux" ? [ "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser", ] : [ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", ]; const candidates = [process.env.PUPPETEER_EXECUTABLE_PATH, ...platformCandidates] .filter(Boolean); const executablePath = candidates.find((candidate) => fs.existsSync(candidate)); if (!executablePath) { throw new Error( `未找到 Chrome/Chromium,请配置 PUPPETEER_EXECUTABLE_PATH。已检查:${candidates.join(", ")}` ); } return executablePath; } async function getBrowser() { if (!browserPromise) { browserPromise = puppeteer.launch({ executablePath: getExecutablePath(), headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"], }); const browser = await browserPromise; browser.on("disconnected", () => { browserPromise = undefined; }); } return browserPromise; } function escapeHtml(value) { return String(value ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/\"/g, """) .replace(/'/g, "'"); } function safeDataImage(value) { if (typeof value !== "string" || value.length > 1024 * 1024) return ""; return /^data:image\/(png|jpeg|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : ""; } function getUploadRoot() { // 与 upload.js 使用相同的共享上传目录,拒绝任意文件系统路径。 return path.resolve(__dirname, "../../../upload-files"); } function getImageMimeType(buffer, contentType = "") { const normalizedContentType = String(contentType).split(";", 1)[0].trim().toLowerCase(); if (["image/png", "image/jpeg", "image/webp"].includes(normalizedContentType)) { return normalizedContentType; } if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex"))) return "image/png"; if (buffer.length >= 3 && buffer.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) return "image/jpeg"; if (buffer.length >= 12 && buffer.subarray(0, 4).toString() === "RIFF" && buffer.subarray(8, 12).toString() === "WEBP") return "image/webp"; return ""; } function getLocalSignaturePath(value) { if (typeof value !== "string") return ""; let pathname = value; if (/^https?:\/\//i.test(value)) { try { pathname = new URL(value).pathname; } catch (_error) { return ""; } } if (!pathname.startsWith("/upload-files/")) return ""; const uploadRoot = getUploadRoot(); let relativePath; try { relativePath = decodeURIComponent(pathname.slice("/upload-files/".length)); } catch (_error) { return ""; } const filePath = path.resolve(uploadRoot, relativePath); return filePath.startsWith(`${uploadRoot}${path.sep}`) ? filePath : ""; } async function loadLocalSignatureDataImage(value) { const filePath = getLocalSignaturePath(value); if (!filePath) return ""; const mimeType = signatureMimeTypes[path.extname(filePath).toLowerCase()]; if (!mimeType) return ""; try { const file = await fsp.readFile(filePath); if (file.length === 0 || file.length > MAX_SIGNATURE_IMAGE_BYTES) return ""; return `data:${mimeType};base64,${file.toString("base64")}`; } catch (_error) { return ""; } } async function downloadSignatureDataImage(value) { if (typeof value !== "string" || !/^https?:\/\//i.test(value)) return ""; try { const url = new URL(value); if (url.protocol !== "http:" && url.protocol !== "https:") return ""; const response = await axios.get(url.toString(), { responseType: "arraybuffer", timeout: SIGNATURE_DOWNLOAD_TIMEOUT_MS, maxContentLength: MAX_SIGNATURE_IMAGE_BYTES, maxBodyLength: MAX_SIGNATURE_IMAGE_BYTES, maxRedirects: 2, validateStatus: (status) => status >= 200 && status < 300, }); const image = Buffer.from(response.data); if (image.length === 0 || image.length > MAX_SIGNATURE_IMAGE_BYTES) return ""; const mimeType = getImageMimeType(image, response.headers["content-type"]); return mimeType ? `data:${mimeType};base64,${image.toString("base64")}` : ""; } catch (_error) { return ""; } } async function loadSignatureDataImage(value) { const embeddedImage = safeDataImage(value); if (embeddedImage) return embeddedImage; const localImage = await loadLocalSignatureDataImage(value); if (localImage) return localImage; return downloadSignatureDataImage(value); } function getAge(record) { if (Number.isFinite(Number(record.age)) && Number(record.age) >= 0) { return `${Number(record.age)}岁`; } const idCard = String(record.idCard || ""); if (!/^\d{17}[\dXx]$/.test(idCard)) return ""; const birth = dayjs(`${idCard.slice(6, 10)}-${idCard.slice(10, 12)}-${idCard.slice(12, 14)}`); const reference = dayjs(record.prescriptionEndTime || record.createTime); return birth.isValid() && reference.isValid() ? `${reference.diff(birth, "year")}岁` : ""; } function getSex(record) { if (record.sex === "男" || record.sex === "女") return record.sex; const idCard = String(record.idCard || ""); return /^\d{17}[\dXx]$/.test(idCard) ? (Number(idCard[16]) % 2 ? "男" : "女") : ""; } function medicineUsage(drug) { return [ drug.usageName, drug.frequencyName, drug.dosage ? `每次${drug.dosage}${drug.dosage_unit || ""}` : "", ].filter(Boolean).join(" "); } function signature(value, fallback) { const image = safeDataImage(value); return image ? `签名` : `${escapeHtml(fallback)}`; } async function addDrugStoreName(db, record) { if (typeof record.drugStoreName === "string" || typeof record.orderId !== "string" || !record.orderId.trim()) { return record; } try { const order = await db.collection("consult-order").findOne( { orderId: record.orderId }, { projection: { drugStoreName: 1 } } ); return { ...record, drugStoreName: order?.drugStoreName || "" }; } catch (_error) { // 门店名称仅用于处方提示语;查询异常不能阻止 PDF 的生成。 return record; } } function buildPrescriptionHtml(record, paperSize = "A5", signatureImages = {}) { const size = paperSize === "A4" ? "A4" : "A5"; const diagnosis = Array.isArray(record.diagnosisList) ? record.diagnosisList : []; const drugs = Array.isArray(record.drugs) ? record.drugs : []; const prescriptionTime = dayjs(record.prescriptionEndTime || record.createTime); const diagnosisText = diagnosis.map((item) => item?.name).filter(Boolean).join("+"); const drugHtml = drugs.length ? drugs.map((drug, index) => `
${index + 1}. ${escapeHtml(drug.drugName)} x ${escapeHtml(drug.quantity)} ${escapeHtml(drug.unit)}
规格:${escapeHtml(drug.specification)}
用法:${escapeHtml(medicineUsage(drug))}
`).join("") : "
暂无药品
"; return `
普通
处方
海宁康华医院互联网医院
处方笺
NO:${escapeHtml(record.medOrgOrderNo || record._id)}
姓名:${escapeHtml(record.name)}
性别:${escapeHtml(getSex(record))}
年龄:${escapeHtml(getAge(record))}
费别:${record.feeType === "YB" ? "医保" : "自费"}
门诊病历号:${escapeHtml(record.blhno)}
科别:${escapeHtml(record.deptName)}
地址:${escapeHtml(record.address)}
电话号:${escapeHtml(record.mobile)}
诊断:${escapeHtml(diagnosisText)}
开方日期:${prescriptionTime.isValid() ? prescriptionTime.format("YYYY-MM-DD HH:mm") : ""}
Rp
${signatureImages.seal ? `处方印章` : ""}
${drugHtml}
`; } async function generatePrescriptionPdf(record, options = {}) { await acquireSlot(); let page; try { const browser = await getBrowser(); page = await browser.newPage(); const [doctor, pharmacist, seal] = await Promise.all([ loadSignatureDataImage(record.doctorSignImg), loadSignatureDataImage(record.pharmacistSignImg), loadSignatureDataImage(PRESCRIPTION_SEAL_IMAGE_URL), ]); await page.setContent( buildPrescriptionHtml(record, options.paperSize, { doctor, pharmacist, seal }), { waitUntil: "load" } ); await page.evaluate(() => document.fonts && document.fonts.ready); return Buffer.from(await page.pdf({ format: options.paperSize === "A4" ? "A4" : "A5", preferCSSPageSize: true, printBackground: true, margin: { top: 0, right: 0, bottom: 0, left: 0 }, })); } finally { if (page) await page.close().catch(() => {}); releaseSlot(); } } async function createPrescriptionPdf(db, id, options) { if (typeof id !== "string" || !ObjectId.isValid(id)) { const error = new Error("处方 id 格式错误"); error.statusCode = 400; throw error; } const record = await db.collection("diagnostic-record").findOne({ _id: new ObjectId(id) }); if (!record) { const error = new Error("处方不存在"); error.statusCode = 404; throw error; } return generatePrescriptionPdf( await addDrugStoreName(db, decryptDiagnosticRecordFields(record)), options ); } function getPdfPath(id, paperSize) { const size = paperSize === "A4" ? "A4" : "A5"; // MongoDB ObjectId 的前四字节是创建时间戳;用 UTC 日期分目录,避免因服务器时区不同写入不同路径。 const createdAt = new ObjectId(id).getTimestamp(); const year = String(createdAt.getUTCFullYear()); const month = String(createdAt.getUTCMonth() + 1).padStart(2, "0"); const day = String(createdAt.getUTCDate()).padStart(2, "0"); return path.join( getPdfRoot(), year, month, day, `${id}-${size}-${PDF_TEMPLATE_VERSION}.pdf` ); } async function getOrCreatePrescriptionPdf(db, id, options = {}) { if (typeof id !== "string" || !ObjectId.isValid(id)) { const error = new Error("处方 id 格式错误"); error.statusCode = 400; throw error; } const paperSize = options.paperSize === "A4" ? "A4" : "A5"; const pdfPath = getPdfPath(id, paperSize); try { await fsp.access(pdfPath, fs.constants.R_OK); return { pdfPath, cached: true }; } catch (_error) { // 文件尚未生成,继续生成;不要将 access 错误泄漏给客户端。 } const key = `${id}:${paperSize}`; if (!cachedPdfJobs.has(key)) { cachedPdfJobs.set(key, (async () => { await initializeStorage(); await fsp.mkdir(path.dirname(pdfPath), { recursive: true }); try { await fsp.access(pdfPath, fs.constants.R_OK); return; } catch (_error) { // 仍不存在,当前请求负责生成。 } const record = await db.collection("diagnostic-record").findOne({ _id: new ObjectId(id) }); if (!record) { const error = new Error("处方不存在"); error.statusCode = 404; throw error; } const pdf = await generatePrescriptionPdf( await addDrugStoreName(db, decryptDiagnosticRecordFields(record)), { paperSize } ); const temporaryPath = `${pdfPath}.${crypto.randomUUID()}.tmp`; try { await fsp.writeFile(temporaryPath, pdf, { flag: "wx" }); await fsp.rename(temporaryPath, pdfPath); } catch (error) { await fsp.unlink(temporaryPath).catch(() => {}); throw error; } })().finally(() => cachedPdfJobs.delete(key))); } await cachedPdfJobs.get(key); return { pdfPath, cached: false }; } async function closeBrowser() { if (!browserPromise) return; const browser = await browserPromise; browserPromise = undefined; await browser.close(); } module.exports = { buildPrescriptionHtml, createPrescriptionPdf, generatePrescriptionPdf, getOrCreatePrescriptionPdf, initializeStorage, loadSignatureDataImage, loadLocalSignatureDataImage, downloadSignatureDataImage, getPdfPath, closeBrowser, };