NAC_Blockchain/ops/nac-admin/server/reportGenerator.ts

287 lines
12 KiB
TypeScript
Raw Permalink 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.

/**
* NAC知识引擎 — PDF合规报告生成模块 (v16)
*
* 功能:
* 1. 按辖区/资产类型/语言筛选规则
* 2. 生成专业PDF报告NAC品牌头部、规则表格、多语言内容
* 3. 上传到S3并返回下载URL
*
* 技术pdfkit + S3存储
*/
import PDFDocument from "pdfkit";
import { getMongoDb, COLLECTIONS } from "./mongodb";
import { storagePut } from "./storage";
import path from "path";
import fs from "fs";
// ─── 类型定义 ──────────────────────────────────────────────────────
export interface ReportConfig {
jurisdiction?: string;
assetType?: string;
lang: string;
title: string;
includeDisabled?: boolean;
excludeJurisdictions?: string[];
}
// ─── 辖区全称映射 ─────────────────────────────────────────────────
const JURISDICTION_NAMES: Record<string, Record<string, string>> = {
CN: { zh: "中国大陆", en: "Mainland China", ar: "الصين", ja: "中国本土", ko: "중국 본토", fr: "Chine continentale", ru: "Материковый Китай" },
HK: { zh: "中国香港", en: "Hong Kong SAR", ar: "هونغ كونغ", ja: "香港", ko: "홍콩", fr: "Hong Kong", ru: "Гонконг" },
SG: { zh: "新加坡", en: "Singapore", ar: "سنغافورة", ja: "シンガポール", ko: "싱가포르", fr: "Singapour", ru: "Сингапур" },
US: { zh: "美国", en: "United States", ar: "الولايات المتحدة", ja: "アメリカ", ko: "미국", fr: "États-Unis", ru: "США" },
EU: { zh: "欧盟", en: "European Union", ar: "الاتحاد الأوروبي", ja: "欧州連合", ko: "유럽연합", fr: "Union européenne", ru: "Европейский союз" },
AE: { zh: "阿联酋", en: "United Arab Emirates", ar: "الإمارات العربية المتحدة", ja: "アラブ首長国連邦", ko: "아랍에미리트", fr: "Émirats arabes unis", ru: "ОАЭ" },
};
// ─── 语言标签映射 ─────────────────────────────────────────────────
const LANG_LABELS: Record<string, string> = {
zh: "中文(简体)", en: "English", ar: "العربية", ja: "日本語", ko: "한국어", fr: "Français", ru: "Русский",
};
// ─── 查找可用字体(支持中文) ──────────────────────────────────────
function findCJKFont(): string | null {
const candidates = [
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"/usr/share/fonts/truetype/arphic/uming.ttc",
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
];
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
return null;
}
// ─── 主PDF生成函数 ────────────────────────────────────────────────
export async function generateComplianceReport(config: ReportConfig): Promise<{
url: string;
filename: string;
ruleCount: number;
fileSize: number;
}> {
const db = await getMongoDb();
if (!db) throw new Error("数据库连接失败");
const lang = config.lang || "zh";
// ── 查询规则数据 ──────────────────────────────────────────────
const filter: Record<string, unknown> = {};
if (config.jurisdiction && config.jurisdiction !== "ALL") filter.jurisdiction = config.jurisdiction;
if (config.assetType && config.assetType !== "ALL") filter.assetType = config.assetType;
if (!config.includeDisabled) filter.status = "active";
const rules = await db.collection(COLLECTIONS.COMPLIANCE_RULES)
.find(filter)
.sort({ jurisdiction: 1, assetType: 1, createdAt: 1 })
.toArray() as any[];
if (rules.length === 0) throw new Error("没有找到符合条件的规则,请调整筛选条件");
// ── 生成PDF ──────────────────────────────────────────────────
const cjkFont = findCJKFont();
const doc = new PDFDocument({
size: "A4",
margins: { top: 60, bottom: 60, left: 60, right: 60 },
info: {
Title: config.title,
Author: "NAC NewAssetChain",
Subject: "Compliance Rules Report",
Creator: "NAC Knowledge Engine v16",
},
});
const chunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
// 注册CJK字体如果可用
if (cjkFont) {
try {
doc.registerFont("CJK", cjkFont);
} catch {
// 字体注册失败,使用默认字体
}
}
const useCJK = !!cjkFont;
const fontName = useCJK ? "CJK" : "Helvetica";
const fontBold = useCJK ? "CJK" : "Helvetica-Bold";
// ── 封面页 ────────────────────────────────────────────────────
// 顶部品牌色条
doc.rect(0, 0, doc.page.width, 8).fill("#1a56db");
// NAC Logo区域
doc.rect(60, 40, 480, 80).fill("#0f172a");
doc.font(fontBold).fontSize(22).fillColor("#60a5fa")
.text("NAC", 80, 58);
doc.font(fontName).fontSize(10).fillColor("#94a3b8")
.text("NewAssetChain — RWA Native Blockchain", 80, 85);
doc.font(fontBold).fontSize(14).fillColor("#ffffff")
.text("Knowledge Engine", 300, 65, { align: "right", width: 220 });
// 报告标题
doc.moveDown(3);
doc.font(fontBold).fontSize(20).fillColor("#1e293b")
.text(config.title, 60, 160, { align: "center", width: 480 });
// 报告元信息
const metaY = 210;
doc.font(fontName).fontSize(10).fillColor("#64748b");
const jurisdictionLabel = config.jurisdiction && config.jurisdiction !== "ALL"
? (JURISDICTION_NAMES[config.jurisdiction]?.[lang] || config.jurisdiction)
: (lang === "zh" ? "全部辖区" : "All Jurisdictions");
const assetTypeLabel = config.assetType && config.assetType !== "ALL"
? config.assetType
: (lang === "zh" ? "全部资产类型" : "All Asset Types");
const metaItems = [
{ label: lang === "zh" ? "辖区" : "Jurisdiction", value: jurisdictionLabel },
{ label: lang === "zh" ? "资产类型" : "Asset Type", value: assetTypeLabel },
{ label: lang === "zh" ? "显示语言" : "Language", value: LANG_LABELS[lang] || lang },
{ label: lang === "zh" ? "规则总数" : "Total Rules", value: `${rules.length}` },
{ label: lang === "zh" ? "生成时间" : "Generated", value: new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" }) },
];
metaItems.forEach((item, i) => {
const x = i % 2 === 0 ? 80 : 320;
const y = metaY + Math.floor(i / 2) * 22;
doc.font(fontBold).fontSize(9).fillColor("#475569").text(item.label + "", x, y);
doc.font(fontName).fontSize(9).fillColor("#1e293b").text(item.value, x + 60, y);
});
// 分隔线
doc.moveTo(60, metaY + 80).lineTo(540, metaY + 80).strokeColor("#e2e8f0").lineWidth(1).stroke();
// ── 目录(按辖区分组) ────────────────────────────────────────
const jurisdictions = Array.from(new Set(rules.map((r: any) => r.jurisdiction as string))).sort();
doc.addPage();
doc.rect(0, 0, doc.page.width, 8).fill("#1a56db");
doc.font(fontBold).fontSize(14).fillColor("#1e293b")
.text(lang === "zh" ? "目录" : "Table of Contents", 60, 40);
doc.moveTo(60, 60).lineTo(540, 60).strokeColor("#e2e8f0").lineWidth(0.5).stroke();
let tocY = 75;
jurisdictions.forEach((j, idx) => {
const jRules = rules.filter((r: any) => r.jurisdiction === j);
const jName = JURISDICTION_NAMES[j]?.[lang] || j;
doc.font(fontBold).fontSize(10).fillColor("#1a56db")
.text(`${idx + 1}. ${j}${jName}`, 60, tocY);
doc.font(fontName).fontSize(9).fillColor("#64748b")
.text(`${jRules.length} ${lang === "zh" ? "条规则" : "rules"}`, 480, tocY, { align: "right", width: 60 });
tocY += 20;
});
// ── 规则内容页(按辖区分组) ──────────────────────────────────
jurisdictions.forEach((jurisdiction) => {
const jRules = rules.filter((r: any) => r.jurisdiction === jurisdiction);
const jName = JURISDICTION_NAMES[jurisdiction]?.[lang] || jurisdiction;
doc.addPage();
doc.rect(0, 0, doc.page.width, 8).fill("#1a56db");
// 辖区标题
doc.rect(60, 30, 480, 36).fill("#0f172a");
doc.font(fontBold).fontSize(14).fillColor("#60a5fa")
.text(jurisdiction, 75, 40);
doc.font(fontName).fontSize(11).fillColor("#94a3b8")
.text(jName, 130, 43);
doc.font(fontName).fontSize(9).fillColor("#64748b")
.text(`${jRules.length} ${lang === "zh" ? "条规则" : "rules"}`, 480, 43, { align: "right", width: 60 });
let ruleY = 85;
jRules.forEach((rule: any, idx: number) => {
// 检查是否需要新页
if (ruleY > doc.page.height - 120) {
doc.addPage();
doc.rect(0, 0, doc.page.width, 8).fill("#1a56db");
ruleY = 30;
}
const ruleName = rule.ruleNameI18n?.[lang] || rule.ruleName;
const ruleDesc = rule.descriptionI18n?.[lang] || rule.description;
// 规则序号背景
doc.rect(60, ruleY, 480, 1).fill("#e2e8f0");
ruleY += 8;
// 规则编号 + 名称
doc.font(fontBold).fontSize(10).fillColor("#1a56db")
.text(`${idx + 1}.`, 60, ruleY, { width: 20 });
doc.font(fontBold).fontSize(10).fillColor("#1e293b")
.text(ruleName, 82, ruleY, { width: 400 });
// 资产类型 + 状态徽章
const badgeText = `${rule.assetType}${rule.required ? (lang === "zh" ? " · 强制" : " · Required") : ""}`;
doc.font(fontName).fontSize(8).fillColor("#64748b")
.text(badgeText, 82, ruleY + 14, { width: 200 });
ruleY += 28;
// 规则描述
const descHeight = Math.ceil(ruleDesc.length / 80) * 12 + 8;
if (ruleY + descHeight > doc.page.height - 80) {
doc.addPage();
doc.rect(0, 0, doc.page.width, 8).fill("#1a56db");
ruleY = 30;
}
doc.font(fontName).fontSize(9).fillColor("#374151")
.text(ruleDesc, 82, ruleY, { width: 440, lineGap: 2 });
ruleY = (doc as any).y + 12;
// 标签
if (rule.tags && rule.tags.length > 0) {
doc.font(fontName).fontSize(7.5).fillColor("#94a3b8")
.text(`${lang === "zh" ? "标签" : "Tags"}: ${rule.tags.join(", ")}`, 82, ruleY);
ruleY = (doc as any).y + 8;
}
});
});
// ── 页脚(每页) ─────────────────────────────────────────────
const pageCount = (doc as any).bufferedPageRange?.()?.count || 1;
for (let i = 0; i < pageCount; i++) {
doc.switchToPage(i);
doc.rect(0, doc.page.height - 30, doc.page.width, 30).fill("#f8fafc");
doc.font(fontName).fontSize(7.5).fillColor("#94a3b8")
.text(
`NAC NewAssetChain Knowledge Engine · ${config.title} · ${new Date().toLocaleDateString("zh-CN")} · Page ${i + 1}`,
60, doc.page.height - 18,
{ align: "center", width: 480 }
);
}
doc.end();
// 等待PDF生成完成
const pdfBuffer = await new Promise<Buffer>((resolve, reject) => {
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
});
// ── 上传到S3 ──────────────────────────────────────────────────
const timestamp = Date.now();
const safeTitle = config.title.replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, "-").slice(0, 30);
const filename = `nac-compliance-report-${safeTitle}-${timestamp}.pdf`;
const fileKey = `reports/${filename}`;
const { url } = await storagePut(fileKey, pdfBuffer, "application/pdf");
return {
url,
filename,
ruleCount: rules.length,
fileSize: pdfBuffer.length,
};
}