feat(t230): use OCR for ERP session login

This commit is contained in:
QiuSW
2026-07-29 10:48:06 +08:00
parent 8c84b81d48
commit f887bc4882
30 changed files with 512 additions and 205 deletions
@@ -269,8 +269,7 @@ func safeNext(value string) string {
if parsed.Path != "/tasks" &&
!strings.HasPrefix(parsed.Path, "/tasks/") &&
parsed.Path != "/freight" &&
!strings.HasPrefix(parsed.Path, "/freight/") &&
parsed.Path != "/erp" {
!strings.HasPrefix(parsed.Path, "/freight/") {
return "/tasks"
}
return parsed.String()
@@ -77,12 +77,6 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) {
routes.POST("/freight/import", SecurityHeaders(), h.CreateFreightImport)
routes.GET("/freight/:id", SecurityHeaders(), h.FreightDetail)
}
if _, ok := h.service.(ERPConnectionService); ok {
routes.GET("/erp", SecurityHeaders(), h.ERPConnection)
routes.POST("/erp/captcha", SecurityHeaders(), h.RequestERPCaptcha)
routes.GET("/erp/captcha/:ticket", SecurityHeaders(), h.ERPCaptchaImage)
routes.POST("/erp/login", SecurityHeaders(), h.LoginERP)
}
if _, ok := h.service.(ProcurementService); ok {
routes.POST(
"/freight/items/:id/procurement-request",
@@ -417,6 +411,12 @@ 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"
}
h.render(ctx, serviceErrorStatus(err), "freight-import", freightImportPage{
Page: pageView{
Title: "导入 ERP 货运",
@@ -428,7 +428,8 @@ func (h *Handler) CreateFreightImport(ctx *gin.Context) {
CreatedFrom: createdFrom,
CreatedTo: createdTo,
IdempotencyKey: key,
Error: "同步任务创建失败,请稍后使用相同提交标识重试。",
Error: message,
ErrorCode: code,
})
return
}
@@ -1227,6 +1228,8 @@ func serviceErrorStatus(err error) int {
switch {
case errors.Is(err, ErrValidation), errors.Is(err, ErrInvalidFile):
return http.StatusUnprocessableEntity
case errors.Is(err, ErrOCRServiceInvalid):
return http.StatusServiceUnavailable
case errors.Is(err, ErrConflict):
return http.StatusConflict
case errors.Is(err, context.DeadlineExceeded):
@@ -1299,6 +1302,7 @@ type freightImportPage struct {
CreatedTo string
IdempotencyKey string
Error string
ErrorCode string
Sync *FreightSync
Watermark *FreightWatermark
}
@@ -1095,102 +1095,47 @@ func TestFreightSyncToNowIgnoresPrefilledManualDates(t *testing.T) {
}
}
func TestERPConnectionPageUsesCaptchaOnlyAndPreservesNoCredentials(t *testing.T) {
ticket := mustToken(t)
service := &fakeERPService{
func TestERPConnectionRoutesAreNotRegistered(t *testing.T) {
router := newTestRouter(t, &fakeService{})
for _, target := range []string{"/erp", "/erp/captcha", "/erp/login"} {
response := performRequest(t, router, http.MethodGet, target, nil, "")
if response.Code != http.StatusNotFound {
t.Fatalf("%s status = %d", target, response.Code)
}
}
}
func TestFreightImportShowsOCRServiceDialogBeforeCreatingSync(t *testing.T) {
service := &fakeFreightService{
fakeService: &fakeService{},
status: ERPConnectionStatus{
Configured: true,
},
image: ERPCaptchaImage{
Content: []byte("captcha-image"),
ContentType: "image/png",
},
ticket: ticket,
err: ErrOCRServiceInvalid,
}
router := newTestRouter(t, service)
page := performRequest(t, router, http.MethodGet, "/erp", nil, "")
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "获取验证码") ||
strings.Contains(page.Body.String(), "private-password") {
t.Fatalf("ERP page = %d / %s", page.Code, page.Body)
page := performRequest(t, router, http.MethodGet, "/freight/import", nil, "")
if page.Code != http.StatusOK {
t.Fatalf("import page status = %d", page.Code)
}
assertSecurityHeaders(t, page)
cookie := csrfCookie(t, page)
values := url.Values{"csrf_token": {cookie.Value}}
values := url.Values{
"csrf_token": {cookie.Value},
"idempotency_key": {mustToken(t)},
"mode": {"ORDER_NUMBER"},
"order_number": {"ORDER-123"},
}
request := httptest.NewRequest(
http.MethodPost,
"/erp/captcha",
"/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.StatusSeeOther ||
response.Header().Get("Location") != "/erp?notice=captcha-ready" ||
service.captchaRequests != 1 {
t.Fatalf("captcha response/calls = %d / %q / %d", response.Code, response.Header().Get("Location"), service.captchaRequests)
}
service.status.CaptchaReady = true
service.status.CaptchaTicket = ticket
page = performRequest(t, router, http.MethodGet, "/erp", nil, "")
if page.Code != http.StatusOK ||
!strings.Contains(page.Body.String(), "/erp/captcha/"+ticket) ||
!strings.Contains(page.Body.String(), `name="captcha_code"`) ||
strings.Contains(page.Body.String(), "password") {
t.Fatalf("captcha page = %d / %s", page.Code, page.Body)
}
image := performRequest(t, router, http.MethodGet, "/erp/captcha/"+ticket, nil, "")
if image.Code != http.StatusOK || image.Header().Get("Cache-Control") != "no-store" ||
image.Body.String() != "captcha-image" {
t.Fatalf("captcha image = %d / %q / %s", image.Code, image.Header(), image.Body)
}
cookie = csrfCookie(t, page)
values = url.Values{
"csrf_token": {cookie.Value},
"captcha_ticket": {ticket},
"captcha_code": {"1234"},
}
request = httptest.NewRequest(
http.MethodPost,
"/erp/login",
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.StatusSeeOther ||
response.Header().Get("Location") != "/erp?notice=login-succeeded" ||
service.login.CaptchaTicket != ticket || service.login.CaptchaCode != "1234" {
t.Fatalf("login response/input = %d / %q / %+v", response.Code, response.Header().Get("Location"), service.login)
}
service.status = ERPConnectionStatus{
Configured: true,
CaptchaReady: true,
CaptchaTicket: ticket,
}
service.err = ErrERPLoginRejected
page = performRequest(t, router, http.MethodGet, "/erp", nil, "")
cookie = csrfCookie(t, page)
values = url.Values{
"csrf_token": {cookie.Value},
"captcha_ticket": {ticket},
"captcha_code": {"1234"},
}
request = httptest.NewRequest(
http.MethodPost,
"/erp/login",
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.StatusUnprocessableEntity ||
!strings.Contains(response.Body.String(), "验证码不正确或 ERP 拒绝登录") ||
strings.Contains(response.Body.String(), "private") {
t.Fatalf("rejected login page = %d / %s", response.Code, response.Body)
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)
}
}
@@ -1,56 +0,0 @@
{{define "erp-connection"}}
<!doctype html>
<html lang="zh-CN">
<head>
<title>{{.Page.Title}} - 采购任务管理</title>
{{template "document-head" .}}
</head>
<body>
{{template "site-header" .}}
<main id="main-content" class="page narrow-page">
<div class="title-row">
<div>
<h1>ERP 连接</h1>
<p class="subtitle">建立本次服务进程内的顺运宝会话</p>
</div>
<a class="button" href="/freight/import">返回导入</a>
</div>
{{if .Error}}<div class="notice danger" role="alert">{{.Error}}</div>{{end}}
{{if .Notice}}<div class="notice success" role="status">{{.Notice}}</div>{{end}}
{{if .Status.Authenticated}}
<section class="detail-section" aria-labelledby="erp-connected-title">
<h2 id="erp-connected-title">已连接</h2>
<p>当前后端进程持有受控 ERP 会话。重启后需要重新获取验证码登录。</p>
</section>
{{else if not .Status.Configured}}
<section class="detail-section" aria-labelledby="erp-config-title">
<h2 id="erp-config-title">尚未配置</h2>
<p>请在后端启动环境中配置顺运宝账号和密码后重启服务。</p>
</section>
{{else if .Status.CaptchaReady}}
<section class="detail-section" aria-labelledby="erp-captcha-title">
<h2 id="erp-captcha-title">输入验证码</h2>
<img class="erp-captcha-image" src="/erp/captcha/{{pathPart .Status.CaptchaTicket}}"
alt="ERP 验证码" width="180" height="64">
<form class="form-panel compact-form" method="post" action="/erp/login" data-loading-form>
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
<input type="hidden" name="captcha_ticket" value="{{.Status.CaptchaTicket}}">
<div class="field">
<label for="captcha-code">验证码</label>
<input id="captcha-code" name="captcha_code" maxlength="64" autocomplete="one-time-code" required>
</div>
<button class="button primary" type="submit" data-loading-label="正在登录…">登录 ERP</button>
</form>
</section>
{{else}}
<form class="form-panel" method="post" action="/erp/captcha" data-loading-form>
<input type="hidden" name="csrf_token" value="{{.Page.CSRFToken}}">
<h2>获取验证码</h2>
<p class="secondary">验证码一次有效,仅用于当前后端进程中的 ERP 会话。</p>
<button class="button primary" type="submit" data-loading-label="正在获取…">获取验证码</button>
</form>
{{end}}
</main>
</body>
</html>
{{end}}
@@ -16,6 +16,13 @@
<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>
<p>{{.Error}}</p>
<form method="dialog"><button class="button primary" type="submit" autofocus>关闭</button></form>
</dialog>
{{end}}
{{if .Sync}}
<section class="detail-section" aria-labelledby="sync-result-title">
<h2 id="sync-result-title">同步状态</h2>
@@ -18,7 +18,6 @@
<a href="/tasks" {{if .Page.TasksCurrent}}aria-current="page"{{end}}>任务列表</a>
<a href="/tasks/new" {{if .Page.NewCurrent}}aria-current="page"{{end}}>新建任务</a>
<a href="/freight" {{if .Page.FreightCurrent}}aria-current="page"{{end}}>ERP 货运</a>
<a href="/erp" {{if .Page.ERPCurrent}}aria-current="page"{{end}}>ERP 连接</a>
</nav>
{{if .Page.CSRFToken}}
<form class="logout-form" method="post" action="/logout">
@@ -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")
ErrOCRServiceInvalid = errors.New("OCR service is invalid")
)
// Service is the application boundary required by the server-rendered admin UI.
@@ -319,6 +319,9 @@ 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}
}
return FreightSync{}, mapUsecaseError(err)
}
return freightSyncFrom(run), nil
@@ -357,6 +360,9 @@ func (adapter *UsecaseAdapter) CreateFreightSync(
)
}
if err != nil {
if errors.Is(err, domain.ErrFreightSourceOCRInvalid) {
return FreightSync{}, &adapterError{public: ErrOCRServiceInvalid, cause: err}
}
return FreightSync{}, mapUsecaseError(err)
}
return freightSyncFrom(result.Run), nil