diff --git a/backend-api/cmd/api/main.go b/backend-api/cmd/api/main.go index cdf3408..e3877ef 100644 --- a/backend-api/cmd/api/main.go +++ b/backend-api/cmd/api/main.go @@ -306,7 +306,9 @@ func buildRouter( if err != nil { return nil, err } - webHandler, err := webui.NewHandler(webService, renderer) + webHandler, err := webui.NewHandler(webService, renderer, func(event string) { + log.Print(event) + }) if err != nil { return nil, err } diff --git a/backend-api/internal/transport/webui/handler.go b/backend-api/internal/transport/webui/handler.go index 7fb8381..e522d30 100644 --- a/backend-api/internal/transport/webui/handler.go +++ b/backend-api/internal/transport/webui/handler.go @@ -35,18 +35,30 @@ const ( type Handler struct { service Service renderer *Renderer + logEvent EventLogger } -func NewHandler(service Service, renderer *Renderer) (*Handler, error) { +type EventLogger func(string) + +func NewHandler( + service Service, + renderer *Renderer, + loggers ...EventLogger, +) (*Handler, error) { if service == nil { return nil, errors.New("admin web service is required") } if renderer == nil { return nil, errors.New("admin web renderer is required") } + logEvent := EventLogger(func(string) {}) + if len(loggers) > 0 && loggers[0] != nil { + logEvent = loggers[0] + } return &Handler{ service: service, renderer: renderer, + logEvent: logEvent, }, nil } @@ -412,7 +424,9 @@ func (h *Handler) CreateFreightImport(ctx *gin.Context) { if err != nil { token, _ := csrfToken(ctx) code, title, message := freightImportError(err) - h.render(ctx, serviceErrorStatus(err), "freight-import", freightImportPage{ + status := serviceErrorStatus(err) + h.logFreightImportFailure(code, status) + h.render(ctx, status, "freight-import", freightImportPage{ Page: pageView{ Title: "导入 ERP 货运", FreightCurrent: true, @@ -432,6 +446,17 @@ func (h *Handler) CreateFreightImport(ctx *gin.Context) { ctx.Redirect(http.StatusSeeOther, "/freight/import?sync="+pathEscape(run.ID)) } +func (h *Handler) logFreightImportFailure(code string, status int) { + if code == "" { + h.logEvent("freight_import_failed status=" + strconv.Itoa(status)) + return + } + h.logEvent( + "freight_import_preflight_failed code=" + code + + " status=" + strconv.Itoa(status), + ) +} + func freightImportError(err error) (string, string, string) { switch { case errors.Is(err, ErrOCRServiceInvalid): diff --git a/backend-api/internal/transport/webui/handler_test.go b/backend-api/internal/transport/webui/handler_test.go index 68f663d..17332e2 100644 --- a/backend-api/internal/transport/webui/handler_test.go +++ b/backend-api/internal/transport/webui/handler_test.go @@ -10,6 +10,7 @@ import ( "net/http/httptest" "net/url" "regexp" + "strconv" "strings" "testing" "time" @@ -1122,7 +1123,10 @@ func TestFreightImportShowsStablePreflightErrorDialog(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { service := &fakeFreightService{fakeService: &fakeService{}, err: testCase.err} - router := newTestRouter(t, service) + var event string + router := newTestRouterWithLogger(t, service, func(value string) { + event = value + }) page := performRequest(t, router, http.MethodGet, "/freight/import", nil, "") if page.Code != http.StatusOK { t.Fatalf("import page status = %d", page.Code) @@ -1147,10 +1151,44 @@ func TestFreightImportShowsStablePreflightErrorDialog(t *testing.T) { service.createInput.OrderNumber != "ORDER-123" { t.Fatalf("dialog response/input = %d / %s / %+v", response.Code, response.Body, service.createInput) } + wantEvent := "freight_import_preflight_failed code=" + + testCase.code + " status=" + strconv.Itoa(testCase.status) + if event != wantEvent || strings.Contains(event, "private") { + t.Fatalf("event = %q, want %q", event, wantEvent) + } }) } } +func TestFreightImportLogsUnknownFailureWithoutRawError(t *testing.T) { + service := &fakeFreightService{ + fakeService: &fakeService{}, + err: errors.New("private ERP response with cookie and password"), + } + var event string + router := newTestRouterWithLogger(t, service, func(value string) { + event = value + }) + page := performRequest(t, router, http.MethodGet, "/freight/import", nil, "") + cookie := csrfCookie(t, page) + values := url.Values{ + "csrf_token": {cookie.Value}, + "idempotency_key": {mustToken(t)}, + "mode": {"ORDER_NUMBER"}, + "order_number": {"ORDER-123"}, + } + request := httptest.NewRequest(http.MethodPost, "/freight/import", strings.NewReader(values.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(cookie) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusInternalServerError || + event != "freight_import_failed status=500" || + strings.Contains(event, "private") { + t.Fatalf("response/event = %d / %q", response.Code, event) + } +} + func TestFreightDetailCreatesProcurementTaskWithCSRF(t *testing.T) { const itemID = "00000000-0000-4000-8000-000000000002" service := &fakeProcurementService{ @@ -1431,13 +1469,21 @@ func (service *fakeService) AuthorizeOrder( } func newTestRouter(t *testing.T, service Service) http.Handler { + return newTestRouterWithLogger(t, service, nil) +} + +func newTestRouterWithLogger( + t *testing.T, + service Service, + logEvent EventLogger, +) http.Handler { t.Helper() gin.SetMode(gin.TestMode) renderer, err := NewRenderer() if err != nil { t.Fatalf("NewRenderer() error = %v", err) } - handler, err := NewHandler(service, renderer) + handler, err := NewHandler(service, renderer, logEvent) if err != nil { t.Fatalf("NewHandler() error = %v", err) } diff --git a/docs/current-state.md b/docs/current-state.md index 28805f4..8a0ce0c 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,7 +5,7 @@ ## 当前快照 - 日期:2026-07-29 -- 阶段:T-232 计划补充 ERP/OCR 导入预检的安全诊断日志 +- 阶段:T-232 已记录 ERP/OCR 导入预检的安全诊断日志 - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219 均按文档提交、实现提交的顺序纳入历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 @@ -204,9 +204,9 @@ - 已完成:另含 T-220 至 T-231 ERP 契约、货运存储、采购需求生成、日期增量同步、Go 直连协议、OCR 会话预检、稳定预检错误、直连 `FreightSource`、旧 Connector 清理和受控本地 凭证加载。 -- 进行中:T-232 将把货运导入的稳定预检 code 和 HTTP 状态写入 API 终端;不记录 - 单号、凭证、Cookie、验证码、OCR 文本或 ERP 原文。 -- 下一步:确认开放 API、OCR/ERP 数据使用权限,并以受控单号执行一次不记录订单内容的 smoke。 +- 进行中:无。 +- 下一步:重启 API 后以受控单号导入一次,读取终端的货运预检稳定 code;再确认开放 API、 + OCR/ERP 数据使用权限并完成不记录订单内容的 smoke。 ## 当前可运行内容 diff --git a/docs/tasks/T-232.md b/docs/tasks/T-232.md index e9bd4cf..1eed16a 100644 --- a/docs/tasks/T-232.md +++ b/docs/tasks/T-232.md @@ -4,7 +4,7 @@ title: 记录 ERP 货运导入预检的安全诊断日志 phase: 2 deps: - T-231 -status: PLANNED +status: DONE created: 2026-07-29 context_ref: 5ed27af work_branch: null @@ -33,11 +33,11 @@ OCR 服务、ERP 连通性还是协议错误;直接记录底层 error 又可 ## 验收要点 -- [ ] `/freight/import` 的五类稳定预检错误在 API 终端均有可搜索的 code 和 HTTP 状态。 -- [ ] 页面和 JSON 响应继续只返回匿名公开提示,日志不包含单号、凭证、Cookie、验证码、 +- [x] `/freight/import` 的五类稳定预检错误在 API 终端均有可搜索的 code 和 HTTP 状态。 +- [x] 页面和 JSON 响应继续只返回匿名公开提示,日志不包含单号、凭证、Cookie、验证码、 OCR 文本或 ERP 原始正文。 -- [ ] 历史 `NewHandler` 调用保持兼容;日志记录器缺省为安全 no-op。 -- [ ] `go test ./...`、`go test -race ./...`、`go vet ./...` 和三个 Go 入口构建通过。 +- [x] 历史 `NewHandler` 调用保持兼容;日志记录器缺省为安全 no-op。 +- [x] `go test ./...`、`go test -race ./...`、`go vet ./...` 和三个 Go 入口构建通过。 ## 边界 @@ -48,3 +48,6 @@ OCR 服务、ERP 连通性还是协议错误;直接记录底层 error 又可 - 2026-07-29:创建任务。现场检查时本机 `8080` 无监听进程,无法回放先前 `503`;确认 T-231 的 SSR 分支会渲染稳定 code,但未向 API 终端写入任何诊断事件。 +- 2026-07-29:API 入口向 Web handler 注入标准日志;五类稳定预检错误记录固定事件、code + 和 status,未知失败也只记录固定事件/status。`go test ./...`、`go test -race ./...`、 + `go vet ./...` 及 api/migrate/authctl 构建均通过。