f
This commit is contained in:
79
internal/infrastructure/external/yuyuecha/crypto.go
vendored
Normal file
79
internal/infrastructure/external/yuyuecha/crypto.go
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
package yuyuecha
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// HashIDCard 将身份证号转为上游要求的身份摘要。
|
||||
// 规则:已是 32 位 MD5 / 64 位 SHA-256 十六进制则原样小写返回;否则对规范化身份证号做 MD5。
|
||||
func HashIDCard(idCard string) string {
|
||||
id := strings.TrimSpace(idCard)
|
||||
if isHexDigest(id, 32) || isHexDigest(id, 64) {
|
||||
return strings.ToLower(id)
|
||||
}
|
||||
// 末位校验码 X 统一大写后再摘要,与授权侧常见落库一致
|
||||
sum := md5.Sum([]byte(strings.ToUpper(id)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func isHexDigest(s string, length int) bool {
|
||||
if len(s) != length {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if !unicode.Is(unicode.ASCII_Hex_Digit, r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SignHeaders 按 OpenAPI 规范生成签名请求头
|
||||
// canonical:
|
||||
//
|
||||
// clientId={id}\ntimestamp={ms}\nnonce={hex}\nmethod={METHOD}\npath={path}\nbodySha256={sha256}
|
||||
func SignHeaders(clientID, clientSecret, method, path string, body []byte) (map[string]string, error) {
|
||||
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
nonce, err := randomNonce(16)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成 nonce 失败: %w", err)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(body)
|
||||
bodySHA256 := hex.EncodeToString(sum[:])
|
||||
|
||||
canonical := "clientId=" + clientID +
|
||||
"\ntimestamp=" + timestamp +
|
||||
"\nnonce=" + nonce +
|
||||
"\nmethod=" + method +
|
||||
"\npath=" + path +
|
||||
"\nbodySha256=" + bodySHA256
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(clientSecret))
|
||||
mac.Write([]byte(canonical))
|
||||
signature := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return map[string]string{
|
||||
"clientId": clientID,
|
||||
"timestamp": timestamp,
|
||||
"nonce": nonce,
|
||||
"signature": signature,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func randomNonce(nBytes int) (string, error) {
|
||||
buf := make([]byte, nBytes)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
33
internal/infrastructure/external/yuyuecha/curl_helper.go
vendored
Normal file
33
internal/infrastructure/external/yuyuecha/curl_helper.go
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
package yuyuecha
|
||||
|
||||
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(escapeShellSingleQuote(key))
|
||||
cmd.WriteString(": ")
|
||||
cmd.WriteString(escapeShellSingleQuote(value))
|
||||
cmd.WriteString("'")
|
||||
}
|
||||
|
||||
if body != "" {
|
||||
cmd.WriteString(" \\\n -d '")
|
||||
cmd.WriteString(escapeShellSingleQuote(body))
|
||||
cmd.WriteString("'")
|
||||
}
|
||||
|
||||
return cmd.String()
|
||||
}
|
||||
|
||||
func escapeShellSingleQuote(s string) string {
|
||||
return strings.ReplaceAll(s, "'", `'\''`)
|
||||
}
|
||||
27
internal/infrastructure/external/yuyuecha/yuyuecha_errors.go
vendored
Normal file
27
internal/infrastructure/external/yuyuecha/yuyuecha_errors.go
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
package yuyuecha
|
||||
|
||||
import "errors"
|
||||
|
||||
// 内部哨兵错误(供处理器 errors.Is 判断)
|
||||
var (
|
||||
ErrDatasource = errors.New("数据源异常")
|
||||
ErrSystem = errors.New("系统异常")
|
||||
ErrNotFound = errors.New("查询为空")
|
||||
)
|
||||
|
||||
// HTTP 状态码与文档 9.3 对齐
|
||||
const (
|
||||
HTTPBadRequest = 400 // BAD_REQUEST / INVALID_NONCE
|
||||
HTTPUnauthorized = 401 // 签名、时间戳或 clientId 错误
|
||||
HTTPPaymentRequired = 402 // INSUFFICIENT_BALANCE
|
||||
HTTPForbidden = 403 // CLIENT_DISABLED / TRIAL_EXPIRED
|
||||
HTTPConflict = 409 // NONCE_CONFLICT / REQUEST_PROCESSING
|
||||
HTTPTooManyRequests = 429 // QUOTA_EXCEEDED
|
||||
HTTPBadGateway = 502 // UPSTREAM_UNAVAILABLE
|
||||
)
|
||||
|
||||
// ResultCode 业务结果码(data[].resultCode)
|
||||
const (
|
||||
ResultCodeNoData = 0 // 完成查询但暂无数据(正式环境计费)
|
||||
ResultCodeHasData = 1 // 查询有数据(正式环境计费)
|
||||
)
|
||||
50
internal/infrastructure/external/yuyuecha/yuyuecha_factory.go
vendored
Normal file
50
internal/infrastructure/external/yuyuecha/yuyuecha_factory.go
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
package yuyuecha
|
||||
|
||||
import (
|
||||
"tyapi-server/internal/config"
|
||||
"tyapi-server/internal/shared/external_logger"
|
||||
)
|
||||
|
||||
// NewYuyuechaServiceWithConfig 使用配置创建愉悦查服务
|
||||
func NewYuyuechaServiceWithConfig(cfg *config.Config) (*YuyuechaService, error) {
|
||||
loggingConfig := external_logger.ExternalServiceLoggingConfig{
|
||||
Enabled: cfg.Yuyuecha.Logging.Enabled,
|
||||
LogDir: cfg.Yuyuecha.Logging.LogDir,
|
||||
ServiceName: "yuyuecha",
|
||||
UseDaily: cfg.Yuyuecha.Logging.UseDaily,
|
||||
EnableLevelSeparation: cfg.Yuyuecha.Logging.EnableLevelSeparation,
|
||||
LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig),
|
||||
}
|
||||
for k, v := range cfg.Yuyuecha.Logging.LevelConfigs {
|
||||
loggingConfig.LevelConfigs[k] = external_logger.ExternalServiceLevelFileConfig{
|
||||
MaxSize: v.MaxSize,
|
||||
MaxBackups: v.MaxBackups,
|
||||
MaxAge: v.MaxAge,
|
||||
Compress: v.Compress,
|
||||
}
|
||||
}
|
||||
|
||||
var logger *external_logger.ExternalServiceLogger
|
||||
var err error
|
||||
if loggingConfig.Enabled {
|
||||
logger, err = external_logger.NewExternalServiceLogger(loggingConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
timeout := defaultRequestTimeout
|
||||
if cfg.Yuyuecha.Timeout > 0 {
|
||||
timeout = cfg.Yuyuecha.Timeout
|
||||
}
|
||||
|
||||
serviceCfg := ServiceConfig{
|
||||
BaseURL: cfg.Yuyuecha.BaseURL,
|
||||
ClientID: cfg.Yuyuecha.ClientID,
|
||||
ClientSecret: cfg.Yuyuecha.ClientSecret,
|
||||
Timeout: timeout,
|
||||
Debug: cfg.App.IsDevelopment(),
|
||||
}
|
||||
|
||||
return NewYuyuechaService(serviceCfg, logger), nil
|
||||
}
|
||||
346
internal/infrastructure/external/yuyuecha/yuyuecha_service.go
vendored
Normal file
346
internal/infrastructure/external/yuyuecha/yuyuecha_service.go
vendored
Normal file
@@ -0,0 +1,346 @@
|
||||
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)),
|
||||
)
|
||||
}
|
||||
95
internal/infrastructure/external/yuyuecha/yuyuecha_service_test.go
vendored
Normal file
95
internal/infrastructure/external/yuyuecha/yuyuecha_service_test.go
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
package yuyuecha
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildOpenAPIPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"a01/person-company-relations", "/openapi/v1/a01/person-company-relations"},
|
||||
{"/a01/person-company-relations", "/openapi/v1/a01/person-company-relations"},
|
||||
{"openapi/v1/a01/person-company-relations", "/openapi/v1/a01/person-company-relations"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := buildOpenAPIPath(tt.in); got != tt.want {
|
||||
t.Fatalf("buildOpenAPIPath(%q)=%q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashIDCard(t *testing.T) {
|
||||
got := HashIDCard("450322197603171539")
|
||||
if len(got) != 32 {
|
||||
t.Fatalf("md5 digest length=%d, want 32", len(got))
|
||||
}
|
||||
if got != strings.ToLower(got) {
|
||||
t.Fatalf("digest should be lowercase: %s", got)
|
||||
}
|
||||
if pass := HashIDCard(got); pass != got {
|
||||
t.Fatalf("already-hashed pass-through: got %q want %q", pass, got)
|
||||
}
|
||||
if pass := HashIDCard(strings.ToUpper(got)); pass != got {
|
||||
t.Fatalf("uppercase digest normalize: got %q want %q", pass, got)
|
||||
}
|
||||
}
|
||||
|
||||
func sandboxService() *YuyuechaService {
|
||||
return NewYuyuechaService(ServiceConfig{
|
||||
BaseURL: "https://sandbox-api.yuyuecha.com",
|
||||
ClientID: "sandbox-haiyu-20260724",
|
||||
ClientSecret: "HuidklNz6RnSfCjn6MIcUW_Rj8trLljdN4v6FUs3NsY",
|
||||
Timeout: defaultRequestTimeout,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func TestSandboxPersonCompanyRelations(t *testing.T) {
|
||||
svc := sandboxService()
|
||||
|
||||
result, err := svc.CallAPI(context.Background(), "a01/person-company-relations", map[string]interface{}{
|
||||
"name": "骆炳荣",
|
||||
"idCard": HashIDCard("450322197603171539"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CallAPI failed: %v", err)
|
||||
}
|
||||
if result == nil || len(result.Data) == 0 {
|
||||
t.Fatal("expected non-empty data")
|
||||
}
|
||||
// 沙箱不计费
|
||||
if result.Billable {
|
||||
t.Fatalf("sandbox should not be billable, got chargeFen=%d", result.ChargeFen)
|
||||
}
|
||||
t.Logf("requestId=%s sandbox=%v billable=%v data=%s", result.RequestID, result.Sandbox, result.Billable, string(result.Data))
|
||||
}
|
||||
|
||||
// TestSandboxGetAccount 查询账户汇总:GET,请求体为空
|
||||
func TestSandboxGetAccount(t *testing.T) {
|
||||
svc := sandboxService()
|
||||
|
||||
info, err := svc.GetAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccount failed: %v", err)
|
||||
}
|
||||
if info == nil {
|
||||
t.Fatal("expected account info")
|
||||
}
|
||||
if info.ClientID != "sandbox-haiyu-20260724" {
|
||||
t.Fatalf("clientId=%q", info.ClientID)
|
||||
}
|
||||
if info.Mode != "sandbox" {
|
||||
t.Fatalf("mode=%q", info.Mode)
|
||||
}
|
||||
if !info.Enabled {
|
||||
t.Fatal("expected enabled=true")
|
||||
}
|
||||
if info.TotalLimit <= 0 {
|
||||
t.Fatalf("totalLimit=%d", info.TotalLimit)
|
||||
}
|
||||
t.Logf("account: clientId=%s mode=%s totalLimit=%d trialRemaining=%d trialEndsAt=%s totalCalls=%d",
|
||||
info.ClientID, info.Mode, info.TotalLimit, info.TrialRemaining, info.TrialEndsAt, info.TotalCalls)
|
||||
}
|
||||
48
internal/infrastructure/external/yuyuecha/yuyuecha_types.go
vendored
Normal file
48
internal/infrastructure/external/yuyuecha/yuyuecha_types.go
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
package yuyuecha
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// APIResponse 愉悦查 OpenAPI 业务响应
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
RequestID string `json:"requestId"`
|
||||
Sandbox bool `json:"sandbox"`
|
||||
Status int `json:"status"`
|
||||
ErrCode *string `json:"errCode"`
|
||||
ErrMessage *string `json:"errMessage"`
|
||||
Billing *BillingInfo `json:"billing"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// BillingInfo 响应体内计费信息(与 X-Billable / X-Charge-Fen 头对齐)
|
||||
type BillingInfo struct {
|
||||
Billable bool `json:"billable"`
|
||||
ChargeFen int `json:"chargeFen"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// CallResult 调用结果(含上游计费信号,供处理器决定是否平台扣费)
|
||||
type CallResult struct {
|
||||
Data []byte
|
||||
Billable bool
|
||||
ChargeFen int
|
||||
RequestID string
|
||||
Sandbox bool
|
||||
}
|
||||
|
||||
// AccountInfo 账户汇总(GET /openapi/v1/account)
|
||||
type AccountInfo struct {
|
||||
ClientID string `json:"clientId"`
|
||||
Mode string `json:"mode"`
|
||||
Enabled bool `json:"enabled"`
|
||||
TotalLimit int `json:"totalLimit"`
|
||||
HitLimit int `json:"hitLimit"`
|
||||
TotalCalls int `json:"totalCalls"`
|
||||
HitCalls int `json:"hitCalls"`
|
||||
PriceFen int `json:"priceFen"`
|
||||
BalanceFen int `json:"balanceFen"`
|
||||
ReservedFen int `json:"reservedFen"`
|
||||
AvailableFen int `json:"availableFen"`
|
||||
TrialEndsAt string `json:"trialEndsAt"`
|
||||
TrialRemaining int `json:"trialRemaining"`
|
||||
}
|
||||
Reference in New Issue
Block a user