diff --git a/config.yaml b/config.yaml index 37c3a4d..c635b98 100644 --- a/config.yaml +++ b/config.yaml @@ -670,6 +670,45 @@ nuoer: 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)配置 +# =========================================== +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" + baseUrl2: "https://napi.zhixin.net:9000/api/data" + app_code2: "1508795945301708800" + + logging: + enabled: true + log_dir: "logs/external_services" + service_name: "huibo" + use_daily: true + enable_level_separation: true level_configs: info: max_size: 100 diff --git a/internal/config/config.go b/internal/config/config.go index 5f1e196..89690a6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,6 +42,7 @@ type Config struct { Shumai ShumaiConfig `mapstructure:"shumai"` Shujubao ShujubaoConfig `mapstructure:"shujubao"` Nuoer NuoerConfig `mapstructure:"nuoer"` + Huibo HuiboConfig `mapstructure:"huibo"` PDFGen PDFGenConfig `mapstructure:"pdfgen"` } @@ -709,6 +710,40 @@ type NuoerLevelFileConfig struct { Compress bool `mapstructure:"compress"` } +// HuiboConfig 汇博配置 +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"` + BaseURL2 string `mapstructure:"baseUrl2"` + AppCode2 string `mapstructure:"app_code2"` + + Logging HuiboLoggingConfig `mapstructure:"logging"` +} + +// HuiboLoggingConfig 汇博日志配置 +type HuiboLoggingConfig struct { + Enabled bool `mapstructure:"enabled"` + LogDir string `mapstructure:"log_dir"` + ServiceName string `mapstructure:"service_name"` + 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"` +} + // PDFGenConfig PDF生成服务配置 type PDFGenConfig struct { DevelopmentURL string `mapstructure:"development_url"` // 开发环境服务地址 diff --git a/internal/container/container.go b/internal/container/container.go index da8ab35..2c37aa9 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -39,6 +39,7 @@ import ( "hyapi-server/internal/infrastructure/external/alicloud" "hyapi-server/internal/infrastructure/external/captcha" "hyapi-server/internal/infrastructure/external/email" + "hyapi-server/internal/infrastructure/external/huibo" "hyapi-server/internal/infrastructure/external/jiguang" "hyapi-server/internal/infrastructure/external/muzi" "hyapi-server/internal/infrastructure/external/nuoer" @@ -408,6 +409,10 @@ func NewContainer() *Container { func(cfg *config.Config) (*nuoer.NuoerService, error) { return nuoer.NewNuoerServiceWithConfig(cfg) }, + // HuiboService - 汇博服务 + func(cfg *config.Config) (*huibo.HuiboService, error) { + return huibo.NewHuiboServiceWithConfig(cfg) + }, func(cfg *config.Config) *yushan.YushanService { return yushan.NewYushanService( cfg.Yushan.URL, diff --git a/internal/domains/api/dto/api_request_dto.go b/internal/domains/api/dto/api_request_dto.go index 245eced..7337b6f 100644 --- a/internal/domains/api/dto/api_request_dto.go +++ b/internal/domains/api/dto/api_request_dto.go @@ -1231,3 +1231,12 @@ type JRZQVKK6Req struct { Name string `json:"name" validate:"required,min=1,validName"` MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"` } + +type FLXGHB4FReq struct { + IDCard string `json:"id_card" validate:"required,validIDCard"` + Name string `json:"name" validate:"required,min=1,validName"` +} + +type QYGLBH7YReq struct { + EntName string `json:"ent_name" validate:"required,min=1,validEnterpriseName"` +} diff --git a/internal/domains/api/services/api_request_service.go b/internal/domains/api/services/api_request_service.go index 1587039..a36eca3 100644 --- a/internal/domains/api/services/api_request_service.go +++ b/internal/domains/api/services/api_request_service.go @@ -21,6 +21,7 @@ import ( "hyapi-server/internal/domains/api/services/processors/yysy" "hyapi-server/internal/domains/product/services" "hyapi-server/internal/infrastructure/external/alicloud" + "hyapi-server/internal/infrastructure/external/huibo" "hyapi-server/internal/infrastructure/external/jiguang" "hyapi-server/internal/infrastructure/external/muzi" nuoerext "hyapi-server/internal/infrastructure/external/nuoer" @@ -68,6 +69,7 @@ func NewApiRequestService( jiguangService *jiguang.JiguangService, shumaiService *shumai.ShumaiService, nuoerService *nuoerext.NuoerService, + huiboService *huibo.HuiboService, validator interfaces.RequestValidator, productManagementService *services.ProductManagementService, cfg *appconfig.Config, @@ -84,6 +86,7 @@ func NewApiRequestService( jiguangService, shumaiService, nuoerService, + huiboService, validator, productManagementService, cfg, @@ -105,6 +108,7 @@ func NewApiRequestServiceWithRepos( jiguangService *jiguang.JiguangService, shumaiService *shumai.ShumaiService, nuoerService *nuoerext.NuoerService, + huiboService *huibo.HuiboService, validator interfaces.RequestValidator, productManagementService *services.ProductManagementService, cfg *appconfig.Config, @@ -132,6 +136,7 @@ func NewApiRequestServiceWithRepos( jiguangService, shumaiService, nuoerService, + huiboService, validator, combService, reportRepo, @@ -187,6 +192,7 @@ func registerAllProcessors(combService *comb.CombService) { "FLXGK5D2": flxg.ProcessFLXGK5D2Request, "FLXGDJG3": flxg.ProcessFLXGDJG3Request, //董监高司法综合信息核验 "FLXGG0S4": flxg.ProcessFLXGG0S4Request, //个人诉讼定制版 + "FLXGHB4F": flxg.ProcessFLXGHB4FRequest, //个人涉诉案件查询汇博 // JRZQ系列处理器 "JRZQ8203": jrzq.ProcessJRZQ8203Request, "JRZQ0A03": jrzq.ProcessJRZQ0A03Request, @@ -274,6 +280,7 @@ func registerAllProcessors(combService *comb.CombService) { "QYGL8848": qygl.ProcessQYGL8848Request, //企业税收违法核查 "QYGLVR76": qygl.ProcessQYGLVR76Request, //人企关联 "QYGLV4S6": qygl.ProcessQYGLV4S6Request, //企业诉讼定制版 + "QYGLBH7Y": qygl.ProcessQYGLBH7YRequest, //企业案件查询汇博 // YYSY系列处理器 "YYSY35TA": yysy.ProcessYYSY35TARequest, //运营商归属地数卖 diff --git a/internal/domains/api/services/form_config_service.go b/internal/domains/api/services/form_config_service.go index 9445646..df5dc13 100644 --- a/internal/domains/api/services/form_config_service.go +++ b/internal/domains/api/services/form_config_service.go @@ -301,6 +301,9 @@ func (s *FormConfigServiceImpl) getDTOStruct(ctx context.Context, apiCode string "JRZQVZTF": &dto.JRZQVZTFReq{}, //智瞳-通用版 "JRZQV4TF": &dto.JRZQV4TFReq{}, //智瞳分尊享版 "JRZQVKK6": &dto.JRZQVKK6Req{}, //坤羽模型V3-标签版 + "FLXGHB4F": &dto.FLXGHB4FReq{}, //汇博-个人涉诉 + "QYGLBH7Y": &dto.QYGLBH7YReq{}, //汇博-企业涉诉 + } // 优先返回已配置的DTO diff --git a/internal/domains/api/services/processors/dependencies.go b/internal/domains/api/services/processors/dependencies.go index 8f973f5..c3fbb39 100644 --- a/internal/domains/api/services/processors/dependencies.go +++ b/internal/domains/api/services/processors/dependencies.go @@ -6,6 +6,7 @@ import ( "hyapi-server/internal/application/api/commands" "hyapi-server/internal/domains/api/repositories" "hyapi-server/internal/infrastructure/external/alicloud" + "hyapi-server/internal/infrastructure/external/huibo" "hyapi-server/internal/infrastructure/external/jiguang" "hyapi-server/internal/infrastructure/external/muzi" "hyapi-server/internal/infrastructure/external/nuoer" @@ -42,6 +43,7 @@ type ProcessorDependencies struct { JiguangService *jiguang.JiguangService ShumaiService *shumai.ShumaiService NuoerService *nuoer.NuoerService + HuiboService *huibo.HuiboService Validator interfaces.RequestValidator CombService CombServiceInterface // Changed to interface to break import cycle Options *commands.ApiCallOptions // 添加Options支持 @@ -70,6 +72,7 @@ func NewProcessorDependencies( jiguangService *jiguang.JiguangService, shumaiService *shumai.ShumaiService, nuoerService *nuoer.NuoerService, + huiboService *huibo.HuiboService, validator interfaces.RequestValidator, combService CombServiceInterface, // Changed to interface reportRepo repositories.ReportRepository, @@ -88,6 +91,7 @@ func NewProcessorDependencies( JiguangService: jiguangService, ShumaiService: shumaiService, NuoerService: nuoerService, + HuiboService: huiboService, Validator: validator, CombService: combService, Options: nil, // 初始化为nil,在调用时设置 diff --git a/internal/domains/api/services/processors/flxg/flxghb4f_processor.go b/internal/domains/api/services/processors/flxg/flxghb4f_processor.go new file mode 100644 index 0000000..8446b7f --- /dev/null +++ b/internal/domains/api/services/processors/flxg/flxghb4f_processor.go @@ -0,0 +1,64 @@ +package flxg + +import ( + "context" + "encoding/json" + "errors" + + "hyapi-server/internal/domains/api/dto" + "hyapi-server/internal/domains/api/services/processors" + "hyapi-server/internal/infrastructure/external/huibo" +) + +// ProcessFLXGHB4FRequest FLXGHB4F API处理方法 - 个人涉诉案件查询汇博 +func ProcessFLXGHB4FRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { + var paramsDto dto.FLXGHB4FReq + if err := json.Unmarshal(params, ¶msDto); err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + if deps.HuiboService == nil { + return nil, errors.Join(processors.ErrSystem, errors.New("汇博服务未初始化")) + } + + if err := deps.Validator.ValidateStruct(paramsDto); err != nil { + return nil, errors.Join(processors.ErrInvalidParam, err) + } + + // 使用 MD5 加密 name 和 idCard + // encryptedName := "MD5:" + huibo.MD5Encrypt(paramsDto.Name, deps.HuiboService.GetConfig().AppKey) + // encryptedIDCard := "MD5:" + huibo.MD5Encrypt(paramsDto.IDCard, deps.HuiboService.GetConfig().AppKey) + + reqdata := map[string]interface{}{ + "name": paramsDto.Name, + "idCard": paramsDto.IDCard, + } + + respBytes, err := deps.HuiboService.CallAPI2(ctx, "P_004_0271", reqdata) + if err != nil { + return nil, errors.Join(processors.ErrDatasource, err) + } + + // 解析响应 + var response huibo.CallAPI2Response + if err := json.Unmarshal(respBytes, &response); err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + // 处理状态码 + switch response.Code { + case huibo.CallAPI2StatusSuccess: + // 查询成功 + if response.Data == nil { + return []byte("{}"), nil + } + return respBytes, nil + case huibo.CallAPI2StatusNoData: + // 查询成功,无数据 - 按产品约定按调用成功计费 + return []byte("{}"), nil + default: + // 其他错误状态码 + message := huibo.GetCallAPI2StatusMessage(response.Code) + return nil, errors.Join(processors.ErrDatasource, errors.New(message)) + } +} diff --git a/internal/domains/api/services/processors/qygl/qyglbh7y_processor.go b/internal/domains/api/services/processors/qygl/qyglbh7y_processor.go new file mode 100644 index 0000000..c75ca26 --- /dev/null +++ b/internal/domains/api/services/processors/qygl/qyglbh7y_processor.go @@ -0,0 +1,59 @@ +package qygl + +import ( + "context" + "encoding/json" + "errors" + + "hyapi-server/internal/domains/api/dto" + "hyapi-server/internal/domains/api/services/processors" + "hyapi-server/internal/infrastructure/external/huibo" +) + +// ProcessQYGLBH7YRequest QYGLBH7Y API处理方法 - 企业案件查询汇博 +func ProcessQYGLBH7YRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { + var paramsDto dto.QYGLBH7YReq + if err := json.Unmarshal(params, ¶msDto); err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + if deps.HuiboService == nil { + return nil, errors.Join(processors.ErrSystem, errors.New("汇博服务未初始化")) + } + + if err := deps.Validator.ValidateStruct(paramsDto); err != nil { + return nil, errors.Join(processors.ErrInvalidParam, err) + } + + reqdata := map[string]interface{}{ + "companyName": paramsDto.EntName, + } + + respBytes, err := deps.HuiboService.CallAPI2(ctx, "E_004_0261", reqdata) + if err != nil { + return nil, errors.Join(processors.ErrDatasource, err) + } + + // 解析响应 + var response huibo.CallAPI2Response + if err := json.Unmarshal(respBytes, &response); err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + // 处理状态码 + switch response.Code { + case huibo.CallAPI2StatusSuccess: + // 查询成功 + if response.Data == nil { + return []byte("{}"), nil + } + return respBytes, nil + case huibo.CallAPI2StatusNoData: + // 查询成功,无数据 - 按产品约定按调用成功计费 + return []byte("{}"), nil + default: + // 其他错误状态码 + message := huibo.GetCallAPI2StatusMessage(response.Code) + return nil, errors.Join(processors.ErrDatasource, errors.New(message)) + } +} diff --git a/internal/infrastructure/external/huibo/crypto.go b/internal/infrastructure/external/huibo/crypto.go new file mode 100644 index 0000000..834fbee --- /dev/null +++ b/internal/infrastructure/external/huibo/crypto.go @@ -0,0 +1,83 @@ +package huibo + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/md5" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" +) + +// MD5Encrypt 使用 AppKey 进行 MD5 加密 +func MD5Encrypt(data, appKey string) string { + h := md5.New() + h.Write([]byte(data + appKey)) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +// HMACSHA256Base64 使用 HMAC-SHA256 算法生成签名 +func HMACSHA256Base64(data, secret string) string { + m := hmac.New(sha256.New, []byte(secret)) + _, _ = m.Write([]byte(data)) + return base64.StdEncoding.EncodeToString(m.Sum(nil)) +} + +// EncryptAESGCMBase64 使用 AES-GCM 算法加密数据 +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 +} + +// DecryptAESGCMBase64 使用 AES-GCM 算法解密数据 +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 +} diff --git a/internal/infrastructure/external/huibo/crypto_test.go b/internal/infrastructure/external/huibo/crypto_test.go new file mode 100644 index 0000000..6658db7 --- /dev/null +++ b/internal/infrastructure/external/huibo/crypto_test.go @@ -0,0 +1,432 @@ +package huibo + +import ( + "encoding/base64" + "testing" +) + +// 测试 MD5 加密(固定密文对比验证) +func TestMD5Encrypt(t *testing.T) { + appKey := "a6c9935e967894e731c62ecfcd9b7c95" + + testCases := []struct { + name string + data string + expected string // 固定密文 + }{ + { + name: "姓名", + data: "何志勇", + expected: "64d4d5c6457026117a4911acf189e269", // 固定密文 + }, + { + name: "身份证号", + data: "452528197907133014", + expected: "7c6cc77dabb83d95948904dba5a7219d", // 固定密文 + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // 加密 + result := MD5Encrypt(tc.data, appKey) + + // 最核心:对比是否和固定密文一致 + if result != tc.expected { + t.Errorf("加密不匹配!\n明文:%s\n期望密文:%s\n实际密文:%s", + tc.data, tc.expected, result) + return + } + + // 打印成功日志 + t.Logf("✅ 校验成功\n明文:%s\n密文:%s", tc.data, result) + }) + } +} + +// 测试 HMAC-SHA256 签名 +func TestHMACSHA256Base64(t *testing.T) { + secret := "test_secret_key" + + testCases := []struct { + name string + data string + secret string + }{ + { + name: "简单字符串", + data: "hello world", + secret: secret, + }, + { + name: "JSON数据", + data: `{"name":"张三","idCard":"110101199003072345"}`, + secret: secret, + }, + { + name: "URL参数", + data: "idCard=110101199003072345&name=张三&productCode=22089", + secret: secret, + }, + { + name: "空字符串", + data: "", + secret: secret, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := HMACSHA256Base64(tc.data, tc.secret) + + // 验证结果不为空 + if result == "" { + t.Error("HMAC-SHA256签名结果为空") + } + + // 验证结果是有效的Base64 + _, err := base64.StdEncoding.DecodeString(result) + if err != nil { + t.Errorf("HMAC-SHA256结果不是有效的Base64: %v", err) + } + + // 验证相同输入产生相同输出 + result2 := HMACSHA256Base64(tc.data, tc.secret) + if result != result2 { + t.Error("相同输入产生的签名不一致") + } + + // 验证不同输入产生不同输出 + result3 := HMACSHA256Base64(tc.data+"x", tc.secret) + if result == result3 { + t.Error("不同输入产生的签名相同") + } + + t.Logf("数据: %s, 签名: %s", tc.data, result) + }) + } +} + +// 测试 AES-GCM 加密解密 +func TestEncryptDecryptAESGCMBase64(t *testing.T) { + // 生成一个有效的 Base64 编密的 AES 密钥 + key := make([]byte, 32) // AES-256 + for i := range key { + key[i] = byte(i) + } + base64Key := base64.StdEncoding.EncodeToString(key) + + testCases := []struct { + name string + data string + }{ + { + name: "简单文本", + data: "hello world", + }, + { + name: "中文文本", + data: "你好世界", + }, + { + name: "JSON数据", + data: `{"name":"张三","idCard":"110101199003072345"}`, + }, + { + name: "长文本", + data: "这是一个很长的文本,用来测试加密解密功能是否正常工作。包含各种字符:123456789!@#$%^&*()_+-=[]{}|;':\",./<>?", + }, + { + name: "空字符串", + data: "", + }, + { + name: "特殊字符", + data: "\n\t\r\x00", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // 加密 + encrypted, err := EncryptAESGCMBase64(tc.data, base64Key) + if err != nil { + t.Fatalf("加密失败: %v", err) + } + + // 验证加密结果不为空 + if encrypted == "" { + t.Error("加密结果为空") + } + + // 验证加密结果是有效的Base64 + _, err = base64.StdEncoding.DecodeString(encrypted) + if err != nil { + t.Errorf("加密结果不是有效的Base64: %v", err) + } + + // 验证加密结果与原文不同 + if encrypted == tc.data { + t.Error("加密结果与原文相同") + } + + // 解密 + decrypted, err := DecryptAESGCMBase64(encrypted, base64Key) + if err != nil { + t.Fatalf("解密失败: %v", err) + } + + // 验证解密结果与原文一致 + if decrypted != tc.data { + t.Errorf("解密结果不匹配,期望: %s, 实际: %s", tc.data, decrypted) + } + + t.Logf("原文: %s", tc.data) + t.Logf("密文: %s", encrypted) + t.Logf("解密: %s", decrypted) + }) + } +} + +// 测试错误的密钥 +func TestEncryptDecryptWithWrongKey(t *testing.T) { + // 生成两个不同的密钥 + key1 := make([]byte, 32) + for i := range key1 { + key1[i] = byte(i) + } + base64Key1 := base64.StdEncoding.EncodeToString(key1) + + key2 := make([]byte, 32) + for i := range key2 { + key2[i] = byte(i + 1) + } + base64Key2 := base64.StdEncoding.EncodeToString(key2) + + data := "sensitive data" + + // 用密钥1加密 + encrypted, err := EncryptAESGCMBase64(data, base64Key1) + if err != nil { + t.Fatalf("加密失败: %v", err) + } + + // 用密钥2解密 + decrypted, err := DecryptAESGCMBase64(encrypted, base64Key2) + if err == nil { + t.Error("用错误密钥解密应该返回错误") + } + + // 验证解密结果与原文不同(如果解密成功的话) + if decrypted == data { + t.Error("用错误密钥解密不应该得到正确结果") + } + + t.Logf("用错误密钥解密预期失败: %v", err) +} + +// 测试无效的 Base64 密钥 +func TestEncryptWithInvalidBase64Key(t *testing.T) { + data := "test data" + + invalidKeys := []string{ + "", // 空字符串 + "not_base64", // 非Base64 + "abc", // 解码后太短 + } + + for _, invalidKey := range invalidKeys { + _, err := EncryptAESGCMBase64(data, invalidKey) + if err == nil { + t.Errorf("使用无效密钥 %s 应该返回错误", invalidKey) + } + t.Logf("无效密钥 %s 预期失败: %v", invalidKey, err) + } +} + +// 测试解密无效数据 +func TestDecryptWithInvalidData(t *testing.T) { + // 生成一个有效的密钥 + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + base64Key := base64.StdEncoding.EncodeToString(key) + + invalidData := []string{ + "", // 空字符串 + "invalid_base64", // 非Base64 + "dGVzdA==", // 有效的Base64但不是AES-GCM数据 + "short", // 太短 + } + + for _, data := range invalidData { + _, err := DecryptAESGCMBase64(data, base64Key) + if err == nil { + t.Errorf("使用无效数据 %s 应该返回错误", data) + } + t.Logf("无效数据 %s 预期失败: %v", data, err) + } +} + +// 测试加密结果的唯一性 +func TestEncryptionUniqueness(t *testing.T) { + // 生成一个有效的密钥 + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + base64Key := base64.StdEncoding.EncodeToString(key) + + data := "same data" + + // 加密多次 + results := make([]string, 10) + for i := 0; i < 10; i++ { + encrypted, err := EncryptAESGCMBase64(data, base64Key) + if err != nil { + t.Fatalf("第%d次加密失败: %v", i, err) + } + results[i] = encrypted + } + + // 验证每次加密结果都不同(因为包含随机IV) + uniqueCount := 0 + for i := 0; i < len(results); i++ { + isUnique := true + for j := 0; j < len(results); j++ { + if i != j && results[i] == results[j] { + isUnique = false + break + } + } + if isUnique { + uniqueCount++ + } + } + + if uniqueCount != len(results) { + t.Errorf("加密结果应该唯一,实际上只有%d个唯一结果,期望%d个", uniqueCount, len(results)) + } + + t.Logf("生成了%d个不同的加密结果", uniqueCount) +} + +// 测试使用真实配置的加密解密 +func TestEncryptionWithRealConfig(t *testing.T) { + // 使用配置文件中的真实AES密钥 + aesKey := "NQYN3YO+pb/GEcCBNX0ptMb7cUlnXSPvcX7VvNofBkc=" + appKey := "a6c9935e967894e731c62ecfcd9b7c95" + + // 测试数据 + testData := `{"name":"张三","idCard":"110101199003072345","productCode":"22089"}` + + t.Run("MD5加密", func(t *testing.T) { + // 测试 MD5 加密 + md5Result := MD5Encrypt("张三", appKey) + t.Logf("姓名 MD5: %s", md5Result) + + md5Result2 := MD5Encrypt("110101199003072345", appKey) + t.Logf("身份证号 MD5: %s", md5Result2) + + // 验证格式 + if len(md5Result) != 32 { + t.Errorf("MD5结果长度错误,期望32位,实际%d位", len(md5Result)) + } + }) + + t.Run("HMAC-SHA256签名", func(t *testing.T) { + // 生成排序后的参数 + sortedParam := "idCard=110101199003072345&name=张三&productCode=22089" + signature := HMACSHA256Base64(sortedParam, aesKey) + t.Logf("签名参数: %s", sortedParam) + t.Logf("HMAC-SHA256签名: %s", signature) + + // 验证格式 + _, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + t.Errorf("签名不是有效的Base64: %v", err) + } + }) + + t.Run("AES-GCM加密解密", func(t *testing.T) { + // 测试 AES-GCM 加密 + encrypted, err := EncryptAESGCMBase64(testData, aesKey) + if err != nil { + t.Fatalf("加密失败: %v", err) + } + t.Logf("原始数据: %s", testData) + t.Logf("加密结果: %s", encrypted) + + // 测试解密 + decrypted, err := DecryptAESGCMBase64(encrypted, aesKey) + if err != nil { + t.Fatalf("解密失败: %v", err) + } + t.Logf("解密结果: %s", decrypted) + + // 验证结果 + if decrypted != testData { + t.Errorf("解密结果不匹配") + } + }) +} + +// 基准测试 +func BenchmarkMD5Encrypt(b *testing.B) { + data := "张三" + appKey := "a6c9935e967894e731c62ecfcd9b7c95" + + b.ResetTimer() + for i := 0; i < b.N; i++ { + MD5Encrypt(data, appKey) + } +} + +func BenchmarkHMACSHA256Base64(b *testing.B) { + data := "idCard=110101199003072345&name=张三" + secret := "a6c9935e967894e731c62ecfcd9b7c95" + + b.ResetTimer() + for i := 0; i < b.N; i++ { + HMACSHA256Base64(data, secret) + } +} + +func BenchmarkEncryptAESGCMBase64(b *testing.B) { + data := `{"name":"张三","idCard":"110101199003072345","productCode":"22089"}` + + // 生成密钥 + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + base64Key := base64.StdEncoding.EncodeToString(key) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + EncryptAESGCMBase64(data, base64Key) + } +} + +func BenchmarkDecryptAESGCMBase64(b *testing.B) { + data := `{"name":"张三","idCard":"110101199003072345","productCode":"22089"}` + + // 生成密钥 + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + base64Key := base64.StdEncoding.EncodeToString(key) + + // 先加密一次 + encrypted, err := EncryptAESGCMBase64(data, base64Key) + if err != nil { + b.Fatalf("预加密失败: %v", err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + DecryptAESGCMBase64(encrypted, base64Key) + } +} diff --git a/internal/infrastructure/external/huibo/curl_helper.go b/internal/infrastructure/external/huibo/curl_helper.go new file mode 100644 index 0000000..f7ca797 --- /dev/null +++ b/internal/infrastructure/external/huibo/curl_helper.go @@ -0,0 +1,59 @@ +package huibo + +import ( + "net/http" + "strings" +) + +// generateCurlCommand 从 HTTP 请求生成 curl 命令 +func generateCurlCommand(req *http.Request) string { + var cmd strings.Builder + cmd.WriteString("curl -X ") + cmd.WriteString(req.Method) + cmd.WriteString(" '") + cmd.WriteString(req.URL.String()) + cmd.WriteString("'") + + // 添加请求头 + for key, values := range req.Header { + for _, value := range values { + cmd.WriteString(" \\\n -H '") + cmd.WriteString(key) + cmd.WriteString(": ") + cmd.WriteString(value) + cmd.WriteString("'") + } + } + + return cmd.String() +} + +// generateCurlCommandWithBody 生成包含请求体的 curl 命令 +func generateCurlCommandWithBody(method, url string, headers map[string]string, body string) string { + var cmd strings.Builder + cmd.WriteString("curl -X ") + cmd.WriteString(method) + cmd.WriteString(" '") + cmd.WriteString(url) + cmd.WriteString("'") + + // 添加请求头 + for key, value := range headers { + cmd.WriteString(" \\\n -H '") + cmd.WriteString(key) + cmd.WriteString(": ") + cmd.WriteString(value) + cmd.WriteString("'") + } + + // 添加请求体 + if body != "" { + cmd.WriteString(" \\\n -d '") + cmd.WriteString(body) + cmd.WriteString("'") + } + + cmd.WriteString(" \\\n --compressed") + + return cmd.String() +} \ No newline at end of file diff --git a/internal/infrastructure/external/huibo/huibo_factory.go b/internal/infrastructure/external/huibo/huibo_factory.go new file mode 100644 index 0000000..d28eed4 --- /dev/null +++ b/internal/infrastructure/external/huibo/huibo_factory.go @@ -0,0 +1,47 @@ +package huibo + +import ( + "hyapi-server/internal/config" + "hyapi-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, + BaseURL2: cfg.Huibo.BaseURL2, + AppCode2: cfg.Huibo.AppCode2, + }, 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..2550234 --- /dev/null +++ b/internal/infrastructure/external/huibo/huibo_service.go @@ -0,0 +1,612 @@ +package huibo + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/md5" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "hyapi-server/internal/shared/external_logger" + "hyapi-server/internal/shared/pdfvalidate" + + "go.uber.org/zap" +) + +var ( + ErrDatasource = errors.New("数据源异常") + ErrSystem = errors.New("系统异常") +) + +const ( + headerAuthorization = "Authorization" + headerWorkOrderCode = "workOrderCode" + headerOrderCode = "X-ORDER-CODE" + headerSecretIDHdr = "secretId" + headerAESKeyHdr = "aesKey" +) + +// 汇博常见状态码 +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 + BaseURL2 string // CallAPI2 使用的 URL + AppCode2 string // CallAPI2 使用的 AppCode +} + +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"` +} + +// CallAPI2Response CallAPI2 的响应结构体 +type CallAPI2Response struct { + Code string `json:"code"` + Data map[string]interface{} `json:"data"` + Msg string `json:"msg"` +} + +func NewHuiboService(config HuiboConfig, logger *external_logger.ExternalServiceLogger) *HuiboService { + return &HuiboService{config: config, logger: logger} +} + +// GetConfig 获取汇博配置 +func (s *HuiboService) GetConfig() HuiboConfig { + return s.config +} + +// 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) + // 与对接示例一致:先 file 再 req + 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.WriteField("req", string(reqOuterJSON)); 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(headerWorkOrderCode, s.config.WorkOrderCode) + req.Header.Set(headerOrderCode, s.config.XOrderCode) + req.Header.Set(headerSecretIDHdr, s.config.SecretID) + req.Header.Set(headerAESKeyHdr, s.config.AESKey) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + if err != nil { + if s.logger != nil { + s.logger.LogErrorWithFields("汇博 HTTP 请求失败", + zap.String("url", s.config.URL), + zap.Error(err), + ) + } + return nil, errors.Join(ErrDatasource, err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + if s.logger != nil { + s.logger.LogErrorWithFields("汇博 读取响应体失败", + zap.String("url", s.config.URL), + zap.Int("http_status", resp.StatusCode), + zap.Error(err), + ) + } + return nil, errors.Join(ErrSystem, err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + if s.logger != nil { + bodySnippet := string(respBody) + const maxLog = 1024 + if len(bodySnippet) > maxLog { + bodySnippet = bodySnippet[:maxLog] + "...(truncated)" + } + s.logger.LogErrorWithFields("汇博 HTTP 状态异常", + zap.String("url", s.config.URL), + zap.Int("http_status", resp.StatusCode), + zap.String("response_body", bodySnippet), + ) + } + 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.WorkOrderCode) == "" || + 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) +} + +// MD5Encrypt 使用配置的 AppKey 进行 MD5 加密 +func (s *HuiboService) MD5Encrypt(data string) string { + h := md5.New() + h.Write([]byte(data + s.config.AppKey)) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +// CallAPI2 通用 HTTP 调用方法,返回原始响应 JSON +func (s *HuiboService) CallAPI2(ctx context.Context, pcode string, requestData map[string]interface{}) ([]byte, error) { + startTime := time.Now() + transactionID := "" + if v, ok := ctx.Value("transaction_id").(string); ok { + transactionID = v + } + + if s.logger != nil { + s.logger.LogRequest("", transactionID, "huibo_callapi2", s.config.BaseURL2) + } + + if strings.TrimSpace(s.config.BaseURL2) == "" { + return nil, errors.Join(ErrSystem, errors.New("汇博配置不完整:BaseURL2为空")) + } + + if strings.TrimSpace(s.config.AppCode2) == "" { + return nil, errors.Join(ErrSystem, errors.New("汇博配置不完整:AppCode2为空")) + } + + reqJSON, err := json.Marshal(requestData) + if err != nil { + return nil, errors.Join(ErrSystem, fmt.Errorf("请求参数序列化失败: %w", err)) + } + + // 构建 curl 命令的 headers + headers := map[string]string{ + "AppCode": s.config.AppCode2, + "pcode": pcode, + "Content-Type": "application/json", + "X-ORDER-CODE": s.config.XOrderCode, + } + + // 生成包含请求体的 curl 命令用于日志记录 + curlCmd := generateCurlCommandWithBody("POST", s.config.BaseURL2, headers, string(reqJSON)) + + // 创建 HTTP 请求 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.config.BaseURL2, bytes.NewBuffer(reqJSON)) + if err != nil { + return nil, errors.Join(ErrSystem, fmt.Errorf("创建HTTP请求失败: %w", err)) + } + + // req.Header.Set(headerAuthorization, s.config.AppID+"::"+s.config.AppKey) + // req.Header.Set(headerWorkOrderCode, s.config.WorkOrderCode) + // req.Header.Set(headerOrderCode, s.config.XOrderCode) + // req.Header.Set(headerSecretIDHdr, s.config.SecretID) + // req.Header.Set(headerAESKeyHdr, s.config.AESKey) + // req.Header.Set("Content-Type", writer.FormDataContentType()) + + // 设置请求头 + req.Header.Set("AppCode", s.config.AppCode2) + req.Header.Set("pcode", pcode) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-ORDER-CODE", s.config.XOrderCode) + + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + if err != nil { + if s.logger != nil { + s.logger.LogErrorWithFields("汇博 CallAPI2 HTTP 请求失败", + zap.String("url", s.config.BaseURL2), + zap.String("pcode", pcode), + zap.String("curl", curlCmd), + zap.Error(err), + ) + } + return nil, errors.Join(ErrDatasource, err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + if s.logger != nil { + s.logger.LogErrorWithFields("汇博 CallAPI2 读取响应体失败", + zap.String("url", s.config.BaseURL2), + zap.Int("http_status", resp.StatusCode), + zap.Error(err), + ) + } + return nil, errors.Join(ErrSystem, fmt.Errorf("读取响应体失败: %w", err)) + } + + // 解析响应以检查业务状态码 + var response CallAPI2Response + if err := json.Unmarshal(respBody, &response); err != nil { + if s.logger != nil { + s.logger.LogErrorWithFields("汇博 CallAPI2 响应解析失败", + zap.String("url", s.config.BaseURL2), + zap.String("pcode", pcode), + zap.Error(err), + ) + } + return nil, errors.Join(ErrDatasource, fmt.Errorf("响应解析失败: %w", err)) + } + + // 根据业务状态码进行处理 + switch response.Code { + case CallAPI2StatusSuccess: + // 查询成功 + if s.logger != nil { + s.logger.LogInfo( + "汇博 CallAPI2 查询成功", + zap.String("pcode", pcode), + zap.String("code", response.Code), + zap.String("transaction_id", transactionID), + ) + } + case CallAPI2StatusNoData: + // 查询成功,无数据 + if s.logger != nil { + s.logger.LogInfo( + "汇博 CallAPI2 查询成功但无数据", + zap.String("pcode", pcode), + zap.String("code", response.Code), + zap.String("transaction_id", transactionID), + ) + } + default: + // 其他错误状态码 + message := GetCallAPI2StatusMessage(response.Code) + if s.logger != nil { + s.logger.LogErrorWithFields("汇博 CallAPI2 业务状态异常", + zap.String("url", s.config.BaseURL2), + zap.String("pcode", pcode), + zap.String("code", response.Code), + zap.String("message", message), + ) + } + return nil, errors.Join(ErrDatasource, fmt.Errorf("业务状态异常(code=%s,msg=%s)", response.Code, message)) + } + + // 记录 curl 命令和响应 + if s.logger != nil { + s.logger.LogInfo( + "汇博 CallAPI2 请求响应", + zap.String("curl", curlCmd), + zap.String("response_body", string(respBody)), + zap.String("transaction_id", transactionID), + ) + } + + if s.logger != nil { + s.logger.LogResponse("", transactionID, "huibo_callapi2", http.StatusOK, time.Since(startTime)) + } + + return respBody, nil +} diff --git a/internal/infrastructure/external/huibo/status_codes.go b/internal/infrastructure/external/huibo/status_codes.go new file mode 100644 index 0000000..5a59915 --- /dev/null +++ b/internal/infrastructure/external/huibo/status_codes.go @@ -0,0 +1,52 @@ +package huibo + +// CallAPI2 状态码常量 +const ( + CallAPI2StatusSuccess = "100" // 查询成功 + CallAPI2StatusNoData = "110" // 查询成功,无数据 + CallAPI2StatusParamError = "101" // 参数错误 + CallAPI2StatusAccountError = "103" // 账户不存在 + CallAPI2StatusIPError = "104" // IP 限制 + CallAPI2StatusExpired = "105" // 账号已过期 + CallAPI2StatusServiceError = "107" // 服务不存在 + CallAPI2StatusChannelError = "108" // 产品通道已关闭 + CallAPI2StatusBalanceError = "109" // 账户资金不足 + CallAPI2StatusUnknownError = "500" // 未知请求错误 +) + +// CallAPI2 状态码对应的错误信息 +var CallAPI2StatusMessages = map[string]string{ + CallAPI2StatusSuccess: "查询成功", + CallAPI2StatusNoData: "查询成功,无数据", + CallAPI2StatusParamError: "参数错误", + CallAPI2StatusAccountError: "账户不存在", + CallAPI2StatusIPError: "IP 限制", + CallAPI2StatusExpired: "账号已过期", + CallAPI2StatusServiceError: "服务不存在", + CallAPI2StatusChannelError: "产品通道已关闭", + CallAPI2StatusBalanceError: "账户资金不足", + CallAPI2StatusUnknownError: "未知请求错误", +} + +// IsCallAPI2Success 判断 CallAPI2 状态码是否为成功(需要扣费) +func IsCallAPI2Success(code string) bool { + return code == CallAPI2StatusSuccess +} + +// IsCallAPI2NoData 判断 CallAPI2 状态码是否为无数据(需要扣费) +func IsCallAPI2NoData(code string) bool { + return code == CallAPI2StatusNoData +} + +// IsCallAPI2Billable 判断 CallAPI2 状态码是否需要扣费 +func IsCallAPI2Billable(code string) bool { + return IsCallAPI2Success(code) || IsCallAPI2NoData(code) +} + +// GetCallAPI2StatusMessage 获取 CallAPI2 状态码对应的错误信息 +func GetCallAPI2StatusMessage(code string) string { + if msg, ok := CallAPI2StatusMessages[code]; ok { + return msg + } + return "未知状态码: " + code +} diff --git a/internal/shared/pdfvalidate/pdfvalidate.go b/internal/shared/pdfvalidate/pdfvalidate.go new file mode 100644 index 0000000..178745a --- /dev/null +++ b/internal/shared/pdfvalidate/pdfvalidate.go @@ -0,0 +1,26 @@ +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 +}