《从零开发校园墙微信小程序》进阶篇之四。「培正森林」里最受同学欢迎的功能其实是课表 :绑定一次教务账号,就能在小程序里看每周课程。这个功能要从学校的强智教务系统里取数据,是整个项目里「和外部系统周旋」最多的一块。这一篇讲登录。

先说清楚边界

这套功能涉及学生自己的教务账号密码 ,所以先把几条原则放在最前面:

  1. 只在用户本人主动绑定时才登录 ,并且只取该用户自己的 课表;
  2. 请求要限速、串行 ,不能给学校的系统造成压力(第 13 篇专门讲这个);
  3. 密码绝不能 明文落库,也绝不能 写进日志;
  4. 这是学校的系统,请遵守学校的使用规定。本文只讲「自己的小程序如何稳定地取回自己的数据」,不讨论、也不鼓励任何绕过访问控制的做法。

下面的内容里,教务系统的真实域名我都替换成了占位符。

整体流程

小程序                  后端                          教务系统
  │ 提交学号+密码         │                              │
  │───────────────────▶ │ 入队(第 13 篇)               │
  │ ◀── 返回 taskId ────│                              │
  │                     │ 登录教务 ───────────────────▶ │
  │                     │ ◀─────── 课表 HTML ───────── │
  │                     │ 解析(第 14 篇)→ 入库         │
  │ 轮询任务状态 ───────▶ │                              │
  │ ◀─── 完成 ──────────│                              │

注意这里的设计:登录是异步的 。小程序提交后立即拿到一个 taskId,然后轮询任务状态。原因是教务系统慢、不稳定,同步等待会让请求长时间挂起、前端体验很差,后端线程也会被占住。

两条登录通道

强智教务有两套并行的登录入口,项目里两条都支持 ,并且有明确的优先级:

优先级通道特点
首选/jsxsd 独立登录无验证码,账号密码做 Base64 编码后提交
兜底根路径登录需要验证码,并且前端有一套账号密码的混淆编码

分发器的逻辑非常清晰:

private Result tryLoginOnce(boolean forUpdate, String openid, String username, String password,
                            String jsessionid, String randomCode) {
    // 手动登录带了 session:直接用 root 登录(验证码由前端输入)
    if (jsessionid != null && !jsessionid.isEmpty()) {
        return tryRootLoginOnce(forUpdate, openid, username, password, jsessionid, randomCode);
    }
    // auto/update:首选 /jsxsd 独立登录(无验证码)
    Result r = tryJsxsdLogin(forUpdate, openid, username, password);
    if (r != null) return r;
    // 兜底:root 登录 + 验证码 OCR
    String[] vc = fetchVerifyCodeWithOcr();
    if (vc == null) return new Result(401, "验证码识别失败,请手动输入", "");
    return tryRootLoginOnce(forUpdate, openid, username, password, vc[0], vc[1]);
}

tryJsxsdLogin 的返回值有三种形态 ,这是整个降级设计的核心:

/**
 *   Result(200)           登录成功并入库;
 *   Result(403, "Fails:") 账号/密码错误等【确定性失败】(不重试);
 *   null                  【瞬态失败】(建连/空响应/会话未建立/学期缺失),调用方回退兜底。
 */

把失败分成「确定性」和「瞬态」两类 ,是这类集成里最重要的一个思维:

  • 确定性失败 (密码错了):再试一百次结果也一样。必须立刻停下来告诉用户,绝不能重试 ——不仅没用,反复用错误密码尝试还可能导致账号被锁定;
  • 瞬态失败 (网络抖动、会话没建立):换个时机、换条路径就可能成功,应该降级重试。

登录步骤(/jsxsd 通道)

// 1) 建立会话:GET 一次课表页(未登录返回"请先登录",同时下发/复用会话)
Call<String> initCall = verifyCodeService.getXskbList(null);
initCall.execute();
String sid = findJsessionId(initCall.request().url());

// 2) 登录:encoded = base64(账号) + "%%%" + base64(密码)
String encoded = base64(username) + "%%%" + base64(password);
Response<String> xkResp = verifyCodeService.loginXk(username, password, encoded, "JSESSIONID=" + sid).execute();

// 3) 判定:密码/账号错误 → 留在登录页,页面里有 id=showMsg 的错误提示
Document doc = Jsoup.parse(xkResp.body());
Element showMsg = doc.getElementById("showMsg");
if (showMsg != null && !showMsg.text().isEmpty()) {
    return Map.of("error", showMsg.text().trim());     // 确定性失败
}

