Files

373 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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{}{
"jy000008": {}, // 探针C
"jy000017": {}, // 探针A
"jy000019": {}, // 商业险有效性
"jy000020": {}, // 交强险有效性
"jy000022": {}, // 洞侦1.0
"jy000028": {}, // 车VIN查车牌号
"jy000042": {}, // 借贷意向验证3.0
"jy000048": {}, // 申请借贷
"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
}
// 去掉 data.result 包装,直接返回最内层业务数据
jiyiResp.Data = unwrapResultPayload(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
}
// unwrapResultPayload 若载荷为 {"result": <业务数据>},则直接返回业务数据。
// 五个集奕产品实测成功响应均为此结构。
func unwrapResultPayload(data interface{}) interface{} {
payload, ok := data.(map[string]interface{})
if !ok {
return data
}
result, exists := payload["result"]
if !exists {
return data
}
return result
}
// 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)
}
}