This commit is contained in:
Mrx
2026-03-21 19:17:19 +08:00
20 changed files with 60710 additions and 436 deletions

View File

@@ -1,6 +1,8 @@
package config
import (
"os"
"strings"
"time"
)
@@ -200,6 +202,37 @@ type AppConfig struct {
// APIConfig API配置
type APIConfig struct {
Domain string `mapstructure:"domain"`
// PublicBaseURL 浏览器/第三方访问本 API 服务的完整基址(如 https://api.example.com 或 http://127.0.0.1:8080无尾斜杠。
// 用于企业全景报告 reportUrl、headless PDF 预生成等。为空时由 Domain 推导为 https://{Domain}Domain 若已含 scheme 则沿用)。
PublicBaseURL string `mapstructure:"public_base_url"`
}
// ResolvedPublicBaseURL 由配置推导对外基址(不读环境变量)。
func (c *APIConfig) ResolvedPublicBaseURL() string {
u := strings.TrimSpace(c.PublicBaseURL)
if u != "" {
return strings.TrimRight(u, "/")
}
d := strings.TrimSpace(c.Domain)
if d == "" {
return ""
}
lo := strings.ToLower(d)
if strings.HasPrefix(lo, "http://") || strings.HasPrefix(lo, "https://") {
return strings.TrimRight(d, "/")
}
return "https://" + strings.TrimRight(d, "/")
}
// ResolveAPIPublicBaseURL 对外 API 基址。优先环境变量 API_PUBLIC_BASE_URL否则使用 API 配置。
func ResolveAPIPublicBaseURL(cfg *APIConfig) string {
if s := strings.TrimSpace(os.Getenv("API_PUBLIC_BASE_URL")); s != "" {
return strings.TrimRight(s, "/")
}
if cfg == nil {
return ""
}
return cfg.ResolvedPublicBaseURL()
}
// SMSConfig 短信配置

View File

@@ -88,6 +88,7 @@ import (
api_app "tyapi-server/internal/application/api"
domain_api_repo "tyapi-server/internal/domains/api/repositories"
api_services "tyapi-server/internal/domains/api/services"
api_processors "tyapi-server/internal/domains/api/services/processors"
finance_services "tyapi-server/internal/domains/finance/services"
product_services "tyapi-server/internal/domains/product/services"
domain_statistics_repo "tyapi-server/internal/domains/statistics/repositories"
@@ -1207,6 +1208,18 @@ func NewContainer() *Container {
return cacheManager, nil
},
),
// 企业全景报告 PDF 异步预生成(依赖 PDF 缓存目录与公网可访问基址)
// 同时以 processors.QYGLReportPDFScheduler 注入 ApiRequestService
fx.Provide(
fx.Annotate(
func(cfg *config.Config, logger *zap.Logger, cache *pdf.PDFCacheManager) *pdf.QYGLReportPDFPregen {
base := config.ResolveAPIPublicBaseURL(&cfg.API)
return pdf.NewQYGLReportPDFPregen(logger, cache, base)
},
fx.As(new(api_processors.QYGLReportPDFScheduler)),
fx.As(fx.Self()), // 同时保留 *pdf.QYGLReportPDFPregen供 QYGLReportHandler 等注入
),
),
// 本地文件存储服务
fx.Provide(
func(logger *zap.Logger) *storage.LocalFileStorageService {

View File

@@ -6,7 +6,7 @@ import (
"fmt"
"tyapi-server/internal/application/api/commands"
"tyapi-server/internal/config"
appconfig "tyapi-server/internal/config"
api_repositories "tyapi-server/internal/domains/api/repositories"
"tyapi-server/internal/domains/api/services/processors"
"tyapi-server/internal/domains/api/services/processors/comb"
@@ -50,7 +50,7 @@ type ApiRequestService struct {
validator interfaces.RequestValidator
processorDeps *processors.ProcessorDependencies
combService *comb.CombService
config *config.Config
config *appconfig.Config
reportRepo api_repositories.ReportRepository
}
@@ -68,7 +68,7 @@ func NewApiRequestService(
shumaiService *shumai.ShumaiService,
validator interfaces.RequestValidator,
productManagementService *services.ProductManagementService,
cfg *config.Config,
cfg *appconfig.Config,
) *ApiRequestService {
return NewApiRequestServiceWithRepos(
westDexService,
@@ -85,6 +85,7 @@ func NewApiRequestService(
productManagementService,
cfg,
nil,
nil,
)
}
@@ -102,12 +103,18 @@ func NewApiRequestServiceWithRepos(
shumaiService *shumai.ShumaiService,
validator interfaces.RequestValidator,
productManagementService *services.ProductManagementService,
cfg *config.Config,
cfg *appconfig.Config,
reportRepo api_repositories.ReportRepository,
qyglReportPDFScheduler processors.QYGLReportPDFScheduler,
) *ApiRequestService {
// 创建组合包服务
combService := comb.NewCombService(productManagementService)
apiPublicBase := ""
if cfg != nil {
apiPublicBase = appconfig.ResolveAPIPublicBaseURL(&cfg.API)
}
// 创建处理器依赖容器
processorDeps := processors.NewProcessorDependencies(
westDexService,
@@ -123,6 +130,8 @@ func NewApiRequestServiceWithRepos(
validator,
combService,
reportRepo,
qyglReportPDFScheduler,
apiPublicBase,
)
// 统一注册所有处理器

View File

@@ -8,10 +8,10 @@ import (
"tyapi-server/internal/infrastructure/external/alicloud"
"tyapi-server/internal/infrastructure/external/jiguang"
"tyapi-server/internal/infrastructure/external/muzi"
"tyapi-server/internal/infrastructure/external/shujubao"
"tyapi-server/internal/infrastructure/external/shumai"
"tyapi-server/internal/infrastructure/external/tianyancha"
"tyapi-server/internal/infrastructure/external/westdex"
"tyapi-server/internal/infrastructure/external/shujubao"
"tyapi-server/internal/infrastructure/external/xingwei"
"tyapi-server/internal/infrastructure/external/yushan"
"tyapi-server/internal/infrastructure/external/zhicha"
@@ -47,6 +47,12 @@ type ProcessorDependencies struct {
// 企业报告记录仓储,用于持久化 QYGLJ1U9 生成的企业报告
ReportRepo repositories.ReportRepository
// 企业报告 PDF 异步预生成(可为 nil
ReportPDFScheduler QYGLReportPDFScheduler
// APIPublicBaseURL 对外 API 根地址(无尾斜杠),用于 QYGL reportUrl 等
APIPublicBaseURL string
}
// NewProcessorDependencies 创建处理器依赖容器
@@ -64,23 +70,27 @@ func NewProcessorDependencies(
validator interfaces.RequestValidator,
combService CombServiceInterface, // Changed to interface
reportRepo repositories.ReportRepository,
reportPDFScheduler QYGLReportPDFScheduler,
apiPublicBaseURL string,
) *ProcessorDependencies {
return &ProcessorDependencies{
WestDexService: westDexService,
ShujubaoService: shujubaoService,
MuziService: muziService,
YushanService: yushanService,
TianYanChaService: tianYanChaService,
AlicloudService: alicloudService,
ZhichaService: zhichaService,
XingweiService: xingweiService,
JiguangService: jiguangService,
ShumaiService: shumaiService,
Validator: validator,
CombService: combService,
Options: nil, // 初始化为nil在调用时设置
CallContext: nil, // 初始化为nil在调用时设置
ReportRepo: reportRepo,
WestDexService: westDexService,
ShujubaoService: shujubaoService,
MuziService: muziService,
YushanService: yushanService,
TianYanChaService: tianYanChaService,
AlicloudService: alicloudService,
ZhichaService: zhichaService,
XingweiService: xingweiService,
JiguangService: jiguangService,
ShumaiService: shumaiService,
Validator: validator,
CombService: combService,
Options: nil, // 初始化为nil在调用时设置
CallContext: nil, // 初始化为nil在调用时设置
ReportRepo: reportRepo,
ReportPDFScheduler: reportPDFScheduler,
APIPublicBaseURL: apiPublicBaseURL,
}
}

View File

@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"net/url"
"strings"
"sync"
"time"
@@ -17,7 +18,8 @@ import (
)
// ProcessQYGLJ1U9Request 企业全景报告处理器:并发调用企业全量(QYGLUY3S)、股权全景(QYGLJ0Q1)、司法涉诉(QYGL5S1I)
// 然后复用 qyglj1u9_processor_build.go 中的 buildReport / map* 逻辑生成企业报告结构
// 以及企业年报(QYGLDJ12)、税收违法(QYGL8848)、欠税公告(QYGL7D9A)
// 前三者走 buildReport / map*;后三者在 buildReport 中转为 annualReports、taxViolations、ownTaxNotices 供页面展示。
func ProcessQYGLJ1U9Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
// 复用 QYGLUY3S 的入参结构:企业名称/注册号/统一社会信用代码
var p dto.QYGLJ1U9Req
@@ -34,7 +36,7 @@ func ProcessQYGLJ1U9Request(ctx context.Context, params []byte, deps *processors
data map[string]interface{}
err error
}
resultsCh := make(chan apiResult, 3)
resultsCh := make(chan apiResult, 6)
var wg sync.WaitGroup
call := func(key string, req interface{}, fn func(context.Context, []byte, *processors.ProcessorDependencies) ([]byte, error)) {
@@ -52,8 +54,15 @@ func ProcessQYGLJ1U9Request(ctx context.Context, params []byte, deps *processors
return
}
var m map[string]interface{}
if err := json.Unmarshal(resp, &m); err != nil {
resultsCh <- apiResult{key: key, err: err}
var uerr error
// 根节点可能是数组或非对象,与欠税接口一致用宽松解析
if key == "taxArrears" || key == "annualReport" || key == "taxViolation" {
m, uerr = unmarshalToReportMap(resp)
} else {
uerr = json.Unmarshal(resp, &m)
}
if uerr != nil {
resultsCh <- apiResult{key: key, err: uerr}
return
}
resultsCh <- apiResult{key: key, data: m}
@@ -62,8 +71,8 @@ func ProcessQYGLJ1U9Request(ctx context.Context, params []byte, deps *processors
// 企业全量信息核验V2QYGLUY3S
call("jiguangFull", map[string]interface{}{
"ent_name": p.EntName,
"ent_code": p.EntCode,
"ent_name": p.EntName,
"ent_code": p.EntCode,
}, ProcessQYGLUY3SRequest)
// 企业股权结构全景QYGLJ0Q1
@@ -77,10 +86,30 @@ func ProcessQYGLJ1U9Request(ctx context.Context, params []byte, deps *processors
"ent_code": p.EntCode,
}, ProcessQYGL5S1IRequest)
// 企业年报信息核验QYGLDJ12
call("annualReport", map[string]interface{}{
"ent_name": p.EntName,
"ent_code": p.EntCode,
}, ProcessQYGLDJ12Request)
// 企业税收违法核查QYGL8848
call("taxViolation", map[string]interface{}{
"ent_name": p.EntName,
"ent_code": p.EntCode,
}, ProcessQYGL8848Request)
// 欠税公告QYGL7D9A天眼查 OwnTaxkeyword 为统一社会信用代码)
call("taxArrears", map[string]interface{}{
"ent_code": p.EntCode,
"page_size": 20,
"page_num": 1,
}, ProcessQYGL7D9ARequest)
wg.Wait()
close(resultsCh)
var jiguang, judicial, equity map[string]interface{}
var annualReport, taxViolation, taxArrears map[string]interface{}
for r := range resultsCh {
if r.err != nil {
// 任一关键数据源异常,则返回系统错误(也可以根据需求做降级)
@@ -93,6 +122,12 @@ func ProcessQYGLJ1U9Request(ctx context.Context, params []byte, deps *processors
judicial = r.data
case "equityPanorama":
equity = r.data
case "annualReport":
annualReport = r.data
case "taxViolation":
taxViolation = r.data
case "taxArrears":
taxArrears = r.data
}
}
if jiguang == nil {
@@ -104,14 +139,28 @@ func ProcessQYGLJ1U9Request(ctx context.Context, params []byte, deps *processors
if equity == nil {
equity = map[string]interface{}{}
}
if annualReport == nil {
annualReport = map[string]interface{}{}
}
if taxViolation == nil {
taxViolation = map[string]interface{}{}
}
if taxArrears == nil {
taxArrears = map[string]interface{}{}
}
// 复用构建逻辑生成企业报告结构
report := buildReport(jiguang, judicial, equity)
// 复用构建逻辑生成企业报告结构(含年报 / 税收违法 / 欠税公告的转化结果)
report := buildReport(jiguang, judicial, equity, annualReport, taxViolation, taxArrears)
// 为报告生成唯一编号并缓存,供后续通过编号查看
reportID := saveQYGLReport(report)
report["reportId"] = reportID
// 异步预生成 PDF写入磁盘缓存用户点击「保存为 PDF」时可直读缓存
if deps.ReportPDFScheduler != nil {
deps.ReportPDFScheduler.ScheduleQYGLReportPDF(context.Background(), reportID)
}
// 持久化企业报告记录到数据库(忽略持久化失败,不影响接口主流程)
if deps.ReportRepo != nil {
reqJSON, _ := json.Marshal(p)
@@ -127,7 +176,7 @@ func ProcessQYGLJ1U9Request(ctx context.Context, params []byte, deps *processors
})
}
// 为报告补充前端查看链接,供调用方直接跳转到企业报告页面(通过编号访问)
report["reportUrl"] = buildQYGLReportURLByID(reportID)
report["reportUrl"] = buildQYGLReportURLByID(deps.APIPublicBaseURL, reportID)
out, err := json.Marshal(report)
if err != nil {
@@ -136,6 +185,18 @@ func ProcessQYGLJ1U9Request(ctx context.Context, params []byte, deps *processors
return out, nil
}
// unmarshalToReportMap 将 JSON 解析为报告用 map根节点非对象时包在 data 下(兼容欠税等接口根为数组的情况)。
func unmarshalToReportMap(b []byte) (map[string]interface{}, error) {
var raw interface{}
if err := json.Unmarshal(b, &raw); err != nil {
return nil, err
}
if m, ok := raw.(map[string]interface{}); ok {
return m, nil
}
return map[string]interface{}{"data": raw}, nil
}
// 内存中的企业报告缓存(简单实现,进程重启后清空)
var qyglReportStore = struct {
sync.RWMutex
@@ -171,8 +232,12 @@ func generateQYGLReportID() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
// buildQYGLReportURLByID 构造企业报告前端查看链接(通过编号查看)
func buildQYGLReportURLByID(id string) string {
return "https://api.tianyuanapi.com/reports/qygl/" + url.PathEscape(id)
// buildQYGLReportURLByID 构造企业报告前端查看链接(通过编号查看)
// publicBase 为对外 API 基址(如 https://api.example.com空则返回站内相对路径。
func buildQYGLReportURLByID(publicBase, id string) string {
path := "/reports/qygl/" + url.PathEscape(id)
if publicBase == "" {
return path
}
return strings.TrimRight(publicBase, "/") + path
}

View File

@@ -8,10 +8,17 @@ import (
"time"
)
func buildReport(jiguang, judicial, equity map[string]interface{}) map[string]interface{} {
func buildReport(jiguang, judicial, equity, annualRaw, taxViolationRaw, taxArrearsRaw map[string]interface{}) map[string]interface{} {
report := make(map[string]interface{})
report["reportTime"] = time.Now().Format("2006-01-02 15:04:05")
// 先转化年报接口数据;若有内容则以 QYGLDJ12 为准,不再使用全量 V2 中 YEARREPORT* 表(避免与「企业年报」板块重复)
annualReports := mapAnnualReports(annualRaw)
jgNoYearReport := jiguang
if len(annualReports) > 0 {
jgNoYearReport = jiguangWithoutYearReportTables(jiguang)
}
basic := mapFromBASIC(jiguang)
report["creditCode"] = str(basic["creditCode"])
report["entName"] = str(basic["entName"])
@@ -20,21 +27,22 @@ func buildReport(jiguang, judicial, equity map[string]interface{}) map[string]in
report["branches"] = mapBranches(jiguang)
// 股权/实控人/受益人/对外投资:有股权全景时以其为准,否则用全量信息
if len(equity) > 0 {
report["shareholding"] = mapShareholdingWithEquity(jiguang, equity)
report["shareholding"] = mapShareholdingWithEquity(jgNoYearReport, equity)
report["controller"] = mapControllerFromEquity(equity)
report["beneficiaries"] = mapBeneficiariesFromEquity(equity)
report["investments"] = mapInvestmentsWithEquity(jiguang, equity)
} else {
report["shareholding"] = mapShareholding(jiguang)
report["shareholding"] = mapShareholding(jgNoYearReport)
report["controller"] = mapController(jiguang)
report["beneficiaries"] = mapBeneficiaries()
report["investments"] = mapInvestments(jiguang)
}
report["guarantees"] = mapGuarantees(jiguang)
report["management"] = mapManagement(jiguang)
report["assets"] = mapAssets(jiguang)
// 以下块在全量 V2 中依赖年报类表;接入 DJ12 后改从 jgNoYearReport 读取(已剔除 YEARREPORT*
report["guarantees"] = mapGuarantees(jgNoYearReport)
report["management"] = mapManagement(jgNoYearReport)
report["assets"] = mapAssets(jgNoYearReport)
report["licenses"] = mapLicenses(jiguang)
report["activities"] = mapActivities(jiguang)
report["activities"] = mapActivities(jgNoYearReport)
report["inspections"] = mapInspections(jiguang)
risks := mapRisks(jiguang, judicial)
report["risks"] = risks
@@ -45,9 +53,215 @@ func buildReport(jiguang, judicial, equity map[string]interface{}) map[string]in
if bl, _ := jiguang["BASICLIST"].([]interface{}); len(bl) > 0 {
report["basicList"] = bl
}
// QYGLDJ12 企业年报 / QYGL8848 税收违法 / QYGL7D9A 欠税公告(转化后的前端友好结构)
report["annualReports"] = annualReports
report["taxViolations"] = mapTaxViolations(taxViolationRaw)
report["ownTaxNotices"] = mapOwnTaxNotices(taxArrearsRaw)
return report
}
// jiguangWithoutYearReportTables 浅拷贝全量 map并去掉企业全量 V2 中与「公示年报」对应的 YEARREPORT* 键。
// 在已接入 QYGLDJ12 且年报列表非空时使用,避免 build 与 HTML 中与「十六、企业年报」重复展示。
func jiguangWithoutYearReportTables(jiguang map[string]interface{}) map[string]interface{} {
if len(jiguang) == 0 {
return map[string]interface{}{}
}
strip := map[string]struct{}{
"YEARREPORTFORGUARANTEE": {},
"YEARREPORTPAIDUPCAPITAL": {},
"YEARREPORTSUBCAPITAL": {},
"YEARREPORTBASIC": {},
"YEARREPORTSOCSEC": {},
"YEARREPORTANASSETSINFO": {},
"YEARREPORTWEBSITEINFO": {},
}
out := make(map[string]interface{}, len(jiguang))
for k, v := range jiguang {
if _, drop := strip[k]; drop {
continue
}
out[k] = v
}
return out
}
// BuildReportFromRawSources 供开发/测试:将各处理器原始 JSON与 QYGLJ1U9 并发结果形态一致)走与线上一致的 buildReport 转化。
// 任一路传入 nil 时按空 map 处理。
func BuildReportFromRawSources(jiguang, judicial, equity, annualRaw, taxViolationRaw, taxArrearsRaw map[string]interface{}) map[string]interface{} {
if jiguang == nil {
jiguang = map[string]interface{}{}
}
if judicial == nil {
judicial = map[string]interface{}{}
}
if equity == nil {
equity = map[string]interface{}{}
}
if annualRaw == nil {
annualRaw = map[string]interface{}{}
}
if taxViolationRaw == nil {
taxViolationRaw = map[string]interface{}{}
}
if taxArrearsRaw == nil {
taxArrearsRaw = map[string]interface{}{}
}
return buildReport(jiguang, judicial, equity, annualRaw, taxViolationRaw, taxArrearsRaw)
}
// extractJSONArrayFromEnterpriseAPI 从数据宝/天眼查类响应中提取数组主体data/list/result 等)。
func extractJSONArrayFromEnterpriseAPI(m map[string]interface{}) []interface{} {
if len(m) == 0 {
return nil
}
priority := []string{"data", "list", "result", "records", "items", "body"}
for _, k := range priority {
if arr := sliceOrEmpty(m[k]); len(arr) > 0 {
return arr
}
}
if len(m) == 1 {
for _, v := range m {
if arr, ok := v.([]interface{}); ok {
return arr
}
}
}
return nil
}
func intFromAny(v interface{}) int {
if v == nil {
return 0
}
switch x := v.(type) {
case float64:
return int(x)
case int:
return x
case int64:
return int(x)
default:
s := strings.TrimSpace(str(x))
if s == "" {
return 0
}
if n, err := strconv.Atoi(s); err == nil {
return n
}
if f, err := strconv.ParseFloat(s, 64); err == nil {
return int(f)
}
}
return 0
}
// mapOwnTaxNotices QYGL7D9A 欠税公告 → { total, items }
func mapOwnTaxNotices(raw map[string]interface{}) map[string]interface{} {
out := map[string]interface{}{
"total": 0,
"items": []interface{}{},
}
if raw == nil {
return out
}
items := sliceOrEmpty(raw["items"])
total := intFromAny(raw["total"])
if total == 0 {
total = len(items)
}
mapped := make([]interface{}, 0, len(items))
for _, it := range items {
row, _ := it.(map[string]interface{})
if row == nil {
continue
}
mapped = append(mapped, map[string]interface{}{
"taxIdNumber": str(row["taxIdNumber"]),
"taxpayerName": str(row["name"]),
"taxCategory": str(row["taxCategory"]),
"ownTaxBalance": str(row["ownTaxBalance"]),
"ownTaxAmount": str(row["ownTaxAmount"]),
"newOwnTaxBalance": str(row["newOwnTaxBalance"]),
"taxType": str(row["type"]),
"publishDate": str(row["publishDate"]),
"department": str(row["department"]),
"location": str(row["location"]),
"legalPersonName": str(row["legalpersonName"]),
"personIdNumber": str(row["personIdNumber"]),
"personIdName": str(row["personIdName"]),
"taxpayerType": str(row["taxpayerType"]),
"regType": str(row["regType"]),
})
}
out["total"] = total
out["items"] = mapped
return out
}
// mapTaxViolations QYGL8848 税收违法 → { total, items }(字段驼峰化)
func mapTaxViolations(raw map[string]interface{}) map[string]interface{} {
out := map[string]interface{}{
"total": 0,
"items": []interface{}{},
}
if raw == nil {
return out
}
items := sliceOrEmpty(raw["items"])
total := intFromAny(raw["total"])
if total == 0 {
total = len(items)
}
mapped := make([]interface{}, 0, len(items))
for _, it := range items {
row, _ := it.(map[string]interface{})
if row == nil {
continue
}
cm := convertReportKeysToCamel(row, true)
if mm, ok := cm.(map[string]interface{}); ok {
mapped = append(mapped, mm)
}
}
out["total"] = total
out["items"] = mapped
return out
}
// mapAnnualReports QYGLDJ12 企业年报列表 → []年报对象(键名驼峰化,按 reportYear 降序)
func mapAnnualReports(raw map[string]interface{}) []interface{} {
rows := extractJSONArrayFromEnterpriseAPI(raw)
if len(rows) == 0 {
return []interface{}{}
}
out := make([]interface{}, 0, len(rows))
for _, r := range rows {
m, ok := r.(map[string]interface{})
if !ok {
continue
}
if v, ok := m["rpport_change_info"]; ok {
if _, has := m["report_change_info"]; !has {
m["report_change_info"] = v
}
}
cm := convertReportKeysToCamel(m, true)
mm, ok := cm.(map[string]interface{})
if !ok {
continue
}
out = append(out, mm)
}
sort.Slice(out, func(i, j int) bool {
ai, _ := out[i].(map[string]interface{})
bj, _ := out[j].(map[string]interface{})
return intFromAny(ai["reportYear"]) > intFromAny(bj["reportYear"])
})
return out
}
func mapFromBASIC(jiguang map[string]interface{}) map[string]interface{} {
basic := make(map[string]interface{})
b, _ := jiguang["BASIC"].(map[string]interface{})

View File

@@ -0,0 +1,9 @@
package processors
import "context"
// QYGLReportPDFScheduler 企业全景报告 PDF 异步预生成调度器(可为 nil 表示禁用)
type QYGLReportPDFScheduler interface {
// ScheduleQYGLReportPDF 在报告数据就绪后异步生成 PDF 并写入缓存
ScheduleQYGLReportPDF(ctx context.Context, reportID string)
}

View File

@@ -6,6 +6,7 @@ import (
"html/template"
"net/http"
"net/url"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
@@ -27,6 +28,7 @@ type QYGLReportHandler struct {
reportRepo api_repositories.ReportRepository
pdfCacheManager *pdf.PDFCacheManager
qyglPDFPregen *pdf.QYGLReportPDFPregen
}
// NewQYGLReportHandler 创建企业报告页面处理器
@@ -35,12 +37,14 @@ func NewQYGLReportHandler(
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,
}
}
@@ -141,7 +145,49 @@ func (h *QYGLReportHandler) GetQYGLReportPageByID(c *gin.Context) {
})
}
// GetQYGLReportPDFByID 通过编号导出企业全景报告 PDF基于 headless Chrome 渲染 HTML
// 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)
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")
@@ -150,7 +196,6 @@ func (h *QYGLReportHandler) GetQYGLReportPDFByID(c *gin.Context) {
return
}
// 可选:从数据库查一次,用于生成更友好的文件名
var fileName = "企业全景报告.pdf"
if h.reportRepo != nil {
if entity, err := h.reportRepo.FindByReportID(c.Request.Context(), id); err == nil && entity != nil {
@@ -160,46 +205,66 @@ func (h *QYGLReportHandler) GetQYGLReportPDFByID(c *gin.Context) {
}
}
// 临时关闭企业全景报告 PDF 的读取缓存,强制每次都重新生成
// 如果后续需要恢复缓存,可在此重新启用 pdfCacheManager.GetByReportID 的逻辑。
// 根据当前请求推断访问协议(支持通过反向代理的 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
var pdfBytes []byte
if h.pdfCacheManager != nil {
if b, hit, _, err := h.pdfCacheManager.GetByReportID(id); hit && err == nil && len(b) > 0 {
pdfBytes = b
}
}
// 构建用于 headless 浏览器访问的完整报告页面 URL
reportURL := fmt.Sprintf("%s://%s/reports/qygl/%s", scheme, c.Request.Host, id)
h.logger.Info("开始生成企业全景报告 PDFheadless 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.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 {
h.logger.Error("生成的企业全景报告 PDF 为空", zap.String("report_id", id))
c.String(http.StatusInternalServerError, "生成的企业报告 PDF 为空,请稍后重试")
return
}
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)
// 临时关闭企业全景报告 PDF 的写入缓存,只返回最新生成的 PDF。
// 若后续需要重新启用缓存,可恢复对 pdfCacheManager.SetByReportID 的调用。
h.logger.Info("现场生成企业全景报告 PDFheadless 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)
}

View File

@@ -29,7 +29,9 @@ func (r *QYGLReportRoutes) Register(router *sharedhttp.GinRouter) {
// 企业全景报告页面(通过编号查看)
engine.GET("/reports/qygl/:id", r.handler.GetQYGLReportPageByID)
// 企业全景报告 PDF 预生成状态(通过编号,供前端轮询)
engine.GET("/reports/qygl/:id/pdf/status", r.handler.GetQYGLReportPDFStatusByID)
// 企业全景报告 PDF 导出(通过编号)
engine.GET("/reports/qygl/:id/pdf", r.handler.GetQYGLReportPDFByID)
}

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"sort"
"strings"
"github.com/jung-kurt/gofpdf/v2"
@@ -106,15 +107,15 @@ func GenerateQYGLReportPDF(_ context.Context, logger *zap.Logger, report map[str
{"riskOverview", "风险情况(综合分析)"},
{"basic", "一、主体概览(企业基础信息)"},
{"branches", "二、分支机构"},
{"shareholding", "三、股权与控制"},
{"shareholding", "三、股权与控制(持股结构;认缴与实缴公示见第十六节)"},
{"controller", "四、实际控制人"},
{"beneficiaries", "五、最终受益人"},
{"investments", "六、对外投资"},
{"guarantees", "七、对外担保"},
{"management", "八、人员与组织"},
{"assets", "九、资产与经营(年报"},
{"guarantees", "七、对外担保(全量年报披露摘要;公示年报详版见第十六节)"},
{"management", "八、人员与组织(高管与任职;年报从业与社保见第十六节)"},
{"assets", "九、资产与经营(全量年报财务摘要;公示年报详版见第十六节"},
{"licenses", "十、行政许可与资质"},
{"activities", "十一、经营活动"},
{"activities", "十一、经营活动(招投标;网站或网店公示见第十六节)"},
{"inspections", "十二、抽查检查"},
{"risks", "十三、风险与合规"},
{"timeline", "十四、发展时间线"},
@@ -246,8 +247,8 @@ func writeKeyValue(pdf *gofpdf.Fpdf, fontName, label, value string) {
// 使用黑色描边,左侧标签单元格填充标题蓝色背景
pdf.SetDrawColor(0, 0, 0)
pdf.SetFillColor(91, 155, 213)
pdf.Rect(x, y, labelW, rowH, "FD") // 标签:填充+描边
pdf.Rect(x+labelW, y, valueW, rowH, "D") // 值:仅描边
pdf.Rect(x, y, labelW, rowH, "FD") // 标签:填充+描边
pdf.Rect(x+labelW, y, valueW, rowH, "D") // 值:仅描边
// 写入标签单元格
pdf.SetXY(x, y)
@@ -891,7 +892,9 @@ func renderPDFManagement(pdf *gofpdf.Fpdf, fontName, title string, v map[string]
// 从业与社保
employeeCount := getString(v, "employeeCount")
femaleEmployeeCount := getString(v, "femaleEmployeeCount")
if employeeCount != "" || femaleEmployeeCount != "" {
ssMap, _ := v["socialSecurity"].(map[string]interface{})
hasSS := len(ssMap) > 0
if employeeCount != "" || femaleEmployeeCount != "" || hasSS {
pdf.Ln(2)
pdf.SetFont(fontName, "B", 12)
pdf.MultiCell(0, 6, "从业与社保", "", "L", false)
@@ -902,6 +905,35 @@ func renderPDFManagement(pdf *gofpdf.Fpdf, fontName, title string, v map[string]
if femaleEmployeeCount != "" {
writeKeyValue(pdf, fontName, "女性从业人数", femaleEmployeeCount)
}
if hasSS {
pdf.Ln(1)
pdf.SetFont(fontName, "B", 11)
pdf.MultiCell(0, 5, "社会保险参保人数", "", "L", false)
pdf.SetFont(fontName, "", 11)
socialLabels := map[string]string{
"endowmentInsuranceEmployeeCnt": "城镇职工基本养老保险参保人数",
"unemploymentInsuranceEmployeeCnt": "失业保险参保人数",
"medicalInsuranceEmployeeCnt": "职工基本医疗保险参保人数",
"injuryInsuranceEmployeeCnt": "工伤保险参保人数",
"maternityInsuranceEmployeeCnt": "生育保险参保人数",
}
keys := make([]string, 0, len(ssMap))
for k := range ssMap {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
val := ssMap[k]
if val == nil {
continue
}
lbl := socialLabels[k]
if lbl == "" {
lbl = k
}
writeKeyValue(pdf, fontName, lbl, fmt.Sprint(val))
}
}
}
// 法定代表人其他任职
@@ -1808,4 +1840,3 @@ func boolToCN(s string) string {
return s
}
}

View File

@@ -0,0 +1,177 @@
package pdf
import (
"context"
"net/url"
"strings"
"sync"
"time"
"go.uber.org/zap"
)
// QYGLReportPDFStatus 企业报告 PDF 预生成状态(供前端轮询)
type QYGLReportPDFStatus string
const (
QYGLReportPDFStatusNone QYGLReportPDFStatus = "none"
QYGLReportPDFStatusPending QYGLReportPDFStatus = "pending"
QYGLReportPDFStatusGenerating QYGLReportPDFStatus = "generating"
QYGLReportPDFStatusReady QYGLReportPDFStatus = "ready"
QYGLReportPDFStatusFailed QYGLReportPDFStatus = "failed"
)
// QYGLReportPDFPregen 异步预渲染企业报告 PDFheadless Chrome 访问公网可访问的报告 HTML
type QYGLReportPDFPregen struct {
logger *zap.Logger
cache *PDFCacheManager
baseURL string // 已 trim无尾斜杠空则禁用
mu sync.RWMutex
states map[string]*qyglPDFState
}
type qyglPDFState struct {
Status QYGLReportPDFStatus
Message string
UpdatedAt time.Time
}
// NewQYGLReportPDFPregen baseURL 须为外部可访问基址(如 https://api.example.com为空时不预生成
func NewQYGLReportPDFPregen(logger *zap.Logger, cache *PDFCacheManager, baseURL string) *QYGLReportPDFPregen {
if logger == nil {
logger = zap.NewNop()
}
u := strings.TrimSpace(baseURL)
u = strings.TrimRight(u, "/")
return &QYGLReportPDFPregen{
logger: logger,
cache: cache,
baseURL: u,
states: make(map[string]*qyglPDFState),
}
}
// ScheduleQYGLReportPDF 实现 processors.QYGLReportPDFScheduler
func (p *QYGLReportPDFPregen) ScheduleQYGLReportPDF(ctx context.Context, reportID string) {
p.schedule(ctx, reportID)
}
func (p *QYGLReportPDFPregen) schedule(_ context.Context, reportID string) {
if p == nil || p.baseURL == "" || reportID == "" || p.cache == nil {
return
}
if b, hit, _, err := p.cache.GetByReportID(reportID); hit && err == nil && len(b) > 0 {
p.setState(reportID, QYGLReportPDFStatusReady, "")
return
}
p.mu.Lock()
if st, ok := p.states[reportID]; ok {
if st.Status == QYGLReportPDFStatusGenerating || st.Status == QYGLReportPDFStatusPending {
p.mu.Unlock()
return
}
if st.Status == QYGLReportPDFStatusReady {
p.mu.Unlock()
return
}
}
p.states[reportID] = &qyglPDFState{
Status: QYGLReportPDFStatusPending,
Message: "",
UpdatedAt: time.Now(),
}
p.mu.Unlock()
go p.runGeneration(reportID)
}
func (p *QYGLReportPDFPregen) runGeneration(reportID string) {
p.setState(reportID, QYGLReportPDFStatusGenerating, "")
fullURL := p.baseURL + "/reports/qygl/" + url.PathEscape(reportID)
gen := NewHTMLPDFGenerator(p.logger)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
pdfBytes, err := gen.GenerateFromURL(ctx, fullURL)
if err != nil {
p.logger.Error("企业报告 PDF 预生成失败",
zap.String("report_id", reportID),
zap.String("url", fullURL),
zap.Error(err),
)
p.setState(reportID, QYGLReportPDFStatusFailed, "生成失败:"+err.Error())
return
}
if len(pdfBytes) == 0 {
p.setState(reportID, QYGLReportPDFStatusFailed, "生成的 PDF 为空")
return
}
if err := p.cache.SetByReportID(reportID, pdfBytes); err != nil {
p.logger.Error("企业报告 PDF 写入缓存失败",
zap.String("report_id", reportID),
zap.Error(err),
)
p.setState(reportID, QYGLReportPDFStatusFailed, "写入缓存失败")
return
}
p.setState(reportID, QYGLReportPDFStatusReady, "")
p.logger.Info("企业报告 PDF 预生成完成",
zap.String("report_id", reportID),
zap.Int("size", len(pdfBytes)),
)
}
func (p *QYGLReportPDFPregen) setState(reportID string, st QYGLReportPDFStatus, msg string) {
if p == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
if p.states == nil {
p.states = make(map[string]*qyglPDFState)
}
p.states[reportID] = &qyglPDFState{
Status: st,
Message: msg,
UpdatedAt: time.Now(),
}
}
// Status 返回当前状态;若磁盘缓存已命中则始终为 ready
func (p *QYGLReportPDFPregen) Status(reportID string) (QYGLReportPDFStatus, string) {
if p == nil || reportID == "" {
return QYGLReportPDFStatusNone, ""
}
if p.cache != nil {
if b, hit, _, err := p.cache.GetByReportID(reportID); hit && err == nil && len(b) > 0 {
return QYGLReportPDFStatusReady, ""
}
}
p.mu.RLock()
defer p.mu.RUnlock()
st := p.states[reportID]
if st == nil {
if p.baseURL == "" {
return QYGLReportPDFStatusNone, "未启用预生成"
}
return QYGLReportPDFStatusNone, ""
}
return st.Status, st.Message
}
// MarkReadyAfterUpload 同步生成成功后与预生成状态对齐(可选)
func (p *QYGLReportPDFPregen) MarkReadyAfterUpload(reportID string) {
if p == nil || reportID == "" {
return
}
p.setState(reportID, QYGLReportPDFStatusReady, "")
}
// Enabled 是否配置了预生成基址
func (p *QYGLReportPDFPregen) Enabled() bool {
return p != nil && p.baseURL != ""
}