// 4) 成功:响应体是主框架页,里面含学生姓名
String studentName = doc.select("#btn_gotoGrzx .ckgrxx a").first().text();

// 5) 抓课表:以登录后的当前会话为准(服务器可能轮换 JSESSIONID,重新从 CookieJar 取)

学到的几点:
1. 会话靠JSESSIONID,要从 CookieJar 里取,不能从响应头里取。

// 不能用响应头 Set-Cookie:首次请求服务器会下发 Set-Cookie,但后续刷新验证码时
// 服务器复用已有会话、不再返回 Set-Cookie,从响应头解析会得到 null。
for (Cookie cookie : okHttpClient.cookieJar().loadForRequest(url)) {
    if ("JSESSIONID".equals(cookie.name())) { jsessionid = cookie.value(); break; }
}

这是个典型的「第一次能用,第二次就坏」的 bug:服务器只在新建会话时才下发 Set-Cookie。正确做法是让 HTTP 客户端的 CookieJar 管理会话,需要时从里面取。
2. 登录成功/失败靠「页面特征」判断,而不是 HTTP 状态码。 强智的密码错误时,HTTP 状态码仍然是 200,只是页面里多了一个 showMsg 元素。所以要解析 HTML 来判断。
3. 登录后还要再确认。 登录后抓课表,如果返回内容里仍有「请先登录」,说明会话实际没建立成功(教务是集群,节点间会话可能不同步),按瞬态失败处理,交给调用方走兜底,而不是当成成功 。
4. 空响应也要当瞬态处理。 xkBody == null、课表页空响应,都返回 null,让上层降级,而不是抛异常或者继续往下解析一个空页面。

验证码:OCR 自动识别,失败回退人工

兜底通道需要验证码。流程是:后端从教务系统下载验证码图片,交给一个独立的 OCR 服务识别,识别结果用来登录:

/**
 * ddddocr 验证码自动识别服务(POST /classification 返回 {"result":"识别文本"})。
 * 识别失败 / 超时返回 null,由调用方决定回退人工输入。
 * 本服务走内网直连,不经过任何代理。
 */
public String recognize(byte[] imageBytes) {
    String b64 = Base64.getEncoder().encodeToString(imageBytes);
    String resp = WebClient.create(ocrUrl).post().uri("/classification")
            .contentType(MediaType.APPLICATION_JSON)
            .bodyValue(Map.of("image", b64))
            .retrieve().bodyToMono(String.class)
            .timeout(Duration.ofSeconds(10))       // ← 必须有超时
            .block();
    ...
}

OCR 用的是开源的 ddddocr,用 Docker 部署成一个独立的 HTTP 服务,和后端同机,后端通过 localhost 调用。几个设计要点:

  • 识别失败返回null,不抛异常。 由调用方决定下一步:自动流程里把它当作「可重试的 401」,换一张验证码再试;
  • 10 秒超时。 外部服务一定要有超时,否则它一卡,你的线程就跟着卡;
  • OCR 的准确率不是 100% 。代码里有一条注释很说明问题:教务系统单次登录成功率约 25%~50%(集群会话不稳定加上验证码识别误差),所以后面会设计多轮重试 ,4 轮重试能把整体成功率提升到约 68%~94%(第 13 篇)。

目前小程序前端走的是「全自动模式」 :验证码完全由后端识别,前端既不展示验证码图片,也不让用户输入。前端提交学号密码时验证码参数传空串,后端据此判断为 auto 任务,自己下载验证码并识别。
后端仍然保留了人工验证码模式(manual):此时由前端携带用户输入的验证码和 JSESSIONID 提交。这是一条备用的出路——自动识别彻底失灵时,可以随时切回人工,不需要改后端。

一个小提醒:接口里还有一个旧的「取验证码」入口,会同时返回图片地址、JSESSIONID 和 OCR 的识别文本,但当前前端并没有调用它 。留着它是为了人工兜底模式。设计兜底路径时,「现在不用、但随时能启用」本身就是一种价值。

下载验证码的网络容错链

下载验证码这一步,我专门设计了一条多级的网络兜底链,原因是它直接决定了登录的成功率。思路是:

尝试 1~3:从代理池随机选代理访问,每次失败就换一个(失败的代理冷却 15 分钟)
   ↓ 全部失败
兜底 A:改用配置里的固定代理
   ↓ 也失败
兜底 B:直连

关键代码:

int maxAttempts = 3;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
        return fetchVerifyCodeOnce();
    } catch (IOException e) {
        // 反馈本次失败的代理进黑名单,冷却 15 分钟后才会再被选中
        String bad = useProxyPool.takeCurrentProxy();
        if (bad != null) useProxyPool.markFailed(bad);
        Thread.sleep(400L * attempt);          // 线性退避
    }
}

