fadd
This commit is contained in:
94
internal/infrastructure/external/rongxing/crypto.go
vendored
Normal file
94
internal/infrastructure/external/rongxing/crypto.go
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
package rongxing
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MD5Upper 对明文做 MD5,返回 32 位大写十六进制(入参说明要求大写)。
|
||||
func MD5Upper(plaintext string) string {
|
||||
sum := md5.Sum([]byte(strings.TrimSpace(plaintext)))
|
||||
return strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
}
|
||||
|
||||
// EncodePasswordBase64 将明文密码做 Base64 编码(登录接口要求)。
|
||||
func EncodePasswordBase64(password string) string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(password))
|
||||
}
|
||||
|
||||
// BuildSignContent 生成登录签名原文:过滤 sign/空值 → 按 key 升序 → key=value&...
|
||||
func BuildSignContent(params map[string]interface{}) string {
|
||||
keys := make([]string, 0, len(params))
|
||||
for k, v := range params {
|
||||
if strings.EqualFold(k, "sign") || v == nil {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s=%v", k, params[k]))
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
// SignSHA256WithRSA 使用私钥对原文做 SHA256withRSA 签名,返回 Base64。
|
||||
func SignSHA256WithRSA(content string, privateKey *rsa.PrivateKey) (string, error) {
|
||||
if privateKey == nil {
|
||||
return "", fmt.Errorf("私钥为空")
|
||||
}
|
||||
hashed := sha256.Sum256([]byte(content))
|
||||
signed, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashed[:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("RSA 签名失败: %w", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(signed), nil
|
||||
}
|
||||
|
||||
// ParsePrivateKey 解析 RSA 私钥(支持 PEM / PKCS#8 / PKCS#1 原始 Base64)。
|
||||
func ParsePrivateKey(raw string) (*rsa.PrivateKey, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("私钥为空")
|
||||
}
|
||||
|
||||
var der []byte
|
||||
if block, _ := pem.Decode([]byte(raw)); block != nil {
|
||||
der = block.Bytes
|
||||
} else {
|
||||
normalized := strings.ReplaceAll(raw, "\n", "")
|
||||
normalized = strings.ReplaceAll(normalized, "\r", "")
|
||||
normalized = strings.ReplaceAll(normalized, " ", "")
|
||||
decoded, err := base64.StdEncoding.DecodeString(normalized)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("私钥 Base64 解码失败: %w", err)
|
||||
}
|
||||
der = decoded
|
||||
}
|
||||
|
||||
if key, err := x509.ParsePKCS8PrivateKey(der); err == nil {
|
||||
rsaKey, ok := key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("私钥不是 RSA 类型")
|
||||
}
|
||||
return rsaKey, nil
|
||||
}
|
||||
|
||||
if key, err := x509.ParsePKCS1PrivateKey(der); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("无法解析 RSA 私钥(需 PKCS#8 或 PKCS#1)")
|
||||
}
|
||||
66
internal/infrastructure/external/rongxing/crypto_test.go
vendored
Normal file
66
internal/infrastructure/external/rongxing/crypto_test.go
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
package rongxing
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMD5Upper(t *testing.T) {
|
||||
if got := MD5Upper(""); got != "D41D8CD98F00B204E9800998ECF8427E" {
|
||||
t.Fatalf("MD5Upper empty = %s", got)
|
||||
}
|
||||
if got := MD5Upper(" hello "); got != MD5Upper("hello") {
|
||||
t.Fatalf("MD5Upper should trim spaces")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSignContent(t *testing.T) {
|
||||
params := map[string]interface{}{
|
||||
"account": "haiyukeji",
|
||||
"password": "cGFzcw==",
|
||||
"appId": "hykj",
|
||||
"timestamp": int64(1710000000000),
|
||||
"sign": "ignored",
|
||||
}
|
||||
got := BuildSignContent(params)
|
||||
want := "account=haiyukeji&appId=hykj&password=cGFzcw==×tamp=1710000000000"
|
||||
if got != want {
|
||||
t.Fatalf("BuildSignContent mismatch\ngot: %s\nwant: %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignSHA256WithRSA(t *testing.T) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
sig, err := SignSHA256WithRSA("account=a&appId=b", key)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
if sig == "" {
|
||||
t.Fatal("empty signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrivateKeyPKCS8Base64(t *testing.T) {
|
||||
// 与对接文档一致的 PKCS#8 Base64(无 PEM 头)
|
||||
raw := "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAI1eWDx325U/DCpaw+394555voDNwKyfg6rAIBYGSmJDmUyCi6c8LeqVtYnmtUMJrTDrfUWFG7jQ+mQy65rXMY7zrZ0Cc+orP7uRgrvCBsH1775KuSji8TkVbEBw+Upro7FuNkutBItNxZCvcpFpNqNBwCCkCK1xscVN8gErxc7/AgMBAAECgYAL/ZVHWU7Ni5TyrLmTBwAjWD+RX9V4iGkb3QLiGCayZS05NGNq/ytrCvqxMSY6HIKAZ6Du+hmXvr+JXll/slvyGs1ETOgDi7563RAT/2TVicZF16IM2d7nhK6eTJffmiG2ZZC8n043F0QNposleEIMhM251iT1xiwZfg+QqHB0EQJBAI+BFcbYl+Vxpxdouvuwq11gYMTNepcGdY3OaPZzW4sQA41s+6aZhZ7tAk65Tk7PgLfdU2yimBKNkJstlSUT1w8CQQD8MKjAi5wngnDO+04n5mt5EotpNy6xpvkQP2izL0FWKmvwZtc0ihJpxa64vfo4+1jE8YXx1/qGe16A15fmywkRAkAbImVjvAC8ucjGfF8eyEEe3uJtVA0iEW6Y6bafIyDkIpsJWtoanlzNuDL/f7p23HWSTp8/o17t4ya8sNnKsP2xAkEArIQc7JqUl/KDeRQwwtq9anVlKPS23JB8kMDPvsP0zhz2+d1gGnDZZ8HzZC2RnqlScGdIWciFeLmsTDcvkpISAQJANaQU0uKBPvgMx+uedbCbn9MfYmpEiONHblrINH8WAa3Z1pr7R0wBUog1fEWimnxOX5wGwsGmDOMw5B+4xJ4Zfw=="
|
||||
key, err := ParsePrivateKey(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrivateKey: %v", err)
|
||||
}
|
||||
if key == nil {
|
||||
t.Fatal("nil key")
|
||||
}
|
||||
if _, err := SignSHA256WithRSA("account=haiyukeji&appId=hykj", key); err != nil {
|
||||
t.Fatalf("sign with parsed key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodePasswordBase64(t *testing.T) {
|
||||
if got := EncodePasswordBase64("haiyukeji@123"); got != "aGFpeXVrZWppQDEyMw==" {
|
||||
t.Fatalf("EncodePasswordBase64 = %s", got)
|
||||
}
|
||||
}
|
||||
35
internal/infrastructure/external/rongxing/curl_helper.go
vendored
Normal file
35
internal/infrastructure/external/rongxing/curl_helper.go
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
package rongxing
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// generateCurlCommand 生成可直接复现的 curl 命令,便于联调排查。
|
||||
func generateCurlCommand(method, url string, headers map[string]string, body string) string {
|
||||
var cmd strings.Builder
|
||||
cmd.WriteString("curl -X ")
|
||||
cmd.WriteString(method)
|
||||
cmd.WriteString(" '")
|
||||
cmd.WriteString(url)
|
||||
cmd.WriteString("'")
|
||||
|
||||
for key, value := range headers {
|
||||
cmd.WriteString(" \\\n -H '")
|
||||
cmd.WriteString(key)
|
||||
cmd.WriteString(": ")
|
||||
cmd.WriteString(escapeSingleQuotes(value))
|
||||
cmd.WriteString("'")
|
||||
}
|
||||
|
||||
if body != "" {
|
||||
cmd.WriteString(" \\\n -d '")
|
||||
cmd.WriteString(escapeSingleQuotes(body))
|
||||
cmd.WriteString("'")
|
||||
}
|
||||
|
||||
return cmd.String()
|
||||
}
|
||||
|
||||
func escapeSingleQuotes(s string) string {
|
||||
return strings.ReplaceAll(s, "'", `'\''`)
|
||||
}
|
||||
50
internal/infrastructure/external/rongxing/rongxing_factory.go
vendored
Normal file
50
internal/infrastructure/external/rongxing/rongxing_factory.go
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
package rongxing
|
||||
|
||||
import (
|
||||
"hyapi-server/internal/config"
|
||||
"hyapi-server/internal/shared/external_logger"
|
||||
)
|
||||
|
||||
// NewRongxingServiceWithConfig 使用配置创建戎行服务
|
||||
func NewRongxingServiceWithConfig(cfg *config.Config) (*RongxingService, error) {
|
||||
loggingConfig := external_logger.ExternalServiceLoggingConfig{
|
||||
Enabled: cfg.Rongxing.Logging.Enabled,
|
||||
LogDir: cfg.Rongxing.Logging.LogDir,
|
||||
ServiceName: "rongxing",
|
||||
UseDaily: cfg.Rongxing.Logging.UseDaily,
|
||||
EnableLevelSeparation: cfg.Rongxing.Logging.EnableLevelSeparation,
|
||||
LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig),
|
||||
}
|
||||
|
||||
if cfg.Rongxing.Logging.ServiceName != "" {
|
||||
loggingConfig.ServiceName = cfg.Rongxing.Logging.ServiceName
|
||||
}
|
||||
|
||||
for key, value := range cfg.Rongxing.Logging.LevelConfigs {
|
||||
loggingConfig.LevelConfigs[key] = external_logger.ExternalServiceLevelFileConfig{
|
||||
MaxSize: value.MaxSize,
|
||||
MaxBackups: value.MaxBackups,
|
||||
MaxAge: value.MaxAge,
|
||||
Compress: value.Compress,
|
||||
}
|
||||
}
|
||||
|
||||
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timeout := cfg.Rongxing.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultRequestTimeout
|
||||
}
|
||||
|
||||
return NewRongxingService(serviceConfig{
|
||||
BaseURL: cfg.Rongxing.URL,
|
||||
Account: cfg.Rongxing.Account,
|
||||
Password: cfg.Rongxing.Password,
|
||||
AppID: cfg.Rongxing.AppID,
|
||||
PrivateKey: cfg.Rongxing.PrivateKey,
|
||||
Timeout: timeout,
|
||||
}, logger), nil
|
||||
}
|
||||
432
internal/infrastructure/external/rongxing/rongxing_service.go
vendored
Normal file
432
internal/infrastructure/external/rongxing/rongxing_service.go
vendored
Normal file
@@ -0,0 +1,432 @@
|
||||
package rongxing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hyapi-server/internal/shared/external_logger"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRequestTimeout = 10 * time.Second
|
||||
apiKeyLogin = "auth_login"
|
||||
pathAuthLogin = "/auth/login"
|
||||
headerDmsToken = "dms-token"
|
||||
)
|
||||
|
||||
// serviceConfig 戎行服务运行时配置
|
||||
type serviceConfig struct {
|
||||
BaseURL string
|
||||
Account string
|
||||
Password string
|
||||
AppID string
|
||||
PrivateKey string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// RongxingService 戎行数据源服务
|
||||
type RongxingService struct {
|
||||
config serviceConfig
|
||||
logger *external_logger.ExternalServiceLogger
|
||||
|
||||
tokenMu sync.RWMutex
|
||||
cachedToken string
|
||||
}
|
||||
|
||||
// apiResponse 戎行统一响应。code 可能是数字或字符串;扣费以 consumeFlag 为准。
|
||||
type apiResponse struct {
|
||||
Flag bool `json:"flag"`
|
||||
Code json.RawMessage `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
ConsumeFlag int `json:"consumeFlag"`
|
||||
}
|
||||
|
||||
func (r apiResponse) text() string {
|
||||
if r.Msg != "" {
|
||||
return r.Msg
|
||||
}
|
||||
return r.Message
|
||||
}
|
||||
|
||||
func (r apiResponse) code() string {
|
||||
return parseCode(r.Code)
|
||||
}
|
||||
|
||||
// NewRongxingService 创建戎行服务实例
|
||||
func NewRongxingService(cfg serviceConfig, logger *external_logger.ExternalServiceLogger) *RongxingService {
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = defaultRequestTimeout
|
||||
}
|
||||
cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
|
||||
return &RongxingService{config: cfg, logger: logger}
|
||||
}
|
||||
|
||||
// GetConfig 获取运行时配置
|
||||
func (s *RongxingService) GetConfig() serviceConfig {
|
||||
return s.config
|
||||
}
|
||||
|
||||
// CallAPI 通用业务接口调用。
|
||||
// apiPath 为相对路径(如 /third/loan/info360),reqData 为已组装好的请求体。
|
||||
// Token 获取与 Header 注入由服务内部处理;401/403 时自动刷新 Token 并重试一次。
|
||||
func (s *RongxingService) CallAPI(ctx context.Context, apiPath string, reqData map[string]interface{}) ([]byte, error) {
|
||||
apiKey := strings.Trim(apiPath, "/")
|
||||
|
||||
var transactionID string
|
||||
if id, ok := ctx.Value("transaction_id").(string); ok {
|
||||
transactionID = id
|
||||
}
|
||||
|
||||
if err := s.validateConfig(); err != nil {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
s.logError(transactionID, apiKey, "", err, nil)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(apiPath, "/") {
|
||||
apiPath = "/" + apiPath
|
||||
}
|
||||
requestURL := s.config.BaseURL + apiPath
|
||||
|
||||
bodyBytes, err := json.Marshal(reqData)
|
||||
if err != nil {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
s.logError(transactionID, apiKey, "", err, reqData)
|
||||
return nil, err
|
||||
}
|
||||
bodyStr := string(bodyBytes)
|
||||
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
startTime := time.Now()
|
||||
|
||||
token, err := s.getToken(ctx, transactionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
headerDmsToken: token,
|
||||
}
|
||||
curlCmd := generateCurlCommand(http.MethodPost, requestURL, headers, bodyStr)
|
||||
s.logCurl(transactionID, apiKey, requestURL, curlCmd)
|
||||
|
||||
if s.logger != nil {
|
||||
s.logger.LogRequest("", transactionID, apiKey, requestURL)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewBuffer(bodyBytes))
|
||||
if err != nil {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, "")
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set(headerDmsToken, token)
|
||||
|
||||
respBody, statusCode, err := s.doHTTP(req)
|
||||
if err != nil {
|
||||
err = errors.Join(ErrDatasource, err)
|
||||
s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, "")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
duration := time.Since(startTime)
|
||||
respStr := string(respBody)
|
||||
|
||||
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
|
||||
s.clearToken()
|
||||
if attempt == 0 {
|
||||
continue
|
||||
}
|
||||
err = errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码 %d", statusCode))
|
||||
s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, respStr)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if statusCode != http.StatusOK {
|
||||
err = errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码 %d", statusCode))
|
||||
s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, respStr)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp apiResponse
|
||||
if err := json.Unmarshal(respBody, &resp); err != nil {
|
||||
err = errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %w, body=%s", err, respStr))
|
||||
s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, respStr)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
code := resp.code()
|
||||
payload := extractBusinessPayload(resp.Data)
|
||||
s.logResponse(transactionID, apiKey, statusCode, duration, respStr)
|
||||
|
||||
// 扣费只看 consumeFlag:1 扣费(按成功返回),0 不扣费
|
||||
if IsBillable(resp.ConsumeFlag) {
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
sentinel := MapNonBillableToErr(code)
|
||||
err = errors.Join(sentinel, NewRongxingError(code, resp.text()))
|
||||
if !errors.Is(sentinel, ErrNotFound) {
|
||||
s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, respStr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, errors.Join(ErrDatasource, errors.New("请求失败"))
|
||||
}
|
||||
|
||||
func (s *RongxingService) getToken(ctx context.Context, transactionID string) (string, error) {
|
||||
s.tokenMu.RLock()
|
||||
token := s.cachedToken
|
||||
s.tokenMu.RUnlock()
|
||||
if token != "" {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
s.tokenMu.Lock()
|
||||
defer s.tokenMu.Unlock()
|
||||
if s.cachedToken != "" {
|
||||
return s.cachedToken, nil
|
||||
}
|
||||
|
||||
token, err := s.login(ctx, transactionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.cachedToken = token
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *RongxingService) clearToken() {
|
||||
s.tokenMu.Lock()
|
||||
s.cachedToken = ""
|
||||
s.tokenMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *RongxingService) login(ctx context.Context, transactionID string) (string, error) {
|
||||
privateKey, err := ParsePrivateKey(s.config.PrivateKey)
|
||||
if err != nil {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
s.logError(transactionID, apiKeyLogin, "", err, nil)
|
||||
return "", err
|
||||
}
|
||||
|
||||
passwordB64 := EncodePasswordBase64(s.config.Password)
|
||||
timestamp := time.Now().UnixMilli()
|
||||
signParams := map[string]interface{}{
|
||||
"account": s.config.Account,
|
||||
"password": passwordB64,
|
||||
"appId": s.config.AppID,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
content := BuildSignContent(signParams)
|
||||
sign, err := SignSHA256WithRSA(content, privateKey)
|
||||
if err != nil {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
s.logError(transactionID, apiKeyLogin, "", err, nil)
|
||||
return "", err
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"account": s.config.Account,
|
||||
"password": passwordB64,
|
||||
"appId": s.config.AppID,
|
||||
"timestamp": timestamp,
|
||||
"sign": sign,
|
||||
}
|
||||
requestURL := s.config.BaseURL + pathAuthLogin
|
||||
|
||||
bodyBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
s.logError(transactionID, apiKeyLogin, "", err, map[string]interface{}{
|
||||
"account": s.config.Account,
|
||||
"appId": s.config.AppID,
|
||||
})
|
||||
return "", err
|
||||
}
|
||||
bodyStr := string(bodyBytes)
|
||||
|
||||
headers := map[string]string{"Content-Type": "application/json"}
|
||||
curlCmd := generateCurlCommand(http.MethodPost, requestURL, headers, bodyStr)
|
||||
s.logCurl(transactionID, apiKeyLogin, requestURL, curlCmd)
|
||||
|
||||
if s.logger != nil {
|
||||
s.logger.LogRequest("", transactionID, apiKeyLogin, requestURL)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewBuffer(bodyBytes))
|
||||
if err != nil {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, "")
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
startTime := time.Now()
|
||||
respBody, statusCode, err := s.doHTTP(req)
|
||||
if err != nil {
|
||||
err = errors.Join(ErrDatasource, err)
|
||||
s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, "")
|
||||
return "", err
|
||||
}
|
||||
duration := time.Since(startTime)
|
||||
respStr := string(respBody)
|
||||
|
||||
if statusCode != http.StatusOK {
|
||||
err = errors.Join(ErrDatasource, fmt.Errorf("登录 HTTP状态码 %d, body=%s", statusCode, respStr))
|
||||
s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, respStr)
|
||||
return "", err
|
||||
}
|
||||
|
||||
var loginResp apiResponse
|
||||
if err := json.Unmarshal(respBody, &loginResp); err != nil {
|
||||
err = errors.Join(ErrSystem, fmt.Errorf("登录响应解析失败: %w, body=%s", err, respStr))
|
||||
s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, respStr)
|
||||
return "", err
|
||||
}
|
||||
|
||||
code := loginResp.code()
|
||||
if code != CodeSuccess {
|
||||
rxErr := NewRongxingError(code, loginResp.text())
|
||||
err = errors.Join(ErrDatasource, rxErr)
|
||||
s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, respStr)
|
||||
return "", err
|
||||
}
|
||||
|
||||
var token string
|
||||
if err := json.Unmarshal(loginResp.Data, &token); err != nil {
|
||||
err = errors.Join(ErrSystem, fmt.Errorf("登录响应 Token 解析失败: %w, body=%s", err, respStr))
|
||||
s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, respStr)
|
||||
return "", err
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
err = errors.Join(ErrSystem, fmt.Errorf("登录响应 Token 为空, body=%s", respStr))
|
||||
s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, respStr)
|
||||
return "", err
|
||||
}
|
||||
|
||||
s.logResponse(transactionID, apiKeyLogin, statusCode, duration, respStr)
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *RongxingService) doHTTP(req *http.Request) ([]byte, int, error) {
|
||||
client := &http.Client{Timeout: s.config.Timeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (s *RongxingService) validateConfig() error {
|
||||
if s.config.BaseURL == "" {
|
||||
return errors.New("戎行 url 未配置")
|
||||
}
|
||||
if strings.TrimSpace(s.config.Account) == "" {
|
||||
return errors.New("戎行 account 未配置")
|
||||
}
|
||||
if strings.TrimSpace(s.config.Password) == "" {
|
||||
return errors.New("戎行 password 未配置")
|
||||
}
|
||||
if strings.TrimSpace(s.config.AppID) == "" {
|
||||
return errors.New("戎行 app_id 未配置")
|
||||
}
|
||||
if strings.TrimSpace(s.config.PrivateKey) == "" {
|
||||
return errors.New("戎行 private_key 未配置")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCode(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
var n json.Number
|
||||
if err := json.Unmarshal(raw, &n); err == nil {
|
||||
return n.String()
|
||||
}
|
||||
return strings.Trim(string(raw), `"`)
|
||||
}
|
||||
|
||||
// extractBusinessPayload 提取对外返回的业务 data。
|
||||
// 若外层 data 内还嵌套 data(如 Info360),则取内层标签对象。
|
||||
func extractBusinessPayload(data json.RawMessage) []byte {
|
||||
if len(data) == 0 || string(data) == "null" {
|
||||
return []byte("{}")
|
||||
}
|
||||
|
||||
var wrap struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &wrap); err == nil &&
|
||||
len(wrap.Data) > 0 && string(wrap.Data) != "null" {
|
||||
return wrap.Data
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func (s *RongxingService) logCurl(transactionID, apiKey, url, curlCmd string) {
|
||||
if s.logger == nil {
|
||||
return
|
||||
}
|
||||
s.logger.LogInfo("rongxing curl",
|
||||
zap.String("transaction_id", transactionID),
|
||||
zap.String("api_code", apiKey),
|
||||
zap.String("url", url),
|
||||
zap.String("curl", curlCmd),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *RongxingService) logResponse(transactionID, apiKey string, statusCode int, duration time.Duration, responseBody string) {
|
||||
if s.logger == nil {
|
||||
return
|
||||
}
|
||||
s.logger.LogResponseWithBody("", transactionID, apiKey, statusCode, duration, responseBody)
|
||||
}
|
||||
|
||||
func (s *RongxingService) logError(transactionID, apiKey, requestID string, err error, payload interface{}) {
|
||||
if s.logger == nil {
|
||||
return
|
||||
}
|
||||
s.logger.LogError(requestID, transactionID, apiKey, err, payload)
|
||||
}
|
||||
|
||||
func (s *RongxingService) logErrorWithCurl(transactionID, apiKey string, err error, payload interface{}, curlCmd, respBody string) {
|
||||
if s.logger == nil {
|
||||
return
|
||||
}
|
||||
s.logger.LogErrorWithFields("rongxing API错误",
|
||||
zap.String("transaction_id", transactionID),
|
||||
zap.String("api_code", apiKey),
|
||||
zap.Error(err),
|
||||
zap.Any("params", payload),
|
||||
zap.String("curl", curlCmd),
|
||||
zap.String("response_body", respBody),
|
||||
)
|
||||
}
|
||||
75
internal/infrastructure/external/rongxing/status_codes.go
vendored
Normal file
75
internal/infrastructure/external/rongxing/status_codes.go
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
package rongxing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// 业务返回码(仅用于错误描述/分类,不作为扣费依据)
|
||||
const (
|
||||
CodeSuccess = "200" // 成功
|
||||
CodeNoData = "204" // 未查到数据
|
||||
CodeInternalErr = "503" // 内部服务错误
|
||||
)
|
||||
|
||||
// consumeFlag 扣费标记(对方各业务码场景下均可能返回)
|
||||
const (
|
||||
ConsumeFlagNoCharge = 0 // 不扣费
|
||||
ConsumeFlagCharge = 1 // 扣费
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDatasource = errors.New("数据源异常")
|
||||
ErrSystem = errors.New("系统异常")
|
||||
ErrNotFound = errors.New("查询为空")
|
||||
)
|
||||
|
||||
var codeMessage = map[string]string{
|
||||
CodeSuccess: "成功",
|
||||
CodeNoData: "未查到数据",
|
||||
CodeInternalErr: "内部服务错误",
|
||||
}
|
||||
|
||||
// GetCodeMessage 返回码描述
|
||||
func GetCodeMessage(code string) string {
|
||||
if msg, ok := codeMessage[code]; ok {
|
||||
return msg
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsBillable 按 consumeFlag 判断是否扣费(不以 code 为依据)
|
||||
func IsBillable(consumeFlag int) bool {
|
||||
return consumeFlag == ConsumeFlagCharge
|
||||
}
|
||||
|
||||
type rongxingError struct {
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *rongxingError) Error() string {
|
||||
return fmt.Sprintf("戎行返回错误,code: %s,message: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// NewRongxingError 创建戎行业务错误
|
||||
func NewRongxingError(code, message string) *rongxingError {
|
||||
if message == "" {
|
||||
if desc := GetCodeMessage(code); desc != "" {
|
||||
message = desc
|
||||
} else {
|
||||
message = "戎行返回未知错误"
|
||||
}
|
||||
}
|
||||
return &rongxingError{Code: code, Message: message}
|
||||
}
|
||||
|
||||
// MapNonBillableToErr 不扣费场景下,将返回码映射为内部哨兵错误
|
||||
func MapNonBillableToErr(code string) error {
|
||||
switch code {
|
||||
case CodeSuccess, CodeNoData:
|
||||
return ErrNotFound
|
||||
default:
|
||||
return ErrDatasource
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user