feat: 顺运宝登录接入验证码自动识别 (#47)

#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 <noreply@anthropic.com>
This commit is contained in:
chengma
2026-08-09 12:35:02 +08:00
co-authored by Claude Opus 5
parent 5e426cacf6
commit 2e686b17a4
10 changed files with 890 additions and 23 deletions
+8
View File
@@ -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
+12 -2
View File
@@ -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 的顶层结构。目前只有顺运宝一节,
+90 -16
View File
@@ -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),
+71
View File
@@ -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=<userID> 校验当前 Cookie 代表的
// 会话是否仍然有效,并核对返回的 id/username 与期望值一致(08 §3.5:
// 不一致说明串号了,同样按未登录处理)。
+251
View File
@@ -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 时应该给出降级原因")
}
}
+129
View File
@@ -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
}
+223
View File
@@ -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("<html>不是 JSON</html>"))
}))
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)
}
}
+6
View File
@@ -110,6 +110,12 @@
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="order_no" value="{{.Keyword}}">
<div class="modal-body">
{{/* `[必须]` 工单 #47:自动识别验证码失败/降级时要说明原因
(已尝试 N 次 / 服务不可用),操作员才知道是配置错了还是
服务挂了。ocr_url 留空时压根没尝试过自动识别,不显示这条。 */}}
{{if .NeedLoginReason}}
<p class="hint login-degrade-reason">{{.NeedLoginReason}}</p>
{{end}}
<div class="field">
<label for="login-username">账号</label>
<input id="login-username" type="text" value="{{.Username}}" readonly>
+16 -1
View File
@@ -112,8 +112,23 @@ password: 0012345 # ✗ 解析成整数 12345
没有这个文件时,点「同步」会提示"没有找到配置文件……请复制
config.example.yaml",不是一句读不出原因的报错。
`config.yaml` 里还有两个和验证码自动识别相关的配置(工单 #47):
```yaml
syb:
ocr_url: https://ocr.ilapage.cn/ocr # 留空则只用手工输入弹窗,不报错
ocr_max_attempts: 5 # 识别失败的重试次数上限
```
配了 `ocr_url` 后,会话过期时点「同步」会先自动识别验证码登录,
无需人在场;识别失败或服务连不上会自动降级到手工输入弹窗,弹窗里
会说明降级原因。`ocr_url` 留空就和 #46 时一样,一直走手工输入。
`[必须]` 验证码图片会被发送到 `ocr_url` 配置的地址,见
[08 顺运宝接口](08-顺运宝接口.md) §8.1。
接口细节和这几个配置项各自的含义见
[08 顺运宝接口](08-顺运宝接口.md) §8。
[08 顺运宝接口](08-顺运宝接口.md) §8、§8.1。
## 4. 你应该看到什么
+84 -4
View File
@@ -379,10 +379,21 @@ Go 侧不用跟着调。
`data/` 在 exe 旁边。示例脚本用 Redis 是因为它是反复启动的一次性脚本,
进程间要传会话;Admin 是常驻进程,没有这个需求,持久化只为重启后免登录。
`[必须]` **不引入 OCR 服务。** 会话 24 小时,一天登录一次。
为省一次手工输验证码而依赖 `127.0.0.1:8000` 不划算——多一个必须先启动的东西,
而且 OCR 会失败(示例脚本自己写了 5 次重试),失败了照样要人工。
界面上显示验证码图片、操作员输一次即可。
`[决定已变更]` ~~不引入 OCR 服务。~~ 这条判断在工单 #47 里被推翻了,
原文和推翻理由都留在这里,方便后来人知道这个决定变过、为什么变:
> 原判断(工单 #46):会话 24 小时,一天登录一次。为省一次手工输验证码
> 而依赖 `127.0.0.1:8000` 不划算——多一个必须先启动的东西,而且 OCR
> 会失败(示例脚本自己写了 5 次重试),失败了照样要人工。界面上显示
> 验证码图片、操作员输一次即可。
`[必须]` **这条判断的前提是"本机服务 `127.0.0.1:8000`",托管服务不适用。**
工单 #47 里用户提供了托管地址 `https://ocr.ilapage.cn/ocr`:没有要启动的
东西,就是一次 HTTP 调用,"多一个必须先启动的东西"这条理由不成立了。
而"每天第一次同步都要人在场"这个代价是实打实的——会话 24 小时过期,
意味着做不了无人值守的定时同步。于是工单 #47 引入了 OCR 自动识别,
失败或服务不可达时**降级**到原有的手工输入弹窗(那条兜底路径没有变),
不是"失败了照样要人工"变成了"失败了才要人工"。详见下面「验证码自动识别」一节。
`[必须]` **凭据放 `admin/config.yaml`**,已在 `.gitignore` 里。
仓库里提供 `admin/config.example.yaml` 作为模板(不含真实凭据)。
@@ -402,6 +413,75 @@ password: "0012345" # ✓
---
## 8.1 验证码自动识别(工单 #47)
### 响应格式(已实测)
```
POST https://ocr.ilapage.cn/ocr
Content-Type: multipart/form-data,字段名 file
→ 200 {"code":200,"message":"Success","data":"kycv"}
```
实测耗时约 1.4 秒。
`[必须]` **识别失败也是 `code:200`,这是最容易写错的地方。**
实测用一张无文字的图片探测,返回的是 `{"code":200,"message":"Success","data":""}`——
不是错误码,是 `code:200` 加空 `data`。判断这一次调用真正"识别出了点什么",
必须同时满足:HTTP 200、`code == 200`、**`data` 非空**。只看 `code` 会把
"没识别出来"当成功,拿空字符串去登录。
`[必须]` 顺运宝验证码固定 **4 位字母数字**(08 §3.2 实测)。识别结果过滤
空格标点后长度不是 4,说明识别错了,**不要拿去登录**——直接换一张图重试。
拿明知不对的验证码去登录白费一次尝试,而且频繁的错误登录可能触发对方风控。
### 重试与降级
```text
点同步 → 会话过期
├─ ocr_url 已配置
│ └─ 循环 ocr_max_attempts 次:取新验证码图 → OCR 识别 →
│ 校验(非空 && 4位字母数字) → 登录
│ ├─ 登录成功 ─────────────────→ 直接同步,无人值守
│ └─ 次数用完仍失败 ────────────┐
└─ ocr_url 未配置 / 请求本身失败 ────────┴─→ 弹手工输入框(#46 已有的兜底路径)
```
`[必须]` **OCR 不可达要降级,不是报错。** 外部服务挂了不该让整个同步功能
不可用——手工路径一直在,走它就是了。
`[必须]` 每次重试都要**重新取一张验证码图**。同一张图再识别一次结果一样,
纯属浪费;而且验证码可能已经被上一次失败的登录作废。
`[必须]` OCR 请求本身失败(连不上、超时、返回非法 JSON、`code != 200`)判定为
"服务不可用",**立即降级,不占用重试次数**——重试对"服务本身连不上"这种情况
没有意义。只有"HTTP 调用成功但识别结果不合格(空/长度不对)"才占用一次重试。
`[必须]` 调 OCR **不带顺运宝的 Cookie**,用独立的 `http.Client`(独立的
Cookie Jar、独立的超时),避免把顺运宝会话泄漏给另一个服务;也不共用
顺运宝请求的超时,OCR 慢不该拖垮整个登录流程(`[建议]` 10 秒)。
`[必须]` **验证码图片全程在内存里传字节,不写文件。** 参考实现
`raw_data/shunyunbaoerp_single.py` 把图片存成 `captcha.jpg` 是命令行脚本
的做法,Admin 是常驻进程,写文件只会在 `data/` 里堆垃圾。
`[必须]` **验证码图片会被发送到 `ocr_url` 配置的外部服务。**
当前 `https://ocr.ilapage.cn/ocr` 是用户自己的服务,不算交给第三方;
把 `ocr_url` 换成别人运营的服务前,必须重新评估这一点。
### 配置
```yaml
syb:
# 验证码自动识别服务地址。留空则只用手工输入弹窗,不报错。
ocr_url: https://ocr.ilapage.cn/ocr
ocr_max_attempts: 5
```
`[必须]` `ocr_url` 留空 = 禁用,直接走手工输入弹窗,**不报错**。
---
## 9. 已知未验证的部分
实现前应逐条确认,都只有单一样本支撑: