diff --git a/internal/domains/api/dto/api_request_dto.go b/internal/domains/api/dto/api_request_dto.go index dcc7bd0..24858d9 100644 --- a/internal/domains/api/dto/api_request_dto.go +++ b/internal/domains/api/dto/api_request_dto.go @@ -199,6 +199,10 @@ type QCXG3M7ZReq struct { Name string `json:"name" validate:"required,min=1,validName"` PlateColor string `json:"plate_color" validate:"omitempty"` } +type QCXG7K2NReq struct { + PlateNo string `json:"plate_no" validate:"required"` + Name string `json:"name" validate:"required,min=1,validName"` +} type QCXG3B8ZReq 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 6d79476..55ca914 100644 --- a/internal/domains/api/services/api_request_service.go +++ b/internal/domains/api/services/api_request_service.go @@ -407,6 +407,7 @@ func registerAllProcessors(combService *comb.CombService) { "QCXG5U0Z": qcxg.ProcessQCXG5U0ZRequest, // 车辆静态信息查询 10479 "QCXGY7F2": qcxg.ProcessQCXGY7F2Request, // 二手车VIN估值 10443 "QCXG3M7Z": qcxg.ProcessQCXG3M7ZRequest, //人车关系核验(ETC)10093 月更 + "QCXG7K2N": qcxg.ProcessQCXG7K2NRequest, //人车核验加强版 "QCXGM4CL": qcxg.ProcessQCXGM4CLRequest, //名下车辆诺尔 "QYGLVR76": qygl.ProcessQYGLVR76Request, //名下企业诺尔 "QCXG1S2L": qcxg.ProcessQCXG1S2LRequest, //车辆车五项信息核验V2 diff --git a/internal/domains/api/services/form_config_service.go b/internal/domains/api/services/form_config_service.go index 7daf45d..8750ae0 100644 --- a/internal/domains/api/services/form_config_service.go +++ b/internal/domains/api/services/form_config_service.go @@ -291,6 +291,7 @@ func (s *FormConfigServiceImpl) getDTOStruct(ctx context.Context, apiCode string "QCXGY7F2": &dto.QCXGY7F2Req{}, //二手车VIN估值 10443 "YYSYK9R4": &dto.YYSYK9R4Req{}, //全网手机三要素验证1979周更新版 "QCXG3M7Z": &dto.QCXG3M7ZReq{}, //人车关系核验(ETC)10093 月更 + "QCXG7K2N": &dto.QCXG7K2NReq{}, //人车核验加强版 "JRZQ1P5G": &dto.JRZQ1P5GReq{}, //全国自然人借贷压力指数查询(2) "IVYZOCR1": &dto.IVYZOCR1Req{}, //身份证OCR "IVYZOCR2": &dto.IVYZOCR1Req{}, //身份证OCR2数卖 diff --git a/internal/domains/api/services/processors/flxg/flxghb4f_processor.go b/internal/domains/api/services/processors/flxg/flxghb4f_processor.go index a10bfd5..48dfb1b 100644 --- a/internal/domains/api/services/processors/flxg/flxghb4f_processor.go +++ b/internal/domains/api/services/processors/flxg/flxghb4f_processor.go @@ -32,8 +32,7 @@ func ProcessFLXGHB4FRequest(ctx context.Context, params []byte, deps *processors "auth_pdf_base64": paramsDto.AuthAuthorizeFileBase64, } - apiPath := "/api/v1/FLXGC4CT" - respBytes, err := deps.HaiyuapiService.CallAPI(ctx, apiPath, reqParams) + respBytes, err := deps.HaiyuapiService.CallAPI(ctx, "FLXGC4CT", reqParams) if err != nil { if errors.Is(err, haiyuapi.ErrNotFound) { return nil, errors.Join(processors.ErrNotFound, err) diff --git a/internal/domains/api/services/processors/qcxg/qcxg7k2n_processor.go b/internal/domains/api/services/processors/qcxg/qcxg7k2n_processor.go new file mode 100644 index 0000000..4fc47fe --- /dev/null +++ b/internal/domains/api/services/processors/qcxg/qcxg7k2n_processor.go @@ -0,0 +1,61 @@ +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/haiyuapi" + "tyapi-server/internal/infrastructure/external/shujubao" +) + +// ProcessQCXG7K2NRequest QCXG7K2N 人车核验加强版: +// 优先走数据宝 10093(人车关系核验 ETC),查无结果时降级走海宇 QCXGX2X6(行驶证信息核验V2)。 +func ProcessQCXG7K2NRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { + var paramsDto dto.QCXG7K2NReq + 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) + } + + data, err := deps.ShujubaoService.CallAPI(ctx, "/communication/personal/10093", map[string]interface{}{ + "key": "a2f32fc54b44ebc85b97a2aaff1734ec", + "carNo": paramsDto.PlateNo, + "name": paramsDto.Name, + }) + if err == nil { + respBytes, marshalErr := json.Marshal(data) + if marshalErr != nil { + return nil, errors.Join(processors.ErrSystem, marshalErr) + } + return respBytes, nil + } + if !errors.Is(err, shujubao.ErrQueryEmpty) { + if errors.Is(err, shujubao.ErrDatasource) { + return nil, errors.Join(processors.ErrDatasource, err) + } + return nil, errors.Join(processors.ErrSystem, err) + } + + if deps.HaiyuapiService == nil { + return nil, errors.Join(processors.ErrSystem, errors.New("海宇API服务未初始化")) + } + respBytes, err := deps.HaiyuapiService.CallAPI(ctx, "QCXGX2X6", map[string]interface{}{ + "plate_no": paramsDto.PlateNo, + "name": paramsDto.Name, + }) + if err != nil { + if errors.Is(err, haiyuapi.ErrNotFound) { + return nil, errors.Join(processors.ErrNotFound, err) + } + if errors.Is(err, haiyuapi.ErrDatasource) { + return nil, errors.Join(processors.ErrDatasource, err) + } + return nil, errors.Join(processors.ErrSystem, err) + } + return respBytes, nil +} diff --git a/internal/domains/api/services/processors/qygl/qyglbh7y_processor.go b/internal/domains/api/services/processors/qygl/qyglbh7y_processor.go index 0ab061f..a9eb2c0 100644 --- a/internal/domains/api/services/processors/qygl/qyglbh7y_processor.go +++ b/internal/domains/api/services/processors/qygl/qyglbh7y_processor.go @@ -30,8 +30,7 @@ func ProcessQYGLBH7YRequest(ctx context.Context, params []byte, deps *processors "auth_pdf_base64": paramsDto.AuthAuthorizeFileBase64, } - apiPath := "/api/v1/QYGLLUCM" - respBytes, err := deps.HaiyuapiService.CallAPI(ctx, apiPath, reqParams) + respBytes, err := deps.HaiyuapiService.CallAPI(ctx, "QYGLLUCM", reqParams) if err != nil { if errors.Is(err, haiyuapi.ErrNotFound) { return nil, errors.Join(processors.ErrNotFound, err) diff --git a/internal/infrastructure/external/haiyuapi/haiyuapi_factory.go b/internal/infrastructure/external/haiyuapi/haiyuapi_factory.go index 778d772..5bd85c0 100644 --- a/internal/infrastructure/external/haiyuapi/haiyuapi_factory.go +++ b/internal/infrastructure/external/haiyuapi/haiyuapi_factory.go @@ -1,13 +1,11 @@ package haiyuapi import ( - "time" - "tyapi-server/internal/config" "tyapi-server/internal/shared/external_logger" ) -// NewHaiyuapiServiceWithConfig 使用配置创建海宇API服务 +// NewHaiyuapiServiceWithConfig 使用配置创建海宇 API 服务 func NewHaiyuapiServiceWithConfig(cfg *config.Config) (*HaiyuapiService, error) { loggingConfig := external_logger.ExternalServiceLoggingConfig{ Enabled: cfg.Haiyuapi.Logging.Enabled, @@ -17,48 +15,35 @@ func NewHaiyuapiServiceWithConfig(cfg *config.Config) (*HaiyuapiService, error) EnableLevelSeparation: cfg.Haiyuapi.Logging.EnableLevelSeparation, LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig), } - - for level, levelCfg := range cfg.Haiyuapi.Logging.LevelConfigs { - loggingConfig.LevelConfigs[level] = external_logger.ExternalServiceLevelFileConfig{ - MaxSize: levelCfg.MaxSize, - MaxBackups: levelCfg.MaxBackups, - MaxAge: levelCfg.MaxAge, - Compress: levelCfg.Compress, + for k, v := range cfg.Haiyuapi.Logging.LevelConfigs { + loggingConfig.LevelConfigs[k] = external_logger.ExternalServiceLevelFileConfig{ + MaxSize: v.MaxSize, + MaxBackups: v.MaxBackups, + MaxAge: v.MaxAge, + Compress: v.Compress, } } - logger, err := external_logger.NewExternalServiceLogger(loggingConfig) - if err != nil { - return nil, err + var logger *external_logger.ExternalServiceLogger + var err error + if loggingConfig.Enabled { + logger, err = external_logger.NewExternalServiceLogger(loggingConfig) + if err != nil { + return nil, err + } } - timeout := cfg.Haiyuapi.Timeout - if timeout <= 0 { - timeout = defaultRequestTimeout + timeout := defaultRequestTimeout + if cfg.Haiyuapi.Timeout > 0 { + timeout = cfg.Haiyuapi.Timeout } - return NewHaiyuapiService( - cfg.Haiyuapi.BaseURL, - cfg.Haiyuapi.AccessID, - cfg.Haiyuapi.SecretKey, - timeout, - logger, - ), nil -} - -// NewHaiyuapiServiceWithLogging 使用自定义日志配置创建海宇API服务 -func NewHaiyuapiServiceWithLogging(baseURL, accessID, secretKey string, timeout time.Duration, loggingConfig external_logger.ExternalServiceLoggingConfig) (*HaiyuapiService, error) { - loggingConfig.ServiceName = "haiyuapi" - - logger, err := external_logger.NewExternalServiceLogger(loggingConfig) - if err != nil { - return nil, err + serviceCfg := HaiyuapiConfig{ + BaseURL: cfg.Haiyuapi.BaseURL, + AccessID: cfg.Haiyuapi.AccessID, + SecretKey: cfg.Haiyuapi.SecretKey, + Timeout: timeout, } - return NewHaiyuapiService(baseURL, accessID, secretKey, timeout, logger), nil -} - -// NewHaiyuapiServiceSimple 创建无日志的海宇API服务 -func NewHaiyuapiServiceSimple(baseURL, accessID, secretKey string, timeout time.Duration) *HaiyuapiService { - return NewHaiyuapiService(baseURL, accessID, secretKey, timeout, nil) + return NewHaiyuapiService(serviceCfg, logger), nil } diff --git a/internal/infrastructure/external/haiyuapi/haiyuapi_service.go b/internal/infrastructure/external/haiyuapi/haiyuapi_service.go index ec2e1bf..2d64c9d 100644 --- a/internal/infrastructure/external/haiyuapi/haiyuapi_service.go +++ b/internal/infrastructure/external/haiyuapi/haiyuapi_service.go @@ -16,65 +16,93 @@ import ( "tyapi-server/internal/shared/external_logger" ) -const defaultRequestTimeout = 60 * time.Second +const ( + defaultRequestTimeout = 60 * time.Second + maxLogParamValueLen = 300 + maxLogResponseBodyLen = 500 +) -// serviceConfig 海宇API服务运行时配置(Access Key 为 16 进制 AES-128 密钥) -type serviceConfig struct { +// HaiyuapiConfig 海宇 API 服务配置 +type HaiyuapiConfig struct { BaseURL string AccessID string SecretKey string Timeout time.Duration } -// HaiyuapiService 海宇API上游服务客户端 +// HaiyuapiService 海宇平台中转服务 type HaiyuapiService struct { - config serviceConfig + config HaiyuapiConfig logger *external_logger.ExternalServiceLogger } -// NewHaiyuapiService 创建海宇API服务实例 -func NewHaiyuapiService(baseURL, accessID, secretKey string, timeout time.Duration, logger *external_logger.ExternalServiceLogger) *HaiyuapiService { - if timeout <= 0 { - timeout = defaultRequestTimeout +// NewHaiyuapiService 创建海宇 API 服务实例 +func NewHaiyuapiService(cfg HaiyuapiConfig, logger *external_logger.ExternalServiceLogger) *HaiyuapiService { + if cfg.Timeout == 0 { + cfg.Timeout = defaultRequestTimeout } return &HaiyuapiService{ - config: serviceConfig{ - BaseURL: strings.TrimRight(baseURL, "/"), - AccessID: accessID, - SecretKey: secretKey, - Timeout: timeout, - }, + config: cfg, logger: logger, } } -// CallAPI 调用海宇API:apiPath 如 /api/v1/FLXGHB4F,自动拼接 base_url 与 ?t=13位毫秒时间戳,返回解密后的明文 JSON -func (s *HaiyuapiService) CallAPI(ctx context.Context, apiPath string, params map[string]interface{}) ([]byte, error) { +func (s *HaiyuapiService) generateRequestID() string { + timestamp := time.Now().UnixNano() + hash := md5.Sum([]byte(fmt.Sprintf("%d_%s", timestamp, s.config.AccessID))) + return fmt.Sprintf("haiyuapi_%x", hash[:8]) +} + +func truncateForLog(str string, maxLen int) string { + if maxLen <= 0 || len(str) <= maxLen { + return str + } + return str[:maxLen] + "...[truncated, total " + strconv.Itoa(len(str)) + " chars]" +} + +func requestParamsForLog(params map[string]interface{}) map[string]interface{} { + if params == nil { + return nil + } + out := make(map[string]interface{}, len(params)) + for k, v := range params { + if v == nil { + out[k] = nil + continue + } + switch val := v.(type) { + case string: + out[k] = truncateForLog(val, maxLogParamValueLen) + default: + out[k] = truncateForLog(fmt.Sprint(v), maxLogParamValueLen) + } + } + return out +} + +// CallAPI 调用海宇平台指定产品(产品编号与处理器 ApiCode 一致) +func (s *HaiyuapiService) CallAPI(ctx context.Context, productCode string, params map[string]interface{}) ([]byte, error) { startTime := time.Now() - hash := md5.Sum([]byte(fmt.Sprintf("%d_%s", time.Now().UnixNano(), s.config.SecretKey))) - requestID := fmt.Sprintf("haiyuapi_%x", hash[:8]) + requestID := s.generateRequestID() var transactionID string if id, ok := ctx.Value("transaction_id").(string); ok { transactionID = id } - path := apiPath - if !strings.HasPrefix(path, "/") { - path = "/" + path - } + baseURL := strings.TrimSuffix(s.config.BaseURL, "/") timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) - reqURL := s.config.BaseURL + path + "?t=" + timestamp + reqURL := fmt.Sprintf("%s/api/v1/%s?t=%s", baseURL, productCode, timestamp) if s.logger != nil { - s.logger.LogRequest(requestID, transactionID, apiPath, reqURL) + s.logger.LogRequest(requestID, transactionID, productCode, reqURL) } encryptedData, err := EncryptParams(params, s.config.SecretKey) if err != nil { err = errors.Join(ErrSystem, fmt.Errorf("请求加密失败: %w", err)) if s.logger != nil { - s.logger.LogError(requestID, transactionID, apiPath, err, params) + s.logger.LogError(requestID, transactionID, productCode, err, map[string]interface{}{"request_params": requestParamsForLog(params)}) } return nil, err } @@ -83,83 +111,137 @@ func (s *HaiyuapiService) CallAPI(ctx context.Context, apiPath string, params ma if err != nil { err = errors.Join(ErrSystem, err) if s.logger != nil { - s.logger.LogError(requestID, transactionID, apiPath, err, params) + s.logger.LogError(requestID, transactionID, productCode, err, map[string]interface{}{"request_params": requestParamsForLog(params)}) } return nil, err } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewBuffer(bodyBytes)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(bodyBytes)) if err != nil { err = errors.Join(ErrSystem, err) if s.logger != nil { - s.logger.LogError(requestID, transactionID, apiPath, err, params) + s.logger.LogError(requestID, transactionID, productCode, err, map[string]interface{}{"request_params": requestParamsForLog(params)}) } return nil, err } req.Header.Set(HeaderContentType, ContentTypeJSON) req.Header.Set(HeaderAccessID, s.config.AccessID) - resp, err := (&http.Client{Timeout: s.config.Timeout}).Do(req) + 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, apiPath, err, params) + s.logger.LogError(requestID, transactionID, productCode, err, map[string]interface{}{"request_params": requestParamsForLog(params)}) } return nil, err } defer resp.Body.Close() - respBody, err := io.ReadAll(resp.Body) + duration := time.Since(startTime) + raw, err := io.ReadAll(resp.Body) if err != nil { err = errors.Join(ErrSystem, err) if s.logger != nil { - s.logger.LogError(requestID, transactionID, apiPath, err, params) + s.logger.LogError(requestID, transactionID, productCode, err, map[string]interface{}{"request_params": requestParamsForLog(params)}) } return nil, err } - duration := time.Since(startTime) - if resp.StatusCode != http.StatusOK { - err = errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码 %d", resp.StatusCode)) + err = errors.Join(ErrDatasource, fmt.Errorf("HTTP %d", resp.StatusCode)) if s.logger != nil { - s.logger.LogError(requestID, transactionID, apiPath, err, params) - } - return nil, err - } - - var apiResp APIResponse - if err := json.Unmarshal(respBody, &apiResp); err != nil { - err = errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %w", err)) - if s.logger != nil { - s.logger.LogError(requestID, transactionID, apiPath, err, params) - } - return nil, err - } - - if apiResp.Code != CodeSuccess { - apiErr := NewHaiyuapiAPIError(apiResp.Code, apiResp.Message) - err = errors.Join(GetErrByCode(apiResp.Code), apiErr) - if s.logger != nil { - s.logger.LogError(requestID, transactionID, apiPath, apiErr, params) - } - return nil, err - } - - plainResp, err := DecryptData(apiResp.Data, s.config.SecretKey) - if err != nil { - err = errors.Join(ErrSystem, fmt.Errorf("响应解密失败: %w", err)) - if s.logger != nil { - s.logger.LogError(requestID, transactionID, apiPath, err, params) + s.logger.LogError(requestID, transactionID, productCode, err, map[string]interface{}{ + "request_params": requestParamsForLog(params), + "response_body": truncateForLog(string(raw), maxLogResponseBodyLen), + }) } return nil, err } if s.logger != nil { - s.logger.LogResponse(requestID, transactionID, apiPath, resp.StatusCode, duration) + s.logger.LogResponse(requestID, transactionID, productCode, resp.StatusCode, duration) } - return plainResp, nil + var outer APIResponse + if err := json.Unmarshal(raw, &outer); err != nil { + parseErr := errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %w", err)) + if s.logger != nil { + s.logger.LogError(requestID, transactionID, productCode, parseErr, map[string]interface{}{ + "request_params": requestParamsForLog(params), + "response_body": truncateForLog(string(raw), maxLogResponseBodyLen), + }) + } + return nil, parseErr + } + + if outer.Code != CodeSuccess { + mappedErr := mapBusinessError(outer.Code, outer.Message) + if s.logger != nil { + s.logger.LogError(requestID, transactionID, productCode, mappedErr, map[string]interface{}{ + "request_params": requestParamsForLog(params), + "response_body": truncateForLog(string(raw), maxLogResponseBodyLen), + "api_code": outer.Code, + }) + } + if errors.Is(mappedErr, ErrNotFound) { + return nil, mappedErr + } + return nil, mappedErr + } + + plain, err := DecryptData(outer.Data, s.config.SecretKey) + if err != nil { + decErr := errors.Join(ErrSystem, fmt.Errorf("解密响应失败: %w", err)) + if s.logger != nil { + s.logger.LogError(requestID, transactionID, productCode, decErr, map[string]interface{}{ + "request_params": requestParamsForLog(params), + "response_body": truncateForLog(string(raw), maxLogResponseBodyLen), + }) + } + return nil, decErr + } + + if len(plain) == 0 { + return []byte("{}"), nil + } + if !json.Valid(plain) { + return plain, nil + } + return plain, nil +} + +func mapBusinessError(code int, message string) error { + switch code { + case CodeSuccess: + return nil + case CodeQueryEmpty: + if message != "" { + return errors.Join(ErrNotFound, errors.New(message)) + } + return ErrNotFound + case CodeRequestParam: + if message != "" { + return errors.Join(ErrSystem, errors.New(message)) + } + return errors.Join(ErrSystem, errors.New("请求参数结构不正确")) + case CodeSystem, CodeDecryptFail: + if message != "" { + return errors.Join(ErrSystem, errors.New(message)) + } + return ErrSystem + case CodeInvalidIP, CodeMissingAccessID, CodeInvalidAccessID, + CodeInsufficientBalance, CodeProductNotSubscribed, CodeBusiness: + if message != "" { + return errors.Join(ErrDatasource, errors.New(message)) + } + return ErrDatasource + default: + if message != "" { + return errors.Join(ErrDatasource, errors.New(message)) + } + return ErrDatasource + } } func wrapHTTPError(err error) error { @@ -169,10 +251,9 @@ func wrapHTTPError(err error) error { 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": + es := err.Error() + if strings.Contains(es, "deadline exceeded") || strings.Contains(es, "timeout") || strings.Contains(es, "canceled") { return errors.Join(ErrDatasource, err) - default: - return errors.Join(ErrSystem, err) } + return errors.Join(ErrSystem, err) } diff --git a/internal/infrastructure/external/haiyuapi/haiyuapi_service_test.go b/internal/infrastructure/external/haiyuapi/haiyuapi_service_test.go new file mode 100644 index 0000000..b511abc --- /dev/null +++ b/internal/infrastructure/external/haiyuapi/haiyuapi_service_test.go @@ -0,0 +1,21 @@ +package haiyuapi + +import ( + "errors" + "testing" +) + +func TestMapBusinessError(t *testing.T) { + if err := mapBusinessError(CodeSuccess, ""); err != nil { + t.Fatalf("code 0 should be nil, got %v", err) + } + if !errors.Is(mapBusinessError(CodeQueryEmpty, "查无"), ErrNotFound) { + t.Fatal("expected ErrNotFound for 1000") + } + if !errors.Is(mapBusinessError(CodeBusiness, "业务失败"), ErrDatasource) { + t.Fatal("expected ErrDatasource for 2001") + } + if !errors.Is(mapBusinessError(CodeRequestParam, "参数错误"), ErrSystem) { + t.Fatal("expected ErrSystem for 1003") + } +}