Files
tyapi-server/internal/infrastructure/external/yuyuecha/yuyuecha_service.go

331 lines
9.9 KiB
Go
Raw Normal View History

2026-07-24 23:27:17 +08:00
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
2026-07-25 14:18:14 +08:00
// Debug 开发环境开启时写入单一排查文件curl + response不重复打 request/response 日志
2026-07-24 23:27:17 +08:00
Debug bool
2026-07-25 14:18:14 +08:00
// DebugLogDir 排查日志目录,文件名为 dev_curl.log
DebugLogDir string
2026-07-24 23:27:17 +08:00
}
// 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
2026-07-25 14:18:14 +08:00
// dev 排查走单一文件,避免 request/response/info 重复打点
if !s.config.Debug && s.logger != nil {
2026-07-24 23:27:17 +08:00
s.logger.LogRequest(requestID, transactionID, path, reqURL)
}
2026-07-25 14:18:14 +08:00
// nonce 与本地 request_id 保持一致,后续仅凭 request_id 即可对接上游排查
signHeaders, err := SignHeaders(s.config.ClientID, s.config.ClientSecret, method, path, bodyBytes, requestID)
2026-07-24 23:27:17 +08:00
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)
2026-07-25 14:18:14 +08:00
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})
2026-07-24 23:27:17 +08:00
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)
2026-07-25 14:18:14 +08:00
s.logDebugExchange(requestID, transactionID, path, curlCmd, resp.StatusCode, duration, nil, err.Error())
s.logError(requestID, transactionID, path, err, map[string]interface{}{"request_params": logParams})
2026-07-24 23:27:17 +08:00
return nil, err
}
2026-07-25 14:18:14 +08:00
s.logDebugExchange(requestID, transactionID, path, curlCmd, resp.StatusCode, duration, raw, "")
2026-07-24 23:27:17 +08:00
if resp.StatusCode != http.StatusOK {
mapped := mapHTTPStatusError(resp.StatusCode, raw)
2026-07-25 14:18:14 +08:00
s.logError(requestID, transactionID, path, mapped, map[string]interface{}{
2026-07-24 23:27:17 +08:00
"request_params": logParams,
"response_body": truncateForLog(string(raw), maxLogResponseBodyLen),
"http_status": resp.StatusCode,
2026-07-25 14:18:14 +08:00
})
2026-07-24 23:27:17 +08:00
return nil, mapped
}
2026-07-25 14:18:14 +08:00
if !s.config.Debug && s.logger != nil {
2026-07-24 23:27:17 +08:00
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))
2026-07-25 14:18:14 +08:00
s.logError(requestID, transactionID, path, parseErr, map[string]interface{}{
2026-07-24 23:27:17 +08:00
"request_params": logParams,
"response_body": truncateForLog(string(raw), maxLogResponseBodyLen),
2026-07-25 14:18:14 +08:00
})
2026-07-24 23:27:17 +08:00
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))
2026-07-25 14:18:14 +08:00
s.logError(requestID, transactionID, path, mapped, map[string]interface{}{
2026-07-24 23:27:17 +08:00
"request_params": logParams,
"response_body": truncateForLog(string(raw), maxLogResponseBodyLen),
2026-07-25 14:18:14 +08:00
})
2026-07-24 23:27:17 +08:00
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)
}
}
2026-07-25 14:18:14 +08:00
func (s *YuyuechaService) logDebugExchange(requestID, transactionID, apiCode, curlCmd string, statusCode int, duration time.Duration, raw []byte, errMsg string) {
if !s.config.Debug {
2026-07-24 23:27:17 +08:00
return
}
2026-07-25 14:18:14 +08:00
writeDebugExchange(s.config.DebugLogDir, debugExchangeRecord{
RequestID: requestID,
TransactionID: transactionID,
API: apiCode,
Status: statusCode,
Duration: duration,
Curl: curlCmd,
ResponseBody: raw,
Error: errMsg,
})
2026-07-24 23:27:17 +08:00
}