f
This commit is contained in:
346
internal/infrastructure/external/yuyuecha/yuyuecha_service.go
vendored
Normal file
346
internal/infrastructure/external/yuyuecha/yuyuecha_service.go
vendored
Normal file
@@ -0,0 +1,346 @@
|
||||
package yuyuecha
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tyapi-server/internal/shared/external_logger"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRequestTimeout = 30 * time.Second
|
||||
maxLogResponseBodyLen = 500
|
||||
|
||||
headerContentType = "Content-Type"
|
||||
contentTypeJSON = "application/json"
|
||||
|
||||
headerBillable = "X-Billable"
|
||||
headerChargeFen = "X-Charge-Fen"
|
||||
headerIdempotentReplay = "X-Idempotent-Replay"
|
||||
|
||||
openAPIPrefix = "/openapi/v1"
|
||||
accountPath = "/openapi/v1/account"
|
||||
)
|
||||
|
||||
// ServiceConfig 愉悦查服务配置
|
||||
type ServiceConfig struct {
|
||||
BaseURL string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
Timeout time.Duration
|
||||
// Debug 开发环境开启时输出可复现 curl 与完整响应体,便于数据源排查
|
||||
Debug bool
|
||||
}
|
||||
|
||||
// YuyuechaService 愉悦查 OpenAPI 客户端
|
||||
type YuyuechaService struct {
|
||||
config ServiceConfig
|
||||
logger *external_logger.ExternalServiceLogger
|
||||
}
|
||||
|
||||
// NewYuyuechaService 创建愉悦查服务实例
|
||||
func NewYuyuechaService(cfg ServiceConfig, logger *external_logger.ExternalServiceLogger) *YuyuechaService {
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = defaultRequestTimeout
|
||||
}
|
||||
return &YuyuechaService{
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *YuyuechaService) generateRequestID() string {
|
||||
timestamp := time.Now().UnixNano()
|
||||
hash := md5.Sum([]byte(fmt.Sprintf("%d_%s", timestamp, s.config.ClientID)))
|
||||
return fmt.Sprintf("yuyuecha_%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]"
|
||||
}
|
||||
|
||||
// CallAPI 调用愉悦查业务 OpenAPI(POST)
|
||||
// apiPath 为相对路径入参,如 a01/person-company-relations;服务内拼接为 /openapi/v1/{apiPath}
|
||||
// body 会按紧凑 JSON(无空格、保留 Unicode)序列化后参与签名,务必与上游一致。
|
||||
func (s *YuyuechaService) CallAPI(ctx context.Context, apiPath string, body map[string]interface{}) (*CallResult, error) {
|
||||
path := buildOpenAPIPath(apiPath)
|
||||
bodyBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, errors.Join(ErrSystem, fmt.Errorf("请求序列化失败: %w", err))
|
||||
}
|
||||
return s.doRequest(ctx, http.MethodPost, path, bodyBytes, body)
|
||||
}
|
||||
|
||||
// buildOpenAPIPath 将相对 apiPath 拼成签名用完整 path:/openapi/v1/{apiPath}
|
||||
func buildOpenAPIPath(apiPath string) string {
|
||||
apiPath = strings.TrimSpace(apiPath)
|
||||
apiPath = strings.TrimPrefix(apiPath, "/")
|
||||
if strings.HasPrefix(apiPath, "openapi/v1/") {
|
||||
return "/" + apiPath
|
||||
}
|
||||
return openAPIPrefix + "/" + apiPath
|
||||
}
|
||||
|
||||
// GetAccount 查询账户汇总(GET /openapi/v1/account,请求体为空,签名 bodySha256 对 b"")
|
||||
func (s *YuyuechaService) GetAccount(ctx context.Context) (*AccountInfo, error) {
|
||||
result, err := s.doRequest(ctx, http.MethodGet, accountPath, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var info AccountInfo
|
||||
if err := json.Unmarshal(result.Data, &info); err != nil {
|
||||
return nil, errors.Join(ErrSystem, fmt.Errorf("账户响应解析失败: %w", err))
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func (s *YuyuechaService) doRequest(ctx context.Context, method, path string, bodyBytes []byte, logParams interface{}) (*CallResult, error) {
|
||||
startTime := time.Now()
|
||||
requestID := s.generateRequestID()
|
||||
|
||||
var transactionID string
|
||||
if id, ok := ctx.Value("transaction_id").(string); ok {
|
||||
transactionID = id
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
if bodyBytes == nil {
|
||||
bodyBytes = []byte{}
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSuffix(s.config.BaseURL, "/")
|
||||
reqURL := baseURL + path
|
||||
|
||||
if s.logger != nil {
|
||||
s.logger.LogRequest(requestID, transactionID, path, reqURL)
|
||||
}
|
||||
|
||||
signHeaders, err := SignHeaders(s.config.ClientID, s.config.ClientSecret, method, path, bodyBytes)
|
||||
if err != nil {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
s.logError(requestID, transactionID, path, err, map[string]interface{}{"request_params": logParams})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if method != http.MethodGet && method != http.MethodHead {
|
||||
bodyReader = bytes.NewReader(bodyBytes)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
|
||||
if err != nil {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
s.logError(requestID, transactionID, path, err, map[string]interface{}{"request_params": logParams})
|
||||
return nil, err
|
||||
}
|
||||
curlHeaders := make(map[string]string, len(signHeaders)+1)
|
||||
if bodyReader != nil {
|
||||
req.Header.Set(headerContentType, contentTypeJSON)
|
||||
curlHeaders[headerContentType] = contentTypeJSON
|
||||
}
|
||||
for k, v := range signHeaders {
|
||||
req.Header.Set(k, v)
|
||||
curlHeaders[k] = v
|
||||
}
|
||||
|
||||
var curlCmd string
|
||||
if s.config.Debug {
|
||||
bodyForCurl := ""
|
||||
if method != http.MethodGet && method != http.MethodHead {
|
||||
bodyForCurl = string(bodyBytes)
|
||||
}
|
||||
curlCmd = generateCurlCommand(method, reqURL, curlHeaders, bodyForCurl)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: s.config.Timeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
err = wrapHTTPError(err)
|
||||
errParams := map[string]interface{}{"request_params": logParams}
|
||||
if curlCmd != "" {
|
||||
errParams["curl"] = curlCmd
|
||||
}
|
||||
s.logError(requestID, transactionID, path, err, errParams)
|
||||
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)
|
||||
errParams := map[string]interface{}{"request_params": logParams}
|
||||
if curlCmd != "" {
|
||||
errParams["curl"] = curlCmd
|
||||
}
|
||||
s.logError(requestID, transactionID, path, err, errParams)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logDebugCurlAndResponse(requestID, transactionID, path, curlCmd, resp.StatusCode, duration, raw)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
mapped := mapHTTPStatusError(resp.StatusCode, raw)
|
||||
errParams := map[string]interface{}{
|
||||
"request_params": logParams,
|
||||
"response_body": truncateForLog(string(raw), maxLogResponseBodyLen),
|
||||
"http_status": resp.StatusCode,
|
||||
}
|
||||
if curlCmd != "" {
|
||||
errParams["curl"] = curlCmd
|
||||
}
|
||||
s.logError(requestID, transactionID, path, mapped, errParams)
|
||||
return nil, mapped
|
||||
}
|
||||
|
||||
if s.logger != nil {
|
||||
s.logger.LogResponse(requestID, transactionID, path, resp.StatusCode, duration)
|
||||
}
|
||||
|
||||
var outer APIResponse
|
||||
if err := json.Unmarshal(raw, &outer); err != nil {
|
||||
parseErr := errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %w", err))
|
||||
errParams := map[string]interface{}{
|
||||
"request_params": logParams,
|
||||
"response_body": truncateForLog(string(raw), maxLogResponseBodyLen),
|
||||
}
|
||||
if curlCmd != "" {
|
||||
errParams["curl"] = curlCmd
|
||||
}
|
||||
s.logError(requestID, transactionID, path, parseErr, errParams)
|
||||
return nil, parseErr
|
||||
}
|
||||
|
||||
if !outer.Success {
|
||||
msg := "上游返回 success=false"
|
||||
if outer.ErrMessage != nil && *outer.ErrMessage != "" {
|
||||
msg = *outer.ErrMessage
|
||||
} else if outer.ErrCode != nil && *outer.ErrCode != "" {
|
||||
msg = *outer.ErrCode
|
||||
}
|
||||
mapped := errors.Join(ErrDatasource, errors.New(msg))
|
||||
errParams := map[string]interface{}{
|
||||
"request_params": logParams,
|
||||
"response_body": truncateForLog(string(raw), maxLogResponseBodyLen),
|
||||
}
|
||||
if curlCmd != "" {
|
||||
errParams["curl"] = curlCmd
|
||||
}
|
||||
s.logError(requestID, transactionID, path, mapped, errParams)
|
||||
return nil, mapped
|
||||
}
|
||||
|
||||
billable, chargeFen := resolveBilling(resp.Header, outer.Billing)
|
||||
data := outer.Data
|
||||
if len(data) == 0 || string(data) == "null" {
|
||||
data = []byte("[]")
|
||||
}
|
||||
|
||||
return &CallResult{
|
||||
Data: data,
|
||||
Billable: billable,
|
||||
ChargeFen: chargeFen,
|
||||
RequestID: outer.RequestID,
|
||||
Sandbox: outer.Sandbox,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveBilling(header http.Header, billing *BillingInfo) (billable bool, chargeFen int) {
|
||||
// 优先响应头(文档 9.2)
|
||||
if v := strings.TrimSpace(header.Get(headerBillable)); v != "" {
|
||||
billable = strings.EqualFold(v, "true")
|
||||
} else if billing != nil {
|
||||
billable = billing.Billable
|
||||
}
|
||||
|
||||
if v := strings.TrimSpace(header.Get(headerChargeFen)); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
chargeFen = n
|
||||
}
|
||||
} else if billing != nil {
|
||||
chargeFen = billing.ChargeFen
|
||||
}
|
||||
|
||||
// 幂等重放:上游不重复扣费
|
||||
if strings.EqualFold(strings.TrimSpace(header.Get(headerIdempotentReplay)), "true") {
|
||||
billable = false
|
||||
chargeFen = 0
|
||||
}
|
||||
return billable, chargeFen
|
||||
}
|
||||
|
||||
func mapHTTPStatusError(status int, raw []byte) error {
|
||||
msg := strings.TrimSpace(string(raw))
|
||||
if len(msg) > 200 {
|
||||
msg = msg[:200]
|
||||
}
|
||||
detail := fmt.Sprintf("HTTP %d", status)
|
||||
if msg != "" {
|
||||
detail = detail + ": " + msg
|
||||
}
|
||||
|
||||
switch status {
|
||||
case HTTPBadRequest:
|
||||
return errors.Join(ErrSystem, errors.New(detail))
|
||||
case HTTPUnauthorized, HTTPPaymentRequired, HTTPForbidden,
|
||||
HTTPConflict, HTTPTooManyRequests, HTTPBadGateway:
|
||||
return errors.Join(ErrDatasource, errors.New(detail))
|
||||
default:
|
||||
if status >= 500 {
|
||||
return errors.Join(ErrDatasource, errors.New(detail))
|
||||
}
|
||||
return errors.Join(ErrDatasource, errors.New(detail))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (s *YuyuechaService) logError(requestID, transactionID, apiCode string, err error, params interface{}) {
|
||||
if s.logger != nil {
|
||||
s.logger.LogError(requestID, transactionID, apiCode, err, params)
|
||||
}
|
||||
}
|
||||
|
||||
// logDebugCurlAndResponse 仅在 Debug(开发环境)输出完整 curl 与响应体
|
||||
func (s *YuyuechaService) logDebugCurlAndResponse(requestID, transactionID, apiCode, curlCmd string, statusCode int, duration time.Duration, raw []byte) {
|
||||
if !s.config.Debug || s.logger == nil || curlCmd == "" {
|
||||
return
|
||||
}
|
||||
s.logger.LogInfo("愉悦查请求响应(dev)",
|
||||
zap.String("request_id", requestID),
|
||||
zap.String("transaction_id", transactionID),
|
||||
zap.String("api_code", apiCode),
|
||||
zap.Int("http_status", statusCode),
|
||||
zap.Duration("duration", duration),
|
||||
zap.String("curl", curlCmd),
|
||||
zap.String("response_body", string(raw)),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user