diff --git a/backend-api/.env.example b/backend-api/.env.example index 27f8743..baba601 100644 --- a/backend-api/.env.example +++ b/backend-api/.env.example @@ -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 diff --git a/backend-api/README.md b/backend-api/README.md index 994a632..2d31615 100644 --- a/backend-api/README.md +++ b/backend-api/README.md @@ -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 diff --git a/backend-api/cmd/api/main.go b/backend-api/cmd/api/main.go index fd17847..cdf3408 100644 --- a/backend-api/cmd/api/main.go +++ b/backend-api/cmd/api/main.go @@ -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, ) diff --git a/backend-api/cmd/api/main_test.go b/backend-api/cmd/api/main_test.go index 38d70ff..65d501b 100644 --- a/backend-api/cmd/api/main_test.go +++ b/backend-api/cmd/api/main_test.go @@ -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) diff --git a/backend-api/internal/config/config.go b/backend-api/internal/config/config.go index e76b615..d0f0fa0 100644 --- a/backend-api/internal/config/config.go +++ b/backend-api/internal/config/config.go @@ -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 == "" || diff --git a/backend-api/internal/config/envfile.go b/backend-api/internal/config/envfile.go index a1572ee..3300ce5 100644 --- a/backend-api/internal/config/envfile.go +++ b/backend-api/internal/config/envfile.go @@ -122,7 +122,8 @@ func isERPEnvironmentName(name string) bool { switch name { case ShunyunbaoURLEnvironment, ShunyunbaoUsernameEnvironment, - ShunyunbaoPasswordEnvironment: + ShunyunbaoPasswordEnvironment, + OCRAPIURLEnvironment: return true default: return false diff --git a/backend-api/internal/config/envfile_test.go b/backend-api/internal/config/envfile_test.go index faae6d1..1341d35 100644 --- a/backend-api/internal/config/envfile_test.go +++ b/backend-api/internal/config/envfile_test.go @@ -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 { diff --git a/backend-api/internal/domain/freight_source_errors.go b/backend-api/internal/domain/freight_source_errors.go index 17ff92c..4cabc1d 100644 --- a/backend-api/internal/domain/freight_source_errors.go +++ b/backend-api/internal/domain/freight_source_errors.go @@ -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") ) diff --git a/backend-api/internal/platform/ocrapi/client.go b/backend-api/internal/platform/ocrapi/client.go new file mode 100644 index 0000000..1bd1afb --- /dev/null +++ b/backend-api/internal/platform/ocrapi/client.go @@ -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 +} diff --git a/backend-api/internal/platform/ocrapi/client_test.go b/backend-api/internal/platform/ocrapi/client_test.go new file mode 100644 index 0000000..0b41938 --- /dev/null +++ b/backend-api/internal/platform/ocrapi/client_test.go @@ -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) + } +} diff --git a/backend-api/internal/platform/shunyunbao/session.go b/backend-api/internal/platform/shunyunbao/session.go index bccc2de..1304f58 100644 --- a/backend-api/internal/platform/shunyunbao/session.go +++ b/backend-api/internal/platform/shunyunbao/session.go @@ -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() diff --git a/backend-api/internal/platform/shunyunbao/session_test.go b/backend-api/internal/platform/shunyunbao/session_test.go index 32354c1..692bf01 100644 --- a/backend-api/internal/platform/shunyunbao/session_test.go +++ b/backend-api/internal/platform/shunyunbao/session_test.go @@ -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, diff --git a/backend-api/internal/transport/httpapi/admin_handlers.go b/backend-api/internal/transport/httpapi/admin_handlers.go index 34d8055..9050b09 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers.go +++ b/backend-api/internal/transport/httpapi/admin_handlers.go @@ -89,9 +89,6 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error { handler.createProcurementTask, ) } - if services.ERP != nil { - registerERPAdminAPI(routes, handler) - } return nil } diff --git a/backend-api/internal/transport/httpapi/auth_handlers.go b/backend-api/internal/transport/httpapi/auth_handlers.go index 593a0a0..6275a58 100644 --- a/backend-api/internal/transport/httpapi/auth_handlers.go +++ b/backend-api/internal/transport/httpapi/auth_handlers.go @@ -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() diff --git a/backend-api/internal/transport/webui/auth_handler.go b/backend-api/internal/transport/webui/auth_handler.go index a4c2aa7..e509ca5 100644 --- a/backend-api/internal/transport/webui/auth_handler.go +++ b/backend-api/internal/transport/webui/auth_handler.go @@ -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() diff --git a/backend-api/internal/transport/webui/handler.go b/backend-api/internal/transport/webui/handler.go index d62a84d..9a6a715 100644 --- a/backend-api/internal/transport/webui/handler.go +++ b/backend-api/internal/transport/webui/handler.go @@ -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 } diff --git a/backend-api/internal/transport/webui/handler_test.go b/backend-api/internal/transport/webui/handler_test.go index b12ac15..cc287ce 100644 --- a/backend-api/internal/transport/webui/handler_test.go +++ b/backend-api/internal/transport/webui/handler_test.go @@ -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) } } diff --git a/backend-api/internal/transport/webui/templates/erp-connection.gohtml b/backend-api/internal/transport/webui/templates/erp-connection.gohtml deleted file mode 100644 index b89db9c..0000000 --- a/backend-api/internal/transport/webui/templates/erp-connection.gohtml +++ /dev/null @@ -1,56 +0,0 @@ -{{define "erp-connection"}} - - - - {{.Page.Title}} - 采购任务管理 - {{template "document-head" .}} - - - {{template "site-header" .}} -
-
-
-

ERP 连接

-

建立本次服务进程内的顺运宝会话

-
- 返回导入 -
- {{if .Error}}{{end}} - {{if .Notice}}
{{.Notice}}
{{end}} - {{if .Status.Authenticated}} -
-

已连接

-

当前后端进程持有受控 ERP 会话。重启后需要重新获取验证码登录。

-
- {{else if not .Status.Configured}} -
-

尚未配置

-

请在后端启动环境中配置顺运宝账号和密码后重启服务。

-
- {{else if .Status.CaptchaReady}} -
-

输入验证码

- ERP 验证码 -
- - -
- - -
- -
-
- {{else}} -
- -

获取验证码

-

验证码一次有效,仅用于当前后端进程中的 ERP 会话。

- -
- {{end}} -
- - -{{end}} diff --git a/backend-api/internal/transport/webui/templates/freight-import.gohtml b/backend-api/internal/transport/webui/templates/freight-import.gohtml index 3ae2bff..020e9b2 100644 --- a/backend-api/internal/transport/webui/templates/freight-import.gohtml +++ b/backend-api/internal/transport/webui/templates/freight-import.gohtml @@ -16,6 +16,13 @@ 返回列表 {{if .Error}}{{end}} + {{if eq .ErrorCode "OCR_SERVICE_INVALID"}} + +

OCR 服务无效

+

{{.Error}}

+
+
+ {{end}} {{if .Sync}}

同步状态

diff --git a/backend-api/internal/transport/webui/templates/partials.gohtml b/backend-api/internal/transport/webui/templates/partials.gohtml index e70a123..c9ca760 100644 --- a/backend-api/internal/transport/webui/templates/partials.gohtml +++ b/backend-api/internal/transport/webui/templates/partials.gohtml @@ -18,7 +18,6 @@ 任务列表 新建任务 ERP 货运 - ERP 连接 {{if .Page.CSRFToken}}
diff --git a/backend-api/internal/transport/webui/types.go b/backend-api/internal/transport/webui/types.go index c93a1de..79fd7a6 100644 --- a/backend-api/internal/transport/webui/types.go +++ b/backend-api/internal/transport/webui/types.go @@ -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. diff --git a/backend-api/internal/transport/webui/usecase_adapter.go b/backend-api/internal/transport/webui/usecase_adapter.go index b642333..4f0eeb3 100644 --- a/backend-api/internal/transport/webui/usecase_adapter.go +++ b/backend-api/internal/transport/webui/usecase_adapter.go @@ -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 diff --git a/backend-api/internal/usecase/freight_service.go b/backend-api/internal/usecase/freight_service.go index 69197ed..035b496 100644 --- a/backend-api/internal/usecase/freight_service.go +++ b/backend-api/internal/usecase/freight_service.go @@ -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" } diff --git a/backend-api/internal/usecase/freight_service_test.go b/backend-api/internal/usecase/freight_service_test.go index cff16c7..c0297f4 100644 --- a/backend-api/internal/usecase/freight_service_test.go +++ b/backend-api/internal/usecase/freight_service_test.go @@ -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 } diff --git a/docs/05-coding-rules.md b/docs/05-coding-rules.md index c401c81..f64abe7 100644 --- a/docs/05-coding-rules.md +++ b/docs/05-coding-rules.md @@ -118,7 +118,8 @@ - ERP adapter 只能把版本化 allowlist 归一化对象交给 `FreightSource`;其 HTTP 会话只在 Go API 进程内。收件人、电话、 地址、完整响应、Cookie、JWT、账号、密码和验证码不得进入领域对象、错误、日志、 - fixture、SQLite、浏览器或 VLM。验证码必须由人员输入,禁止 OCR、猜测或重放。 + fixture、SQLite、浏览器或 VLM。T-230 仅允许 API 进程把 ERP 验证码图片一次性发送给 + 受控 `CMROUBAO_OCR_API_URL`;不持久化结果、不重试、不轮询、不暴露人工验证码接口。 ## 7. 安全与隐私 diff --git a/docs/api.md b/docs/api.md index 159028d..95bc1b0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -198,24 +198,11 @@ T-203 成功返回 `201`。使用相同 `Idempotency-Key` 和相同图片内容 不得返回 receiver、receiverTel、receiverAddr、Cookie、JWT、ERP 用户资料或完整原始 对象。多商品必须全部保留;缺失详情返回协议错误,不允许部分成功。 -### Go ERP 会话(T-226) +### Go ERP 会话(T-230) -以下接口均要求 ADMIN session;所有 `POST` 还要求现有 `X-CSRF-Token` 与 CSRF -Cookie。它们只管理单个后端进程内的顺运宝会话,不创建货运同步,也不下发 ERP 账号、 -密码、Cookie、JWT、完整用户资料或原始响应。 - -| 接口 | 说明 | -| --- | --- | -| `GET /api/v1/erp-session` | 返回 `configured`、`authenticated`、`captcha_ready` 三个布尔状态。 | -| `POST /api/v1/erp-session/captcha` | 同一服务端 Cookie jar 获取一次验证码,返回短期 `captcha_ticket` 和仅供当前 ADMIN 读取图片的 `captcha_url`。 | -| `GET /api/v1/erp-session/captcha/{ticket}` | 返回 `image/*` 验证码,始终 `Cache-Control: no-store`。ticket 过期或不匹配返回稳定匿名错误。 | -| `POST /api/v1/erp-session/login` | 仅接受 `{"captcha_ticket":"...","captcha_code":"..."}`,使用受控本地配置中的账号/密码完成登录和用户校验。 | - -成功状态响应不包含 ticket,验证码接口以外也不会返回验证码图片。稳定错误为 -`ERP_NOT_CONFIGURED`、`ERP_SESSION_REQUIRED`、`ERP_CAPTCHA_INVALID`、 -`ERP_LOGIN_REJECTED`、`ERP_RESPONSE_INVALID` 和 `ERP_UNAVAILABLE`;均不包含 ERP -正文或凭证。管理员 Web 的等价入口为 `/erp`、`/erp/captcha` 和 `/erp/login`,表单没有 -账号或密码字段。 +`/erp`、`/erp/captcha`、`/erp/login` 和 `/api/v1/erp-session*` 不再暴露。创建货运同步 +前,单一 API 进程在未认证时以同一 Cookie jar 获取验证码、调用受控 OCR、登录并校验用户。 +验证码图片、OCR 文字、Cookie、JWT、账号、密码和原始响应都不返回浏览器/API,也不写 SQLite。 ERP 配置来源: @@ -226,8 +213,12 @@ ERP 配置来源: - `cmd/api` 只把标准工作目录 `backend-api/.env` 中的上述三个值作为回退。系统环境变量 优先;文件缺失不报错。该解析器不修改全局环境,不加载数据库、HTTP、TLS 或 authctl 密码,也不支持变量展开、命令执行或任意路径。 +- `CMROUBAO_OCR_API_URL` 为可选 OCR `POST` endpoint;只允许 HTTPS,或本机 loopback HTTP, + 不允许 userinfo、query、fragment 或重定向。请求使用 `multipart/form-data` 的 `file` 字段; + OCR 不可达、超时、非成功、过大或响应无有效文本时,货运创建返回 `503 OCR_SERVICE_INVALID` + 且不会创建同步记录。 -会话不写 SQLite、不使用 OCR 或 Redis;服务重启后必须重新人工登录。 +会话不写 SQLite 或 Redis;服务重启后在下一次货运导入前重新经 OCR 建立会话。 ### `POST /api/v1/freight-syncs` diff --git a/docs/current-state.md b/docs/current-state.md index 059fdbb..3b0bcec 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,7 +5,7 @@ ## 当前快照 - 日期:2026-07-29 -- 阶段:T-230 待将本机 OCR 接入 ERP 自动登录并移除人工 `/erp` 连接模块 +- 阶段:T-230 已接入本机 OCR ERP 自动登录并移除人工 `/erp` 连接模块 - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219 均按文档提交、实现提交的顺序纳入历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 @@ -21,7 +21,8 @@ 单号/日期查询、分页、详情批量和字段 allowlist,并使货运用例依赖来源中立错误。T-226 已增加受锁保护的 Go 内存 Cookie jar、验证码 ticket、登录和用户校验,以及 ADMIN 的 `/erp` 页面/API;账号密码可由 `backend-api/.env` 的受控 `CMROUBAO_SHUNYUNBAO_*` - 回退提供,进程环境变量优先,重启后需人工重新登录。T-227 已将其作为 `FreightSource`, + 回退提供,进程环境变量优先。T-230 改为导入前由受控 `CMROUBAO_OCR_API_URL` 获取验证码 + 文字并建立内存会话,人工 `/erp` 模块已删除。T-227 已将其作为 `FreightSource`, 查询先校验会话、再执行有界分页/详情 批量并返回 allowlist;T-228 已删除旧 Python Connector、loopback 端口和共享 API Key; 未访问真实 ERP。 @@ -187,6 +188,7 @@ | `docs/tasks/T-227.md` | DONE | Go 直连顺运宝查询接入货运同步 | | `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/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | @@ -198,11 +200,10 @@ ## 任务摘要 - 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。 -- 已完成:另含 T-220 至 T-229 ERP 契约、货运存储、采购需求生成、日期增量同步、Go - 直连协议、人工验证码会话、直连 `FreightSource`、旧 Connector 清理和受控本地凭证加载。 +- 已完成:另含 T-220 至 T-230 ERP 契约、货运存储、采购需求生成、日期增量同步、Go + 直连协议、OCR 会话预检、直连 `FreightSource`、旧 Connector 清理和受控本地凭证加载。 - 进行中:无。 -- 下一步:T-230 在按单号导入前受控调用本机 OCR 并建立 ERP 会话;完成后确认开放 API、 - OCR/ERP 数据使用权限,并以受控单号执行一次不记录订单内容的 smoke。 +- 下一步:确认开放 API、OCR/ERP 数据使用权限,并以受控单号执行一次不记录订单内容的 smoke。 ## 当前可运行内容 diff --git a/docs/integrations/shunyunbao-contract.md b/docs/integrations/shunyunbao-contract.md index 11ad088..714326e 100644 --- a/docs/integrations/shunyunbao-contract.md +++ b/docs/integrations/shunyunbao-contract.md @@ -37,8 +37,9 @@ T-225 至 T-227 在 `backend-api/internal/platform/shunyunbao` 用脱敏 fixture - 所有结果必须经 Go allowlist 归一化;fixture 专门含收件信息、Cookie/JWT 标记值, 测试断言它们不会出现在输出或错误里。 -Go 直连的验证码、登录、Cookie jar 和查询 source 都在 API 进程内。不得用本地 OCR 或 -Redis 取代人工验证码流程;真实线上请求不属于自动化测试。 +Go 直连的验证码、登录、Cookie jar 和查询 source 都在 API 进程内。T-230 在未认证时仅可将 +验证码图片一次提交给受控本机/HTTPS OCR endpoint,结果只用于当前登录请求;不得写入 Redis、 +SQLite、日志或浏览器,也不得轮询或重试。真实线上请求不属于自动化测试。 ## 身份和规范字段 diff --git a/docs/routes.md b/docs/routes.md index 78709ce..ca3312a 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -14,10 +14,9 @@ T-202 的离线 P0 页面入口见[原型索引](design/index.html)。原型仅 | `/freight` | ERP 货运列表 | 查看已导入货运单并进入全部商品明细 | US-011 | IX-012 | | `/freight/import` | ERP 货运导入 | 创建精确单号、最多 7 天日期范围或“同步至现在”任务,并查看成功水位 | US-011 | IX-012 | | `/freight/{id}` | ERP 货运详情 | 查看全部商品明细、来源变化、补图和采购任务生成状态 | US-011、US-012 | IX-012、IX-013 | -| `/erp` | ERP 连接 | 查看匿名会话状态、获取验证码并人工输入验证码;不显示或编辑 ERP 凭证 | US-011 | IX-012 | MVP 登录后默认进入 `/tasks`。未登录访问受保护页面时跳转 `/login` 并携带安全的 -站内返回路径;只接受 `/tasks`、`/freight`、`/erp` 及其本站子路径,拒绝绝对 URL、`//` +站内返回路径;只接受 `/tasks`、`/freight` 及其本站子路径,拒绝绝对 URL、`//` 和反斜杠。 不存在和无权限必须使用不同内部原因,但页面均不得泄露任务内容。 @@ -92,7 +91,6 @@ T-207 的 events/candidates/complete/fail 接受原设备对授权内已产生 | `PendingPaymentSummary` | 管理 Web/App | 只读展示对账订单与人工付款提醒,不提供付款或重提动作 | | `FreightSyncForm` | 管理 Web | 创建精确单号/日期同步记录、查看成功水位,不持有 ERP 凭证 | | `FreightItemReview` | 管理 Web | 显示规范化来源字段、缺失项和采购任务生成操作 | -| `ERPConnectionPanel` | 管理 Web | 仅展示匿名连接状态、验证码图片和验证码输入,不接收 ERP 账号或密码 | 具体状态反馈以[交互清单](08-interaction-checklist.md)为准,组件命名可在接入真实框架后 调整并同步本文。 diff --git a/docs/tasks/T-230.md b/docs/tasks/T-230.md index 143584f..a7c1f2d 100644 --- a/docs/tasks/T-230.md +++ b/docs/tasks/T-230.md @@ -4,7 +4,7 @@ title: OCR 自动登录顺运宝并移除人工连接页 phase: 2 deps: - T-229 -status: TODO +status: DONE created: 2026-07-29 context_ref: f0fbdba work_branch: null @@ -62,15 +62,15 @@ write_paths: ## 验收要点 -- [ ] `.env.example` 包含本机 OCR URL,环境变量覆盖 `.env`;无效 URL 不启动,真实 OCR +- [x] `.env.example` 包含本机 OCR URL,环境变量覆盖 `.env`;无效 URL 不启动,真实 OCR endpoint 不进入 Git、日志、浏览器或数据库。 -- [ ] OCR client 的 multipart 字段、超时、重定向拒绝、上限、纯文本/JSON 结果与敏感错误均有 +- [x] OCR client 的 multipart 字段、超时、重定向拒绝、上限、纯文本/JSON 结果与敏感错误均有 fixture 测试。 -- [ ] 未认证的按单号导入先完成 OCR 登录再创建同步;OCR 失败不创建同步且 Admin 返回可关闭 +- [x] 未认证的按单号导入先完成 OCR 登录再创建同步;OCR 失败不创建同步且 Admin 返回可关闭 `OCR_SERVICE_INVALID` 弹窗并保留表单;已认证会话不重复 OCR。 -- [ ] `/erp`、人工验证码图片/文本和 ERP connection JSON 路由不再暴露;API 不返回验证码、 +- [x] `/erp`、人工验证码图片/文本和 ERP connection JSON 路由不再暴露;API 不返回验证码、 Cookie、账号、密码或 OCR 原文。 -- [ ] `go test ./...`、`go test -race ./...`、`go vet ./...` 和 API/migrate/authctl 构建通过。 +- [x] `go test ./...`、`go test -race ./...`、`go vet ./...` 和 API/migrate/authctl 构建通过。 ## 边界 @@ -83,3 +83,5 @@ write_paths: - 2026-07-29:创建任务;本机发现 `127.0.0.1:8000` 正在监听,但缺少可安全复现的验证码 fixture,线上 OCR/ERP 验证留给部署 smoke。 +- 2026-07-29:实现受控 OCR multipart client、ERP 自动会话预检、导入失败弹窗和公开人工 + ERP 路由移除;测试与构建命令见实现提交。