feat(t230): use OCR for ERP session login
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user