
新理 Lite 里的课表、成绩、校园卡和电费,分别来自学校的几个业务系统。把它们放进同一个 App,首先要解决的是:入口在哪里、用什么身份请求、返回的数据怎么读。
学校系统没有提供一份覆盖这些功能的统一接口文档。有的功能能从官方网页找到 JSON 请求,有的只能拿到 HTML 表格;即使都经过统一认证,教务和一卡通也各自维护登录态。后端做的主要工作,就是沿着官方页面的调用链,把这些差异整理成一套可用的接口。
下面按后端的实际请求展开。示例里的凭证、房间 ID 和返回值均使用占位内容。
从官方页面找到接口
定位接口时,我关心的不是某个 URL 能不能打开,而是能不能还原一笔完整的业务请求:它从哪个页面发起,依赖什么登录态,参数来自哪里,响应又经过了哪些处理。
从页面功能追到后端请求
先确定功能所在的官方入口
课表和成绩从教务页面进入;校园卡、付款码、电费从门户提供的一卡通入口进入。记录入口与跳转目标,先分清实际负责业务的是哪个系统。
对照网络请求,记录完整协议
在官方页面正常使用功能时,重点核对请求方法、查询参数、请求体、Cookie、业务 Token 和响应。登录过程还要关注
Location,因为一次跳转里的票据可能正是下一个系统的入口。沿页面脚本追踪字段来源
遇到响应加密、字段含义不清,或者多个接口名称相近时,继续查看页面加载的脚本:页面组件调用了哪个服务函数,请求拦截器补了什么头,响应拦截器有没有解密。
在后端复现,再整理成业务数据
保留必要的请求条件,确认登录失效、空数据和正常结果分别是什么形态,再将学校字段映射成自己的返回结构。HTML 表格、密文外层和学校原始字段都在这一层处理。
上面是定位接口时的核对顺序。项目里一个具体例子,是校园卡余额究竟取哪个接口。
仓库的余额对接记录保留了官方首页与付款码页面的脚本线索:页面入口加载了首页和校园码相关分包,组件通过 userInfo 读取 resultData.user,余额展示使用其中的 balance 与 accTypes。由此对应到实际请求:POST /server/user/info。
另一个看起来很相关的 /user/tradeListConfig,实际上返回流水筛选配置,不能用来读余额。只按接口名字猜,很容易找错数据源。
确认 URL 后还没结束。余额接口的 HTTP 响应可能只有这样的外层:
{ "data": "<Base64 编码的密文>"}它没有直接给 success,但这不代表业务失败。对照官方请求处理逻辑,必须先做 Base64 解码、SM4 解密和 JSON 解析,才能读到真正的业务结果。这个处理最终放进了 NewcardClient 的公共响应层,查询、获取令牌和刷新令牌都能复用。
官方前端脚本的文件名带构建哈希,定位时应从页面当前加载的资源继续找;哈希文件名只是排查线索,不适合当作长期固定的接口入口。
一次登录,三种业务会话
接入的系统可以分成三组。PC 教务靠 Cookie,移动教务靠自己的 Token,一卡通还要通过门户入口再换一套令牌。
flowchart TB
A["用户完成学校统一认证"] --> B["学校 idToken"]
B --> C["PC 教务 SSO"]
B --> D["移动教务 SSO"]
B --> E["学校门户的一卡通入口"]
C --> F["Cookie 会话 · HTML 页面"]
D --> G["移动业务 token · JSON 接口"]
E --> H["tid 换一卡通令牌 · JSON / 加密响应"]这几种凭证不能混着用:
| 凭证 | 谁接收它 | 用在什么地方 |
|---|---|---|
学校 idToken | 学校认证、门户及 SSO 链路 | 继续建立各业务系统的会话 |
| PC 教务 Cookie | PC 教务 | 获取课表、成绩、个人信息等网页 |
移动教务 token | 移动教务 | 请求考试安排等移动接口 |
一卡通 accessToken / refreshToken | 一卡通 | 校园卡、电费、付款码与充值 |
新理 Lite API accessToken | 自己的后端 | App 调用 /api/v1/run 等接口 |
App 只拿自己后端的 API Token。后端根据功能选择业务客户端,学校侧的令牌和 Cookie 由对应客户端管理。
CAS:先按官方流程建立学校登录态
密码登录与微信登录
密码登录首先获取 CAS 页面和当前公钥:
GET /cas/login?service=<学校 superapp 地址> HTTP/1.1Host: cas.xjit.edu.cn
GET /cas/jwt/publicKey HTTP/1.1Host: cas.xjit.edu.cn这里使用的 service 是 https://superapp.xjit.edu.cn/pages/tab/index/index。两个请求与后续提交共用带 CookieJar 的 opener。
xjit/auth.py 先解析登录页的 fm1 表单,保留当前页面生成的隐藏字段,再填入账号、RSA 加密后的密码和验证码等内容。公钥来自学校接口,密码按页面流程使用 RSA PKCS#1 v1.5 加密。
POST /cas/login?service=<URL 编码后的 service> HTTP/1.1Host: cas.xjit.edu.cnContent-Type: application/x-www-form-urlencodedOrigin: https://cas.xjit.edu.cnReferer: <刚才获取的登录页地址>Cookie: <当前 CookieJar 中的 Cookie>
username=<账号>&password=<URL 编码后的密码密文>&<当前表单中的其他字段>提交后主动读取 Location,提取回跳参数中的 ticket。当前学校链路的 ticket 带有 JWT 形态的载荷,后端从中读取 idToken。
因此,不能只保存一份固定表单长期使用:页面隐藏字段和 Cookie 是这次登录过程的一部分。没有预期回跳、没有 ticket 或没有 idToken,都不能视作成功。
微信扫码沿学校 CAS 的联合认证入口进行:
GET /cas/federatedRedirect?service=<URL 编码后的 service>&federatedName=openweixin HTTP/1.1Host: cas.xjit.edu.cn自己的后端先创建带 state 的扫码会话,App 打开学校认证页。用户完成学校的授权流程,回到 superapp 地址后,把 ticket 交给后端完成接口。
后端仍然从学校回跳凭证中取得 idToken,随后签发自己的 API Token。后续校园业务使用同一套分发流程。
两种方式的区别主要在续期:密码登录的后端内存会话保留账号密码,可用于重新认证;扫码会话没有 CAS 密码,学校登录态过期后需要重新扫码。
CAS 密码加密:RSA、Base64 与字段前缀
密码字段不是直接提交明文,也不是做一次 MD5。cas_login.py 中的 openssl_encrypt() 对应的是下面这条处理链:
用户输入的密码 → UTF-8 字节 → RSA 公钥加密,使用 PKCS#1 v1.5 填充 → 密文字节转 Base64 文本 → 加上 __RSA__ 前缀 → 放入 password 字段,随整个表单做 URL 编码RSA 公钥从学校的 /cas/jwt/publicKey 获取,返回内容按 PEM 公钥使用。加密用公钥即可,后端不持有学校的私钥;学校收到表单后才能完成对应的私钥解密。
其中 __RSA__ 是当前登录协议的一部分,不能漏掉。Base64 只是把二进制密文转换成表单可传输的文本,并不承担加密作用。
下面将源码中的主路径整理成一个独立函数。源码还保留了旧版 rsautl 命令的兼容回退,这里集中展示 pkeyutl 的参数:
import base64import subprocessimport tempfilefrom pathlib import Path
def encrypt_cas_password(public_key_pem: str, password: str) -> str: with tempfile.TemporaryDirectory() as directory: key_path = Path(directory) / "cas-public.pem" key_path.write_text(public_key_pem, encoding="utf-8") result = subprocess.run( [ "openssl", "pkeyutl", "-encrypt", "-pubin", "-inkey", str(key_path), "-pkeyopt", "rsa_padding_mode:pkcs1", ], input=password.encode("utf-8"), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, ) ciphertext = base64.b64encode(result.stdout).decode("ascii") return "__RSA__" + ciphertext这里有三个需要和官方流程保持一致的地方:填充模式是 pkcs1,不是 OAEP;明文通过标准输入交给 OpenSSL,不拼进命令参数;密文字节转 Base64,而不是转十六进制。
同一密码每次生成的密文可以不同,因为 PKCS#1 v1.5 加密填充包含随机字节。核对实现时应验证能否被对应私钥正确解密,不能要求输出字符串与某次页面请求完全相同。
把加密结果接回登录表单
这段与 xjit/auth.py 的调用关系一致。opener 复用同一个 CookieJar,login_url 包含当前 service,form_payload() 保留从 fm1 解析出的字段:
from urllib.parse import urlencodefrom cas_login import fetch_text, parse_form, form_payload
login_html = fetch_text(opener, login_url)form = parse_form(login_html, "fm1")public_key = fetch_text(opener, public_key_url).strip()
encrypted_password = encrypt_cas_password(public_key, password)payload = form_payload( form, username, encrypted_password, captcha, remember_me)body = urlencode(payload).encode("utf-8")最后才以 application/x-www-form-urlencoded 提交 body。Base64 中的 +、/、= 由表单编码处理,不手工拼接字符串,也不提前把密码字段重复 URL 编码。RSA 字段加密之外,整个请求仍然通过 HTTPS 发送。
教务:网页和移动接口分别怎么请求
PC 教务先以 https://jw.xjit.edu.cn/jsxsd/framework/xsMain.jsp 为 service,带学校 idToken 走 CAS SSO。JwClient.from_id_token() 完成跳转并保留 CookieJar,后续请求直接复用这份教务会话。
以课表为例:
GET /jsxsd/xskb/xskb_list.do?xnxq01id=2025-2026-2 HTTP/1.1Host: jw.xjit.edu.cnCookie: <教务会话 Cookie>学期参数是 xnxq01id;不传时使用系统默认学期。这里拿到的是 HTML,后端还需要解析表格。
| 功能 | 学校页面 / 数据来源 |
|---|---|
| 课表 | /jsxsd/xskb/xskb_list.do |
| 成绩 | /jsxsd/kscj/cjcx_list |
| 成绩补充来源 | /jsxsd/bygl/bygl_ckxsList |
| 毕业信息 | /jsxsd/xxwcqk/byqkcx.do |
| 个人信息 | 从可用个人信息页读取字段,统一映射姓名、学号、学院、专业、班级等 |
PC 教务有个需要单独处理的情况:会话失效后返回登录页,HTTP 状态仍可能是 200。客户端会检查登录页特征,功能解析层再验证预期的表格,避免把登录失效显示成“没有数据”。
考试安排使用移动教务。SSO 的 service 改为:
https://jw.xjit.edu.cn/xjlgxyhd/loginSso?toMenu=xs_ksap后端检查跳转链、最终地址和页面里的 token 参数,兼容 URL 查询串与片段中的参数。如果先拿到 askToken,再请求 getXgtToken 换取最终业务 token。
POST /xjlgxyhd/student/examinationArrangement?semester=2025-2026-2 HTTP/1.1Host: jw.xjit.edu.cntoken: <移动教务 token>Content-Type: application/x-www-form-urlencoded;charset=UTF-8Accept: application/json, text/plain, */*Cache-Control: no-cacheReferer: https://jw.xjit.edu.cn/xjlgxy/Content-Length: 0这个请求值得单独写出来:方法是 POST,参数在 URL 查询串,请求体为空。 代码里的方法叫 mobile_post_json(),其中 JSON 指返回数据的处理方式,不能据此推断请求体也是 JSON。
响应的 data 应当是数组。后端再做字段兼容,例如 kcmc 映射为 courseName,ksdd 映射为 examPlace;结构异常则报错,不把异常对象当空列表返回。
HTML 怎样变成可用的课表和成绩
对教务网页的处理集中在后端功能模块里。请求成功只是第一步,后面还要解决表格结构和字段语义。
课表:从单元格中提取课程
课表解析器读取星期、时间段和每个单元格里的课程,再拆出课程名、教师、周次、节次与教室。一个格子里有多门课时分别保留,不把整块文字当成一条课程。
学期优先读取 xnxq01id 下拉框中选中的选项,避免页面其他位置出现的学期文字影响判断。
还有一种实践课数据:同一天五个时段重复出现相同课程、教师、周次和地点,节次却全部写成 01节。代码只在满足这组条件时按课表时段补齐节次,同时保留 originalSections 和 sectionSource。普通课程不套用这个修正。
成绩:合并来源,但保留每次记录
成绩主页面之外,后端还读取毕业审核相关页面作为补充来源。使用“学期、课程代码、课程名称”识别已有记录,只补充主列表缺失的内容,并保留来源信息。
原始记录与统计口径分开处理。普通考试、补考等记录仍然保留;需要每门课取最佳记录时,优先按绩点、再按分数选择。课程代码缺失时才退回课程名称分组。
加权绩点只纳入学分和绩点都能解析为数字的记录。分母是参与计算的学分,不能拿全部记录的学分直接代替;总学分也不能直接解释为“已通过学分”。
个人信息与毕业信息:按字段含义映射
个人信息按页面标签映射学号、姓名、学院、专业、班级等字段,减少对某一固定列位置的依赖。
功能名 jw.training 当前读取的是毕业信息页,整理毕业年份、类型、结论、时间及证书编号等字段。它并不等于已经接入完整的培养方案系统。
一卡通:门户入口、tid 和业务令牌
一卡通的入口不是固定带着用户身份的普通链接。学校门户根据当前登录态提供服务地址,后端先带学校 idToken 请求门户:
GET /portal-api/v1/personalData/getECardDataList HTTP/1.1Host: portal.xjit.edu.cnX-Id-Token: <学校 idToken>X-Device-Info: AppleMacOSX-Terminal-Info: h5xjit/portal.py 还会结合服务列表、主题配置及相关入口查找一卡通地址。门户可能直接返回 tid,也可能把服务地址放在嵌套字段中,需要继续请求并跟随跳转。遇到 tokenAccept 配置时,按配置决定令牌的参数名和替换方式。
最终找到包含 tid 的一卡通入口,再换业务令牌:
POST /server/auth/getToken HTTP/1.1Host: newcard.xjit.edu.cnContent-Type: application/jsonReferer: https://newcard.xjit.edu.cn/?tid=<tid>&orgId=2
{"tid":"<门户入口中的 tid>"}返回的 resultData 中包含 accessToken、refreshToken 和有效期。后续业务请求的基本形式如下:
POST /server/user/info HTTP/1.1Host: newcard.xjit.edu.cnAuthorization: <一卡通 accessToken>Content-Type: application/jsonReferer: https://newcard.xjit.edu.cn/?orgId=2
{}这里的 Authorization 直接放一卡通 Token,没有 Bearer 前缀。自己的 API 则使用 Authorization: Bearer <API Token>。实际业务请求优先沿用取得的入口 URL 作为 Referer,上面展示的是客户端的默认回退值。
在公共客户端里解码响应
NewcardClient._post() 发请求后统一调用 newcard_decode_response()。明文业务响应直接使用,加密响应按以下顺序处理:
一卡通响应处理
识别外层结构
判断它是否已经是明文业务对象,或带有需要处理的
data密文字段。还原密文字节并解密
Base64 解码后检查长度,再按官方客户端协议进行 SM4-ECB 解密。
校验填充与 JSON
显式验证 PKCS#7 的填充长度和填充字节,去除填充后解码 UTF-8,解析 JSON,并确认结果是对象。
交给具体业务模块
余额模块读取账户,付款码模块读取二维码,电费模块读取账户字段。解密失败时返回明确错误,不把密文、个人信息或令牌带进错误信息。
这样,业务功能不用分别判断学校这次返回了明文还是密文。协议处理留在客户端,业务字段留在功能模块。
SM4 解密具体怎么做
一卡通这里使用的是 SM4-ECB + PKCS#7。SM4 的密钥和分组长度都是 16 字节,ECB 模式不传 IV;Base64 解码得到的密文字节长度必须是 16 的倍数。
下面是从 xjit/crypto.py 的响应处理整理出的等价示例。协议密钥通过参数传入,不在文章中公开具体值;不是随意生成一把密钥就能解开学校的响应。
import base64import jsonfrom gmssl.sm4 import CryptSM4, SM4_DECRYPT
def decrypt_sm4_data(encoded: str, key: bytes) -> bytes: if len(key) != 16: raise ValueError("SM4 密钥必须为 16 字节") ciphertext = base64.b64decode(encoded, validate=True) if not ciphertext or len(ciphertext) % 16: raise ValueError("密文长度不符合 SM4 分组要求")
# 禁用库的自动去填充,接下来显式验证填充字节。 crypt = CryptSM4(padding_mode=-1) crypt.set_key(key, SM4_DECRYPT) padded = crypt.crypt_ecb(ciphertext) count = padded[-1] if not 1 <= count <= 16: raise ValueError("PKCS#7 填充长度无效") if padded[-count:] != bytes([count]) * count: raise ValueError("PKCS#7 填充内容无效") return padded[:-count]
def decode_newcard_response(payload: dict, key: bytes) -> dict: if payload.get("success") or not payload.get("data"): return payload plaintext = decrypt_sm4_data(payload["data"], key) result = json.loads(plaintext.decode("utf-8")) if not isinstance(result, dict): raise ValueError("解密结果不是业务对象") return result例如尾部填充长度为 4,最后四个字节就必须全部是 0x04。只读最后一个字节就直接截断,无法发现前面的填充字节是否异常,所以源码对两项都做了校验。
在这一层,解密成功不等于业务成功。还原出内部 JSON 后,仍由具体功能检查业务状态、错误信息和必需字段。SM4-ECB 本身也不提供消息完整性认证,填充检查不能被当成签名验证。
余额、流水与付款码
余额来源是 POST /server/user/info,主要读取 resultData.user.accTypes。每个账户分别整理为 typeCode、typeName、balance 和 unit。
几个容易影响结果的细节都在 newcard_balance.py 处理:
- 多个账户按学校顺序分别返回,不直接相加。
- 账户明细不存在或为空时,仅在
user.balance是有效金额的情况下退回总余额。 - 零余额是有效数据;缺失余额与结构异常要报错,不能补成零。
- 学校这个字段的单位已经是元,不再除以 100。
金额文本使用 Decimal 校验,避免二进制浮点误差影响数值判断。
POST /server/user/tradeList HTTP/1.1Host: newcard.xjit.edu.cnAuthorization: <一卡通 accessToken>Content-Type: application/jsonReferer: <一卡通入口地址>
{ "fromDate": "2026-09-01", "toDate": "2026-09-30", "tradeType": "1,2,3", "pageNo": 0, "pageSize": 20}学校分页从 0 开始,自己的业务参数默认从第 1 页开始,后端做转换;每页最多 100 条,未指定日期时默认查询最近 30 天。
响应中的日期、摘要、商户、金额、退款标识和流水号被整理为固定字段。其中学校的 amt 映射为 amount,文本还会移除不可打印字符。
付款码先读配置,再请求在线码:
POST /server/virtualCard/config HTTP/1.1Host: newcard.xjit.edu.cnAuthorization: <一卡通 accessToken>Content-Type: application/json
{"devCode":"xjit-api"}
### 读取配置并确定 qrcodeType 后
POST /server/virtualCard/qrcodeOnline HTTP/1.1Host: newcard.xjit.edu.cnAuthorization: <一卡通 accessToken>Content-Type: application/json
{"qrcodeType":"<配置中的码类型>"}配置决定是否允许在线模式、默认码类型、刷新间隔,以及是否显示姓名和余额。码类型优先采用调用者明确指定的值,否则读取学校选中的配置,再退回第一个配置。
如果需要显示余额,先读取余额,再生成短时有效的付款码,避免先生成的码在等待附加请求时消耗有效期。余额属于可选展示信息,读取失败不阻止有效码生成;学校配置不显示余额时不额外查询。
接口返回码内容与 expiresInSeconds,刷新时间优先使用学校的 refreshSecond,缺失时默认 30 秒。付款码内容是业务凭证,不作为普通调试数据保存。
宿舍电费:房间号怎么变成学校的定位参数
电费接口不是只接收“几号楼、几号房”。实际查询依赖一组学校内部 ID:
{ "utilityType": "electric", "bigArea": "<校区 ID>", "area": "<区域 ID>", "building": "<楼栋 ID>", "unit": "", "level": "<楼层 ID>", "room": "<房间 ID>", "subArea": ""}这些 ID 来自学校页面的位置选择接口 POST /server/utilities/location。请求里的 locationType 指定当前查询哪一级,已选中的父级 ID 一并传入,响应返回 resultData.locationList。
项目中的 tools/dump_newcard_electricity_rooms.py 使用已认证的一卡通客户端,沿学校的选择层级读取位置并整理房间映射。这里不能假定所有楼都完全相同:有些分支在 unit 查询结果中直接给房间,有些要继续读取楼层,再查询房间。
一次电费查询的参数转换
解析用户输入
将
5#524、5号楼524等形式解析为楼号和房号。这只是格式示例,不代表某位用户的实际宿舍。查询房间映射
用规范化后的楼号与房号查找本地映射,取得学校真实的区域、楼栋、楼层和房间 ID。找不到映射时明确报错,不猜测 ID。
发送账户查询
将这些 ID 组装进
/utilities/account的 JSON 请求体。调用者已经提供完整location时可以直接使用;未指定房间时,也可从/utilities/config读取学校的默认位置。按字段编码读取剩余电量
遍历响应的
templateList,寻找code为quantity的项目,返回它的value与unit,不依赖它恰好排在列表第几项。
映射缓存的是位置目录,不是实时电量。日常查询直接定位房间,再请求最新账户数据;学校调整楼栋或房间目录时,才需要更新映射。
充值:普通下单与敏感请求的区别
校园卡充值和电费充值都属于一卡通业务,但请求封装并不完全相同。代码把读取配置、校验金额、选择支付方式与提交业务分开处理。
先请求 /server/recharge/config。金额档位来自 amtGroupList,支付方式来自 daLianPayList、wapPayList 等学校配置,后端将其整理为自己的 amountOptions 与 payMethods。
创建订单时会重新读取配置,校验当前选择仍然可用。金额要求大于零、最多两位小数,再获取交易编号:
POST /server/consume/transaction HTTP/1.1Host: newcard.xjit.edu.cnAuthorization: <一卡通 accessToken>Content-Type: application/json
{"datetime":"<当前 YYYY-MM-DD HH:MM:SS>"}随后提交 /server/recharge/pay,请求体包含 payCode、tradeType、amt、returnUrl 和毫秒时间戳,按业务需要附带其他参数。交易编号放入请求头;源码同时设置了两种大小写写法的 transactionid / transactionId,HTTP 头名本身不区分大小写。
校园卡充值这里使用普通 JSON 请求。 返回数据可能是 H5 地址、需要 POST 的表单,或者微信 JSAPI 参数,后端按类型整理,再交给客户端继续支付。
电费先查询配置和房间账户,确认目标位置、金额与支付方式。当前选用校园卡余额渠道且学校要求密码时,还要提供支付密码。
提交 /server/utilities/pay 使用 sensitive_post(),线上请求体不直接暴露业务字段,而是:
{ "id": "<本次加密配置 ID>", "params": "<SM2 加密后的十六进制密文>"}对应的封装过程是:
- 请求
/server/auth/getEncrypt,取得加密配置 ID 和封装后的公钥。 - 按官方协议还原公钥,将业务参数序列化为紧凑 JSON。
- 使用 SM2 的 C1C3C2 模式加密 UTF-8 字节,转成十六进制字符串。
- 获取交易编号,将封装后的请求体和交易头交给
/utilities/pay。
余额支付的即时结果与需要继续打开收银台的结果分开返回。后端不会仅凭打开支付页就将订单标为成功。
SM2:加密的是完整业务参数
电费敏感请求的 params 与 CAS 的 password 是两种不同封装。CAS 只对密码字段做 RSA 加密;一卡通这里先把整份业务参数变成 JSON,再做 SM2 加密。
/auth/getEncrypt 返回的 publicKey 也不是直接可用的明文公钥。当前协议先用 Base64 和 SM4 封装它,需要先还原,才能交给 SM2:
/auth/getEncrypt ├─ id → 原样放入本次请求外层 └─ publicKey → Base64 解码 → SM4 解密 → SM2 公钥
房间定位、金额、支付方式等业务参数 → 紧凑 JSON → UTF-8 → SM2(C1C3C2)→ 十六进制文本 → {"id": "…", "params": "…"}SM4 处理的是服务端下发的公钥封装,SM2 处理的是准备上送的业务参数,两步的输入、输出不能混用。公钥还原可以复用上面的 decrypt_sm4_data();下面把对应操作串起来:
from gmssl import sm2
def encrypt_sensitive_request( business_payload: dict, encrypt_config: dict, sm4_key: bytes) -> dict: public_key = decrypt_sm4_data( encrypt_config["publicKey"], sm4_key ).decode("utf-8") if len(public_key) not in (128, 130) or any( char not in "0123456789abcdefABCDEF" for char in public_key ): raise ValueError("SM2 公钥格式无效")
text = json.dumps( business_payload, ensure_ascii=False, separators=(",", ":") ) crypt = sm2.CryptSM2( private_key="", public_key=public_key, mode=1 ) ciphertext = crypt.encrypt(text.encode("utf-8")) if not ciphertext: raise ValueError("SM2 加密失败") return {"id": str(encrypt_config["id"]), "params": ciphertext.hex()}mode=1 对应当前协议的 C1C3C2 排列,不能替换成默认模式后仍假定密文兼容。ensure_ascii=False 与紧凑分隔符对应源码的 JSON 序列化方式,最后输出十六进制,不加 CAS 的 __RSA__ 前缀,也不再转 Base64。
实际 NewcardClient.sensitive_post() 会先读取本次加密配置,再构造上面的封装,获取交易编号后发送请求。文章示例保留协议步骤,公钥解包部分额外复用了严格的填充验证;源码目前对公钥解包和业务响应解包采用了不同的辅助函数。
| 位置 | 要处理的数据 | 算法与封装 | 结果用在哪里 |
|---|---|---|---|
| CAS 密码登录 | 密码 UTF-8 字节 | RSA PKCS#1 v1.5 → Base64 → __RSA__ | 表单 password 字段 |
| 一卡通响应 | 外层 data | Base64 解码 → SM4-ECB → 去 PKCS#7 → JSON | 后端业务解析 |
| 一卡通公钥配置 | publicKey | Base64 解码 → SM4 解密 | SM2 公钥 |
| 电费敏感请求 | 整份业务参数 JSON | SM2 C1C3C2 → 十六进制 | 外层 params 字段 |
后端怎样把这些请求组织起来
服务本身用 Python 标准库的 ThreadingHTTPServer 提供 HTTP 接口,外部请求主要由 urllib、CookieJar 和公共 JSON 请求函数完成;国密相关处理使用 gmssl。代码没有引入一套重型 Web 框架。
调用入口统一为 POST /api/v1/run。后端先根据 API Token 找到用户会话,确认学校 idToken 可用,再通过 feature 选择客户端与处理函数。
明确的功能名称,例如 jw.schedule 或 newcard.balance。功能注册表同时指定它应使用 PC 教务、移动教务还是一卡通客户端。
{}功能参数,例如课表的学期、流水的日期和页码,或电费的房间查询。由对应功能模块校验并转换为学校参数。
当前注册的 13 个业务功能如下:
| 系统 | 功能名 | 后端处理 |
|---|---|---|
| PC 教务 | jw.profile | 个人信息网页解析 |
| PC 教务 | jw.schedule | 课表解析与结构整理 |
| PC 教务 | jw.grades | 成绩合并与统计 |
| PC 教务 | jw.training | 毕业信息整理 |
| 移动教务 | jw.exams | 考试安排字段映射 |
| 一卡通 | newcard.balance | 校园卡账户余额 |
| 一卡通 | newcard.transactions | 消费流水与分页转换 |
| 一卡通 | newcard.campus_code.qrcode | 在线付款码与配置 |
| 一卡通 | newcard.recharge.config | 校园卡充值配置 |
| 一卡通 | newcard.recharge.create_order | 校园卡支付单 |
| 一卡通 | newcard.electricity.account | 宿舍定位与电量查询 |
| 一卡通 | newcard.electricity.recharge.config | 电费充值配置 |
| 一卡通 | newcard.electricity.recharge.pay | 电费支付提交 |
会话复用、续期和预热
runner.py 按学校 idToken 的摘要区分用户,并分别缓存 PC 教务、移动教务和一卡通客户端。这样,同一个用户连续查看余额和流水时,可以复用一卡通会话,不用重新走门户入口。
学校登录态接近过期时,密码登录会话可以重新认证;一卡通自己的 Token 则通过 /auth/updateToken 刷新。若请求被识别为认证失效,功能层会重建客户端并重试一次。目前部分判断基于错误文本,不能把它描述为对所有上游异常都能精确分类。
登录后,后台线程还会预热余额、成绩和考试安排三个只读功能,提前建立各系统会话;登录响应本身不等待全部预热完成,部分系统失败也能单独反映状态。
主要代码分工
xjit-cas-login-tool
- cas_login.pyCAS 页面、表单与基础登录工具
- jw_schedule.py教务跳转与课表网页处理
xjit
- server.pyHTTP 路由与认证入口
- api_sessions.py自有 API 会话
- auth.py学校 idToken 获取
- portal.py一卡通入口发现
- http.pyHTTP / JSON 公共请求
- crypto.py一卡通响应与敏感请求处理
- runner.py客户端选择、复用与预热
clients
- jw.pyPC / 移动教务客户端
- newcard.py一卡通令牌与业务请求
- features各业务的参数与解析逻辑
tools
- dump_newcard_electricity_rooms.py
App 只需要接好这一层
新理 Lite 的 App 使用 Flutter,通过 Dio 调用自己的后端。以查电费为例,客户端不需要知道学校的房间 ID、入口 tid 或 SM4 响应格式:
POST /api/v1/run HTTP/1.1Authorization: Bearer <新理 Lite API Token>Content-Type: application/json
{ "feature": "newcard.electricity.account", "params": { "roomQuery": "5#524" }}后端返回整理后的电量与房间信息,App 负责展示、刷新和错误提示。课表、成绩、余额也采用相同的调用方式,付款码则按后端提供的有效期更新。
客户端通过安全存储保存自己的 API 会话;学校各系统的原始凭证主要由后端管理。支付需要打开学校收银台时,App 再承接后端整理好的支付结果类型。
学校页面字段变化、位置目录更新、登录跳转调整,修改通常落在对应的后端客户端或功能模块里。这也是这次对接中最有价值的部分:每个功能都能追溯到具体的学校请求,出了问题,可以沿着认证、请求、解码和字段解析逐层定位。
分享文章
生成精美分享图或复制链接,与更多人分享本文。
继续阅读
最后更新于 ,距今已过 0 天
部分内容可能已过时