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 {
+4 -1
View File
@@ -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 建立会话。
+6 -5
View File
@@ -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。
## 当前可运行内容
+7 -5
View File
@@ -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 验证命令和结果见实现提交。