这里想强调的设计思想 ,而不是代理本身:

  1. 探测通过不等于真的可用。 代理池里的代理是别的进程每 10 分钟探测一次的结果,探测通过不代表教务系统愿意接受它。所以访问失败时要即时反馈 给池子,让它把坏代理拉黑冷却;
  2. 别把最后一级「直连」当成能扛的东西。 代码里确实留了直连兜底,但项目自己的说明里写得很直白:教务系统会按出口 IP 拦截,同一台服务器,经家庭代理取验证码返回 200,直连返回 403。也就是说,这条降级链真正在干活的是前面的代理,最后那一级更像是「万一教务哪天放开了呢」的保险。兜底的价值取决于它实际能不能成功,别因为链条写得长,就以为系统很稳 。所以代理本身要有人盯着——这正是后台里「教务代理」页每小时探活、状态翻转就发邮件的原因(下一篇会讲);
  3. 每一级都要能记录「走到了哪一级」 ,出问题时才好定位。

一个重要的细节:同一轮登录要固定同一个出口

后面第 13 篇会讲到,登录是多轮重试的。每一轮开始时,先固定一个代理,贯穿这一轮的全部请求 :

String pinned = useProxyPool.pickOne();
if (pinned != null) useProxyPool.pin(pinned);
try {
    Result r = tryLoginOnce(...);
    ...
} finally {
    useProxyPool.unpin();      // 本轮结束解除固定,下一轮重新随机
}

为什么要固定?注释里写得很清楚:教务是集群,会话 / SSO 票据不保证在各节点间同步 。如果同一次登录的几个请求每次换一个出口,就可能被打散到不同的后端节点,导致票据校验失败(表现为「请先登录系统」)。固定出口后,一轮内的所有请求更可能落在同一个节点上。

这是一个很有意思的经验:很多「随机 / 轮询」的负载手段,用在有状态 的会话上反而会出问题。有状态的流程,要保证它自己的一致性。

安全:几条硬性要求

这里把前面提的原则落到代码层面:

  • 密码入库必须加密 :落库用的是 AES 加密(password_enc 字段),只在「更新课表」需要复用时才解密;
  • 加密密钥不能写死在代码里 ,必须通过配置注入,并且在启动时校验长度(AES 密钥必须是 16 / 24 / 32 字节)。项目里曾经踩过一个坑:一个密钥实际是 17 字节,SecureUtil.aes() 创建对象时不报错 ,直到第一次真正加密才抛 InvalidKeyException,导致登录接口运行期才 500 。所以要在 @PostConstruct 里主动校验:
@PostConstruct
public void initAes() {
    byte[] key = aesKey.getBytes(StandardCharsets.UTF_8);
    if (key.length != 16 && key.length != 24 && key.length != 32) {
        throw new IllegalStateException("school.aes-key 长度必须为 16/24/32 字节,当前 " + key.length + " 字节");
    }
    this.aes = SecureUtil.aes(key);
}

「能在启动时发现的错误,就不要拖到运行时 」,这个做法值得记住(fail fast)。

  • 日志里永远不要出现密码。 排查登录问题时很想打印账号密码,但那是诱惑。日志里只记学号(并且带上学号,方便按学号定位失败原因),密码一律不记;
  • 任务数据里的临时凭据 ,也应该加密并缩短保留时间;
  • 不再需要的凭据要及时清理。

小结

  1. 登录异步化:提交拿 taskId,前端轮询,避免长时间挂起;
  2. 两条登录通道,首选无验证码的,失败再回退验证码通道 ;
  3. 把失败分成「确定性」和「瞬态」 :确定性失败不重试(尤其是密码错,避免锁账号),瞬态失败才降级;
  4. 会话从 CookieJar 取,不从响应头取;登录成败看页面特征;登录后要二次确认;
  5. OCR 识别失败返回 null、有超时,并且保留人工验证码的备用通道 ;
  6. 网络容错链:多次重试 → 备用 → 直连(直连未必管用,代理要有健康监控);坏代理要即时反馈并冷却;
  7. 有状态的会话,同一轮要固定同一个出口 ;
  8. 密码加密入库、密钥配置注入并在启动时校验、日志不记密码。

下一篇讲登录队列:为什么要排队,以及怎么判断一次失败「值不值得重试」。

最后修改:2026 年 11 月 12 日
如果觉得我的文章对你有用,请随意赞赏