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
+1
View File
@@ -2,3 +2,4 @@
CMROUBAO_SHUNYUNBAO_URL=https://www.shunyunbaoerp.com
CMROUBAO_SHUNYUNBAO_USERNAME=
CMROUBAO_SHUNYUNBAO_PASSWORD=
CMROUBAO_OCR_API_URL=http://127.0.0.1:8000/ocr
+8 -2
View File
@@ -27,6 +27,7 @@ start/运行续租/release 和取消安全确认。
| `CMROUBAO_SHUNYUNBAO_URL` | `https://www.shunyunbaoerp.com` | 顺运宝 HTTPS origin |
| `CMROUBAO_SHUNYUNBAO_USERNAME` | 无 | 顺运宝账号;必须与密码同时设置 |
| `CMROUBAO_SHUNYUNBAO_PASSWORD` | 无 | 顺运宝密码;必须与账号同时设置 |
| `CMROUBAO_OCR_API_URL` | 无 | OCR `POST` endpoint;HTTP 只允许本机 loopback |
### 本地 ERP `.env`
@@ -39,14 +40,19 @@ Copy-Item .env.example .env
# 编辑 .env,填入顺运宝账号和密码
```
该文件只允许上述三个 `CMROUBAO_SHUNYUNBAO_*` 值,进程环境变量优先于同名 `.env` 值。
该文件只允许上述 `CMROUBAO_SHUNYUNBAO_*` 值和 `CMROUBAO_OCR_API_URL`,进程环境变量
优先于同名 `.env` 值。
它不配置数据库、监听/TLS、`authctl` 密码或其他应用选项,也不会修改全局进程环境。缺失
`.env` 时 ERP 保持未配置,其他本地功能仍可启动。仅支持空行、整行 `#` 注释和 `KEY=VALUE`
`.env` 时 ERP/OCR 保持未配置,其他本地功能仍可启动。仅支持空行、整行 `#` 注释和 `KEY=VALUE`
(需要保留空格或 `#` 的值可使用成对单/双引号);不支持变量展开、命令或行内注释。
`.env` 已被 Git 忽略,应只保存在本机受限目录;`.env.example` 不得填入真实凭证。`var/`
运行数据同样不得提交。
按单号导入货运时,API 在创建同步前使用该 OCR endpoint 读取 ERP 验证码并建立进程内会话。
OCR 不可用时导入页显示 `OCR 服务无效`,不会创建同步记录;验证码、图片、Cookie 和识别文字
不写入数据库、日志或浏览器。
## 命令
```powershell
+10 -6
View File
@@ -15,6 +15,7 @@ import (
"cmroubao/backend-api/internal/platform/assetstore"
"cmroubao/backend-api/internal/platform/database"
"cmroubao/backend-api/internal/platform/migration"
"cmroubao/backend-api/internal/platform/ocrapi"
"cmroubao/backend-api/internal/platform/password"
"cmroubao/backend-api/internal/platform/shunyunbao"
repository "cmroubao/backend-api/internal/repository/sqlite"
@@ -232,11 +233,16 @@ func buildRouter(
if err != nil {
return nil, err
}
ocr, err := ocrapi.NewClient(cfg.OCRAPIURL, 0)
if err != nil {
return nil, err
}
erpSession, err := shunyunbao.NewSessionManager(shunyunbao.SessionConfig{
BaseURL: cfg.ShunyunbaoURL,
Username: cfg.ShunyunbaoUsername,
Password: cfg.ShunyunbaoPassword,
Timeout: cfg.ShunyunbaoTimeout,
BaseURL: cfg.ShunyunbaoURL,
Username: cfg.ShunyunbaoUsername,
Password: cfg.ShunyunbaoPassword,
Timeout: cfg.ShunyunbaoTimeout,
CaptchaRecognizer: ocr,
})
if err != nil {
return nil, err
@@ -296,7 +302,6 @@ func buildRouter(
return nil, err
}
webService.SetProcurement(procurement)
webService.SetERPConnection(erpSession)
renderer, err := webui.NewRenderer()
if err != nil {
return nil, err
@@ -340,7 +345,6 @@ func buildRouter(
Authorizations: authorizations,
Freight: freight,
Procurement: procurement,
ERP: erpSession,
},
webHandler,
)
+1 -1
View File
@@ -196,7 +196,7 @@ func TestBuildRouterRegistersProtectedLogoutRoute(t *testing.T) {
response.Header().Get("Location"),
)
}
for _, target := range []string{"/freight", "/freight/import", "/erp"} {
for _, target := range []string{"/freight", "/freight/import"} {
request = httptest.NewRequest(http.MethodGet, target, nil)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
+33
View File
@@ -22,6 +22,7 @@ const (
ShunyunbaoURLEnvironment = "CMROUBAO_SHUNYUNBAO_URL"
ShunyunbaoUsernameEnvironment = "CMROUBAO_SHUNYUNBAO_USERNAME"
ShunyunbaoPasswordEnvironment = "CMROUBAO_SHUNYUNBAO_PASSWORD"
OCRAPIURLEnvironment = "CMROUBAO_OCR_API_URL"
defaultHTTPAddress = "127.0.0.1:8080"
defaultDatabasePath = "var/cmroubao.db"
@@ -52,6 +53,7 @@ type Config struct {
ShunyunbaoURL string
ShunyunbaoUsername string
ShunyunbaoPassword string
OCRAPIURL string
ShunyunbaoTimeout time.Duration
}
@@ -177,6 +179,13 @@ func Load(lookup LookupEnvironment) (Config, error) {
ShunyunbaoPasswordEnvironment + " must be set together",
)
}
ocrAPIURL, ocrAPISet, err := optionalEnvironmentValue(lookup, OCRAPIURLEnvironment)
if err != nil {
return Config{}, err
}
if ocrAPISet && !validOCRAPIURL(ocrAPIURL) {
return Config{}, errors.New(OCRAPIURLEnvironment + " must be an approved OCR endpoint")
}
return Config{
HTTPAddress: httpAddress,
@@ -196,10 +205,34 @@ func Load(lookup LookupEnvironment) (Config, error) {
ShunyunbaoURL: strings.TrimRight(shunyunbaoURL, "/"),
ShunyunbaoUsername: shunyunbaoUsername,
ShunyunbaoPassword: shunyunbaoPassword,
OCRAPIURL: ocrAPIURL,
ShunyunbaoTimeout: 30 * time.Second,
}, nil
}
func validOCRAPIURL(value string) bool {
parsed, err := url.Parse(value)
if err != nil || parsed.Host == "" || parsed.User != nil ||
parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path == "" {
return false
}
if port := parsed.Port(); port != "" {
parsedPort, portErr := strconv.Atoi(port)
if portErr != nil || parsedPort < 1 || parsedPort > 65535 {
return false
}
}
if parsed.Scheme == "https" {
return true
}
if parsed.Scheme != "http" {
return false
}
host := strings.Trim(parsed.Hostname(), "[]")
return strings.EqualFold(host, "localhost") ||
(net.ParseIP(host) != nil && net.ParseIP(host).IsLoopback())
}
func validateHTTPSOrigin(value, environment string) error {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" ||
+2 -1
View File
@@ -122,7 +122,8 @@ func isERPEnvironmentName(name string) bool {
switch name {
case ShunyunbaoURLEnvironment,
ShunyunbaoUsernameEnvironment,
ShunyunbaoPasswordEnvironment:
ShunyunbaoPasswordEnvironment,
OCRAPIURLEnvironment:
return true
default:
return false
+5 -2
View File
@@ -13,6 +13,7 @@ func TestWithERPEnvironmentFileUsesApprovedFallbackValues(t *testing.T) {
"CMROUBAO_SHUNYUNBAO_URL=https://erp.example.test",
"CMROUBAO_SHUNYUNBAO_USERNAME=dotenv-user",
"CMROUBAO_SHUNYUNBAO_PASSWORD='dotenv password #1'",
"CMROUBAO_OCR_API_URL=http://127.0.0.1:8000/ocr",
}, "\n"))
lookup, err := WithERPEnvironmentFile(path, func(string) (string, bool) {
@@ -27,14 +28,16 @@ func TestWithERPEnvironmentFileUsesApprovedFallbackValues(t *testing.T) {
}
if cfg.ShunyunbaoURL != "https://erp.example.test" ||
cfg.ShunyunbaoUsername != "dotenv-user" ||
cfg.ShunyunbaoPassword != "dotenv password #1" {
cfg.ShunyunbaoPassword != "dotenv password #1" ||
cfg.OCRAPIURL != "http://127.0.0.1:8000/ocr" {
t.Fatalf(
"ERP config = %#v",
struct {
URL string
Username string
Password string
}{cfg.ShunyunbaoURL, cfg.ShunyunbaoUsername, cfg.ShunyunbaoPassword},
OCRURL string
}{cfg.ShunyunbaoURL, cfg.ShunyunbaoUsername, cfg.ShunyunbaoPassword, cfg.OCRAPIURL},
)
}
if _, exists := lookup("OTHER_TOOL_TOKEN"); exists {
@@ -10,4 +10,5 @@ var (
ErrFreightSourceNotFound = errors.New("freight source order not found")
ErrFreightSourceUnavailable = errors.New("freight source is unavailable")
ErrFreightSourceProtocol = errors.New("freight source protocol is invalid")
ErrFreightSourceOCRInvalid = errors.New("freight source OCR service is invalid")
)
@@ -0,0 +1,138 @@
package ocrapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"mime/multipart"
"net/http"
"strings"
"time"
"unicode/utf8"
)
const (
defaultTimeout = 5 * time.Second
maximumReplyBytes = 64 << 10
)
var ErrServiceInvalid = errors.New("OCR service is invalid")
type Client struct {
endpoint string
http *http.Client
}
func NewClient(endpoint string, timeout time.Duration) (*Client, error) {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
return nil, nil
}
if timeout <= 0 {
timeout = defaultTimeout
}
return &Client{
endpoint: endpoint,
http: &http.Client{
Timeout: timeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
}, nil
}
func (client *Client) Recognize(
ctx context.Context,
image []byte,
contentType string,
) (string, error) {
if client == nil || len(image) == 0 || !strings.HasPrefix(contentType, "image/") {
return "", ErrServiceInvalid
}
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "captcha"+extension(contentType))
if err != nil {
return "", ErrServiceInvalid
}
if _, err := part.Write(image); err != nil || writer.Close() != nil {
return "", ErrServiceInvalid
}
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
client.endpoint,
&body,
)
if err != nil {
return "", ErrServiceInvalid
}
request.Header.Set("Content-Type", writer.FormDataContentType())
response, err := client.http.Do(request)
if err != nil {
return "", ErrServiceInvalid
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return "", ErrServiceInvalid
}
reply, err := readBounded(response.Body, maximumReplyBytes)
if err != nil {
return "", ErrServiceInvalid
}
code, err := parseCode(reply, response.Header.Get("Content-Type"))
if err != nil {
return "", ErrServiceInvalid
}
return code, nil
}
func parseCode(reply []byte, contentType string) (string, error) {
value := ""
if strings.HasPrefix(strings.ToLower(contentType), "application/json") {
var object map[string]any
if err := json.Unmarshal(reply, &object); err != nil {
return "", ErrServiceInvalid
}
for _, key := range []string{"text", "result", "data"} {
if candidate, ok := object[key].(string); ok {
value = candidate
break
}
}
} else {
value = string(reply)
}
value = strings.TrimSpace(value)
if value == "" || len([]byte(value)) > 64 || !utf8.ValidString(value) {
return "", ErrServiceInvalid
}
for _, character := range value {
if character < 32 || character == 127 {
return "", ErrServiceInvalid
}
}
return value, nil
}
func extension(contentType string) string {
switch contentType {
case "image/png":
return ".png"
case "image/jpeg":
return ".jpg"
default:
return ".img"
}
}
func readBounded(reader io.Reader, maximum int64) ([]byte, error) {
result, err := io.ReadAll(io.LimitReader(reader, maximum+1))
if err != nil || int64(len(result)) > maximum {
return nil, ErrServiceInvalid
}
return result, nil
}
@@ -0,0 +1,65 @@
package ocrapi
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestClientRecognizePostsMultipartAndParsesSupportedReplies(t *testing.T) {
for _, testCase := range []struct {
name string
contentType string
body string
want string
}{
{"text", "text/plain", "aB12", "aB12"},
{"json", "application/json", `{"text":"K9"}`, "K9"},
{"result", "application/json", `{"result":"Z7"}`, "Z7"},
} {
t.Run(testCase.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/ocr" {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
file, header, err := r.FormFile("file")
if err != nil || header.Filename != "captcha.png" {
t.Fatalf("FormFile() = %v / %#v", err, header)
}
content, _ := io.ReadAll(file)
if string(content) != "image-bytes" {
t.Fatalf("image content = %q", content)
}
w.Header().Set("Content-Type", testCase.contentType)
_, _ = w.Write([]byte(testCase.body))
}))
defer server.Close()
client, err := NewClient(server.URL+"/ocr", 0)
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
actual, err := client.Recognize(context.Background(), []byte("image-bytes"), "image/png")
if err != nil || actual != testCase.want {
t.Fatalf("Recognize() = %q, %v", actual, err)
}
})
}
}
func TestClientRecognizeFailsClosed(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/other", http.StatusFound)
}))
defer server.Close()
client, err := NewClient(server.URL+"/ocr", 0)
if err != nil {
t.Fatalf("NewClient() error = %v", err)
}
_, err = client.Recognize(context.Background(), []byte("image"), "image/png")
if !errors.Is(err, ErrServiceInvalid) {
t.Fatalf("Recognize() error = %v", err)
}
}
@@ -39,6 +39,11 @@ type SessionConfig struct {
Timeout time.Duration
CaptchaTTL time.Duration
AllowInsecureHTTP bool // Used only by isolated httptest contracts.
CaptchaRecognizer CaptchaRecognizer
}
type CaptchaRecognizer interface {
Recognize(context.Context, []byte, string) (string, error)
}
type SessionStatus struct {
@@ -54,6 +59,7 @@ type CaptchaImage struct {
}
type SessionManager struct {
authMu sync.Mutex
mu sync.Mutex
baseURL string
username string
@@ -62,6 +68,7 @@ type SessionManager struct {
captchaTTL time.Duration
headers http.Header
http *http.Client
recognizer CaptchaRecognizer
authenticated bool
captchaTicket string
captchaContent []byte
@@ -111,9 +118,39 @@ func NewSessionManager(config SessionConfig) (*SessionManager, error) {
return http.ErrUseLastResponse
},
},
recognizer: config.CaptchaRecognizer,
}, nil
}
// EnsureAuthenticated establishes the single in-memory ERP session only when
// the current cookie jar cannot be validated.
func (manager *SessionManager) EnsureAuthenticated(ctx context.Context) error {
manager.authMu.Lock()
defer manager.authMu.Unlock()
if _, err := manager.Validate(ctx); err == nil {
return nil
} else if !errors.Is(err, domain.ErrFreightSourceSessionNeeded) {
return err
}
if manager.recognizer == nil {
return domain.ErrFreightSourceOCRInvalid
}
status, err := manager.FetchCaptcha(ctx)
if err != nil {
return err
}
image, err := manager.OpenCaptcha(status.CaptchaTicket)
if err != nil {
return domain.ErrFreightSourceProtocol
}
code, err := manager.recognizer.Recognize(ctx, image.Content, image.ContentType)
if err != nil || !validCaptchaCode(code) {
return domain.ErrFreightSourceOCRInvalid
}
_, err = manager.Login(ctx, status.CaptchaTicket, code)
return err
}
func (manager *SessionManager) Status() SessionStatus {
manager.mu.Lock()
defer manager.mu.Unlock()
@@ -166,6 +166,66 @@ func TestSessionManagerSerializesCaptchaRequests(t *testing.T) {
}
}
func TestSessionManagerEnsureAuthenticatedUsesRecognizerOnce(t *testing.T) {
var captchaCalls, loginCalls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case CaptchaPath:
captchaCalls++
http.SetCookie(w, &http.Cookie{Name: "captcha", Value: "ready", Path: "/"})
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("captcha-image"))
case LoginPath:
loginCalls++
http.SetCookie(w, &http.Cookie{Name: "authenticated", Value: "yes", Path: "/"})
_, _ = w.Write([]byte(`{"status":true,"data":{"user":{"id":12}}}`))
case UserPath:
_, _ = w.Write([]byte(`{"status":true,"data":{"id":12}}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
recognizer := &fixedRecognizer{code: "1234"}
manager := testSessionManager(t, server.URL, "test-user", "test-password")
manager.recognizer = recognizer
if err := manager.EnsureAuthenticated(context.Background()); err != nil {
t.Fatalf("EnsureAuthenticated() error = %v", err)
}
if !manager.Status().Authenticated || recognizer.calls != 1 || captchaCalls != 1 || loginCalls != 1 {
t.Fatalf("state/calls = %+v / %d / %d / %d", manager.Status(), recognizer.calls, captchaCalls, loginCalls)
}
if err := manager.EnsureAuthenticated(context.Background()); err != nil {
t.Fatalf("second EnsureAuthenticated() error = %v", err)
}
if recognizer.calls != 1 || captchaCalls != 1 || loginCalls != 1 {
t.Fatalf("second call repeated OCR/login = %d / %d / %d", recognizer.calls, captchaCalls, loginCalls)
}
}
func TestSessionManagerEnsureAuthenticatedRequiresRecognizer(t *testing.T) {
manager := testSessionManager(t, "https://erp.example.test", "test-user", "test-password")
err := manager.EnsureAuthenticated(context.Background())
if !errors.Is(err, domain.ErrFreightSourceOCRInvalid) {
t.Fatalf("EnsureAuthenticated() error = %v", err)
}
}
type fixedRecognizer struct {
code string
err error
calls int
}
func (recognizer *fixedRecognizer) Recognize(
context.Context,
[]byte,
string,
) (string, error) {
recognizer.calls++
return recognizer.code, recognizer.err
}
func testSessionManager(
t *testing.T,
baseURL, username, password string,
@@ -89,9 +89,6 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
handler.createProcurementTask,
)
}
if services.ERP != nil {
registerERPAdminAPI(routes, handler)
}
return nil
}
@@ -233,8 +233,7 @@ func denyAdminSession(ctx *gin.Context) {
(next != "/tasks" && !strings.HasPrefix(next, "/tasks?") &&
!strings.HasPrefix(next, "/tasks/") &&
next != "/freight" && !strings.HasPrefix(next, "/freight?") &&
!strings.HasPrefix(next, "/freight/") &&
next != "/erp" && !strings.HasPrefix(next, "/erp?")) {
!strings.HasPrefix(next, "/freight/")) {
next = "/tasks"
}
ctx.Abort()
@@ -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
@@ -30,6 +30,10 @@ type FreightService struct {
timeout time.Duration
}
type FreightSourcePreflight interface {
EnsureAuthenticated(context.Context) error
}
type CreateFreightSyncCommand struct {
CreatorSubject string
ActorUserID string
@@ -101,6 +105,9 @@ func (service *FreightService) CreateOrderSync(
fields,
)
}
if err := service.ensureSource(ctx); err != nil {
return CreateFreightSyncResult{}, err
}
runID, err := service.ids.NewID()
if err != nil {
return CreateFreightSyncResult{}, wrapRepositoryError(err)
@@ -166,6 +173,9 @@ func (service *FreightService) CreateDateSync(
fields,
)
}
if err := service.ensureSource(ctx); err != nil {
return CreateFreightSyncResult{}, err
}
now := service.clock.Now().UTC()
location, err := time.LoadLocation("Asia/Shanghai")
@@ -278,6 +288,24 @@ func (service *FreightService) CreateDateSync(
return CreateFreightSyncResult{Run: run, Replayed: !created}, nil
}
func (service *FreightService) ensureSource(ctx context.Context) error {
preflight, ok := service.source.(FreightSourcePreflight)
if !ok {
return nil
}
if err := preflight.EnsureAuthenticated(ctx); err != nil {
result := newError(
ErrorKindUnavailable,
freightSourceErrorCode(err),
"freight source session is unavailable",
err,
)
result.Retryable = true
return result
}
return nil
}
func (service *FreightService) execute(run domain.FreightSyncRun) {
ctx, cancel := context.WithTimeout(context.Background(), service.timeout)
defer cancel()
@@ -679,6 +707,8 @@ func freightSourceErrorCode(err error) string {
return "ERP_FREIGHT_NOT_FOUND"
case errors.Is(err, domain.ErrFreightSourceProtocol):
return "ERP_RESPONSE_INVALID"
case errors.Is(err, domain.ErrFreightSourceOCRInvalid):
return "OCR_SERVICE_INVALID"
default:
return "ERP_UNAVAILABLE"
}
@@ -64,6 +64,7 @@ func TestFreightSourceErrorCodesAreSourceNeutral(t *testing.T) {
{domain.ErrFreightSourceSessionNeeded, "ERP_SESSION_REQUIRED"},
{domain.ErrFreightSourceNotFound, "ERP_FREIGHT_NOT_FOUND"},
{domain.ErrFreightSourceProtocol, "ERP_RESPONSE_INVALID"},
{domain.ErrFreightSourceOCRInvalid, "OCR_SERVICE_INVALID"},
{errors.New("temporary source failure"), "ERP_UNAVAILABLE"},
}
for _, testCase := range cases {
@@ -73,6 +74,29 @@ func TestFreightSourceErrorCodesAreSourceNeutral(t *testing.T) {
}
}
func TestCreateFreightOrderSyncStopsBeforePersistingWhenOCRIsInvalid(t *testing.T) {
repository := &dateCaptureRepository{}
service, err := NewFreightService(
repository,
&recordingDateSource{ensureErr: domain.ErrFreightSourceOCRInvalid},
fakeClock{},
&sequenceIDs{},
time.Minute,
)
if err != nil {
t.Fatalf("NewFreightService() error = %v", err)
}
_, err = service.CreateOrderSync(context.Background(), CreateFreightSyncCommand{
CreatorSubject: "local-admin",
ActorUserID: "00000000-0000-4000-8000-000000000099",
IdempotencyKey: "ocr-invalid",
OrderNumber: "ORDER-123",
})
if !errors.Is(err, domain.ErrFreightSourceOCRInvalid) || repository.createCalls != 0 {
t.Fatalf("CreateOrderSync() error/calls = %v / %d", err, repository.createCalls)
}
}
func TestFreightDateQuerySplitsIntoSevenDayWindows(t *testing.T) {
source := &recordingDateSource{}
service := &FreightService{source: source}
@@ -230,8 +254,15 @@ func validFreightSource() domain.FreightSourceBatch {
var errDateSourceFailure = errors.New("date source failed")
type recordingDateSource struct {
calls [][2]string
failOnCall int
calls [][2]string
failOnCall int
ensureCalls int
ensureErr error
}
func (source *recordingDateSource) EnsureAuthenticated(context.Context) error {
source.ensureCalls++
return source.ensureErr
}
func (source *recordingDateSource) QueryOrder(
@@ -261,7 +292,8 @@ func (source *recordingDateSource) QueryCreatedRange(
}
type dateCaptureRepository struct {
watermark *domain.FreightSyncWatermark
watermark *domain.FreightSyncWatermark
createCalls int
}
func (repository *dateCaptureRepository) CreateFreightSync(
@@ -269,6 +301,7 @@ func (repository *dateCaptureRepository) CreateFreightSync(
run domain.FreightSyncRun,
_, _ string,
) (domain.FreightSyncRun, bool, error) {
repository.createCalls++
return run, false, nil
}