This commit is contained in:
2026-07-25 14:18:14 +08:00
parent cc2513652e
commit fa55098520
7 changed files with 216 additions and 58 deletions

View File

@@ -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),

View File

@@ -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)

View File

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

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

View File

@@ -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

View File

@@ -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,
})
}

View File

@@ -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",