fix(t231): expose freight preflight errors

This commit is contained in:
QiuSW
2026-07-29 10:58:28 +08:00
parent 2d8ad97167
commit 5ed27afeb5
15 changed files with 240 additions and 56 deletions
@@ -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")
)
@@ -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
}
@@ -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,
@@ -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)
}
})
}
}
@@ -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
}
@@ -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)
}
})
}
}
@@ -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)
}
}
}
@@ -16,10 +16,11 @@
<a class="button" href="/freight">返回列表</a>
</div>
{{if .Error}}<div class="notice danger" role="alert">{{.Error}}</div>{{end}}
{{if eq .ErrorCode "OCR_SERVICE_INVALID"}}
<dialog open aria-labelledby="ocr-service-error-title">
<h2 id="ocr-service-error-title">OCR 服务无效</h2>
{{if .ErrorCode}}
<dialog open aria-labelledby="freight-import-error-title">
<h2 id="freight-import-error-title">{{.ErrorTitle}}</h2>
<p>{{.Error}}</p>
<p class="secondary">{{.ErrorCode}}</p>
<form method="dialog"><button class="button primary" type="submit" autofocus>关闭</button></form>
</dialog>
{{end}}
@@ -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")
)
@@ -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,
@@ -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)
@@ -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 {