Files
tyapi-server/internal/infrastructure/external/yuyuecha/yuyuecha_service.go
2026-07-24 23:27:17 +08:00

347 lines
10 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 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 调用愉悦查业务 OpenAPIPOST
// 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)),
)
}