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:
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
// 不一致说明串号了,同样按未登录处理)。
|
||||
|
||||
@@ -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 时应该给出降级原因")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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. 已知未验证的部分
|
||||
|
||||
实现前应逐条确认,都只有单一样本支撑:
|
||||
|
||||
Reference in New Issue
Block a user