From 2e686b17a455b702a666135701e85cfc4ee4bf83 Mon Sep 17 00:00:00 2001 From: chengma Date: Sun, 9 Aug 2026 12:35:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A1=BA=E8=BF=90=E5=AE=9D=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=8E=A5=E5=85=A5=E9=AA=8C=E8=AF=81=E7=A0=81=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=AF=86=E5=88=AB=20(#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #46 的登录只有手工输验证码一条路,而会话 24 小时就过期——每天第一次 同步都得有人在场,将来也做不了定时同步。 docs/admin/08 §8 当时写死"不引入 OCR 服务",理由是"多一个必须先启动的 东西"。那条判断基于示例脚本里的 http://127.0.0.1:8000/ocr(本机服务)。 用户提供了托管地址后前提不成立,本工单推翻它——文档里改写并保留原文, 让后来人知道这个决定变过、为什么变。 OCR 优先、手工兜底:识别成功直接登录,失败或服务不可达降级到 #46 已有的 手工弹窗,并在弹窗里说明是"已尝试 N 次"还是"服务不可用"。手工路径不删, 外部服务挂了不该让整个同步功能不可用。 识别失败也是 code:200。实测拿无文字图片探测 https://ocr.ilapage.cn/ocr 返回 {"code":200,"message":"Success","data":""}——不是错误码。所以 Recognize 只负责"这次 HTTP 调用有没有问题",空 data 照常返回 (", nil), 业务校验交给调用方;空 data 和长度不对收敛到同一个 len(code) != 4, 一条规则覆盖两种情况。 不合格的验证码不拿去登录:白费一次尝试,且频繁错误登录可能触发风控。 审查时变异测试发现这条没有测试守着——原测试只断言"重新取图了"和 "最终登录成功",禁用长度校验后依然成立。已补 loginRecorder 记录每次 提交到 /am/auth/login 的 code,断言登录只被调用一次且提交的是合格的那个。 每次重试重新取图(同一张图再识别结果一样,且可能已被上次失败的登录作废); OCR 用独立 HTTP 客户端不带顺运宝 Cookie;验证码图片只在内存里传,不落盘。 OCR 不可达立即降级、不占用重试次数——对着连不上的地址重试 5 次, 操作员要等 50 秒才看到手工输入框,结果注定一样。 测试全部用 httptest,不打真实的 ocr.ilapage.cn 和 shunyunbaoerp.com。 Co-Authored-By: Claude Opus 5 --- admin/config.example.yaml | 8 + admin/config/config.go | 14 +- admin/handler/web/others.go | 106 +++++++++++-- admin/syb/client.go | 71 +++++++++ admin/syb/client_test.go | 251 +++++++++++++++++++++++++++++++ admin/syb/ocr.go | 129 ++++++++++++++++ admin/syb/ocr_test.go | 223 +++++++++++++++++++++++++++ admin/templates/syb/list.html | 6 + docs/admin/00-getting-started.md | 17 ++- docs/admin/08-顺运宝接口.md | 88 ++++++++++- 10 files changed, 890 insertions(+), 23 deletions(-) create mode 100644 admin/syb/ocr.go create mode 100644 admin/syb/ocr_test.go diff --git a/admin/config.example.yaml b/admin/config.example.yaml index 183c77d..37c0386 100644 --- a/admin/config.example.yaml +++ b/admin/config.example.yaml @@ -30,3 +30,11 @@ syb: # 首次同步的起始日期。之后按「上次同步时间」增量拉,不再用这个值。 sync_from: "2026-07-01" + + # 验证码自动识别服务地址(工单 #47)。留空 = 只用手工输入弹窗,不报错。 + # `[必须]` 验证码图片会被发送到这个地址,见 docs/admin/08-顺运宝接口.md + # 「验证码自动识别」一节——换成别人的服务前先重新评估这一点。 + ocr_url: https://ocr.ilapage.cn/ocr + + # 自动识别失败后的重试次数上限,默认 5。 + ocr_max_attempts: 5 diff --git a/admin/config/config.go b/admin/config/config.go index 66871fe..ca4608e 100644 --- a/admin/config/config.go +++ b/admin/config/config.go @@ -93,6 +93,16 @@ type SybConfig struct { PageSize int `yaml:"page_size"` MaxMatches int `yaml:"max_matches"` SyncFrom string `yaml:"sync_from"` + + // OcrURL 是验证码自动识别服务的地址(工单 #47)。 + // + // `[必须]` 留空 = 禁用自动识别,登录时直接走手工输入弹窗,不报错—— + // 这是有意的降级路径,见 docs/admin/08-顺运宝接口.md「验证码自动识别」一节。 + OcrURL string `yaml:"ocr_url"` + + // OcrMaxAttempts 是自动识别失败后的重试次数上限,<=0 时代码里退化成 5 + // (示例脚本 raw_data/shunyunbaoerp_single.py 用的也是 5)。 + OcrMaxAttempts int `yaml:"ocr_max_attempts"` } // String 把密码打码,防止 %v、log.Printf("%+v", cfg) 这类写法 @@ -106,8 +116,8 @@ func (c SybConfig) String() string { pw = "****" } return fmt.Sprintf( - "SybConfig{BaseURL:%s Username:%s Password:%s PageSize:%d MaxMatches:%d SyncFrom:%s}", - c.BaseURL, c.Username, pw, c.PageSize, c.MaxMatches, c.SyncFrom) + "SybConfig{BaseURL:%s Username:%s Password:%s PageSize:%d MaxMatches:%d SyncFrom:%s OcrURL:%s OcrMaxAttempts:%d}", + c.BaseURL, c.Username, pw, c.PageSize, c.MaxMatches, c.SyncFrom, c.OcrURL, c.OcrMaxAttempts) } // Config 是 config.yaml 的顶层结构。目前只有顺运宝一节, diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index cc0090f..6f32b97 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -29,6 +29,13 @@ func (h *Handler) SybList(c *gin.Context) { // renderSybList 是 SybList、SybSync、SybLoginAndSync 跳转回来共用的渲染逻辑。 func (h *Handler) renderSybList(c *gin.Context, keyword, pageRaw, msg string) { + h.renderSybListWithLoginReason(c, keyword, pageRaw, msg, c.Query("login_reason")) +} + +// renderSybListWithLoginReason 同 renderSybList,额外带一条"登录弹窗里要 +// 显示的原因"——工单 #47:自动识别验证码失败或降级时,操作员需要知道 +// 是配置错了还是服务挂了,不能弹一个空的手工输入框了事。 +func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, msg, loginReason string) { pageNum := service.ParsePage(pageRaw) result, err := service.ListSybOrdersView(h.db, keyword, pageNum) if err != nil { @@ -71,15 +78,16 @@ func (h *Handler) renderSybList(c *gin.Context, keyword, pageRaw, msg string) { } c.HTML(http.StatusOK, "syb/list", page(c, "syb", "顺运宝数据", gin.H{ - "Keyword": keyword, - "Rows": result.Rows, - "Status": status, - "HasAny": result.HasAny, - "IsFiltered": result.IsFiltered, - "NeedLogin": needLogin, - "Username": username, - "ConfigProblem": configProblem, - "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), + "Keyword": keyword, + "Rows": result.Rows, + "Status": status, + "HasAny": result.HasAny, + "IsFiltered": result.IsFiltered, + "NeedLogin": needLogin, + "NeedLoginReason": loginReason, + "Username": username, + "ConfigProblem": configProblem, + "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), })) } @@ -95,10 +103,12 @@ func sybStatusLine(result *service.SybListResult) string { // SybSync 点「同步」按钮的入口。 // -// 流程见工单 #46: +// 流程见工单 #46/#47: // -// 会话有效 ────────────────→ 直接开始同步(后台跑,立即跳转回列表页) -// 会话无效/过期 ──→ 跳回列表页,页面上弹登录框(不是报错) +// 会话有效 ─────────────────────────→ 直接开始同步(后台跑,立即跳转回列表页) +// 会话无效/过期 ──→ OCR 已配置 ──→ 自动识别验证码登录成功 ──→ 直接开始同步,无人值守 +// │ └→ 识别失败/OCR 不可达 ──┐ +// └→ OCR 未配置(留空)────────────────────┴→ 跳回列表页,弹手工登录框 func (h *Handler) SybSync(c *gin.Context) { cfg, err := config.Load() if err != nil { @@ -112,12 +122,23 @@ func (h *Handler) SybSync(c *gin.Context) { } if err := service.EnsureSybSession(h.db, client, cfg.Syb.Username, time.Now()); err != nil { - if errors.Is(err, service.ErrSybLoginRequired) { - // `[必须]` 会话过期后点同步 → 弹登录框,不是报错。 - h.sybRedirect(c, "") + if !errors.Is(err, service.ErrSybLoginRequired) { + fail(c, http.StatusInternalServerError, "校验顺运宝会话失败:"+err.Error()) return } - fail(c, http.StatusInternalServerError, "校验顺运宝会话失败:"+err.Error()) + + // `[必须]` 会话过期后先尝试 OCR 自动登录(工单 #47), + // 识别不出来或 OCR 不可达再降级弹手工输入框——不是直接报错。 + reason, ok := h.attemptSybAutoLogin(c, client, *cfg) + if !ok { + h.sybRedirectLogin(c, reason) + return + } + if !h.startSybSync(client, *cfg) { + h.sybRedirect(c, "验证码自动识别成功,已自动登录;但已经有一个同步任务在跑,请稍后刷新页面查看结果") + return + } + h.sybRedirect(c, "验证码自动识别成功,已自动登录,同步已开始,请稍后刷新页面查看结果") return } @@ -128,6 +149,40 @@ func (h *Handler) SybSync(c *gin.Context) { h.sybRedirect(c, "同步已开始,请稍后刷新页面查看结果") } +// attemptSybAutoLogin 尝试用配置的 OCR 服务自动识别验证码并登录。 +// +// `[必须]` cfg.Syb.OcrURL 留空 = 禁用,直接返回 ok=false、reason=""—— +// 这种情况下弹出的手工登录框不应该带任何"失败原因"文案,因为压根没 +// 尝试过自动识别,见工单 #47 验收标准「ocr_url 留空 → 直接走手工,不报错」。 +// +// 返回 ok=true 时表示登录已成功、会话已缓存,调用方可以直接发起同步; +// ok=false 时 reason 是给操作员看的降级原因,调用方应该带着它弹手工输入框。 +func (h *Handler) attemptSybAutoLogin(c *gin.Context, client *syb.Client, cfg config.Config) (reason string, ok bool) { + if strings.TrimSpace(cfg.Syb.OcrURL) == "" { + return "", false + } + + ocrClient, err := syb.NewOcrClient(cfg.Syb.OcrURL, syb.DefaultOcrTimeout) + if err != nil { + // `[必须]` OCR 客户端本身构造失败(配置有误)也是"服务不可用"的 + // 一种,同样降级到手工,不报错,见工单 #47。 + return "验证码识别服务配置有误(" + err.Error() + "),请手工输入", false + } + + result, degradeReason := client.LoginWithOCR( + c.Request.Context(), ocrClient, cfg.Syb.Username, cfg.Syb.Password, cfg.Syb.OcrMaxAttempts) + if degradeReason != "" { + return degradeReason, false + } + + // `[必须]` 缓存写失败不能让已经登录的会话失效——只记日志,照样往下走同步, + // 和 SybLoginAndSync 手工登录路径的处理方式一致。 + if err := service.SaveSybLoginSession(h.db, client, result.User.Username, result.ExpiresAt); err != nil { + log.Printf("syb_session_save_failed username=%s err=%v", result.User.Username, err) + } + return "", true +} + // SybCaptcha 返回一张新的顺运宝验证码图片。 // // `[必须]` 验证码和随后提交的登录必须用同一个 Cookie Jar(08 §3.2), @@ -232,6 +287,25 @@ func (h *Handler) sybRedirect(c *gin.Context, msg string) { c.Redirect(http.StatusSeeOther, target) } +// sybRedirectLogin 同 sybRedirect,但把 reason 放进 login_reason 参数, +// 渲染时会显示在**登录弹窗里面**(工单 #47 要求:自动识别失败/降级要 +// 说明原因,不能弹一个不知道为什么弹出来的空表单)。reason 为空时 +// (比如 ocr_url 留空,压根没尝试过自动识别)弹窗不显示任何原因说明。 +func (h *Handler) sybRedirectLogin(c *gin.Context, reason string) { + params := url.Values{} + if q := c.PostForm("order_no"); q != "" { + params.Set("order_no", q) + } + if reason != "" { + params.Set("login_reason", reason) + } + target := "/syb" + if len(params) > 0 { + target += "?" + params.Encode() + } + c.Redirect(http.StatusSeeOther, target) +} + // SybMatch 保存规格匹配结果。 // // 关键:保存的是**可复用的 SKU 映射**(写 sku_mappings), diff --git a/admin/syb/client.go b/admin/syb/client.go index 8500231..e0bc884 100644 --- a/admin/syb/client.go +++ b/admin/syb/client.go @@ -351,6 +351,77 @@ func jwtExpiry(token string) (time.Time, bool) { return time.Unix(claims.Exp, 0), true } +// ---------- 登录:验证码自动识别(工单 #47) ---------- + +// DefaultOcrMaxAttempts 是自动识别失败后的重试次数上限,maxAttempts<=0 +// 时退化成这个值——示例脚本 raw_data/shunyunbaoerp_single.py 用的也是 5。 +const DefaultOcrMaxAttempts = 5 + +// LoginWithOCR 尝试用 OCR 服务自动识别验证码并登录,最多重试 maxAttempts 次。 +// +// `[必须]` 见 docs/admin/08-顺运宝接口.md「验证码自动识别」一节和工单 #47: +// - 每次重试都要重新取一张验证码图——同一张图再识别一次结果不会变, +// 纯属浪费,而且验证码可能已经被上一次失败的登录作废; +// - 识别结果必须**非空且恰好 4 位字母数字**才拿去登录,过滤掉 +// OCR 可能带回的空格和标点;不满足就换图重试,不拿去试登录—— +// 白费一次登录尝试,而且频繁错误登录可能触发对方风控; +// - OCR 请求本身失败(网络/超时/格式错误/业务失败)判定为"服务不可用", +// 立即降级,不占用重试次数——服务本身连不上,重试没有意义; +// - 达到 maxAttempts 仍没登录成功,降级并说明已尝试的次数。 +// +// `[必须]` 本函数**不返回 error**:外部 OCR 服务失败、识别失败都是 +// 预期路径,不是异常——调用方应该把非空的返回原因展示给操作员并弹 +// 手工输入框,而不是当成系统错误处理。返回 (result, "") 表示登录成功; +// 返回 (nil, reason) 表示需要降级到手工,reason 是给操作员看的说明。 +func (c *Client) LoginWithOCR(ctx context.Context, ocr *OcrClient, username, password string, maxAttempts int) (*LoginResult, string) { + if ocr == nil { + return nil, "验证码识别服务未配置,请手工输入" + } + if maxAttempts <= 0 { + maxAttempts = DefaultOcrMaxAttempts + } + + for attempt := 1; attempt <= maxAttempts; attempt++ { + captcha, err := c.FetchCaptcha(ctx) + if err != nil { + return nil, fmt.Sprintf("获取验证码图片失败(%s),请手工输入", err.Error()) + } + + code, err := ocr.Recognize(ctx, captcha.Image) + if err != nil { + return nil, fmt.Sprintf("验证码识别服务暂时不可用(%s),请手工输入", err.Error()) + } + + code = filterAlnum(code) + if len(code) != 4 { + // 识别结果为空或长度不对,说明这张图没识别对,换图重试, + // 不拿去登录(08 §OCR:不是 4 位就不要拿去登录)。 + continue + } + + result, err := c.Login(ctx, username, password, code) + if err == nil { + return result, "" + } + // 登录失败(验证码错/密码错,08 §9 未区分),换一张图重试。 + } + + return nil, fmt.Sprintf("自动识别验证码失败(已尝试 %d 次),请手工输入", maxAttempts) +} + +// filterAlnum 只保留字母和数字,滤掉 OCR 可能带回的空格和标点 +// (照抄 raw_data/shunyunbaoerp_single.py 的 ocr_captcha() 的做法)。 +func filterAlnum(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + b.WriteRune(r) + } + } + return b.String() +} + // CheckSession 用 GET /am/user/get?id= 校验当前 Cookie 代表的 // 会话是否仍然有效,并核对返回的 id/username 与期望值一致(08 §3.5: // 不一致说明串号了,同样按未登录处理)。 diff --git a/admin/syb/client_test.go b/admin/syb/client_test.go index 1b374d9..2d9e66e 100644 --- a/admin/syb/client_test.go +++ b/admin/syb/client_test.go @@ -6,9 +6,12 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/http/httptest" "strings" + "sync" + "sync/atomic" "testing" "time" ) @@ -421,3 +424,251 @@ func TestClient_业务失败但不是登录问题时返回普通错误(t *testin t.Errorf("错误信息应该带上服务端的 msg,实际: %v", err) } } + +// ── LoginWithOCR:验证码自动识别登录(工单 #47) ────────────── +// +// `[必须]` 全部用 httptest 起假的顺运宝服务端和假的 OCR 服务端, +// 绝不能打真实的 shunyunbaoerp.com 或 ocr.ilapage.cn。 + +// loginRecorder 记录每一次提交给 /am/auth/login 的验证码文本。 +// +// `[必须]` 光断言"重试了几次"证明不了"不合格的验证码没被拿去登录"—— +// 变异测试(把长度校验改成 if false)在只看重试次数的断言下依然能 +// 全绿通过,因为"拿不合格的码登录失败→触发重试"和"校验不通过→ +// 直接换图重试"从外部看调用次数是一样的。必须实际记下提交了什么码, +// 才能证明"ab"、""这类不合格的码**从来没有被提交过**。 +type loginRecorder struct { + mu sync.Mutex + codes []string +} + +func (r *loginRecorder) record(code string) { + r.mu.Lock() + defer r.mu.Unlock() + r.codes = append(r.codes, code) +} + +func (r *loginRecorder) snapshot() []string { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]string, len(r.codes)) + copy(out, r.codes) + return out +} + +// fakeSybServer 起一个假顺运宝服务端:验证码接口每次返回一张"新图" +// (用递增的字节内容区分,方便断言"每次重试都取了新图"),登录接口 +// 按 loginCheck 决定成功还是失败,并把每次提交的验证码记进 +// loginRecorder,供测试断言"不合格的码有没有被拿去登录"。 +func fakeSybServer(t *testing.T, loginCheck func(code string) bool) (*httptest.Server, *int32, *loginRecorder) { + t.Helper() + var captchaCalls int32 + rec := &loginRecorder{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/p/code1": + n := atomic.AddInt32(&captchaCalls, 1) + w.Header().Set("Content-Type", "image/jpeg") + w.Write([]byte(fmt.Sprintf("fake-jpeg-%d", n))) + case "/am/auth/login": + var body struct { + Code string `json:"code"` + } + raw, _ := io.ReadAll(r.Body) + json.Unmarshal(raw, &body) + rec.record(body.Code) + if loginCheck(body.Code) { + w.Write(envelopeBody(t, true, "登录成功", map[string]any{ + "user": map[string]any{"id": 1001, "username": "tester"}, + "token": fakeJWT(t, time.Now().Add(2*time.Hour).Unix()), + }, nil)) + } else { + w.Write(envelopeBody(t, false, "验证码错误", nil, "1")) + } + } + })) + return srv, &captchaCalls, rec +} + +// fakeOcrServer 起一个假 OCR 服务端:每次调用按顺序返回 responses 里的 +// 下一个 data;responses 用完后重复最后一个。 +func fakeOcrServer(t *testing.T, responses ...string) (*httptest.Server, *int32) { + t.Helper() + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := int(atomic.AddInt32(&calls, 1)) - 1 + if n >= len(responses) { + n = len(responses) - 1 + } + w.Write(ocrBody(200, "Success", responses[n])) + })) + return srv, &calls +} + +func TestClient_LoginWithOCR_识别成功一次就登录(t *testing.T) { + sybSrv, captchaCalls, _ := fakeSybServer(t, func(code string) bool { return code == "kycv" }) + defer sybSrv.Close() + ocrSrv, ocrCalls := fakeOcrServer(t, "kycv") + defer ocrSrv.Close() + + c, _ := New(sybSrv.URL) + ocr, _ := NewOcrClient(ocrSrv.URL, time.Second) + + result, reason := c.LoginWithOCR(context.Background(), ocr, "tester", "pw", 5) + if reason != "" { + t.Fatalf("应该自动登录成功,不应该降级,实际 reason=%q", reason) + } + if result == nil || result.User.Username != "tester" { + t.Fatalf("登录结果不对: %+v", result) + } + if atomic.LoadInt32(captchaCalls) != 1 || atomic.LoadInt32(ocrCalls) != 1 { + t.Errorf("一次成功不应该重试,captcha=%d ocr=%d", *captchaCalls, *ocrCalls) + } +} + +func TestClient_LoginWithOCR_data为空判定失败并重新取图重试(t *testing.T) { + // `[必须]` loginCheck 故意让空字符串也能"成功"——如果长度校验被 + // 意外禁用/删掉,空字符串会被直接拿去登录并成功,captchaCalls 和 + // reason 这两个断言就会跟着变绿,测试就抓不到这个回归。真正能 + // 抓住回归的是下面对 rec.snapshot() 的断言:不管登录接口对空码 + // 判不判定成功,只要空码被"提交"过一次,就说明校验没生效。 + sybSrv, captchaCalls, rec := fakeSybServer(t, func(code string) bool { return true }) + defer sybSrv.Close() + // 第一次 data 为空(识别失败,不是错误),第二次识别出 4 位有效码。 + ocrSrv, ocrCalls := fakeOcrServer(t, "", "wxyz") + defer ocrSrv.Close() + + c, _ := New(sybSrv.URL) + ocr, _ := NewOcrClient(ocrSrv.URL, time.Second) + + result, reason := c.LoginWithOCR(context.Background(), ocr, "tester", "pw", 5) + if reason != "" { + t.Fatalf("第二次应该识别成功登录,不应该降级,实际 reason=%q", reason) + } + if result == nil { + t.Fatal("登录结果不应该为空") + } + if atomic.LoadInt32(captchaCalls) != 2 { + t.Errorf("data 为空应该重新取验证码图再试一次,captcha 调用次数应该是 2,实际 %d", *captchaCalls) + } + if atomic.LoadInt32(ocrCalls) != 2 { + t.Errorf("应该调用 OCR 两次,实际 %d", *ocrCalls) + } + codes := rec.snapshot() + if len(codes) != 1 { + t.Fatalf("空 data 不应该被拿去登录,登录接口应该只被调用 1 次(用第二次识别出的 wxyz),实际调用 %d 次: %v", len(codes), codes) + } + if codes[0] != "wxyz" { + t.Errorf("唯一一次登录提交的应该是识别成功的 wxyz,实际 %q(说明空字符串被拿去登录了)", codes[0]) + } +} + +func TestClient_LoginWithOCR_长度不对判定失败并重试(t *testing.T) { + // `[必须]` loginCheck 故意让任何码都能"成功",理由同上一个测试: + // 真正能抓住"长度校验被删掉"这个回归的是 rec.snapshot() 断言, + // 不是重试次数或最终结果。 + sybSrv, captchaCalls, rec := fakeSybServer(t, func(code string) bool { return true }) + defer sybSrv.Close() + // 第一次只识别出 2 位,不是合法的 4 位验证码,应该换图重试。 + ocrSrv, _ := fakeOcrServer(t, "ab", "wxyz") + defer ocrSrv.Close() + + c, _ := New(sybSrv.URL) + ocr, _ := NewOcrClient(ocrSrv.URL, time.Second) + + result, reason := c.LoginWithOCR(context.Background(), ocr, "tester", "pw", 5) + if reason != "" { + t.Fatalf("第二次应该识别成功登录,实际 reason=%q", reason) + } + if result == nil { + t.Fatal("登录结果不应该为空") + } + if atomic.LoadInt32(captchaCalls) != 2 { + t.Errorf("长度不对应该重新取图重试,captcha 调用次数应该是 2,实际 %d", *captchaCalls) + } + codes := rec.snapshot() + if len(codes) != 1 { + t.Fatalf("长度不对(\"ab\")不应该被拿去登录,登录接口应该只被调用 1 次(用第二次识别出的 wxyz),实际调用 %d 次: %v", len(codes), codes) + } + if codes[0] != "wxyz" { + t.Errorf("唯一一次登录提交的应该是 4 位的 wxyz,实际 %q(说明 \"ab\" 被拿去登录了)", codes[0]) + } +} + +func TestClient_LoginWithOCR_过滤空格和标点后再判长度(t *testing.T) { + sybSrv, _, _ := fakeSybServer(t, func(code string) bool { return code == "ab12" }) + defer sybSrv.Close() + // OCR 带回空格和标点,过滤后应该恰好是 4 位 "ab12"。 + ocrSrv, _ := fakeOcrServer(t, " a-b1 2.") + defer ocrSrv.Close() + + c, _ := New(sybSrv.URL) + ocr, _ := NewOcrClient(ocrSrv.URL, time.Second) + + result, reason := c.LoginWithOCR(context.Background(), ocr, "tester", "pw", 5) + if reason != "" { + t.Fatalf("过滤空格标点后应该识别成 4 位并登录成功,实际 reason=%q", reason) + } + if result == nil { + t.Fatal("登录结果不应该为空") + } +} + +func TestClient_LoginWithOCR_达到重试上限后降级说明次数(t *testing.T) { + sybSrv, captchaCalls, _ := fakeSybServer(t, func(code string) bool { return false }) + defer sybSrv.Close() + // 每次都识别出 4 位,但登录一直失败(模拟验证码一直识别错)。 + ocrSrv, _ := fakeOcrServer(t, "aaaa") + defer ocrSrv.Close() + + c, _ := New(sybSrv.URL) + ocr, _ := NewOcrClient(ocrSrv.URL, time.Second) + + result, reason := c.LoginWithOCR(context.Background(), ocr, "tester", "pw", 3) + if result != nil { + t.Fatal("登录应该一直失败,不应该有结果") + } + if !strings.Contains(reason, "已尝试 3 次") { + t.Errorf("降级说明应该带上已尝试次数,实际: %q", reason) + } + if atomic.LoadInt32(captchaCalls) != 3 { + t.Errorf("应该恰好重试 3 次(=maxAttempts),实际 captcha 调用 %d 次", *captchaCalls) + } +} + +func TestClient_LoginWithOCR_OCR不可达立即降级不占满重试次数(t *testing.T) { + sybSrv, captchaCalls, _ := fakeSybServer(t, func(code string) bool { return true }) + defer sybSrv.Close() + + // 服务地址存在但已关闭:连不上。 + deadSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + deadSrv.Close() + + c, _ := New(sybSrv.URL) + ocr, _ := NewOcrClient(deadSrv.URL, 500*time.Millisecond) + + result, reason := c.LoginWithOCR(context.Background(), ocr, "tester", "pw", 5) + if result != nil { + t.Fatal("OCR 不可达不应该登录成功") + } + if !strings.Contains(reason, "不可用") { + t.Errorf("降级说明应该提示服务不可用,实际: %q", reason) + } + if atomic.LoadInt32(captchaCalls) != 1 { + t.Errorf("OCR 不可达应该立即降级,不应该重试 maxAttempts 次,实际取了 %d 次验证码图", *captchaCalls) + } +} + +func TestClient_LoginWithOCR_ocr未配置直接降级(t *testing.T) { + sybSrv, _, _ := fakeSybServer(t, func(code string) bool { return true }) + defer sybSrv.Close() + + c, _ := New(sybSrv.URL) + result, reason := c.LoginWithOCR(context.Background(), nil, "tester", "pw", 5) + if result != nil { + t.Fatal("ocr 为 nil 时不应该登录成功") + } + if reason == "" { + t.Fatal("ocr 为 nil 时应该给出降级原因") + } +} diff --git a/admin/syb/ocr.go b/admin/syb/ocr.go new file mode 100644 index 0000000..fe288ce --- /dev/null +++ b/admin/syb/ocr.go @@ -0,0 +1,129 @@ +// 验证码自动识别(工单 #47)。 +// +// `[必须]` 这是独立于顺运宝会话的一次 HTTP 调用,见 +// docs/admin/08-顺运宝接口.md「验证码自动识别」一节: +// - 用**独立的** http.Client,不带顺运宝的 Cookie Jar, +// 避免把 ERP 会话泄漏给另一个服务; +// - 独立超时(默认 10 秒),不和顺运宝的超时共用; +// - 验证码图片全程在内存里传字节,不落盘。 +package syb + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "strings" + "time" +) + +// DefaultOcrTimeout 是 OCR 请求的默认超时。 +// +// `[建议]` 08 文档:实测耗时约 1.4 秒,10 秒留足够余量, +// 又不至于在服务真的挂了的时候把登录流程拖太久。 +const DefaultOcrTimeout = 10 * time.Second + +// OcrClient 调用托管的验证码识别服务(形如 https://ocr.ilapage.cn/ocr)。 +// +// `[必须]` 它和 Client(顺运宝会话客户端)**没有任何共享状态**—— +// 没有共用 Cookie Jar,是两个完全独立的 http.Client,理由见包注释。 +type OcrClient struct { + url string + http *http.Client +} + +// NewOcrClient 创建一个 OCR 客户端。 +// +// `[必须]` url 留空是配置错误(调用方应该在 url 为空时直接跳过 OCR、 +// 走手工输入,不应该走到这里来),所以这里返回 error 而不是静默禁用。 +func NewOcrClient(url string, timeout time.Duration) (*OcrClient, error) { + url = strings.TrimSpace(url) + if url == "" { + return nil, fmt.Errorf("OCR 地址不能为空") + } + if timeout <= 0 { + timeout = DefaultOcrTimeout + } + return &OcrClient{ + url: url, + // `[必须]` 独立的 http.Client,不传 Jar——不带顺运宝 Cookie。 + http: &http.Client{Timeout: timeout}, + }, nil +} + +// ocrResponse 是 OCR 服务的响应形状(工单 #47 实测): +// +// {"code":200,"message":"Success","data":"kycv"} 识别成功 +// {"code":200,"message":"Success","data":""} 识别失败(不是错误码!) +type ocrResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data string `json:"data"` +} + +// Recognize 把一张验证码图片发给 OCR 服务,返回识别出的文字。 +// +// `[必须]` 返回 (data, nil) 表示"成功拿到了一个可信的响应"——data 本身 +// 仍然可能是空字符串,那是"没识别出来",**不是错误**(实测:无文字图片 +// 探测得到 code:200 + data:"")。调用方要另外校验 data 非空、长度是否 +// 符合预期,本函数不做这层业务校验,只负责"这次 HTTP 调用有没有问题"。 +// +// 返回非 nil error 表示 OCR 服务本身有问题(连不上、超时、返回非法 +// JSON、code != 200),调用方应该把这类情况当成"服务不可用"处理, +// 立即降级到手工,而不是当作"这一张图识别失败"继续重试——重试对 +// "服务本身连不上"这种情况没有意义。 +func (o *OcrClient) Recognize(ctx context.Context, image []byte) (string, error) { + if o == nil { + return "", fmt.Errorf("OCR 客户端未初始化") + } + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + part, err := w.CreateFormFile("file", "captcha.jpg") + if err != nil { + return "", fmt.Errorf("构造 OCR 请求失败: %w", err) + } + if _, err := part.Write(image); err != nil { + return "", fmt.Errorf("构造 OCR 请求失败: %w", err) + } + if err := w.Close(); err != nil { + return "", fmt.Errorf("构造 OCR 请求失败: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.url, &buf) + if err != nil { + return "", fmt.Errorf("构造 OCR 请求失败: %w", err) + } + req.Header.Set("Content-Type", w.FormDataContentType()) + + resp, err := o.http.Do(req) + if err != nil { + return "", fmt.Errorf("请求 OCR 服务失败(网络问题): %w", err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("读取 OCR 服务响应失败: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("OCR 服务返回意外状态码 %d", resp.StatusCode) + } + + var payload ocrResponse + if err := json.Unmarshal(raw, &payload); err != nil { + return "", fmt.Errorf("OCR 服务响应不是合法 JSON: %w", err) + } + // `[必须]` 只看 code == 200 才算这次调用成功,msg 只用来拼错误信息。 + if payload.Code != 200 { + return "", fmt.Errorf("OCR 服务返回业务失败: code=%d message=%s", payload.Code, orDefault(payload.Message, "(无)")) + } + + // data 可能是空字符串——那是"没识别出来",不是这里的错误, + // 交给调用方(LoginWithOCR)按长度校验。 + return payload.Data, nil +} diff --git a/admin/syb/ocr_test.go b/admin/syb/ocr_test.go new file mode 100644 index 0000000..ce7a086 --- /dev/null +++ b/admin/syb/ocr_test.go @@ -0,0 +1,223 @@ +package syb + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// `[必须]` 本文件全部用 httptest 起假 OCR 服务端,绝不能打真实的 +// ocr.ilapage.cn——见工单 #47 和 admin/AGENTS.md。 + +func ocrBody(code int, message, data string) []byte { + b, _ := json.Marshal(map[string]any{"code": code, "message": message, "data": data}) + return b +} + +// ── 三种约定的假响应(工单 #47「怎么验证」逐条要求) ────────────── + +func TestOcrClient_Recognize_识别成功(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(ocrBody(200, "Success", "kycv")) + })) + defer srv.Close() + + ocr, err := NewOcrClient(srv.URL, time.Second) + if err != nil { + t.Fatalf("创建 OCR 客户端失败: %v", err) + } + data, err := ocr.Recognize(context.Background(), []byte("fake-image")) + if err != nil { + t.Fatalf("识别应该成功,实际报错: %v", err) + } + if data != "kycv" { + t.Errorf("识别结果应该是 kycv,实际 %q", data) + } +} + +func TestOcrClient_Recognize_code200但data为空不是错误(t *testing.T) { + // `[必须]` 实测:无文字图片探测得到 code:200 + data:"",这不是 + // HTTP/JSON 错误,Recognize 应该返回 (空字符串, nil),由调用方 + // (LoginWithOCR)的长度校验去判定"这次没识别出来"。 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(ocrBody(200, "Success", "")) + })) + defer srv.Close() + + ocr, err := NewOcrClient(srv.URL, time.Second) + if err != nil { + t.Fatalf("创建 OCR 客户端失败: %v", err) + } + data, err := ocr.Recognize(context.Background(), []byte("fake-image")) + if err != nil { + t.Fatalf("code:200 + data:\"\" 不应该被当成错误,实际报错: %v", err) + } + if data != "" { + t.Errorf("期望空字符串,实际 %q", data) + } +} + +func TestOcrClient_Recognize_长度不对时原样返回交给调用方校验(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(ocrBody(200, "Success", "ab")) + })) + defer srv.Close() + + ocr, err := NewOcrClient(srv.URL, time.Second) + if err != nil { + t.Fatalf("创建 OCR 客户端失败: %v", err) + } + data, err := ocr.Recognize(context.Background(), []byte("fake-image")) + if err != nil { + t.Fatalf("HTTP 调用本身没问题,不应该报错,实际: %v", err) + } + if data != "ab" { + t.Errorf("Recognize 不做长度校验,应该原样返回,实际 %q", data) + } +} + +// ── 请求内容:字段名 file,multipart 上传,不落盘 ────────────── + +func TestOcrClient_Recognize_用multipart字段名file上传(t *testing.T) { + var gotFieldName string + var gotBytes []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(1 << 20); err != nil { + t.Errorf("解析 multipart 表单失败: %v", err) + } + if r.MultipartForm != nil && len(r.MultipartForm.File) > 0 { + for name, files := range r.MultipartForm.File { + gotFieldName = name + if len(files) > 0 { + f, _ := files[0].Open() + gotBytes = make([]byte, files[0].Size) + f.Read(gotBytes) + f.Close() + } + } + } + w.Write(ocrBody(200, "Success", "abcd")) + })) + defer srv.Close() + + ocr, _ := NewOcrClient(srv.URL, time.Second) + image := []byte("这是验证码图片的字节内容") + if _, err := ocr.Recognize(context.Background(), image); err != nil { + t.Fatalf("识别失败: %v", err) + } + if gotFieldName != "file" { + t.Errorf("multipart 字段名应该是 file,实际 %q", gotFieldName) + } + if string(gotBytes) != string(image) { + t.Errorf("上传的图片字节应该原样传到服务端,实际 %q", string(gotBytes)) + } +} + +// ── 降级路径:不可达 / 非 JSON / 非 200 ────────────────────── + +func TestOcrClient_Recognize_服务不可达返回error(t *testing.T) { + // 关掉的服务器:地址存在但连不上。 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + srv.Close() + + ocr, err := NewOcrClient(srv.URL, 500*time.Millisecond) + if err != nil { + t.Fatalf("创建 OCR 客户端失败: %v", err) + } + _, err = ocr.Recognize(context.Background(), []byte("fake-image")) + if err == nil { + t.Fatal("服务连不上应该返回 error") + } +} + +func TestOcrClient_Recognize_非JSON响应返回error不panic(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("不是 JSON")) + })) + defer srv.Close() + + ocr, _ := NewOcrClient(srv.URL, time.Second) + _, err := ocr.Recognize(context.Background(), []byte("fake-image")) + if err == nil { + t.Fatal("非 JSON 响应应该返回 error") + } +} + +func TestOcrClient_Recognize_HTTP非200返回error(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("internal error")) + })) + defer srv.Close() + + ocr, _ := NewOcrClient(srv.URL, time.Second) + _, err := ocr.Recognize(context.Background(), []byte("fake-image")) + if err == nil { + t.Fatal("HTTP 500 应该返回 error") + } +} + +func TestOcrClient_Recognize_业务code非200返回error(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(ocrBody(500, "内部错误", "")) + })) + defer srv.Close() + + ocr, _ := NewOcrClient(srv.URL, time.Second) + _, err := ocr.Recognize(context.Background(), []byte("fake-image")) + if err == nil { + t.Fatal("code != 200 应该返回 error") + } +} + +func TestNewOcrClient_地址留空报错(t *testing.T) { + if _, err := NewOcrClient("", time.Second); err == nil { + t.Fatal("空地址应该报错——调用方应该在这之前就判断 ocr_url 是否留空并跳过 OCR") + } + if _, err := NewOcrClient(" ", time.Second); err == nil { + t.Fatal("空白地址同样应该报错") + } +} + +// ── OCR 请求不带顺运宝 Cookie:独立 http.Client ────────────── + +func TestOcrClient_不携带顺运宝的Cookie(t *testing.T) { + var gotCookie string + sybSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: "erp_session", Value: "should-not-leak", Path: "/"}) + w.Header().Set("Content-Type", "image/jpeg") + w.Write([]byte("fake-jpeg")) + })) + defer sybSrv.Close() + + ocrSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if ck, err := r.Cookie("erp_session"); err == nil { + gotCookie = ck.Value + } + w.Write(ocrBody(200, "Success", "abcd")) + })) + defer ocrSrv.Close() + + sybClient, err := New(sybSrv.URL) + if err != nil { + t.Fatalf("创建顺运宝客户端失败: %v", err) + } + captcha, err := sybClient.FetchCaptcha(context.Background()) + if err != nil { + t.Fatalf("获取验证码失败: %v", err) + } + + ocrClient, err := NewOcrClient(ocrSrv.URL, time.Second) + if err != nil { + t.Fatalf("创建 OCR 客户端失败: %v", err) + } + if _, err := ocrClient.Recognize(context.Background(), captcha.Image); err != nil { + t.Fatalf("识别失败: %v", err) + } + if gotCookie != "" { + t.Fatalf("OCR 请求不应该带上顺运宝的 Cookie,实际带了 %q", gotCookie) + } +} diff --git a/admin/templates/syb/list.html b/admin/templates/syb/list.html index 125d28e..09756e5 100644 --- a/admin/templates/syb/list.html +++ b/admin/templates/syb/list.html @@ -110,6 +110,12 @@