From 3b7bf1052ea5b5405942502313f5b43ce7e59066 Mon Sep 17 00:00:00 2001 From: liangzai <2440983361@qq.com> Date: Tue, 28 Apr 2026 12:25:41 +0800 Subject: [PATCH] add huibo ivyz4y27 --- config.yaml | 36 ++ internal/config/config.go | 32 ++ internal/container/container.go | 11 +- internal/domains/api/dto/api_request_dto.go | 5 + .../api/services/api_request_service.go | 9 +- .../api/services/form_config_service.go | 5 +- .../api/services/processors/dependencies.go | 4 + .../processors/ivyz/ivyz4y27_processor.go | 33 ++ .../external/huibo/huibo_factory.go | 45 ++ .../external/huibo/huibo_service.go | 414 ++++++++++++++++++ internal/shared/pdfvalidate/pdfvalidate.go | 27 ++ .../shared/validator/custom_validators.go | 17 + 12 files changed, 632 insertions(+), 6 deletions(-) create mode 100644 internal/domains/api/services/processors/ivyz/ivyz4y27_processor.go create mode 100644 internal/infrastructure/external/huibo/huibo_factory.go create mode 100644 internal/infrastructure/external/huibo/huibo_service.go create mode 100644 internal/shared/pdfvalidate/pdfvalidate.go diff --git a/config.yaml b/config.yaml index 675860a..03ee80e 100644 --- a/config.yaml +++ b/config.yaml @@ -641,3 +641,39 @@ shujubao: max_backups: 5 max_age: 30 compress: true + +# =========================================== +# ✨ 汇博(BHSC)配置 +# =========================================== +huibo: + url: "http://47.111.187.101:12654/api/v1/project-api/bg_check_ssw" + app_id: "db0029527bb4558c" + app_key: "a6c9935e967894e731c62ecfcd9b7c95" + x_order_code: "cpdd219219725093" + secret_id: "cf581fe84aaf46ca" + aes_key: "NQYN3YO+pb/GEcCBNX0ptMb7cUlnXSPvcX7VvNofBkc=" + work_order_code: "gd219219725093" + product_code: "22089" + + logging: + enabled: true + log_dir: "logs/external_services" + service_name: "huibo" + use_daily: true + enable_level_separation: true + level_configs: + info: + max_size: 100 + max_backups: 5 + max_age: 30 + compress: true + error: + max_size: 200 + max_backups: 10 + max_age: 90 + compress: true + warn: + max_size: 100 + max_backups: 5 + max_age: 30 + compress: true diff --git a/internal/config/config.go b/internal/config/config.go index 3b8629c..19366b0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,6 +44,7 @@ type Config struct { Shumai ShumaiConfig `mapstructure:"shumai"` Shujubao ShujubaoConfig `mapstructure:"shujubao"` PDFGen PDFGenConfig `mapstructure:"pdfgen"` + Huibo HuiboConfig `mapstructure:"huibo"` } // ServerConfig HTTP服务器配置 @@ -671,6 +672,37 @@ type PDFGenCacheConfig struct { MaxSize int64 `mapstructure:"max_size"` // 最大缓存大小(0表示不限制,单位:字节) } +// HuiboConfig 汇博(BHSC)配置 +type HuiboConfig struct { + URL string `mapstructure:"url"` + AppID string `mapstructure:"app_id"` + AppKey string `mapstructure:"app_key"` + XOrderCode string `mapstructure:"x_order_code"` + SecretID string `mapstructure:"secret_id"` + AESKey string `mapstructure:"aes_key"` + WorkOrderCode string `mapstructure:"work_order_code"` + ProductCode string `mapstructure:"product_code"` + + Logging HuiboLoggingConfig `mapstructure:"logging"` +} + +// HuiboLoggingConfig 汇博日志配置 +type HuiboLoggingConfig struct { + Enabled bool `mapstructure:"enabled"` + LogDir string `mapstructure:"log_dir"` + UseDaily bool `mapstructure:"use_daily"` + EnableLevelSeparation bool `mapstructure:"enable_level_separation"` + LevelConfigs map[string]HuiboLevelFileConfig `mapstructure:"level_configs"` +} + +// HuiboLevelFileConfig 汇博级别日志配置 +type HuiboLevelFileConfig struct { + MaxSize int `mapstructure:"max_size"` + MaxBackups int `mapstructure:"max_backups"` + MaxAge int `mapstructure:"max_age"` + Compress bool `mapstructure:"compress"` +} + // DomainConfig 域名配置 type DomainConfig struct { API string `mapstructure:"api"` // API域名 diff --git a/internal/container/container.go b/internal/container/container.go index 9a48ebd..0d9b43d 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -15,8 +15,8 @@ import ( "tyapi-server/internal/application/certification" "tyapi-server/internal/application/finance" "tyapi-server/internal/application/product" - subordinate_app "tyapi-server/internal/application/subordinate" "tyapi-server/internal/application/statistics" + subordinate_app "tyapi-server/internal/application/subordinate" "tyapi-server/internal/application/user" "tyapi-server/internal/config" api_repositories "tyapi-server/internal/domains/api/repositories" @@ -28,8 +28,8 @@ import ( finance_service "tyapi-server/internal/domains/finance/services" domain_product_repo "tyapi-server/internal/domains/product/repositories" product_service "tyapi-server/internal/domains/product/services" - domain_subordinate_repo "tyapi-server/internal/domains/subordinate/repositories" statistics_service "tyapi-server/internal/domains/statistics/services" + domain_subordinate_repo "tyapi-server/internal/domains/subordinate/repositories" user_service "tyapi-server/internal/domains/user/services" "tyapi-server/internal/infrastructure/cache" "tyapi-server/internal/infrastructure/database" @@ -39,10 +39,10 @@ import ( product_repo "tyapi-server/internal/infrastructure/database/repositories/product" subordinate_db "tyapi-server/internal/infrastructure/database/repositories/subordinate" infra_events "tyapi-server/internal/infrastructure/events" - subordinate_infra "tyapi-server/internal/infrastructure/subordinate" "tyapi-server/internal/infrastructure/external/alicloud" "tyapi-server/internal/infrastructure/external/captcha" "tyapi-server/internal/infrastructure/external/email" + "tyapi-server/internal/infrastructure/external/huibo" "tyapi-server/internal/infrastructure/external/jiguang" "tyapi-server/internal/infrastructure/external/muzi" "tyapi-server/internal/infrastructure/external/ocr" @@ -57,6 +57,7 @@ import ( "tyapi-server/internal/infrastructure/external/zhicha" "tyapi-server/internal/infrastructure/http/handlers" "tyapi-server/internal/infrastructure/http/routes" + subordinate_infra "tyapi-server/internal/infrastructure/subordinate" "tyapi-server/internal/infrastructure/task" task_implementations "tyapi-server/internal/infrastructure/task/implementations" asynq "tyapi-server/internal/infrastructure/task/implementations/asynq" @@ -396,6 +397,10 @@ func NewContainer() *Container { func(cfg *config.Config) (*shumai.ShumaiService, error) { return shumai.NewShumaiServiceWithConfig(cfg) }, + // HuiboService - 汇博(BHSC)服务 + func(cfg *config.Config) (*huibo.HuiboService, error) { + return huibo.NewHuiboServiceWithConfig(cfg) + }, // ShujubaoService - 数据宝服务 func(cfg *config.Config) (*shujubao.ShujubaoService, error) { return shujubao.NewShujubaoServiceWithConfig(cfg) diff --git a/internal/domains/api/dto/api_request_dto.go b/internal/domains/api/dto/api_request_dto.go index c214f56..002460c 100644 --- a/internal/domains/api/dto/api_request_dto.go +++ b/internal/domains/api/dto/api_request_dto.go @@ -522,6 +522,11 @@ type IVYZ9K2LReq struct { IDCard string `json:"id_card" validate:"required,validIDCard"` PhotoData string `json:"photo_data" validate:"required,validBase64Image"` } +type IVYZ4Y27Req struct { + Name string `json:"name" validate:"required,min=1,validName"` + IDCard string `json:"id_card" validate:"required,validIDCard"` + AuthAuthorizeFileBase64 string `json:"auth_authorize_file_base64" validate:"required,validBase64PDF"` +} type IVYZP2Q6Req struct { Name string `json:"name" validate:"required,min=1,validName"` IDCard string `json:"id_card" validate:"required,validIDCard"` diff --git a/internal/domains/api/services/api_request_service.go b/internal/domains/api/services/api_request_service.go index 16b9da9..e6cbc87 100644 --- a/internal/domains/api/services/api_request_service.go +++ b/internal/domains/api/services/api_request_service.go @@ -21,6 +21,7 @@ import ( "tyapi-server/internal/domains/api/services/processors/yysy" "tyapi-server/internal/domains/product/services" "tyapi-server/internal/infrastructure/external/alicloud" + "tyapi-server/internal/infrastructure/external/huibo" "tyapi-server/internal/infrastructure/external/jiguang" "tyapi-server/internal/infrastructure/external/muzi" "tyapi-server/internal/infrastructure/external/shujubao" @@ -66,6 +67,7 @@ func NewApiRequestService( xingweiService *xingwei.XingweiService, jiguangService *jiguang.JiguangService, shumaiService *shumai.ShumaiService, + huiboService *huibo.HuiboService, validator interfaces.RequestValidator, productManagementService *services.ProductManagementService, cfg *appconfig.Config, @@ -81,6 +83,7 @@ func NewApiRequestService( xingweiService, jiguangService, shumaiService, + huiboService, validator, productManagementService, cfg, @@ -101,6 +104,7 @@ func NewApiRequestServiceWithRepos( xingweiService *xingwei.XingweiService, jiguangService *jiguang.JiguangService, shumaiService *shumai.ShumaiService, + huiboService *huibo.HuiboService, validator interfaces.RequestValidator, productManagementService *services.ProductManagementService, cfg *appconfig.Config, @@ -127,6 +131,7 @@ func NewApiRequestServiceWithRepos( xingweiService, jiguangService, shumaiService, + huiboService, validator, combService, reportRepo, @@ -331,8 +336,8 @@ func registerAllProcessors(combService *comb.CombService) { "IVYZ48SR": ivyz.ProcessIVYZ48SRRequest, //婚姻状态核验V2(双人) "IVYZ5E22": ivyz.ProcessIVYZ5E22Request, //双人婚姻评估查询zhicha版本 "IVYZRAX1": ivyz.ProcessIVYZRAX1Request, //融安信用分 - "IVYZRAX2": ivyz.ProcessIVYZRAX2Request,//融御反欺诈分 - + "IVYZRAX2": ivyz.ProcessIVYZRAX2Request, //融御反欺诈分 + "IVYZ4Y27": ivyz.ProcessIVYZ4Y27Request, //教育背景(详细)查询(PDF授权书) // COMB系列处理器 - 只注册有自定义逻辑的组合包 "COMB86PM": comb.ProcessCOMB86PMRequest, // 有自定义逻辑:重命名ApiCode diff --git a/internal/domains/api/services/form_config_service.go b/internal/domains/api/services/form_config_service.go index e8b39e8..64a9168 100644 --- a/internal/domains/api/services/form_config_service.go +++ b/internal/domains/api/services/form_config_service.go @@ -121,6 +121,7 @@ func (s *FormConfigServiceImpl) getDTOStruct(ctx context.Context, apiCode string "IVYZ9A2B": &dto.IVYZ9A2BReq{}, "IVYZ7F2A": &dto.IVYZ7F2AReq{}, "IVYZ4E8B": &dto.IVYZ4E8BReq{}, + "IVYZ4Y27": &dto.IVYZ4Y27Req{}, //教育背景(详细)PDF授权书 "IVYZ1C9D": &dto.IVYZ1C9DReq{}, "IVYZGZ08": &dto.IVYZGZ08Req{}, "FLXG8A3F": &dto.FLXG8A3FReq{}, @@ -401,6 +402,8 @@ func (s *FormConfigServiceImpl) parseValidationRules(validateTag string) string frontendRules = append(frontendRules, "Base64图片格式(JPG、BMP、PNG)") case rule == "base64" || rule == "validBase64": frontendRules = append(frontendRules, "Base64编码格式(支持图片/PDF)") + case rule == "validBase64PDF": + frontendRules = append(frontendRules, "PDF文件的Base64编码(仅PDF,最大500KB)") case strings.HasPrefix(rule, "oneof="): values := strings.TrimPrefix(rule, "oneof=") frontendRules = append(frontendRules, "可选值: "+values) @@ -507,7 +510,7 @@ func (s *FormConfigServiceImpl) generateFieldLabel(jsonTag string) string { "color": "颜色", "plate_color": "车牌颜色", "marital_type": "婚姻状况类型", - "auth_authorize_file_base64": "PDF授权文件Base64编码(5MB以内)", + "auth_authorize_file_base64": "PDF授权文件Base64编码(≤500KB,仅PDF)", } if label, exists := labelMap[jsonTag]; exists { diff --git a/internal/domains/api/services/processors/dependencies.go b/internal/domains/api/services/processors/dependencies.go index 20d755a..1455e2e 100644 --- a/internal/domains/api/services/processors/dependencies.go +++ b/internal/domains/api/services/processors/dependencies.go @@ -6,6 +6,7 @@ import ( "tyapi-server/internal/application/api/commands" "tyapi-server/internal/domains/api/repositories" "tyapi-server/internal/infrastructure/external/alicloud" + "tyapi-server/internal/infrastructure/external/huibo" "tyapi-server/internal/infrastructure/external/jiguang" "tyapi-server/internal/infrastructure/external/muzi" "tyapi-server/internal/infrastructure/external/shujubao" @@ -40,6 +41,7 @@ type ProcessorDependencies struct { XingweiService *xingwei.XingweiService JiguangService *jiguang.JiguangService ShumaiService *shumai.ShumaiService + HuiboService *huibo.HuiboService Validator interfaces.RequestValidator CombService CombServiceInterface // Changed to interface to break import cycle Options *commands.ApiCallOptions // 添加Options支持 @@ -67,6 +69,7 @@ func NewProcessorDependencies( xingweiService *xingwei.XingweiService, jiguangService *jiguang.JiguangService, shumaiService *shumai.ShumaiService, + huiboService *huibo.HuiboService, validator interfaces.RequestValidator, combService CombServiceInterface, // Changed to interface reportRepo repositories.ReportRepository, @@ -84,6 +87,7 @@ func NewProcessorDependencies( XingweiService: xingweiService, JiguangService: jiguangService, ShumaiService: shumaiService, + HuiboService: huiboService, Validator: validator, CombService: combService, Options: nil, // 初始化为nil,在调用时设置 diff --git a/internal/domains/api/services/processors/ivyz/ivyz4y27_processor.go b/internal/domains/api/services/processors/ivyz/ivyz4y27_processor.go new file mode 100644 index 0000000..9a5efdf --- /dev/null +++ b/internal/domains/api/services/processors/ivyz/ivyz4y27_processor.go @@ -0,0 +1,33 @@ +package ivyz + +import ( + "context" + "encoding/json" + "errors" + + "tyapi-server/internal/domains/api/dto" + "tyapi-server/internal/domains/api/services/processors" +) + +// ProcessIVYZ4Y27Request IVYZ4Y27 API处理方法 - 教育背景(详细)查询 +func ProcessIVYZ4Y27Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { + var paramsDto dto.IVYZ4Y27Req + 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) + } + + if deps.HuiboService == nil { + return nil, errors.Join(processors.ErrSystem, errors.New("汇博服务未初始化")) + } + + respBytes, err := deps.HuiboService.CallEducationBackgroundDetailed(ctx, paramsDto.Name, paramsDto.IDCard, paramsDto.AuthAuthorizeFileBase64) + if err != nil { + return nil, errors.Join(processors.ErrDatasource, err) + } + + return respBytes, nil +} diff --git a/internal/infrastructure/external/huibo/huibo_factory.go b/internal/infrastructure/external/huibo/huibo_factory.go new file mode 100644 index 0000000..4a65df3 --- /dev/null +++ b/internal/infrastructure/external/huibo/huibo_factory.go @@ -0,0 +1,45 @@ +package huibo + +import ( + "tyapi-server/internal/config" + "tyapi-server/internal/shared/external_logger" +) + +// NewHuiboServiceWithConfig 使用配置创建汇博服务 +func NewHuiboServiceWithConfig(cfg *config.Config) (*HuiboService, error) { + loggingConfig := external_logger.ExternalServiceLoggingConfig{ + Enabled: cfg.Huibo.Logging.Enabled, + LogDir: cfg.Huibo.Logging.LogDir, + ServiceName: "huibo", + UseDaily: cfg.Huibo.Logging.UseDaily, + EnableLevelSeparation: cfg.Huibo.Logging.EnableLevelSeparation, + LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig), + } + + for key, value := range cfg.Huibo.Logging.LevelConfigs { + loggingConfig.LevelConfigs[key] = external_logger.ExternalServiceLevelFileConfig{ + MaxSize: value.MaxSize, + MaxBackups: value.MaxBackups, + MaxAge: value.MaxAge, + Compress: value.Compress, + } + } + + logger, err := external_logger.NewExternalServiceLogger(loggingConfig) + if err != nil { + return nil, err + } + + service := NewHuiboService(HuiboConfig{ + URL: cfg.Huibo.URL, + AppID: cfg.Huibo.AppID, + AppKey: cfg.Huibo.AppKey, + XOrderCode: cfg.Huibo.XOrderCode, + SecretID: cfg.Huibo.SecretID, + AESKey: cfg.Huibo.AESKey, + WorkOrderCode: cfg.Huibo.WorkOrderCode, + ProductCode: cfg.Huibo.ProductCode, + }, logger) + + return service, nil +} diff --git a/internal/infrastructure/external/huibo/huibo_service.go b/internal/infrastructure/external/huibo/huibo_service.go new file mode 100644 index 0000000..29dfb0f --- /dev/null +++ b/internal/infrastructure/external/huibo/huibo_service.go @@ -0,0 +1,414 @@ +package huibo + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "tyapi-server/internal/shared/external_logger" + "tyapi-server/internal/shared/pdfvalidate" + + "go.uber.org/zap" +) + +var ( + ErrDatasource = errors.New("数据源异常") + ErrSystem = errors.New("系统异常") +) + +const ( + headerAuthorization = "Authorization" + headerYMDate = "YmDate" + headerOrderCode = "X-ORDER-CODE" + headerResponseType = "X-RESPONSE-TYPE" + headerResponseTypeDataVal = "data" +) + +// 汇博常见状态码 +const ( + HuiboStatusSuccess = "0" + HuiboStatusException = "1" + HuiboStatusNoData = "2" + HuiboStatusDataEmpty = "6010001" + HuiboStatusSignFailed = "6010002" + HuiboStatusDecryptFailed = "6010003" + HuiboStatusAppIDEmpty = "6010004" + HuiboStatusEncryptedEmpty = "6010005" + HuiboStatusRandomKeyEmpty = "6010006" + HuiboStatusTimestampEmpty = "6010007" + HuiboStatusProductCodeEmpty = "6010008" + HuiboStatusProductNotFound = "6010010" + HuiboStatusProductNotEnabled = "6010013" + HuiboStatusBalanceNotEnough = "6010020" + HuiboStatusUsageLimitReached = "6010021" +) + +var huiboStatusMessage = map[string]string{ + HuiboStatusSuccess: "操作成功", + HuiboStatusException: "异常", + HuiboStatusNoData: "数据未查得", + HuiboStatusDataEmpty: "请求体 data 为空", + HuiboStatusSignFailed: "验证签名失败", + HuiboStatusDecryptFailed: "使用 AES/SM4 加解密失败", + HuiboStatusAppIDEmpty: "appId 不能为空", + HuiboStatusEncryptedEmpty: "AES/SM4 加密后的内容不可为空", + HuiboStatusRandomKeyEmpty: "随机 AES/SM4 加密密钥不可为空", + HuiboStatusTimestampEmpty: "请求时间戳不可为空", + HuiboStatusProductCodeEmpty: "产品 code 不能为空", + HuiboStatusProductNotFound: "产品不存在", + HuiboStatusProductNotEnabled: "企业未开通产品", + HuiboStatusBalanceNotEnough: "企业账户余额不足", + HuiboStatusUsageLimitReached: "产品使用次数到达限制", +} + +type HuiboConfig struct { + URL string + AppID string + AppKey string + XOrderCode string + SecretID string + AESKey string + WorkOrderCode string + ProductCode string +} + +type HuiboService struct { + config HuiboConfig + logger *external_logger.ExternalServiceLogger +} + +type responseWrapper struct { + Code json.RawMessage `json:"code"` + Msg string `json:"msg"` + Data struct { + Status json.RawMessage `json:"status"` + Data string `json:"data"` + } `json:"data"` +} + +func NewHuiboService(config HuiboConfig, logger *external_logger.ExternalServiceLogger) *HuiboService { + return &HuiboService{config: config, logger: logger} +} + +// CallEducationBackgroundDetailed 教育背景(详细)查询 +func (s *HuiboService) CallEducationBackgroundDetailed(ctx context.Context, name, idCard, authPDFBase64 string) ([]byte, error) { + requestID := s.generateRequestID() + startTime := time.Now() + transactionID := "" + if v, ok := ctx.Value("transaction_id").(string); ok { + transactionID = v + } + + if s.logger != nil { + s.logger.LogRequest(requestID, transactionID, "huibo_bg_check_ssw", s.config.URL) + } + + if err := s.validateConfig(); err != nil { + return nil, errors.Join(ErrSystem, err) + } + + pdfBytes, err := decodeAndValidatePDF(authPDFBase64) + if err != nil { + return nil, errors.Join(ErrDatasource, err) + } + + bizParam := map[string]string{ + "productCode": s.getProductCode(), + "name": name, + "idCard": idCard, + } + + rawJSON, err := json.Marshal(bizParam) + if err != nil { + return nil, errors.Join(ErrSystem, err) + } + + encryptedData, err := encryptAESGCMBase64(string(rawJSON), s.config.AESKey) + if err != nil { + return nil, errors.Join(ErrSystem, fmt.Errorf("AES-GCM加密失败: %w", err)) + } + + sortedParam := generateSortedParam(bizParam) + signature := hmacSHA256Base64(sortedParam, s.config.AESKey) + + reqInner := map[string]string{ + "data": encryptedData, + "requestId": requestID, + "secretId": s.config.SecretID, + "signature": signature, + } + reqInnerBytes, err := json.Marshal(reqInner) + if err != nil { + return nil, errors.Join(ErrSystem, err) + } + reqOuter := map[string]string{"data": string(reqInnerBytes)} + reqOuterBytes, err := json.Marshal(reqOuter) + if err != nil { + return nil, errors.Join(ErrSystem, err) + } + + respBody, err := s.callAPI(ctx, reqOuterBytes, pdfBytes) + if err != nil { + if s.logger != nil { + s.logger.LogError(requestID, transactionID, "huibo_bg_check_ssw", err, map[string]interface{}{"name": name, "id_card": idCard}) + } + return nil, err + } + + if s.logger != nil { + s.logger.LogResponse(requestID, transactionID, "huibo_bg_check_ssw", http.StatusOK, time.Since(startTime)) + } + + var wrapper responseWrapper + if err = json.Unmarshal(respBody, &wrapper); err != nil { + return nil, errors.Join(ErrDatasource, fmt.Errorf("响应解析失败: %w", err)) + } + + outerCode := normalizeStatus(wrapper.Code) + outerMsg := strings.TrimSpace(wrapper.Msg) + if outerCode != "" && outerCode != "200" { + return nil, errors.Join(ErrDatasource, fmt.Errorf("汇博外层响应异常(code=%s,msg=%s)", outerCode, outerMsg)) + } + + status := normalizeStatus(wrapper.Data.Status) + // status=2「数据未查得」:产品约定按调用成功计费,对外返回 {}(与外层 code=200 成功一致,走应用层异步扣款) + if status == HuiboStatusNoData { + if s.logger != nil { + s.logger.LogInfo( + "汇博教育背景:数据未查得(status=2),返回空 JSON 并按成功计费", + zap.String("request_id", requestID), + zap.String("transaction_id", transactionID), + zap.String("name", name), + zap.String("id_card", idCard), + ) + } + return []byte("{}"), nil + } + + if status != HuiboStatusSuccess { + msg := wrapper.Data.Data + if strings.TrimSpace(msg) == "" { + msg = getHuiboStatusMessage(status) + } + if outerMsg != "" && !strings.Contains(msg, outerMsg) { + msg = msg + " | 外层消息: " + outerMsg + } + return nil, errors.Join(ErrDatasource, fmt.Errorf("汇博业务状态异常(status=%s,msg=%s)", status, msg)) + } + + if wrapper.Data.Data == "" { + return nil, errors.Join(ErrDatasource, errors.New("响应缺少加密数据")) + } + + decrypted, err := decryptAESGCMBase64(wrapper.Data.Data, s.config.AESKey) + if err != nil { + return nil, errors.Join(ErrDatasource, fmt.Errorf("响应解密失败: %w", err)) + } + return []byte(decrypted), nil +} + +func (s *HuiboService) callAPI(ctx context.Context, reqOuterJSON []byte, pdfBytes []byte) ([]byte, error) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("req", string(reqOuterJSON)); err != nil { + return nil, errors.Join(ErrSystem, err) + } + part, err := writer.CreateFormFile("file", "authorization.pdf") + if err != nil { + return nil, errors.Join(ErrSystem, err) + } + if _, err = part.Write(pdfBytes); err != nil { + return nil, errors.Join(ErrSystem, err) + } + if err = writer.Close(); err != nil { + return nil, errors.Join(ErrSystem, err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.config.URL, &body) + if err != nil { + return nil, errors.Join(ErrSystem, err) + } + req.Header.Set(headerAuthorization, s.config.AppID+"::"+s.config.AppKey) + req.Header.Set(headerYMDate, strconv.FormatInt(time.Now().UnixMilli(), 10)) + req.Header.Set(headerOrderCode, s.config.XOrderCode) + req.Header.Set(headerResponseType, headerResponseTypeDataVal) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, errors.Join(ErrDatasource, err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errors.Join(ErrSystem, err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码异常: %d, body: %s", resp.StatusCode, string(respBody))) + } + return respBody, nil +} + +func (s *HuiboService) validateConfig() error { + if strings.TrimSpace(s.config.URL) == "" || + strings.TrimSpace(s.config.AppID) == "" || + strings.TrimSpace(s.config.AppKey) == "" || + strings.TrimSpace(s.config.SecretID) == "" || + strings.TrimSpace(s.config.AESKey) == "" || + strings.TrimSpace(s.config.XOrderCode) == "" { + return errors.New("汇博配置不完整") + } + return nil +} + +func (s *HuiboService) getProductCode() string { + pc := strings.TrimSpace(s.config.ProductCode) + if pc == "" { + return "22089" + } + return pc +} + +func (s *HuiboService) generateRequestID() string { + return "ssw_" + time.Now().Format("060102150405000") + randomDigits(6) +} + +func decodeAndValidatePDF(base64PDF string) ([]byte, error) { + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(base64PDF)) + if err != nil { + return nil, fmt.Errorf("授权书文件base64格式错误: %w", err) + } + if err := pdfvalidate.ValidateDecodedPDFBinary(raw); err != nil { + return nil, err + } + return raw, nil +} + +func generateSortedParam(m map[string]string) string { + keys := make([]string, 0, len(m)) + for k, v := range m { + if strings.TrimSpace(v) == "" { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, k+"="+m[k]) + } + return strings.Join(parts, "&") +} + +func hmacSHA256Base64(data, secret string) string { + m := hmac.New(sha256.New, []byte(secret)) + _, _ = m.Write([]byte(data)) + return base64.StdEncoding.EncodeToString(m.Sum(nil)) +} + +func encryptAESGCMBase64(plainText, base64Key string) (string, error) { + key, err := base64.StdEncoding.DecodeString(base64Key) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + iv := make([]byte, 12) + if _, err = io.ReadFull(rand.Reader, iv); err != nil { + return "", err + } + ciphertext := gcm.Seal(nil, iv, []byte(plainText), nil) + out := append(iv, ciphertext...) + return base64.StdEncoding.EncodeToString(out), nil +} + +func decryptAESGCMBase64(encryptedBase64, base64Key string) (string, error) { + key, err := base64.StdEncoding.DecodeString(base64Key) + if err != nil { + return "", err + } + raw, err := base64.StdEncoding.DecodeString(encryptedBase64) + if err != nil { + return "", err + } + if len(raw) < 13 { + return "", errors.New("密文长度非法") + } + + iv := raw[:12] + ciphertext := raw[12:] + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + plain, err := gcm.Open(nil, iv, ciphertext, nil) + if err != nil { + return "", err + } + return string(plain), nil +} + +func normalizeStatus(raw json.RawMessage) string { + s := strings.TrimSpace(string(raw)) + if s == "" { + return "" + } + if strings.HasPrefix(s, "\"") && strings.HasSuffix(s, "\"") { + return strings.Trim(s, "\"") + } + return s +} + +func getHuiboStatusMessage(status string) string { + if msg, ok := huiboStatusMessage[status]; ok { + return msg + } + if status == "" { + return "数据源返回失败" + } + return "未知状态码: " + status +} + +func randomDigits(n int) string { + if n <= 0 { + return "" + } + raw := make([]byte, n) + if _, err := io.ReadFull(rand.Reader, raw); err != nil { + return strconv.FormatInt(time.Now().UnixNano(), 10) + } + b := make([]byte, n) + for i := 0; i < n; i++ { + b[i] = byte('0' + int(raw[i])%10) + } + return string(b) +} diff --git a/internal/shared/pdfvalidate/pdfvalidate.go b/internal/shared/pdfvalidate/pdfvalidate.go new file mode 100644 index 0000000..3bf64ed --- /dev/null +++ b/internal/shared/pdfvalidate/pdfvalidate.go @@ -0,0 +1,27 @@ +// Package pdfvalidate 对「已解码的 PDF 二进制」做格式与尺寸校验(与 multipart 发往数据源的字节一致) +package pdfvalidate + +import ( + "bytes" + "errors" + "fmt" +) + +// MaxAuthorizePDFBytes 授权类 PDF 大小上限(与汇博等对接约定一致) +const MaxAuthorizePDFBytes = 500 * 1024 + +var pdfMagic = []byte("%PDF-") + +// ValidateDecodedPDFBinary 仅校验已通过 Base64 解码得到的原始字节:非空、长度、PDF 魔数头部。 +func ValidateDecodedPDFBinary(raw []byte) error { + if len(raw) == 0 { + return errors.New("授权书文件不能为空") + } + if len(raw) > MaxAuthorizePDFBytes { + return fmt.Errorf("授权书文件不能超过500KB,当前大小: %d字节", len(raw)) + } + if len(raw) < len(pdfMagic) || !bytes.Equal(raw[:len(pdfMagic)], pdfMagic) { + return errors.New("授权书文件必须为PDF格式") + } + return nil +} diff --git a/internal/shared/validator/custom_validators.go b/internal/shared/validator/custom_validators.go index 34f9d67..d6e7c13 100644 --- a/internal/shared/validator/custom_validators.go +++ b/internal/shared/validator/custom_validators.go @@ -10,6 +10,7 @@ import ( "time" "github.com/go-playground/validator/v10" + "tyapi-server/internal/shared/pdfvalidate" ) // RegisterCustomValidators 注册所有自定义验证器 @@ -104,6 +105,9 @@ func RegisterCustomValidators(validate *validator.Validate) { // Base64编码格式验证器 validate.RegisterValidation("base64", validateBase64) validate.RegisterValidation("validBase64", validateBase64) + + // PDF 文件 Base64(与汇博授权书等场景一致:仅 PDF,≤500KB) + validate.RegisterValidation("validBase64PDF", validateBase64PDF) } // validatePhone 手机号验证 @@ -1037,3 +1041,16 @@ func validateBase64(fl validator.FieldLevel) bool { _, err := base64.StdEncoding.DecodeString(base64Str) return err == nil } + +// validateBase64PDF:先将入参当作 Base64 解码为二进制,再对二进制做 PDF 校验(与发往数据源的逻辑一致)。 +func validateBase64PDF(fl validator.FieldLevel) bool { + base64Str := strings.TrimSpace(fl.Field().String()) + if base64Str == "" { + return true + } + decoded, err := base64.StdEncoding.DecodeString(base64Str) + if err != nil { + return false + } + return pdfvalidate.ValidateDecodedPDFBinary(decoded) == nil +}