2026-10-13 10:11:45 +08:00

502 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = "v11";
const MAX_SIGNATURE_IMAGE_BYTES = 1024 * 1024;
const SIGNATURE_DOWNLOAD_TIMEOUT_MS = 5000;
const MAX_PRESCRIPTION_PDF_BYTES = 100 * 1024;
const RASTER_PAGE_SIZES = {
// A5 (148mm) 减去左右 8mm 页边距后的实际打印宽度为 132mm。
A5: { viewport: { width: 500, height: 733, deviceScaleFactor: 1 }, contentWidth: "132mm", contentHeight: "194mm" },
A4: { viewport: { width: 733, height: 1062, deviceScaleFactor: 1 }, contentWidth: "194mm", contentHeight: "281mm" },
};
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", "--disable-gpu"],
});
const browser = await browserPromise;
browser.on("disconnected", () => {
browserPromise = undefined;
});
}
return browserPromise;
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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() {
// 源码运行时:<repo>/hn-hlw-service/hlw/prescription-pdf -> <repo>/upload-files。
// esbuild 后 __dirname 为 /opt/apps(bundle_pro.js 所在目录),上传目录则为 /opt/apps/upload-files。
return process.env.IS_BUILDED === "YES"
? path.resolve(__dirname, "../upload-files")
: 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
? `<img class="signature-image" src="${image}" alt="签名" />`
: `<span>${escapeHtml(fallback)}</span>`;
}
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) => `
<section class="medicine-item">
<div class="medicine-name">${index + 1}. ${escapeHtml(drug.drugName)} x ${escapeHtml(drug.quantity)} ${escapeHtml(drug.unit)}</div>
<div class="medicine-detail">规格:${escapeHtml(drug.specification)}</div>
<div class="medicine-detail">用法:${escapeHtml(medicineUsage(drug))}</div>
</section>`).join("")
: "<section class=\"medicine-item\">暂无药品</section>";
return `<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8" />
<style>
@page { size: ${size} portrait; margin: 8mm; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; color: #111; background: #fff; font-family: "Noto Sans CJK SC", "Noto Sans SC", SimSun, "Songti SC", serif; font-size: 14px; }
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.prescription { width: calc(100% - 1px); min-height: ${size === "A5" ? "194mm" : "281mm"}; display: flex; flex-direction: column; line-height: 1.28; overflow: hidden; }
.header { position: relative; border-bottom: 1px solid #111; padding: 3px 0 4px; }
.title { font-weight: 700; font-size: 17px; text-align: center; line-height: 1.4; }
.rx-type { position: absolute; top: 0; right: 1px; width: 40px; border: 1px solid #333; text-align: center; color: #666; font-size: 12px; line-height: 1.3; }
.rx-number { margin-top: 5px; }
.patient-info { padding-top: 5px; }
.info-row { display: flex; padding: 2px; }
.info-cell { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.info-half { width: 50%; overflow-wrap: anywhere; }
.diagnosis { margin-top: 5px; }
.diagnosis-head { display: flex; justify-content: space-between; gap: 8px; }
.diagnosis-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.date { flex: none; white-space: nowrap; }
.medicine { margin-top: 5px; }
.rp-line { position: relative; display: flex; align-items: center; min-height: 30px; margin-bottom: 6px; }
.rp-line::after { content: ""; flex: 1; margin-left: 8px; border-top: 1px dashed #6b7280; }
.rp { font-size: 20px; font-weight: 700; }
.seal-image { position: absolute; z-index: 1; top: -4px; right: 0; width: 120px; height: 120px; object-fit: contain; }
.medicine-item { break-inside: avoid; margin: 0 0 6px; }
.medicine-name { font-weight: 700; }
.medicine-detail { color: #666; margin-top: 1px; }
.footer { margin-top: auto; border-top: 1px dashed #999; padding-top: 5px; break-inside: avoid; }
.signature-row, .action-row { display: flex; padding-top: 4px; }
.signature-cell, .action-cell { width: 33.333%; min-height: 24px; }
.signature-image { display: inline-block; width: 76px; height: 30px; object-fit: contain; vertical-align: middle; }
.notice { margin-top: 0; font-size: 12px; line-height: 1.25; white-space: nowrap; }
.notice-first { margin-top: 7px; }
@media print { .medicine-item, .footer { page-break-inside: avoid; } }
</style></head><body>
<main class="prescription">
<header class="header">
<div class="rx-type">普通<br>处方</div>
<div class="title">海宁康华医院互联网医院<br>处方笺</div>
<div class="rx-number">NO:${escapeHtml(record.medOrgOrderNo || record._id)}</div>
</header>
<section class="patient-info">
<div class="info-row"><div class="info-cell">姓名:${escapeHtml(record.name)}</div><div class="info-cell">性别:${escapeHtml(getSex(record))}</div><div class="info-cell">年龄:${escapeHtml(getAge(record))}</div><div class="info-cell">费别:${record.feeType === "YB" ? "医保" : "自费"}</div></div>
<div class="info-row"><div class="info-half">门诊病历号:${escapeHtml(record.blhno)}</div><div class="info-half">科别:${escapeHtml(record.deptName)}</div></div>
<div class="info-row"><div class="info-cell">地址:${escapeHtml(record.address)}</div></div>
<div class="info-row"><div class="info-cell">电话号:${escapeHtml(record.mobile)}</div></div>
</section>
<section class="diagnosis"><div class="diagnosis-head"><div class="diagnosis-text">诊断:${escapeHtml(diagnosisText)}</div><div class="date">开方日期:${prescriptionTime.isValid() ? prescriptionTime.format("YYYY-MM-DD HH:mm") : ""}</div></div></section>
<section class="medicine"><div class="rp-line"><div class="rp">Rp</div>${signatureImages.seal ? `<img class="seal-image" src="${signatureImages.seal}" alt="处方印章" />` : ""}</div>${drugHtml}</section>
<footer class="footer"><div class="signature-row"><div class="signature-cell">医生:${signature(signatureImages.doctor || record.doctorSignImg, record.doctorName)}</div><div class="signature-cell">审方药师:${signature(signatureImages.pharmacist || record.pharmacistSignImg, record.pharmacist)}</div><div class="signature-cell"></div></div><div class="action-row"><div class="action-cell">审核:</div><div class="action-cell">调配:</div><div class="action-cell">核对/发药:</div></div><div class="notice notice-first">1.根据《处方管理办法》规定,本处方开具三天有效;</div><div class="notice">2.本处方仅供患者${escapeHtml(record.drugStoreName)}使用,本处方金额以实际药店成交金额为准;</div><div class="notice">3.请仔细阅读药品使用说明,服药谨遵医嘱,如有不适请立即停药并前往医院就诊。</div></footer>
</main>
</body></html>`;
}
function buildRasterPdfHtml(image, paperSize) {
const size = RASTER_PAGE_SIZES[paperSize];
return `<!doctype html><html><head><meta charset="utf-8" /><style>
@page { size: ${paperSize} portrait; margin: 8mm; }
html, body { margin: 0; padding: 0; }
img { display: block; width: ${size.contentWidth}; height: ${size.contentHeight}; }
</style></head><body><img src="${image}" alt="处方笺" /></body></html>`;
}
async function generateCompactPrescriptionPdf(html, paperSize) {
// Chrome 会为中文文本嵌入字体子集;即使没有图片,单页也约 280KB。
// 将实际打印区域以 96dpi 截图并转为 JPEG,可保留处方可读性且避免字体嵌入。
const browser = await getBrowser();
const size = RASTER_PAGE_SIZES[paperSize];
const rasterPage = await browser.newPage();
try {
await rasterPage.setViewport(size.viewport);
await rasterPage.setContent(html, { waitUntil: "load" });
await rasterPage.evaluate(() => document.fonts && document.fonts.ready);
const screenshot = await rasterPage.screenshot({
type: "jpeg",
quality: 72,
clip: { x: 0, y: 0, width: size.viewport.width, height: size.viewport.height },
});
const compactPage = await browser.newPage();
try {
await compactPage.setContent(
buildRasterPdfHtml(`data:image/jpeg;base64,${Buffer.from(screenshot).toString("base64")}`, paperSize),
{ waitUntil: "load" }
);
const pdf = Buffer.from(await compactPage.pdf({
format: paperSize,
preferCSSPageSize: true,
printBackground: true,
margin: { top: 0, right: 0, bottom: 0, left: 0 },
}));
if (pdf.length > MAX_PRESCRIPTION_PDF_BYTES) {
throw new Error(`处方 PDF 压缩后仍超过 ${MAX_PRESCRIPTION_PDF_BYTES} 字节`);
}
return pdf;
} finally {
await compactPage.close().catch(() => {});
}
} finally {
await rasterPage.close().catch(() => {});
}
}
async function generatePrescriptionPdf(record, options = {}) {
await acquireSlot();
try {
const [doctor, pharmacist, seal] = await Promise.all([
loadSignatureDataImage(record.doctorSignImg),
loadSignatureDataImage(record.pharmacistSignImg),
loadSignatureDataImage(PRESCRIPTION_SEAL_IMAGE_URL),
]);
const html = buildPrescriptionHtml(record, options.paperSize, { doctor, pharmacist, seal });
const paperSize = options.paperSize === "A4" ? "A4" : "A5";
return generateCompactPrescriptionPdf(html, paperSize);
} finally {
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,
};