This commit is contained in:
2026-07-24 23:27:17 +08:00
parent b935c557b9
commit cc2513652e
17 changed files with 1600 additions and 7 deletions

View File

@@ -47,6 +47,7 @@ type Config struct {
Huibo HuiboConfig `mapstructure:"huibo"`
Nuoer NuoerConfig `mapstructure:"nuoer"`
Haiyuapi HaiyuapiConfig `mapstructure:"haiyuapi"`
Yuyuecha YuyuechaConfig `mapstructure:"yuyuecha"`
QueryWhitelist QueryWhitelistConfig `mapstructure:"query_whitelist"`
}
@@ -775,6 +776,34 @@ type HaiyuapiLevelFileConfig struct {
Compress bool `mapstructure:"compress"`
}
// YuyuechaConfig 愉悦查 OpenAPI 配置
type YuyuechaConfig struct {
BaseURL string `mapstructure:"base_url"`
ClientID string `mapstructure:"client_id"`
ClientSecret string `mapstructure:"client_secret"`
Timeout time.Duration `mapstructure:"timeout"`
Logging YuyuechaLoggingConfig `mapstructure:"logging"`
}
// YuyuechaLoggingConfig 愉悦查日志配置
type YuyuechaLoggingConfig struct {
Enabled bool `mapstructure:"enabled"`
LogDir string `mapstructure:"log_dir"`
ServiceName string `mapstructure:"service_name"`
UseDaily bool `mapstructure:"use_daily"`
EnableLevelSeparation bool `mapstructure:"enable_level_separation"`
LevelConfigs map[string]YuyuechaLevelFileConfig `mapstructure:"level_configs"`
}
// YuyuechaLevelFileConfig 愉悦查级别文件配置
type YuyuechaLevelFileConfig struct {
MaxSize int `mapstructure:"max_size"`
MaxBackups int `mapstructure:"max_backups"`
MaxAge int `mapstructure:"max_age"`
Compress bool `mapstructure:"compress"`
}
// DomainConfig 域名配置
type DomainConfig struct {
API string `mapstructure:"api"` // API域名

View File

@@ -56,6 +56,7 @@ import (
"tyapi-server/internal/infrastructure/external/westdex"
"tyapi-server/internal/infrastructure/external/xingwei"
"tyapi-server/internal/infrastructure/external/yushan"
"tyapi-server/internal/infrastructure/external/yuyuecha"
"tyapi-server/internal/infrastructure/external/zhicha"
"tyapi-server/internal/infrastructure/http/handlers"
"tyapi-server/internal/infrastructure/http/routes"
@@ -415,6 +416,10 @@ func NewContainer() *Container {
func(cfg *config.Config) (*haiyuapi.HaiyuapiService, error) {
return haiyuapi.NewHaiyuapiServiceWithConfig(cfg)
},
// YuyuechaService - 愉悦查 OpenAPI
func(cfg *config.Config) (*yuyuecha.YuyuechaService, error) {
return yuyuecha.NewYuyuechaServiceWithConfig(cfg)
},
func(cfg *config.Config) *yushan.YushanService {
return yushan.NewYushanService(
cfg.Yushan.URL,

View File

@@ -1313,3 +1313,10 @@ type IVYZHY87Req struct {
IDCard string `json:"id_card" validate:"required,validIDCard"`
Name string `json:"name" validate:"required,min=1,validName"`
}
// QYGL101AReq 愉悦查人企关系查询OpenAPI A01
// 平台入参为明文身份证;调用上游前会转为 32 位 MD5兼容 64 位 SHA-256身份摘要
type QYGL101AReq struct {
Name string `json:"name" validate:"required,min=1,validName"`
IDCard string `json:"id_card" validate:"required,min=15,max=64"`
}

View File

@@ -32,6 +32,7 @@ import (
"tyapi-server/internal/infrastructure/external/westdex"
"tyapi-server/internal/infrastructure/external/xingwei"
"tyapi-server/internal/infrastructure/external/yushan"
"tyapi-server/internal/infrastructure/external/yuyuecha"
"tyapi-server/internal/infrastructure/external/zhicha"
"tyapi-server/internal/shared/interfaces"
)
@@ -72,6 +73,7 @@ func NewApiRequestService(
huiboService *huibo.HuiboService,
nuoerService *nuoer.NuoerService,
haiyuapiService *haiyuapi.HaiyuapiService,
yuyuechaService *yuyuecha.YuyuechaService,
validator interfaces.RequestValidator,
productManagementService *services.ProductManagementService,
cfg *appconfig.Config,
@@ -90,6 +92,7 @@ func NewApiRequestService(
huiboService,
nuoerService,
haiyuapiService,
yuyuechaService,
validator,
productManagementService,
cfg,
@@ -113,6 +116,7 @@ func NewApiRequestServiceWithRepos(
huiboService *huibo.HuiboService,
nuoerService *nuoer.NuoerService,
haiyuapiService *haiyuapi.HaiyuapiService,
yuyuechaService *yuyuecha.YuyuechaService,
validator interfaces.RequestValidator,
productManagementService *services.ProductManagementService,
cfg *appconfig.Config,
@@ -142,6 +146,7 @@ func NewApiRequestServiceWithRepos(
huiboService,
nuoerService,
haiyuapiService,
yuyuechaService,
validator,
combService,
reportRepo,
@@ -240,10 +245,10 @@ func registerAllProcessors(combService *comb.CombService) {
"JRZQV7MD": jrzq.ProcessJRZQV7MDRequest, // 特殊名单
"JRZQ5T8S": jrzq.ProcessJRZQ5T8SRequest, // 借贷意向验证V2
"JRZQT57Z": jrzq.ProcessJRZQT57ZRequest, // 团伙欺诈风险识别
"JRZQK9P2": jrzq.ProcessJRZQK9P2Request, // 洞侦1.0(海宇,查空计费)
"JRZQR4N7": jrzq.ProcessJRZQR4N7Request, // 借贷意向验证3.0(海宇,查空计费)
"JRZQP8D2": jrzq.ProcessJRZQP8D2Request, // 全景雷达BH海宇查空不计费
"JRZQ0OO1": jrzq.ProcessJRZQ0OO1Request, // 戎行贷后信息 Info360海宇
"JRZQK9P2": jrzq.ProcessJRZQK9P2Request, // 洞侦1.0(海宇,查空计费)
"JRZQR4N7": jrzq.ProcessJRZQR4N7Request, // 借贷意向验证3.0(海宇,查空计费)
"JRZQP8D2": jrzq.ProcessJRZQP8D2Request, // 全景雷达BH海宇查空不计费
"JRZQ0OO1": jrzq.ProcessJRZQ0OO1Request, // 戎行贷后信息 Info360海宇
// QYGL系列处理器
"QYGL8261": qygl.ProcessQYGL8261Request,
@@ -284,6 +289,7 @@ func registerAllProcessors(combService *comb.CombService) {
"QYGL2YSB": qygl.ProcessQYGL2YSBRequest, //企业二要素认证shumai
"QYGLDG77": qygl.ProcessQYGLDG77Request, //企业对公打款认证shumai
"QYGLBH7Y": qygl.ProcessQYGLBH7YRequest, //企业涉诉案件查询海宇
"QYGL101A": qygl.ProcessQYGL101ARequest, //企业关系查询(北京正信 A01示例
// YYSY系列处理器
"YYSY35TA": yysy.ProcessYYSY35TARequest, //运营商归属地数卖
@@ -419,7 +425,7 @@ func registerAllProcessors(combService *comb.CombService) {
"QYGLVR76": qygl.ProcessQYGLVR76Request, //名下企业诺尔
"QCXG1S2L": qcxg.ProcessQCXG1S2LRequest, //车辆车五项信息核验V2
"QCXGA8V3": qcxg.ProcessQCXGA8V3Request, //全国车辆配置查验(车五项+使用性质+承保)
"QCXGCP77": qcxg.ProcessQCXGCP77Request, //全国车辆配置查验(车辆详情)
"QCXGCP77": qcxg.ProcessQCXGCP77Request, //全国车辆配置查验(车辆详情)
"QCXG6U5G": qcxg.ProcessQCXG6U5GRequest, //名下车辆核验
// DWBG系列处理器 - 多维报告
"DWBG6A2C": dwbg.ProcessDWBG6A2CRequest,

View File

@@ -352,6 +352,7 @@ func (s *FormConfigServiceImpl) getDTOStruct(ctx context.Context, apiCode string
"QCXG6U5G": &dto.QCXG6U5GReq{}, //名下车辆核验
"IVYZ2MA8": &dto.IVYZ2MA8Req{}, //学历核验
"IVYZHY87": &dto.IVYZHY87Req{}, //婚姻状态查询
"QYGL101A": &dto.QYGL101AReq{}, //企业关系查询(北京正信 A01示例
}
// 优先返回已配置的DTO

View File

@@ -17,6 +17,7 @@ import (
"tyapi-server/internal/infrastructure/external/westdex"
"tyapi-server/internal/infrastructure/external/xingwei"
"tyapi-server/internal/infrastructure/external/yushan"
"tyapi-server/internal/infrastructure/external/yuyuecha"
"tyapi-server/internal/infrastructure/external/zhicha"
"tyapi-server/internal/shared/interfaces"
)
@@ -46,6 +47,7 @@ type ProcessorDependencies struct {
HuiboService *huibo.HuiboService
NuoerService *nuoer.NuoerService
HaiyuapiService *haiyuapi.HaiyuapiService
YuyuechaService *yuyuecha.YuyuechaService
Validator interfaces.RequestValidator
CombService CombServiceInterface // Changed to interface to break import cycle
Options *commands.ApiCallOptions // 添加Options支持
@@ -76,6 +78,7 @@ func NewProcessorDependencies(
huiboService *huibo.HuiboService,
nuoerService *nuoer.NuoerService,
haiyuapiService *haiyuapi.HaiyuapiService,
yuyuechaService *yuyuecha.YuyuechaService,
validator interfaces.RequestValidator,
combService CombServiceInterface, // Changed to interface
reportRepo repositories.ReportRepository,
@@ -96,6 +99,7 @@ func NewProcessorDependencies(
HuiboService: huiboService,
NuoerService: nuoerService,
HaiyuapiService: haiyuapiService,
YuyuechaService: yuyuechaService,
Validator: validator,
CombService: combService,
Options: nil, // 初始化为nil在调用时设置

View File

@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
"tyapi-server/internal/domains/api/dto"
"tyapi-server/internal/domains/api/services/processors"
"tyapi-server/internal/infrastructure/external/nuoer"

View File

@@ -0,0 +1,55 @@
package qygl
import (
"context"
"encoding/json"
"errors"
"tyapi-server/internal/domains/api/dto"
"tyapi-server/internal/domains/api/services/processors"
"tyapi-server/internal/infrastructure/external/yuyuecha"
)
// ProcessQYGL101ARequest QYGL101A - 企业关系查询(愉悦查 A01 示例)
//
// 扣费策略(对齐上游 9.2 / 9.3
// - HTTP 200 + success按 X-Billable / billing.billable 决定是否平台扣费
// - 沙箱 / 幂等重放通常 billable=false → SuccessNoBillError返回数据但不扣费
// - 正式环境 resultCode=0/1 且 billable=true → 正常成功并扣费
// - 4xx/5xx / success=false → 哨兵错误,不扣费
func ProcessQYGL101ARequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.QYGL101AReq
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
if deps.YuyuechaService == nil {
return nil, errors.Join(processors.ErrSystem, errors.New("北京正信服务未初始化"))
}
// apiPath 入参CallAPI 内拼接为 /openapi/v1/a01/person-company-relations
// 上游 idCard 要求 32 位 MD5兼容 64 位 SHA-256平台侧收明文后在此做摘要
result, err := deps.YuyuechaService.CallAPI(ctx, "a01/person-company-relations", map[string]interface{}{
"name": paramsDto.Name,
"idCard": yuyuecha.HashIDCard(paramsDto.IDCard),
})
if err != nil {
if errors.Is(err, yuyuecha.ErrNotFound) {
return nil, errors.Join(processors.ErrNotFound, err)
}
if errors.Is(err, yuyuecha.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
}
return nil, errors.Join(processors.ErrSystem, err)
}
if !result.Billable {
return nil, &processors.SuccessNoBillError{Response: result.Data}
}
return result.Data, nil
}

View 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
}

View 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, "'", `'\''`)
}

View 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 // 查询有数据(正式环境计费)
)

View 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
}

View 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 调用愉悦查业务 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
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)),
)
}

View 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)
}

View 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"`
}