feat(t230): use OCR for ERP session login
This commit is contained in:
@@ -2,3 +2,4 @@
|
|||||||
CMROUBAO_SHUNYUNBAO_URL=https://www.shunyunbaoerp.com
|
CMROUBAO_SHUNYUNBAO_URL=https://www.shunyunbaoerp.com
|
||||||
CMROUBAO_SHUNYUNBAO_USERNAME=
|
CMROUBAO_SHUNYUNBAO_USERNAME=
|
||||||
CMROUBAO_SHUNYUNBAO_PASSWORD=
|
CMROUBAO_SHUNYUNBAO_PASSWORD=
|
||||||
|
CMROUBAO_OCR_API_URL=http://127.0.0.1:8000/ocr
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ start/运行续租/release 和取消安全确认。
|
|||||||
| `CMROUBAO_SHUNYUNBAO_URL` | `https://www.shunyunbaoerp.com` | 顺运宝 HTTPS origin |
|
| `CMROUBAO_SHUNYUNBAO_URL` | `https://www.shunyunbaoerp.com` | 顺运宝 HTTPS origin |
|
||||||
| `CMROUBAO_SHUNYUNBAO_USERNAME` | 无 | 顺运宝账号;必须与密码同时设置 |
|
| `CMROUBAO_SHUNYUNBAO_USERNAME` | 无 | 顺运宝账号;必须与密码同时设置 |
|
||||||
| `CMROUBAO_SHUNYUNBAO_PASSWORD` | 无 | 顺运宝密码;必须与账号同时设置 |
|
| `CMROUBAO_SHUNYUNBAO_PASSWORD` | 无 | 顺运宝密码;必须与账号同时设置 |
|
||||||
|
| `CMROUBAO_OCR_API_URL` | 无 | OCR `POST` endpoint;HTTP 只允许本机 loopback |
|
||||||
|
|
||||||
### 本地 ERP `.env`
|
### 本地 ERP `.env`
|
||||||
|
|
||||||
@@ -39,14 +40,19 @@ Copy-Item .env.example .env
|
|||||||
# 编辑 .env,填入顺运宝账号和密码
|
# 编辑 .env,填入顺运宝账号和密码
|
||||||
```
|
```
|
||||||
|
|
||||||
该文件只允许上述三个 `CMROUBAO_SHUNYUNBAO_*` 值,进程环境变量优先于同名 `.env` 值。
|
该文件只允许上述 `CMROUBAO_SHUNYUNBAO_*` 值和 `CMROUBAO_OCR_API_URL`,进程环境变量
|
||||||
|
优先于同名 `.env` 值。
|
||||||
它不配置数据库、监听/TLS、`authctl` 密码或其他应用选项,也不会修改全局进程环境。缺失
|
它不配置数据库、监听/TLS、`authctl` 密码或其他应用选项,也不会修改全局进程环境。缺失
|
||||||
`.env` 时 ERP 保持未配置,其他本地功能仍可启动。仅支持空行、整行 `#` 注释和 `KEY=VALUE`
|
`.env` 时 ERP/OCR 保持未配置,其他本地功能仍可启动。仅支持空行、整行 `#` 注释和 `KEY=VALUE`
|
||||||
(需要保留空格或 `#` 的值可使用成对单/双引号);不支持变量展开、命令或行内注释。
|
(需要保留空格或 `#` 的值可使用成对单/双引号);不支持变量展开、命令或行内注释。
|
||||||
|
|
||||||
`.env` 已被 Git 忽略,应只保存在本机受限目录;`.env.example` 不得填入真实凭证。`var/`
|
`.env` 已被 Git 忽略,应只保存在本机受限目录;`.env.example` 不得填入真实凭证。`var/`
|
||||||
运行数据同样不得提交。
|
运行数据同样不得提交。
|
||||||
|
|
||||||
|
按单号导入货运时,API 在创建同步前使用该 OCR endpoint 读取 ERP 验证码并建立进程内会话。
|
||||||
|
OCR 不可用时导入页显示 `OCR 服务无效`,不会创建同步记录;验证码、图片、Cookie 和识别文字
|
||||||
|
不写入数据库、日志或浏览器。
|
||||||
|
|
||||||
## 命令
|
## 命令
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"cmroubao/backend-api/internal/platform/assetstore"
|
"cmroubao/backend-api/internal/platform/assetstore"
|
||||||
"cmroubao/backend-api/internal/platform/database"
|
"cmroubao/backend-api/internal/platform/database"
|
||||||
"cmroubao/backend-api/internal/platform/migration"
|
"cmroubao/backend-api/internal/platform/migration"
|
||||||
|
"cmroubao/backend-api/internal/platform/ocrapi"
|
||||||
"cmroubao/backend-api/internal/platform/password"
|
"cmroubao/backend-api/internal/platform/password"
|
||||||
"cmroubao/backend-api/internal/platform/shunyunbao"
|
"cmroubao/backend-api/internal/platform/shunyunbao"
|
||||||
repository "cmroubao/backend-api/internal/repository/sqlite"
|
repository "cmroubao/backend-api/internal/repository/sqlite"
|
||||||
@@ -232,11 +233,16 @@ func buildRouter(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
ocr, err := ocrapi.NewClient(cfg.OCRAPIURL, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
erpSession, err := shunyunbao.NewSessionManager(shunyunbao.SessionConfig{
|
erpSession, err := shunyunbao.NewSessionManager(shunyunbao.SessionConfig{
|
||||||
BaseURL: cfg.ShunyunbaoURL,
|
BaseURL: cfg.ShunyunbaoURL,
|
||||||
Username: cfg.ShunyunbaoUsername,
|
Username: cfg.ShunyunbaoUsername,
|
||||||
Password: cfg.ShunyunbaoPassword,
|
Password: cfg.ShunyunbaoPassword,
|
||||||
Timeout: cfg.ShunyunbaoTimeout,
|
Timeout: cfg.ShunyunbaoTimeout,
|
||||||
|
CaptchaRecognizer: ocr,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -296,7 +302,6 @@ func buildRouter(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
webService.SetProcurement(procurement)
|
webService.SetProcurement(procurement)
|
||||||
webService.SetERPConnection(erpSession)
|
|
||||||
renderer, err := webui.NewRenderer()
|
renderer, err := webui.NewRenderer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -340,7 +345,6 @@ func buildRouter(
|
|||||||
Authorizations: authorizations,
|
Authorizations: authorizations,
|
||||||
Freight: freight,
|
Freight: freight,
|
||||||
Procurement: procurement,
|
Procurement: procurement,
|
||||||
ERP: erpSession,
|
|
||||||
},
|
},
|
||||||
webHandler,
|
webHandler,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ func TestBuildRouterRegistersProtectedLogoutRoute(t *testing.T) {
|
|||||||
response.Header().Get("Location"),
|
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)
|
request = httptest.NewRequest(http.MethodGet, target, nil)
|
||||||
response = httptest.NewRecorder()
|
response = httptest.NewRecorder()
|
||||||
router.ServeHTTP(response, request)
|
router.ServeHTTP(response, request)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const (
|
|||||||
ShunyunbaoURLEnvironment = "CMROUBAO_SHUNYUNBAO_URL"
|
ShunyunbaoURLEnvironment = "CMROUBAO_SHUNYUNBAO_URL"
|
||||||
ShunyunbaoUsernameEnvironment = "CMROUBAO_SHUNYUNBAO_USERNAME"
|
ShunyunbaoUsernameEnvironment = "CMROUBAO_SHUNYUNBAO_USERNAME"
|
||||||
ShunyunbaoPasswordEnvironment = "CMROUBAO_SHUNYUNBAO_PASSWORD"
|
ShunyunbaoPasswordEnvironment = "CMROUBAO_SHUNYUNBAO_PASSWORD"
|
||||||
|
OCRAPIURLEnvironment = "CMROUBAO_OCR_API_URL"
|
||||||
|
|
||||||
defaultHTTPAddress = "127.0.0.1:8080"
|
defaultHTTPAddress = "127.0.0.1:8080"
|
||||||
defaultDatabasePath = "var/cmroubao.db"
|
defaultDatabasePath = "var/cmroubao.db"
|
||||||
@@ -52,6 +53,7 @@ type Config struct {
|
|||||||
ShunyunbaoURL string
|
ShunyunbaoURL string
|
||||||
ShunyunbaoUsername string
|
ShunyunbaoUsername string
|
||||||
ShunyunbaoPassword string
|
ShunyunbaoPassword string
|
||||||
|
OCRAPIURL string
|
||||||
ShunyunbaoTimeout time.Duration
|
ShunyunbaoTimeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +179,13 @@ func Load(lookup LookupEnvironment) (Config, error) {
|
|||||||
ShunyunbaoPasswordEnvironment + " must be set together",
|
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{
|
return Config{
|
||||||
HTTPAddress: httpAddress,
|
HTTPAddress: httpAddress,
|
||||||
@@ -196,10 +205,34 @@ func Load(lookup LookupEnvironment) (Config, error) {
|
|||||||
ShunyunbaoURL: strings.TrimRight(shunyunbaoURL, "/"),
|
ShunyunbaoURL: strings.TrimRight(shunyunbaoURL, "/"),
|
||||||
ShunyunbaoUsername: shunyunbaoUsername,
|
ShunyunbaoUsername: shunyunbaoUsername,
|
||||||
ShunyunbaoPassword: shunyunbaoPassword,
|
ShunyunbaoPassword: shunyunbaoPassword,
|
||||||
|
OCRAPIURL: ocrAPIURL,
|
||||||
ShunyunbaoTimeout: 30 * time.Second,
|
ShunyunbaoTimeout: 30 * time.Second,
|
||||||
}, nil
|
}, 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 {
|
func validateHTTPSOrigin(value, environment string) error {
|
||||||
parsed, err := url.Parse(value)
|
parsed, err := url.Parse(value)
|
||||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" ||
|
if err != nil || parsed.Scheme != "https" || parsed.Host == "" ||
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ func isERPEnvironmentName(name string) bool {
|
|||||||
switch name {
|
switch name {
|
||||||
case ShunyunbaoURLEnvironment,
|
case ShunyunbaoURLEnvironment,
|
||||||
ShunyunbaoUsernameEnvironment,
|
ShunyunbaoUsernameEnvironment,
|
||||||
ShunyunbaoPasswordEnvironment:
|
ShunyunbaoPasswordEnvironment,
|
||||||
|
OCRAPIURLEnvironment:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ func TestWithERPEnvironmentFileUsesApprovedFallbackValues(t *testing.T) {
|
|||||||
"CMROUBAO_SHUNYUNBAO_URL=https://erp.example.test",
|
"CMROUBAO_SHUNYUNBAO_URL=https://erp.example.test",
|
||||||
"CMROUBAO_SHUNYUNBAO_USERNAME=dotenv-user",
|
"CMROUBAO_SHUNYUNBAO_USERNAME=dotenv-user",
|
||||||
"CMROUBAO_SHUNYUNBAO_PASSWORD='dotenv password #1'",
|
"CMROUBAO_SHUNYUNBAO_PASSWORD='dotenv password #1'",
|
||||||
|
"CMROUBAO_OCR_API_URL=http://127.0.0.1:8000/ocr",
|
||||||
}, "\n"))
|
}, "\n"))
|
||||||
|
|
||||||
lookup, err := WithERPEnvironmentFile(path, func(string) (string, bool) {
|
lookup, err := WithERPEnvironmentFile(path, func(string) (string, bool) {
|
||||||
@@ -27,14 +28,16 @@ func TestWithERPEnvironmentFileUsesApprovedFallbackValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if cfg.ShunyunbaoURL != "https://erp.example.test" ||
|
if cfg.ShunyunbaoURL != "https://erp.example.test" ||
|
||||||
cfg.ShunyunbaoUsername != "dotenv-user" ||
|
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(
|
t.Fatalf(
|
||||||
"ERP config = %#v",
|
"ERP config = %#v",
|
||||||
struct {
|
struct {
|
||||||
URL string
|
URL string
|
||||||
Username string
|
Username string
|
||||||
Password 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 {
|
if _, exists := lookup("OTHER_TOOL_TOKEN"); exists {
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ var (
|
|||||||
ErrFreightSourceNotFound = errors.New("freight source order not found")
|
ErrFreightSourceNotFound = errors.New("freight source order not found")
|
||||||
ErrFreightSourceUnavailable = errors.New("freight source is unavailable")
|
ErrFreightSourceUnavailable = errors.New("freight source is unavailable")
|
||||||
ErrFreightSourceProtocol = errors.New("freight source protocol is invalid")
|
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
|
Timeout time.Duration
|
||||||
CaptchaTTL time.Duration
|
CaptchaTTL time.Duration
|
||||||
AllowInsecureHTTP bool // Used only by isolated httptest contracts.
|
AllowInsecureHTTP bool // Used only by isolated httptest contracts.
|
||||||
|
CaptchaRecognizer CaptchaRecognizer
|
||||||
|
}
|
||||||
|
|
||||||
|
type CaptchaRecognizer interface {
|
||||||
|
Recognize(context.Context, []byte, string) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionStatus struct {
|
type SessionStatus struct {
|
||||||
@@ -54,6 +59,7 @@ type CaptchaImage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SessionManager struct {
|
type SessionManager struct {
|
||||||
|
authMu sync.Mutex
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
baseURL string
|
baseURL string
|
||||||
username string
|
username string
|
||||||
@@ -62,6 +68,7 @@ type SessionManager struct {
|
|||||||
captchaTTL time.Duration
|
captchaTTL time.Duration
|
||||||
headers http.Header
|
headers http.Header
|
||||||
http *http.Client
|
http *http.Client
|
||||||
|
recognizer CaptchaRecognizer
|
||||||
authenticated bool
|
authenticated bool
|
||||||
captchaTicket string
|
captchaTicket string
|
||||||
captchaContent []byte
|
captchaContent []byte
|
||||||
@@ -111,9 +118,39 @@ func NewSessionManager(config SessionConfig) (*SessionManager, error) {
|
|||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
recognizer: config.CaptchaRecognizer,
|
||||||
}, nil
|
}, 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 {
|
func (manager *SessionManager) Status() SessionStatus {
|
||||||
manager.mu.Lock()
|
manager.mu.Lock()
|
||||||
defer manager.mu.Unlock()
|
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(
|
func testSessionManager(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
baseURL, username, password string,
|
baseURL, username, password string,
|
||||||
|
|||||||
@@ -89,9 +89,6 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
|
|||||||
handler.createProcurementTask,
|
handler.createProcurementTask,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if services.ERP != nil {
|
|
||||||
registerERPAdminAPI(routes, handler)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -233,8 +233,7 @@ func denyAdminSession(ctx *gin.Context) {
|
|||||||
(next != "/tasks" && !strings.HasPrefix(next, "/tasks?") &&
|
(next != "/tasks" && !strings.HasPrefix(next, "/tasks?") &&
|
||||||
!strings.HasPrefix(next, "/tasks/") &&
|
!strings.HasPrefix(next, "/tasks/") &&
|
||||||
next != "/freight" && !strings.HasPrefix(next, "/freight?") &&
|
next != "/freight" && !strings.HasPrefix(next, "/freight?") &&
|
||||||
!strings.HasPrefix(next, "/freight/") &&
|
!strings.HasPrefix(next, "/freight/")) {
|
||||||
next != "/erp" && !strings.HasPrefix(next, "/erp?")) {
|
|
||||||
next = "/tasks"
|
next = "/tasks"
|
||||||
}
|
}
|
||||||
ctx.Abort()
|
ctx.Abort()
|
||||||
|
|||||||
@@ -269,8 +269,7 @@ func safeNext(value string) string {
|
|||||||
if parsed.Path != "/tasks" &&
|
if parsed.Path != "/tasks" &&
|
||||||
!strings.HasPrefix(parsed.Path, "/tasks/") &&
|
!strings.HasPrefix(parsed.Path, "/tasks/") &&
|
||||||
parsed.Path != "/freight" &&
|
parsed.Path != "/freight" &&
|
||||||
!strings.HasPrefix(parsed.Path, "/freight/") &&
|
!strings.HasPrefix(parsed.Path, "/freight/") {
|
||||||
parsed.Path != "/erp" {
|
|
||||||
return "/tasks"
|
return "/tasks"
|
||||||
}
|
}
|
||||||
return parsed.String()
|
return parsed.String()
|
||||||
|
|||||||
@@ -77,12 +77,6 @@ func (h *Handler) RegisterProtected(routes gin.IRoutes) {
|
|||||||
routes.POST("/freight/import", SecurityHeaders(), h.CreateFreightImport)
|
routes.POST("/freight/import", SecurityHeaders(), h.CreateFreightImport)
|
||||||
routes.GET("/freight/:id", SecurityHeaders(), h.FreightDetail)
|
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 {
|
if _, ok := h.service.(ProcurementService); ok {
|
||||||
routes.POST(
|
routes.POST(
|
||||||
"/freight/items/:id/procurement-request",
|
"/freight/items/:id/procurement-request",
|
||||||
@@ -417,6 +411,12 @@ func (h *Handler) CreateFreightImport(ctx *gin.Context) {
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
token, _ := csrfToken(ctx)
|
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{
|
h.render(ctx, serviceErrorStatus(err), "freight-import", freightImportPage{
|
||||||
Page: pageView{
|
Page: pageView{
|
||||||
Title: "导入 ERP 货运",
|
Title: "导入 ERP 货运",
|
||||||
@@ -428,7 +428,8 @@ func (h *Handler) CreateFreightImport(ctx *gin.Context) {
|
|||||||
CreatedFrom: createdFrom,
|
CreatedFrom: createdFrom,
|
||||||
CreatedTo: createdTo,
|
CreatedTo: createdTo,
|
||||||
IdempotencyKey: key,
|
IdempotencyKey: key,
|
||||||
Error: "同步任务创建失败,请稍后使用相同提交标识重试。",
|
Error: message,
|
||||||
|
ErrorCode: code,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1227,6 +1228,8 @@ func serviceErrorStatus(err error) int {
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrValidation), errors.Is(err, ErrInvalidFile):
|
case errors.Is(err, ErrValidation), errors.Is(err, ErrInvalidFile):
|
||||||
return http.StatusUnprocessableEntity
|
return http.StatusUnprocessableEntity
|
||||||
|
case errors.Is(err, ErrOCRServiceInvalid):
|
||||||
|
return http.StatusServiceUnavailable
|
||||||
case errors.Is(err, ErrConflict):
|
case errors.Is(err, ErrConflict):
|
||||||
return http.StatusConflict
|
return http.StatusConflict
|
||||||
case errors.Is(err, context.DeadlineExceeded):
|
case errors.Is(err, context.DeadlineExceeded):
|
||||||
@@ -1299,6 +1302,7 @@ type freightImportPage struct {
|
|||||||
CreatedTo string
|
CreatedTo string
|
||||||
IdempotencyKey string
|
IdempotencyKey string
|
||||||
Error string
|
Error string
|
||||||
|
ErrorCode string
|
||||||
Sync *FreightSync
|
Sync *FreightSync
|
||||||
Watermark *FreightWatermark
|
Watermark *FreightWatermark
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1095,102 +1095,47 @@ func TestFreightSyncToNowIgnoresPrefilledManualDates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestERPConnectionPageUsesCaptchaOnlyAndPreservesNoCredentials(t *testing.T) {
|
func TestERPConnectionRoutesAreNotRegistered(t *testing.T) {
|
||||||
ticket := mustToken(t)
|
router := newTestRouter(t, &fakeService{})
|
||||||
service := &fakeERPService{
|
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{},
|
fakeService: &fakeService{},
|
||||||
status: ERPConnectionStatus{
|
err: ErrOCRServiceInvalid,
|
||||||
Configured: true,
|
|
||||||
},
|
|
||||||
image: ERPCaptchaImage{
|
|
||||||
Content: []byte("captcha-image"),
|
|
||||||
ContentType: "image/png",
|
|
||||||
},
|
|
||||||
ticket: ticket,
|
|
||||||
}
|
}
|
||||||
router := newTestRouter(t, service)
|
router := newTestRouter(t, service)
|
||||||
page := performRequest(t, router, http.MethodGet, "/erp", nil, "")
|
page := performRequest(t, router, http.MethodGet, "/freight/import", nil, "")
|
||||||
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "获取验证码") ||
|
if page.Code != http.StatusOK {
|
||||||
strings.Contains(page.Body.String(), "private-password") {
|
t.Fatalf("import page status = %d", page.Code)
|
||||||
t.Fatalf("ERP page = %d / %s", page.Code, page.Body)
|
|
||||||
}
|
}
|
||||||
assertSecurityHeaders(t, page)
|
|
||||||
cookie := csrfCookie(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(
|
request := httptest.NewRequest(
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/erp/captcha",
|
"/freight/import",
|
||||||
strings.NewReader(values.Encode()),
|
strings.NewReader(values.Encode()),
|
||||||
)
|
)
|
||||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
request.AddCookie(cookie)
|
request.AddCookie(cookie)
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
router.ServeHTTP(response, request)
|
router.ServeHTTP(response, request)
|
||||||
if response.Code != http.StatusSeeOther ||
|
if response.Code != http.StatusServiceUnavailable ||
|
||||||
response.Header().Get("Location") != "/erp?notice=captcha-ready" ||
|
!strings.Contains(response.Body.String(), "OCR 服务无效") ||
|
||||||
service.captchaRequests != 1 {
|
!strings.Contains(response.Body.String(), "ORDER-123") ||
|
||||||
t.Fatalf("captcha response/calls = %d / %q / %d", response.Code, response.Header().Get("Location"), service.captchaRequests)
|
service.createInput.OrderNumber != "ORDER-123" {
|
||||||
}
|
t.Fatalf("OCR dialog response/input = %d / %s / %+v", response.Code, response.Body, service.createInput)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
<a class="button" href="/freight">返回列表</a>
|
||||||
</div>
|
</div>
|
||||||
{{if .Error}}<div class="notice danger" role="alert">{{.Error}}</div>{{end}}
|
{{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}}
|
{{if .Sync}}
|
||||||
<section class="detail-section" aria-labelledby="sync-result-title">
|
<section class="detail-section" aria-labelledby="sync-result-title">
|
||||||
<h2 id="sync-result-title">同步状态</h2>
|
<h2 id="sync-result-title">同步状态</h2>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
<a href="/tasks" {{if .Page.TasksCurrent}}aria-current="page"{{end}}>任务列表</a>
|
<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="/tasks/new" {{if .Page.NewCurrent}}aria-current="page"{{end}}>新建任务</a>
|
||||||
<a href="/freight" {{if .Page.FreightCurrent}}aria-current="page"{{end}}>ERP 货运</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>
|
</nav>
|
||||||
{{if .Page.CSRFToken}}
|
{{if .Page.CSRFToken}}
|
||||||
<form class="logout-form" method="post" action="/logout">
|
<form class="logout-form" method="post" action="/logout">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ var (
|
|||||||
ErrERPCaptchaInvalid = errors.New("ERP captcha is invalid")
|
ErrERPCaptchaInvalid = errors.New("ERP captcha is invalid")
|
||||||
ErrERPLoginRejected = errors.New("ERP login was rejected")
|
ErrERPLoginRejected = errors.New("ERP login was rejected")
|
||||||
ErrERPProtocol = errors.New("ERP protocol is invalid")
|
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.
|
// 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)
|
run, err := adapter.freight.GetSync(ctx, localAdminSubject, syncID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrFreightSourceOCRInvalid) {
|
||||||
|
return FreightSync{}, &adapterError{public: ErrOCRServiceInvalid, cause: err}
|
||||||
|
}
|
||||||
return FreightSync{}, mapUsecaseError(err)
|
return FreightSync{}, mapUsecaseError(err)
|
||||||
}
|
}
|
||||||
return freightSyncFrom(run), nil
|
return freightSyncFrom(run), nil
|
||||||
@@ -357,6 +360,9 @@ func (adapter *UsecaseAdapter) CreateFreightSync(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrFreightSourceOCRInvalid) {
|
||||||
|
return FreightSync{}, &adapterError{public: ErrOCRServiceInvalid, cause: err}
|
||||||
|
}
|
||||||
return FreightSync{}, mapUsecaseError(err)
|
return FreightSync{}, mapUsecaseError(err)
|
||||||
}
|
}
|
||||||
return freightSyncFrom(result.Run), nil
|
return freightSyncFrom(result.Run), nil
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ type FreightService struct {
|
|||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FreightSourcePreflight interface {
|
||||||
|
EnsureAuthenticated(context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
type CreateFreightSyncCommand struct {
|
type CreateFreightSyncCommand struct {
|
||||||
CreatorSubject string
|
CreatorSubject string
|
||||||
ActorUserID string
|
ActorUserID string
|
||||||
@@ -101,6 +105,9 @@ func (service *FreightService) CreateOrderSync(
|
|||||||
fields,
|
fields,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if err := service.ensureSource(ctx); err != nil {
|
||||||
|
return CreateFreightSyncResult{}, err
|
||||||
|
}
|
||||||
runID, err := service.ids.NewID()
|
runID, err := service.ids.NewID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CreateFreightSyncResult{}, wrapRepositoryError(err)
|
return CreateFreightSyncResult{}, wrapRepositoryError(err)
|
||||||
@@ -166,6 +173,9 @@ func (service *FreightService) CreateDateSync(
|
|||||||
fields,
|
fields,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if err := service.ensureSource(ctx); err != nil {
|
||||||
|
return CreateFreightSyncResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
now := service.clock.Now().UTC()
|
now := service.clock.Now().UTC()
|
||||||
location, err := time.LoadLocation("Asia/Shanghai")
|
location, err := time.LoadLocation("Asia/Shanghai")
|
||||||
@@ -278,6 +288,24 @@ func (service *FreightService) CreateDateSync(
|
|||||||
return CreateFreightSyncResult{Run: run, Replayed: !created}, nil
|
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) {
|
func (service *FreightService) execute(run domain.FreightSyncRun) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), service.timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), service.timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -679,6 +707,8 @@ func freightSourceErrorCode(err error) string {
|
|||||||
return "ERP_FREIGHT_NOT_FOUND"
|
return "ERP_FREIGHT_NOT_FOUND"
|
||||||
case errors.Is(err, domain.ErrFreightSourceProtocol):
|
case errors.Is(err, domain.ErrFreightSourceProtocol):
|
||||||
return "ERP_RESPONSE_INVALID"
|
return "ERP_RESPONSE_INVALID"
|
||||||
|
case errors.Is(err, domain.ErrFreightSourceOCRInvalid):
|
||||||
|
return "OCR_SERVICE_INVALID"
|
||||||
default:
|
default:
|
||||||
return "ERP_UNAVAILABLE"
|
return "ERP_UNAVAILABLE"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ func TestFreightSourceErrorCodesAreSourceNeutral(t *testing.T) {
|
|||||||
{domain.ErrFreightSourceSessionNeeded, "ERP_SESSION_REQUIRED"},
|
{domain.ErrFreightSourceSessionNeeded, "ERP_SESSION_REQUIRED"},
|
||||||
{domain.ErrFreightSourceNotFound, "ERP_FREIGHT_NOT_FOUND"},
|
{domain.ErrFreightSourceNotFound, "ERP_FREIGHT_NOT_FOUND"},
|
||||||
{domain.ErrFreightSourceProtocol, "ERP_RESPONSE_INVALID"},
|
{domain.ErrFreightSourceProtocol, "ERP_RESPONSE_INVALID"},
|
||||||
|
{domain.ErrFreightSourceOCRInvalid, "OCR_SERVICE_INVALID"},
|
||||||
{errors.New("temporary source failure"), "ERP_UNAVAILABLE"},
|
{errors.New("temporary source failure"), "ERP_UNAVAILABLE"},
|
||||||
}
|
}
|
||||||
for _, testCase := range cases {
|
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) {
|
func TestFreightDateQuerySplitsIntoSevenDayWindows(t *testing.T) {
|
||||||
source := &recordingDateSource{}
|
source := &recordingDateSource{}
|
||||||
service := &FreightService{source: source}
|
service := &FreightService{source: source}
|
||||||
@@ -230,8 +254,15 @@ func validFreightSource() domain.FreightSourceBatch {
|
|||||||
var errDateSourceFailure = errors.New("date source failed")
|
var errDateSourceFailure = errors.New("date source failed")
|
||||||
|
|
||||||
type recordingDateSource struct {
|
type recordingDateSource struct {
|
||||||
calls [][2]string
|
calls [][2]string
|
||||||
failOnCall int
|
failOnCall int
|
||||||
|
ensureCalls int
|
||||||
|
ensureErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (source *recordingDateSource) EnsureAuthenticated(context.Context) error {
|
||||||
|
source.ensureCalls++
|
||||||
|
return source.ensureErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (source *recordingDateSource) QueryOrder(
|
func (source *recordingDateSource) QueryOrder(
|
||||||
@@ -261,7 +292,8 @@ func (source *recordingDateSource) QueryCreatedRange(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type dateCaptureRepository struct {
|
type dateCaptureRepository struct {
|
||||||
watermark *domain.FreightSyncWatermark
|
watermark *domain.FreightSyncWatermark
|
||||||
|
createCalls int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (repository *dateCaptureRepository) CreateFreightSync(
|
func (repository *dateCaptureRepository) CreateFreightSync(
|
||||||
@@ -269,6 +301,7 @@ func (repository *dateCaptureRepository) CreateFreightSync(
|
|||||||
run domain.FreightSyncRun,
|
run domain.FreightSyncRun,
|
||||||
_, _ string,
|
_, _ string,
|
||||||
) (domain.FreightSyncRun, bool, error) {
|
) (domain.FreightSyncRun, bool, error) {
|
||||||
|
repository.createCalls++
|
||||||
return run, false, nil
|
return run, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,8 @@
|
|||||||
- ERP adapter 只能把版本化 allowlist 归一化对象交给 `FreightSource`;其 HTTP 会话只在
|
- ERP adapter 只能把版本化 allowlist 归一化对象交给 `FreightSource`;其 HTTP 会话只在
|
||||||
Go API 进程内。收件人、电话、
|
Go API 进程内。收件人、电话、
|
||||||
地址、完整响应、Cookie、JWT、账号、密码和验证码不得进入领域对象、错误、日志、
|
地址、完整响应、Cookie、JWT、账号、密码和验证码不得进入领域对象、错误、日志、
|
||||||
fixture、SQLite、浏览器或 VLM。验证码必须由人员输入,禁止 OCR、猜测或重放。
|
fixture、SQLite、浏览器或 VLM。T-230 仅允许 API 进程把 ERP 验证码图片一次性发送给
|
||||||
|
受控 `CMROUBAO_OCR_API_URL`;不持久化结果、不重试、不轮询、不暴露人工验证码接口。
|
||||||
|
|
||||||
## 7. 安全与隐私
|
## 7. 安全与隐私
|
||||||
|
|
||||||
|
|||||||
+9
-18
@@ -198,24 +198,11 @@ T-203 成功返回 `201`。使用相同 `Idempotency-Key` 和相同图片内容
|
|||||||
不得返回 receiver、receiverTel、receiverAddr、Cookie、JWT、ERP 用户资料或完整原始
|
不得返回 receiver、receiverTel、receiverAddr、Cookie、JWT、ERP 用户资料或完整原始
|
||||||
对象。多商品必须全部保留;缺失详情返回协议错误,不允许部分成功。
|
对象。多商品必须全部保留;缺失详情返回协议错误,不允许部分成功。
|
||||||
|
|
||||||
### Go ERP 会话(T-226)
|
### Go ERP 会话(T-230)
|
||||||
|
|
||||||
以下接口均要求 ADMIN session;所有 `POST` 还要求现有 `X-CSRF-Token` 与 CSRF
|
`/erp`、`/erp/captcha`、`/erp/login` 和 `/api/v1/erp-session*` 不再暴露。创建货运同步
|
||||||
Cookie。它们只管理单个后端进程内的顺运宝会话,不创建货运同步,也不下发 ERP 账号、
|
前,单一 API 进程在未认证时以同一 Cookie jar 获取验证码、调用受控 OCR、登录并校验用户。
|
||||||
密码、Cookie、JWT、完整用户资料或原始响应。
|
验证码图片、OCR 文字、Cookie、JWT、账号、密码和原始响应都不返回浏览器/API,也不写 SQLite。
|
||||||
|
|
||||||
| 接口 | 说明 |
|
|
||||||
| --- | --- |
|
|
||||||
| `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 配置来源:
|
||||||
|
|
||||||
@@ -226,8 +213,12 @@ ERP 配置来源:
|
|||||||
- `cmd/api` 只把标准工作目录 `backend-api/.env` 中的上述三个值作为回退。系统环境变量
|
- `cmd/api` 只把标准工作目录 `backend-api/.env` 中的上述三个值作为回退。系统环境变量
|
||||||
优先;文件缺失不报错。该解析器不修改全局环境,不加载数据库、HTTP、TLS 或 authctl
|
优先;文件缺失不报错。该解析器不修改全局环境,不加载数据库、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`
|
### `POST /api/v1/freight-syncs`
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
## 当前快照
|
## 当前快照
|
||||||
|
|
||||||
- 日期:2026-07-29
|
- 日期: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
|
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
|
||||||
均按文档提交、实现提交的顺序纳入历史
|
均按文档提交、实现提交的顺序纳入历史
|
||||||
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
||||||
@@ -21,7 +21,8 @@
|
|||||||
单号/日期查询、分页、详情批量和字段 allowlist,并使货运用例依赖来源中立错误。T-226
|
单号/日期查询、分页、详情批量和字段 allowlist,并使货运用例依赖来源中立错误。T-226
|
||||||
已增加受锁保护的 Go 内存 Cookie jar、验证码 ticket、登录和用户校验,以及 ADMIN 的
|
已增加受锁保护的 Go 内存 Cookie jar、验证码 ticket、登录和用户校验,以及 ADMIN 的
|
||||||
`/erp` 页面/API;账号密码可由 `backend-api/.env` 的受控 `CMROUBAO_SHUNYUNBAO_*`
|
`/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;
|
批量并返回 allowlist;T-228 已删除旧 Python Connector、loopback 端口和共享 API Key;
|
||||||
未访问真实 ERP。
|
未访问真实 ERP。
|
||||||
@@ -187,6 +188,7 @@
|
|||||||
| `docs/tasks/T-227.md` | DONE | Go 直连顺运宝查询接入货运同步 |
|
| `docs/tasks/T-227.md` | DONE | Go 直连顺运宝查询接入货运同步 |
|
||||||
| `docs/tasks/T-228.md` | DONE | 移除 Python Connector 并完成 Go 切换 |
|
| `docs/tasks/T-228.md` | DONE | 移除 Python Connector 并完成 Go 切换 |
|
||||||
| `docs/tasks/T-229.md` | DONE | 从受控 `.env` 加载 ERP 凭证 |
|
| `docs/tasks/T-229.md` | DONE | 从受控 `.env` 加载 ERP 凭证 |
|
||||||
|
| `docs/tasks/T-230.md` | DONE | OCR 自动登录并移除人工 ERP 连接页 |
|
||||||
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
||||||
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
||||||
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
||||||
@@ -198,11 +200,10 @@
|
|||||||
## 任务摘要
|
## 任务摘要
|
||||||
|
|
||||||
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。
|
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。
|
||||||
- 已完成:另含 T-220 至 T-229 ERP 契约、货运存储、采购需求生成、日期增量同步、Go
|
- 已完成:另含 T-220 至 T-230 ERP 契约、货运存储、采购需求生成、日期增量同步、Go
|
||||||
直连协议、人工验证码会话、直连 `FreightSource`、旧 Connector 清理和受控本地凭证加载。
|
直连协议、OCR 会话预检、直连 `FreightSource`、旧 Connector 清理和受控本地凭证加载。
|
||||||
- 进行中:无。
|
- 进行中:无。
|
||||||
- 下一步:T-230 在按单号导入前受控调用本机 OCR 并建立 ERP 会话;完成后确认开放 API、
|
- 下一步:确认开放 API、OCR/ERP 数据使用权限,并以受控单号执行一次不记录订单内容的 smoke。
|
||||||
OCR/ERP 数据使用权限,并以受控单号执行一次不记录订单内容的 smoke。
|
|
||||||
|
|
||||||
## 当前可运行内容
|
## 当前可运行内容
|
||||||
|
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ T-225 至 T-227 在 `backend-api/internal/platform/shunyunbao` 用脱敏 fixture
|
|||||||
- 所有结果必须经 Go allowlist 归一化;fixture 专门含收件信息、Cookie/JWT 标记值,
|
- 所有结果必须经 Go allowlist 归一化;fixture 专门含收件信息、Cookie/JWT 标记值,
|
||||||
测试断言它们不会出现在输出或错误里。
|
测试断言它们不会出现在输出或错误里。
|
||||||
|
|
||||||
Go 直连的验证码、登录、Cookie jar 和查询 source 都在 API 进程内。不得用本地 OCR 或
|
Go 直连的验证码、登录、Cookie jar 和查询 source 都在 API 进程内。T-230 在未认证时仅可将
|
||||||
Redis 取代人工验证码流程;真实线上请求不属于自动化测试。
|
验证码图片一次提交给受控本机/HTTPS OCR endpoint,结果只用于当前登录请求;不得写入 Redis、
|
||||||
|
SQLite、日志或浏览器,也不得轮询或重试。真实线上请求不属于自动化测试。
|
||||||
|
|
||||||
## 身份和规范字段
|
## 身份和规范字段
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -14,10 +14,9 @@ T-202 的离线 P0 页面入口见[原型索引](design/index.html)。原型仅
|
|||||||
| `/freight` | ERP 货运列表 | 查看已导入货运单并进入全部商品明细 | US-011 | IX-012 |
|
| `/freight` | ERP 货运列表 | 查看已导入货运单并进入全部商品明细 | US-011 | IX-012 |
|
||||||
| `/freight/import` | ERP 货运导入 | 创建精确单号、最多 7 天日期范围或“同步至现在”任务,并查看成功水位 | US-011 | IX-012 |
|
| `/freight/import` | ERP 货运导入 | 创建精确单号、最多 7 天日期范围或“同步至现在”任务,并查看成功水位 | US-011 | IX-012 |
|
||||||
| `/freight/{id}` | ERP 货运详情 | 查看全部商品明细、来源变化、补图和采购任务生成状态 | US-011、US-012 | IX-012、IX-013 |
|
| `/freight/{id}` | ERP 货运详情 | 查看全部商品明细、来源变化、补图和采购任务生成状态 | US-011、US-012 | IX-012、IX-013 |
|
||||||
| `/erp` | ERP 连接 | 查看匿名会话状态、获取验证码并人工输入验证码;不显示或编辑 ERP 凭证 | US-011 | IX-012 |
|
|
||||||
|
|
||||||
MVP 登录后默认进入 `/tasks`。未登录访问受保护页面时跳转 `/login` 并携带安全的
|
MVP 登录后默认进入 `/tasks`。未登录访问受保护页面时跳转 `/login` 并携带安全的
|
||||||
站内返回路径;只接受 `/tasks`、`/freight`、`/erp` 及其本站子路径,拒绝绝对 URL、`//`
|
站内返回路径;只接受 `/tasks`、`/freight` 及其本站子路径,拒绝绝对 URL、`//`
|
||||||
和反斜杠。
|
和反斜杠。
|
||||||
不存在和无权限必须使用不同内部原因,但页面均不得泄露任务内容。
|
不存在和无权限必须使用不同内部原因,但页面均不得泄露任务内容。
|
||||||
|
|
||||||
@@ -92,7 +91,6 @@ T-207 的 events/candidates/complete/fail 接受原设备对授权内已产生
|
|||||||
| `PendingPaymentSummary` | 管理 Web/App | 只读展示对账订单与人工付款提醒,不提供付款或重提动作 |
|
| `PendingPaymentSummary` | 管理 Web/App | 只读展示对账订单与人工付款提醒,不提供付款或重提动作 |
|
||||||
| `FreightSyncForm` | 管理 Web | 创建精确单号/日期同步记录、查看成功水位,不持有 ERP 凭证 |
|
| `FreightSyncForm` | 管理 Web | 创建精确单号/日期同步记录、查看成功水位,不持有 ERP 凭证 |
|
||||||
| `FreightItemReview` | 管理 Web | 显示规范化来源字段、缺失项和采购任务生成操作 |
|
| `FreightItemReview` | 管理 Web | 显示规范化来源字段、缺失项和采购任务生成操作 |
|
||||||
| `ERPConnectionPanel` | 管理 Web | 仅展示匿名连接状态、验证码图片和验证码输入,不接收 ERP 账号或密码 |
|
|
||||||
|
|
||||||
具体状态反馈以[交互清单](08-interaction-checklist.md)为准,组件命名可在接入真实框架后
|
具体状态反馈以[交互清单](08-interaction-checklist.md)为准,组件命名可在接入真实框架后
|
||||||
调整并同步本文。
|
调整并同步本文。
|
||||||
|
|||||||
+8
-6
@@ -4,7 +4,7 @@ title: OCR 自动登录顺运宝并移除人工连接页
|
|||||||
phase: 2
|
phase: 2
|
||||||
deps:
|
deps:
|
||||||
- T-229
|
- T-229
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-07-29
|
created: 2026-07-29
|
||||||
context_ref: f0fbdba
|
context_ref: f0fbdba
|
||||||
work_branch: null
|
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、日志、浏览器或数据库。
|
endpoint 不进入 Git、日志、浏览器或数据库。
|
||||||
- [ ] OCR client 的 multipart 字段、超时、重定向拒绝、上限、纯文本/JSON 结果与敏感错误均有
|
- [x] OCR client 的 multipart 字段、超时、重定向拒绝、上限、纯文本/JSON 结果与敏感错误均有
|
||||||
fixture 测试。
|
fixture 测试。
|
||||||
- [ ] 未认证的按单号导入先完成 OCR 登录再创建同步;OCR 失败不创建同步且 Admin 返回可关闭
|
- [x] 未认证的按单号导入先完成 OCR 登录再创建同步;OCR 失败不创建同步且 Admin 返回可关闭
|
||||||
`OCR_SERVICE_INVALID` 弹窗并保留表单;已认证会话不重复 OCR。
|
`OCR_SERVICE_INVALID` 弹窗并保留表单;已认证会话不重复 OCR。
|
||||||
- [ ] `/erp`、人工验证码图片/文本和 ERP connection JSON 路由不再暴露;API 不返回验证码、
|
- [x] `/erp`、人工验证码图片/文本和 ERP connection JSON 路由不再暴露;API 不返回验证码、
|
||||||
Cookie、账号、密码或 OCR 原文。
|
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` 正在监听,但缺少可安全复现的验证码
|
- 2026-07-29:创建任务;本机发现 `127.0.0.1:8000` 正在监听,但缺少可安全复现的验证码
|
||||||
fixture,线上 OCR/ERP 验证留给部署 smoke。
|
fixture,线上 OCR/ERP 验证留给部署 smoke。
|
||||||
|
- 2026-07-29:实现受控 OCR multipart client、ERP 自动会话预检、导入失败弹窗和公开人工
|
||||||
|
ERP 路由移除;测试与构建命令见实现提交。
|
||||||
|
|||||||
Reference in New Issue
Block a user