diff --git a/config.yaml b/config.yaml index af615cd..bd3dbaa 100644 --- a/config.yaml +++ b/config.yaml @@ -531,6 +531,26 @@ jiguang: max_age: 30 compress: true +# =========================================== +# 📄 PDF生成服务配置 +# =========================================== +pdfgen: + # 服务地址配置 + development_url: "http://pdfg.tianyuanapi.com" # 开发环境服务地址 + production_url: "http://localhost:15990" # 生产环境服务地址 + + # API路径配置 + api_path: "/api/v1/generate/guangzhou" # PDF生成API路径 + + # 超时配置 + timeout: 120s # 请求超时时间(120秒) + + # 缓存配置 + cache: + ttl: 24h # 缓存过期时间(24小时) + cache_dir: "" # 缓存目录(空则使用默认目录) + max_size: 0 # 最大缓存大小(0表示不限制,单位:字节) + # =========================================== # ✨ 数脉配置走实时接口 # =========================================== diff --git a/internal/application/certification/certification_application_service.go b/internal/application/certification/certification_application_service.go index 0a7d4e7..8e42854 100644 --- a/internal/application/certification/certification_application_service.go +++ b/internal/application/certification/certification_application_service.go @@ -32,6 +32,11 @@ type CertificationApplicationService interface { // 获取认证列表(管理员) ListCertifications(ctx context.Context, query *queries.ListCertificationsQuery) (*responses.CertificationListResponse, error) + // ================ 管理员后台操作用例 ================ + + // AdminCompleteCertificationWithoutContract 管理员代用户完成认证(暂不关联合同) + AdminCompleteCertificationWithoutContract(ctx context.Context, cmd *commands.AdminCompleteCertificationCommand) (*responses.CertificationResponse, error) + // ================ e签宝回调处理 ================ // 处理e签宝回调 diff --git a/internal/application/certification/certification_application_service_impl.go b/internal/application/certification/certification_application_service_impl.go index 9d4684c..d209252 100644 --- a/internal/application/certification/certification_application_service_impl.go +++ b/internal/application/certification/certification_application_service_impl.go @@ -581,6 +581,110 @@ func (s *CertificationApplicationServiceImpl) HandleEsignCallback( } } +// ================ 管理员后台操作用例 ================ + +// AdminCompleteCertificationWithoutContract 管理员代用户完成认证(暂不关联合同) +func (s *CertificationApplicationServiceImpl) AdminCompleteCertificationWithoutContract( + ctx context.Context, + cmd *commands.AdminCompleteCertificationCommand, +) (*responses.CertificationResponse, error) { + s.logger.Info("管理员代用户完成认证(不关联合同)", + zap.String("admin_id", cmd.AdminID), + zap.String("user_id", cmd.UserID), + ) + + // 1. 基础参数及企业信息校验 + enterpriseInfo := &certification_value_objects.EnterpriseInfo{ + CompanyName: cmd.CompanyName, + UnifiedSocialCode: cmd.UnifiedSocialCode, + LegalPersonName: cmd.LegalPersonName, + LegalPersonID: cmd.LegalPersonID, + LegalPersonPhone: cmd.LegalPersonPhone, + EnterpriseAddress: cmd.EnterpriseAddress, + } + if err := enterpriseInfo.Validate(); err != nil { + return nil, fmt.Errorf("企业信息验证失败: %s", err.Error()) + } + + // 检查统一社会信用代码唯一性(排除当前用户) + exists, err := s.userAggregateService.CheckUnifiedSocialCodeExists(ctx, cmd.UnifiedSocialCode, cmd.UserID) + if err != nil { + return nil, fmt.Errorf("检查企业信息失败: %s", err.Error()) + } + if exists { + return nil, fmt.Errorf("统一社会信用代码已被其他用户使用") + } + + var cert *entities.Certification + + // 2. 事务内:创建/加载认证、写入企业信息、直接完成认证、创建钱包和API用户 + err = s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error { + // 2.1 检查并创建认证记录 + existsCert, err := s.aggregateService.ExistsByUserID(txCtx, cmd.UserID) + if err != nil { + return fmt.Errorf("检查用户认证是否存在失败: %s", err.Error()) + } + if !existsCert { + cert, err = s.aggregateService.CreateCertification(txCtx, cmd.UserID) + if err != nil { + return fmt.Errorf("创建认证信息失败: %s", err.Error()) + } + } else { + cert, err = s.aggregateService.LoadCertificationByUserID(txCtx, cmd.UserID) + if err != nil { + return fmt.Errorf("加载认证信息失败: %s", err.Error()) + } + } + + // 2.2 写入/覆盖用户域企业信息 + if err := s.userAggregateService.CreateOrUpdateEnterpriseInfo( + txCtx, + cmd.UserID, + cmd.CompanyName, + cmd.UnifiedSocialCode, + cmd.LegalPersonName, + cmd.LegalPersonID, + cmd.LegalPersonPhone, + cmd.EnterpriseAddress, + ); err != nil { + return fmt.Errorf("保存企业信息失败: %s", err.Error()) + } + + // 2.3 直接将认证状态设置为完成(管理员操作,暂不校验合同信息) + if err := cert.TransitionTo( + enums.StatusCompleted, + enums.ActorTypeAdmin, + cmd.AdminID, + fmt.Sprintf("管理员代用户完成认证:%s", cmd.Reason), + ); err != nil { + return fmt.Errorf("更新认证状态失败: %s", err.Error()) + } + + // 2.4 基础激活:创建钱包、API用户并在用户域标记完成认证 + if err := s.completeUserActivationWithoutContract(txCtx, cert); err != nil { + return err + } + + // 2.5 保存认证信息 + if err := s.aggregateService.SaveCertification(txCtx, cert); err != nil { + return fmt.Errorf("保存认证信息失败: %s", err.Error()) + } + + return nil + }) + if err != nil { + return nil, err + } + + response := s.convertToResponse(cert) + s.logger.Info("管理员代用户完成认证成功(不关联合同)", + zap.String("admin_id", cmd.AdminID), + zap.String("user_id", cmd.UserID), + zap.String("certification_id", cert.ID), + ) + return response, nil +} + // ================ 辅助方法 ================ // convertToResponse 转换实体为响应DTO @@ -932,21 +1036,8 @@ func (s *CertificationApplicationServiceImpl) handleContractAfterSignComplete(ct s.logger.Info("合同信息已保存到聚合根", zap.String("file_name", fileName), zap.String("qiniu_url", qiniuURL)) } - _, err = s.walletAggregateService.CreateWallet(ctx, cert.UserID) - if err != nil { - s.logger.Error("创建钱包失败", zap.String("user_id", cert.UserID), zap.Error(err)) - } - - // 6. 创建API用户 - err = s.apiUserAggregateService.CreateApiUser(ctx, cert.UserID) - if err != nil { - s.logger.Error("创建API用户失败", zap.String("user_id", cert.UserID), zap.Error(err)) - } - err = s.userAggregateService.CompleteCertification(ctx, cert.UserID) - if err != nil { - s.logger.Error("用户域完成认证失败", zap.String("user_id", cert.UserID), zap.Error(err)) - } - return nil + // 合同签署完成后的基础激活流程 + return s.completeUserActivationWithoutContract(ctx, cert) } // downloadFileContent 通过URL下载文件内容 @@ -995,6 +1086,27 @@ func (s *CertificationApplicationServiceImpl) AddStatusMetadata(ctx context.Cont return metadata, nil } +// completeUserActivationWithoutContract 创建钱包、API用户并在用户域标记完成认证(不依赖合同信息) +func (s *CertificationApplicationServiceImpl) completeUserActivationWithoutContract(ctx context.Context, cert *entities.Certification) error { + // 创建钱包 + if _, err := s.walletAggregateService.CreateWallet(ctx, cert.UserID); err != nil { + s.logger.Error("创建钱包失败", zap.String("user_id", cert.UserID), zap.Error(err)) + } + + // 创建API用户 + if err := s.apiUserAggregateService.CreateApiUser(ctx, cert.UserID); err != nil { + s.logger.Error("创建API用户失败", zap.String("user_id", cert.UserID), zap.Error(err)) + } + + // 标记用户域完成认证 + if err := s.userAggregateService.CompleteCertification(ctx, cert.UserID); err != nil { + s.logger.Error("用户域完成认证失败", zap.String("user_id", cert.UserID), zap.Error(err)) + return err + } + + return nil +} + // RecognizeBusinessLicense OCR识别营业执照 func (s *CertificationApplicationServiceImpl) RecognizeBusinessLicense( ctx context.Context, diff --git a/internal/application/certification/dto/commands/certification_commands.go b/internal/application/certification/dto/commands/certification_commands.go index 3c6918c..6dbdc63 100644 --- a/internal/application/certification/dto/commands/certification_commands.go +++ b/internal/application/certification/dto/commands/certification_commands.go @@ -71,7 +71,19 @@ type EsignOrganization struct { // 可以根据需要添加更多企业信息字段 } - +// AdminCompleteCertificationCommand 管理员代用户完成认证命令(可不关联合同) +type AdminCompleteCertificationCommand struct { + AdminID string `json:"-" validate:"required"` + UserID string `json:"user_id" validate:"required"` + CompanyName string `json:"company_name" validate:"required,min=2,max=100"` + UnifiedSocialCode string `json:"unified_social_code" validate:"required"` + LegalPersonName string `json:"legal_person_name" validate:"required,min=2,max=20"` + LegalPersonID string `json:"legal_person_id" validate:"required"` + LegalPersonPhone string `json:"legal_person_phone" validate:"required"` + EnterpriseAddress string `json:"enterprise_address" validate:"required"` + // 备注信息,用于记录后台操作原因 + Reason string `json:"reason" validate:"required"` +} // ForceTransitionStatusCommand 强制状态转换命令(管理员) type ForceTransitionStatusCommand struct { CertificationID string `json:"certification_id" validate:"required"` diff --git a/internal/config/config.go b/internal/config/config.go index b6055ad..498d71f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,8 +38,9 @@ type Config struct { TianYanCha TianYanChaConfig `mapstructure:"tianyancha"` Alicloud AlicloudConfig `mapstructure:"alicloud"` Xingwei XingweiConfig `mapstructure:"xingwei"` - Jiguang JiguangConfig `mapstructure:"jiguang"` - Shumai ShumaiConfig `mapstructure:"shumai"` + Jiguang JiguangConfig `mapstructure:"jiguang"` + Shumai ShumaiConfig `mapstructure:"shumai"` + PDFGen PDFGenConfig `mapstructure:"pdfgen"` } // ServerConfig HTTP服务器配置 @@ -581,6 +582,22 @@ type ShumaiLevelFileConfig struct { Compress bool `mapstructure:"compress"` } +// PDFGenConfig PDF生成服务配置 +type PDFGenConfig struct { + DevelopmentURL string `mapstructure:"development_url"` // 开发环境服务地址 + ProductionURL string `mapstructure:"production_url"` // 生产环境服务地址 + APIPath string `mapstructure:"api_path"` // API路径 + Timeout time.Duration `mapstructure:"timeout"` // 请求超时时间 + Cache PDFGenCacheConfig `mapstructure:"cache"` // 缓存配置 +} + +// PDFGenCacheConfig PDF生成缓存配置 +type PDFGenCacheConfig struct { + TTL time.Duration `mapstructure:"ttl"` // 缓存过期时间 + CacheDir string `mapstructure:"cache_dir"` // 缓存目录(空则使用默认目录) + MaxSize int64 `mapstructure:"max_size"` // 最大缓存大小(0表示不限制,单位:字节) +} + // DomainConfig 域名配置 type DomainConfig struct { API string `mapstructure:"api"` // API域名 diff --git a/internal/container/container.go b/internal/container/container.go index dee7361..1d84b1c 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "strconv" "time" "go.uber.org/fx" @@ -1145,36 +1144,33 @@ func NewContainer() *Container { return pdf.NewPDFGenerator(logger) }, ), - // PDF缓存管理器 + // PDF缓存管理器(用于PDFG) fx.Provide( - func(logger *zap.Logger) (*pdf.PDFCacheManager, error) { - // 使用默认配置:缓存目录在临时目录,TTL为24小时,最大缓存大小为500MB - cacheDir := "" // 使用默认目录(临时目录下的tyapi_pdf_cache) - ttl := 24 * time.Hour - maxSize := int64(500 * 1024 * 1024) // 500MB + func(cfg *config.Config, logger *zap.Logger) (*pdf.PDFCacheManager, error) { + cacheDir := cfg.PDFGen.Cache.CacheDir + ttl := cfg.PDFGen.Cache.TTL + if ttl == 0 { + ttl = 24 * time.Hour + } - // 可以通过环境变量覆盖 - if envCacheDir := os.Getenv("PDF_CACHE_DIR"); envCacheDir != "" { + // 环境变量可以覆盖配置 + if envCacheDir := os.Getenv("PDFG_CACHE_DIR"); envCacheDir != "" { cacheDir = envCacheDir } - if envTTL := os.Getenv("PDF_CACHE_TTL"); envTTL != "" { + if envTTL := os.Getenv("PDFG_CACHE_TTL"); envTTL != "" { if parsedTTL, err := time.ParseDuration(envTTL); err == nil { ttl = parsedTTL } } - if envMaxSize := os.Getenv("PDF_CACHE_MAX_SIZE"); envMaxSize != "" { - if parsedMaxSize, err := strconv.ParseInt(envMaxSize, 10, 64); err == nil { - maxSize = parsedMaxSize - } - } + maxSize := cfg.PDFGen.Cache.MaxSize cacheManager, err := pdf.NewPDFCacheManager(logger, cacheDir, ttl, maxSize) if err != nil { - logger.Warn("PDF缓存管理器初始化失败,将禁用缓存功能", zap.Error(err)) - return nil, nil // 返回nil,handler中会检查 + logger.Warn("PDFG缓存管理器初始化失败", zap.Error(err)) + return nil, err } - logger.Info("PDF缓存管理器已初始化", + logger.Info("PDFG缓存管理器已初始化", zap.String("cache_dir", cacheDir), zap.Duration("ttl", ttl), zap.Int64("max_size", maxSize), @@ -1237,6 +1233,8 @@ func NewContainer() *Container { ) *handlers.AnnouncementHandler { return handlers.NewAnnouncementHandler(appService, responseBuilder, validator, logger) }, + // PDFG HTTP处理器 + handlers.NewPDFGHandler, // 组件报告处理器 func( productRepo domain_product_repo.ProductRepository, @@ -1299,6 +1297,8 @@ func NewContainer() *Container { routes.NewApiRoutes, // 统计路由 routes.NewStatisticsRoutes, + // PDFG路由 + routes.NewPDFGRoutes, ), // 应用生命周期 @@ -1412,6 +1412,7 @@ func RegisterRoutes( announcementRoutes *routes.AnnouncementRoutes, apiRoutes *routes.ApiRoutes, statisticsRoutes *routes.StatisticsRoutes, + pdfgRoutes *routes.PDFGRoutes, jwtAuth *middleware.JWTAuthMiddleware, adminAuth *middleware.AdminAuthMiddleware, cfg *config.Config, @@ -1435,6 +1436,7 @@ func RegisterRoutes( articleRoutes.Register(router) announcementRoutes.Register(router) statisticsRoutes.Register(router) + pdfgRoutes.Register(router) // 打印注册的路由信息 router.PrintRoutes() diff --git a/internal/domains/api/dto/pdfg_dto.go b/internal/domains/api/dto/pdfg_dto.go new file mode 100644 index 0000000..a351c70 --- /dev/null +++ b/internal/domains/api/dto/pdfg_dto.go @@ -0,0 +1,11 @@ +package dto + +// PDFG01GZReq PDFG01GZ 请求参数 +type PDFG01GZReq struct { + Name string `json:"name" validate:"required,min=1,validName"` + IDCard string `json:"id_card" validate:"required,validIDCard"` + MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"` + AuthAuthorizeFileCode string `json:"auth_authorize_file_code" validate:"required"` // IVYZ5A9O需要 + Authorized string `json:"authorized" validate:"required,oneof=0 1"` // 授权标识,0或1 +} + diff --git a/internal/domains/api/services/api_request_service.go b/internal/domains/api/services/api_request_service.go index 6b03ca0..fe9c8bb 100644 --- a/internal/domains/api/services/api_request_service.go +++ b/internal/domains/api/services/api_request_service.go @@ -6,12 +6,14 @@ import ( "fmt" "tyapi-server/internal/application/api/commands" + "tyapi-server/internal/config" "tyapi-server/internal/domains/api/services/processors" "tyapi-server/internal/domains/api/services/processors/comb" "tyapi-server/internal/domains/api/services/processors/dwbg" "tyapi-server/internal/domains/api/services/processors/flxg" "tyapi-server/internal/domains/api/services/processors/ivyz" "tyapi-server/internal/domains/api/services/processors/jrzq" + "tyapi-server/internal/domains/api/services/processors/pdfg" "tyapi-server/internal/domains/api/services/processors/qcxg" "tyapi-server/internal/domains/api/services/processors/qygl" "tyapi-server/internal/domains/api/services/processors/test" @@ -46,6 +48,7 @@ type ApiRequestService struct { validator interfaces.RequestValidator processorDeps *processors.ProcessorDependencies combService *comb.CombService + config *config.Config } func NewApiRequestService( @@ -60,6 +63,7 @@ func NewApiRequestService( shumaiService *shumai.ShumaiService, validator interfaces.RequestValidator, productManagementService *services.ProductManagementService, + cfg *config.Config, ) *ApiRequestService { // 创建组合包服务 combService := comb.NewCombService(productManagementService) @@ -79,6 +83,7 @@ func NewApiRequestService( validator: validator, processorDeps: processorDeps, combService: combService, + config: cfg, } } @@ -274,6 +279,9 @@ func registerAllProcessors(combService *comb.CombService) { "TEST001": test.ProcessTestRequest, "TEST002": test.ProcessTestErrorRequest, "TEST003": test.ProcessTestTimeoutRequest, + + // PDFG系列处理器 - PDF生成 + "PDFG01GZ": pdfg.ProcessPDFG01GZRequest, } // 批量注册到组合包服务 @@ -295,6 +303,8 @@ func (a *ApiRequestService) PreprocessRequestApi(ctx context.Context, apiCode st // 将apiCode放入context,供外部服务使用 ctx = context.WithValue(ctx, "api_code", apiCode) + // 将config放入context,供处理器使用 + ctx = context.WithValue(ctx, "config", a.config) // 1. 优先查找已注册的自定义处理器 if processor, exists := RequestProcessors[apiCode]; exists { diff --git a/internal/domains/api/services/form_config_service.go b/internal/domains/api/services/form_config_service.go index e74eb06..81eeb1d 100644 --- a/internal/domains/api/services/form_config_service.go +++ b/internal/domains/api/services/form_config_service.go @@ -242,6 +242,7 @@ func (s *FormConfigServiceImpl) getDTOStruct(ctx context.Context, apiCode string "IVYZN2P8": &dto.IVYZ9K7FReq{}, //身份证实名认证政务版 "YYSYH6F3": &dto.YYSYH6F3Req{}, //运营商三要素简版即时版查询 "IVYZX5Q2": &dto.IVYZX5Q2Req{}, //活体识别步骤二 + "PDFG01GZ": &dto.PDFG01GZReq{}, // } // 优先返回已配置的DTO diff --git a/internal/domains/api/services/processors/comb/comb_service.go b/internal/domains/api/services/processors/comb/comb_service.go index 7b7b1c4..6bcbbf7 100644 --- a/internal/domains/api/services/processors/comb/comb_service.go +++ b/internal/domains/api/services/processors/comb/comb_service.go @@ -32,6 +32,12 @@ func (cs *CombService) RegisterProcessor(apiCode string, processor processors.Pr cs.processorRegistry[apiCode] = processor } +// GetProcessor 获取处理器(用于内部调用) +func (cs *CombService) GetProcessor(apiCode string) (processors.ProcessorFunc, bool) { + processor, exists := cs.processorRegistry[apiCode] + return processor, exists +} + // ProcessCombRequest 处理组合包请求 - 实现 CombServiceInterface func (cs *CombService) ProcessCombRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies, packageCode string) (*processors.CombinedResult, error) { // 1. 根据组合包code获取产品信息 diff --git a/internal/domains/api/services/processors/pdfg/pdfg01gz_processor.go b/internal/domains/api/services/processors/pdfg/pdfg01gz_processor.go new file mode 100644 index 0000000..c2b0b58 --- /dev/null +++ b/internal/domains/api/services/processors/pdfg/pdfg01gz_processor.go @@ -0,0 +1,466 @@ +package pdfg + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "tyapi-server/internal/config" + "tyapi-server/internal/domains/api/dto" + "tyapi-server/internal/domains/api/services/processors" + "tyapi-server/internal/infrastructure/external/pdfgen" + "tyapi-server/internal/shared/logger" + "tyapi-server/internal/shared/pdf" + + "go.uber.org/zap" +) + +// ProcessPDFG01GZRequest PDFG01GZ 处理器 - 大数据租赁风险PDF报告 +func ProcessPDFG01GZRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { + var paramsDto dto.PDFG01GZReq + if err := json.Unmarshal(params, ¶msDto); err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + if err := deps.Validator.ValidateStruct(paramsDto); err != nil { + return nil, errors.Join(processors.ErrInvalidParam, err) + } + + // 获取全局logger + zapLogger := logger.GetGlobalLogger() + + // 从context获取config(如果存在) + var cacheTTL time.Duration = 24 * time.Hour + var cacheDir string + if cfg, ok := ctx.Value("config").(*config.Config); ok && cfg != nil { + cacheTTL = cfg.PDFGen.Cache.TTL + if cacheTTL == 0 { + cacheTTL = 24 * time.Hour + } + cacheDir = cfg.PDFGen.Cache.CacheDir + } + + // 获取最大缓存大小 + var maxSize int64 + if cfg, ok := ctx.Value("config").(*config.Config); ok && cfg != nil { + maxSize = cfg.PDFGen.Cache.MaxSize + } + + // 创建PDF缓存管理器 + cacheManager, err := pdf.NewPDFCacheManager(zapLogger, cacheDir, cacheTTL, maxSize) + if err != nil { + return nil, errors.Join(processors.ErrSystem, fmt.Errorf("创建PDF缓存管理器失败: %w", err)) + } + + // 从context获取config创建PDF生成服务 + var pdfGenService *pdfgen.PDFGenService + if cfg, ok := ctx.Value("config").(*config.Config); ok && cfg != nil { + pdfGenService = pdfgen.NewPDFGenService(cfg, zapLogger) + } else { + // 如果无法获取config,使用默认配置 + defaultCfg := &config.Config{ + App: config.AppConfig{Env: "development"}, + PDFGen: config.PDFGenConfig{ + DevelopmentURL: "http://pdfg.tianyuanapi.com", + ProductionURL: "http://localhost:15990", + APIPath: "/api/v1/generate/guangzhou", + Timeout: 120 * time.Second, + }, + } + pdfGenService = pdfgen.NewPDFGenService(defaultCfg, zapLogger) + } + + // 检查缓存 + _, hit, createdAt, err := cacheManager.Get(paramsDto.Name, paramsDto.IDCard) + if err != nil { + zapLogger.Warn("检查缓存失败,继续生成PDF", zap.Error(err)) + } else if hit { + // 缓存命中,模拟慢几秒 + zapLogger.Info("PDF缓存命中,返回缓存文件", + zap.String("name", paramsDto.Name), + zap.String("id_card", paramsDto.IDCard), + zap.Time("created_at", createdAt), + ) + // 模拟慢几秒(2-4秒) + time.Sleep(2 * time.Second) + + // 生成下载链接 + downloadURL := generateDownloadURL(paramsDto.Name, paramsDto.IDCard) + return json.Marshal(map[string]interface{}{ + "download_url": downloadURL, + "cached": true, + "created_at": createdAt.Format(time.RFC3339), + }) + } + + // 缓存未命中,需要生成PDF + zapLogger.Info("PDF缓存未命中,开始生成PDF", + zap.String("name", paramsDto.Name), + zap.String("id_card", paramsDto.IDCard), + ) + + // 调用多个处理器获取数据(即使部分失败也继续) + apiData := collectAPIData(ctx, paramsDto, deps, zapLogger) + + // 格式化数据为PDF生成服务需要的格式(为缺失的数据提供默认值) + formattedData := formatDataForPDF(apiData, paramsDto, zapLogger) + + // 从APPLICANT_BASIC_INFO中提取报告编号(如果存在) + var reportNumber string + if len(formattedData) > 0 { + if basicInfo, ok := formattedData[0]["data"].(map[string]interface{}); ok { + if rn, ok := basicInfo["report_number"].(string); ok { + reportNumber = rn + } + } + } + // 如果没有提取到,生成新的报告编号 + if reportNumber == "" { + reportNumber = generateReportNumber() + } + + // 构建PDF生成请求 + pdfReq := &pdfgen.GeneratePDFRequest{ + Data: formattedData, + ReportNumber: reportNumber, + GenerateTime: time.Now().Format("2006-01-02 15:04:05"), + } + + // 调用PDF生成服务 + // 即使部分子处理器失败,只要有APPLICANT_BASIC_INFO就可以生成PDF + pdfResp, err := pdfGenService.GenerateGuangzhouPDF(ctx, pdfReq) + if err != nil { + zapLogger.Error("生成PDF失败", + zap.Error(err), + zap.Int("data_items", len(formattedData)), + ) + return nil, errors.Join(processors.ErrSystem, fmt.Errorf("生成PDF失败: %w", err)) + } + + // 保存到缓存 + if err := cacheManager.Set(paramsDto.Name, paramsDto.IDCard, pdfResp.PDFBytes); err != nil { + zapLogger.Warn("保存PDF到缓存失败", zap.Error(err)) + // 不影响返回结果,只记录警告 + } + + // 生成下载链接 + downloadURL := generateDownloadURL(paramsDto.Name, paramsDto.IDCard) + + zapLogger.Info("PDF生成成功", + zap.String("name", paramsDto.Name), + zap.String("id_card", paramsDto.IDCard), + zap.String("report_number", reportNumber), + zap.String("download_url", downloadURL), + ) + + return json.Marshal(map[string]interface{}{ + "download_url": downloadURL, + "report_number": reportNumber, + "cached": false, + }) +} + +// collectAPIData 收集所有需要的API数据 +// 即使部分或全部子处理器失败,也会返回结果(失败的设为nil),确保流程继续 +func collectAPIData(ctx context.Context, params dto.PDFG01GZReq, deps *processors.ProcessorDependencies, logger *zap.Logger) map[string]interface{} { + apiData := make(map[string]interface{}) + + // 并发调用多个处理器 + type processorResult struct { + apiCode string + data interface{} + err error + } + + results := make(chan processorResult, 5) + + // 调用IVYZ5A9O - 需要: name, id_card, auth_authorize_file_code + go func() { + defer func() { + if r := recover(); r != nil { + logger.Error("调用IVYZ5A9O处理器时发生panic", + zap.Any("panic", r), + ) + results <- processorResult{"IVYZ5A9O", nil, fmt.Errorf("处理器panic: %v", r)} + } + }() + // 检查必需字段 + if params.AuthAuthorizeFileCode == "" { + logger.Warn("IVYZ5A9O缺少auth_authorize_file_code字段,跳过调用") + results <- processorResult{"IVYZ5A9O", nil, fmt.Errorf("缺少必需字段: auth_authorize_file_code")} + return + } + ivyzParams := map[string]interface{}{ + "name": params.Name, + "id_card": params.IDCard, + "auth_authorize_file_code": params.AuthAuthorizeFileCode, + } + paramsBytes, err := json.Marshal(ivyzParams) + if err != nil { + logger.Warn("序列化IVYZ5A9O参数失败", zap.Error(err)) + results <- processorResult{"IVYZ5A9O", nil, err} + return + } + data, err := callProcessor(ctx, "IVYZ5A9O", paramsBytes, deps) + results <- processorResult{"IVYZ5A9O", data, err} + }() + + // 调用JRZQ8A2D - 需要: name, id_card, mobile_no, authorized + go func() { + defer func() { + if r := recover(); r != nil { + logger.Error("调用JRZQ8A2D处理器时发生panic", + zap.Any("panic", r), + ) + results <- processorResult{"JRZQ8A2D", nil, fmt.Errorf("处理器panic: %v", r)} + } + }() + jrzq8a2dParams := map[string]interface{}{ + "name": params.Name, + "id_card": params.IDCard, + "mobile_no": params.MobileNo, + "authorized": params.Authorized, + } + paramsBytes, err := json.Marshal(jrzq8a2dParams) + if err != nil { + logger.Warn("序列化JRZQ8A2D参数失败", zap.Error(err)) + results <- processorResult{"JRZQ8A2D", nil, err} + return + } + data, err := callProcessor(ctx, "JRZQ8A2D", paramsBytes, deps) + results <- processorResult{"JRZQ8A2D", data, err} + }() + + // 调用FLXGDEA9 - 需要: name, id_card, authorized + go func() { + defer func() { + if r := recover(); r != nil { + logger.Error("调用FLXGDEA9处理器时发生panic", + zap.Any("panic", r), + ) + results <- processorResult{"FLXGDEA9", nil, fmt.Errorf("处理器panic: %v", r)} + } + }() + flxgParams := map[string]interface{}{ + "name": params.Name, + "id_card": params.IDCard, + "authorized": params.Authorized, + } + paramsBytes, err := json.Marshal(flxgParams) + if err != nil { + logger.Warn("序列化FLXGDEA9参数失败", zap.Error(err)) + results <- processorResult{"FLXGDEA9", nil, err} + return + } + data, err := callProcessor(ctx, "FLXGDEA9", paramsBytes, deps) + results <- processorResult{"FLXGDEA9", data, err} + }() + + // 调用JRZQ1D09 - 需要: name, id_card, mobile_no, authorized + go func() { + defer func() { + if r := recover(); r != nil { + logger.Error("调用JRZQ1D09处理器时发生panic", + zap.Any("panic", r), + ) + results <- processorResult{"JRZQ1D09", nil, fmt.Errorf("处理器panic: %v", r)} + } + }() + jrzq1d09Params := map[string]interface{}{ + "name": params.Name, + "id_card": params.IDCard, + "mobile_no": params.MobileNo, + "authorized": params.Authorized, + } + paramsBytes, err := json.Marshal(jrzq1d09Params) + if err != nil { + logger.Warn("序列化JRZQ1D09参数失败", zap.Error(err)) + results <- processorResult{"JRZQ1D09", nil, err} + return + } + data, err := callProcessor(ctx, "JRZQ1D09", paramsBytes, deps) + results <- processorResult{"JRZQ1D09", data, err} + }() + + // 调用JRZQ8B3C - 需要: name, id_card, mobile_no (不需要authorized) + go func() { + defer func() { + if r := recover(); r != nil { + logger.Error("调用JRZQ8B3C处理器时发生panic", + zap.Any("panic", r), + ) + results <- processorResult{"JRZQ8B3C", nil, fmt.Errorf("处理器panic: %v", r)} + } + }() + jrzq8b3cParams := map[string]interface{}{ + "name": params.Name, + "id_card": params.IDCard, + "mobile_no": params.MobileNo, + } + paramsBytes, err := json.Marshal(jrzq8b3cParams) + if err != nil { + logger.Warn("序列化JRZQ8B3C参数失败", zap.Error(err)) + results <- processorResult{"JRZQ8B3C", nil, err} + return + } + data, err := callProcessor(ctx, "JRZQ8B3C", paramsBytes, deps) + results <- processorResult{"JRZQ8B3C", data, err} + }() + + // 收集结果,即使所有处理器都失败也继续 + successCount := 0 + for i := 0; i < 5; i++ { + result := <-results + if result.err != nil { + // 记录错误但不中断流程,允许部分数据缺失 + logger.Warn("调用处理器失败,将使用默认值", + zap.String("api_code", result.apiCode), + zap.Error(result.err), + ) + apiData[result.apiCode] = nil + } else { + apiData[result.apiCode] = result.data + successCount++ + } + } + + logger.Info("子处理器调用完成", + zap.Int("total", 5), + zap.Int("success", successCount), + zap.Int("failed", 5-successCount), + ) + + return apiData +} + +// callProcessor 调用指定的处理器 +func callProcessor(ctx context.Context, apiCode string, params []byte, deps *processors.ProcessorDependencies) (interface{}, error) { + // 通过CombService获取处理器 + if combSvc, ok := deps.CombService.(interface { + GetProcessor(apiCode string) (processors.ProcessorFunc, bool) + }); ok { + processor, exists := combSvc.GetProcessor(apiCode) + if !exists { + return nil, fmt.Errorf("未找到处理器: %s", apiCode) + } + respBytes, err := processor(ctx, params, deps) + if err != nil { + return nil, err + } + var data interface{} + if err := json.Unmarshal(respBytes, &data); err != nil { + return nil, fmt.Errorf("解析响应失败: %w", err) + } + return data, nil + } + + // 如果无法通过CombService获取,返回错误 + return nil, fmt.Errorf("无法获取处理器: %s,CombService不支持GetProcessor方法", apiCode) +} + +// formatDataForPDF 格式化数据为PDF生成服务需要的格式 +// 为所有子处理器提供数据,即使失败也提供默认值,确保PDF生成服务能收到完整结构 +func formatDataForPDF(apiData map[string]interface{}, params dto.PDFG01GZReq, logger *zap.Logger) []map[string]interface{} { + result := make([]map[string]interface{}, 0) + + // 1. APPLICANT_BASIC_INFO - 申请人基本信息(始终存在) + result = append(result, map[string]interface{}{ + "apiID": "APPLICANT_BASIC_INFO", + "data": map[string]interface{}{ + "name": params.Name, + "id_card": params.IDCard, + "mobile": params.MobileNo, + "query_time": time.Now().Format("2006-01-02 15:04:05"), + "report_number": generateReportNumber(), + "generate_time": time.Now().Format("2006-01-02 15:04:05"), + }, + }) + + // 2. IVYZ5A9O - 自然人综合风险智能评估模型 + if data, ok := apiData["IVYZ5A9O"]; ok && data != nil { + result = append(result, map[string]interface{}{ + "apiID": "IVYZ5A9O", + "data": data, + }) + } else { + // 子处理器失败或无数据时,返回空对象 {} + logger.Debug("IVYZ5A9O数据缺失,使用空对象") + result = append(result, map[string]interface{}{ + "apiID": "IVYZ5A9O", + "data": map[string]interface{}{}, + }) + } + + // 3. JRZQ8A2D - 特殊名单验证B + if data, ok := apiData["JRZQ8A2D"]; ok && data != nil { + result = append(result, map[string]interface{}{ + "apiID": "JRZQ8A2D", + "data": data, + }) + } else { + logger.Debug("JRZQ8A2D数据缺失,使用空对象") + result = append(result, map[string]interface{}{ + "apiID": "JRZQ8A2D", + "data": map[string]interface{}{}, + }) + } + + // 4. FLXGDEA9 - 公安不良人员名单 + if data, ok := apiData["FLXGDEA9"]; ok && data != nil { + result = append(result, map[string]interface{}{ + "apiID": "FLXGDEA9", + "data": data, + }) + } else { + logger.Debug("FLXGDEA9数据缺失,使用空对象") + result = append(result, map[string]interface{}{ + "apiID": "FLXGDEA9", + "data": map[string]interface{}{}, + }) + } + + // 5. JRZQ1D09 - 3C租赁申请意向 + if data, ok := apiData["JRZQ1D09"]; ok && data != nil { + result = append(result, map[string]interface{}{ + "apiID": "JRZQ1D09", + "data": data, + }) + } else { + logger.Debug("JRZQ1D09数据缺失,使用空对象") + result = append(result, map[string]interface{}{ + "apiID": "JRZQ1D09", + "data": map[string]interface{}{}, + }) + } + + // 6. JRZQ8B3C - 个人消费能力等级 + if data, ok := apiData["JRZQ8B3C"]; ok && data != nil { + result = append(result, map[string]interface{}{ + "apiID": "JRZQ8B3C", + "data": data, + }) + } else { + logger.Debug("JRZQ8B3C数据缺失,使用空对象") + result = append(result, map[string]interface{}{ + "apiID": "JRZQ8B3C", + "data": map[string]interface{}{}, + }) + } + + return result +} + +// generateReportNumber 生成报告编号 +func generateReportNumber() string { + return fmt.Sprintf("RPT%s", time.Now().Format("20060102150405")) +} + +// generateDownloadURL 生成下载链接 +func generateDownloadURL(name, idCard string) string { + // 这里应该生成实际的下载URL + // 暂时返回一个占位符,实际应该根据服务器配置生成 + return fmt.Sprintf("/api/v1/pdfg/download?name=%s&id_card=%s", name, idCard) +} + diff --git a/internal/domains/certification/entities/certification.go b/internal/domains/certification/entities/certification.go index 21c119d..edf3d22 100644 --- a/internal/domains/certification/entities/certification.go +++ b/internal/domains/certification/entities/certification.go @@ -428,11 +428,6 @@ func (c *Certification) CompleteCertification() error { return fmt.Errorf("当前状态 %s 不允许完成认证", enums.GetStatusName(c.Status)) } - // 验证合同信息完整性 - if c.ContractFileID == "" || c.EsignFlowID == "" || c.ContractURL == "" { - return errors.New("合同信息不完整,无法完成认证") - } - // 状态转换 if err := c.TransitionTo(enums.StatusCompleted, enums.ActorTypeSystem, "system", "系统处理完成,认证成功"); err != nil { return err @@ -567,18 +562,11 @@ func (c *Certification) ValidateBusinessRules() error { // 状态相关验证 switch c.Status { - case enums.StatusEnterpriseVerified: - if c.ContractURL == "" { - return errors.New("企业认证成功后,合同文件ID和合同URL不能为空") - } case enums.StatusContractSigned: if c.ContractFileID == "" || c.EsignFlowID == "" { return errors.New("合同签署状态下必须有完整的合同信息") } case enums.StatusCompleted: - if c.ContractFileID == "" || c.EsignFlowID == "" || c.ContractURL == "" { - return errors.New("认证完成状态下必须有完整的合同信息") - } if c.CompletedAt == nil { return errors.New("认证完成状态下必须有完成时间") } diff --git a/internal/infrastructure/external/pdfgen/pdfgen_service.go b/internal/infrastructure/external/pdfgen/pdfgen_service.go new file mode 100644 index 0000000..7b99ed4 --- /dev/null +++ b/internal/infrastructure/external/pdfgen/pdfgen_service.go @@ -0,0 +1,154 @@ +package pdfgen + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "tyapi-server/internal/config" + + "go.uber.org/zap" +) + +// PDFGenService PDF生成服务客户端 +type PDFGenService struct { + baseURL string + apiPath string + logger *zap.Logger + client *http.Client +} + +// NewPDFGenService 创建PDF生成服务客户端 +func NewPDFGenService(cfg *config.Config, logger *zap.Logger) *PDFGenService { + // 根据环境选择服务地址 + var baseURL string + if cfg.App.IsProduction() { + baseURL = cfg.PDFGen.ProductionURL + } else { + baseURL = cfg.PDFGen.DevelopmentURL + } + + // 如果配置为空,使用默认值 + if baseURL == "" { + if cfg.App.IsProduction() { + baseURL = "http://localhost:15990" + } else { + baseURL = "http://1.117.67.95:15990" + } + } + + // 获取API路径,如果为空使用默认值 + apiPath := cfg.PDFGen.APIPath + if apiPath == "" { + apiPath = "/api/v1/generate/guangzhou" + } + + // 获取超时时间,如果为0使用默认值 + timeout := cfg.PDFGen.Timeout + if timeout == 0 { + timeout = 120 * time.Second + } + + logger.Info("PDF生成服务已初始化", + zap.String("base_url", baseURL), + zap.String("api_path", apiPath), + zap.Duration("timeout", timeout), + ) + + return &PDFGenService{ + baseURL: baseURL, + apiPath: apiPath, + logger: logger, + client: &http.Client{ + Timeout: timeout, + }, + } +} + +// GeneratePDFRequest PDF生成请求 +type GeneratePDFRequest struct { + Data []map[string]interface{} `json:"data"` + ReportNumber string `json:"report_number,omitempty"` + GenerateTime string `json:"generate_time,omitempty"` +} + +// GeneratePDFResponse PDF生成响应 +type GeneratePDFResponse struct { + PDFBytes []byte + FileName string +} + +// GenerateGuangzhouPDF 生成广州大数据租赁风险PDF报告 +func (s *PDFGenService) GenerateGuangzhouPDF(ctx context.Context, req *GeneratePDFRequest) (*GeneratePDFResponse, error) { + // 构建请求体 + reqBody, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("序列化请求失败: %w", err) + } + + // 构建请求URL + url := fmt.Sprintf("%s%s", s.baseURL, s.apiPath) + + // 创建HTTP请求 + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(reqBody)) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + + // 设置请求头 + httpReq.Header.Set("Content-Type", "application/json") + + // 发送请求 + s.logger.Info("开始调用PDF生成服务", + zap.String("url", url), + zap.Int("data_count", len(req.Data)), + ) + + resp, err := s.client.Do(httpReq) + if err != nil { + s.logger.Error("调用PDF生成服务失败", + zap.String("url", url), + zap.Error(err), + ) + return nil, fmt.Errorf("调用PDF生成服务失败: %w", err) + } + defer resp.Body.Close() + + // 检查HTTP状态码 + if resp.StatusCode != http.StatusOK { + // 尝试读取错误信息 + errorBody, _ := io.ReadAll(resp.Body) + s.logger.Error("PDF生成服务返回错误", + zap.Int("status_code", resp.StatusCode), + zap.String("error_body", string(errorBody)), + ) + return nil, fmt.Errorf("PDF生成失败,状态码: %d, 错误: %s", resp.StatusCode, string(errorBody)) + } + + // 读取PDF文件 + pdfBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取PDF文件失败: %w", err) + } + + // 生成文件名 + fileName := "大数据租赁风险报告.pdf" + if req.ReportNumber != "" { + fileName = fmt.Sprintf("%s.pdf", req.ReportNumber) + } + + s.logger.Info("PDF生成成功", + zap.String("file_name", fileName), + zap.Int("file_size", len(pdfBytes)), + ) + + return &GeneratePDFResponse{ + PDFBytes: pdfBytes, + FileName: fileName, + }, nil +} + diff --git a/internal/infrastructure/http/handlers/certification_handler.go b/internal/infrastructure/http/handlers/certification_handler.go index ba330d5..48b5c46 100644 --- a/internal/infrastructure/http/handlers/certification_handler.go +++ b/internal/infrastructure/http/handlers/certification_handler.go @@ -338,6 +338,43 @@ func (h *CertificationHandler) ListCertifications(c *gin.Context) { h.response.Success(c, result, "获取认证列表成功") } +// AdminCompleteCertificationWithoutContract 管理员代用户完成认证(暂不关联合同) +// @Summary 管理员代用户完成认证(暂不关联合同) +// @Description 后台补充企业信息并直接完成认证,暂时不要求上传合同 +// @Tags 认证管理 +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body commands.AdminCompleteCertificationCommand true "管理员代用户完成认证请求" +// @Success 200 {object} responses.CertificationResponse "代用户完成认证成功" +// @Failure 400 {object} map[string]interface{} "请求参数错误" +// @Failure 401 {object} map[string]interface{} "未认证" +// @Failure 403 {object} map[string]interface{} "权限不足" +// @Failure 500 {object} map[string]interface{} "服务器内部错误" +// @Router /api/v1/certifications/admin/complete-without-contract [post] +func (h *CertificationHandler) AdminCompleteCertificationWithoutContract(c *gin.Context) { + adminID := h.getCurrentUserID(c) + if adminID == "" { + h.response.Unauthorized(c, "用户未登录") + return + } + + var cmd commands.AdminCompleteCertificationCommand + if err := h.validator.BindAndValidate(c, &cmd); err != nil { + return + } + cmd.AdminID = adminID + + result, err := h.appService.AdminCompleteCertificationWithoutContract(c.Request.Context(), &cmd) + if err != nil { + h.logger.Error("管理员代用户完成认证失败", zap.Error(err), zap.String("admin_id", adminID), zap.String("user_id", cmd.UserID)) + h.response.BadRequest(c, err.Error()) + return + } + + h.response.Success(c, result, "代用户完成认证成功") +} + // ================ 回调处理 ================ // HandleEsignCallback 处理e签宝回调 diff --git a/internal/infrastructure/http/handlers/pdfg_handler.go b/internal/infrastructure/http/handlers/pdfg_handler.go new file mode 100644 index 0000000..fb4740d --- /dev/null +++ b/internal/infrastructure/http/handlers/pdfg_handler.go @@ -0,0 +1,93 @@ +package handlers + +import ( + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "tyapi-server/internal/shared/interfaces" + "tyapi-server/internal/shared/pdf" +) + +// PDFGHandler PDFG处理器 +type PDFGHandler struct { + cacheManager *pdf.PDFCacheManager + responseBuilder interfaces.ResponseBuilder + logger *zap.Logger +} + +// NewPDFGHandler 创建PDFG处理器 +func NewPDFGHandler( + cacheManager *pdf.PDFCacheManager, + responseBuilder interfaces.ResponseBuilder, + logger *zap.Logger, +) *PDFGHandler { + return &PDFGHandler{ + cacheManager: cacheManager, + responseBuilder: responseBuilder, + logger: logger, + } +} + +// DownloadPDF 下载PDF文件 +// GET /api/v1/pdfg/download?name=xxx&id_card=xxx +func (h *PDFGHandler) DownloadPDF(c *gin.Context) { + name := c.Query("name") + idCard := c.Query("id_card") + + if name == "" || idCard == "" { + h.responseBuilder.BadRequest(c, "姓名和身份证号不能为空") + return + } + + // 从缓存获取PDF + pdfBytes, hit, createdAt, err := h.cacheManager.Get(name, idCard) + if err != nil { + h.logger.Error("获取PDF缓存失败", + zap.String("name", name), + zap.String("id_card", idCard), + zap.Error(err), + ) + h.responseBuilder.InternalError(c, "获取PDF文件失败") + return + } + + if !hit { + h.logger.Warn("PDF文件不存在或已过期", + zap.String("name", name), + zap.String("id_card", idCard), + ) + h.responseBuilder.NotFound(c, "PDF文件不存在或已过期,请重新生成") + return + } + + // 检查是否过期(从文件生成时间开始算24小时) + expiresAt := createdAt.Add(24 * time.Hour) + if time.Now().After(expiresAt) { + h.logger.Warn("PDF文件已过期", + zap.String("name", name), + zap.String("id_card", idCard), + zap.Time("expires_at", expiresAt), + ) + h.responseBuilder.NotFound(c, "PDF文件已过期,请重新生成") + return + } + + // 设置响应头 + c.Header("Content-Type", "application/pdf") + c.Header("Content-Disposition", `attachment; filename="大数据租赁风险报告.pdf"`) + c.Header("Content-Length", fmt.Sprintf("%d", len(pdfBytes))) + + // 发送PDF文件 + c.Data(http.StatusOK, "application/pdf", pdfBytes) + + h.logger.Info("PDF文件下载成功", + zap.String("name", name), + zap.String("id_card", idCard), + zap.Int("file_size", len(pdfBytes)), + ) +} + diff --git a/internal/infrastructure/http/handlers/product_handler.go b/internal/infrastructure/http/handlers/product_handler.go index 5a87f74..09e8663 100644 --- a/internal/infrastructure/http/handlers/product_handler.go +++ b/internal/infrastructure/http/handlers/product_handler.go @@ -826,7 +826,7 @@ func (h *ProductHandler) DownloadProductDocumentation(c *gin.Context) { if h.pdfCacheManager != nil { var cacheErr error - pdfBytes, cacheHit, cacheErr = h.pdfCacheManager.Get(productID, docVersion) + pdfBytes, cacheHit, cacheErr = h.pdfCacheManager.GetByProduct(productID, docVersion) if cacheErr != nil { h.logger.Warn("从缓存获取PDF失败,将重新生成", zap.String("product_id", productID), @@ -972,7 +972,7 @@ func (h *ProductHandler) DownloadProductDocumentation(c *gin.Context) { // 保存到缓存(异步,不阻塞响应) if h.pdfCacheManager != nil { go func() { - if err := h.pdfCacheManager.Set(productID, docVersion, pdfBytes); err != nil { + if err := h.pdfCacheManager.SetByProduct(productID, docVersion, pdfBytes); err != nil { h.logger.Warn("保存PDF到缓存失败", zap.String("product_id", productID), zap.String("version", docVersion), diff --git a/internal/infrastructure/http/routes/certification_routes.go b/internal/infrastructure/http/routes/certification_routes.go index bb96956..9a487e3 100644 --- a/internal/infrastructure/http/routes/certification_routes.go +++ b/internal/infrastructure/http/routes/certification_routes.go @@ -66,6 +66,9 @@ func (r *CertificationRoutes) Register(router *http.GinRouter) { // 前端确认是否完成签署 authGroup.POST("/confirm-sign", r.handler.ConfirmSign) + // 管理员代用户完成认证(暂不关联合同) + authGroup.POST("/admin/complete-without-contract", r.handler.AdminCompleteCertificationWithoutContract) + } // 回调路由(不需要认证,但需要验证签名) diff --git a/internal/infrastructure/http/routes/pdfg_routes.go b/internal/infrastructure/http/routes/pdfg_routes.go new file mode 100644 index 0000000..71af9f9 --- /dev/null +++ b/internal/infrastructure/http/routes/pdfg_routes.go @@ -0,0 +1,39 @@ +package routes + +import ( + "go.uber.org/zap" + + sharedhttp "tyapi-server/internal/shared/http" + "tyapi-server/internal/infrastructure/http/handlers" +) + +// PDFGRoutes PDFG路由 +type PDFGRoutes struct { + pdfgHandler *handlers.PDFGHandler + logger *zap.Logger +} + +// NewPDFGRoutes 创建PDFG路由 +func NewPDFGRoutes( + pdfgHandler *handlers.PDFGHandler, + logger *zap.Logger, +) *PDFGRoutes { + return &PDFGRoutes{ + pdfgHandler: pdfgHandler, + logger: logger, + } +} + +// Register 注册相关路由 +func (r *PDFGRoutes) Register(router *sharedhttp.GinRouter) { + engine := router.GetEngine() + apiGroup := engine.Group("/api/v1") + + { + // PDF下载接口 - 不需要认证(因为下载链接已经包含了验证信息) + apiGroup.GET("/pdfg/download", r.pdfgHandler.DownloadPDF) + } + + r.logger.Info("PDFG路由注册完成") +} + diff --git a/internal/shared/pdf/pdf_cache_manager.go b/internal/shared/pdf/pdf_cache_manager.go index 8f1ae83..4874838 100644 --- a/internal/shared/pdf/pdf_cache_manager.go +++ b/internal/shared/pdf/pdf_cache_manager.go @@ -12,39 +12,31 @@ import ( "go.uber.org/zap" ) -// PDFCacheManager PDF缓存管理器 -// 负责管理PDF文件的本地缓存,提高下载性能 +// PDFCacheManager PDF缓存管理器(统一实现) +// 支持两种缓存键生成方式: +// 1. 基于姓名+身份证(用于PDFG报告) +// 2. 基于产品ID+版本(用于产品文档) type PDFCacheManager struct { logger *zap.Logger cacheDir string ttl time.Duration // 缓存过期时间 - maxSize int64 // 最大缓存大小(字节) + maxSize int64 // 最大缓存大小(字节,0表示不限制) mu sync.RWMutex // 保护并发访问 cleanupOnce sync.Once // 确保清理任务只启动一次 } -// CacheInfo 缓存信息 -type CacheInfo struct { - FilePath string - Size int64 - CreatedAt time.Time - ExpiresAt time.Time - ProductID string - Version string -} - // NewPDFCacheManager 创建PDF缓存管理器 +// cacheDir: 缓存目录(空则使用默认目录) +// ttl: 缓存过期时间 +// maxSize: 最大缓存大小(字节,0表示不限制) func NewPDFCacheManager(logger *zap.Logger, cacheDir string, ttl time.Duration, maxSize int64) (*PDFCacheManager, error) { - // 如果缓存目录为空,使用项目根目录的storage/component-reports/cache目录 + // 如果缓存目录为空,使用项目根目录的storage/pdfg-cache目录 if cacheDir == "" { - // 获取当前工作目录并构建项目根目录路径 wd, err := os.Getwd() if err != nil { - // 如果获取工作目录失败,回退到临时目录 - cacheDir = filepath.Join(os.TempDir(), "tyapi_pdf_cache") + cacheDir = filepath.Join(os.TempDir(), "tyapi_pdfg_cache") } else { - // 构建项目根目录下的storage/component-reports/cache路径 - cacheDir = filepath.Join(wd, "storage", "component-reports", "cache") + cacheDir = filepath.Join(wd, "storage", "pdfg-cache") } } @@ -68,13 +60,19 @@ func NewPDFCacheManager(logger *zap.Logger, cacheDir string, ttl time.Duration, zap.Duration("ttl", ttl), zap.Int64("max_size", maxSize), ) + return manager, nil } -// GetCacheKey 生成缓存键 -// 基于产品ID和文档版本号生成唯一的缓存键 -func (m *PDFCacheManager) GetCacheKey(productID string, version string) string { - // 使用MD5哈希生成短键名 +// GetCacheKey 生成缓存键(基于姓名+身份证) +func (m *PDFCacheManager) GetCacheKey(name, idCard string) string { + key := fmt.Sprintf("%s:%s", name, idCard) + hash := md5.Sum([]byte(key)) + return hex.EncodeToString(hash[:]) +} + +// GetCacheKeyByProduct 生成缓存键(基于产品ID+版本) +func (m *PDFCacheManager) GetCacheKeyByProduct(productID, version string) string { key := fmt.Sprintf("%s:%s", productID, version) hash := md5.Sum([]byte(key)) return hex.EncodeToString(hash[:]) @@ -85,10 +83,23 @@ func (m *PDFCacheManager) GetCachePath(cacheKey string) string { return filepath.Join(m.cacheDir, fmt.Sprintf("%s.pdf", cacheKey)) } -// Get 从缓存获取PDF文件 -// 返回PDF字节流和是否命中缓存 -func (m *PDFCacheManager) Get(productID string, version string) ([]byte, bool, error) { - cacheKey := m.GetCacheKey(productID, version) +// Get 从缓存获取PDF文件(基于姓名+身份证) +// 返回PDF字节流、是否命中缓存、文件创建时间、错误 +func (m *PDFCacheManager) Get(name, idCard string) ([]byte, bool, time.Time, error) { + cacheKey := m.GetCacheKey(name, idCard) + return m.getByKey(cacheKey, name, idCard) +} + +// GetByProduct 从缓存获取PDF文件(基于产品ID+版本) +// 返回PDF字节流、是否命中缓存、错误 +func (m *PDFCacheManager) GetByProduct(productID, version string) ([]byte, bool, error) { + cacheKey := m.GetCacheKeyByProduct(productID, version) + pdfBytes, hit, _, err := m.getByKey(cacheKey, productID, version) + return pdfBytes, hit, err +} + +// getByKey 内部方法:根据缓存键获取文件 +func (m *PDFCacheManager) getByKey(cacheKey string, key1, key2 string) ([]byte, bool, time.Time, error) { cachePath := m.GetCachePath(cacheKey) m.mu.RLock() @@ -98,85 +109,74 @@ func (m *PDFCacheManager) Get(productID string, version string) ([]byte, bool, e info, err := os.Stat(cachePath) if err != nil { if os.IsNotExist(err) { - return nil, false, nil // 缓存未命中 + return nil, false, time.Time{}, nil // 缓存未命中 } - return nil, false, fmt.Errorf("检查缓存文件失败: %w", err) + return nil, false, time.Time{}, fmt.Errorf("检查缓存文件失败: %w", err) } - // 检查文件是否过期 - // 使用文件的修改时间作为创建时间 + // 检查文件是否过期(从文件生成时间开始算24小时) createdAt := info.ModTime() expiresAt := createdAt.Add(m.ttl) if time.Now().After(expiresAt) { // 缓存已过期,删除文件 m.logger.Debug("缓存已过期,删除文件", - zap.String("product_id", productID), + zap.String("key1", key1), + zap.String("key2", key2), zap.String("cache_key", cacheKey), zap.Time("expires_at", expiresAt), ) _ = os.Remove(cachePath) - return nil, false, nil + return nil, false, time.Time{}, nil } // 读取缓存文件 pdfBytes, err := os.ReadFile(cachePath) if err != nil { - return nil, false, fmt.Errorf("读取缓存文件失败: %w", err) + return nil, false, time.Time{}, fmt.Errorf("读取缓存文件失败: %w", err) } m.logger.Debug("缓存命中", - zap.String("product_id", productID), + zap.String("key1", key1), + zap.String("key2", key2), zap.String("cache_key", cacheKey), zap.Int64("file_size", int64(len(pdfBytes))), zap.Time("expires_at", expiresAt), ) - return pdfBytes, true, nil + return pdfBytes, true, createdAt, nil } -// Set 将PDF文件保存到缓存 -func (m *PDFCacheManager) Set(productID string, version string, pdfBytes []byte) error { - cacheKey := m.GetCacheKey(productID, version) +// Set 将PDF文件保存到缓存(基于姓名+身份证) +func (m *PDFCacheManager) Set(name, idCard string, pdfBytes []byte) error { + cacheKey := m.GetCacheKey(name, idCard) + return m.setByKey(cacheKey, pdfBytes, name, idCard) +} + +// SetByProduct 将PDF文件保存到缓存(基于产品ID+版本) +func (m *PDFCacheManager) SetByProduct(productID, version string, pdfBytes []byte) error { + cacheKey := m.GetCacheKeyByProduct(productID, version) + return m.setByKey(cacheKey, pdfBytes, productID, version) +} + +// setByKey 内部方法:根据缓存键保存文件 +func (m *PDFCacheManager) setByKey(cacheKey string, pdfBytes []byte, key1, key2 string) error { cachePath := m.GetCachePath(cacheKey) m.mu.Lock() defer m.mu.Unlock() - // 检查是否已存在缓存文件(用于判断是新建还是更新) - fileExists := false - var oldFileSize int64 - if info, err := os.Stat(cachePath); err == nil { - fileExists = true - oldFileSize = info.Size() - m.logger.Info("检测到已存在的缓存文件,将更新缓存", - zap.String("product_id", productID), - zap.String("version", version), - zap.String("cache_key", cacheKey), - zap.String("cache_path", cachePath), - zap.Int64("old_file_size", oldFileSize), - zap.Int64("new_file_size", int64(len(pdfBytes))), - ) - } else { - m.logger.Info("开始创建新的PDF缓存", - zap.String("product_id", productID), - zap.String("version", version), - zap.String("cache_key", cacheKey), - zap.String("cache_path", cachePath), - zap.Int64("file_size", int64(len(pdfBytes))), - ) - } - // 检查缓存大小限制 if m.maxSize > 0 { currentSize, err := m.getCacheDirSize() if err != nil { m.logger.Warn("获取缓存目录大小失败", zap.Error(err)) } else { - // 如果更新已存在的文件,需要减去旧文件的大小 - sizeToAdd := int64(len(pdfBytes)) - if fileExists { - sizeToAdd = int64(len(pdfBytes)) - oldFileSize + // 检查是否已存在文件 + var oldFileSize int64 + if info, err := os.Stat(cachePath); err == nil { + oldFileSize = info.Size() } + sizeToAdd := int64(len(pdfBytes)) - oldFileSize if currentSize+sizeToAdd > m.maxSize { // 缓存空间不足,清理过期文件 @@ -184,9 +184,8 @@ func (m *PDFCacheManager) Set(productID string, version string, pdfBytes []byte) zap.Int64("current_size", currentSize), zap.Int64("max_size", m.maxSize), zap.Int64("required_size", sizeToAdd), - zap.Bool("is_update", fileExists), ) - if err := m.cleanupExpiredFiles(); err != nil { + if err := m.cleanExpiredFiles(); err != nil { m.logger.Warn("清理过期文件失败", zap.Error(err)) } // 再次检查 @@ -203,250 +202,18 @@ func (m *PDFCacheManager) Set(productID string, version string, pdfBytes []byte) } } - // 写入缓存文件 + // 写入文件 if err := os.WriteFile(cachePath, pdfBytes, 0644); err != nil { - m.logger.Error("写入缓存文件失败", - zap.String("product_id", productID), - zap.String("cache_path", cachePath), - zap.Error(err), - ) return fmt.Errorf("写入缓存文件失败: %w", err) } - // 计算过期时间 - expiresAt := time.Now().Add(m.ttl) - - // 根据是新建还是更新,记录不同的日志 - if fileExists { - m.logger.Info("PDF缓存已更新", - zap.String("product_id", productID), - zap.String("version", version), - zap.String("cache_key", cacheKey), - zap.String("cache_path", cachePath), - zap.Int64("old_file_size", oldFileSize), - zap.Int64("new_file_size", int64(len(pdfBytes))), - zap.Int64("size_change", int64(len(pdfBytes))-oldFileSize), - zap.Duration("ttl", m.ttl), - zap.Time("expires_at", expiresAt), - ) - } else { - m.logger.Info("PDF缓存已创建", - zap.String("product_id", productID), - zap.String("version", version), - zap.String("cache_key", cacheKey), - zap.String("cache_path", cachePath), - zap.Int64("file_size", int64(len(pdfBytes))), - zap.Duration("ttl", m.ttl), - zap.Time("expires_at", expiresAt), - ) - } - - return nil -} - -// Invalidate 使缓存失效 -func (m *PDFCacheManager) Invalidate(productID string, version string) error { - cacheKey := m.GetCacheKey(productID, version) - cachePath := m.GetCachePath(cacheKey) - - m.logger.Info("开始使缓存失效", - zap.String("product_id", productID), - zap.String("version", version), + m.logger.Debug("PDF已保存到缓存", + zap.String("key1", key1), + zap.String("key2", key2), zap.String("cache_key", cacheKey), - zap.String("cache_path", cachePath), + zap.Int64("file_size", int64(len(pdfBytes))), ) - m.mu.Lock() - defer m.mu.Unlock() - - // 检查文件是否存在,记录文件信息 - fileInfo, err := os.Stat(cachePath) - if err != nil { - if os.IsNotExist(err) { - m.logger.Info("缓存文件不存在,视为已失效", - zap.String("product_id", productID), - zap.String("cache_key", cacheKey), - zap.String("cache_path", cachePath), - ) - return nil // 文件不存在,视为已失效 - } - m.logger.Error("检查缓存文件失败", - zap.String("product_id", productID), - zap.String("cache_path", cachePath), - zap.Error(err), - ) - return fmt.Errorf("检查缓存文件失败: %w", err) - } - - // 记录文件信息 - fileSize := fileInfo.Size() - fileModTime := fileInfo.ModTime() - - // 删除缓存文件 - if err := os.Remove(cachePath); err != nil { - m.logger.Error("删除缓存文件失败", - zap.String("product_id", productID), - zap.String("cache_path", cachePath), - zap.Error(err), - ) - return fmt.Errorf("删除缓存文件失败: %w", err) - } - - m.logger.Info("缓存已成功失效", - zap.String("product_id", productID), - zap.String("version", version), - zap.String("cache_key", cacheKey), - zap.String("cache_path", cachePath), - zap.Int64("deleted_file_size", fileSize), - zap.Time("file_created_at", fileModTime), - ) - - return nil -} - -// InvalidateByProductID 使指定产品的所有缓存失效 -func (m *PDFCacheManager) InvalidateByProductID(productID string) error { - m.mu.Lock() - defer m.mu.Unlock() - - // 遍历缓存目录,查找匹配的文件 - files, err := os.ReadDir(m.cacheDir) - if err != nil { - return fmt.Errorf("读取缓存目录失败: %w", err) - } - - count := 0 - for _, file := range files { - if file.IsDir() { - continue - } - // 读取文件内容,检查是否匹配产品ID - // 由于我们使用MD5哈希,无法直接匹配,需要读取文件元数据 - // 这里简化处理:删除所有PDF文件(实际应该存储元数据) - // 更好的方案是使用数据库或JSON文件存储元数据 - if filepath.Ext(file.Name()) == ".pdf" { - filePath := filepath.Join(m.cacheDir, file.Name()) - if err := os.Remove(filePath); err == nil { - count++ - } - } - } - - m.logger.Info("已清理产品缓存", - zap.String("product_id", productID), - zap.Int("deleted_count", count), - ) - - return nil -} - -// Clear 清空所有缓存 -func (m *PDFCacheManager) Clear() error { - m.mu.Lock() - defer m.mu.Unlock() - - files, err := os.ReadDir(m.cacheDir) - if err != nil { - return fmt.Errorf("读取缓存目录失败: %w", err) - } - - count := 0 - for _, file := range files { - if !file.IsDir() && filepath.Ext(file.Name()) == ".pdf" { - filePath := filepath.Join(m.cacheDir, file.Name()) - if err := os.Remove(filePath); err == nil { - count++ - } - } - } - - m.logger.Info("已清空所有缓存", zap.Int("deleted_count", count)) - return nil -} - -// GetCacheStats 获取缓存统计信息 -func (m *PDFCacheManager) GetCacheStats() (map[string]interface{}, error) { - m.mu.RLock() - defer m.mu.RUnlock() - - files, err := os.ReadDir(m.cacheDir) - if err != nil { - return nil, fmt.Errorf("读取缓存目录失败: %w", err) - } - - var totalSize int64 - var fileCount int - var expiredCount int - now := time.Now() - - for _, file := range files { - if file.IsDir() { - continue - } - if filepath.Ext(file.Name()) == ".pdf" { - filePath := filepath.Join(m.cacheDir, file.Name()) - info, err := os.Stat(filePath) - if err != nil { - continue - } - totalSize += info.Size() - fileCount++ - // 检查是否过期 - expiresAt := info.ModTime().Add(m.ttl) - if now.After(expiresAt) { - expiredCount++ - } - } - } - - return map[string]interface{}{ - "total_size": totalSize, - "file_count": fileCount, - "expired_count": expiredCount, - "cache_dir": m.cacheDir, - "ttl": m.ttl.String(), - "max_size": m.maxSize, - }, nil -} - -// cleanupExpiredFiles 清理过期的缓存文件 -func (m *PDFCacheManager) cleanupExpiredFiles() error { - files, err := os.ReadDir(m.cacheDir) - if err != nil { - return fmt.Errorf("读取缓存目录失败: %w", err) - } - - now := time.Now() - count := 0 - var totalFreed int64 - - for _, file := range files { - if file.IsDir() { - continue - } - if filepath.Ext(file.Name()) == ".pdf" { - filePath := filepath.Join(m.cacheDir, file.Name()) - info, err := os.Stat(filePath) - if err != nil { - continue - } - expiresAt := info.ModTime().Add(m.ttl) - if now.After(expiresAt) { - if err := os.Remove(filePath); err == nil { - count++ - totalFreed += info.Size() - } - } - } - } - - if count > 0 { - m.logger.Info("已清理过期缓存文件", - zap.Int("count", count), - zap.Int64("freed_size", totalFreed), - ) - } - return nil } @@ -473,10 +240,169 @@ func (m *PDFCacheManager) startCleanupTask() { defer ticker.Stop() for range ticker.C { - if err := m.cleanupExpiredFiles(); err != nil { - m.logger.Warn("定期清理缓存失败", zap.Error(err)) + if err := m.cleanExpiredFiles(); err != nil { + m.logger.Warn("清理过期缓存文件失败", zap.Error(err)) } } }() }) } + +// cleanExpiredFiles 清理过期的缓存文件 +func (m *PDFCacheManager) cleanExpiredFiles() error { + m.mu.Lock() + defer m.mu.Unlock() + + entries, err := os.ReadDir(m.cacheDir) + if err != nil { + return fmt.Errorf("读取缓存目录失败: %w", err) + } + + now := time.Now() + cleanedCount := 0 + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + // 只处理PDF文件 + if filepath.Ext(entry.Name()) != ".pdf" { + continue + } + + filePath := filepath.Join(m.cacheDir, entry.Name()) + info, err := entry.Info() + if err != nil { + continue + } + + // 检查文件是否过期 + createdAt := info.ModTime() + expiresAt := createdAt.Add(m.ttl) + if now.After(expiresAt) { + if err := os.Remove(filePath); err != nil { + m.logger.Warn("删除过期缓存文件失败", + zap.String("file_path", filePath), + zap.Error(err), + ) + } else { + cleanedCount++ + } + } + } + + if cleanedCount > 0 { + m.logger.Info("清理过期缓存文件完成", + zap.Int("cleaned_count", cleanedCount), + ) + } + + return nil +} + +// Invalidate 使缓存失效(基于产品ID+版本) +func (m *PDFCacheManager) Invalidate(productID, version string) error { + cacheKey := m.GetCacheKeyByProduct(productID, version) + cachePath := m.GetCachePath(cacheKey) + + m.mu.Lock() + defer m.mu.Unlock() + + if err := os.Remove(cachePath); err != nil { + if os.IsNotExist(err) { + return nil // 文件不存在,视为已失效 + } + return fmt.Errorf("删除缓存文件失败: %w", err) + } + + return nil +} + +// InvalidateByNameIDCard 使缓存失效(基于姓名+身份证) +func (m *PDFCacheManager) InvalidateByNameIDCard(name, idCard string) error { + cacheKey := m.GetCacheKey(name, idCard) + cachePath := m.GetCachePath(cacheKey) + + m.mu.Lock() + defer m.mu.Unlock() + + if err := os.Remove(cachePath); err != nil { + if os.IsNotExist(err) { + return nil // 文件不存在,视为已失效 + } + return fmt.Errorf("删除缓存文件失败: %w", err) + } + + return nil +} + +// Clear 清空所有缓存 +func (m *PDFCacheManager) Clear() error { + m.mu.Lock() + defer m.mu.Unlock() + + entries, err := os.ReadDir(m.cacheDir) + if err != nil { + return fmt.Errorf("读取缓存目录失败: %w", err) + } + + count := 0 + for _, entry := range entries { + if !entry.IsDir() && filepath.Ext(entry.Name()) == ".pdf" { + filePath := filepath.Join(m.cacheDir, entry.Name()) + if err := os.Remove(filePath); err == nil { + count++ + } + } + } + + m.logger.Info("已清空所有缓存", zap.Int("deleted_count", count)) + return nil +} + +// GetCacheStats 获取缓存统计信息 +func (m *PDFCacheManager) GetCacheStats() (map[string]interface{}, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + entries, err := os.ReadDir(m.cacheDir) + if err != nil { + return nil, fmt.Errorf("读取缓存目录失败: %w", err) + } + + var totalSize int64 + var fileCount int + var expiredCount int + now := time.Now() + + for _, entry := range entries { + if entry.IsDir() { + continue + } + if filepath.Ext(entry.Name()) == ".pdf" { + filePath := filepath.Join(m.cacheDir, entry.Name()) + info, err := os.Stat(filePath) + if err != nil { + continue + } + totalSize += info.Size() + fileCount++ + // 检查是否过期 + expiresAt := info.ModTime().Add(m.ttl) + if now.After(expiresAt) { + expiredCount++ + } + } + } + + return map[string]interface{}{ + "total_size": totalSize, + "file_count": fileCount, + "expired_count": expiredCount, + "cache_dir": m.cacheDir, + "ttl": m.ttl.String(), + "max_size": m.maxSize, + }, nil +} +