From 454e60dd7287e7b30c57fe2a8c3c23b046ece091 Mon Sep 17 00:00:00 2001 From: liangzai <2440983361@qq.com> Date: Wed, 11 Mar 2026 15:21:53 +0800 Subject: [PATCH] f --- Dockerfile | 18 +- go.mod | 7 + go.sum | 14 ++ .../http/handlers/qygl_report_handler.go | 59 ++++++ .../http/routes/qygl_report_routes.go | 3 + internal/shared/pdf/html_pdf_generator.go | 82 ++++++++ resources/qiye.html | 190 ++++++------------ 7 files changed, 240 insertions(+), 133 deletions(-) create mode 100644 internal/shared/pdf/html_pdf_generator.go diff --git a/Dockerfile b/Dockerfile index cc03d8a..0c113c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,12 +37,26 @@ FROM alpine:3.19 # 设置Alpine镜像源 RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories -# 安装必要的包 -RUN apk --no-cache add tzdata curl +# 安装必要的包(包含 headless Chrome 所需依赖) +# - tzdata: 时区 +# - curl: 健康检查 +# - chromium: 无头浏览器,用于 chromedp 生成 HTML 报告 PDF +# - nss、freetype、harfbuzz、ttf-freefont: 字体及渲染依赖,避免中文/图标丢失 +RUN apk --no-cache add \ + tzdata \ + curl \ + chromium \ + nss \ + freetype \ + harfbuzz \ + ttf-freefont # 设置时区 ENV TZ=Asia/Shanghai +# 为 chromedp 指定默认的 Chrome 路径(Alpine 下 chromium 包的可执行文件) +ENV CHROME_BIN=/usr/bin/chromium-browser + # 设置工作目录 WORKDIR /app diff --git a/go.mod b/go.mod index bfcc97f..f7f573f 100644 --- a/go.mod +++ b/go.mod @@ -58,6 +58,9 @@ require ( github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cenkalti/backoff/v5 v5.0.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chromedp/cdproto v0.0.0-20250319231242-a755498943c8 // indirect + github.com/chromedp/chromedp v0.13.2 // indirect + github.com/chromedp/sysutil v1.1.0 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/cloudwego/base64x v0.1.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -66,6 +69,7 @@ require ( github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gammazero/toposort v0.1.1 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-json-experiment/json v0.0.0-20250211171154-1ae217ad3535 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect @@ -73,6 +77,9 @@ require ( github.com/go-openapi/spec v0.20.4 // indirect github.com/go-openapi/swag v0.19.15 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/gofrs/flock v0.8.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect diff --git a/go.sum b/go.sum index 6769984..d031843 100644 --- a/go.sum +++ b/go.sum @@ -80,6 +80,12 @@ github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F9 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chromedp/cdproto v0.0.0-20250319231242-a755498943c8 h1:AqW2bDQf67Zbq6Tpop/+yJSIknxhiQecO2B8jNYTAPs= +github.com/chromedp/cdproto v0.0.0-20250319231242-a755498943c8/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= +github.com/chromedp/chromedp v0.13.2 h1:f6sZFFzCzPLvWSzeuXQBgONKG7zPq54YfEyEj0EplOY= +github.com/chromedp/chromedp v0.13.2/go.mod h1:khsDP9OP20GrowpJfZ7N05iGCwcAYxk7qf9AZBzR3Qw= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= @@ -115,6 +121,8 @@ github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fq github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-json-experiment/json v0.0.0-20250211171154-1ae217ad3535 h1:yE7argOs92u+sSCRgqqe6eF+cDaVhSPlioy1UkA0p/w= +github.com/go-json-experiment/json v0.0.0-20250211171154-1ae217ad3535/go.mod h1:BWmvoE1Xia34f3l/ibJweyhrT+aROb/FQ6d+37F0e2s= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -146,6 +154,12 @@ github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= diff --git a/internal/infrastructure/http/handlers/qygl_report_handler.go b/internal/infrastructure/http/handlers/qygl_report_handler.go index 177e2a3..c93af7e 100644 --- a/internal/infrastructure/http/handlers/qygl_report_handler.go +++ b/internal/infrastructure/http/handlers/qygl_report_handler.go @@ -2,8 +2,10 @@ package handlers import ( "encoding/json" + "fmt" "html/template" "net/http" + "net/url" "github.com/gin-gonic/gin" "go.uber.org/zap" @@ -14,6 +16,7 @@ import ( api_services "tyapi-server/internal/domains/api/services" "tyapi-server/internal/domains/api/services/processors" "tyapi-server/internal/domains/api/services/processors/qygl" + "tyapi-server/internal/shared/pdf" ) // QYGLReportHandler 企业全景报告页面渲染处理器 @@ -135,3 +138,59 @@ func (h *QYGLReportHandler) GetQYGLReportPageByID(c *gin.Context) { }) } +// GetQYGLReportPDFByID 通过编号导出企业全景报告 PDF(基于 headless Chrome 渲染 HTML) +// GET /reports/qygl/:id/pdf +func (h *QYGLReportHandler) GetQYGLReportPDFByID(c *gin.Context) { + id := c.Param("id") + if id == "" { + c.String(http.StatusBadRequest, "报告编号不能为空") + return + } + + // 可选:从数据库查一次,用于生成更友好的文件名 + var fileName = "企业全景报告.pdf" + if h.reportRepo != nil { + if entity, err := h.reportRepo.FindByReportID(c.Request.Context(), id); err == nil && entity != nil { + if entity.EntName != "" { + fileName = fmt.Sprintf("%s_企业全景报告.pdf", entity.EntName) + } + } + } + + // 根据当前请求推断访问协议(支持通过反向代理的 X-Forwarded-Proto) + scheme := "http" + if c.Request.TLS != nil { + scheme = "https" + } else if forwardedProto := c.Request.Header.Get("X-Forwarded-Proto"); forwardedProto != "" { + scheme = forwardedProto + } + + // 构建用于 headless 浏览器访问的完整报告页面 URL + reportURL := fmt.Sprintf("%s://%s/reports/qygl/%s", scheme, c.Request.Host, id) + + h.logger.Info("开始生成企业全景报告 PDF(headless Chrome)", + zap.String("report_id", id), + zap.String("url", reportURL), + ) + + pdfGen := pdf.NewHTMLPDFGenerator(h.logger) + pdfBytes, err := pdfGen.GenerateFromURL(c.Request.Context(), reportURL) + if err != nil { + h.logger.Error("生成企业全景报告 PDF 失败", zap.String("report_id", id), zap.Error(err)) + c.String(http.StatusInternalServerError, "生成企业报告 PDF 失败,请稍后重试") + return + } + + if len(pdfBytes) == 0 { + h.logger.Error("生成的企业全景报告 PDF 为空", zap.String("report_id", id)) + c.String(http.StatusInternalServerError, "生成的企业报告 PDF 为空,请稍后重试") + return + } + + encodedFileName := url.QueryEscape(fileName) + c.Header("Content-Type", "application/pdf") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", encodedFileName)) + c.Data(http.StatusOK, "application/pdf", pdfBytes) +} + + diff --git a/internal/infrastructure/http/routes/qygl_report_routes.go b/internal/infrastructure/http/routes/qygl_report_routes.go index fa4512f..90262b7 100644 --- a/internal/infrastructure/http/routes/qygl_report_routes.go +++ b/internal/infrastructure/http/routes/qygl_report_routes.go @@ -28,5 +28,8 @@ func (r *QYGLReportRoutes) Register(router *sharedhttp.GinRouter) { // 企业全景报告页面(通过编号查看) engine.GET("/reports/qygl/:id", r.handler.GetQYGLReportPageByID) + + // 企业全景报告 PDF 导出(通过编号) + engine.GET("/reports/qygl/:id/pdf", r.handler.GetQYGLReportPDFByID) } diff --git a/internal/shared/pdf/html_pdf_generator.go b/internal/shared/pdf/html_pdf_generator.go new file mode 100644 index 0000000..f9bb761 --- /dev/null +++ b/internal/shared/pdf/html_pdf_generator.go @@ -0,0 +1,82 @@ +package pdf + +import ( + "context" + "fmt" + "time" + + "github.com/chromedp/cdproto/page" + "github.com/chromedp/chromedp" + "go.uber.org/zap" +) + +// HTMLPDFGenerator 使用 headless Chrome 将 HTML 页面渲染为 PDF +type HTMLPDFGenerator struct { + logger *zap.Logger +} + +// NewHTMLPDFGenerator 创建 HTMLPDFGenerator +func NewHTMLPDFGenerator(logger *zap.Logger) *HTMLPDFGenerator { + if logger == nil { + logger = zap.NewNop() + } + return &HTMLPDFGenerator{ + logger: logger, + } +} + +// GenerateFromURL 使用 headless Chrome 打开指定 URL,并导出为 PDF 字节流 +// 这里固定使用 A4 纵向纸张,开启背景打印 +func (g *HTMLPDFGenerator) GenerateFromURL(ctx context.Context, url string) ([]byte, error) { + if ctx == nil { + ctx = context.Background() + } + + // 整个生成过程增加超时时间,避免长时间卡死 + timeoutCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + // 创建 Chrome 上下文(使用系统默认的 headless Chrome/Chromium) + chromeCtx, cancelChrome := chromedp.NewContext(timeoutCtx) + defer cancelChrome() + + var pdfBuf []byte + + tasks := chromedp.Tasks{ + chromedp.Navigate(url), + // 等待页面主体和报告容器就绪,确保数据渲染完成 + chromedp.WaitReady("body", chromedp.ByQuery), + chromedp.WaitVisible(".page", chromedp.ByQuery), + chromedp.ActionFunc(func(ctx context.Context) error { + g.logger.Info("开始通过 headless Chrome 生成企业报告 PDF", zap.String("url", url)) + var err error + pdfBuf, err = page.PrintToPDF(). + WithPrintBackground(true). + WithPaperWidth(8.27). // A4 宽度(英寸 -> 约 210mm) + WithPaperHeight(11.69). // A4 高度(英寸 -> 约 297mm) + WithMarginTop(0.4). + WithMarginBottom(0.4). + WithMarginLeft(0.4). + WithMarginRight(0.4). + Do(ctx) + return err + }), + } + + if err := chromedp.Run(chromeCtx, tasks); err != nil { + g.logger.Error("使用 headless Chrome 生成 HTML 报告 PDF 失败", zap.String("url", url), zap.Error(err)) + return nil, err + } + + if len(pdfBuf) == 0 { + return nil, fmt.Errorf("生成的 PDF 内容为空") + } + + g.logger.Info("通过 headless Chrome 生成企业报告 PDF 成功", + zap.String("url", url), + zap.Int("pdf_size", len(pdfBuf)), + ) + + return pdfBuf, nil +} + diff --git a/resources/qiye.html b/resources/qiye.html index a992c15..d5ea195 100644 --- a/resources/qiye.html +++ b/resources/qiye.html @@ -2185,16 +2185,11 @@ } loadReport(); - // 绑定「保存为 PDF」按钮:html2canvas 截取 .page → 转 JPEG → jsPDF 分页生成 PDF(按钮在 .page 外固定悬浮,不会进 PDF) + // 绑定「保存为 PDF」按钮:调用后端 /reports/qygl/:id/pdf 接口,由服务器端 headless Chrome 生成高质量 PDF var saveBtn = document.getElementById("btnSavePdf"); var loadingOverlay = document.getElementById("pdfLoadingOverlay"); - if (saveBtn && window.html2canvas && window.jspdf && window.jspdf.jsPDF) { + if (saveBtn) { saveBtn.addEventListener("click", function () { - var pageEl = document.querySelector(".page"); - if (!pageEl) { - console.error("未找到 .page 容器"); - return; - } var btnText = saveBtn.textContent; saveBtn.disabled = true; saveBtn.textContent = "生成中..."; @@ -2208,135 +2203,68 @@ loadingOverlay.setAttribute("aria-hidden", "true"); } } - // 等待浏览器完成布局与绘制,再截取,避免截到白屏 - function doCapture() { - requestAnimationFrame(function () { - requestAnimationFrame(function () { - try { - (function () { - var scale = 1.25; - var pdf = new jspdf.jsPDF("p", "mm", "a4"); - var pageWidthMm = pdf.internal.pageSize.getWidth(); - var pageHeightMm = pdf.internal.pageSize.getHeight(); - // 以元素宽度为基准,计算每一页对应的像素高度(避免生成超长大图导致 jsPDF 白页) - var targetWidthPx = Math.max( - 1, - Math.floor(pageEl.scrollWidth), - ); - var pageHeightPx = Math.max( - 1, - Math.floor((targetWidthPx * pageHeightMm) / pageWidthMm), - ); - var totalHeightPx = Math.max( - 1, - Math.floor(pageEl.scrollHeight), - ); + try { + // 从当前 URL 中解析报告编号,路径形如 /reports/qygl/:id + var path = window.location.pathname || ""; + var match = path.match(/\\/reports\\/qygl\\/([^/]+)/); + if (!match || !match[1]) { + console.error("无法从当前 URL 解析报告编号,路径为", path); + restoreBtn(); + return; + } + var reportId = match[1]; + var pdfUrl = "/reports/qygl/" + encodeURIComponent(reportId) + "/pdf"; - var offsetY = 0; - var pageIndex = 0; - - function renderSlice() { - var sliceHeight = Math.min( - pageHeightPx, - totalHeightPx - offsetY, - ); - if (sliceHeight <= 0) { - // 结束,保存 - var fileName = "企业全景报告.pdf"; - if ( - reportData && - reportData.entName && - typeof reportData.entName === "string" - ) { - fileName = - reportData.entName + - "_企业全景报告.pdf"; - } - pdf.save(fileName); - restoreBtn(); - return; - } - - html2canvas(pageEl, { - scale: scale, - useCORS: true, - allowTaint: false, - backgroundColor: "#ffffff", - x: 0, - y: offsetY, - width: targetWidthPx, - height: sliceHeight, - scrollX: 0, - scrollY: 0, - windowWidth: targetWidthPx, - windowHeight: sliceHeight, - onclone: function (clonedDoc) { - var body = clonedDoc.body; - if (body) { - body.style.backgroundColor = "#ffffff"; - body.style.overflow = "visible"; - } - var clonePage = clonedDoc.querySelector(".page"); - if (clonePage) { - clonePage.style.overflow = "visible"; - clonePage.style.backgroundColor = "#ffffff"; - } - }, - }) - .then(function (canvas) { - if (!canvas.width || !canvas.height) { - console.error("分片截图为空,宽或高为 0", { - offsetY: offsetY, - sliceHeight: sliceHeight, - }); - restoreBtn(); - return; - } - // 调试:输出每页分片信息 - console.info("PDF分片截图信息", { - page: pageIndex + 1, - w: canvas.width, - h: canvas.height, - offsetY: offsetY, - sliceHeight: sliceHeight, - totalHeightPx: totalHeightPx, - }); - - var imgData = canvas.toDataURL("image/jpeg", 0.9); - - if (pageIndex > 0) pdf.addPage(); - pdf.addImage( - imgData, - "JPEG", - 0, - 0, - pageWidthMm, - pageHeightMm, - ); - - pageIndex += 1; - offsetY += sliceHeight; - - // 给 UI 一点喘息,避免长任务卡死 - setTimeout(renderSlice, 0); - }) - .catch(function (e) { - console.error("生成 PDF 失败(分片截图阶段)", e); - restoreBtn(); - }); - } - - renderSlice(); - })(); - } catch (e) { - console.error("触发生成 PDF 失败", e); - restoreBtn(); + // 通过 fetch 获取 PDF 二进制并触发下载 + fetch(pdfUrl, { + method: "GET", + }) + .then(function (resp) { + if (!resp.ok) { + throw new Error("生成 PDF 接口返回错误状态:" + resp.status); } + var contentType = resp.headers.get("Content-Type") || ""; + if ( + contentType.indexOf("application/pdf") === -1 && + contentType.indexOf("application/octet-stream") === -1 + ) { + // 可能返回的是错误字符串 + return resp.text().then(function (txt) { + throw new Error("生成 PDF 失败:" + txt); + }); + } + return resp.blob(); + }) + .then(function (blob) { + var fileName = "企业全景报告.pdf"; + if ( + reportData && + reportData.entName && + typeof reportData.entName === "string" + ) { + fileName = + reportData.entName + "_企业全景报告.pdf"; + } + var url = window.URL.createObjectURL(blob); + var a = document.createElement("a"); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + restoreBtn(); + }) + .catch(function (e) { + console.error("生成 PDF 失败(后端接口)", e); + restoreBtn(); + alert("生成 PDF 失败,请稍后重试"); }); - }); + } catch (e) { + console.error("触发生成 PDF 失败", e); + restoreBtn(); } - doCapture(); }); } })();