From 1e703ef2577f17faffe00532ada47f715c73929d Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 29 Jul 2026 11:24:04 +0800 Subject: [PATCH] fix(t233): add redacted ERP diagnostics --- backend-api/.env.example | 1 + backend-api/cmd/api/main.go | 8 + backend-api/internal/config/config.go | 26 ++ backend-api/internal/config/config_test.go | 12 +- backend-api/internal/config/envfile.go | 3 +- backend-api/internal/config/envfile_test.go | 3 +- .../internal/platform/shunyunbao/session.go | 225 +++++++++++++++++- .../platform/shunyunbao/session_test.go | 59 +++++ .../transport/webui/preflight_error_test.go | 18 ++ .../transport/webui/usecase_adapter.go | 12 +- docs/api.md | 6 + docs/current-state.md | 18 +- docs/tasks/T-233.md | 17 +- start-backend.bat | 32 ++- 14 files changed, 412 insertions(+), 28 deletions(-) diff --git a/backend-api/.env.example b/backend-api/.env.example index baba601..c52e28f 100644 --- a/backend-api/.env.example +++ b/backend-api/.env.example @@ -3,3 +3,4 @@ CMROUBAO_SHUNYUNBAO_URL=https://www.shunyunbaoerp.com CMROUBAO_SHUNYUNBAO_USERNAME= CMROUBAO_SHUNYUNBAO_PASSWORD= CMROUBAO_OCR_API_URL=http://127.0.0.1:8000/ocr +CMROUBAO_ERP_DEBUG_LOG=false diff --git a/backend-api/cmd/api/main.go b/backend-api/cmd/api/main.go index e3877ef..3a5a8a5 100644 --- a/backend-api/cmd/api/main.go +++ b/backend-api/cmd/api/main.go @@ -237,12 +237,20 @@ func buildRouter( if err != nil { return nil, err } + var erpDiagnosticLogger shunyunbao.DiagnosticLogger + if cfg.ERPDebugLog { + log.Print("ERP diagnostic logging enabled; response summaries are redacted") + erpDiagnosticLogger = func(event string) { + log.Print(event) + } + } erpSession, err := shunyunbao.NewSessionManager(shunyunbao.SessionConfig{ BaseURL: cfg.ShunyunbaoURL, Username: cfg.ShunyunbaoUsername, Password: cfg.ShunyunbaoPassword, Timeout: cfg.ShunyunbaoTimeout, CaptchaRecognizer: ocr, + DiagnosticLogger: erpDiagnosticLogger, }) if err != nil { return nil, err diff --git a/backend-api/internal/config/config.go b/backend-api/internal/config/config.go index d0f0fa0..32ac8d5 100644 --- a/backend-api/internal/config/config.go +++ b/backend-api/internal/config/config.go @@ -23,6 +23,7 @@ const ( ShunyunbaoUsernameEnvironment = "CMROUBAO_SHUNYUNBAO_USERNAME" ShunyunbaoPasswordEnvironment = "CMROUBAO_SHUNYUNBAO_PASSWORD" OCRAPIURLEnvironment = "CMROUBAO_OCR_API_URL" + ERPDebugLogEnvironment = "CMROUBAO_ERP_DEBUG_LOG" defaultHTTPAddress = "127.0.0.1:8080" defaultDatabasePath = "var/cmroubao.db" @@ -55,6 +56,7 @@ type Config struct { ShunyunbaoPassword string OCRAPIURL string ShunyunbaoTimeout time.Duration + ERPDebugLog bool } func Load(lookup LookupEnvironment) (Config, error) { @@ -186,6 +188,10 @@ func Load(lookup LookupEnvironment) (Config, error) { if ocrAPISet && !validOCRAPIURL(ocrAPIURL) { return Config{}, errors.New(OCRAPIURLEnvironment + " must be an approved OCR endpoint") } + erpDebugLog, err := booleanEnvironment(lookup, ERPDebugLogEnvironment, false) + if err != nil { + return Config{}, err + } return Config{ HTTPAddress: httpAddress, @@ -207,6 +213,7 @@ func Load(lookup LookupEnvironment) (Config, error) { ShunyunbaoPassword: shunyunbaoPassword, OCRAPIURL: ocrAPIURL, ShunyunbaoTimeout: 30 * time.Second, + ERPDebugLog: erpDebugLog, }, nil } @@ -267,6 +274,25 @@ func durationEnvironment( return duration, nil } +func booleanEnvironment( + lookup LookupEnvironment, + name string, + defaultValue bool, +) (bool, error) { + value, exists := lookup(name) + if !exists { + return defaultValue, nil + } + switch strings.ToLower(strings.TrimSpace(value)) { + case "true": + return true, nil + case "false": + return false, nil + default: + return false, errors.New(name + " must be true or false") + } +} + func cleanOptionalPath(value string) string { if value == "" { return "" diff --git a/backend-api/internal/config/config_test.go b/backend-api/internal/config/config_test.go index 07d1f62..487187a 100644 --- a/backend-api/internal/config/config_test.go +++ b/backend-api/internal/config/config_test.go @@ -44,7 +44,7 @@ func TestLoadUsesSafeDefaults(t *testing.T) { } if cfg.ShunyunbaoURL != "https://www.shunyunbaoerp.com" || cfg.ShunyunbaoUsername != "" || cfg.ShunyunbaoPassword != "" || - cfg.ShunyunbaoTimeout != 30*time.Second { + cfg.ShunyunbaoTimeout != 30*time.Second || cfg.ERPDebugLog { t.Fatalf( "shunyunbao defaults = %q / %q / %q / %s", cfg.ShunyunbaoURL, @@ -68,6 +68,7 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) { ShunyunbaoURLEnvironment: "https://erp.example.test:8443", ShunyunbaoUsernameEnvironment: "service-user", ShunyunbaoPasswordEnvironment: " pass with spaces ", + ERPDebugLogEnvironment: "true", } cfg, err := Load(mapEnvironment(values)) @@ -104,7 +105,8 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) { } if cfg.ShunyunbaoURL != values[ShunyunbaoURLEnvironment] || cfg.ShunyunbaoUsername != values[ShunyunbaoUsernameEnvironment] || - cfg.ShunyunbaoPassword != values[ShunyunbaoPasswordEnvironment] { + cfg.ShunyunbaoPassword != values[ShunyunbaoPasswordEnvironment] || + !cfg.ERPDebugLog { t.Fatalf("shunyunbao config was not preserved") } } @@ -138,6 +140,12 @@ func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) { ShunyunbaoPasswordEnvironment: "password", }, }, + { + name: "invalid ERP debug log", + values: map[string]string{ + ERPDebugLogEnvironment: "yes", + }, + }, { name: "blank explicit address", values: map[string]string{ diff --git a/backend-api/internal/config/envfile.go b/backend-api/internal/config/envfile.go index 3300ce5..06e129f 100644 --- a/backend-api/internal/config/envfile.go +++ b/backend-api/internal/config/envfile.go @@ -123,7 +123,8 @@ func isERPEnvironmentName(name string) bool { case ShunyunbaoURLEnvironment, ShunyunbaoUsernameEnvironment, ShunyunbaoPasswordEnvironment, - OCRAPIURLEnvironment: + OCRAPIURLEnvironment, + ERPDebugLogEnvironment: return true default: return false diff --git a/backend-api/internal/config/envfile_test.go b/backend-api/internal/config/envfile_test.go index 1341d35..1ff84c5 100644 --- a/backend-api/internal/config/envfile_test.go +++ b/backend-api/internal/config/envfile_test.go @@ -14,6 +14,7 @@ func TestWithERPEnvironmentFileUsesApprovedFallbackValues(t *testing.T) { "CMROUBAO_SHUNYUNBAO_USERNAME=dotenv-user", "CMROUBAO_SHUNYUNBAO_PASSWORD='dotenv password #1'", "CMROUBAO_OCR_API_URL=http://127.0.0.1:8000/ocr", + "CMROUBAO_ERP_DEBUG_LOG=true", }, "\n")) lookup, err := WithERPEnvironmentFile(path, func(string) (string, bool) { @@ -29,7 +30,7 @@ func TestWithERPEnvironmentFileUsesApprovedFallbackValues(t *testing.T) { if cfg.ShunyunbaoURL != "https://erp.example.test" || cfg.ShunyunbaoUsername != "dotenv-user" || cfg.ShunyunbaoPassword != "dotenv password #1" || - cfg.OCRAPIURL != "http://127.0.0.1:8000/ocr" { + cfg.OCRAPIURL != "http://127.0.0.1:8000/ocr" || !cfg.ERPDebugLog { t.Fatalf( "ERP config = %#v", struct { diff --git a/backend-api/internal/platform/shunyunbao/session.go b/backend-api/internal/platform/shunyunbao/session.go index de821a1..f4576fc 100644 --- a/backend-api/internal/platform/shunyunbao/session.go +++ b/backend-api/internal/platform/shunyunbao/session.go @@ -25,6 +25,7 @@ const ( defaultCaptchaTTL = 5 * time.Minute maxCaptchaBytes = 2 << 20 maxERPResponseBytes = 4 << 20 + maxDiagnosticBytes = 4 << 10 ) var ( @@ -40,12 +41,17 @@ type SessionConfig struct { CaptchaTTL time.Duration AllowInsecureHTTP bool // Used only by isolated httptest contracts. CaptchaRecognizer CaptchaRecognizer + DiagnosticLogger DiagnosticLogger } type CaptchaRecognizer interface { Recognize(context.Context, []byte, string) (string, error) } +// DiagnosticLogger receives only redacted request/response summaries when +// explicitly enabled by the API composition root. +type DiagnosticLogger func(string) + type SessionStatus struct { Configured bool Authenticated bool @@ -69,6 +75,8 @@ type SessionManager struct { headers http.Header http *http.Client recognizer CaptchaRecognizer + diagnosticLog DiagnosticLogger + diagnosticsOn bool authenticated bool captchaTicket string captchaContent []byte @@ -118,7 +126,9 @@ func NewSessionManager(config SessionConfig) (*SessionManager, error) { return http.ErrUseLastResponse }, }, - recognizer: config.CaptchaRecognizer, + recognizer: config.CaptchaRecognizer, + diagnosticLog: config.DiagnosticLogger, + diagnosticsOn: config.DiagnosticLogger != nil, }, nil } @@ -179,12 +189,15 @@ func (manager *SessionManager) FetchCaptcha( return manager.statusLocked(), domain.ErrFreightSourceUnavailable } manager.applyHeaders(request) + manager.logERPRequest(request) response, err := manager.http.Do(request) if err != nil { + manager.logERPTransportFailure(request) return manager.statusLocked(), domain.ErrFreightSourceUnavailable } defer response.Body.Close() if response.StatusCode != http.StatusOK { + manager.logERPResponsePreview(request, response) return manager.statusLocked(), manager.responseErrorLocked(response.StatusCode) } contentType := strings.TrimSpace( @@ -195,8 +208,10 @@ func (manager *SessionManager) FetchCaptcha( } content, err := readBounded(response.Body, maxCaptchaBytes) if err != nil || len(content) == 0 { + manager.logERPResponseReadFailure(request, response) return manager.statusLocked(), domain.ErrFreightSourceUnavailable } + manager.logERPResponse(request, response, content, false) ticket, err := newCaptchaTicket() if err != nil { return manager.statusLocked(), domain.ErrFreightSourceUnavailable @@ -327,12 +342,15 @@ func (manager *SessionManager) requestJSONLocked( if body != nil { request.Header.Set("Content-Type", "application/json") } + manager.logERPRequest(request) response, err := manager.http.Do(request) if err != nil { + manager.logERPTransportFailure(request) return nil, domain.ErrFreightSourceUnavailable } defer response.Body.Close() if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + manager.logERPResponsePreview(request, response) if loginRequest { return nil, ErrLoginRejected } @@ -340,8 +358,10 @@ func (manager *SessionManager) requestJSONLocked( } contentBytes, err := readBounded(response.Body, maxERPResponseBytes) if err != nil { + manager.logERPResponseReadFailure(request, response) return nil, domain.ErrFreightSourceUnavailable } + manager.logERPResponse(request, response, contentBytes, false) var envelope struct { Status *bool `json:"status"` Code json.RawMessage `json:"code"` @@ -439,6 +459,209 @@ func (manager *SessionManager) clearCaptchaLocked() { manager.captchaExpires = time.Time{} } +func (manager *SessionManager) logERPRequest(request *http.Request) { + if !manager.diagnosticsOn { + return + } + manager.diagnosticLog( + "erp_request method=" + request.Method + + " path=" + request.URL.EscapedPath(), + ) +} + +func (manager *SessionManager) logERPTransportFailure(request *http.Request) { + if !manager.diagnosticsOn { + return + } + manager.diagnosticLog( + "erp_transport_failed method=" + request.Method + + " path=" + request.URL.EscapedPath() + " class=transport", + ) +} + +func (manager *SessionManager) logERPResponsePreview( + request *http.Request, + response *http.Response, +) { + if !manager.diagnosticsOn { + return + } + content, truncated, readable := readDiagnosticPreview(response.Body) + if !readable { + manager.logERPResponseReadFailure(request, response) + return + } + manager.logERPResponse(request, response, content, truncated) +} + +func (manager *SessionManager) logERPResponseReadFailure( + request *http.Request, + response *http.Response, +) { + if !manager.diagnosticsOn { + return + } + manager.diagnosticLog( + "erp_response method=" + request.Method + + " path=" + request.URL.EscapedPath() + + " status=" + strconv.Itoa(response.StatusCode) + + " content_type=" + diagnosticContentType(response.Header.Get("Content-Type")) + + " body=unavailable", + ) +} + +func (manager *SessionManager) logERPResponse( + request *http.Request, + response *http.Response, + content []byte, + truncated bool, +) { + if !manager.diagnosticsOn { + return + } + byteCount := "bytes=" + strconv.Itoa(len(content)) + body := "body=empty" + if truncated { + byteCount = "bytes_at_least=" + strconv.Itoa(len(content)) + body = "body=omitted_truncated" + } else if len(content) > 0 { + body = "body=omitted_non_json" + if summary, ok := redactedDiagnosticJSON(content); ok { + body = "json=" + summary + } + } + manager.diagnosticLog( + "erp_response method=" + request.Method + + " path=" + request.URL.EscapedPath() + + " status=" + strconv.Itoa(response.StatusCode) + + " content_type=" + diagnosticContentType(response.Header.Get("Content-Type")) + + " " + byteCount + " " + body, + ) +} + +func readDiagnosticPreview(reader io.Reader) ([]byte, bool, bool) { + content, err := io.ReadAll(io.LimitReader(reader, maxDiagnosticBytes+1)) + if err != nil { + return nil, false, false + } + if len(content) > maxDiagnosticBytes { + return content[:maxDiagnosticBytes], true, true + } + return content, false, true +} + +func diagnosticContentType(value string) string { + value = strings.TrimSpace(strings.Split(value, ";")[0]) + if value == "" || len(value) > 128 || !utf8.ValidString(value) || hasControl(value) { + return "unknown" + } + return value +} + +func redactedDiagnosticJSON(content []byte) (string, bool) { + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return "", false + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + return "", false + } + encoded, err := json.Marshal(redactDiagnosticValue(value)) + if err != nil || len(encoded) > maxDiagnosticBytes { + return `{"summary":"omitted_large_json"}`, true + } + return string(encoded), true +} + +func redactDiagnosticValue(value any) any { + switch typed := value.(type) { + case map[string]any: + redacted := make(map[string]any, len(typed)) + for key, item := range typed { + if sensitiveDiagnosticKey(key) { + redacted[key] = "[REDACTED]" + continue + } + redacted[key] = redactDiagnosticValue(item) + } + return redacted + case []any: + limit := len(typed) + if limit > 20 { + limit = 20 + } + redacted := make([]any, 0, limit+1) + for _, item := range typed[:limit] { + redacted = append(redacted, redactDiagnosticValue(item)) + } + if len(typed) > limit { + redacted = append(redacted, "[TRUNCATED]") + } + return redacted + case string: + return redactDiagnosticString(typed) + default: + return value + } +} + +func sensitiveDiagnosticKey(value string) bool { + var normalized strings.Builder + for _, character := range strings.ToLower(value) { + if (character >= 'a' && character <= 'z') || + (character >= '0' && character <= '9') { + normalized.WriteRune(character) + } + } + key := normalized.String() + for _, marker := range []string{ + "password", "passwd", "pwd", "username", "user", "captcha", "token", + "cookie", "authorization", "auth", "receiver", "recipient", "phone", + "mobile", "tel", "address", "email", "order", "stock", "tracking", + "track", "express", "shipment", "shop", "name", "id", "code", "remark", + "note", "detail", + } { + if strings.Contains(key, marker) { + return true + } + } + return false +} + +func redactDiagnosticString(value string) string { + value = strings.ToValidUTF8(strings.TrimSpace(value), "?") + value = strings.NewReplacer("\r", " ", "\n", " ", "\t", " ").Replace(value) + if strings.Contains(value, "@") { + return "[REDACTED]" + } + var result strings.Builder + for index := 0; index < len(value); { + if value[index] < '0' || value[index] > '9' { + result.WriteByte(value[index]) + index++ + continue + } + end := index + for end < len(value) && value[end] >= '0' && value[end] <= '9' { + end++ + } + if end-index >= 6 { + result.WriteString("[REDACTED]") + } else { + result.WriteString(value[index:end]) + } + index = end + } + characters := []rune(result.String()) + if len(characters) > 512 { + return string(characters[:512]) + "[TRUNCATED]" + } + return string(characters) +} + func hasUser(value any) bool { data, ok := value.(map[string]any) if !ok { diff --git a/backend-api/internal/platform/shunyunbao/session_test.go b/backend-api/internal/platform/shunyunbao/session_test.go index 692bf01..69674f7 100644 --- a/backend-api/internal/platform/shunyunbao/session_test.go +++ b/backend-api/internal/platform/shunyunbao/session_test.go @@ -211,6 +211,65 @@ func TestSessionManagerEnsureAuthenticatedRequiresRecognizer(t *testing.T) { } } +func TestSessionManagerDiagnosticLogsAreRedacted(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case CaptchaPath: + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write([]byte("private-captcha-image")) + case LoginPath: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ +"status":false, +"msg":"ERP says order 123456789 is blocked", +"data":{"username":"test-user","password":"test-password","token":"secret-token","orderNumber":"order-123456789"} +}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + var events []string + manager, err := NewSessionManager(SessionConfig{ + BaseURL: server.URL, + Username: "test-user", + Password: "test-password", + Timeout: time.Second, + AllowInsecureHTTP: true, + CaptchaRecognizer: &fixedRecognizer{code: "1234"}, + DiagnosticLogger: func(event string) { + events = append(events, event) + }, + }) + if err != nil { + t.Fatalf("NewSessionManager() error = %v", err) + } + if err := manager.EnsureAuthenticated(context.Background()); !errors.Is(err, domain.ErrFreightSourceLoginRejected) { + t.Fatalf("EnsureAuthenticated() error = %v", err) + } + actual := strings.Join(events, "\n") + for _, expected := range []string{ + "erp_request method=GET path=/api/p/code1", + "erp_response method=GET path=/api/p/code1 status=200", + "body=omitted_non_json", + "erp_request method=POST path=/am/auth/login", + "erp_response method=POST path=/am/auth/login status=200", + `"status":false`, + `"password":"[REDACTED]"`, + } { + if !strings.Contains(actual, expected) { + t.Fatalf("diagnostic log missing %q: %s", expected, actual) + } + } + for _, secret := range []string{ + "test-user", "test-password", "secret-token", "123456789", "private-captcha-image", + } { + if strings.Contains(actual, secret) { + t.Fatalf("diagnostic log leaked %q: %s", secret, actual) + } + } +} + type fixedRecognizer struct { code string err error diff --git a/backend-api/internal/transport/webui/preflight_error_test.go b/backend-api/internal/transport/webui/preflight_error_test.go index 2306d98..ae4a330 100644 --- a/backend-api/internal/transport/webui/preflight_error_test.go +++ b/backend-api/internal/transport/webui/preflight_error_test.go @@ -25,3 +25,21 @@ func TestMapFreightPreflightErrorUsesStablePublicErrors(t *testing.T) { } } } + +func TestMapFreightCreateErrorPreservesPreflightErrors(t *testing.T) { + for _, testCase := range []struct { + err error + want error + }{ + {domain.ErrFreightSourceOCRInvalid, ErrOCRServiceInvalid}, + {domain.ErrFreightSourceNotConfigured, ErrERPNotConfigured}, + {domain.ErrFreightSourceLoginRejected, ErrERPLoginRejected}, + {domain.ErrFreightSourceProtocol, ErrERPProtocol}, + {domain.ErrFreightSourceUnavailable, ErrERPUnavailable}, + } { + actual := mapFreightCreateError(testCase.err) + if !errors.Is(actual, testCase.want) || !errors.Is(actual, testCase.err) { + t.Fatalf("mapFreightCreateError(%v) = %v", testCase.err, actual) + } + } +} diff --git a/backend-api/internal/transport/webui/usecase_adapter.go b/backend-api/internal/transport/webui/usecase_adapter.go index 0ba32c5..0c9b79f 100644 --- a/backend-api/internal/transport/webui/usecase_adapter.go +++ b/backend-api/internal/transport/webui/usecase_adapter.go @@ -379,14 +379,18 @@ func (adapter *UsecaseAdapter) CreateFreightSync( ) } if err != nil { - if errors.Is(err, domain.ErrFreightSourceOCRInvalid) { - return FreightSync{}, &adapterError{public: ErrOCRServiceInvalid, cause: err} - } - return FreightSync{}, mapUsecaseError(err) + return FreightSync{}, mapFreightCreateError(err) } return freightSyncFrom(result.Run), nil } +func mapFreightCreateError(err error) error { + if mapped := mapFreightPreflightError(err); mapped != nil { + return mapped + } + return mapUsecaseError(err) +} + func (adapter *UsecaseAdapter) GetFreightWatermark( ctx context.Context, ) (*FreightWatermark, error) { diff --git a/docs/api.md b/docs/api.md index b663151..7cd1f10 100644 --- a/docs/api.md +++ b/docs/api.md @@ -220,6 +220,12 @@ ERP 配置来源: `422 ERP_LOGIN_REJECTED`、`502 ERP_RESPONSE_INVALID` 和 `503 ERP_UNAVAILABLE`;SSR 导入页 用相同 code 显示可关闭弹窗并保留表单。所有 message 都是固定匿名文本,不含 ERP/OCR 原始 响应、验证码、Cookie、账号或密码。 +- `CMROUBAO_ERP_DEBUG_LOG` 默认 `false`,仅接受 `true` 或 `false`。临时设为 `true` 后, + API 启动窗口记录 ERP request 的 method/path,以及 response 的 status、content type、长度和 + 最多 4 KiB 的递归脱敏 JSON 摘要;不记录请求 body、完整 URL query、header、Cookie、验证码、 + OCR 文本、账号、密码、token、订单标识、收件信息、非 JSON body 或图片。诊断完成后必须设回 + `false` 并重启 API。Windows 可使用根目录 `start-backend.bat --erp-debug` 仅为本次 API + 进程覆盖开启;也可与 `--migrate` 组合。 会话不写 SQLite 或 Redis;服务重启后在下一次货运导入前重新经 OCR 建立会话。 diff --git a/docs/current-state.md b/docs/current-state.md index 885eba9..923c705 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,7 +5,7 @@ ## 当前快照 - 日期:2026-07-29 -- 阶段:T-233 计划修复 ERP 预检错误映射并增加受控诊断日志 +- 阶段:T-233 已修复 ERP 预检错误映射并增加受控诊断日志 - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219 均按文档提交、实现提交的顺序纳入历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 @@ -190,6 +190,8 @@ | `docs/tasks/T-229.md` | DONE | 从受控 `.env` 加载 ERP 凭证 | | `docs/tasks/T-230.md` | DONE | OCR 自动登录并移除人工 ERP 连接页 | | `docs/tasks/T-231.md` | DONE | 显示 ERP/OCR 导入预检稳定错误 | +| `docs/tasks/T-232.md` | DONE | 记录 ERP/OCR 导入预检安全诊断日志 | +| `docs/tasks/T-233.md` | DONE | 修复预检错误映射并增加受控 ERP 诊断日志 | | `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | @@ -201,13 +203,13 @@ ## 任务摘要 - 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。 -- 已完成:另含 T-220 至 T-231 ERP 契约、货运存储、采购需求生成、日期增量同步、Go - 直连协议、OCR 会话预检、稳定预检错误、直连 `FreightSource`、旧 Connector 清理和受控本地 - 凭证加载。 -- 进行中:T-233 修复将 ERP 预检错误降级为通用 `503` 的 Web adapter 映射,并增加默认关闭的 - ERP 脱敏诊断日志。 -- 下一步:启用受控诊断日志后以受控单号导入一次,读取 ERP 请求/响应摘要和货运预检 code; - 再确认开放 API、OCR/ERP 数据使用权限并完成不记录订单内容的 smoke。 +- 已完成:另含 T-220 至 T-233 ERP 契约、货运存储、采购需求生成、日期增量同步、Go + 直连协议、OCR 会话预检、稳定预检错误、安全诊断日志、直连 `FreightSource`、旧 Connector + 清理和受控本地凭证加载。 +- 进行中:无。 +- 下一步:使用 `start-backend.bat --erp-debug` 重启 API 后以受控单号导入一次,读取 ERP + 请求/响应摘要和货运预检 code;再确认开放 API、OCR/ERP 数据使用权限并完成不记录订单内容的 + smoke。 ## 当前可运行内容 diff --git a/docs/tasks/T-233.md b/docs/tasks/T-233.md index 2852311..b500765 100644 --- a/docs/tasks/T-233.md +++ b/docs/tasks/T-233.md @@ -4,7 +4,7 @@ title: 修复 ERP 预检错误映射并增加受控诊断日志 phase: 2 deps: - T-232 -status: PLANNED +status: DONE created: 2026-07-29 context_ref: b316287 work_branch: null @@ -12,6 +12,7 @@ write_paths: - docs/tasks/T-233.md - docs/current-state.md - docs/api.md + - start-backend.bat - backend-api/.env.example - backend-api/cmd/api/** - backend-api/internal/config/** @@ -37,17 +38,18 @@ client 不记录请求路径、HTTP 状态或响应摘要,无法区分 TLS/网 token、Cookie、收件人、电话、地址和订单标识均替换为 `[REDACTED]`。图片、非 JSON 和 传输失败只输出类型、长度、状态或固定类别,不输出原始内容或 Go error。 4. 不记录请求 body、完整 URL query、header、Cookie、OCR 文本或密钥。启动时仅记录调试 - 已启用,不输出配置值;调试结束后将 `.env` 设回 `false`。 + 已启用,不输出配置值;调试结束后将 `.env` 设回 `false`。根目录 + `start-backend.bat --erp-debug` 可只对本次 API 进程开启同一开关。 ## 验收要点 -- [ ] ERP 不可用可从 `/freight/import` 日志看到 +- [x] ERP 不可用可从 `/freight/import` 日志看到 `freight_import_preflight_failed code=ERP_UNAVAILABLE status=503`。 -- [ ] 开启 `CMROUBAO_ERP_DEBUG_LOG=true` 后,验证码、登录、用户校验及后续查询均有可关联 +- [x] 开启 `CMROUBAO_ERP_DEBUG_LOG=true` 后,验证码、登录、用户校验及后续查询均有可关联 的脱敏请求/响应日志;默认关闭时没有这些日志。 -- [ ] 日志绝不含 ERP 凭证、验证码、Cookie、token、订单号、收件人、手机号、地址或原始 +- [x] 日志绝不含 ERP 凭证、验证码、Cookie、token、订单号、收件人、手机号、地址或原始 非 JSON/图片 body。 -- [ ] 配置、脱敏、ERP client 和 Web adapter 均有测试;标准 Go 验证和入口构建通过。 +- [x] 配置、脱敏、ERP client 和 Web adapter 均有测试;标准 Go 验证和入口构建通过。 ## 边界 @@ -59,3 +61,6 @@ client 不记录请求路径、HTTP 状态或响应摘要,无法区分 TLS/网 - 2026-07-29:创建任务。现场终端提供 `freight_import_failed status=503`,确认稳定 code 在 Web adapter 丢失;本机没有可重放的真实 ERP 账户响应。 +- 2026-07-29:修复 CreateFreightSync 复用所有预检错误映射,增加受控 ERP 诊断 logger、 + JSON 递归脱敏和 4 KiB 限制,并提供 `start-backend.bat --erp-debug` 一次性开关。标准 Go + 测试、race、vet 和三个入口构建均通过;未请求真实 ERP。 diff --git a/start-backend.bat b/start-backend.bat index 1caf467..d35a070 100644 --- a/start-backend.bat +++ b/start-backend.bat @@ -2,6 +2,7 @@ setlocal EnableExtensions DisableDelayedExpansion set "PROJECT_ROOT=%~dp0" +set "SCRIPT_NAME=%~nx0" set "BACKEND_ROOT=%PROJECT_ROOT%backend-api" set "LOCAL_TLS_DIR=%PROJECT_ROOT%.local\cmroubao-tls" set "LOCAL_TLS_CERT=%LOCAL_TLS_DIR%\server.crt" @@ -21,11 +22,32 @@ if not defined CMROUBAO_TLS_CERT_FILE ( ) ) -if not "%~1"=="" if /i not "%~1"=="--migrate" ( - echo Usage: %~nx0 [--migrate] - echo --migrate Apply pending database migrations before starting the API. - exit /b 2 +set "RUN_MIGRATIONS=0" +set "ERP_DEBUG_LOG=0" +:parse_arguments +if "%~1"=="" goto :arguments_parsed +if /i "%~1"=="--migrate" ( + if "%RUN_MIGRATIONS%"=="1" goto :usage + set "RUN_MIGRATIONS=1" + shift + goto :parse_arguments ) +if /i "%~1"=="--erp-debug" ( + if "%ERP_DEBUG_LOG%"=="1" goto :usage + set "ERP_DEBUG_LOG=1" + shift + goto :parse_arguments +) +goto :usage + +:usage +echo Usage: %SCRIPT_NAME% [--migrate] [--erp-debug] +echo --migrate Apply pending database migrations before starting the API. +echo --erp-debug Print redacted ERP request and response diagnostics for this process. +exit /b 2 + +:arguments_parsed +if "%ERP_DEBUG_LOG%"=="1" set "CMROUBAO_ERP_DEBUG_LOG=true" if not exist "%BACKEND_ROOT%\go.mod" ( echo Missing backend project: "%BACKEND_ROOT%" @@ -63,7 +85,7 @@ if errorlevel 1 goto :startup_failed if not exist "var\assets" mkdir "var\assets" if errorlevel 1 goto :startup_failed -if /i "%~1"=="--migrate" ( +if "%RUN_MIGRATIONS%"=="1" ( echo Applying database migrations... go run ./cmd/migrate up if errorlevel 1 goto :startup_failed