Files
tyapi-server/internal/infrastructure/external/haiyuapi/haiyuapi_service.go
2026-07-23 15:32:41 +08:00

276 lines
8.0 KiB
Go
Raw 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 haiyuapi
import (
"bytes"
"context"
"crypto/md5"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"tyapi-server/internal/shared/external_logger"
)
const (
defaultRequestTimeout = 60 * time.Second
maxLogParamValueLen = 300
maxLogResponseBodyLen = 500
)
// HaiyuapiConfig 海宇 API 服务配置
type HaiyuapiConfig struct {
BaseURL string
AccessID string
SecretKey string
Timeout time.Duration
}
// HaiyuapiService 海宇平台中转服务
type HaiyuapiService struct {
config HaiyuapiConfig
logger *external_logger.ExternalServiceLogger
}
// NewHaiyuapiService 创建海宇 API 服务实例
func NewHaiyuapiService(cfg HaiyuapiConfig, logger *external_logger.ExternalServiceLogger) *HaiyuapiService {
if cfg.Timeout == 0 {
cfg.Timeout = defaultRequestTimeout
}
return &HaiyuapiService{
config: cfg,
logger: logger,
}
}
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
}
// queryBillingProductCodes 查询计费产品:查空(code=1000)仍按成功返回空数据,由平台侧计费
// 与 hyapi 侧 jiyi.queryBillingAPIKeys 对齐洞侦1.0、借贷意向验证3.0 查空计费全景雷达BH(JRZQP8D2) 查空不计费
var queryBillingProductCodes = map[string]struct{}{
"JRZQK9P2": {}, // 洞侦1.0hyapi jy000022
"JRZQR4N7": {}, // 借贷意向验证3.0hyapi jy000042
}
func isQueryBillingProduct(productCode string) bool {
_, ok := queryBillingProductCodes[productCode]
return ok
}
// CallAPI 调用海宇平台指定产品(产品编号与处理器 ApiCode 一致)
func (s *HaiyuapiService) CallAPI(ctx context.Context, productCode string, params map[string]interface{}) ([]byte, error) {
startTime := time.Now()
requestID := s.generateRequestID()
var transactionID string
if id, ok := ctx.Value("transaction_id").(string); ok {
transactionID = id
}
baseURL := strings.TrimSuffix(s.config.BaseURL, "/")
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
reqURL := fmt.Sprintf("%s/api/v1/%s?t=%s", baseURL, productCode, timestamp)
if s.logger != nil {
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, productCode, err, map[string]interface{}{"request_params": requestParamsForLog(params)})
}
return nil, err
}
bodyBytes, err := json.Marshal(RequestPayload{Data: encryptedData})
if err != nil {
err = errors.Join(ErrSystem, err)
if s.logger != nil {
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.NewReader(bodyBytes))
if err != nil {
err = errors.Join(ErrSystem, err)
if s.logger != nil {
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)
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, productCode, err, map[string]interface{}{"request_params": requestParamsForLog(params)})
}
return nil, err
}
defer resp.Body.Close()
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, productCode, err, map[string]interface{}{"request_params": requestParamsForLog(params)})
}
return nil, err
}
if resp.StatusCode != http.StatusOK {
err = errors.Join(ErrDatasource, fmt.Errorf("HTTP %d", resp.StatusCode))
if s.logger != nil {
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, productCode, resp.StatusCode, duration)
}
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 {
// 查询计费产品:查空仍返回空数据,由平台计费(对齐 hyapi
if outer.Code == CodeQueryEmpty && isQueryBillingProduct(productCode) {
return []byte("{}"), nil
}
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 {
if err == context.DeadlineExceeded {
return errors.Join(ErrDatasource, err)
}
if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
return errors.Join(ErrDatasource, err)
}
es := err.Error()
if strings.Contains(es, "deadline exceeded") || strings.Contains(es, "timeout") || strings.Contains(es, "canceled") {
return errors.Join(ErrDatasource, err)
}
return errors.Join(ErrSystem, err)
}