fix(t231): expose freight preflight errors
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user