<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="zh_CN">
  <title>是青旨啊</title>
  <subtitle>我的随笔与心得</subtitle>
  <link href="https://sayqz.com/" rel="alternate" type="text/html"/>
  <link href="https://sayqz.com/atom.xml" rel="self" type="application/atom+xml"/>
  <id>https://sayqz.com/</id>
  <updated>2026-10-20T00:00:00.000Z</updated>
  <entry>
    <title>新理 Lite：校园接口对接</title>
    <link href="https://sayqz.com/posts/xllite-school-system-integration/" rel="alternate" type="text/html"/>
    <id>https://sayqz.com/posts/xllite-school-system-integration/</id>
    <published>2026-09-30T00:00:00.000Z</published>
    <updated>2026-10-20T00:00:00.000Z</updated>
    <summary>沿学校官方页面定位接口，梳理 CAS 登录、教务会话、一卡通令牌与加密协议，以及课表、成绩、付款码和宿舍电费的后端实现。</summary>
    <content type="html"><![CDATA[<p>新理 Lite 里的课表、成绩、校园卡和电费，分别来自学校的几个业务系统。把它们放进同一个 App，首先要解决的是：<strong>入口在哪里、用什么身份请求、返回的数据怎么读。</strong></p>
<p>学校系统没有提供一份覆盖这些功能的统一接口文档。有的功能能从官方网页找到 JSON 请求，有的只能拿到 HTML 表格；即使都经过统一认证，教务和一卡通也各自维护登录态。后端做的主要工作，就是沿着官方页面的调用链，把这些差异整理成一套可用的接口。</p>
<p>下面按后端的实际请求展开。示例里的凭证、房间 ID 和返回值均使用占位内容。</p>
<h2>从官方页面找到接口</h2>
<p>定位接口时，我关心的不是某个 URL 能不能打开，而是能不能还原一笔完整的业务请求：它从哪个页面发起，依赖什么登录态，参数来自哪里，响应又经过了哪些处理。</p>
<p>:::steps{title="从页面功能追到后端请求"}</p>
<ol>
<li>
<p><strong>先确定功能所在的官方入口</strong></p>
<p>课表和成绩从教务页面进入；校园卡、付款码、电费从门户提供的一卡通入口进入。记录入口与跳转目标，先分清实际负责业务的是哪个系统。</p>
</li>
<li>
<p><strong>对照网络请求，记录完整协议</strong></p>
<p>在官方页面正常使用功能时，重点核对请求方法、查询参数、请求体、Cookie、业务 Token 和响应。登录过程还要关注 <code>Location</code>，因为一次跳转里的票据可能正是下一个系统的入口。</p>
</li>
<li>
<p><strong>沿页面脚本追踪字段来源</strong></p>
<p>遇到响应加密、字段含义不清，或者多个接口名称相近时，继续查看页面加载的脚本：页面组件调用了哪个服务函数，请求拦截器补了什么头，响应拦截器有没有解密。</p>
</li>
<li>
<p><strong>在后端复现，再整理成业务数据</strong></p>
<p>保留必要的请求条件，确认登录失效、空数据和正常结果分别是什么形态，再将学校字段映射成自己的返回结构。HTML 表格、密文外层和学校原始字段都在这一层处理。
:::</p>
</li>
</ol>
<p>上面是定位接口时的核对顺序。项目里一个具体例子，是<strong>校园卡余额究竟取哪个接口</strong>。</p>
<p>仓库的余额对接记录保留了官方首页与付款码页面的脚本线索：页面入口加载了首页和校园码相关分包，组件通过 <code>userInfo</code> 读取 <code>resultData.user</code>，余额展示使用其中的 <code>balance</code> 与 <code>accTypes</code>。由此对应到实际请求：<code>POST /server/user/info</code>。</p>
<p>另一个看起来很相关的 <code>/user/tradeListConfig</code>，实际上返回流水筛选配置，不能用来读余额。只按接口名字猜，很容易找错数据源。</p>
<p>确认 URL 后还没结束。余额接口的 HTTP 响应可能只有这样的外层：</p>
<pre><code>{
  "data": "&lt;Base64 编码的密文&gt;"
}
</code></pre>
<p>它没有直接给 <code>success</code>，但这不代表业务失败。对照官方请求处理逻辑，必须先做 Base64 解码、SM4 解密和 JSON 解析，才能读到真正的业务结果。这个处理最终放进了 <code>NewcardClient</code> 的公共响应层，查询、获取令牌和刷新令牌都能复用。</p>
<p>官方前端脚本的文件名带构建哈希，定位时应从页面当前加载的资源继续找；哈希文件名只是排查线索，不适合当作长期固定的接口入口。</p>
<h2>一次登录，三种业务会话</h2>
<p>接入的系统可以分成三组。PC 教务靠 Cookie，移动教务靠自己的 Token，一卡通还要通过门户入口再换一套令牌。</p>
<pre><code>flowchart TB
    A["用户完成学校统一认证"] --&gt; B["学校 idToken"]
    B --&gt; C["PC 教务 SSO"]
    B --&gt; D["移动教务 SSO"]
    B --&gt; E["学校门户的一卡通入口"]
    C --&gt; F["Cookie 会话 · HTML 页面"]
    D --&gt; G["移动业务 token · JSON 接口"]
    E --&gt; H["tid 换一卡通令牌 · JSON / 加密响应"]
</code></pre>
<p>这几种凭证不能混着用：</p>
<table>
<thead>
<tr>
<th>凭证</th>
<th>谁接收它</th>
<th>用在什么地方</th>
</tr>
</thead>
<tbody>
<tr>
<td>学校 <code>idToken</code></td>
<td>学校认证、门户及 SSO 链路</td>
<td>继续建立各业务系统的会话</td>
</tr>
<tr>
<td>PC 教务 Cookie</td>
<td>PC 教务</td>
<td>获取课表、成绩、个人信息等网页</td>
</tr>
<tr>
<td>移动教务 <code>token</code></td>
<td>移动教务</td>
<td>请求考试安排等移动接口</td>
</tr>
<tr>
<td>一卡通 <code>accessToken</code> / <code>refreshToken</code></td>
<td>一卡通</td>
<td>校园卡、电费、付款码与充值</td>
</tr>
<tr>
<td>新理 Lite API <code>accessToken</code></td>
<td>自己的后端</td>
<td>App 调用 <code>/api/v1/run</code> 等接口</td>
</tr>
</tbody>
</table>
<p>App 只拿自己后端的 API Token。后端根据功能选择业务客户端，学校侧的令牌和 Cookie 由对应客户端管理。</p>
<h2>CAS：先按官方流程建立学校登录态</h2>
<h3>密码登录与微信登录</h3>
<p>:::tabs#xllite-auth</p>
<p>@tab 账号密码#password</p>
<p>密码登录首先获取 CAS 页面和当前公钥：</p>
<pre><code>GET /cas/login?service=&lt;学校 superapp 地址&gt; HTTP/1.1
Host: cas.xjit.edu.cn

GET /cas/jwt/publicKey HTTP/1.1
Host: cas.xjit.edu.cn
</code></pre>
<p>这里使用的 service 是 <code>https://superapp.xjit.edu.cn/pages/tab/index/index</code>。两个请求与后续提交共用带 CookieJar 的 opener。</p>
<p><code>xjit/auth.py</code> 先解析登录页的 <code>fm1</code> 表单，保留当前页面生成的隐藏字段，再填入账号、RSA 加密后的密码和验证码等内容。公钥来自学校接口，密码按页面流程使用 RSA PKCS#1 v1.5 加密。</p>
<pre><code>POST /cas/login?service=&lt;URL 编码后的 service&gt; HTTP/1.1
Host: cas.xjit.edu.cn
Content-Type: application/x-www-form-urlencoded
Origin: https://cas.xjit.edu.cn
Referer: &lt;刚才获取的登录页地址&gt;
Cookie: &lt;当前 CookieJar 中的 Cookie&gt;

username=&lt;账号&gt;&amp;password=&lt;URL 编码后的密码密文&gt;&amp;&lt;当前表单中的其他字段&gt;
</code></pre>
<p>提交后主动读取 <code>Location</code>，提取回跳参数中的 <code>ticket</code>。当前学校链路的 ticket 带有 JWT 形态的载荷，后端从中读取 <code>idToken</code>。</p>
<p>因此，不能只保存一份固定表单长期使用：页面隐藏字段和 Cookie 是这次登录过程的一部分。没有预期回跳、没有 ticket 或没有 <code>idToken</code>，都不能视作成功。</p>
<p>@tab 微信扫码#wechat</p>
<p>微信扫码沿学校 CAS 的联合认证入口进行：</p>
<pre><code>GET /cas/federatedRedirect?service=&lt;URL 编码后的 service&gt;&amp;federatedName=openweixin HTTP/1.1
Host: cas.xjit.edu.cn
</code></pre>
<p>自己的后端先创建带 <code>state</code> 的扫码会话，App 打开学校认证页。用户完成学校的授权流程，回到 superapp 地址后，把 ticket 交给后端完成接口。</p>
<p>后端仍然从学校回跳凭证中取得 <code>idToken</code>，随后签发自己的 API Token。后续校园业务使用同一套分发流程。</p>
<p>两种方式的区别主要在续期：密码登录的后端内存会话保留账号密码，可用于重新认证；扫码会话没有 CAS 密码，学校登录态过期后需要重新扫码。</p>
<p>:::</p>
<p>:::note[读取载荷与验证身份是两件事]</p>
<p>对 ticket 做 Base64 解码只是读取载荷，不能称为完成了 JWT 签名验证。取得 <code>idToken</code> 后，还要由学校业务系统接受它，才能建立后续会话。当前 API 会话存放在服务进程内存中，服务重启后不会保留。</p>
<p>:::</p>
<h3>CAS 密码加密：RSA、Base64 与字段前缀</h3>
<p>密码字段不是直接提交明文，也不是做一次 MD5。<code>cas_login.py</code> 中的 <code>openssl_encrypt()</code> 对应的是下面这条处理链：</p>
<pre><code>用户输入的密码
  → UTF-8 字节
  → RSA 公钥加密，使用 PKCS#1 v1.5 填充
  → 密文字节转 Base64 文本
  → 加上 __RSA__ 前缀
  → 放入 password 字段，随整个表单做 URL 编码
</code></pre>
<p>RSA 公钥从学校的 <code>/cas/jwt/publicKey</code> 获取，返回内容按 PEM 公钥使用。加密用公钥即可，后端不持有学校的私钥；学校收到表单后才能完成对应的私钥解密。</p>
<p>其中 <strong><code>__RSA__</code> 是当前登录协议的一部分</strong>，不能漏掉。Base64 只是把二进制密文转换成表单可传输的文本，并不承担加密作用。</p>
<p>下面将源码中的主路径整理成一个独立函数。源码还保留了旧版 <code>rsautl</code> 命令的兼容回退，这里集中展示 <code>pkeyutl</code> 的参数：</p>
<pre><code>import base64
import subprocess
import tempfile
from pathlib import Path


def encrypt_cas_password(public_key_pem: str, password: str) -&gt; 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
</code></pre>
<p>这里有三个需要和官方流程保持一致的地方：填充模式是 <code>pkcs1</code>，不是 OAEP；明文通过标准输入交给 OpenSSL，不拼进命令参数；密文字节转 Base64，而不是转十六进制。</p>
<p>同一密码每次生成的密文可以不同，因为 PKCS#1 v1.5 加密填充包含随机字节。核对实现时应验证能否被对应私钥正确解密，不能要求输出字符串与某次页面请求完全相同。</p>
<p>:::details[把加密结果接回登录表单]</p>
<p>这段与 <code>xjit/auth.py</code> 的调用关系一致。<code>opener</code> 复用同一个 CookieJar，<code>login_url</code> 包含当前 service，<code>form_payload()</code> 保留从 <code>fm1</code> 解析出的字段：</p>
<pre><code>from urllib.parse import urlencode
from 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")
</code></pre>
<p>最后才以 <code>application/x-www-form-urlencoded</code> 提交 <code>body</code>。Base64 中的 <code>+</code>、<code>/</code>、<code>=</code> 由表单编码处理，不手工拼接字符串，也不提前把密码字段重复 URL 编码。RSA 字段加密之外，整个请求仍然通过 HTTPS 发送。</p>
<p>:::</p>
<h2>教务：网页和移动接口分别怎么请求</h2>
<p>:::tabs#xllite-jw-protocol</p>
<p>@tab PC 教务#pc</p>
<p>PC 教务先以 <code>https://jw.xjit.edu.cn/jsxsd/framework/xsMain.jsp</code> 为 service，带学校 <code>idToken</code> 走 CAS SSO。<code>JwClient.from_id_token()</code> 完成跳转并保留 CookieJar，后续请求直接复用这份教务会话。</p>
<p>以课表为例：</p>
<pre><code>GET /jsxsd/xskb/xskb_list.do?xnxq01id=2025-2026-2 HTTP/1.1
Host: jw.xjit.edu.cn
Cookie: &lt;教务会话 Cookie&gt;
</code></pre>
<p>学期参数是 <code>xnxq01id</code>；不传时使用系统默认学期。这里拿到的是 HTML，后端还需要解析表格。</p>
<table>
<thead>
<tr>
<th>功能</th>
<th>学校页面 / 数据来源</th>
</tr>
</thead>
<tbody>
<tr>
<td>课表</td>
<td><code>/jsxsd/xskb/xskb_list.do</code></td>
</tr>
<tr>
<td>成绩</td>
<td><code>/jsxsd/kscj/cjcx_list</code></td>
</tr>
<tr>
<td>成绩补充来源</td>
<td><code>/jsxsd/bygl/bygl_ckxsList</code></td>
</tr>
<tr>
<td>毕业信息</td>
<td><code>/jsxsd/xxwcqk/byqkcx.do</code></td>
</tr>
<tr>
<td>个人信息</td>
<td>从可用个人信息页读取字段，统一映射姓名、学号、学院、专业、班级等</td>
</tr>
</tbody>
</table>
<p>PC 教务有个需要单独处理的情况：会话失效后返回登录页，HTTP 状态仍可能是 200。客户端会检查登录页特征，功能解析层再验证预期的表格，避免把登录失效显示成“没有数据”。</p>
<p>@tab 移动教务#mobile</p>
<p>考试安排使用移动教务。SSO 的 service 改为：</p>
<pre><code>https://jw.xjit.edu.cn/xjlgxyhd/loginSso?toMenu=xs_ksap
</code></pre>
<p>后端检查跳转链、最终地址和页面里的 token 参数，兼容 URL 查询串与片段中的参数。如果先拿到 <code>askToken</code>，再请求 <code>getXgtToken</code> 换取最终业务 token。</p>
<pre><code>POST /xjlgxyhd/student/examinationArrangement?semester=2025-2026-2 HTTP/1.1
Host: jw.xjit.edu.cn
token: &lt;移动教务 token&gt;
Content-Type: application/x-www-form-urlencoded;charset=UTF-8
Accept: application/json, text/plain, */*
Cache-Control: no-cache
Referer: https://jw.xjit.edu.cn/xjlgxy/
Content-Length: 0
</code></pre>
<p>这个请求值得单独写出来：<strong>方法是 POST，参数在 URL 查询串，请求体为空。</strong> 代码里的方法叫 <code>mobile_post_json()</code>，其中 JSON 指返回数据的处理方式，不能据此推断请求体也是 JSON。</p>
<p>响应的 <code>data</code> 应当是数组。后端再做字段兼容，例如 <code>kcmc</code> 映射为 <code>courseName</code>，<code>ksdd</code> 映射为 <code>examPlace</code>；结构异常则报错，不把异常对象当空列表返回。</p>
<p>:::</p>
<h3>HTML 怎样变成可用的课表和成绩</h3>
<p>对教务网页的处理集中在后端功能模块里。请求成功只是第一步，后面还要解决表格结构和字段语义。</p>
<p>:::collapse</p>
<ul>
<li>
<p>:+ 课表：从单元格中提取课程</p>
<p>课表解析器读取星期、时间段和每个单元格里的课程，再拆出课程名、教师、周次、节次与教室。一个格子里有多门课时分别保留，不把整块文字当成一条课程。</p>
<p>学期优先读取 <code>xnxq01id</code> 下拉框中选中的选项，避免页面其他位置出现的学期文字影响判断。</p>
<p>还有一种实践课数据：同一天五个时段重复出现相同课程、教师、周次和地点，节次却全部写成 <code>01节</code>。代码只在满足这组条件时按课表时段补齐节次，同时保留 <code>originalSections</code> 和 <code>sectionSource</code>。普通课程不套用这个修正。</p>
</li>
<li>
<p>成绩：合并来源，但保留每次记录</p>
<p>成绩主页面之外，后端还读取毕业审核相关页面作为补充来源。使用“学期、课程代码、课程名称”识别已有记录，只补充主列表缺失的内容，并保留来源信息。</p>
<p>原始记录与统计口径分开处理。普通考试、补考等记录仍然保留；需要每门课取最佳记录时，优先按绩点、再按分数选择。课程代码缺失时才退回课程名称分组。</p>
<p>加权绩点只纳入学分和绩点都能解析为数字的记录。分母是参与计算的学分，不能拿全部记录的学分直接代替；总学分也不能直接解释为“已通过学分”。</p>
</li>
<li>
<p>个人信息与毕业信息：按字段含义映射</p>
<p>个人信息按页面标签映射学号、姓名、学院、专业、班级等字段，减少对某一固定列位置的依赖。</p>
<p>功能名 <code>jw.training</code> 当前读取的是毕业信息页，整理毕业年份、类型、结论、时间及证书编号等字段。它并不等于已经接入完整的培养方案系统。
:::</p>
</li>
</ul>
<h2>一卡通：门户入口、tid 和业务令牌</h2>
<p>一卡通的入口不是固定带着用户身份的普通链接。学校门户根据当前登录态提供服务地址，后端先带学校 <code>idToken</code> 请求门户：</p>
<pre><code>GET /portal-api/v1/personalData/getECardDataList HTTP/1.1
Host: portal.xjit.edu.cn
X-Id-Token: &lt;学校 idToken&gt;
X-Device-Info: AppleMacOS
X-Terminal-Info: h5
</code></pre>
<p><code>xjit/portal.py</code> 还会结合服务列表、主题配置及相关入口查找一卡通地址。门户可能直接返回 <code>tid</code>，也可能把服务地址放在嵌套字段中，需要继续请求并跟随跳转。遇到 <code>tokenAccept</code> 配置时，按配置决定令牌的参数名和替换方式。</p>
<p>最终找到包含 <code>tid</code> 的一卡通入口，再换业务令牌：</p>
<pre><code>POST /server/auth/getToken HTTP/1.1
Host: newcard.xjit.edu.cn
Content-Type: application/json
Referer: https://newcard.xjit.edu.cn/?tid=&lt;tid&gt;&amp;orgId=2

{"tid":"&lt;门户入口中的 tid&gt;"}
</code></pre>
<p>返回的 <code>resultData</code> 中包含 <code>accessToken</code>、<code>refreshToken</code> 和有效期。后续业务请求的基本形式如下：</p>
<pre><code>POST /server/user/info HTTP/1.1
Host: newcard.xjit.edu.cn
Authorization: &lt;一卡通 accessToken&gt;
Content-Type: application/json
Referer: https://newcard.xjit.edu.cn/?orgId=2

{}
</code></pre>
<p>这里的 <code>Authorization</code> 直接放一卡通 Token，<strong>没有 <code>Bearer</code> 前缀</strong>。自己的 API 则使用 <code>Authorization: Bearer &lt;API Token&gt;</code>。实际业务请求优先沿用取得的入口 URL 作为 Referer，上面展示的是客户端的默认回退值。</p>
<h3>在公共客户端里解码响应</h3>
<p><code>NewcardClient._post()</code> 发请求后统一调用 <code>newcard_decode_response()</code>。明文业务响应直接使用，加密响应按以下顺序处理：</p>
<p>:::steps{title="一卡通响应处理"}</p>
<ol>
<li>
<p><strong>识别外层结构</strong></p>
<p>判断它是否已经是明文业务对象，或带有需要处理的 <code>data</code> 密文字段。</p>
</li>
<li>
<p><strong>还原密文字节并解密</strong></p>
<p>Base64 解码后检查长度，再按官方客户端协议进行 SM4-ECB 解密。</p>
</li>
<li>
<p><strong>校验填充与 JSON</strong></p>
<p>显式验证 PKCS#7 的填充长度和填充字节，去除填充后解码 UTF-8，解析 JSON，并确认结果是对象。</p>
</li>
<li>
<p><strong>交给具体业务模块</strong></p>
<p>余额模块读取账户，付款码模块读取二维码，电费模块读取账户字段。解密失败时返回明确错误，不把密文、个人信息或令牌带进错误信息。
:::</p>
</li>
</ol>
<p>这样，业务功能不用分别判断学校这次返回了明文还是密文。协议处理留在客户端，业务字段留在功能模块。</p>
<h3>SM4 解密具体怎么做</h3>
<p>一卡通这里使用的是 <strong>SM4-ECB + PKCS#7</strong>。SM4 的密钥和分组长度都是 16 字节，ECB 模式不传 IV；Base64 解码得到的密文字节长度必须是 16 的倍数。</p>
<p>下面是从 <code>xjit/crypto.py</code> 的响应处理整理出的等价示例。协议密钥通过参数传入，不在文章中公开具体值；不是随意生成一把密钥就能解开学校的响应。</p>
<pre><code>import base64
import json
from gmssl.sm4 import CryptSM4, SM4_DECRYPT


def decrypt_sm4_data(encoded: str, key: bytes) -&gt; 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 &lt;= count &lt;= 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) -&gt; 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
</code></pre>
<p>例如尾部填充长度为 4，最后四个字节就必须全部是 <code>0x04</code>。只读最后一个字节就直接截断，无法发现前面的填充字节是否异常，所以源码对两项都做了校验。</p>
<p>在这一层，==解密成功不等于业务成功==。还原出内部 JSON 后，仍由具体功能检查业务状态、错误信息和必需字段。SM4-ECB 本身也不提供消息完整性认证，填充检查不能被当成签名验证。</p>
<h2>余额、流水与付款码</h2>
<p>:::tabs#xllite-card-features</p>
<p>@tab 账户余额#balance</p>
<p>余额来源是 <code>POST /server/user/info</code>，主要读取 <code>resultData.user.accTypes</code>。每个账户分别整理为 <code>typeCode</code>、<code>typeName</code>、<code>balance</code> 和 <code>unit</code>。</p>
<p>几个容易影响结果的细节都在 <code>newcard_balance.py</code> 处理：</p>
<ul>
<li>多个账户按学校顺序分别返回，不直接相加。</li>
<li>账户明细不存在或为空时，仅在 <code>user.balance</code> 是有效金额的情况下退回总余额。</li>
<li>零余额是有效数据；缺失余额与结构异常要报错，不能补成零。</li>
<li>学校这个字段的单位已经是元，不再除以 100。</li>
</ul>
<p>金额文本使用 <code>Decimal</code> 校验，避免二进制浮点误差影响数值判断。</p>
<p>@tab 消费流水#transactions</p>
<pre><code>POST /server/user/tradeList HTTP/1.1
Host: newcard.xjit.edu.cn
Authorization: &lt;一卡通 accessToken&gt;
Content-Type: application/json
Referer: &lt;一卡通入口地址&gt;

{
  "fromDate": "2026-09-01",
  "toDate": "2026-09-30",
  "tradeType": "1,2,3",
  "pageNo": 0,
  "pageSize": 20
}
</code></pre>
<p>学校分页从 0 开始，自己的业务参数默认从第 1 页开始，后端做转换；每页最多 100 条，未指定日期时默认查询最近 30 天。</p>
<p>响应中的日期、摘要、商户、金额、退款标识和流水号被整理为固定字段。其中学校的 <code>amt</code> 映射为 <code>amount</code>，文本还会移除不可打印字符。</p>
<p>@tab 校园付款码#qrcode</p>
<p>付款码先读配置，再请求在线码：</p>
<pre><code>POST /server/virtualCard/config HTTP/1.1
Host: newcard.xjit.edu.cn
Authorization: &lt;一卡通 accessToken&gt;
Content-Type: application/json

{"devCode":"xjit-api"}

### 读取配置并确定 qrcodeType 后

POST /server/virtualCard/qrcodeOnline HTTP/1.1
Host: newcard.xjit.edu.cn
Authorization: &lt;一卡通 accessToken&gt;
Content-Type: application/json

{"qrcodeType":"&lt;配置中的码类型&gt;"}
</code></pre>
<p>配置决定是否允许在线模式、默认码类型、刷新间隔，以及是否显示姓名和余额。码类型优先采用调用者明确指定的值，否则读取学校选中的配置，再退回第一个配置。</p>
<p>如果需要显示余额，先读取余额，再生成短时有效的付款码，避免先生成的码在等待附加请求时消耗有效期。余额属于可选展示信息，读取失败不阻止有效码生成；学校配置不显示余额时不额外查询。</p>
<p>接口返回码内容与 <code>expiresInSeconds</code>，刷新时间优先使用学校的 <code>refreshSecond</code>，缺失时默认 30 秒。付款码内容是业务凭证，不作为普通调试数据保存。</p>
<p>:::</p>
<h2>宿舍电费：房间号怎么变成学校的定位参数</h2>
<p>电费接口不是只接收“几号楼、几号房”。实际查询依赖一组学校内部 ID：</p>
<pre><code>{
  "utilityType": "electric",
  "bigArea": "&lt;校区 ID&gt;",
  "area": "&lt;区域 ID&gt;",
  "building": "&lt;楼栋 ID&gt;",
  "unit": "",
  "level": "&lt;楼层 ID&gt;",
  "room": "&lt;房间 ID&gt;",
  "subArea": ""
}
</code></pre>
<p>这些 ID 来自学校页面的位置选择接口 <code>POST /server/utilities/location</code>。请求里的 <code>locationType</code> 指定当前查询哪一级，已选中的父级 ID 一并传入，响应返回 <code>resultData.locationList</code>。</p>
<p>项目中的 <code>tools/dump_newcard_electricity_rooms.py</code> 使用已认证的一卡通客户端，沿学校的选择层级读取位置并整理房间映射。这里不能假定所有楼都完全相同：有些分支在 <code>unit</code> 查询结果中直接给房间，有些要继续读取楼层，再查询房间。</p>
<p>:::steps{title="一次电费查询的参数转换"}</p>
<ol>
<li>
<p><strong>解析用户输入</strong></p>
<p>将 <code>5#524</code>、<code>5号楼524</code> 等形式解析为楼号和房号。这只是格式示例，不代表某位用户的实际宿舍。</p>
</li>
<li>
<p><strong>查询房间映射</strong></p>
<p>用规范化后的楼号与房号查找本地映射，取得学校真实的区域、楼栋、楼层和房间 ID。找不到映射时明确报错，不猜测 ID。</p>
</li>
<li>
<p><strong>发送账户查询</strong></p>
<p>将这些 ID 组装进 <code>/utilities/account</code> 的 JSON 请求体。调用者已经提供完整 <code>location</code> 时可以直接使用；未指定房间时，也可从 <code>/utilities/config</code> 读取学校的默认位置。</p>
</li>
<li>
<p><strong>按字段编码读取剩余电量</strong></p>
<p>遍历响应的 <code>templateList</code>，寻找 <code>code</code> 为 <code>quantity</code> 的项目，返回它的 <code>value</code> 与 <code>unit</code>，不依赖它恰好排在列表第几项。
:::</p>
</li>
</ol>
<p>映射缓存的是位置目录，不是实时电量。日常查询直接定位房间，再请求最新账户数据；学校调整楼栋或房间目录时，才需要更新映射。</p>
<h2>充值：普通下单与敏感请求的区别</h2>
<p>校园卡充值和电费充值都属于一卡通业务，但请求封装并不完全相同。代码把读取配置、校验金额、选择支付方式与提交业务分开处理。</p>
<p>:::tabs#xllite-recharge</p>
<p>@tab 校园卡充值#card</p>
<p>先请求 <code>/server/recharge/config</code>。金额档位来自 <code>amtGroupList</code>，支付方式来自 <code>daLianPayList</code>、<code>wapPayList</code> 等学校配置，后端将其整理为自己的 <code>amountOptions</code> 与 <code>payMethods</code>。</p>
<p>创建订单时会重新读取配置，校验当前选择仍然可用。金额要求大于零、最多两位小数，再获取交易编号：</p>
<pre><code>POST /server/consume/transaction HTTP/1.1
Host: newcard.xjit.edu.cn
Authorization: &lt;一卡通 accessToken&gt;
Content-Type: application/json

{"datetime":"&lt;当前 YYYY-MM-DD HH:MM:SS&gt;"}
</code></pre>
<p>随后提交 <code>/server/recharge/pay</code>，请求体包含 <code>payCode</code>、<code>tradeType</code>、<code>amt</code>、<code>returnUrl</code> 和毫秒时间戳，按业务需要附带其他参数。交易编号放入请求头；源码同时设置了两种大小写写法的 <code>transactionid</code> / <code>transactionId</code>，HTTP 头名本身不区分大小写。</p>
<p><strong>校园卡充值这里使用普通 JSON 请求。</strong> 返回数据可能是 H5 地址、需要 POST 的表单，或者微信 JSAPI 参数，后端按类型整理，再交给客户端继续支付。</p>
<p>@tab 电费充值#electricity</p>
<p>电费先查询配置和房间账户，确认目标位置、金额与支付方式。当前选用校园卡余额渠道且学校要求密码时，还要提供支付密码。</p>
<p>提交 <code>/server/utilities/pay</code> 使用 <code>sensitive_post()</code>，线上请求体不直接暴露业务字段，而是：</p>
<pre><code>{
  "id": "&lt;本次加密配置 ID&gt;",
  "params": "&lt;SM2 加密后的十六进制密文&gt;"
}
</code></pre>
<p>对应的封装过程是：</p>
<ol>
<li>请求 <code>/server/auth/getEncrypt</code>，取得加密配置 ID 和封装后的公钥。</li>
<li>按官方协议还原公钥，将业务参数序列化为紧凑 JSON。</li>
<li>使用 SM2 的 C1C3C2 模式加密 UTF-8 字节，转成十六进制字符串。</li>
<li>获取交易编号，将封装后的请求体和交易头交给 <code>/utilities/pay</code>。</li>
</ol>
<p>余额支付的即时结果与需要继续打开收银台的结果分开返回。后端不会仅凭打开支付页就将订单标为成功。</p>
<p>:::</p>
<p>:::important[认证重试与交易确认需要分开看]</p>
<p>当前客户端对部分鉴权失败会刷新令牌并重试，功能执行层也有识别认证错误后重建客户端、再执行一次的逻辑。因此，现有实现不能直接宣称支付请求具备“只执行一次”的保证。请求超时、页面回跳和实际扣款是不同状态，交易结果需要按学校的业务结果与订单状态确认。</p>
<p>:::</p>
<h3>SM2：加密的是完整业务参数</h3>
<p>电费敏感请求的 <code>params</code> 与 CAS 的 <code>password</code> 是两种不同封装。CAS 只对密码字段做 RSA 加密；一卡通这里先把整份业务参数变成 JSON，再做 SM2 加密。</p>
<p><code>/auth/getEncrypt</code> 返回的 <code>publicKey</code> 也不是直接可用的明文公钥。当前协议先用 Base64 和 SM4 封装它，需要先还原，才能交给 SM2：</p>
<pre><code>/auth/getEncrypt
  ├─ id → 原样放入本次请求外层
  └─ publicKey → Base64 解码 → SM4 解密 → SM2 公钥

房间定位、金额、支付方式等业务参数
  → 紧凑 JSON → UTF-8 → SM2（C1C3C2）→ 十六进制文本
  → {"id": "…", "params": "…"}
</code></pre>
<p>SM4 处理的是服务端下发的公钥封装，SM2 处理的是准备上送的业务参数，两步的输入、输出不能混用。公钥还原可以复用上面的 <code>decrypt_sm4_data()</code>；下面把对应操作串起来：</p>
<pre><code>from gmssl import sm2


def encrypt_sensitive_request(
    business_payload: dict, encrypt_config: dict, sm4_key: bytes
) -&gt; 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()}
</code></pre>
<p><code>mode=1</code> 对应当前协议的 C1C3C2 排列，不能替换成默认模式后仍假定密文兼容。<code>ensure_ascii=False</code> 与紧凑分隔符对应源码的 JSON 序列化方式，最后输出十六进制，不加 CAS 的 <code>__RSA__</code> 前缀，也不再转 Base64。</p>
<p>实际 <code>NewcardClient.sensitive_post()</code> 会先读取本次加密配置，再构造上面的封装，获取交易编号后发送请求。文章示例保留协议步骤，公钥解包部分额外复用了严格的填充验证；源码目前对公钥解包和业务响应解包采用了不同的辅助函数。</p>
<table>
<thead>
<tr>
<th>位置</th>
<th>要处理的数据</th>
<th>算法与封装</th>
<th>结果用在哪里</th>
</tr>
</thead>
<tbody>
<tr>
<td>CAS 密码登录</td>
<td>密码 UTF-8 字节</td>
<td>RSA PKCS#1 v1.5 → Base64 → <code>__RSA__</code></td>
<td>表单 <code>password</code> 字段</td>
</tr>
<tr>
<td>一卡通响应</td>
<td>外层 <code>data</code></td>
<td>Base64 解码 → SM4-ECB → 去 PKCS#7 → JSON</td>
<td>后端业务解析</td>
</tr>
<tr>
<td>一卡通公钥配置</td>
<td><code>publicKey</code></td>
<td>Base64 解码 → SM4 解密</td>
<td>SM2 公钥</td>
</tr>
<tr>
<td>电费敏感请求</td>
<td>整份业务参数 JSON</td>
<td>SM2 C1C3C2 → 十六进制</td>
<td>外层 <code>params</code> 字段</td>
</tr>
</tbody>
</table>
<h2>后端怎样把这些请求组织起来</h2>
<p>服务本身用 Python 标准库的 <code>ThreadingHTTPServer</code> 提供 HTTP 接口，外部请求主要由 <code>urllib</code>、CookieJar 和公共 JSON 请求函数完成；国密相关处理使用 <code>gmssl</code>。代码没有引入一套重型 Web 框架。</p>
<p>调用入口统一为 <code>POST /api/v1/run</code>。后端先根据 API Token 找到用户会话，确认学校 <code>idToken</code> 可用，再通过 <code>feature</code> 选择客户端与处理函数。</p>
<p>::::field-group</p>
<p>:::field feature
@type string
@required</p>
<p>明确的功能名称，例如 <code>jw.schedule</code> 或 <code>newcard.balance</code>。功能注册表同时指定它应使用 PC 教务、移动教务还是一卡通客户端。
:::</p>
<p>:::field params
@type object
@default <code>{}</code></p>
<p>功能参数，例如课表的学期、流水的日期和页码，或电费的房间查询。由对应功能模块校验并转换为学校参数。
:::</p>
<p>::::</p>
<p>当前注册的 13 个业务功能如下：</p>
<table>
<thead>
<tr>
<th>系统</th>
<th>功能名</th>
<th>后端处理</th>
</tr>
</thead>
<tbody>
<tr>
<td>PC 教务</td>
<td><code>jw.profile</code></td>
<td>个人信息网页解析</td>
</tr>
<tr>
<td>PC 教务</td>
<td><code>jw.schedule</code></td>
<td>课表解析与结构整理</td>
</tr>
<tr>
<td>PC 教务</td>
<td><code>jw.grades</code></td>
<td>成绩合并与统计</td>
</tr>
<tr>
<td>PC 教务</td>
<td><code>jw.training</code></td>
<td>毕业信息整理</td>
</tr>
<tr>
<td>移动教务</td>
<td><code>jw.exams</code></td>
<td>考试安排字段映射</td>
</tr>
<tr>
<td>一卡通</td>
<td><code>newcard.balance</code></td>
<td>校园卡账户余额</td>
</tr>
<tr>
<td>一卡通</td>
<td><code>newcard.transactions</code></td>
<td>消费流水与分页转换</td>
</tr>
<tr>
<td>一卡通</td>
<td><code>newcard.campus_code.qrcode</code></td>
<td>在线付款码与配置</td>
</tr>
<tr>
<td>一卡通</td>
<td><code>newcard.recharge.config</code></td>
<td>校园卡充值配置</td>
</tr>
<tr>
<td>一卡通</td>
<td><code>newcard.recharge.create_order</code></td>
<td>校园卡支付单</td>
</tr>
<tr>
<td>一卡通</td>
<td><code>newcard.electricity.account</code></td>
<td>宿舍定位与电量查询</td>
</tr>
<tr>
<td>一卡通</td>
<td><code>newcard.electricity.recharge.config</code></td>
<td>电费充值配置</td>
</tr>
<tr>
<td>一卡通</td>
<td><code>newcard.electricity.recharge.pay</code></td>
<td>电费支付提交</td>
</tr>
</tbody>
</table>
<h3>会话复用、续期和预热</h3>
<p><code>runner.py</code> 按学校 <code>idToken</code> 的摘要区分用户，并分别缓存 PC 教务、移动教务和一卡通客户端。这样，同一个用户连续查看余额和流水时，可以复用一卡通会话，不用重新走门户入口。</p>
<p>学校登录态接近过期时，密码登录会话可以重新认证；一卡通自己的 Token 则通过 <code>/auth/updateToken</code> 刷新。若请求被识别为认证失效，功能层会重建客户端并重试一次。目前部分判断基于错误文本，不能把它描述为对所有上游异常都能精确分类。</p>
<p>登录后，后台线程还会预热余额、成绩和考试安排三个只读功能，提前建立各系统会话；登录响应本身不等待全部预热完成，部分系统失败也能单独反映状态。</p>
<p>:::details[主要代码分工]</p>
<pre><code>xjit-cas-login-tool/
├── cas_login.py                 # CAS 页面、表单与基础登录工具
├── jw_schedule.py               # 教务跳转与课表网页处理
├── xjit/
│   ├── server.py                # HTTP 路由与认证入口
│   ├── api_sessions.py          # 自有 API 会话
│   ├── auth.py                  # 学校 idToken 获取
│   ├── portal.py                # 一卡通入口发现
│   ├── http.py                  # HTTP / JSON 公共请求
│   ├── crypto.py                # 一卡通响应与敏感请求处理
│   ├── runner.py                # 客户端选择、复用与预热
│   ├── clients/
│   │   ├── jw.py                # PC / 移动教务客户端
│   │   └── newcard.py           # 一卡通令牌与业务请求
│   └── features/               # 各业务的参数与解析逻辑
└── tools/
    └── dump_newcard_electricity_rooms.py
</code></pre>
<p>:::</p>
<h2>App 只需要接好这一层</h2>
<p>新理 Lite 的 App 使用 Flutter，通过 Dio 调用自己的后端。以查电费为例，客户端不需要知道学校的房间 ID、入口 tid 或 SM4 响应格式：</p>
<pre><code>POST /api/v1/run HTTP/1.1
Authorization: Bearer &lt;新理 Lite API Token&gt;
Content-Type: application/json

{
  "feature": "newcard.electricity.account",
  "params": {
    "roomQuery": "5#524"
  }
}
</code></pre>
<p>后端返回整理后的电量与房间信息，App 负责展示、刷新和错误提示。课表、成绩、余额也采用相同的调用方式，付款码则按后端提供的有效期更新。</p>
<p>客户端通过安全存储保存自己的 API 会话；学校各系统的原始凭证主要由后端管理。支付需要打开学校收银台时，App 再承接后端整理好的支付结果类型。</p>
<p>学校页面字段变化、位置目录更新、登录跳转调整，修改通常落在对应的后端客户端或功能模块里。这也是这次对接中最有价值的部分：每个功能都能追溯到具体的学校请求，出了问题，可以沿着认证、请求、解码和字段解析逐层定位。</p>
]]></content>
    <author><name>是青旨啊</name></author>
    <category term="工程实践"/>
  </entry>
  <entry>
    <title>汽水音乐播放链路解析</title>
    <link href="https://sayqz.com/blog/qishui-media-pipeline/" rel="alternate" type="text/html"/>
    <id>https://sayqz.com/blog/qishui-media-pipeline/</id>
    <published>2026-08-02T00:00:00.000Z</published>
    <updated>2026-10-19T00:00:00.000Z</updated>
    <summary>记录汽水音乐播放过程中的曲目归一化、MP4 Sample 索引、流式缓冲与播放器输出，以及几类常见的边界问题。</summary>
    <content type="html"><![CDATA[<p>曲目搜索能返回结果，音频地址也能请求成功，播放器却不一定能出声。排查这条链路时，只看 HTTP 状态码不够，还要继续看容器结构、Sample 边界和输出格式。</p>
<p>这次研究汽水音乐的播放过程，我把代码分成了数据访问、容器解析、媒体处理和播放输出几层。下面记录各层负责的工作，以及实现时需要单独处理的边界。</p>
<p>:::note[内容范围]</p>
<p>本文只讨论媒体容器、流式处理和程序结构。平台认证、授权材料及私有实现不公开，文中的媒体处理函数也仅保留调用关系。请尊重平台服务条款与内容版权。</p>
<p>:::</p>
<h2>播放链路的五个阶段</h2>
<p>&lt;figure&gt;
&lt;img src="https://cdn.sayqz.com/sayqz-site/posts/qishui-media-pipeline/qishui-media-pipeline-architecture-6770edfe793b.webp" alt="音频播放的五个阶段：检索、资源选择、容器解析、Sample 处理和播放输出" width="1600" height="900" loading="lazy" decoding="async" /&gt;
&lt;figcaption&gt;先确定每一层的输入和输出，排查问题时才有明确的范围。&lt;/figcaption&gt;
&lt;/figure&gt;</p>
<ol>
<li><strong>检索曲目</strong>：取得名称、作者、时长和资源标识等元数据。</li>
<li><strong>选择资源</strong>：确定音质、码率及编码格式，把结果整理为统一的媒体资源对象。</li>
<li><strong>解析容器</strong>：读取轨道、编码配置、Sample 表和媒体数据区位置。</li>
<li><strong>处理 Sample</strong>：按索引切分数据，并与对应的辅助信息对齐。</li>
<li><strong>输出播放</strong>：根据编码形式组装输出，将数据持续交给播放器。</li>
</ol>
<p>搜索模块不负责播放，容器解析器也不需要知道用户在界面上选了什么。每层只接收约定好的数据，问题就容易缩小到具体位置。</p>
<h2>先统一曲目数据</h2>
<p>远端响应往往服务于客户端界面：字段嵌套较深，不同请求的结构也可能有差别。如果播放模块直接读取这些原始字段，接口的小变化就会影响后面的处理。</p>
<p>我在入口增加了一层归一化，只向后传递固定的曲目模型。下面保留模型和调用关系，<code>read_*</code> 是省略的字段映射函数：</p>
<pre><code>from dataclasses import dataclass, field


@dataclass(slots=True)
class TrackCandidate:
    id: str
    title: str
    artists: list[str]
    duration_ms: int
    qualities: list[str] = field(default_factory=list)


def normalize_track(raw: dict) -&gt; TrackCandidate:
    """将远端响应收敛为播放链路唯一认识的曲目模型。"""
    return TrackCandidate(
        id=read_track_id(raw),
        title=read_title(raw),
        artists=read_artists(raw),
        duration_ms=read_duration(raw),
        qualities=read_available_qualities(raw),
    )
</code></pre>
<p>后续模块只依赖 <code>TrackCandidate</code>。远端字段变了，调整映射层即可，不需要把搜索响应的细节带进整个播放器。</p>
<p>另外，<strong>资源可访问与资源可播放需要分开检查</strong>。<code>200</code> 说明请求成功，<code>audio/mp4</code> 是服务端声明的媒体类型；容器是否完整、编码配置是否齐全、音频帧能否解码，仍然需要继续验证。</p>
<h2>通过 MP4 索引定位音频数据</h2>
<p>MP4 是容器格式，由不同类型的 Box 组成。音频数据与描述它的结构信息通常放在不同位置，不能把响应体直接当作一串连续的音频帧。</p>
<p>&lt;figure&gt;
&lt;img src="https://cdn.sayqz.com/sayqz-site/posts/qishui-media-pipeline/qishui-media-pipeline-container-c9e8a3024691.webp" alt="MP4 中 moov、trak、stbl 和 stsz 的嵌套关系，以及独立 mdat 数据区中的 Sample" width="1600" height="900" loading="lazy" decoding="async" /&gt;
&lt;figcaption&gt;简化结构，省略了 mdia、minf 等中间 Box。Sample 表给出切分依据，mdat 保存实际媒体字节。&lt;/figcaption&gt;
&lt;/figure&gt;</p>
<p>这次主要用到的结构包括：</p>
<ul>
<li><code>moov</code>：文件的媒体结构信息。</li>
<li><code>trak</code>：一条媒体轨道的描述。</li>
<li><code>stbl</code>：Sample 表相关结构的集合。</li>
<li><code>stsz</code>：各个 Sample 的大小。</li>
<li><code>mdat</code>：实际媒体数据。</li>
</ul>
<p>Sample 是这里的媒体处理单元，与网络回调中的 Chunk 没有一一对应关系，长度也不一定相同。程序需要依据索引确定位置和大小，再读取对应的数据段。</p>
<p>下面是简化的 Box 遍历代码，展示普通长度和 64 位扩展长度的处理：</p>
<pre><code>def iter_boxes(data: bytes):
    offset = 0

    while offset + 8 &lt;= len(data):
        size = int.from_bytes(data[offset : offset + 4], "big")
        kind = data[offset + 4 : offset + 8].decode("ascii", errors="replace")
        header_size = 8

        # size == 1 表示 Box 使用 64 位扩展长度。
        if size == 1:
            if offset + 16 &gt; len(data):
                break
            size = int.from_bytes(data[offset + 8 : offset + 16], "big")
            header_size = 16

        if size &lt; header_size or offset + size &gt; len(data):
            break

        yield kind, offset, size, data[offset + header_size : offset + size]
        offset += size
</code></pre>
<p>实际解析还需要进入子容器、区分轨道，并处理文件头尚未读完的情况。Box 的位置应由经过检查的长度逐段计算，不能搜索几个字符串后就假定找到了正确边界。</p>
<p>Sample 切分同样如此：一处长度错误会使后续读取错位。播放器最终表现为无声或解码失败，问题却可能出在更早的索引阶段。</p>
<h3>CENC 与 Sample 辅助信息</h3>
<p>CENC（Common Encryption）将受保护的媒体 Sample 与相应辅助信息关联起来。不同 Sample 可能有各自的初始化向量，也可能由保持原样的区域和需要处理的区域组成。</p>
<p>因此，处理前需要确认三组信息能够对齐：Sample 的数量、每个 Sample 的大小，以及它对应的辅助信息。处理过程中还要保持原有顺序。</p>
<p>本文不展开授权材料的获取或平台私有逻辑。对外层程序而言，重点是把边界检查和索引校验放在处理之前；信息不一致时应明确报错，而不是继续向播放器写入数据。</p>
<h2>用缓冲区衔接网络与媒体边界</h2>
<p>先下载完整文件再处理，适合早期验证，但播放前的等待会随文件大小增加。我随后把下载、处理和输出串成了流式管道。</p>
<p>&lt;figure&gt;
&lt;img src="https://cdn.sayqz.com/sayqz-site/posts/qishui-media-pipeline/qishui-media-pipeline-streaming-fecc7f282c38.webp" alt="网络 Chunk 先进入缓冲区，再按媒体索引组装为完整 Sample 并输出到播放器" width="1600" height="900" loading="lazy" decoding="async" /&gt;
&lt;figcaption&gt;缓冲区负责跨网络分块拼出完整 Sample；图中长度仅作示意，不代表实际字节数。&lt;/figcaption&gt;
&lt;/figure&gt;</p>
<p>程序先收集足够的文件头，建立索引并确定数据区位置，再持续读取媒体数据。网络库每次提供的 Chunk 大小并不固定：可能只有半个 Sample，也可能一次带来两个半 Sample。</p>
<p>这里不能直接把 Chunk 当作一个处理单元。我的做法是在中间保留缓冲区，新字节到达后先追加进去；凑齐当前 Sample 需要的长度，才取出、处理并写入播放器。剩余字节留给下一轮。</p>
<pre><code>pending = bytearray()
sample_index = 0

for chunk in response.iter_bytes():
    pending.extend(chunk)

    while sample_index &lt; len(sample_sizes):
        expected = sample_sizes[sample_index]
        if len(pending) &lt; expected:
            break

        sample = bytes(pending[:expected])
        del pending[:expected]

        frame = process_sample(
            sample,
            metadata=sample_metadata[sample_index],
        )
        player.stdin.write(frame)
        sample_index += 1
</code></pre>
<p><code>process_sample()</code> 代表已获得授权的媒体处理过程，具体实现省略。这里展示的是外层缓冲逻辑：输入按网络到达顺序积累，输出按 Sample 索引逐个推进。</p>
<p>处理完的帧可以立即写入播放器的标准输入，不必再等整个文件下载结束。启动延迟仍受文件头探测、索引建立及首批 Sample 的处理耗时影响，需要结合实际运行测量。</p>
<h3>检查 Range 请求的实际响应</h3>
<p>从媒体数据区发起 Range 请求，可以避免再次读取已经解析过的文件头，但服务端不一定按预期返回区间内容。</p>
<p>实现时需要区分部分响应与完整响应。如果服务器忽略 Range、返回了完整文件，程序就要跳过前置字节；否则文件头也会混进后面的 Sample 数据里。这类问题应当在网络层处理，不要等播放器报错后再猜原因。</p>
<h2>按编码格式组织输出</h2>
<p>MP4 不等同于某一种音频编码。容器解析完成后，还需要读取编码配置，决定输出数据怎样组装。</p>
<p>AAC 的输出需要结合相应的编码配置；FLAC 也有自己的元数据和帧结构。有的情况适合保留容器，有的情况适合直接输出音频流，不宜用同一个分支处理所有格式。</p>
<p>我让解析层提供统一的轨道与编码信息，输出层再决定组装方式。这样扩展格式时，搜索和下载部分可以继续沿用。</p>
<h2>把管道状态显示出来</h2>
<p>下载还在继续，并不表示播放也在推进。文件头不完整、索引缺失、缓冲区数据不足、播放器退出，都可能让链路停在不同位置。</p>
<p>为方便排查，我把容器探测、Sample 索引、Range 通道、缓冲区、处理状态和播放器状态放进了同一个视图。</p>
<p>&lt;figure&gt;
&lt;img src="https://cdn.sayqz.com/sayqz-site/posts/qishui-media-pipeline/qishui-media-pipeline-dashboard-4863dd047790.webp" alt="概念监控界面：容器、Sample 索引、Range 通道、缓冲区、处理模块和播放器的状态" width="1600" height="900" loading="lazy" decoding="async" /&gt;
&lt;figcaption&gt;概念示意，状态用于说明布局，不代表一次真实运行或性能测试。&lt;/figcaption&gt;
&lt;/figure&gt;</p>
<p>实际排查时，我会分别确认：</p>
<ul>
<li>文件头是否收集完整，必要的 Sample 表是否存在。</li>
<li>Sample 数量、大小和辅助信息是否匹配。</li>
<li>请求是否从正确的媒体数据位置开始读取。</li>
<li>缓冲区是在等待更多字节，还是已经有完整 Sample 没有被消费。</li>
<li>播放器是否仍在运行，标准输入管道是否可写。</li>
</ul>
<p>停止流程也要一起考虑。用户停止播放、网络断开或播放器退出后，其他线程、请求和子进程都应随之收尾，不能只让界面回到“未播放”。</p>
<p>这条链路后面还有可以继续检查的地方，例如不完整文件头的恢复、异常容器的诊断，以及缓冲策略对播放延迟的影响。现阶段先把每一层的边界和状态处理清楚，再做进一步优化。</p>
]]></content>
    <author><name>是青旨啊</name></author>
    <category term="工程实践"/>
  </entry>
</feed>
