package handlers import ( "context" "encoding/json" "fmt" "html/template" "net/http" "net/url" "time" "github.com/gin-gonic/gin" "go.uber.org/zap" "tyapi-server/internal/application/api/commands" "tyapi-server/internal/domains/api/dto" api_repositories "tyapi-server/internal/domains/api/repositories" 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 企业全景报告页面渲染处理器 // 使用 QYGLJ1U9 聚合接口生成企业报告数据,并通过模板引擎渲染 qiye.html type QYGLReportHandler struct { apiRequestService *api_services.ApiRequestService logger *zap.Logger reportRepo api_repositories.ReportRepository pdfCacheManager *pdf.PDFCacheManager qyglPDFPregen *pdf.QYGLReportPDFPregen } // NewQYGLReportHandler 创建企业报告页面处理器 func NewQYGLReportHandler( apiRequestService *api_services.ApiRequestService, logger *zap.Logger, reportRepo api_repositories.ReportRepository, pdfCacheManager *pdf.PDFCacheManager, qyglPDFPregen *pdf.QYGLReportPDFPregen, ) *QYGLReportHandler { return &QYGLReportHandler{ apiRequestService: apiRequestService, logger: logger, reportRepo: reportRepo, pdfCacheManager: pdfCacheManager, qyglPDFPregen: qyglPDFPregen, } } // GetQYGLReportPage 企业全景报告页面 // GET /reports/qygl?ent_code=xxx&ent_name=yyy&ent_reg_no=zzz func (h *QYGLReportHandler) GetQYGLReportPage(c *gin.Context) { // 读取查询参数 entCode := c.Query("ent_code") entName := c.Query("ent_name") entRegNo := c.Query("ent_reg_no") // 组装 QYGLUY3S 入参 req := dto.QYGLUY3SReq{ EntName: entName, EntRegno: entRegNo, EntCode: entCode, } params, err := json.Marshal(req) if err != nil { h.logger.Error("序列化企业全景报告入参失败", zap.Error(err)) c.String(http.StatusInternalServerError, "生成企业报告失败,请稍后重试") return } // 通过 ApiRequestService 调用 QYGLJ1U9 聚合处理器 options := &commands.ApiCallOptions{} callCtx := &processors.CallContext{} ctx := c.Request.Context() respBytes, err := h.apiRequestService.PreprocessRequestApi(ctx, "QYGLJ1U9", params, options, callCtx) if err != nil { h.logger.Error("调用企业全景报告处理器失败", zap.Error(err)) c.String(http.StatusInternalServerError, "生成企业报告失败,请稍后重试") return } var report map[string]interface{} if err := json.Unmarshal(respBytes, &report); err != nil { h.logger.Error("解析企业全景报告结果失败", zap.Error(err)) c.String(http.StatusInternalServerError, "生成企业报告失败,请稍后重试") return } reportJSONBytes, err := json.Marshal(report) if err != nil { h.logger.Error("序列化企业全景报告结果失败", zap.Error(err)) c.String(http.StatusInternalServerError, "生成企业报告失败,请稍后重试") return } // 使用 template.JS 避免在脚本中被转义,直接作为 JS 对象字面量注入 reportJSON := template.JS(reportJSONBytes) c.HTML(http.StatusOK, "qiye.html", gin.H{ "ReportJSON": reportJSON, }) } // GetQYGLReportPageByID 通过编号查看企业全景报告页面 // GET /reports/qygl/:id func (h *QYGLReportHandler) GetQYGLReportPageByID(c *gin.Context) { id := c.Param("id") if id == "" { c.String(http.StatusBadRequest, "报告编号不能为空") return } // 优先从数据库中查询报告记录 if h.reportRepo != nil { if entity, err := h.reportRepo.FindByReportID(c.Request.Context(), id); err == nil && entity != nil { h.maybeScheduleQYGLPDFPregen(c.Request.Context(), id) reportJSON := template.JS(entity.ReportData) c.HTML(http.StatusOK, "qiye.html", gin.H{ "ReportJSON": reportJSON, }) return } } // 回退到进程内存缓存(兼容老的访问方式) report, ok := qygl.GetQYGLReport(id) if !ok { c.String(http.StatusNotFound, "报告不存在或已过期") return } reportJSONBytes, err := json.Marshal(report) if err != nil { h.logger.Error("序列化企业全景报告结果失败", zap.Error(err)) c.String(http.StatusInternalServerError, "生成企业报告失败,请稍后再试") return } reportJSON := template.JS(reportJSONBytes) h.maybeScheduleQYGLPDFPregen(c.Request.Context(), id) c.HTML(http.StatusOK, "qiye.html", gin.H{ "ReportJSON": reportJSON, }) } // qyglReportExists 报告是否仍在库或本进程内存中(用于决定是否补开预生成) func (h *QYGLReportHandler) qyglReportExists(ctx context.Context, id string) bool { if h.reportRepo != nil { if e, err := h.reportRepo.FindByReportID(ctx, id); err == nil && e != nil { return true } } _, ok := qygl.GetQYGLReport(id) return ok } // maybeScheduleQYGLPDFPregen 在已配置公网基址时异步预生成 PDF;Schedule 内部会去重。 // 解决:服务重启 / 多实例后内存队列为空,用户打开报告页或轮询状态时仍应能启动预生成。 func (h *QYGLReportHandler) maybeScheduleQYGLPDFPregen(ctx context.Context, id string) { if id == "" || h.qyglPDFPregen == nil || !h.qyglPDFPregen.Enabled() { return } if !h.qyglReportExists(ctx, id) { return } h.qyglPDFPregen.ScheduleQYGLReportPDF(context.Background(), id) } // GetQYGLReportPDFStatusByID 查询企业报告 PDF 预生成状态(供前端轮询) // GET /reports/qygl/:id/pdf/status func (h *QYGLReportHandler) GetQYGLReportPDFStatusByID(c *gin.Context) { id := c.Param("id") if id == "" { c.JSON(http.StatusBadRequest, gin.H{"status": "none", "message": "报告编号不能为空"}) return } if h.pdfCacheManager != nil { if b, hit, _, err := h.pdfCacheManager.GetByReportID(id); hit && err == nil && len(b) > 0 { c.JSON(http.StatusOK, gin.H{"status": string(pdf.QYGLReportPDFStatusReady), "message": ""}) return } } if h.qyglPDFPregen == nil || !h.qyglPDFPregen.Enabled() { c.JSON(http.StatusOK, gin.H{"status": string(pdf.QYGLReportPDFStatusNone), "message": "未启用预生成,将在下载时现场生成"}) return } st, msg := h.qyglPDFPregen.Status(id) if st == pdf.QYGLReportPDFStatusNone && h.qyglReportExists(c.Request.Context(), id) { h.qyglPDFPregen.ScheduleQYGLReportPDF(context.Background(), id) st, msg = h.qyglPDFPregen.Status(id) } c.JSON(http.StatusOK, gin.H{"status": string(st), "message": userFacingPDFStatusMessage(st, msg)}) } func userFacingPDFStatusMessage(st pdf.QYGLReportPDFStatus, raw string) string { switch st { case pdf.QYGLReportPDFStatusPending: return "排队生成中" case pdf.QYGLReportPDFStatusGenerating: return "正在生成 PDF" case pdf.QYGLReportPDFStatusFailed: if raw != "" { return raw } return "预生成失败,下载时将重新生成" case pdf.QYGLReportPDFStatusReady: return "" case pdf.QYGLReportPDFStatusNone: return "尚未开始预生成" default: return "" } } // GetQYGLReportPDFByID 通过编号导出企业全景报告 PDF:优先读缓存;可短时等待预生成;否则 headless 现场生成并写入缓存 // 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) } } } var pdfBytes []byte if h.pdfCacheManager != nil { if b, hit, _, err := h.pdfCacheManager.GetByReportID(id); hit && err == nil && len(b) > 0 { pdfBytes = b } } // 缓存未命中时:若正在预生成,短时等待(与前端轮询互补) if len(pdfBytes) == 0 && h.qyglPDFPregen != nil && h.qyglPDFPregen.Enabled() && h.pdfCacheManager != nil { deadline := time.Now().Add(90 * time.Second) for time.Now().Before(deadline) { st, _ := h.qyglPDFPregen.Status(id) if st == pdf.QYGLReportPDFStatusFailed { break } if b, hit, _, err := h.pdfCacheManager.GetByReportID(id); hit && err == nil && len(b) > 0 { pdfBytes = b break } time.Sleep(400 * time.Millisecond) } } if len(pdfBytes) == 0 { scheme := "http" if c.Request.TLS != nil { scheme = "https" } else if forwardedProto := c.Request.Header.Get("X-Forwarded-Proto"); forwardedProto != "" { scheme = forwardedProto } 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) var err error 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 } if h.pdfCacheManager != nil { _ = h.pdfCacheManager.SetByReportID(id, pdfBytes) } if h.qyglPDFPregen != nil { h.qyglPDFPregen.MarkReadyAfterUpload(id) } } 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) }