347 lines
10 KiB
Go
347 lines
10 KiB
Go
|
|
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)),
|
|||
|
|
)
|
|||
|
|
}
|