2026-07-22 17:15:33 +08:00
|
|
|
|
package jiyi
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"strconv"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"hyapi-server/internal/shared/external_logger"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const defaultRequestTimeout = 4 * time.Second
|
|
|
|
|
|
|
|
|
|
|
|
// queryBillingAPIKeys 查询计费接口:未查得/空结果仍按成功返回空数据,由平台侧计费
|
|
|
|
|
|
var queryBillingAPIKeys = map[string]struct{}{
|
2026-07-24 12:33:42 +08:00
|
|
|
|
"jy000008": {}, // 探针C
|
|
|
|
|
|
"jy000017": {}, // 探针A
|
|
|
|
|
|
"jy000019": {}, // 商业险有效性
|
|
|
|
|
|
"jy000020": {}, // 交强险有效性
|
2026-07-22 17:15:33 +08:00
|
|
|
|
"jy000022": {}, // 洞侦1.0
|
2026-07-24 12:33:42 +08:00
|
|
|
|
"jy000028": {}, // 车VIN查车牌号
|
2026-07-22 17:15:33 +08:00
|
|
|
|
"jy000042": {}, // 借贷意向验证3.0
|
2026-07-24 12:33:42 +08:00
|
|
|
|
"jy000048": {}, // 申请借贷
|
2026-07-22 17:15:33 +08:00
|
|
|
|
"jy000052": {}, // 无间司南-纯黑A版
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func isQueryBillingAPIKey(apiKey string) bool {
|
|
|
|
|
|
_, ok := queryBillingAPIKeys[apiKey]
|
|
|
|
|
|
return ok
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// jiyiResponse 集奕通用响应
|
|
|
|
|
|
type jiyiResponse struct {
|
|
|
|
|
|
Code int `json:"code"`
|
|
|
|
|
|
Msg string `json:"msg"`
|
|
|
|
|
|
Fee string `json:"fee"`
|
|
|
|
|
|
SeqNo string `json:"seqNo"`
|
|
|
|
|
|
Data interface{} `json:"data"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Response 对外暴露的响应别名,便于处理器取值
|
|
|
|
|
|
type Response = jiyiResponse
|
|
|
|
|
|
|
|
|
|
|
|
type serviceConfig struct {
|
|
|
|
|
|
URL string
|
|
|
|
|
|
AppID string
|
|
|
|
|
|
AppSecret string
|
|
|
|
|
|
Timeout time.Duration
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// JiyiService 集奕数据服务
|
|
|
|
|
|
type JiyiService struct {
|
|
|
|
|
|
config serviceConfig
|
|
|
|
|
|
logger *external_logger.ExternalServiceLogger
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NewJiyiService 创建集奕服务实例
|
|
|
|
|
|
func NewJiyiService(url, appID, appSecret string, timeout time.Duration, logger *external_logger.ExternalServiceLogger) *JiyiService {
|
|
|
|
|
|
if timeout <= 0 {
|
|
|
|
|
|
timeout = defaultRequestTimeout
|
|
|
|
|
|
}
|
|
|
|
|
|
return &JiyiService{
|
|
|
|
|
|
config: serviceConfig{
|
|
|
|
|
|
URL: url,
|
|
|
|
|
|
AppID: appID,
|
|
|
|
|
|
AppSecret: appSecret,
|
|
|
|
|
|
Timeout: timeout,
|
|
|
|
|
|
},
|
|
|
|
|
|
logger: logger,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *JiyiService) logResponse(transactionID, apiKey string, statusCode int, duration time.Duration, seqNo string) {
|
|
|
|
|
|
if s.logger == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
s.logger.LogResponse(seqNo, transactionID, apiKey, statusCode, duration)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *JiyiService) logError(transactionID, apiKey, seqNo string, err error, payload interface{}) {
|
|
|
|
|
|
if s.logger == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
s.logger.LogError(seqNo, transactionID, apiKey, err, payload)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CallAPI 调用集奕接口。opts 为空时按明文、不注入额外字段处理。
|
|
|
|
|
|
func (s *JiyiService) CallAPI(ctx context.Context, apiKey, apiPath string, body map[string]string, opts ...CallOptions) (*Response, error) {
|
|
|
|
|
|
requestURL := strings.TrimSuffix(s.config.URL, "/")
|
|
|
|
|
|
if apiPath != "" {
|
|
|
|
|
|
if !strings.HasPrefix(apiPath, "/") {
|
|
|
|
|
|
apiPath = "/" + apiPath
|
|
|
|
|
|
}
|
|
|
|
|
|
requestURL += apiPath
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
startTime := time.Now()
|
|
|
|
|
|
|
|
|
|
|
|
var transactionID string
|
|
|
|
|
|
if id, ok := ctx.Value("transaction_id").(string); ok {
|
|
|
|
|
|
transactionID = id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
opt := DefaultCallOptions()
|
|
|
|
|
|
if len(opts) > 0 {
|
|
|
|
|
|
opt = opts[0]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
requestBody := cloneBody(body)
|
|
|
|
|
|
switch opt.Mode {
|
|
|
|
|
|
case CallModeBodyEncrypt:
|
|
|
|
|
|
encType := opt.EncryptValue
|
|
|
|
|
|
if _, ok := requestBody["timestamp"]; !ok || strings.TrimSpace(requestBody["timestamp"]) == "" {
|
|
|
|
|
|
requestBody["timestamp"] = strconv.FormatInt(time.Now().UnixMilli(), 10)
|
|
|
|
|
|
}
|
|
|
|
|
|
requestBody["encryptType"] = strconv.Itoa(encType)
|
|
|
|
|
|
if encType == 1 {
|
|
|
|
|
|
requestBody = encryptBodyMD5(requestBody)
|
|
|
|
|
|
}
|
|
|
|
|
|
case CallModeTopEncrypt:
|
|
|
|
|
|
if opt.EncryptValue == 2 {
|
|
|
|
|
|
requestBody = encryptBodyMD5(requestBody)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
sign := Sign(requestBody, s.config.AppSecret)
|
|
|
|
|
|
|
|
|
|
|
|
requestPayload := map[string]interface{}{
|
|
|
|
|
|
"appId": s.config.AppID,
|
|
|
|
|
|
"sign": sign,
|
|
|
|
|
|
"apiKey": apiKey,
|
|
|
|
|
|
"body": requestBody,
|
|
|
|
|
|
}
|
|
|
|
|
|
if opt.Mode == CallModeTopEncrypt {
|
|
|
|
|
|
encVal := opt.EncryptValue
|
|
|
|
|
|
if encVal <= 0 {
|
|
|
|
|
|
encVal = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
// 顶层 encryptionType:部分接口要求 JSON number,统一传整型
|
|
|
|
|
|
requestPayload["encryptionType"] = encVal
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if s.logger != nil {
|
|
|
|
|
|
s.logger.LogRequest("", transactionID, apiKey, requestURL)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bodyBytes, err := json.Marshal(requestPayload)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
err = errors.Join(ErrSystem, err)
|
|
|
|
|
|
s.logError(transactionID, apiKey, "", err, requestPayload)
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewBuffer(bodyBytes))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
err = errors.Join(ErrSystem, err)
|
|
|
|
|
|
s.logError(transactionID, apiKey, "", err, requestPayload)
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
|
|
|
|
|
|
client := &http.Client{Timeout: s.config.Timeout}
|
|
|
|
|
|
resp, err := client.Do(req)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
err = wrapHTTPError(err)
|
|
|
|
|
|
s.logError(transactionID, apiKey, "", err, requestPayload)
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
err = errors.Join(ErrSystem, err)
|
|
|
|
|
|
s.logError(transactionID, apiKey, "", err, requestPayload)
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
duration := time.Since(startTime)
|
|
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
err = errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码 %d", resp.StatusCode))
|
|
|
|
|
|
s.logError(transactionID, apiKey, "", err, requestPayload)
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var jiyiResp jiyiResponse
|
|
|
|
|
|
if err := json.Unmarshal(respBody, &jiyiResp); err != nil {
|
|
|
|
|
|
err = errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %w", err))
|
|
|
|
|
|
s.logError(transactionID, apiKey, "", err, requestPayload)
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if jiyiResp.Code != CodeSuccess {
|
|
|
|
|
|
jiyiErr := NewJiyiError(jiyiResp.Code, jiyiResp.Msg)
|
|
|
|
|
|
err = errors.Join(GetErrByPlatformCode(jiyiResp.Code), jiyiErr)
|
|
|
|
|
|
s.logError(transactionID, apiKey, jiyiResp.SeqNo, jiyiErr, requestPayload)
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if jiyiResp.Data == nil {
|
|
|
|
|
|
// 部分接口查空时 data 可能为 null
|
|
|
|
|
|
if isQueryBillingAPIKey(apiKey) {
|
|
|
|
|
|
jiyiResp.Data = map[string]interface{}{}
|
|
|
|
|
|
s.logResponse(transactionID, apiKey, resp.StatusCode, duration, jiyiResp.SeqNo)
|
|
|
|
|
|
return &jiyiResp, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
err = errors.Join(ErrNotFound, errors.New("响应 data 为空"))
|
|
|
|
|
|
s.logError(transactionID, apiKey, jiyiResp.SeqNo, err, requestPayload)
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
busiCode, busiMsg, ok := parseDataBusiInfo(jiyiResp.Data)
|
|
|
|
|
|
if !ok || (busiCode == 0 && strings.TrimSpace(busiMsg) == "") {
|
|
|
|
|
|
err = errors.Join(ErrSystem, errors.New("响应 data 无法解析 busiCode"))
|
|
|
|
|
|
s.logError(transactionID, apiKey, jiyiResp.SeqNo, err, requestPayload)
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if busiCode != BusiCodeSuccess {
|
|
|
|
|
|
// 查询计费产品:未查得仍返回空数据,由平台计费
|
|
|
|
|
|
if busiCode == BusiCodeNotFound && isQueryBillingAPIKey(apiKey) {
|
|
|
|
|
|
jiyiResp.Data = map[string]interface{}{}
|
|
|
|
|
|
s.logResponse(transactionID, apiKey, resp.StatusCode, duration, jiyiResp.SeqNo)
|
|
|
|
|
|
return &jiyiResp, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
busiErr := NewJiyiBusiError(busiCode, busiMsg)
|
|
|
|
|
|
err = errors.Join(GetErrByBusiCode(busiCode), busiErr)
|
|
|
|
|
|
s.logError(transactionID, apiKey, jiyiResp.SeqNo, busiErr, requestPayload)
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cleanedData, err := stripBusiMetaFromData(jiyiResp.Data)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
err = errors.Join(ErrSystem, fmt.Errorf("响应 data 清理失败: %w", err))
|
|
|
|
|
|
s.logError(transactionID, apiKey, jiyiResp.SeqNo, err, requestPayload)
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if isEmptyBusinessResult(cleanedData) {
|
|
|
|
|
|
if isQueryBillingAPIKey(apiKey) {
|
|
|
|
|
|
jiyiResp.Data = map[string]interface{}{}
|
|
|
|
|
|
s.logResponse(transactionID, apiKey, resp.StatusCode, duration, jiyiResp.SeqNo)
|
|
|
|
|
|
return &jiyiResp, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
emptyErr := NewJiyiBusiError(BusiCodeNotFound, "未查得")
|
|
|
|
|
|
err = errors.Join(ErrNotFound, emptyErr)
|
|
|
|
|
|
s.logError(transactionID, apiKey, jiyiResp.SeqNo, err, requestPayload)
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
jiyiResp.Data = cleanedData
|
|
|
|
|
|
|
|
|
|
|
|
s.logResponse(transactionID, apiKey, resp.StatusCode, duration, jiyiResp.SeqNo)
|
|
|
|
|
|
|
|
|
|
|
|
return &jiyiResp, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type jiyiDataBusiMeta struct {
|
|
|
|
|
|
BusiCode int `json:"busiCode"`
|
|
|
|
|
|
BusiMsg string `json:"busiMsg"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func parseDataBusiInfo(data interface{}) (busiCode int, busiMsg string, ok bool) {
|
|
|
|
|
|
if data == nil {
|
|
|
|
|
|
return 0, "", false
|
|
|
|
|
|
}
|
|
|
|
|
|
raw, err := json.Marshal(data)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return 0, "", false
|
|
|
|
|
|
}
|
|
|
|
|
|
var meta jiyiDataBusiMeta
|
|
|
|
|
|
if err := json.Unmarshal(raw, &meta); err != nil {
|
|
|
|
|
|
return 0, "", false
|
|
|
|
|
|
}
|
|
|
|
|
|
return meta.BusiCode, meta.BusiMsg, true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func stripBusiMetaFromData(data interface{}) (interface{}, error) {
|
|
|
|
|
|
raw, err := json.Marshal(data)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
var payload map[string]interface{}
|
|
|
|
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
delete(payload, "busiCode")
|
|
|
|
|
|
delete(payload, "busiMsg")
|
|
|
|
|
|
return payload, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// isEmptyBusinessResult 判断业务载荷是否为空(无 result,或 result 无有效内容)
|
|
|
|
|
|
func isEmptyBusinessResult(data interface{}) bool {
|
|
|
|
|
|
payload, ok := data.(map[string]interface{})
|
|
|
|
|
|
if !ok || len(payload) == 0 {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
result, exists := payload["result"]
|
|
|
|
|
|
if !exists || result == nil {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
return isEmptyValue(result)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func isEmptyValue(v interface{}) bool {
|
|
|
|
|
|
if v == nil {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
switch x := v.(type) {
|
|
|
|
|
|
case string:
|
|
|
|
|
|
return strings.TrimSpace(x) == ""
|
|
|
|
|
|
case map[string]interface{}:
|
|
|
|
|
|
if len(x) == 0 {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, item := range x {
|
|
|
|
|
|
if !isEmptyValue(item) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return true
|
|
|
|
|
|
case []interface{}:
|
|
|
|
|
|
return len(x) == 0
|
|
|
|
|
|
default:
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func cloneBody(body map[string]string) map[string]string {
|
|
|
|
|
|
if body == nil {
|
|
|
|
|
|
return map[string]string{}
|
|
|
|
|
|
}
|
|
|
|
|
|
cloned := make(map[string]string, len(body)+2)
|
|
|
|
|
|
for k, v := range body {
|
|
|
|
|
|
cloned[k] = v
|
|
|
|
|
|
}
|
|
|
|
|
|
return cloned
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func wrapHTTPError(err error) error {
|
|
|
|
|
|
if err == context.DeadlineExceeded {
|
|
|
|
|
|
return errors.Join(ErrDatasource, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
|
|
|
|
|
return errors.Join(ErrDatasource, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
switch err.Error() {
|
|
|
|
|
|
case "context deadline exceeded", "timeout", "Client.Timeout exceeded", "net/http: request canceled":
|
|
|
|
|
|
return errors.Join(ErrDatasource, err)
|
|
|
|
|
|
default:
|
|
|
|
|
|
return errors.Join(ErrSystem, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|