diff --git a/config.yaml b/config.yaml index 6399b64..d804bda 100644 --- a/config.yaml +++ b/config.yaml @@ -679,3 +679,37 @@ huibo: max_backups: 5 max_age: 30 compress: true + + + +# =========================================== +# 🌐 诺尔智汇配置 +# =========================================== +nuoer: + url: "https://api.enolfax.com/enol/api" + app_id: "t4qO2mR3" + app_secret: "d1515bf9ed2f2fe063b5f4f7e2c50f0ec65bfd58" + timeout: 4s + logging: + enabled: true + log_dir: "logs/external_services" + service_name: "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 \ No newline at end of file diff --git a/go.mod b/go.mod index 5e480be..8dd8e10 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ require ( github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 github.com/alibabacloud-go/tea v1.3.13 github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 + github.com/chromedp/cdproto v0.0.0-20250319231242-a755498943c8 + github.com/chromedp/chromedp v0.13.2 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.10.1 github.com/go-playground/locales v0.14.1 @@ -16,6 +18,7 @@ require ( github.com/google/uuid v1.6.0 github.com/hibiken/asynq v0.25.1 github.com/jung-kurt/gofpdf/v2 v2.17.3 + github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260313013624-04e51e218220 github.com/prometheus/client_golang v1.22.0 github.com/qiniu/go-sdk/v7 v7.25.4 github.com/redis/go-redis/v9 v9.11.0 @@ -58,8 +61,6 @@ require ( github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cenkalti/backoff/v5 v5.0.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/chromedp/cdproto v0.0.0-20250319231242-a755498943c8 // indirect - github.com/chromedp/chromedp v0.13.2 // indirect github.com/chromedp/sysutil v1.1.0 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/cloudwego/base64x v0.1.5 // indirect @@ -94,15 +95,12 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260313013624-04e51e218220 // indirect github.com/mailru/easyjson v0.7.6 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect - github.com/oschwald/geoip2-golang v1.13.0 // indirect - github.com/oschwald/maxminddb-golang v1.13.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect diff --git a/go.sum b/go.sum index e9e4a0d..f4c970a 100644 --- a/go.sum +++ b/go.sum @@ -244,6 +244,7 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= @@ -268,10 +269,7 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A= github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= -github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI= -github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= -github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU= -github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= diff --git a/internal/config/config.go b/internal/config/config.go index 13e432e..e255e19 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,6 +45,7 @@ type Config struct { Shujubao ShujubaoConfig `mapstructure:"shujubao"` PDFGen PDFGenConfig `mapstructure:"pdfgen"` Huibo HuiboConfig `mapstructure:"huibo"` + Nuoer NuoerConfig `mapstructure:"nuoer"` } // ServerConfig HTTP服务器配置 @@ -705,6 +706,34 @@ type HuiboLevelFileConfig struct { Compress bool `mapstructure:"compress"` } +// NuoerConfig 诺尔智汇配置 +type NuoerConfig struct { + URL string `mapstructure:"url"` + AppID string `mapstructure:"app_id"` + AppSecret string `mapstructure:"app_secret"` + Timeout time.Duration `mapstructure:"timeout"` + + Logging NuoerLoggingConfig `mapstructure:"logging"` +} + +// NuoerLoggingConfig 诺尔智汇日志配置 +type NuoerLoggingConfig 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]NuoerLevelFileConfig `mapstructure:"level_configs"` +} + +// NuoerLevelFileConfig 诺尔智汇级别文件配置 +type NuoerLevelFileConfig 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 79e9f78..a0c99f8 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -45,6 +45,7 @@ import ( "tyapi-server/internal/infrastructure/external/huibo" "tyapi-server/internal/infrastructure/external/jiguang" "tyapi-server/internal/infrastructure/external/muzi" + "tyapi-server/internal/infrastructure/external/nuoer" "tyapi-server/internal/infrastructure/external/ocr" "tyapi-server/internal/infrastructure/external/shujubao" "tyapi-server/internal/infrastructure/external/shumai" @@ -405,6 +406,10 @@ func NewContainer() *Container { func(cfg *config.Config) (*shujubao.ShujubaoService, error) { return shujubao.NewShujubaoServiceWithConfig(cfg) }, + // NuoerService - 诺尔智汇服务 + func(cfg *config.Config) (*nuoer.NuoerService, error) { + return nuoer.NewNuoerServiceWithConfig(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 dc71ff7..029b9c9 100644 --- a/internal/domains/api/dto/api_request_dto.go +++ b/internal/domains/api/dto/api_request_dto.go @@ -358,6 +358,11 @@ type QCXGGB2QReq struct { Name string `json:"name" validate:"required,min=1,validName"` CarPlateType string `json:"carplate_type" validate:"required"` } + +type QCXGM4CLReq struct { + IDCard string `json:"id_card" validate:"required,validIDCard"` +} + type QCXGJJ2AReq struct { VinCode string `json:"vin_code" validate:"required"` EngineNumber string `json:"engine_number" validate:"omitempty"` diff --git a/internal/domains/api/services/api_request_service.go b/internal/domains/api/services/api_request_service.go index 8cba7ba..e211349 100644 --- a/internal/domains/api/services/api_request_service.go +++ b/internal/domains/api/services/api_request_service.go @@ -24,6 +24,7 @@ import ( "tyapi-server/internal/infrastructure/external/huibo" "tyapi-server/internal/infrastructure/external/jiguang" "tyapi-server/internal/infrastructure/external/muzi" + "tyapi-server/internal/infrastructure/external/nuoer" "tyapi-server/internal/infrastructure/external/shujubao" "tyapi-server/internal/infrastructure/external/shumai" "tyapi-server/internal/infrastructure/external/tianyancha" @@ -68,6 +69,7 @@ func NewApiRequestService( jiguangService *jiguang.JiguangService, shumaiService *shumai.ShumaiService, huiboService *huibo.HuiboService, + nuoerService *nuoer.NuoerService, validator interfaces.RequestValidator, productManagementService *services.ProductManagementService, cfg *appconfig.Config, @@ -84,6 +86,7 @@ func NewApiRequestService( jiguangService, shumaiService, huiboService, + nuoerService, validator, productManagementService, cfg, @@ -105,6 +108,7 @@ func NewApiRequestServiceWithRepos( jiguangService *jiguang.JiguangService, shumaiService *shumai.ShumaiService, huiboService *huibo.HuiboService, + nuoerService *nuoer.NuoerService, validator interfaces.RequestValidator, productManagementService *services.ProductManagementService, cfg *appconfig.Config, @@ -132,6 +136,7 @@ func NewApiRequestServiceWithRepos( jiguangService, shumaiService, huiboService, + nuoerService, validator, combService, reportRepo, @@ -378,7 +383,7 @@ func registerAllProcessors(combService *comb.CombService) { "QCXG5U0Z": qcxg.ProcessQCXG5U0ZRequest, // 车辆静态信息查询 10479 "QCXGY7F2": qcxg.ProcessQCXGY7F2Request, // 二手车VIN估值 10443 "QCXG3M7Z": qcxg.ProcessQCXG3M7ZRequest, //人车关系核验(ETC)10093 月更 - + "QCXGM4CL": qcxg.ProcessQCXGM4CLRequest, //名下车辆诺尔 // DWBG系列处理器 - 多维报告 "DWBG6A2C": dwbg.ProcessDWBG6A2CRequest, "DWBG8B4D": dwbg.ProcessDWBG8B4DRequest, diff --git a/internal/domains/api/services/form_config_service.go b/internal/domains/api/services/form_config_service.go index 1384838..d3d458d 100644 --- a/internal/domains/api/services/form_config_service.go +++ b/internal/domains/api/services/form_config_service.go @@ -287,6 +287,7 @@ func (s *FormConfigServiceImpl) getDTOStruct(ctx context.Context, apiCode string "QYGL3YSB": &dto.QYGL3YSBReq{}, //企业三要素认证shumai "QYGL2YSB": &dto.QYGL2YSBReq{}, //企业二要素认证shumai "QYGLDG77": &dto.QYGLDG77Req{}, //企业对公打款认证shumai + "QCXGM4CL": &dto.QCXGM4CLReq{}, //名下车辆诺尔 } // 优先返回已配置的DTO diff --git a/internal/domains/api/services/processors/dependencies.go b/internal/domains/api/services/processors/dependencies.go index 1455e2e..39a8058 100644 --- a/internal/domains/api/services/processors/dependencies.go +++ b/internal/domains/api/services/processors/dependencies.go @@ -9,6 +9,7 @@ import ( "tyapi-server/internal/infrastructure/external/huibo" "tyapi-server/internal/infrastructure/external/jiguang" "tyapi-server/internal/infrastructure/external/muzi" + "tyapi-server/internal/infrastructure/external/nuoer" "tyapi-server/internal/infrastructure/external/shujubao" "tyapi-server/internal/infrastructure/external/shumai" "tyapi-server/internal/infrastructure/external/tianyancha" @@ -42,6 +43,7 @@ type ProcessorDependencies struct { JiguangService *jiguang.JiguangService ShumaiService *shumai.ShumaiService HuiboService *huibo.HuiboService + NuoerService *nuoer.NuoerService 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, huiboService *huibo.HuiboService, + nuoerService *nuoer.NuoerService, validator interfaces.RequestValidator, combService CombServiceInterface, // Changed to interface reportRepo repositories.ReportRepository, @@ -88,6 +91,7 @@ func NewProcessorDependencies( JiguangService: jiguangService, ShumaiService: shumaiService, HuiboService: huiboService, + NuoerService: nuoerService, Validator: validator, CombService: combService, Options: nil, // 初始化为nil,在调用时设置 diff --git a/internal/domains/api/services/processors/qcxg/qcxg4d2e_processor.go b/internal/domains/api/services/processors/qcxg/qcxg4d2e_processor.go index 84417d1..02c789a 100644 --- a/internal/domains/api/services/processors/qcxg/qcxg4d2e_processor.go +++ b/internal/domains/api/services/processors/qcxg/qcxg4d2e_processor.go @@ -7,10 +7,9 @@ import ( "tyapi-server/internal/domains/api/dto" "tyapi-server/internal/domains/api/services/processors" - "tyapi-server/internal/infrastructure/external/jiguang" ) -// ProcessQCXG4D2ERequest QCXG4D2E API处理方法 - 极光名下车辆数量查询 +// ProcessQCXG4D2ERequest QCXG4D2E API处理方法 - 名下车辆数量(委托诺尔 QCXGM4CL) func ProcessQCXG4D2ERequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { var paramsDto dto.QCXG4D2EReq if err := json.Unmarshal(params, ¶msDto); err != nil { @@ -21,27 +20,15 @@ func ProcessQCXG4D2ERequest(ctx context.Context, params []byte, deps *processors return nil, errors.Join(processors.ErrInvalidParam, err) } - // 构建请求参数 - reqData := map[string]interface{}{ - "idNum": paramsDto.IDCard, - "userType": paramsDto.UserType, - } - - // 调用极光API - // apiCode: vehicle-inquiry-under-name (用于请求头) - // apiPath: vehicle/inquiry-under-name (用于URL路径) - respBytes, err := deps.JiguangService.CallAPI(ctx, "vehicle-inquiry-under-name", "vehicle/inquiry-under-name", reqData) + m4clParams, err := json.Marshal(dto.QCXGM4CLReq{IDCard: paramsDto.IDCard}) if err != nil { - // 根据错误类型返回相应的错误 - if errors.Is(err, jiguang.ErrNotFound) { - return nil, errors.Join(processors.ErrNotFound, err) - } else if errors.Is(err, jiguang.ErrDatasource) { - return nil, errors.Join(processors.ErrDatasource, err) - } else { - return nil, errors.Join(processors.ErrSystem, err) - } + return nil, errors.Join(processors.ErrSystem, err) } - // 极光服务已经返回了 data 字段的 JSON,直接返回即可 - return respBytes, nil + raw, err := ProcessQCXGM4CLRequest(ctx, m4clParams, deps) + if err != nil { + return nil, err + } + + return transformQCXG5F3AResponse(raw) } diff --git a/internal/domains/api/services/processors/qcxg/qcxg5f3a_processor.go b/internal/domains/api/services/processors/qcxg/qcxg5f3a_processor.go index 830dab6..f7c1b9c 100644 --- a/internal/domains/api/services/processors/qcxg/qcxg5f3a_processor.go +++ b/internal/domains/api/services/processors/qcxg/qcxg5f3a_processor.go @@ -4,13 +4,15 @@ import ( "context" "encoding/json" "errors" + "strconv" "tyapi-server/internal/domains/api/dto" "tyapi-server/internal/domains/api/services/processors" - "tyapi-server/internal/infrastructure/external/jiguang" + + "github.com/tidwall/gjson" ) -// ProcessQCXG5F3ARequest QCXG5F3A API处理方法 - 极光名下车辆车牌查询 以替换数量 +// ProcessQCXG5F3ARequest QCXG5F3A API处理方法 - 名下车辆(委托诺尔 QCXGM4CL,响应格式兼容极光) func ProcessQCXG5F3ARequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { var paramsDto dto.QCXG5F3AReq if err := json.Unmarshal(params, ¶msDto); err != nil { @@ -21,28 +23,53 @@ func ProcessQCXG5F3ARequest(ctx context.Context, params []byte, deps *processors return nil, errors.Join(processors.ErrInvalidParam, err) } - // 构建请求参数 - reqData := map[string]interface{}{ - "idNum": paramsDto.IDCard, - "name": paramsDto.Name, - "userType": "1", - } - - // 调用极光API - // apiCode: vehicle-inquiry-under-name (用于请求头) - // apiPath: vehicle/inquiry-under-name (用于URL路径) - respBytes, err := deps.JiguangService.CallAPI(ctx, "vehicle-inquiry-under-name", "vehicle/inquiry-under-name", reqData) + m4clParams, err := json.Marshal(dto.QCXGM4CLReq{IDCard: paramsDto.IDCard}) if err != nil { - // 根据错误类型返回相应的错误 - if errors.Is(err, jiguang.ErrNotFound) { - return nil, errors.Join(processors.ErrNotFound, err) - } else if errors.Is(err, jiguang.ErrDatasource) { - return nil, errors.Join(processors.ErrDatasource, err) - } else { - return nil, errors.Join(processors.ErrSystem, err) - } + return nil, errors.Join(processors.ErrSystem, err) } - // 极光服务已经返回了 data 字段的 JSON,直接返回即可 - return respBytes, nil + raw, err := ProcessQCXGM4CLRequest(ctx, m4clParams, deps) + if err != nil { + return nil, err + } + + return transformQCXG5F3AResponse(raw) +} + +// transformQCXG5F3AResponse 将诺尔响应转为 QCXG5F3A 对外格式:去掉 busiCode/busiMsg,展开 result,vehicleCount 为字符串 +func transformQCXG5F3AResponse(raw []byte) ([]byte, error) { + base := gjson.GetBytes(raw, "result") + if !base.Exists() { + base = gjson.ParseBytes(raw) + } + + list := base.Get("list").Value() + if list == nil { + list = []interface{}{} + } + + countStr, err := formatVehicleCountAsString(base.Get("vehicleCount")) + if err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + out := map[string]interface{}{ + "vehicleCount": countStr, + "list": list, + } + return json.Marshal(out) +} + +func formatVehicleCountAsString(v gjson.Result) (string, error) { + if !v.Exists() { + return "0", nil + } + switch v.Type { + case gjson.String: + return v.String(), nil + case gjson.Number: + return strconv.FormatInt(v.Int(), 10), nil + default: + return "", errors.New("vehicleCount 类型无效") + } } diff --git a/internal/domains/api/services/processors/qcxg/qcxg9p1c_processor.go b/internal/domains/api/services/processors/qcxg/qcxg9p1c_processor.go index 3f18c40..4b9f7ab 100644 --- a/internal/domains/api/services/processors/qcxg/qcxg9p1c_processor.go +++ b/internal/domains/api/services/processors/qcxg/qcxg9p1c_processor.go @@ -8,12 +8,11 @@ import ( "tyapi-server/internal/domains/api/dto" "tyapi-server/internal/domains/api/services/processors" - "tyapi-server/internal/infrastructure/external/jiguang" "github.com/tidwall/gjson" ) -// ProcessQCXG9P1CRequest QCXG9P1C API处理方法 兼容旧版 极光名下车牌查询数量 +// ProcessQCXG9P1CRequest QCXG9P1C API处理方法 - 名下车辆详版(委托诺尔 QCXGM4CL) func ProcessQCXG9P1CRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { var paramsDto dto.QCXG9P1CReq if err := json.Unmarshal(params, ¶msDto); err != nil { @@ -24,54 +23,53 @@ func ProcessQCXG9P1CRequest(ctx context.Context, params []byte, deps *processors return nil, errors.Join(processors.ErrInvalidParam, err) } - null := "" - // 构建请求参数 - reqData := map[string]interface{}{ - "idNum": paramsDto.IDCard, - "name": null, - "userType": "1", - } - - // 调用极光API - // apiCode: vehicle-inquiry-under-name (用于请求头) - // apiPath: vehicle/inquiry-under-name (用于URL路径) - respBytes, err := deps.JiguangService.CallAPI(ctx, "vehicle-inquiry-under-name", "vehicle/inquiry-under-name", reqData) + m4clParams, err := json.Marshal(dto.QCXGM4CLReq{IDCard: paramsDto.IDCard}) if err != nil { - // 根据错误类型返回相应的错误 - if errors.Is(err, jiguang.ErrNotFound) { - return nil, errors.Join(processors.ErrNotFound, err) - } else if errors.Is(err, jiguang.ErrDatasource) { - return nil, errors.Join(processors.ErrDatasource, err) - } else { - return nil, errors.Join(processors.ErrSystem, err) - } + return nil, errors.Join(processors.ErrSystem, err) } - // 极光服务已经返回了 data 字段的 JSON,直接返回即可 - // return respBytes, nil - - // 使用 gjson 检查并转换 vehicleCount 字段 - vehicleCountResult := gjson.GetBytes(respBytes, "vehicleCount") - if vehicleCountResult.Exists() && vehicleCountResult.Type == gjson.String { - // 如果是字符串类型,转换为整数 - vehicleCountInt, err := strconv.Atoi(vehicleCountResult.String()) - if err != nil { - return nil, errors.Join(processors.ErrSystem, err) - } - // 解析 JSON 并修改 vehicleCount 字段 - var respData map[string]interface{} - if err := json.Unmarshal(respBytes, &respData); err != nil { - return nil, errors.Join(processors.ErrSystem, err) - } - respData["vehicleCount"] = vehicleCountInt - // 重新序列化为JSON并返回 - resultBytes, err := json.Marshal(respData) - if err != nil { - return nil, errors.Join(processors.ErrSystem, err) - } - return resultBytes, nil + raw, err := ProcessQCXGM4CLRequest(ctx, m4clParams, deps) + if err != nil { + return nil, err } - // 如果 vehicleCount 不存在或不是字符串,直接返回原始响应 - return respBytes, nil + return transformQCXG9P1CResponse(raw) +} + +// transformQCXG9P1CResponse 将诺尔响应转为 QCXG9P1C 对外格式:去掉 busiCode/busiMsg,展开 result,vehicleCount 为整数 +func transformQCXG9P1CResponse(raw []byte) ([]byte, error) { + base := gjson.GetBytes(raw, "result") + if !base.Exists() { + base = gjson.ParseBytes(raw) + } + + list := base.Get("list").Value() + if list == nil { + list = []interface{}{} + } + + countInt, err := formatVehicleCountAsInt(base.Get("vehicleCount")) + if err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + out := map[string]interface{}{ + "vehicleCount": countInt, + "list": list, + } + return json.Marshal(out) +} + +func formatVehicleCountAsInt(v gjson.Result) (int, error) { + if !v.Exists() { + return 0, nil + } + switch v.Type { + case gjson.String: + return strconv.Atoi(v.String()) + case gjson.Number: + return int(v.Int()), nil + default: + return 0, errors.New("vehicleCount 类型无效") + } } diff --git a/internal/domains/api/services/processors/qcxg/qcxgm4cl_processor.go b/internal/domains/api/services/processors/qcxg/qcxgm4cl_processor.go new file mode 100644 index 0000000..9be2ee1 --- /dev/null +++ b/internal/domains/api/services/processors/qcxg/qcxgm4cl_processor.go @@ -0,0 +1,48 @@ +package qcxg + +import ( + "context" + "encoding/json" + "errors" + + "tyapi-server/internal/domains/api/dto" + "tyapi-server/internal/domains/api/services/processors" + "tyapi-server/internal/infrastructure/external/nuoer" +) + +// ProcessQCXGM4CLRequest QCXGM4CL API处理方法 - 名下车辆诺尔 +func ProcessQCXGM4CLRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { + var paramsDto dto.QCXGM4CLReq + 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{ + "idCard": paramsDto.IDCard, + } + + nuoerDoCheckAPIKey := "id_vehicle_query_102" + ApiPath := "/v1/doCheck" + + resp, err := deps.NuoerService.CallAPI(ctx, nuoerDoCheckAPIKey, ApiPath, body) + if err != nil { + if errors.Is(err, nuoer.ErrNotFound) { + return nil, errors.Join(processors.ErrNotFound, err) + } + if errors.Is(err, nuoer.ErrDatasource) { + return nil, errors.Join(processors.ErrDatasource, 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/nuoer/crypto.go b/internal/infrastructure/external/nuoer/crypto.go new file mode 100644 index 0000000..7fb1398 --- /dev/null +++ b/internal/infrastructure/external/nuoer/crypto.go @@ -0,0 +1,38 @@ +package nuoer + +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[:]) +} diff --git a/internal/infrastructure/external/nuoer/crypto_test.go b/internal/infrastructure/external/nuoer/crypto_test.go new file mode 100644 index 0000000..bd101c8 --- /dev/null +++ b/internal/infrastructure/external/nuoer/crypto_test.go @@ -0,0 +1,21 @@ +package nuoer + +import "testing" + +func TestSign(t *testing.T) { + body := map[string]string{ + "name": "张三", + "mobile": "13290879000", + "idCard": "330129199511153412", + } + secret := "secret" + got := Sign(body, secret) + if got == "" { + t.Fatal("sign should not be empty") + } + // 文档示例:name张三mobile13290879000idCard330129199511153412secret + want := genMD5("idCard330129199511153412mobile13290879000name张三secret") + if got != want { + t.Fatalf("sign mismatch: got %s want %s", got, want) + } +} diff --git a/internal/infrastructure/external/nuoer/nuoer_errors.go b/internal/infrastructure/external/nuoer/nuoer_errors.go new file mode 100644 index 0000000..f77ed6b --- /dev/null +++ b/internal/infrastructure/external/nuoer/nuoer_errors.go @@ -0,0 +1,141 @@ +package nuoer + +import ( + "errors" + "fmt" +) + +// 平台层 code 返回码(见文档2) +const ( + CodeSuccess = 0 // 成功 + CodeResponseError = -1 // 响应异常 +) + +// 业务层 busiCode 返回码(见文档2) +const ( + BusiCodeSuccess = 10 // 查询成功【计费】 + BusiCodeNotFound = 1000 // 数据未查得 + BusiCodeInsufficientFund = 1001 // 账户余额不足 + BusiCodeAccountNotFound = 1002 // 账户信息不存在 + BusiCodeAppIDError = 1003 // appId异常 + BusiCodeProductError = 1004 // 产品编号异常 + BusiCodeAccountError = 1005 // 账号信息异常 + BusiCodeOverdraftLimit = 1006 // 透支余额已达上限 + BusiCodeDataRequestError = 1007 // 数据请求异常 + BusiCodeServiceNotOpen = 1009 // 服务尚未开通 +) + +var ( + ErrDatasource = errors.New("数据源异常") + ErrSystem = errors.New("系统异常") + ErrNotFound = errors.New("查询为空") +) + +// platformCodeDesc 平台层 code -> 描述 +var platformCodeDesc = map[int]string{ + CodeSuccess: "成功", + CodeResponseError: "响应异常", +} + +// busiCodeDesc 业务层 busiCode -> 描述 +var busiCodeDesc = map[int]string{ + BusiCodeSuccess: "查询成功【计费】", + BusiCodeNotFound: "数据未查得", + BusiCodeInsufficientFund: "账户余额不足", + BusiCodeAccountNotFound: "账户信息不存在", + BusiCodeAppIDError: "appId异常", + BusiCodeProductError: "产品编号异常", + BusiCodeAccountError: "账号信息异常", + BusiCodeOverdraftLimit: "透支余额已达上限", + BusiCodeDataRequestError: "数据请求异常", + BusiCodeServiceNotOpen: "服务尚未开通", +} + +// 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 "" +} + +// nuoerError 诺尔智汇平台层错误(响应 code 字段) +type nuoerError struct { + Code int + Message string +} + +func (e *nuoerError) Error() string { + return fmt.Sprintf("诺尔智汇返回错误,code: %d,msg: %s", e.Code, e.Message) +} + +// NewNuoerError 创建平台层错误 +func NewNuoerError(code int, message string) *nuoerError { + if message == "" { + if desc := GetPlatformCodeDesc(code); desc != "" { + message = desc + } else { + message = "诺尔智汇返回未知错误" + } + } + return &nuoerError{Code: code, Message: message} +} + +// nuoerBusiError 诺尔智汇业务层错误(data.busiCode 字段) +type nuoerBusiError struct { + BusiCode int + BusiMsg string +} + +func (e *nuoerBusiError) Error() string { + return fmt.Sprintf("诺尔智汇业务错误,busiCode: %d,busiMsg: %s", e.BusiCode, e.BusiMsg) +} + +// NewNuoerBusiError 创建业务层错误 +func NewNuoerBusiError(busiCode int, busiMsg string) *nuoerBusiError { + if busiMsg == "" { + if desc := GetBusiCodeDesc(busiCode); desc != "" { + busiMsg = desc + } else { + busiMsg = "诺尔智汇业务返回未知错误" + } + } + return &nuoerBusiError{BusiCode: busiCode, BusiMsg: busiMsg} +} + +// GetNotFoundErrByBusiCode 将 busiCode 映射为「查询为空」类错误(不扣费场景) +func GetNotFoundErrByBusiCode(busiCode int) error { + switch busiCode { + case BusiCodeNotFound: + return ErrNotFound + default: + return nil + } +} + +// GetErrByBusiCode 将 busiCode 映射为内部哨兵错误,供处理器 errors.Is 判断 +func GetErrByBusiCode(busiCode int) error { + if busiCode == BusiCodeSuccess { + return nil + } + if notFound := GetNotFoundErrByBusiCode(busiCode); notFound != nil { + return notFound + } + return ErrDatasource +} + +// GetErrByPlatformCode 将平台 code 映射为内部哨兵错误 +func GetErrByPlatformCode(code int) error { + if code == CodeSuccess { + return nil + } + return ErrDatasource +} diff --git a/internal/infrastructure/external/nuoer/nuoer_factory.go b/internal/infrastructure/external/nuoer/nuoer_factory.go new file mode 100644 index 0000000..27e97c4 --- /dev/null +++ b/internal/infrastructure/external/nuoer/nuoer_factory.go @@ -0,0 +1,64 @@ +package nuoer + +import ( + "time" + + "tyapi-server/internal/config" + "tyapi-server/internal/shared/external_logger" +) + +// NewNuoerServiceWithConfig 使用配置创建诺尔智汇服务 +func NewNuoerServiceWithConfig(cfg *config.Config) (*NuoerService, error) { + loggingConfig := external_logger.ExternalServiceLoggingConfig{ + Enabled: cfg.Nuoer.Logging.Enabled, + LogDir: cfg.Nuoer.Logging.LogDir, + ServiceName: "nuoer", + UseDaily: cfg.Nuoer.Logging.UseDaily, + EnableLevelSeparation: cfg.Nuoer.Logging.EnableLevelSeparation, + LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig), + } + + for level, levelCfg := range cfg.Nuoer.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.Nuoer.Timeout + if timeout <= 0 { + timeout = defaultRequestTimeout + } + + return NewNuoerService( + cfg.Nuoer.URL, + cfg.Nuoer.AppID, + cfg.Nuoer.AppSecret, + timeout, + logger, + ), nil +} + +// NewNuoerServiceWithLogging 使用自定义日志配置创建诺尔智汇服务 +func NewNuoerServiceWithLogging(url, appID, appSecret string, timeout time.Duration, loggingConfig external_logger.ExternalServiceLoggingConfig) (*NuoerService, error) { + loggingConfig.ServiceName = "nuoer" + + logger, err := external_logger.NewExternalServiceLogger(loggingConfig) + if err != nil { + return nil, err + } + + return NewNuoerService(url, appID, appSecret, timeout, logger), nil +} + +// NewNuoerServiceSimple 创建无日志的诺尔智汇服务 +func NewNuoerServiceSimple(url, appID, appSecret string, timeout time.Duration) *NuoerService { + return NewNuoerService(url, appID, appSecret, timeout, nil) +} diff --git a/internal/infrastructure/external/nuoer/nuoer_service.go b/internal/infrastructure/external/nuoer/nuoer_service.go new file mode 100644 index 0000000..83626f3 --- /dev/null +++ b/internal/infrastructure/external/nuoer/nuoer_service.go @@ -0,0 +1,253 @@ +package nuoer + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "tyapi-server/internal/shared/external_logger" +) + +const defaultRequestTimeout = 4 * time.Second + +// nuoerResponse 诺尔智汇通用响应 +type nuoerResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + SeqNo string `json:"seqNo"` + Data interface{} `json:"data"` +} + +// serviceConfig 诺尔智汇服务运行时配置 +type serviceConfig struct { + URL string + AppID string + AppSecret string + Timeout time.Duration +} + +// NuoerService 诺尔智汇服务 +type NuoerService struct { + config serviceConfig + logger *external_logger.ExternalServiceLogger +} + +// NewNuoerService 创建诺尔智汇服务实例 +func NewNuoerService(url, appID, appSecret string, timeout time.Duration, logger *external_logger.ExternalServiceLogger) *NuoerService { + if timeout <= 0 { + timeout = defaultRequestTimeout + } + return &NuoerService{ + config: serviceConfig{ + URL: url, + AppID: appID, + AppSecret: appSecret, + Timeout: timeout, + }, + logger: logger, + } +} + +func (s *NuoerService) generateRequestID() string { + timestamp := time.Now().UnixNano() + hash := md5.Sum([]byte(fmt.Sprintf("%d_%s", timestamp, s.config.AppID))) + return fmt.Sprintf("nuoer_%x", hash[:8]) +} + +func (s *NuoerService) CallAPI(ctx context.Context, apiKey, apiPath string, body map[string]string) (*nuoerResponse, error) { + requestURL := strings.TrimSuffix(s.config.URL, "/") + if apiPath != "" { + if !strings.HasPrefix(apiPath, "/") { + apiPath = "/" + apiPath + } + requestURL += apiPath + } + + requestID := s.generateRequestID() + startTime := time.Now() + + var transactionID string + if id, ok := ctx.Value("transaction_id").(string); ok { + transactionID = id + } + + // 对调用方传入的 body 全量参与加签(排除空值,按 key 升序,见 Sign) + sign := Sign(body, s.config.AppSecret) + + requestPayload := map[string]interface{}{ + "appId": s.config.AppID, + "sign": sign, + "apiKey": apiKey, + "body": body, + } + + if s.logger != nil { + s.logger.LogRequest(requestID, transactionID, apiKey, requestURL) + } + + bodyBytes, err := json.Marshal(requestPayload) + if err != nil { + err = errors.Join(ErrSystem, err) + if s.logger != nil { + s.logger.LogError(requestID, 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) + if s.logger != nil { + s.logger.LogError(requestID, 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) + if s.logger != nil { + s.logger.LogError(requestID, 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) + if s.logger != nil { + s.logger.LogError(requestID, transactionID, apiKey, err, requestPayload) + } + return nil, err + } + + if s.logger != nil { + s.logger.LogResponse(requestID, transactionID, apiKey, resp.StatusCode, time.Since(startTime)) + } + + if resp.StatusCode != http.StatusOK { + err = errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码 %d", resp.StatusCode)) + if s.logger != nil { + s.logger.LogError(requestID, transactionID, apiKey, err, requestPayload) + } + return nil, err + } + + var nuoerResp nuoerResponse + if err := json.Unmarshal(respBody, &nuoerResp); err != nil { + err = errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %w", err)) + if s.logger != nil { + s.logger.LogError(requestID, transactionID, apiKey, err, requestPayload) + } + return nil, err + } + + if nuoerResp.Code != CodeSuccess { + nuoerErr := NewNuoerError(nuoerResp.Code, nuoerResp.Msg) + err = errors.Join(GetErrByPlatformCode(nuoerResp.Code), nuoerErr) + if s.logger != nil { + s.logger.LogError(requestID, transactionID, apiKey, nuoerErr, requestPayload) + } + return nil, err + } + + if nuoerResp.Data == nil { + err = errors.Join(ErrSystem, errors.New("响应 data 为空")) + if s.logger != nil { + s.logger.LogError(requestID, transactionID, apiKey, err, requestPayload) + } + return nil, err + } + + busiCode, busiMsg, ok := parseDataBusiInfo(nuoerResp.Data) + if !ok { + err = errors.Join(ErrSystem, errors.New("响应 data 无法解析 busiCode")) + if s.logger != nil { + s.logger.LogError(requestID, transactionID, apiKey, err, requestPayload) + } + return nil, err + } + + if busiCode != BusiCodeSuccess { + busiErr := NewNuoerBusiError(busiCode, busiMsg) + err = errors.Join(GetErrByBusiCode(busiCode), busiErr) + if s.logger != nil { + s.logger.LogError(requestID, transactionID, apiKey, busiErr, requestPayload) + } + return nil, err + } + + cleanedData, err := stripBusiMetaFromData(nuoerResp.Data) + if err != nil { + err = errors.Join(ErrSystem, fmt.Errorf("响应 data 清理失败: %w", err)) + if s.logger != nil { + s.logger.LogError(requestID, transactionID, apiKey, err, requestPayload) + } + return nil, err + } + nuoerResp.Data = cleanedData + + return &nuoerResp, nil +} + +// nuoerDataBusiMeta 业务层状态字段,仅用于解析校验,不对外返回 +type nuoerDataBusiMeta struct { + BusiCode int `json:"busiCode"` + BusiMsg string `json:"busiMsg"` +} + +// parseDataBusiInfo 从各接口不同的 data 结构中解析 busiCode、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 nuoerDataBusiMeta + if err := json.Unmarshal(raw, &meta); err != nil { + return 0, "", false + } + return meta.BusiCode, meta.BusiMsg, true +} + +// stripBusiMetaFromData 去掉 data 中的 busiCode、busiMsg,仅保留业务载荷 +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 +} + +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) + } +}