diff --git a/internal/domains/api/services/processors/qygl/yycxa01a_processor.go b/internal/domains/api/services/processors/qygl/qygl101A_processor.go similarity index 90% rename from internal/domains/api/services/processors/qygl/yycxa01a_processor.go rename to internal/domains/api/services/processors/qygl/qygl101A_processor.go index 1ed4892..8dbf839 100644 --- a/internal/domains/api/services/processors/qygl/yycxa01a_processor.go +++ b/internal/domains/api/services/processors/qygl/qygl101A_processor.go @@ -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), diff --git a/internal/infrastructure/external/yuyuecha/crypto.go b/internal/infrastructure/external/yuyuecha/crypto.go index 456e246..44a792a 100644 --- a/internal/infrastructure/external/yuyuecha/crypto.go +++ b/internal/infrastructure/external/yuyuecha/crypto.go @@ -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) diff --git a/internal/infrastructure/external/yuyuecha/curl_helper.go b/internal/infrastructure/external/yuyuecha/curl_helper.go index 8c6b86f..c6b2a85 100644 --- a/internal/infrastructure/external/yuyuecha/curl_helper.go +++ b/internal/infrastructure/external/yuyuecha/curl_helper.go @@ -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, "'", `'\''`) } diff --git a/internal/infrastructure/external/yuyuecha/debug_exchange.go b/internal/infrastructure/external/yuyuecha/debug_exchange.go new file mode 100644 index 0000000..5a5f35f --- /dev/null +++ b/internal/infrastructure/external/yuyuecha/debug_exchange.go @@ -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() +} diff --git a/internal/infrastructure/external/yuyuecha/yuyuecha_factory.go b/internal/infrastructure/external/yuyuecha/yuyuecha_factory.go index 001dc93..2ef3ad6 100644 --- a/internal/infrastructure/external/yuyuecha/yuyuecha_factory.go +++ b/internal/infrastructure/external/yuyuecha/yuyuecha_factory.go @@ -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 diff --git a/internal/infrastructure/external/yuyuecha/yuyuecha_service.go b/internal/infrastructure/external/yuyuecha/yuyuecha_service.go index 2f749fb..4ddf5e3 100644 --- a/internal/infrastructure/external/yuyuecha/yuyuecha_service.go +++ b/internal/infrastructure/external/yuyuecha/yuyuecha_service.go @@ -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, + }) } diff --git a/internal/infrastructure/external/yuyuecha/yuyuecha_service_test.go b/internal/infrastructure/external/yuyuecha/yuyuecha_service_test.go index 9731d05..1a4966a 100644 --- a/internal/infrastructure/external/yuyuecha/yuyuecha_service_test.go +++ b/internal/infrastructure/external/yuyuecha/yuyuecha_service_test.go @@ -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",