处方
处方笺
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 = "v5";
const MAX_SIGNATURE_IMAGE_BYTES = 1024 * 1024;
const SIGNATURE_DOWNLOAD_TIMEOUT_MS = 5000;
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)}`;
}
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 diagnosisHtml = diagnosis.length
? diagnosis.map((item, index) => `