diff --git a/backend-api/internal/domain/freight_source_errors.go b/backend-api/internal/domain/freight_source_errors.go index 4cabc1d..454380f 100644 --- a/backend-api/internal/domain/freight_source_errors.go +++ b/backend-api/internal/domain/freight_source_errors.go @@ -11,4 +11,5 @@ var ( ErrFreightSourceUnavailable = errors.New("freight source is unavailable") ErrFreightSourceProtocol = errors.New("freight source protocol is invalid") ErrFreightSourceOCRInvalid = errors.New("freight source OCR service is invalid") + ErrFreightSourceLoginRejected = errors.New("freight source login was rejected") ) diff --git a/backend-api/internal/platform/shunyunbao/session.go b/backend-api/internal/platform/shunyunbao/session.go index 1304f58..de821a1 100644 --- a/backend-api/internal/platform/shunyunbao/session.go +++ b/backend-api/internal/platform/shunyunbao/session.go @@ -148,6 +148,9 @@ func (manager *SessionManager) EnsureAuthenticated(ctx context.Context) error { return domain.ErrFreightSourceOCRInvalid } _, err = manager.Login(ctx, status.CaptchaTicket, code) + if errors.Is(err, ErrLoginRejected) { + return domain.ErrFreightSourceLoginRejected + } return err } diff --git a/backend-api/internal/transport/httpapi/admin_handlers.go b/backend-api/internal/transport/httpapi/admin_handlers.go index 9050b09..69dfee5 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers.go +++ b/backend-api/internal/transport/httpapi/admin_handlers.go @@ -845,6 +845,18 @@ func writeUsecaseError(ctx *gin.Context, err error) { case usecase.ErrorKindUnavailable: status = http.StatusServiceUnavailable } + switch typed.Code { + case "ERP_NOT_CONFIGURED", "ERP_LOGIN_REJECTED": + status = http.StatusUnprocessableEntity + case "ERP_RESPONSE_INVALID": + status = http.StatusBadGateway + case "OCR_SERVICE_INVALID", "ERP_UNAVAILABLE": + status = http.StatusServiceUnavailable + } + message := typed.Message + if preflightMessage, ok := freightPreflightPublicMessage(typed.Code); ok { + message = preflightMessage + } details := gin.H{} if len(typed.Fields) > 0 { details["fields"] = typed.Fields @@ -853,12 +865,29 @@ func writeUsecaseError(ctx *gin.Context, err error) { ctx, status, typed.Code, - typed.Message, + message, typed.Retryable, details, ) } +func freightPreflightPublicMessage(code string) (string, bool) { + switch code { + case "ERP_NOT_CONFIGURED": + return "ERP credentials are not configured", true + case "ERP_LOGIN_REJECTED": + return "ERP login was rejected", true + case "ERP_RESPONSE_INVALID": + return "ERP response is invalid", true + case "OCR_SERVICE_INVALID": + return "OCR service is invalid", true + case "ERP_UNAVAILABLE": + return "ERP is temporarily unavailable", true + default: + return "", false + } +} + func writePublicError( ctx *gin.Context, status int, diff --git a/backend-api/internal/transport/httpapi/preflight_error_test.go b/backend-api/internal/transport/httpapi/preflight_error_test.go new file mode 100644 index 0000000..15f411d --- /dev/null +++ b/backend-api/internal/transport/httpapi/preflight_error_test.go @@ -0,0 +1,46 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "cmroubao/backend-api/internal/usecase" + + "github.com/gin-gonic/gin" +) + +func TestWriteUsecaseErrorUsesPreflightStatusAndCode(t *testing.T) { + testCases := []struct { + code string + status int + }{ + {"ERP_NOT_CONFIGURED", http.StatusUnprocessableEntity}, + {"ERP_LOGIN_REJECTED", http.StatusUnprocessableEntity}, + {"ERP_RESPONSE_INVALID", http.StatusBadGateway}, + {"OCR_SERVICE_INVALID", http.StatusServiceUnavailable}, + {"ERP_UNAVAILABLE", http.StatusServiceUnavailable}, + } + for _, testCase := range testCases { + t.Run(testCase.code, func(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/test", func(ctx *gin.Context) { + writeUsecaseError(ctx, &usecase.Error{ + Kind: usecase.ErrorKindUnavailable, + Code: testCase.code, + Message: "private upstream response is hidden", + Fields: map[string]string{}, + }) + }) + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/test", nil)) + if response.Code != testCase.status || + !strings.Contains(response.Body.String(), testCase.code) || + strings.Contains(response.Body.String(), "private upstream") { + t.Fatalf("response = %d / %s", response.Code, response.Body) + } + }) + } +} diff --git a/backend-api/internal/transport/webui/handler.go b/backend-api/internal/transport/webui/handler.go index 9a6a715..7fb8381 100644 --- a/backend-api/internal/transport/webui/handler.go +++ b/backend-api/internal/transport/webui/handler.go @@ -411,12 +411,7 @@ func (h *Handler) CreateFreightImport(ctx *gin.Context) { ) if err != nil { token, _ := csrfToken(ctx) - message := "同步任务创建失败,请稍后使用相同提交标识重试。" - code := "" - if errors.Is(err, ErrOCRServiceInvalid) { - message = "OCR 服务无效,请检查本机 OCR 服务和 CMROUBAO_OCR_API_URL 后重试。" - code = "OCR_SERVICE_INVALID" - } + code, title, message := freightImportError(err) h.render(ctx, serviceErrorStatus(err), "freight-import", freightImportPage{ Page: pageView{ Title: "导入 ERP 货运", @@ -430,12 +425,30 @@ func (h *Handler) CreateFreightImport(ctx *gin.Context) { IdempotencyKey: key, Error: message, ErrorCode: code, + ErrorTitle: title, }) return } ctx.Redirect(http.StatusSeeOther, "/freight/import?sync="+pathEscape(run.ID)) } +func freightImportError(err error) (string, string, string) { + switch { + case errors.Is(err, ErrOCRServiceInvalid): + return "OCR_SERVICE_INVALID", "OCR 服务无效", "OCR 服务无效,请检查本机 OCR 服务和 CMROUBAO_OCR_API_URL 后重试。" + case errors.Is(err, ErrERPNotConfigured): + return "ERP_NOT_CONFIGURED", "ERP 凭证未配置", "ERP 账号或密码未配置,请检查 backend-api/.env 后重试。" + case errors.Is(err, ErrERPLoginRejected): + return "ERP_LOGIN_REJECTED", "ERP 登录被拒绝", "请检查 ERP 账号密码及 OCR 识别结果后重试。" + case errors.Is(err, ErrERPProtocol): + return "ERP_RESPONSE_INVALID", "ERP 响应无效", "ERP 返回格式无法确认,请稍后重试。" + case errors.Is(err, ErrERPUnavailable): + return "ERP_UNAVAILABLE", "ERP 暂时不可用", "ERP 服务暂时不可用,请稍后使用相同提交标识重试。" + default: + return "", "", "同步任务创建失败,请稍后使用相同提交标识重试。" + } +} + func (h *Handler) FreightDetail(ctx *gin.Context) { service := h.service.(FreightService) detail, err := service.GetFreightOrder( @@ -1230,6 +1243,12 @@ func serviceErrorStatus(err error) int { return http.StatusUnprocessableEntity case errors.Is(err, ErrOCRServiceInvalid): return http.StatusServiceUnavailable + case errors.Is(err, ErrERPNotConfigured), errors.Is(err, ErrERPLoginRejected): + return http.StatusUnprocessableEntity + case errors.Is(err, ErrERPProtocol): + return http.StatusBadGateway + case errors.Is(err, ErrERPUnavailable): + return http.StatusServiceUnavailable case errors.Is(err, ErrConflict): return http.StatusConflict case errors.Is(err, context.DeadlineExceeded): @@ -1303,6 +1322,7 @@ type freightImportPage struct { IdempotencyKey string Error string ErrorCode string + ErrorTitle string Sync *FreightSync Watermark *FreightWatermark } diff --git a/backend-api/internal/transport/webui/handler_test.go b/backend-api/internal/transport/webui/handler_test.go index cc287ce..68f663d 100644 --- a/backend-api/internal/transport/webui/handler_test.go +++ b/backend-api/internal/transport/webui/handler_test.go @@ -1105,37 +1105,49 @@ func TestERPConnectionRoutesAreNotRegistered(t *testing.T) { } } -func TestFreightImportShowsOCRServiceDialogBeforeCreatingSync(t *testing.T) { - service := &fakeFreightService{ - fakeService: &fakeService{}, - err: ErrOCRServiceInvalid, +func TestFreightImportShowsStablePreflightErrorDialog(t *testing.T) { + testCases := []struct { + name string + err error + status int + code string + title string + }{ + {"ocr", ErrOCRServiceInvalid, http.StatusServiceUnavailable, "OCR_SERVICE_INVALID", "OCR 服务无效"}, + {"not configured", ErrERPNotConfigured, http.StatusUnprocessableEntity, "ERP_NOT_CONFIGURED", "ERP 凭证未配置"}, + {"login rejected", ErrERPLoginRejected, http.StatusUnprocessableEntity, "ERP_LOGIN_REJECTED", "ERP 登录被拒绝"}, + {"protocol", ErrERPProtocol, http.StatusBadGateway, "ERP_RESPONSE_INVALID", "ERP 响应无效"}, + {"unavailable", ErrERPUnavailable, http.StatusServiceUnavailable, "ERP_UNAVAILABLE", "ERP 暂时不可用"}, } - router := newTestRouter(t, service) - page := performRequest(t, router, http.MethodGet, "/freight/import", nil, "") - if page.Code != http.StatusOK { - t.Fatalf("import page status = %d", page.Code) - } - 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.StatusServiceUnavailable || - !strings.Contains(response.Body.String(), "OCR 服务无效") || - !strings.Contains(response.Body.String(), "ORDER-123") || - service.createInput.OrderNumber != "ORDER-123" { - t.Fatalf("OCR dialog response/input = %d / %s / %+v", response.Code, response.Body, service.createInput) + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + service := &fakeFreightService{fakeService: &fakeService{}, err: testCase.err} + router := newTestRouter(t, service) + page := performRequest(t, router, http.MethodGet, "/freight/import", nil, "") + if page.Code != http.StatusOK { + t.Fatalf("import page status = %d", page.Code) + } + 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 != testCase.status || + !strings.Contains(response.Body.String(), testCase.code) || + !strings.Contains(response.Body.String(), testCase.title) || + !strings.Contains(response.Body.String(), "ORDER-123") || + strings.Contains(response.Body.String(), "private") || + service.createInput.OrderNumber != "ORDER-123" { + t.Fatalf("dialog response/input = %d / %s / %+v", response.Code, response.Body, service.createInput) + } + }) } } diff --git a/backend-api/internal/transport/webui/preflight_error_test.go b/backend-api/internal/transport/webui/preflight_error_test.go new file mode 100644 index 0000000..2306d98 --- /dev/null +++ b/backend-api/internal/transport/webui/preflight_error_test.go @@ -0,0 +1,27 @@ +package webui + +import ( + "errors" + "testing" + + "cmroubao/backend-api/internal/domain" +) + +func TestMapFreightPreflightErrorUsesStablePublicErrors(t *testing.T) { + testCases := []struct { + err error + want error + }{ + {domain.ErrFreightSourceOCRInvalid, ErrOCRServiceInvalid}, + {domain.ErrFreightSourceNotConfigured, ErrERPNotConfigured}, + {domain.ErrFreightSourceLoginRejected, ErrERPLoginRejected}, + {domain.ErrFreightSourceProtocol, ErrERPProtocol}, + {domain.ErrFreightSourceUnavailable, ErrERPUnavailable}, + } + for _, testCase := range testCases { + actual := mapFreightPreflightError(testCase.err) + if !errors.Is(actual, testCase.want) || !errors.Is(actual, testCase.err) { + t.Fatalf("mapFreightPreflightError(%v) = %v", testCase.err, actual) + } + } +} diff --git a/backend-api/internal/transport/webui/templates/freight-import.gohtml b/backend-api/internal/transport/webui/templates/freight-import.gohtml index 020e9b2..b7f117d 100644 --- a/backend-api/internal/transport/webui/templates/freight-import.gohtml +++ b/backend-api/internal/transport/webui/templates/freight-import.gohtml @@ -16,10 +16,11 @@ 返回列表 {{if .Error}}{{end}} - {{if eq .ErrorCode "OCR_SERVICE_INVALID"}} - -

OCR 服务无效

+ {{if .ErrorCode}} + +

{{.ErrorTitle}}

{{.Error}}

+

{{.ErrorCode}}

{{end}} diff --git a/backend-api/internal/transport/webui/types.go b/backend-api/internal/transport/webui/types.go index 79fd7a6..23a7b43 100644 --- a/backend-api/internal/transport/webui/types.go +++ b/backend-api/internal/transport/webui/types.go @@ -19,6 +19,7 @@ var ( ErrERPCaptchaInvalid = errors.New("ERP captcha is invalid") ErrERPLoginRejected = errors.New("ERP login was rejected") ErrERPProtocol = errors.New("ERP protocol is invalid") + ErrERPUnavailable = errors.New("ERP is unavailable") ErrOCRServiceInvalid = errors.New("OCR service is invalid") ) diff --git a/backend-api/internal/transport/webui/usecase_adapter.go b/backend-api/internal/transport/webui/usecase_adapter.go index 4f0eeb3..0ba32c5 100644 --- a/backend-api/internal/transport/webui/usecase_adapter.go +++ b/backend-api/internal/transport/webui/usecase_adapter.go @@ -319,14 +319,33 @@ func (adapter *UsecaseAdapter) GetFreightSync( } run, err := adapter.freight.GetSync(ctx, localAdminSubject, syncID) if err != nil { - if errors.Is(err, domain.ErrFreightSourceOCRInvalid) { - return FreightSync{}, &adapterError{public: ErrOCRServiceInvalid, cause: err} + if mapped := mapFreightPreflightError(err); mapped != nil { + return FreightSync{}, mapped } return FreightSync{}, mapUsecaseError(err) } return freightSyncFrom(run), nil } +func mapFreightPreflightError(err error) error { + var public error + switch { + case errors.Is(err, domain.ErrFreightSourceOCRInvalid): + public = ErrOCRServiceInvalid + case errors.Is(err, domain.ErrFreightSourceNotConfigured): + public = ErrERPNotConfigured + case errors.Is(err, domain.ErrFreightSourceLoginRejected): + public = ErrERPLoginRejected + case errors.Is(err, domain.ErrFreightSourceProtocol): + public = ErrERPProtocol + case errors.Is(err, domain.ErrFreightSourceUnavailable): + public = ErrERPUnavailable + default: + return nil + } + return &adapterError{public: public, cause: err} +} + func (adapter *UsecaseAdapter) CreateFreightSync( ctx context.Context, input CreateFreightSyncInput, diff --git a/backend-api/internal/usecase/freight_service.go b/backend-api/internal/usecase/freight_service.go index 035b496..a8812fb 100644 --- a/backend-api/internal/usecase/freight_service.go +++ b/backend-api/internal/usecase/freight_service.go @@ -294,13 +294,14 @@ func (service *FreightService) ensureSource(ctx context.Context) error { return nil } if err := preflight.EnsureAuthenticated(ctx); err != nil { + code := freightSourceErrorCode(err) result := newError( ErrorKindUnavailable, - freightSourceErrorCode(err), - "freight source session is unavailable", + code, + freightPreflightMessage(code), err, ) - result.Retryable = true + result.Retryable = code == "OCR_SERVICE_INVALID" || code == "ERP_UNAVAILABLE" return result } return nil @@ -709,11 +710,28 @@ func freightSourceErrorCode(err error) string { return "ERP_RESPONSE_INVALID" case errors.Is(err, domain.ErrFreightSourceOCRInvalid): return "OCR_SERVICE_INVALID" + case errors.Is(err, domain.ErrFreightSourceLoginRejected): + return "ERP_LOGIN_REJECTED" default: return "ERP_UNAVAILABLE" } } +func freightPreflightMessage(code string) string { + switch code { + case "ERP_NOT_CONFIGURED": + return "ERP credentials are not configured" + case "ERP_LOGIN_REJECTED": + return "ERP login was rejected" + case "ERP_RESPONSE_INVALID": + return "ERP response is invalid" + case "OCR_SERVICE_INVALID": + return "OCR service is invalid" + default: + return "ERP is temporarily unavailable" + } +} + func validExternalID(value string) (string, bool) { value = strings.TrimSpace(value) number, err := strconv.ParseUint(value, 10, 64) diff --git a/backend-api/internal/usecase/freight_service_test.go b/backend-api/internal/usecase/freight_service_test.go index c0297f4..4feaf34 100644 --- a/backend-api/internal/usecase/freight_service_test.go +++ b/backend-api/internal/usecase/freight_service_test.go @@ -65,6 +65,7 @@ func TestFreightSourceErrorCodesAreSourceNeutral(t *testing.T) { {domain.ErrFreightSourceNotFound, "ERP_FREIGHT_NOT_FOUND"}, {domain.ErrFreightSourceProtocol, "ERP_RESPONSE_INVALID"}, {domain.ErrFreightSourceOCRInvalid, "OCR_SERVICE_INVALID"}, + {domain.ErrFreightSourceLoginRejected, "ERP_LOGIN_REJECTED"}, {errors.New("temporary source failure"), "ERP_UNAVAILABLE"}, } for _, testCase := range cases { diff --git a/docs/api.md b/docs/api.md index 95bc1b0..b663151 100644 --- a/docs/api.md +++ b/docs/api.md @@ -216,7 +216,10 @@ ERP 配置来源: - `CMROUBAO_OCR_API_URL` 为可选 OCR `POST` endpoint;只允许 HTTPS,或本机 loopback HTTP, 不允许 userinfo、query、fragment 或重定向。请求使用 `multipart/form-data` 的 `file` 字段; OCR 不可达、超时、非成功、过大或响应无有效文本时,货运创建返回 `503 OCR_SERVICE_INVALID` - 且不会创建同步记录。 + 且不会创建同步记录。预检的稳定错误还包括 `422 ERP_NOT_CONFIGURED`、 + `422 ERP_LOGIN_REJECTED`、`502 ERP_RESPONSE_INVALID` 和 `503 ERP_UNAVAILABLE`;SSR 导入页 + 用相同 code 显示可关闭弹窗并保留表单。所有 message 都是固定匿名文本,不含 ERP/OCR 原始 + 响应、验证码、Cookie、账号或密码。 会话不写 SQLite 或 Redis;服务重启后在下一次货运导入前重新经 OCR 建立会话。 diff --git a/docs/current-state.md b/docs/current-state.md index 5fdb5de..0afdaf2 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,7 +5,7 @@ ## 当前快照 - 日期:2026-07-29 -- 阶段:T-231 待显示 ERP/OCR 导入预检的稳定错误码与修复提示 +- 阶段:T-231 已显示 ERP/OCR 导入预检的稳定错误码与修复提示 - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219 均按文档提交、实现提交的顺序纳入历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 @@ -189,6 +189,7 @@ | `docs/tasks/T-228.md` | DONE | 移除 Python Connector 并完成 Go 切换 | | `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/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | @@ -200,11 +201,11 @@ ## 任务摘要 - 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。 -- 已完成:另含 T-220 至 T-230 ERP 契约、货运存储、采购需求生成、日期增量同步、Go - 直连协议、OCR 会话预检、直连 `FreightSource`、旧 Connector 清理和受控本地凭证加载。 +- 已完成:另含 T-220 至 T-231 ERP 契约、货运存储、采购需求生成、日期增量同步、Go + 直连协议、OCR 会话预检、稳定预检错误、直连 `FreightSource`、旧 Connector 清理和受控本地 + 凭证加载。 - 进行中:无。 -- 下一步:T-231 先显示 ERP/OCR 导入预检稳定错误;随后确认开放 API、OCR/ERP 数据使用权限, - 并以受控单号执行一次不记录订单内容的 smoke。 +- 下一步:确认开放 API、OCR/ERP 数据使用权限,并以受控单号执行一次不记录订单内容的 smoke。 ## 当前可运行内容 diff --git a/docs/tasks/T-231.md b/docs/tasks/T-231.md index 67b61da..f1849bb 100644 --- a/docs/tasks/T-231.md +++ b/docs/tasks/T-231.md @@ -4,7 +4,7 @@ title: 显示 ERP 货运导入预检的稳定错误 phase: 2 deps: - T-230 -status: TODO +status: DONE created: 2026-07-29 context_ref: f887bc4 work_branch: null @@ -37,11 +37,11 @@ T-230 已让货运导入在创建同步前建立 OCR/ERP 会话,但除 `OCR_SE ## 验收要点 -- [ ] OCR 无效、ERP 未配置、登录拒绝、协议异常和暂时不可用均有稳定 code;登录拒绝不再显示 +- [x] OCR 无效、ERP 未配置、登录拒绝、协议异常和暂时不可用均有稳定 code;登录拒绝不再显示 泛化“同步任务创建失败”。 -- [ ] 导入页弹窗保留表单,并在错误发生前不创建同步记录;JSON 返回相同 code。 -- [ ] 错误、HTML 和 JSON 均不含凭证、验证码、Cookie 或上游响应片段。 -- [ ] `go test ./...`、`go test -race ./...`、`go vet ./...` 和三个 Go 入口构建通过。 +- [x] 导入页弹窗保留表单,并在错误发生前不创建同步记录;JSON 返回相同 code。 +- [x] 错误、HTML 和 JSON 均不含凭证、验证码、Cookie 或上游响应片段。 +- [x] `go test ./...`、`go test -race ./...`、`go vet ./...` 和三个 Go 入口构建通过。 ## 边界 @@ -52,3 +52,5 @@ T-230 已让货运导入在创建同步前建立 OCR/ERP 会话,但除 `OCR_SE - 2026-07-29:创建任务。现场检查确认 `.env` 四项均已设置且后端/OCR 均监听;原 SSR 错误 分支丢弃了 ERP 预检稳定码,尚不能从页面确认上游失败类别。 +- 2026-07-29:将 ERP 登录拒绝归一化为来源错误,并向 SSR/JSON 映射五类匿名稳定预检错误; + 完整 Go 验证命令和结果见实现提交。