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