Files
tyapi-server/internal/infrastructure/external/nuoer/nuoer_service.go
2026-05-28 10:55:28 +08:00

254 lines
6.6 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 nuoer
import (
"bytes"
"context"
"crypto/md5"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"tyapi-server/internal/shared/external_logger"
)
const defaultRequestTimeout = 4 * time.Second
// 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) generateRequestID() string {
timestamp := time.Now().UnixNano()
hash := md5.Sum([]byte(fmt.Sprintf("%d_%s", timestamp, s.config.AppID)))
return fmt.Sprintf("nuoer_%x", hash[:8])
}
func (s *NuoerService) CallAPI(ctx context.Context, apiKey, apiPath string, body map[string]string) (*nuoerResponse, error) {
requestURL := strings.TrimSuffix(s.config.URL, "/")
if apiPath != "" {
if !strings.HasPrefix(apiPath, "/") {
apiPath = "/" + apiPath
}
requestURL += apiPath
}
requestID := s.generateRequestID()
startTime := time.Now()
var transactionID string
if id, ok := ctx.Value("transaction_id").(string); ok {
transactionID = id
}
// 对调用方传入的 body 全量参与加签(排除空值,按 key 升序,见 Sign
sign := Sign(body, s.config.AppSecret)
requestPayload := map[string]interface{}{
"appId": s.config.AppID,
"sign": sign,
"apiKey": apiKey,
"body": body,
}
if s.logger != nil {
s.logger.LogRequest(requestID, transactionID, apiKey, requestURL)
}
bodyBytes, err := json.Marshal(requestPayload)
if err != nil {
err = errors.Join(ErrSystem, err)
if s.logger != nil {
s.logger.LogError(requestID, 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)
if s.logger != nil {
s.logger.LogError(requestID, 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)
if s.logger != nil {
s.logger.LogError(requestID, 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)
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiKey, err, requestPayload)
}
return nil, err
}
if s.logger != nil {
s.logger.LogResponse(requestID, transactionID, apiKey, resp.StatusCode, time.Since(startTime))
}
if resp.StatusCode != http.StatusOK {
err = errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码 %d", resp.StatusCode))
if s.logger != nil {
s.logger.LogError(requestID, 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))
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiKey, err, requestPayload)
}
return nil, err
}
if nuoerResp.Code != CodeSuccess {
nuoerErr := NewNuoerError(nuoerResp.Code, nuoerResp.Msg)
err = errors.Join(GetErrByPlatformCode(nuoerResp.Code), nuoerErr)
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiKey, nuoerErr, requestPayload)
}
return nil, err
}
if nuoerResp.Data == nil {
err = errors.Join(ErrSystem, errors.New("响应 data 为空"))
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiKey, err, requestPayload)
}
return nil, err
}
busiCode, busiMsg, ok := parseDataBusiInfo(nuoerResp.Data)
if !ok {
err = errors.Join(ErrSystem, errors.New("响应 data 无法解析 busiCode"))
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiKey, err, requestPayload)
}
return nil, err
}
if busiCode != BusiCodeSuccess {
busiErr := NewNuoerBusiError(busiCode, busiMsg)
err = errors.Join(GetErrByBusiCode(busiCode), busiErr)
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiKey, busiErr, requestPayload)
}
return nil, err
}
cleanedData, err := stripBusiMetaFromData(nuoerResp.Data)
if err != nil {
err = errors.Join(ErrSystem, fmt.Errorf("响应 data 清理失败: %w", err))
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiKey, err, requestPayload)
}
return nil, err
}
nuoerResp.Data = cleanedData
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)
}
}