From ad732dc796e05c23dd3b47806c2ec262765dbfde Mon Sep 17 00:00:00 2001 From: liangzai <2440983361@qq.com> Date: Wed, 22 Jul 2026 17:15:33 +0800 Subject: [PATCH] add jiyi --- config.yaml | 31 ++ configs/env.development.yaml | 6 + .../product_application_service_impl.go | 4 + internal/config/config.go | 28 ++ internal/container/container.go | 5 + internal/domains/api/dto/api_request_dto.go | 29 ++ .../api/services/api_request_service.go | 9 + .../api/services/form_config_service.go | 4 + .../api/services/processors/dependencies.go | 4 + .../processors/jrzq/jrzqh6m3_processor.go | 50 +++ .../processors/jrzq/jrzqk9p2_processor.go | 50 +++ .../processors/jrzq/jrzqr4n7_processor.go | 50 +++ .../processors/jrzq/jrzqw3l8_processor.go | 50 +++ .../external/jiyi/call_options.go | 40 ++ .../infrastructure/external/jiyi/crypto.go | 67 ++++ .../external/jiyi/crypto_test.go | 43 +++ .../external/jiyi/empty_result_test.go | 53 +++ .../external/jiyi/jiyi_errors.go | 119 ++++++ .../external/jiyi/jiyi_factory.go | 64 ++++ .../external/jiyi/jiyi_service.go | 351 ++++++++++++++++++ 20 files changed, 1057 insertions(+) create mode 100644 internal/domains/api/services/processors/jrzq/jrzqh6m3_processor.go create mode 100644 internal/domains/api/services/processors/jrzq/jrzqk9p2_processor.go create mode 100644 internal/domains/api/services/processors/jrzq/jrzqr4n7_processor.go create mode 100644 internal/domains/api/services/processors/jrzq/jrzqw3l8_processor.go create mode 100644 internal/infrastructure/external/jiyi/call_options.go create mode 100644 internal/infrastructure/external/jiyi/crypto.go create mode 100644 internal/infrastructure/external/jiyi/crypto_test.go create mode 100644 internal/infrastructure/external/jiyi/empty_result_test.go create mode 100644 internal/infrastructure/external/jiyi/jiyi_errors.go create mode 100644 internal/infrastructure/external/jiyi/jiyi_factory.go create mode 100644 internal/infrastructure/external/jiyi/jiyi_service.go diff --git a/config.yaml b/config.yaml index 232214a..edd80d2 100644 --- a/config.yaml +++ b/config.yaml @@ -716,6 +716,37 @@ nuoer: max_age: 30 compress: true +# =========================================== +# ✨ 集奕(JIYI)配置 +# =========================================== +jiyi: + url: "https://api.jiyidata.cn" + app_id: "5DS2yMY9" + app_secret: "16aa682d9b66e17def6aca82e14afc8931fca15d" + timeout: 4s + logging: + enabled: true + log_dir: "logs/external_services" + service_name: "jiyi" + 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 + # =========================================== # ✨ 汇博(BHSC)配置 # =========================================== diff --git a/configs/env.development.yaml b/configs/env.development.yaml index defc679..7812a61 100644 --- a/configs/env.development.yaml +++ b/configs/env.development.yaml @@ -197,6 +197,12 @@ nuoer: url: "https://api.enolfax.com/enol/api" app_id: "t4qO2mR3" app_secret: "d1515bf9ed2f2fe063b5f4f7e2c50f0ec65bfd58" + +# 集奕配置 +jiyi: + url: "https://api.jiyidata.cn" + app_id: "5DS2yMY9" + app_secret: "16aa682d9b66e17def6aca82e14afc8931fca15d" development: enable_cors: true cors_allowed_origins: "http://localhost:5173,http://localhost:8080" diff --git a/internal/application/product/product_application_service_impl.go b/internal/application/product/product_application_service_impl.go index b2824d0..9997fa2 100644 --- a/internal/application/product/product_application_service_impl.go +++ b/internal/application/product/product_application_service_impl.go @@ -1087,6 +1087,10 @@ func (s *ProductApplicationServiceImpl) getDTOMap() map[string]interface{} { "IVYZ6G7H": &dto.IVYZ6G7HReq{}, "IVYZ8I9J": &dto.IVYZ8I9JReq{}, "JRZQ0L85": &dto.JRZQ0L85Req{}, + "JRZQK9P2": &dto.JRZQK9P2Req{}, //洞侦1.0 + "JRZQR4N7": &dto.JRZQR4N7Req{}, //借贷意向验证3.0 + "JRZQH6M3": &dto.JRZQH6M3Req{}, //无间司南-纯黑A版 + "JRZQW3L8": &dto.JRZQW3L8Req{}, //信用司南 } } diff --git a/internal/config/config.go b/internal/config/config.go index 8a1bef4..c6c404e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -43,6 +43,7 @@ type Config struct { Tianyuanapi TianyuanapiConfig `mapstructure:"tianyuanapi"` Shujubao ShujubaoConfig `mapstructure:"shujubao"` Nuoer NuoerConfig `mapstructure:"nuoer"` + Jiyi JiyiConfig `mapstructure:"jiyi"` Huibo HuiboConfig `mapstructure:"huibo"` PDFGen PDFGenConfig `mapstructure:"pdfgen"` } @@ -738,6 +739,33 @@ type NuoerLevelFileConfig struct { Compress bool `mapstructure:"compress"` } +// JiyiConfig 集奕配置 +type JiyiConfig struct { + URL string `mapstructure:"url"` + AppID string `mapstructure:"app_id"` + AppSecret string `mapstructure:"app_secret"` + Timeout time.Duration `mapstructure:"timeout"` + + Logging JiyiLoggingConfig `mapstructure:"logging"` +} + +// JiyiLoggingConfig 集奕日志配置 +type JiyiLoggingConfig struct { + Enabled bool `mapstructure:"enabled"` + LogDir string `mapstructure:"log_dir"` + UseDaily bool `mapstructure:"use_daily"` + EnableLevelSeparation bool `mapstructure:"enable_level_separation"` + LevelConfigs map[string]JiyiLevelFileConfig `mapstructure:"level_configs"` +} + +// JiyiLevelFileConfig 集奕日志级别配置 +type JiyiLevelFileConfig struct { + MaxSize int `mapstructure:"max_size"` + MaxBackups int `mapstructure:"max_backups"` + MaxAge int `mapstructure:"max_age"` + Compress bool `mapstructure:"compress"` +} + // HuiboConfig 汇博配置 type HuiboConfig struct { URL string `mapstructure:"url"` diff --git a/internal/container/container.go b/internal/container/container.go index df895dc..ae5ea83 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -42,6 +42,7 @@ import ( "hyapi-server/internal/infrastructure/external/email" "hyapi-server/internal/infrastructure/external/huibo" "hyapi-server/internal/infrastructure/external/jiguang" + "hyapi-server/internal/infrastructure/external/jiyi" "hyapi-server/internal/infrastructure/external/muzi" "hyapi-server/internal/infrastructure/external/nuoer" "hyapi-server/internal/infrastructure/external/ocr" @@ -415,6 +416,10 @@ func NewContainer() *Container { func(cfg *config.Config) (*nuoer.NuoerService, error) { return nuoer.NewNuoerServiceWithConfig(cfg) }, + // JiyiService - 集奕服务 + func(cfg *config.Config) (*jiyi.JiyiService, error) { + return jiyi.NewJiyiServiceWithConfig(cfg) + }, // HuiboService - 汇博服务 func(cfg *config.Config) (*huibo.HuiboService, error) { return huibo.NewHuiboServiceWithConfig(cfg) diff --git a/internal/domains/api/dto/api_request_dto.go b/internal/domains/api/dto/api_request_dto.go index f9b67d1..4ca50f9 100644 --- a/internal/domains/api/dto/api_request_dto.go +++ b/internal/domains/api/dto/api_request_dto.go @@ -211,6 +211,35 @@ type JRZQTH74Req struct { IDCard string `json:"id_card" validate:"required,validIDCard"` MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"` } + +// JRZQK9P2Req 洞侦1.0 +type JRZQK9P2Req 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"` +} + +// JRZQR4N7Req 借贷意向验证3.0 +type JRZQR4N7Req 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"` +} + +// JRZQH6M3Req 无间司南-纯黑A版 +type JRZQH6M3Req 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"` +} + +// JRZQW3L8Req 信用司南 +type JRZQW3L8Req 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"` +} + type QCXG76VAReq struct { PlateNo string `json:"plate_no" validate:"required"` } diff --git a/internal/domains/api/services/api_request_service.go b/internal/domains/api/services/api_request_service.go index aa2ca90..124e0ab 100644 --- a/internal/domains/api/services/api_request_service.go +++ b/internal/domains/api/services/api_request_service.go @@ -20,6 +20,7 @@ import ( "hyapi-server/internal/infrastructure/external/alicloud" "hyapi-server/internal/infrastructure/external/huibo" "hyapi-server/internal/infrastructure/external/jiguang" + jiyiext "hyapi-server/internal/infrastructure/external/jiyi" "hyapi-server/internal/infrastructure/external/muzi" nuoerext "hyapi-server/internal/infrastructure/external/nuoer" "hyapi-server/internal/infrastructure/external/shujubao" @@ -66,6 +67,7 @@ func NewApiRequestService( jiguangService *jiguang.JiguangService, tianyuanapiService *tianyuanapi.TianyuanapiService, nuoerService *nuoerext.NuoerService, + jiyiService *jiyiext.JiyiService, huiboService *huibo.HuiboService, validator interfaces.RequestValidator, productManagementService *services.ProductManagementService, @@ -83,6 +85,7 @@ func NewApiRequestService( jiguangService, tianyuanapiService, nuoerService, + jiyiService, huiboService, validator, productManagementService, @@ -105,6 +108,7 @@ func NewApiRequestServiceWithRepos( jiguangService *jiguang.JiguangService, tianyuanapiService *tianyuanapi.TianyuanapiService, nuoerService *nuoerext.NuoerService, + jiyiService *jiyiext.JiyiService, huiboService *huibo.HuiboService, validator interfaces.RequestValidator, productManagementService *services.ProductManagementService, @@ -133,6 +137,7 @@ func NewApiRequestServiceWithRepos( jiguangService, tianyuanapiService, nuoerService, + jiyiService, huiboService, validator, combService, @@ -193,6 +198,10 @@ func registerAllProcessors(combService *comb.CombService) { "JRZQTH74": jrzq.ProcessJRZQTH74Request, //团伙欺诈 "JRZQGJ99": jrzq.ProcessJRZQGJ99Request, //公积金核验 "JRZQFC59": jrzq.ProcessJRZQFC59Request, //房产核验 + "JRZQK9P2": jrzq.ProcessJRZQK9P2Request, //洞侦1.0 + "JRZQR4N7": jrzq.ProcessJRZQR4N7Request, //借贷意向验证3.0 + "JRZQH6M3": jrzq.ProcessJRZQH6M3Request, //无间司南-纯黑A版 + "JRZQW3L8": jrzq.ProcessJRZQW3L8Request, //信用司南 // QYGL系列处理器 "QYGL7HBN": qygl.ProcessQYGL7HBNRequest, //企业全景报告(聚合 QYGLUY3S/QYGLJ0Q1/QYGL5S1I) diff --git a/internal/domains/api/services/form_config_service.go b/internal/domains/api/services/form_config_service.go index 0da19f2..ef2e88f 100644 --- a/internal/domains/api/services/form_config_service.go +++ b/internal/domains/api/services/form_config_service.go @@ -312,6 +312,10 @@ func (s *FormConfigServiceImpl) getDTOStruct(ctx context.Context, apiCode string "JRZQTH74": &dto.JRZQTH74Req{}, //团伙欺诈 "JRZQGJ99": &dto.JRZQGJ99Req{}, //公积金核验 "JRZQFC59": &dto.JRZQFC59Req{}, //房产核验 + "JRZQK9P2": &dto.JRZQK9P2Req{}, //洞侦1.0 + "JRZQR4N7": &dto.JRZQR4N7Req{}, //借贷意向验证3.0 + "JRZQH6M3": &dto.JRZQH6M3Req{}, //无间司南-纯黑A版 + "JRZQW3L8": &dto.JRZQW3L8Req{}, //信用司南 "IVYZX7J9": &dto.IVYZX7J9Req{}, //学籍核验 "QCXG6U5G": &dto.QCXG6U5GReq{}, //车辆核验 "QCXG7K2N": &dto.QCXG7K2NReq{}, //人车核验加强版 diff --git a/internal/domains/api/services/processors/dependencies.go b/internal/domains/api/services/processors/dependencies.go index aee4c06..6385757 100644 --- a/internal/domains/api/services/processors/dependencies.go +++ b/internal/domains/api/services/processors/dependencies.go @@ -8,6 +8,7 @@ import ( "hyapi-server/internal/infrastructure/external/alicloud" "hyapi-server/internal/infrastructure/external/huibo" "hyapi-server/internal/infrastructure/external/jiguang" + "hyapi-server/internal/infrastructure/external/jiyi" "hyapi-server/internal/infrastructure/external/muzi" "hyapi-server/internal/infrastructure/external/nuoer" "hyapi-server/internal/infrastructure/external/shujubao" @@ -43,6 +44,7 @@ type ProcessorDependencies struct { JiguangService *jiguang.JiguangService TianyuanapiService *tianyuanapi.TianyuanapiService NuoerService *nuoer.NuoerService + JiyiService *jiyi.JiyiService HuiboService *huibo.HuiboService Validator interfaces.RequestValidator CombService CombServiceInterface // Changed to interface to break import cycle @@ -72,6 +74,7 @@ func NewProcessorDependencies( jiguangService *jiguang.JiguangService, tianyuanapiService *tianyuanapi.TianyuanapiService, nuoerService *nuoer.NuoerService, + jiyiService *jiyi.JiyiService, huiboService *huibo.HuiboService, validator interfaces.RequestValidator, combService CombServiceInterface, // Changed to interface @@ -91,6 +94,7 @@ func NewProcessorDependencies( JiguangService: jiguangService, TianyuanapiService: tianyuanapiService, NuoerService: nuoerService, + JiyiService: jiyiService, HuiboService: huiboService, Validator: validator, CombService: combService, diff --git a/internal/domains/api/services/processors/jrzq/jrzqh6m3_processor.go b/internal/domains/api/services/processors/jrzq/jrzqh6m3_processor.go new file mode 100644 index 0000000..c8f5f16 --- /dev/null +++ b/internal/domains/api/services/processors/jrzq/jrzqh6m3_processor.go @@ -0,0 +1,50 @@ +package jrzq + +import ( + "context" + "encoding/json" + "errors" + + "hyapi-server/internal/domains/api/dto" + "hyapi-server/internal/domains/api/services/processors" + "hyapi-server/internal/infrastructure/external/jiyi" +) + +// ProcessJRZQH6M3Request 无间司南-纯黑A版(上游 jy000052) +func ProcessJRZQH6M3Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { + var paramsDto dto.JRZQH6M3Req + 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) + } + + body := map[string]string{ + "name": paramsDto.Name, + "idNo": paramsDto.IDCard, + "mobile": paramsDto.MobileNo, + } + + apiKey := "jy000052" + apiPath := "/api/v1/wujsinan/blacka" + + resp, err := deps.JiyiService.CallAPI(ctx, apiKey, apiPath, body, jiyi.TopEncryptionOptions(1)) + if err != nil { + if errors.Is(err, jiyi.ErrDatasource) { + return nil, errors.Join(processors.ErrDatasource, err) + } + if errors.Is(err, jiyi.ErrNotFound) { + return nil, errors.Join(processors.ErrNotFound, err) + } + return nil, errors.Join(processors.ErrSystem, err) + } + + respBytes, err := json.Marshal(resp.Data) + if err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + return respBytes, nil +} diff --git a/internal/domains/api/services/processors/jrzq/jrzqk9p2_processor.go b/internal/domains/api/services/processors/jrzq/jrzqk9p2_processor.go new file mode 100644 index 0000000..97f3c2c --- /dev/null +++ b/internal/domains/api/services/processors/jrzq/jrzqk9p2_processor.go @@ -0,0 +1,50 @@ +package jrzq + +import ( + "context" + "encoding/json" + "errors" + + "hyapi-server/internal/domains/api/dto" + "hyapi-server/internal/domains/api/services/processors" + "hyapi-server/internal/infrastructure/external/jiyi" +) + +// ProcessJRZQK9P2Request 洞侦1.0(上游 jy000022) +func ProcessJRZQK9P2Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { + var paramsDto dto.JRZQK9P2Req + 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) + } + + body := map[string]string{ + "name": paramsDto.Name, + "idNo": paramsDto.IDCard, + "mobile": paramsDto.MobileNo, + } + + apiKey := "jy000022" + apiPath := "/api/v1/cave/invest" + + resp, err := deps.JiyiService.CallAPI(ctx, apiKey, apiPath, body, jiyi.CaveInvestOptions(0)) + if err != nil { + if errors.Is(err, jiyi.ErrDatasource) { + return nil, errors.Join(processors.ErrDatasource, err) + } + if errors.Is(err, jiyi.ErrNotFound) { + return nil, errors.Join(processors.ErrNotFound, err) + } + return nil, errors.Join(processors.ErrSystem, err) + } + + respBytes, err := json.Marshal(resp.Data) + if err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + return respBytes, nil +} diff --git a/internal/domains/api/services/processors/jrzq/jrzqr4n7_processor.go b/internal/domains/api/services/processors/jrzq/jrzqr4n7_processor.go new file mode 100644 index 0000000..a91a879 --- /dev/null +++ b/internal/domains/api/services/processors/jrzq/jrzqr4n7_processor.go @@ -0,0 +1,50 @@ +package jrzq + +import ( + "context" + "encoding/json" + "errors" + + "hyapi-server/internal/domains/api/dto" + "hyapi-server/internal/domains/api/services/processors" + "hyapi-server/internal/infrastructure/external/jiyi" +) + +// ProcessJRZQR4N7Request 借贷意向验证3.0(上游 jy000042) +func ProcessJRZQR4N7Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { + var paramsDto dto.JRZQR4N7Req + 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) + } + + body := map[string]string{ + "name": paramsDto.Name, + "idNo": paramsDto.IDCard, + "mobile": paramsDto.MobileNo, + } + + apiKey := "jy000042" + apiPath := "/api/v1/loaninten/ver3" + + resp, err := deps.JiyiService.CallAPI(ctx, apiKey, apiPath, body, jiyi.TopEncryptionOptions(1)) + if err != nil { + if errors.Is(err, jiyi.ErrDatasource) { + return nil, errors.Join(processors.ErrDatasource, err) + } + if errors.Is(err, jiyi.ErrNotFound) { + return nil, errors.Join(processors.ErrNotFound, err) + } + return nil, errors.Join(processors.ErrSystem, err) + } + + respBytes, err := json.Marshal(resp.Data) + if err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + return respBytes, nil +} diff --git a/internal/domains/api/services/processors/jrzq/jrzqw3l8_processor.go b/internal/domains/api/services/processors/jrzq/jrzqw3l8_processor.go new file mode 100644 index 0000000..3d7616e --- /dev/null +++ b/internal/domains/api/services/processors/jrzq/jrzqw3l8_processor.go @@ -0,0 +1,50 @@ +package jrzq + +import ( + "context" + "encoding/json" + "errors" + + "hyapi-server/internal/domains/api/dto" + "hyapi-server/internal/domains/api/services/processors" + "hyapi-server/internal/infrastructure/external/jiyi" +) + +// ProcessJRZQW3L8Request 信用司南(上游 jy000004,查空不计费) +func ProcessJRZQW3L8Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { + var paramsDto dto.JRZQW3L8Req + 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) + } + + body := map[string]string{ + "name": paramsDto.Name, + "idCard": paramsDto.IDCard, + "mobile": paramsDto.MobileNo, + } + + apiKey := "jy000004" + apiPath := "/api/v1/credit/panorama" + + resp, err := deps.JiyiService.CallAPI(ctx, apiKey, apiPath, body, jiyi.TopEncryptionOptions(1)) + if err != nil { + if errors.Is(err, jiyi.ErrDatasource) { + return nil, errors.Join(processors.ErrDatasource, err) + } + if errors.Is(err, jiyi.ErrNotFound) { + return nil, errors.Join(processors.ErrNotFound, err) + } + return nil, errors.Join(processors.ErrSystem, err) + } + + respBytes, err := json.Marshal(resp.Data) + if err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + return respBytes, nil +} diff --git a/internal/infrastructure/external/jiyi/call_options.go b/internal/infrastructure/external/jiyi/call_options.go new file mode 100644 index 0000000..9549be6 --- /dev/null +++ b/internal/infrastructure/external/jiyi/call_options.go @@ -0,0 +1,40 @@ +package jiyi + +// CallMode 请求组装模式(不同产品加密字段位置不同) +type CallMode int + +const ( + // CallModePlain 明文,不附加 encryptType/encryptionType/timestamp + CallModePlain CallMode = iota + // CallModeBodyEncrypt 洞侦类:body.encryptType,0 明文 / 1 MD5,并自动补 timestamp + CallModeBodyEncrypt + // CallModeTopEncrypt 借贷意向类:顶层 encryptionType,1 明文 / 2 MD5 + CallModeTopEncrypt +) + +// CallOptions 调用选项 +type CallOptions struct { + Mode CallMode + // EncryptValue: + // - CallModeBodyEncrypt:0 明文,1 MD5(默认 0) + // - CallModeTopEncrypt:1 明文,2 MD5(默认 1) + EncryptValue int +} + +// DefaultCallOptions 默认:明文且不注入额外字段 +func DefaultCallOptions() CallOptions { + return CallOptions{Mode: CallModePlain} +} + +// CaveInvestOptions 洞侦1.0 等:body 内 encryptType + timestamp +func CaveInvestOptions(encryptType int) CallOptions { + return CallOptions{Mode: CallModeBodyEncrypt, EncryptValue: encryptType} +} + +// TopEncryptionOptions 借贷意向验证3.0 等:顶层 encryptionType +func TopEncryptionOptions(encryptionType int) CallOptions { + if encryptionType <= 0 { + encryptionType = 1 + } + return CallOptions{Mode: CallModeTopEncrypt, EncryptValue: encryptionType} +} diff --git a/internal/infrastructure/external/jiyi/crypto.go b/internal/infrastructure/external/jiyi/crypto.go new file mode 100644 index 0000000..d50f8ad --- /dev/null +++ b/internal/infrastructure/external/jiyi/crypto.go @@ -0,0 +1,67 @@ +package jiyi + +import ( + "crypto/md5" + "encoding/hex" + "sort" + "strings" +) + +// Sign 根据 body 业务参数与 secret 生成 MD5 签名。 +// 规则:排除空值参数,按 key 的 ASCII 升序排序,拼接「参数名+参数值」后追加 secret,再 MD5(小写十六进制)。 +func Sign(body map[string]string, secret string) string { + if len(body) == 0 { + return genMD5(secret) + } + + keys := make([]string, 0, len(body)) + for k, v := range body { + if strings.TrimSpace(v) == "" { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + + var sb strings.Builder + for _, k := range keys { + sb.WriteString(k) + sb.WriteString(body[k]) + } + sb.WriteString(secret) + return genMD5(sb.String()) +} + +func genMD5(s string) string { + sum := md5.Sum([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +// MD5Encrypt 对业务入参做 MD5 加密(小写十六进制),用于 encryptType=1。 +func MD5Encrypt(plaintext string) string { + return genMD5(plaintext) +} + +// encryptBodyMD5 将三要素等敏感字段值转为 MD5 密文(保留 encryptType、timestamp)。 +func encryptBodyMD5(body map[string]string) map[string]string { + if len(body) == 0 { + return body + } + skip := map[string]struct{}{ + "encryptType": {}, + "timestamp": {}, + } + encrypted := make(map[string]string, len(body)) + for k, v := range body { + if _, ok := skip[k]; ok { + encrypted[k] = v + continue + } + if strings.TrimSpace(v) == "" { + encrypted[k] = v + continue + } + encrypted[k] = MD5Encrypt(v) + } + return encrypted +} diff --git a/internal/infrastructure/external/jiyi/crypto_test.go b/internal/infrastructure/external/jiyi/crypto_test.go new file mode 100644 index 0000000..b78dfc1 --- /dev/null +++ b/internal/infrastructure/external/jiyi/crypto_test.go @@ -0,0 +1,43 @@ +package jiyi + +import "testing" + +func TestSign(t *testing.T) { + // 与文档 Java 示例一致的待签字符串顺序 + body := map[string]string{ + "idNo": "440181199807041111", + "mobile": "13000000000", + "name": "黄雅莉", + "encryptType": "0", + "timestamp": "1720940866832", + } + secret := "a661a2728f2a54b71601d3e8a7f6210d650c2018" + got := Sign(body, secret) + want := genMD5("encryptType0idNo440181199807041111mobile13000000000name黄雅莉timestamp1720940866832" + secret) + if got != want { + t.Fatalf("sign mismatch: got %s want %s", got, want) + } +} + +func TestEncryptBodyMD5(t *testing.T) { + body := map[string]string{ + "idNo": "440181199807041111", + "mobile": "13000000000", + "name": "黄雅莉", + "encryptType": "1", + "timestamp": "1720940866832", + } + got := encryptBodyMD5(body) + if got["encryptType"] != "1" || got["timestamp"] != "1720940866832" { + t.Fatalf("meta fields should keep plaintext: %+v", got) + } + if got["idNo"] != MD5Encrypt("440181199807041111") { + t.Fatalf("idNo not encrypted: %s", got["idNo"]) + } + if got["mobile"] != MD5Encrypt("13000000000") { + t.Fatalf("mobile not encrypted: %s", got["mobile"]) + } + if got["name"] != MD5Encrypt("黄雅莉") { + t.Fatalf("name not encrypted: %s", got["name"]) + } +} diff --git a/internal/infrastructure/external/jiyi/empty_result_test.go b/internal/infrastructure/external/jiyi/empty_result_test.go new file mode 100644 index 0000000..fc70e4e --- /dev/null +++ b/internal/infrastructure/external/jiyi/empty_result_test.go @@ -0,0 +1,53 @@ +package jiyi + +import "testing" + +func TestIsEmptyBusinessResult(t *testing.T) { + cases := []struct { + name string + data interface{} + empty bool + }{ + {"nil_map", map[string]interface{}{}, true}, + {"missing_result", map[string]interface{}{"foo": 1}, true}, + {"nil_result", map[string]interface{}{"result": nil}, true}, + {"empty_result_map", map[string]interface{}{"result": map[string]interface{}{}}, true}, + {"black_tags_zero_not_empty", map[string]interface{}{ + "result": map[string]interface{}{ + "black_list": "0", + "black_tag04": "0", + }, + }, false}, + {"score_present", map[string]interface{}{ + "result": map[string]interface{}{ + "score": "617", + "reason": "", + "contents": map[string]interface{}{"TC_A001": "0"}, + }, + }, false}, + {"all_blank_strings", map[string]interface{}{ + "result": map[string]interface{}{ + "score": "", + "reason": "", + }, + }, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := isEmptyBusinessResult(tc.data) + if got != tc.empty { + t.Fatalf("got %v want %v", got, tc.empty) + } + }) + } +} + +func TestIsQueryBillingAPIKey(t *testing.T) { + if !isQueryBillingAPIKey("jy000022") || !isQueryBillingAPIKey("jy000042") || !isQueryBillingAPIKey("jy000052") { + t.Fatal("查空计费产品应在白名单中") + } + if isQueryBillingAPIKey("jy000004") { + t.Fatal("信用司南查空不计费,不应在白名单中") + } +} diff --git a/internal/infrastructure/external/jiyi/jiyi_errors.go b/internal/infrastructure/external/jiyi/jiyi_errors.go new file mode 100644 index 0000000..4165716 --- /dev/null +++ b/internal/infrastructure/external/jiyi/jiyi_errors.go @@ -0,0 +1,119 @@ +package jiyi + +import ( + "errors" + "fmt" +) + +// 平台层 code 返回码 +const ( + CodeSuccess = 0 // 成功 + CodeResponseError = -1 // 异常 +) + +// 业务层 busiCode 返回码(洞侦等接口通用) +const ( + BusiCodeSuccess = 10 // 查询成功 + BusiCodeNotFound = 1000 // 未查得 + BusiCodeSignError = 1005 // 签名异常 + BusiCodeDataRequestError = 1007 // 数据请求异常 + BusiCodeParamError = 1010 // 参数错误 + BusiCodeUnknown = -1 // 未知错误 +) + +var ( + ErrDatasource = errors.New("数据源异常") + ErrSystem = errors.New("系统异常") + ErrNotFound = errors.New("查询为空") +) + +var platformCodeDesc = map[int]string{ + CodeSuccess: "成功", + CodeResponseError: "异常", +} + +var busiCodeDesc = map[int]string{ + BusiCodeSuccess: "查询成功", + BusiCodeNotFound: "未查得", + BusiCodeSignError: "签名异常", + BusiCodeDataRequestError: "数据请求异常", + BusiCodeParamError: "参数错误", + BusiCodeUnknown: "未知错误", +} + +// GetPlatformCodeDesc 根据平台 code 获取描述 +func GetPlatformCodeDesc(code int) string { + if desc, ok := platformCodeDesc[code]; ok { + return desc + } + return "" +} + +// GetBusiCodeDesc 根据 busiCode 获取描述 +func GetBusiCodeDesc(busiCode int) string { + if desc, ok := busiCodeDesc[busiCode]; ok { + return desc + } + return "" +} + +type jiyiError struct { + Code int + Message string +} + +func (e *jiyiError) Error() string { + return fmt.Sprintf("集奕返回错误,code: %d,msg: %s", e.Code, e.Message) +} + +// NewJiyiError 创建平台层错误 +func NewJiyiError(code int, message string) *jiyiError { + if message == "" { + if desc := GetPlatformCodeDesc(code); desc != "" { + message = desc + } else { + message = "集奕返回未知错误" + } + } + return &jiyiError{Code: code, Message: message} +} + +type jiyiBusiError struct { + BusiCode int + BusiMsg string +} + +func (e *jiyiBusiError) Error() string { + return fmt.Sprintf("集奕业务错误,busiCode: %d,busiMsg: %s", e.BusiCode, e.BusiMsg) +} + +// NewJiyiBusiError 创建业务层错误 +func NewJiyiBusiError(busiCode int, busiMsg string) *jiyiBusiError { + if busiMsg == "" { + if desc := GetBusiCodeDesc(busiCode); desc != "" { + busiMsg = desc + } else { + busiMsg = "集奕业务返回未知错误" + } + } + return &jiyiBusiError{BusiCode: busiCode, BusiMsg: busiMsg} +} + +// GetErrByBusiCode 将 busiCode 映射为内部哨兵错误 +func GetErrByBusiCode(busiCode int) error { + if busiCode == BusiCodeSuccess { + return nil + } + if busiCode == BusiCodeNotFound { + return ErrNotFound + } + return ErrDatasource +} + +// GetErrByPlatformCode 将平台 code 映射为内部哨兵错误 +func GetErrByPlatformCode(code int) error { + if code == CodeSuccess { + return nil + } + return ErrDatasource +} diff --git a/internal/infrastructure/external/jiyi/jiyi_factory.go b/internal/infrastructure/external/jiyi/jiyi_factory.go new file mode 100644 index 0000000..e9422df --- /dev/null +++ b/internal/infrastructure/external/jiyi/jiyi_factory.go @@ -0,0 +1,64 @@ +package jiyi + +import ( + "time" + + "hyapi-server/internal/config" + "hyapi-server/internal/shared/external_logger" +) + +// NewJiyiServiceWithConfig 使用配置创建集奕服务 +func NewJiyiServiceWithConfig(cfg *config.Config) (*JiyiService, error) { + loggingConfig := external_logger.ExternalServiceLoggingConfig{ + Enabled: cfg.Jiyi.Logging.Enabled, + LogDir: cfg.Jiyi.Logging.LogDir, + ServiceName: "jiyi", + UseDaily: cfg.Jiyi.Logging.UseDaily, + EnableLevelSeparation: cfg.Jiyi.Logging.EnableLevelSeparation, + LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig), + } + + for level, levelCfg := range cfg.Jiyi.Logging.LevelConfigs { + loggingConfig.LevelConfigs[level] = external_logger.ExternalServiceLevelFileConfig{ + MaxSize: levelCfg.MaxSize, + MaxBackups: levelCfg.MaxBackups, + MaxAge: levelCfg.MaxAge, + Compress: levelCfg.Compress, + } + } + + logger, err := external_logger.NewExternalServiceLogger(loggingConfig) + if err != nil { + return nil, err + } + + timeout := cfg.Jiyi.Timeout + if timeout <= 0 { + timeout = defaultRequestTimeout + } + + return NewJiyiService( + cfg.Jiyi.URL, + cfg.Jiyi.AppID, + cfg.Jiyi.AppSecret, + timeout, + logger, + ), nil +} + +// NewJiyiServiceWithLogging 使用自定义日志配置创建集奕服务 +func NewJiyiServiceWithLogging(url, appID, appSecret string, timeout time.Duration, loggingConfig external_logger.ExternalServiceLoggingConfig) (*JiyiService, error) { + loggingConfig.ServiceName = "jiyi" + + logger, err := external_logger.NewExternalServiceLogger(loggingConfig) + if err != nil { + return nil, err + } + + return NewJiyiService(url, appID, appSecret, timeout, logger), nil +} + +// NewJiyiServiceSimple 创建无日志的集奕服务 +func NewJiyiServiceSimple(url, appID, appSecret string, timeout time.Duration) *JiyiService { + return NewJiyiService(url, appID, appSecret, timeout, nil) +} diff --git a/internal/infrastructure/external/jiyi/jiyi_service.go b/internal/infrastructure/external/jiyi/jiyi_service.go new file mode 100644 index 0000000..a93608f --- /dev/null +++ b/internal/infrastructure/external/jiyi/jiyi_service.go @@ -0,0 +1,351 @@ +package jiyi + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "hyapi-server/internal/shared/external_logger" +) + +const defaultRequestTimeout = 4 * time.Second + +// queryBillingAPIKeys 查询计费接口:未查得/空结果仍按成功返回空数据,由平台侧计费 +var queryBillingAPIKeys = map[string]struct{}{ + "jy000022": {}, // 洞侦1.0 + "jy000042": {}, // 借贷意向验证3.0 + "jy000052": {}, // 无间司南-纯黑A版 +} + +func isQueryBillingAPIKey(apiKey string) bool { + _, ok := queryBillingAPIKeys[apiKey] + return ok +} + +// jiyiResponse 集奕通用响应 +type jiyiResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Fee string `json:"fee"` + SeqNo string `json:"seqNo"` + Data interface{} `json:"data"` +} + +// Response 对外暴露的响应别名,便于处理器取值 +type Response = jiyiResponse + +type serviceConfig struct { + URL string + AppID string + AppSecret string + Timeout time.Duration +} + +// JiyiService 集奕数据服务 +type JiyiService struct { + config serviceConfig + logger *external_logger.ExternalServiceLogger +} + +// NewJiyiService 创建集奕服务实例 +func NewJiyiService(url, appID, appSecret string, timeout time.Duration, logger *external_logger.ExternalServiceLogger) *JiyiService { + if timeout <= 0 { + timeout = defaultRequestTimeout + } + return &JiyiService{ + config: serviceConfig{ + URL: url, + AppID: appID, + AppSecret: appSecret, + Timeout: timeout, + }, + logger: logger, + } +} + +func (s *JiyiService) logResponse(transactionID, apiKey string, statusCode int, duration time.Duration, seqNo string) { + if s.logger == nil { + return + } + s.logger.LogResponse(seqNo, transactionID, apiKey, statusCode, duration) +} + +func (s *JiyiService) logError(transactionID, apiKey, seqNo string, err error, payload interface{}) { + if s.logger == nil { + return + } + s.logger.LogError(seqNo, transactionID, apiKey, err, payload) +} + +// CallAPI 调用集奕接口。opts 为空时按明文、不注入额外字段处理。 +func (s *JiyiService) CallAPI(ctx context.Context, apiKey, apiPath string, body map[string]string, opts ...CallOptions) (*Response, error) { + requestURL := strings.TrimSuffix(s.config.URL, "/") + if apiPath != "" { + if !strings.HasPrefix(apiPath, "/") { + apiPath = "/" + apiPath + } + requestURL += apiPath + } + + startTime := time.Now() + + var transactionID string + if id, ok := ctx.Value("transaction_id").(string); ok { + transactionID = id + } + + opt := DefaultCallOptions() + if len(opts) > 0 { + opt = opts[0] + } + + requestBody := cloneBody(body) + switch opt.Mode { + case CallModeBodyEncrypt: + encType := opt.EncryptValue + if _, ok := requestBody["timestamp"]; !ok || strings.TrimSpace(requestBody["timestamp"]) == "" { + requestBody["timestamp"] = strconv.FormatInt(time.Now().UnixMilli(), 10) + } + requestBody["encryptType"] = strconv.Itoa(encType) + if encType == 1 { + requestBody = encryptBodyMD5(requestBody) + } + case CallModeTopEncrypt: + if opt.EncryptValue == 2 { + requestBody = encryptBodyMD5(requestBody) + } + } + + sign := Sign(requestBody, s.config.AppSecret) + + requestPayload := map[string]interface{}{ + "appId": s.config.AppID, + "sign": sign, + "apiKey": apiKey, + "body": requestBody, + } + if opt.Mode == CallModeTopEncrypt { + encVal := opt.EncryptValue + if encVal <= 0 { + encVal = 1 + } + // 顶层 encryptionType:部分接口要求 JSON number,统一传整型 + requestPayload["encryptionType"] = encVal + } + + if s.logger != nil { + s.logger.LogRequest("", transactionID, apiKey, requestURL) + } + + bodyBytes, err := json.Marshal(requestPayload) + if err != nil { + err = errors.Join(ErrSystem, err) + s.logError(transactionID, apiKey, "", err, requestPayload) + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewBuffer(bodyBytes)) + if err != nil { + err = errors.Join(ErrSystem, err) + s.logError(transactionID, apiKey, "", err, requestPayload) + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: s.config.Timeout} + resp, err := client.Do(req) + if err != nil { + err = wrapHTTPError(err) + s.logError(transactionID, apiKey, "", err, requestPayload) + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + err = errors.Join(ErrSystem, err) + s.logError(transactionID, apiKey, "", err, requestPayload) + return nil, err + } + + duration := time.Since(startTime) + + if resp.StatusCode != http.StatusOK { + err = errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码 %d", resp.StatusCode)) + s.logError(transactionID, apiKey, "", err, requestPayload) + return nil, err + } + + var jiyiResp jiyiResponse + if err := json.Unmarshal(respBody, &jiyiResp); err != nil { + err = errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %w", err)) + s.logError(transactionID, apiKey, "", err, requestPayload) + return nil, err + } + + if jiyiResp.Code != CodeSuccess { + jiyiErr := NewJiyiError(jiyiResp.Code, jiyiResp.Msg) + err = errors.Join(GetErrByPlatformCode(jiyiResp.Code), jiyiErr) + s.logError(transactionID, apiKey, jiyiResp.SeqNo, jiyiErr, requestPayload) + return nil, err + } + + if jiyiResp.Data == nil { + // 部分接口查空时 data 可能为 null + if isQueryBillingAPIKey(apiKey) { + jiyiResp.Data = map[string]interface{}{} + s.logResponse(transactionID, apiKey, resp.StatusCode, duration, jiyiResp.SeqNo) + return &jiyiResp, nil + } + err = errors.Join(ErrNotFound, errors.New("响应 data 为空")) + s.logError(transactionID, apiKey, jiyiResp.SeqNo, err, requestPayload) + return nil, err + } + + busiCode, busiMsg, ok := parseDataBusiInfo(jiyiResp.Data) + if !ok || (busiCode == 0 && strings.TrimSpace(busiMsg) == "") { + err = errors.Join(ErrSystem, errors.New("响应 data 无法解析 busiCode")) + s.logError(transactionID, apiKey, jiyiResp.SeqNo, err, requestPayload) + return nil, err + } + + if busiCode != BusiCodeSuccess { + // 查询计费产品:未查得仍返回空数据,由平台计费 + if busiCode == BusiCodeNotFound && isQueryBillingAPIKey(apiKey) { + jiyiResp.Data = map[string]interface{}{} + s.logResponse(transactionID, apiKey, resp.StatusCode, duration, jiyiResp.SeqNo) + return &jiyiResp, nil + } + busiErr := NewJiyiBusiError(busiCode, busiMsg) + err = errors.Join(GetErrByBusiCode(busiCode), busiErr) + s.logError(transactionID, apiKey, jiyiResp.SeqNo, busiErr, requestPayload) + return nil, err + } + + cleanedData, err := stripBusiMetaFromData(jiyiResp.Data) + if err != nil { + err = errors.Join(ErrSystem, fmt.Errorf("响应 data 清理失败: %w", err)) + s.logError(transactionID, apiKey, jiyiResp.SeqNo, err, requestPayload) + return nil, err + } + if isEmptyBusinessResult(cleanedData) { + if isQueryBillingAPIKey(apiKey) { + jiyiResp.Data = map[string]interface{}{} + s.logResponse(transactionID, apiKey, resp.StatusCode, duration, jiyiResp.SeqNo) + return &jiyiResp, nil + } + emptyErr := NewJiyiBusiError(BusiCodeNotFound, "未查得") + err = errors.Join(ErrNotFound, emptyErr) + s.logError(transactionID, apiKey, jiyiResp.SeqNo, err, requestPayload) + return nil, err + } + jiyiResp.Data = cleanedData + + s.logResponse(transactionID, apiKey, resp.StatusCode, duration, jiyiResp.SeqNo) + + return &jiyiResp, nil +} + +type jiyiDataBusiMeta struct { + BusiCode int `json:"busiCode"` + BusiMsg string `json:"busiMsg"` +} + +func parseDataBusiInfo(data interface{}) (busiCode int, busiMsg string, ok bool) { + if data == nil { + return 0, "", false + } + raw, err := json.Marshal(data) + if err != nil { + return 0, "", false + } + var meta jiyiDataBusiMeta + if err := json.Unmarshal(raw, &meta); err != nil { + return 0, "", false + } + return meta.BusiCode, meta.BusiMsg, true +} + +func stripBusiMetaFromData(data interface{}) (interface{}, error) { + raw, err := json.Marshal(data) + if err != nil { + return nil, err + } + var payload map[string]interface{} + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, err + } + delete(payload, "busiCode") + delete(payload, "busiMsg") + return payload, nil +} + +// isEmptyBusinessResult 判断业务载荷是否为空(无 result,或 result 无有效内容) +func isEmptyBusinessResult(data interface{}) bool { + payload, ok := data.(map[string]interface{}) + if !ok || len(payload) == 0 { + return true + } + result, exists := payload["result"] + if !exists || result == nil { + return true + } + return isEmptyValue(result) +} + +func isEmptyValue(v interface{}) bool { + if v == nil { + return true + } + switch x := v.(type) { + case string: + return strings.TrimSpace(x) == "" + case map[string]interface{}: + if len(x) == 0 { + return true + } + for _, item := range x { + if !isEmptyValue(item) { + return false + } + } + return true + case []interface{}: + return len(x) == 0 + default: + return false + } +} + +func cloneBody(body map[string]string) map[string]string { + if body == nil { + return map[string]string{} + } + cloned := make(map[string]string, len(body)+2) + for k, v := range body { + cloned[k] = v + } + return cloned +} + +func wrapHTTPError(err error) error { + if err == context.DeadlineExceeded { + return errors.Join(ErrDatasource, err) + } + if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() { + return errors.Join(ErrDatasource, err) + } + switch err.Error() { + case "context deadline exceeded", "timeout", "Client.Timeout exceeded", "net/http: request canceled": + return errors.Join(ErrDatasource, err) + default: + return errors.Join(ErrSystem, err) + } +}