Files
2026-07-25 14:18:14 +08:00

331 lines
9.9 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 yuyuecha
import (
"bytes"
"context"
"crypto/md5"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"tyapi-server/internal/shared/external_logger"
)
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 + response不重复打 request/response 日志
Debug bool
// DebugLogDir 排查日志目录,文件名为 dev_curl.log
DebugLogDir string
}
// 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
// dev 排查走单一文件,避免 request/response/info 重复打点
if !s.config.Debug && s.logger != nil {
s.logger.LogRequest(requestID, transactionID, path, reqURL)
}
// nonce 与本地 request_id 保持一致,后续仅凭 request_id 即可对接上游排查
signHeaders, err := SignHeaders(s.config.ClientID, s.config.ClientSecret, method, path, bodyBytes, requestID)
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)
s.logDebugExchange(requestID, transactionID, path, curlCmd, 0, time.Since(startTime), nil, err.Error())
s.logError(requestID, transactionID, path, err, map[string]interface{}{"request_params": logParams})
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)
s.logDebugExchange(requestID, transactionID, path, curlCmd, resp.StatusCode, duration, nil, err.Error())
s.logError(requestID, transactionID, path, err, map[string]interface{}{"request_params": logParams})
return nil, err
}
s.logDebugExchange(requestID, transactionID, path, curlCmd, resp.StatusCode, duration, raw, "")
if resp.StatusCode != http.StatusOK {
mapped := mapHTTPStatusError(resp.StatusCode, raw)
s.logError(requestID, transactionID, path, mapped, map[string]interface{}{
"request_params": logParams,
"response_body": truncateForLog(string(raw), maxLogResponseBodyLen),
"http_status": resp.StatusCode,
})
return nil, mapped
}
if !s.config.Debug && 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))
s.logError(requestID, transactionID, path, parseErr, map[string]interface{}{
"request_params": logParams,
"response_body": truncateForLog(string(raw), maxLogResponseBodyLen),
})
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))
s.logError(requestID, transactionID, path, mapped, map[string]interface{}{
"request_params": logParams,
"response_body": truncateForLog(string(raw), maxLogResponseBodyLen),
})
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)
}
}
func (s *YuyuechaService) logDebugExchange(requestID, transactionID, apiCode, curlCmd string, statusCode int, duration time.Duration, raw []byte, errMsg string) {
if !s.config.Debug {
return
}
writeDebugExchange(s.config.DebugLogDir, debugExchangeRecord{
RequestID: requestID,
TransactionID: transactionID,
API: apiCode,
Status: statusCode,
Duration: duration,
Curl: curlCmd,
ResponseBody: raw,
Error: errMsg,
})
}