fadd
This commit is contained in:
@@ -31,8 +31,6 @@ func ProcessQYGL101ARequest(ctx context.Context, params []byte, deps *processors
|
||||
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),
|
||||
@@ -37,15 +37,20 @@ func isHexDigest(s string, length int) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// SignHeaders 按 OpenAPI 规范生成签名请求头
|
||||
// SignHeaders 按 OpenAPI 规范生成签名请求头。
|
||||
// nonce 传入本地请求流水 ID(与日志 request_id 一致),便于上下游按同一 ID 排查;为空时回退随机生成。
|
||||
// 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) {
|
||||
// clientId={id}\ntimestamp={ms}\nnonce={nonce}\nmethod={METHOD}\npath={path}\nbodySha256={sha256}
|
||||
func SignHeaders(clientID, clientSecret, method, path string, body []byte, nonce string) (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)
|
||||
nonce = strings.TrimSpace(nonce)
|
||||
if nonce == "" {
|
||||
var err error
|
||||
nonce, err = randomNonce(16)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成 nonce 失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(body)
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
package yuyuecha
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 签名头固定顺序,便于阅读与对比
|
||||
var preferredHeaderOrder = []string{
|
||||
"Content-Type",
|
||||
"clientId",
|
||||
"timestamp",
|
||||
"nonce",
|
||||
"signature",
|
||||
}
|
||||
|
||||
// generateCurlCommand 生成可复现的 curl 命令,便于数据源排查
|
||||
func generateCurlCommand(method, url string, headers map[string]string, body string) string {
|
||||
@@ -11,11 +23,11 @@ func generateCurlCommand(method, url string, headers map[string]string, body str
|
||||
cmd.WriteString(url)
|
||||
cmd.WriteString("'")
|
||||
|
||||
for key, value := range headers {
|
||||
for _, key := range orderedHeaderKeys(headers) {
|
||||
cmd.WriteString(" \\\n -H '")
|
||||
cmd.WriteString(escapeShellSingleQuote(key))
|
||||
cmd.WriteString(": ")
|
||||
cmd.WriteString(escapeShellSingleQuote(value))
|
||||
cmd.WriteString(escapeShellSingleQuote(headers[key]))
|
||||
cmd.WriteString("'")
|
||||
}
|
||||
|
||||
@@ -28,6 +40,25 @@ func generateCurlCommand(method, url string, headers map[string]string, body str
|
||||
return cmd.String()
|
||||
}
|
||||
|
||||
func orderedHeaderKeys(headers map[string]string) []string {
|
||||
seen := make(map[string]struct{}, len(headers))
|
||||
keys := make([]string, 0, len(headers))
|
||||
for _, key := range preferredHeaderOrder {
|
||||
if _, ok := headers[key]; ok {
|
||||
keys = append(keys, key)
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
rest := make([]string, 0, len(headers))
|
||||
for key := range headers {
|
||||
if _, ok := seen[key]; !ok {
|
||||
rest = append(rest, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(rest)
|
||||
return append(keys, rest...)
|
||||
}
|
||||
|
||||
func escapeShellSingleQuote(s string) string {
|
||||
return strings.ReplaceAll(s, "'", `'\''`)
|
||||
}
|
||||
|
||||
107
internal/infrastructure/external/yuyuecha/debug_exchange.go
vendored
Normal file
107
internal/infrastructure/external/yuyuecha/debug_exchange.go
vendored
Normal file
@@ -0,0 +1,107 @@
|
||||
package yuyuecha
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const debugExchangeFileName = "dev_curl.log"
|
||||
|
||||
var debugExchangeMu sync.Mutex
|
||||
|
||||
type debugExchangeRecord struct {
|
||||
RequestID string
|
||||
TransactionID string
|
||||
API string
|
||||
Status int
|
||||
Duration time.Duration
|
||||
Curl string
|
||||
ResponseBody []byte
|
||||
Error string
|
||||
}
|
||||
|
||||
// writeDebugExchange 开发环境写入单一排查文件:一次调用一条记录,含 curl + response
|
||||
func writeDebugExchange(logDir string, rec debugExchangeRecord) {
|
||||
if strings.TrimSpace(logDir) == "" || strings.TrimSpace(rec.Curl) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(strings.Repeat("=", 72))
|
||||
b.WriteByte('\n')
|
||||
b.WriteString("time: ")
|
||||
b.WriteString(time.Now().Format("2006-01-02 15:04:05.000"))
|
||||
b.WriteByte('\n')
|
||||
b.WriteString("request_id: ")
|
||||
b.WriteString(rec.RequestID)
|
||||
b.WriteByte('\n')
|
||||
// 与上游签名头 nonce 一致,对外排查只提供 request_id 即可
|
||||
b.WriteString("nonce: ")
|
||||
b.WriteString(rec.RequestID)
|
||||
b.WriteByte('\n')
|
||||
if rec.TransactionID != "" {
|
||||
b.WriteString("transaction_id: ")
|
||||
b.WriteString(rec.TransactionID)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString("api: ")
|
||||
b.WriteString(rec.API)
|
||||
b.WriteByte('\n')
|
||||
if rec.Status > 0 {
|
||||
b.WriteString(fmt.Sprintf("status: %d\n", rec.Status))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("duration: %s\n", rec.Duration.Round(time.Millisecond)))
|
||||
if rec.Error != "" {
|
||||
b.WriteString("error: ")
|
||||
b.WriteString(rec.Error)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
|
||||
b.WriteByte('\n')
|
||||
b.WriteString("[curl]\n")
|
||||
b.WriteString(rec.Curl)
|
||||
b.WriteByte('\n')
|
||||
|
||||
b.WriteByte('\n')
|
||||
b.WriteString("[response]\n")
|
||||
if len(rec.ResponseBody) == 0 {
|
||||
b.WriteString("(empty)\n")
|
||||
} else {
|
||||
b.WriteString(prettyJSONForLog(rec.ResponseBody))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString(strings.Repeat("=", 72))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
debugExchangeMu.Lock()
|
||||
defer debugExchangeMu.Unlock()
|
||||
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
return
|
||||
}
|
||||
path := filepath.Join(logDir, debugExchangeFileName)
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
_, _ = f.WriteString(b.String())
|
||||
}
|
||||
|
||||
func prettyJSONForLog(raw []byte) string {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) == 0 {
|
||||
return "(empty)"
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := json.Indent(&buf, trimmed, "", " "); err != nil {
|
||||
return string(raw)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package yuyuecha
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"tyapi-server/internal/config"
|
||||
"tyapi-server/internal/shared/external_logger"
|
||||
)
|
||||
@@ -38,12 +40,18 @@ func NewYuyuechaServiceWithConfig(cfg *config.Config) (*YuyuechaService, error)
|
||||
timeout = cfg.Yuyuecha.Timeout
|
||||
}
|
||||
|
||||
logDir := cfg.Yuyuecha.Logging.LogDir
|
||||
if logDir == "" {
|
||||
logDir = "logs/external_services"
|
||||
}
|
||||
|
||||
serviceCfg := ServiceConfig{
|
||||
BaseURL: cfg.Yuyuecha.BaseURL,
|
||||
ClientID: cfg.Yuyuecha.ClientID,
|
||||
ClientSecret: cfg.Yuyuecha.ClientSecret,
|
||||
Timeout: timeout,
|
||||
Debug: cfg.App.IsDevelopment(),
|
||||
DebugLogDir: filepath.Join(logDir, "yuyuecha"),
|
||||
}
|
||||
|
||||
return NewYuyuechaService(serviceCfg, logger), nil
|
||||
|
||||
@@ -14,8 +14,6 @@ import (
|
||||
"time"
|
||||
|
||||
"tyapi-server/internal/shared/external_logger"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -39,8 +37,10 @@ type ServiceConfig struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
Timeout time.Duration
|
||||
// Debug 开发环境开启时输出可复现 curl 与完整响应体,便于数据源排查
|
||||
// Debug 开发环境开启时写入单一排查文件(curl + response),不重复打 request/response 日志
|
||||
Debug bool
|
||||
// DebugLogDir 排查日志目录,文件名为 dev_curl.log
|
||||
DebugLogDir string
|
||||
}
|
||||
|
||||
// YuyuechaService 愉悦查 OpenAPI 客户端
|
||||
@@ -127,11 +127,13 @@ func (s *YuyuechaService) doRequest(ctx context.Context, method, path string, bo
|
||||
baseURL := strings.TrimSuffix(s.config.BaseURL, "/")
|
||||
reqURL := baseURL + path
|
||||
|
||||
if s.logger != nil {
|
||||
// dev 排查走单一文件,避免 request/response/info 重复打点
|
||||
if !s.config.Debug && s.logger != nil {
|
||||
s.logger.LogRequest(requestID, transactionID, path, reqURL)
|
||||
}
|
||||
|
||||
signHeaders, err := SignHeaders(s.config.ClientID, s.config.ClientSecret, method, path, bodyBytes)
|
||||
// nonce 与本地 request_id 保持一致,后续仅凭 request_id 即可对接上游排查
|
||||
signHeaders, err := SignHeaders(s.config.ClientID, s.config.ClientSecret, method, path, bodyBytes, requestID)
|
||||
if err != nil {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
s.logError(requestID, transactionID, path, err, map[string]interface{}{"request_params": logParams})
|
||||
@@ -172,11 +174,8 @@ func (s *YuyuechaService) doRequest(ctx context.Context, method, path string, bo
|
||||
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)
|
||||
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})
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -185,45 +184,34 @@ func (s *YuyuechaService) doRequest(ctx context.Context, method, path string, bo
|
||||
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)
|
||||
s.logDebugExchange(requestID, transactionID, path, curlCmd, resp.StatusCode, duration, nil, err.Error())
|
||||
s.logError(requestID, transactionID, path, err, map[string]interface{}{"request_params": logParams})
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logDebugCurlAndResponse(requestID, transactionID, path, curlCmd, resp.StatusCode, duration, raw)
|
||||
s.logDebugExchange(requestID, transactionID, path, curlCmd, resp.StatusCode, duration, raw, "")
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
mapped := mapHTTPStatusError(resp.StatusCode, raw)
|
||||
errParams := map[string]interface{}{
|
||||
s.logError(requestID, transactionID, path, mapped, 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 {
|
||||
if !s.config.Debug && 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{}{
|
||||
s.logError(requestID, transactionID, path, parseErr, 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
|
||||
}
|
||||
|
||||
@@ -235,14 +223,10 @@ func (s *YuyuechaService) doRequest(ctx context.Context, method, path string, bo
|
||||
msg = *outer.ErrCode
|
||||
}
|
||||
mapped := errors.Join(ErrDatasource, errors.New(msg))
|
||||
errParams := map[string]interface{}{
|
||||
s.logError(requestID, transactionID, path, mapped, 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
|
||||
}
|
||||
|
||||
@@ -329,18 +313,18 @@ func (s *YuyuechaService) logError(requestID, transactionID, apiCode string, err
|
||||
}
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
func (s *YuyuechaService) logDebugExchange(requestID, transactionID, apiCode, curlCmd string, statusCode int, duration time.Duration, raw []byte, errMsg string) {
|
||||
if !s.config.Debug {
|
||||
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)),
|
||||
)
|
||||
writeDebugExchange(s.config.DebugLogDir, debugExchangeRecord{
|
||||
RequestID: requestID,
|
||||
TransactionID: transactionID,
|
||||
API: apiCode,
|
||||
Status: statusCode,
|
||||
Duration: duration,
|
||||
Curl: curlCmd,
|
||||
ResponseBody: raw,
|
||||
Error: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,6 +38,31 @@ func TestHashIDCard(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCurlCommandHeaderOrder(t *testing.T) {
|
||||
cmd := generateCurlCommand("POST", "https://example.com/openapi/v1/a01/x", map[string]string{
|
||||
"signature": "sig",
|
||||
"Content-Type": "application/json",
|
||||
"nonce": "n",
|
||||
"clientId": "cid",
|
||||
"timestamp": "1",
|
||||
}, `{"name":"a"}`)
|
||||
wantOrder := []string{
|
||||
"-H 'Content-Type: application/json'",
|
||||
"-H 'clientId: cid'",
|
||||
"-H 'timestamp: 1'",
|
||||
"-H 'nonce: n'",
|
||||
"-H 'signature: sig'",
|
||||
}
|
||||
pos := 0
|
||||
for _, part := range wantOrder {
|
||||
idx := strings.Index(cmd[pos:], part)
|
||||
if idx < 0 {
|
||||
t.Fatalf("missing ordered header %q in:\n%s", part, cmd)
|
||||
}
|
||||
pos += idx + len(part)
|
||||
}
|
||||
}
|
||||
|
||||
func sandboxService() *YuyuechaService {
|
||||
return NewYuyuechaService(ServiceConfig{
|
||||
BaseURL: "https://sandbox-api.yuyuecha.com",
|
||||
|
||||
Reference in New Issue
Block a user