package nuoer import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" "hyapi-server/internal/shared/external_logger" ) const defaultRequestTimeout = 4 * time.Second // queryBillingAPIKeys 查询计费接口:数据未查得(busiCode=1000)仍按成功计费,返回空数据 var queryBillingAPIKeys = map[string]struct{}{ "idRiskTagV106": {}, // 身份风险V106 idRiskTagV106 "personalLawsuit_cv2": {}, // 企业诉讼定制版 personallawsuit cv2 "personalLawsuit_cv1": {}, // 个人诉讼定制版personalLawsuit_cv1 "loanRiskTagV11": {}, // 借贷意向查询loanRiskTagV11 "loanRiskTagV5": {}, // 风险变量V5loanRiskTagV5 "loanRiskTagV12": {}, // 特殊名单 loanRiskTagV12 "blackListV121_3_1": {}, // 债务逾期黑名单V3_1 blackListV121 3 1 "blackListV110": {}, // 特殊名单V110 blackListV110 "zhitong_ultra_v4_score": {}, // 智瞳分尊享版 zhitong ultra v4 score "zhixiangScore": {}, // 智享分 zhixiangScore "loanRiskTagV8": {}, // 风险变量V8 loanRiskTagV8 "loanRiskTagV9": {}, // 风险变量V9 loanRiskTagV9 "loanRiskTagV10": {}, // 风险变量V10 loanRiskTagV10 } func isQueryBillingAPIKey(apiKey string) bool { _, ok := queryBillingAPIKeys[apiKey] return ok } // 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) logResponse(transactionID, apiKey string, statusCode int, duration time.Duration, seqNo string) { if s.logger == nil { return } s.logger.LogResponse(seqNo, transactionID, apiKey, statusCode, duration) } func (s *NuoerService) logError(transactionID, apiKey, seqNo string, err error, payload interface{}) { if s.logger == nil { return } s.logger.LogError(seqNo, transactionID, apiKey, err, payload) } func (s *NuoerService) CallAPI(ctx context.Context, apiKey, apiPath string, body map[string]string, encryptionType ...int) (*nuoerResponse, error) { requestURL := strings.TrimSuffix(s.config.URL, "/") if apiPath != "" { if !strings.HasPrefix(apiPath, "/") { apiPath = "/" + apiPath } requestURL += apiPath } startTime := time.Now() var transactionID string if id, ok := ctx.Value("transaction_id").(string); ok { transactionID = id } var encType int if len(encryptionType) > 0 { encType = encryptionType[0] } requestBody := body if encType == 2 { requestBody = encryptBodyMD5(body) } // 对请求 body 全量参与加签(排除空值,按 key 升序,见 Sign) sign := Sign(requestBody, s.config.AppSecret) requestPayload := map[string]interface{}{ "appId": s.config.AppID, "sign": sign, "apiKey": apiKey, "body": requestBody, } if encType > 0 { requestPayload["encryptionType"] = encType } if s.logger != nil { s.logger.LogRequest("", transactionID, apiKey, requestURL) } bodyBytes, err := json.Marshal(requestPayload) if err != nil { err = errors.Join(ErrSystem, err) s.logError(transactionID, apiKey, "", err, requestPayload) return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewBuffer(bodyBytes)) if err != nil { err = errors.Join(ErrSystem, err) s.logError(transactionID, apiKey, "", err, requestPayload) return nil, err } req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: s.config.Timeout} resp, err := client.Do(req) if err != nil { err = wrapHTTPError(err) s.logError(transactionID, apiKey, "", err, requestPayload) return nil, err } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { err = errors.Join(ErrSystem, err) s.logError(transactionID, apiKey, "", err, requestPayload) return nil, err } duration := time.Since(startTime) if resp.StatusCode != http.StatusOK { err = errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码 %d", resp.StatusCode)) s.logError(transactionID, apiKey, "", err, requestPayload) return nil, err } var nuoerResp nuoerResponse if err := json.Unmarshal(respBody, &nuoerResp); err != nil { err = errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %w", err)) s.logError(transactionID, apiKey, "", err, requestPayload) return nil, err } if nuoerResp.Code != CodeSuccess { if nuoerResp.Code == BusiCodeNotFound && isQueryBillingAPIKey(apiKey) { nuoerResp.Data = map[string]interface{}{} s.logResponse(transactionID, apiKey, resp.StatusCode, duration, nuoerResp.SeqNo) return &nuoerResp, nil } nuoerErr := NewNuoerError(nuoerResp.Code, nuoerResp.Msg) err = errors.Join(GetErrByPlatformCode(nuoerResp.Code), nuoerErr) s.logError(transactionID, apiKey, nuoerResp.SeqNo, nuoerErr, requestPayload) return nil, err } if nuoerResp.Data == nil { err = errors.Join(ErrSystem, errors.New("响应 data 为空")) s.logError(transactionID, apiKey, nuoerResp.SeqNo, err, requestPayload) return nil, err } busiCode, busiMsg, ok := parseDataBusiInfo(nuoerResp.Data) if !ok { err = errors.Join(ErrSystem, errors.New("响应 data 无法解析 busiCode")) s.logError(transactionID, apiKey, nuoerResp.SeqNo, err, requestPayload) return nil, err } if busiCode != BusiCodeSuccess { if busiCode == BusiCodeNotFound && isQueryBillingAPIKey(apiKey) { nuoerResp.Data = map[string]interface{}{} s.logResponse(transactionID, apiKey, resp.StatusCode, duration, nuoerResp.SeqNo) return &nuoerResp, nil } busiErr := NewNuoerBusiError(busiCode, busiMsg) err = errors.Join(GetErrByBusiCode(busiCode), busiErr) s.logError(transactionID, apiKey, nuoerResp.SeqNo, busiErr, requestPayload) return nil, err } cleanedData, err := stripBusiMetaFromData(nuoerResp.Data) if err != nil { err = errors.Join(ErrSystem, fmt.Errorf("响应 data 清理失败: %w", err)) s.logError(transactionID, apiKey, nuoerResp.SeqNo, err, requestPayload) return nil, err } nuoerResp.Data = cleanedData s.logResponse(transactionID, apiKey, resp.StatusCode, duration, nuoerResp.SeqNo) 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) } }