feat: 新增处方笺pdf
This commit is contained in:
parent
b886d6d836
commit
8956fb8c8a
5
.env.dev
5
.env.dev
@ -18,3 +18,8 @@ CONFIG_ALIPAY_MINI_PRIVATE_KEY=MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBA
|
||||
## 海宁his api
|
||||
CONFIG_HN_HIS_BASE_URL=http://122.225.16.74:8026
|
||||
|
||||
##
|
||||
PUPPETEER_EXECUTABLE_PATH="C:\Program Files\Google\Chrome\Application\chrome.exe"
|
||||
PRESCRIPTION_PDF_MAX_CONCURRENCY=2
|
||||
CONFIG_PRESCRIPTION_PDF_BASE_URL=https://patient.youcan365.com/hn
|
||||
|
||||
|
||||
6
.env.pro
6
.env.pro
@ -20,3 +20,9 @@ CONFIG_HN_HIS_BASE_URL=http://122.225.16.74:8026
|
||||
|
||||
## im图片存储服务端的位置
|
||||
CONFIG_IM_IMAGE_ROOT=/opt/apps/private-files/im-chat
|
||||
|
||||
## 处方 PDF(Linux Chrome)
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome
|
||||
PRESCRIPTION_PDF_MAX_CONCURRENCY=2
|
||||
CONFIG_PRESCRIPTION_PDF_ROOT=/opt/apps/private-files/prescriptions
|
||||
CONFIG_PRESCRIPTION_PDF_BASE_URL=https://hlwyy.hnkhhospital.com
|
||||
|
||||
20
README.md
20
README.md
@ -45,3 +45,23 @@ GET /im-chat/image/:msgId
|
||||
|
||||
当前接口按需求暂未启用 JWT,但仅允许通过 `MsgId` 查询数据库中归档成功的受控路径。
|
||||
生产使用前建议补充问诊订单权限验证。
|
||||
## 处方 PDF
|
||||
|
||||
处方笺下载接口为 `GET /hlw/prescriptions/:id/pdf`;默认输出 A5,传 `paperSize=A4` 可输出 A4。
|
||||
|
||||
服务运行环境需要预装 Chromium/Chrome,并配置其可执行文件路径:
|
||||
|
||||
```env
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
PRESCRIPTION_PDF_MAX_CONCURRENCY=2
|
||||
# 推荐挂载到持久化卷;未设置时默认 ../private-files/prescriptions
|
||||
CONFIG_PRESCRIPTION_PDF_ROOT=/data/hn-private-files/prescriptions
|
||||
```
|
||||
|
||||
`PRESCRIPTION_PDF_MAX_CONCURRENCY` 默认为 2,用于限制 Chromium 同时渲染的页面数。首次访问会生成并原子写入持久化目录,之后同一处方和纸张规格直接读取已有文件。该链接当前不需要认证。医生和药师签名支持 `data:image/*;base64,...`、项目上传目录的 `/upload-files/*.png|jpg|jpeg|webp` 路径,或 HTTP(S) 图片 URL。按本地文件、网络下载、姓名文本的顺序回退;网络请求最长 5 秒、最大 1 MB。
|
||||
|
||||
PDF 响应使用 `Cache-Control: no-cache`,浏览器每次访问都会向服务端校验文件;已保存的 PDF 仍直接从磁盘读取,不会重复渲染。
|
||||
|
||||
PDF 按处方 ObjectId 的 UTC 创建日期分目录保存,例如 `/opt/apps/private-files/prescriptions/2026/09/15/{处方ID}-A5-v5.pdf`,避免大量文件堆积在同一个目录。
|
||||
|
||||
Linux 服务端需安装中文字体,否则 Chrome 无法渲染处方中的中文。TencentOS Server 4 可执行 `dnf install -y google-noto-sans-cjk-sc-fonts fontconfig && fc-cache -f`,模板会优先使用 `Noto Sans CJK SC`。
|
||||
|
||||
@ -6,6 +6,18 @@ const Sm4Util = require("../../utils/sm4-util");
|
||||
*/
|
||||
const ENCRYPTED_FIELDS = ["name", "mobile", "idCard"];
|
||||
|
||||
function getPrescriptionPdfUrl(data) {
|
||||
const id = data && (data._id || data.id);
|
||||
if (id === undefined || id === null || String(id).trim() === "") {
|
||||
return "";
|
||||
}
|
||||
const baseUrl = String(process.env.CONFIG_PRESCRIPTION_PDF_BASE_URL || "")
|
||||
.trim()
|
||||
.replace(/\/+$/, "");
|
||||
if (!baseUrl) return "";
|
||||
return `${baseUrl}/hlw/prescriptions/${encodeURIComponent(String(id))}/pdf`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密诊断记录敏感字段
|
||||
* @param {Object} data - 诊断记录数据
|
||||
@ -80,6 +92,7 @@ function decryptDiagnosticRecordList(list) {
|
||||
|
||||
exports.formatUploadRxData = function (data) {
|
||||
return {
|
||||
// pdfUrl: getPrescriptionPdfUrl(data),
|
||||
pay_type: data.feeType === 'YB' ? '1' : '0',
|
||||
patientid: data.patientId,
|
||||
orderno: data.medOrgOrderNo,
|
||||
@ -266,3 +279,4 @@ exports.decryptDiagnosticRecordList = decryptDiagnosticRecordList;
|
||||
exports.buildHybridSearchQuery = buildHybridSearchQuery;
|
||||
exports.buildMultiFieldHybridSearch = buildMultiFieldHybridSearch;
|
||||
exports.ENCRYPTED_FIELDS = ENCRYPTED_FIELDS;
|
||||
exports.getPrescriptionPdfUrl = getPrescriptionPdfUrl;
|
||||
|
||||
430
hlw/prescription-pdf/index.js
Normal file
430
hlw/prescription-pdf/index.js
Normal file
@ -0,0 +1,430 @@
|
||||
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, """)
|
||||
.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
|
||||
? `<img class="signature-image" src="${image}" alt="签名" />`
|
||||
: `<span>${escapeHtml(fallback)}</span>`;
|
||||
}
|
||||
|
||||
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) => `<div>${index + 1}. ${escapeHtml(item.name)}</div>`).join("")
|
||||
: "<div>—</div>";
|
||||
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: 6mm; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; color: #111; font-family: "Noto Sans CJK SC", "Noto Sans SC", SimSun, "Songti SC", serif; font-size: 12px; }
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.prescription { min-height: ${size === "A5" ? "198mm" : "285mm"}; display: flex; flex-direction: column; line-height: 1.5; }
|
||||
.header { position: relative; border-bottom: 1px solid #111; padding: 1px 0 5px; }
|
||||
.title { font-weight: 700; font-size: 16px; text-align: center; line-height: 1.75; }
|
||||
.rx-type { position: absolute; top: 0; right: 0; width: 38px; border: 1px solid #555; text-align: center; color: #555; line-height: 1.55; }
|
||||
.rx-number { margin-top: 4px; font-size: 12px; }
|
||||
.patient-info { padding-top: 5px; }
|
||||
.info-row { display: flex; padding: 2px 0; gap: 5px; }
|
||||
.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-list { padding-top: 2px; min-height: 18px; }
|
||||
.date { white-space: nowrap; color: #555; }
|
||||
.medicine { margin-top: 4px; }
|
||||
.rp { font-size: 16px; font-weight: 700; margin-bottom: 3px; }
|
||||
.medicine-item { break-inside: avoid; margin: 0 0 11px; padding-left: 2px; }
|
||||
.medicine-name { font-weight: 700; font-size: 13px; }
|
||||
.medicine-detail { color: #444; margin-top: 2px; }
|
||||
.footer { margin-top: auto; border-top: 1px dashed #999; padding-top: 6px; break-inside: avoid; }
|
||||
.signature-row, .action-row { display: flex; padding: 2px 0; }
|
||||
.signature-cell, .action-cell { width: 33.333%; min-height: 20px; }
|
||||
.signature-image { display: inline-block; width: 82px; height: 28px; object-fit: contain; vertical-align: middle; }
|
||||
.notice { margin-top: 4px; color: #b33; font-size: 10px; line-height: 1.4; }
|
||||
.blank-line { display: inline-block; width: 35px; border-bottom: 1px solid currentColor; }
|
||||
@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">费别:${record.feeType === "YB" ? "医保" : "自费"}</div><div class="info-cell">年龄:${escapeHtml(getAge(record))}</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>诊断:</div><div class="date">开方日期:${prescriptionTime.isValid() ? prescriptionTime.format("YYYY-MM-DD HH:mm") : ""}</div></div><div class="diagnosis-list">${diagnosisHtml}</div></section>
|
||||
<section class="medicine"><div class="rp">Rp</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><div class="action-row"><div class="action-cell">调配:</div><div class="action-cell">核对:</div><div class="action-cell">发药:</div></div><div class="notice">注意:请勿丢失,处方当日有效,因特殊情况,该处方有效为<span class="blank-line"></span>天(签名:<span class="blank-line"></span>)</div></footer>
|
||||
</main>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
async function generatePrescriptionPdf(record, options = {}) {
|
||||
await acquireSlot();
|
||||
let page;
|
||||
try {
|
||||
const browser = await getBrowser();
|
||||
page = await browser.newPage();
|
||||
const [doctor, pharmacist] = await Promise.all([
|
||||
loadSignatureDataImage(record.doctorSignImg),
|
||||
loadSignatureDataImage(record.pharmacistSignImg),
|
||||
]);
|
||||
await page.setContent(
|
||||
buildPrescriptionHtml(record, options.paperSize, { doctor, pharmacist }),
|
||||
{ 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(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(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,
|
||||
};
|
||||
24
index.js
24
index.js
@ -42,6 +42,7 @@ const customerHisSync = require("./customerHisSync");
|
||||
const hlw = require("./hlw");
|
||||
const automation = require("./hlw/automation");
|
||||
const imImageArchive = require("./hlw/im-image-archive");
|
||||
const { getOrCreatePrescriptionPdf, initializeStorage: initializePrescriptionPdfStorage } = require("./hlw/prescription-pdf");
|
||||
const trigger = require("./trigger");
|
||||
const consultTrigger = require("./hlw/consult-order/trigger");
|
||||
const callBack = require("./callBack");
|
||||
@ -207,6 +208,11 @@ const imageStorageReadyPromise = imImageArchive.initializeStorage().catch((err)
|
||||
return null;
|
||||
});
|
||||
|
||||
const prescriptionPdfStorageReadyPromise = initializePrescriptionPdfStorage().catch((err) => {
|
||||
logger.error("处方 PDF 目录初始化失败:", err);
|
||||
return null;
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/zyt/stats",
|
||||
asyncHandler(async (req, res) => {
|
||||
@ -277,6 +283,24 @@ app.get(
|
||||
})
|
||||
);
|
||||
|
||||
// 服务端渲染处方笺。Chromium 路径由 PUPPETEER_EXECUTABLE_PATH 配置,避免应用镜像携带浏览器。
|
||||
app.get(
|
||||
"/hlw/prescriptions/:id/pdf",
|
||||
asyncHandler(async (req, res) => {
|
||||
const db = await getDatabase("Internet-hospital");
|
||||
const paperSize = req.query.paperSize === "A4" ? "A4" : "A5";
|
||||
const { pdfPath } = await getOrCreatePrescriptionPdf(db, req.params.id, { paperSize });
|
||||
res.set({
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `inline; filename=\"prescription-${req.params.id}.pdf\"`,
|
||||
"Cache-Control": "no-cache",
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
res.sendFile(pdfPath, (error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// POST请求处理器
|
||||
app.post(
|
||||
"/getYoucanData/member",
|
||||
|
||||
1229
package-lock.json
generated
1229
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -47,6 +47,7 @@
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"node-schedule": "^2.1.1",
|
||||
"puppeteer-core": "^24.0.0",
|
||||
"querystring": "^0.2.1",
|
||||
"sm-crypto": "^0.3.13",
|
||||
"web-streams-polyfill": "^4.1.0",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user