跳转至

外部作业源(External Homework Sources)· 需求与实现方案

最后更新:2026-09-22 22:57

状态 设计稿(自用 fork,暂不 PR)
目标版本 本地 fork,基于 main @ 2026-09-18
作者 酒狐(代霖)
前置结论 插件系统无 UI 插槽(onethu.ui 只有 toast),故必须动本体

一、背景与目标

清华各课作业散落在多个平台:网络学堂(已支持)/ 荷塘雨课堂 / TUOJ / Tyche。 目标:让 OneTHU 的「全部作业」页与「今日」页同时显示这些平台的作业 DDL, 在一处查看全部作业,避免遗漏。

只读:不提交、不抓题目内容,只取「标题 + 课程 + 截止时间 + 来源」。

二、非目标(明确不做)

  • 不做提交/答题/刷课
  • 不做雨课堂手机验证码登录(凭据手动粘贴即可;验证码登录留待后续)
  • 不改插件系统(不动 plugins/*)
  • 不碰体育场馆等被明文禁止的自动化

三、需求分解

R1 数据源客户端(core 层)

在 packages/core/src/exthw/ 下实现三个只读客户端 + 统一类型。

R2 凭据与调度(desktop state 层)

凭据存 localStorage;定时/手动拉取;缓存与订阅;失败不影响网络学堂主流程。

R3 界面合并

「全部作业」页把外部作业并入「未提交」等分组,行上带来源徽标;「今日」页 DDL 卡同理。

R4 设置入口

设置页新增「外部作业源」小节,填凭据 + 「立即刷新」。

R5 验收

pnpm build 通过;smoke 脚本能真连三平台拉回数据。

四、已逆向的接口(实测 2026-09-18,均 200)

4.1 荷塘雨课堂 https://pro.yuketang.cn

用途 请求
课程列表 GET /v2/api/web/courses/list?identity=2
学习日志 GET /v2/api/web/logs/learn/{classroom_id}?page=0&offset=200&sort=0&actype=-1
用户 GET /api/v3/user/basic-info
  • 鉴权:Cookie: sessionid=…; csrftoken=…; uv_id=2598; university_id=2598; platform_id=3; xtbz=ykt; django_language=zh-cn
  • activities[].type:19=作业,20=试卷(14=课件,5=投票)
  • DDL = activity.content.score_d(毫秒时间戳)
  • 会话失效 → errcode=401000
  • ⚠️ host 必须是 pro.yuketang.cn(www. / changjiang. 会 401)

4.2 TUOJ https://ai.tuoj.thusaac.com

用途 请求
课程 GET /api/course/list
本课作业 GET /api/course/{cid}/rank → courseRank.contests[]
作业详情 GET /api/course/{cid}/contest/{tid}/context
题目 GET /api/course/{cid}/contest/{tid}/problem/list
  • 鉴权:Cookie: session=…; session.sig=…
  • ⚠️ metadata / schedule / status 嵌在 data.context 下(不是顶层)
  • DDL = context.schedule.effectiveEndAt || endAt(毫秒)
  • 会话失效 → HTTP 401/403

4.3 Tyche http://166.111.236.164:6080/tyche/

用途 请求
用户 GET user/UserFetchSetting
课程组 GET group/GroupList
组详情+作业 GET group/ShowGroup?gid=42 → group.tasks[]
作业详情 GET task/ShowTask?tid=1406
  • 鉴权两层:Authorization: Basic base64("cs:thuc++") 且 Cookie: JSESSIONID=…; username=…; uid=…
  • tasks[]:{tid, title, startTime, endTime, judgeEndTime, description, restricted}
  • DDL = judgeEndTime || endTime,形如 2026-09-27T16:00:00 → 无时区,必须按本地时间解析 (new Date("2026-09-27T16:00:00") 会被当 UTC,差 8 小时)
  • 会话失效 → {"status":"login"}

五、实现方案

5.1 目录与文件

packages/core/src/exthw/
  types.ts        # ExternalHomework / HomeworkSource / ExtHwCreds
  yuketang.ts     # createYuketangSource()
  tuoj.ts         # createTuojSource()
  tyche.ts        # createTycheSource()
  index.ts        # createExternalSources(creds, fetchLike)
apps/desktop/src/state/exthw.ts
  # 凭据读写(localStorage) + 拉取/缓存/订阅 + useExternalHomework()
apps/desktop/src/pages/learn/AssignmentsPage.tsx   # 合并渲染(改)
apps/desktop/src/pages/Today.tsx                   # DDL 卡合并(改,Phase 3)
apps/desktop/src/pages/Settings.tsx                # 「外部作业源」小节(改,Phase 2)
tools/exthw-smoke.mjs                              # 真连三平台的冒烟脚本

5.2 核心类型(packages/core/src/exthw/types.ts)

export type ExtHwSourceId = "yuketang" | "tuoj" | "tyche";

export interface ExternalHomework {
  /** 源内稳定唯一 id,用于 React key / 去重 */
  id: string;
  source: ExtHwSourceId;
  courseName: string;
  title: string;
  /** 统一为 "YYYY-MM-DD HH:MM"(本地时区) */
  deadline: string;
  kind: "homework" | "exam";
  /** 详情链接(可空;Tyche/TUOJ 可按 id 拼,雨课堂可留空) */
  url?: string;
}

export interface HomeworkSource {
  id: ExtHwSourceId;
  /** 展示名:雨课堂 / TUOJ / Tyche */
  name: string;
  /** 拉取;失败必须 throw,由调用方隔离 */
  fetch(): Promise<ExternalHomework[]>;
}

/** 凭据(由 desktop 层从 localStorage 读出后注入;core 不碰存储) */
export interface ExtHwCreds {
  yuketang?: { cookie: string; uvId?: string; base?: string };
  tuoj?: { cookie: string; base?: string };
  tyche?: { basic: string; cookie: string; base?: string };
  /** 只保留未来 N 天(默认 30) */
  days?: number;
}

export function createExternalSources(
  creds: ExtHwCreds,
  fetchLike: FetchLike,
): HomeworkSource[];

设计约束:core 不依赖 DOM / 不碰 localStorage(与 docs/ARCHITECTURE.md 一致), fetch 一律走注入的 FetchLike(desktop 侧传 universalFetch,即 Tauri 传输层, 无 CORS、可显式透传 Cookie 头)。

5.3 Homework 类型扩展(packages/core/src/learn/types.ts)

export interface Homework {
  ... // 现有字段不动
  /** 外部来源;网络学堂作业为 undefined */
  source?: ExtHwSourceId;
  /** 外部详情链接 */
  externalUrl?: string;
}

向后兼容:全部新增字段可选,不影响现有渲染与 learn.homework() 插件 API。

5.4 desktop state 层(apps/desktop/src/state/exthw.ts)

  • 存储键:onethu.exthw.v1 → ExtHwCreds(明文,与仓库现有本地存储一致;README 里提示风险)
  • 导出:
  • loadExtHwCreds() / saveExtHwCreds(c)
  • subscribeExtHw(fn) / getExtHwSnapshot()
  • refreshExtHw(): Promise<void>(三源并发 Promise.allSettled,单个失败只记错误)
  • useExternalHomework(): { items: ExternalHomework[]; errors: Partial<Record<ExtHwSourceId,string>>; state: "idle"|"loading"|"ready"; reload(): void }
  • toHomework(e: ExternalHomework): Homework —— 归一化: { id: "ext:"+e.id, courseId: "ext:"+e.source, title, content: "", publishTime: "", deadline, submitted: false, graded: false, source, externalUrl }
  • 触发时机:应用启动(有凭据时)+ 设置页「立即刷新」+ 30 分钟静默重拉(与 subscribeLearnData 同节奏,Phase 3 再接)

5.5 界面合并(Phase 3)

  • AssignmentsPage:const hw = [...(data?.homework ?? []), ...extItems.map(toHomework)]
  • 未提交分组天然包含外部作业(submitted:false)
  • 行上显示来源徽标:<span className="badge">{sourceName}</span>(沿用现有 badge 样式类)
  • Header meta 追加 · 外部 N
  • 点击:有 externalUrl 时用 openExternal(url) 打开官方页
  • Today.tsx:DDL 卡同样合并(外部作业按其 deadline 参与排序)
  • 外部作业不参与「已提交/已批改」分组统计的误导(无数据即不显示)

5.6 设置页(Phase 2)

新增 SectionHead title="外部作业源": - 雨课堂:Cookie(textarea)、uv_id(默认 2598)、服务器 - TUOJ:Cookie(textarea)、服务器 - Tyche:Basic(默认 cs:thuc++)、Cookie(textarea)、服务器 - 通用:天数(默认 30) - 按钮:「保存」「立即刷新」;显示各源最近一次结果(条数 / 错误)

六、任务拆分(派发顺序)

阶段 内容 验收
P1 core 三源客户端 + 类型 + smoke 脚本 pnpm --filter @onethu/core typecheck(或 build)通过;node tools/exthw-smoke.mjs 真连三平台打印条目
P2 desktop state 层 + 设置页 UI pnpm build 通过;设置页可保存/刷新
P3 AssignmentsPage / Today 合并渲染 + 徽标 + 跳转 pnpm build 通过;pnpm dev 目视确认
P4 自用打包(pnpm --filter @onethu/desktop tauri:build) 产出可执行文件

七、验收标准(总)

  1. pnpm build 全绿(含 TS 严格模式)。
  2. 未配置任何凭据时,行为与改动前完全一致(零回归)。
  3. 配置三源凭据后,「全部作业 → 未提交」能看到 3 个平台的作业,按截止时间排序。
  4. 任一平台凭据失效时,只显示该源错误提示,其余照常。
  5. 不新增运行时依赖(除已有)。

八、风险与对策

风险 对策
平台改版导致接口失效 每个 source 独立文件、独立报错;失败只降级该源
校内网不可达(Tyche 是内网 IP) 校外时该源报错即可,不影响其他;后续可接 WebVPN
凭据明文存 localStorage 与仓库现状一致;README 提示;不上传
时间解析错 8 小时 统一用「本地时间」解析(见 4.3)
无 LICENSE,不能对外分发 仅本地自用;如需 PR 先找作者

九、R3 增量:TUOJ 接入清华统一认证(2026-09-18)

9.1 目标

TUOJ 支持清华统一身份认证(CAS)登录,用户零凭据即可接入(不再输 TUOJ 账密)。

9.2 链路(实测)

  1. GET /api/user/oauth/info(无需登录)→ {"tsinghua":{"enable":true,"url":"https://id.tsinghua.edu.cn/do/off/ui/auth/login/form/929e496594c7a63203fb03e457a43c6b/0?/api/user/tsinghua/roaming/AI-TUOJ"}}
  2. 该 url = CAS 漫游表单(app id 929e496594c7a63203fb03e457a43c6b;漫游回调 https://ai.tuoj.thusaac.com/api/user/tsinghua/roaming/AI-TUOJ)。带已有的 id 会话 (HttpClient 的 CookieJar)GET → CAS 发票并 302 到回调 → TUOJ 会话 cookie 落罐 (无会话时返回 CAS 登录页 id="sm2publicKey" → 报「需先登录清华统一认证」)。
  3. 校验 GET /api/course/list 成功即登录完成。

与 learn/info 漫游同一套机制(auth/cas.ts + demoLogin.ts 的锚点范式)。 TUOJ 是公网域:已加入 PUBLIC_DIRECT_HOSTS,一律直连(绝不 WebVPN 包装)。

9.3 实现要点

  • packages/core/src/exthw/tuojCas.ts:tuojRoam(http)(+ TuojCasError / extractTicketAnchor / isCasLoginPage)。
  • createExternalSources({ creds, fetchLike, http }):TUOJ 走带 CookieJar 的 HttpClient (雨课堂 / Tyche 仍走裸 fetchLike);凭据新增 tuoj.via: "cas" | "password"。
  • 设置页 TUOJ 主按钮 =「用清华统一认证登录」;账号密码为折叠备选。

9.4 Tyche 登录补充(实测修正)

  • 登录页 Login.html 的 #vcode-field 默认隐藏,仅 GET user/GetToken?username=X 返回 vcode:true(考场锁定)时才需要 → vcode 非常规必填,Tyche 可免人工登录。
  • 正确登录流程(对齐页面内嵌 JS):GetToken 取 token → 口令变换 password = sha1(sha1(明文) + token) → POST user/Login(username/password/token)。 原实现直传明文口令(无 token)恒失败,已修正。
  • 校外须用 sslvpn(站点明示「不要用 webvpn」)。

9.5 雨课堂

无 CAS 入口(手机号/微信体系),维持短信验证码登录不变。

十、R4 增量:雨课堂 微信 / 雨豆APP 扫码登录(2026-09-18)

10.1 目标

设置页雨课堂区新增主按钮「微信扫码登录」:点开显示二维码,用微信或雨豆APP扫码, 会话自动落到插件(AES-GCM 加密存储),无需手填 Cookie。官网同款流程。

10.2 链路(实测)

  1. GET /api/v3/user/login/app-web-pre-info → {code:0,data:{qrContent, qrImage:"", token}}
  2. qrContent 即要编码进二维码的文本(官网 toDataURL(qrContent) 同源)
  3. token 是 JWT,约 5 分钟过期(exp−iat=300s)
  4. POST /api/v3/user/login/app-web-login,body {"token":"…"}
  5. 长轮询:未扫码时服务端一直挂起(不返回);扫码确认后返回 {code:0,data:…} 并 Set-Cookie
  6. 官网前端 30 秒节流重取二维码、过期自动刷新 —— 本实现同样自动重建

10.3 实现要点

  • packages/core/src/exthw/yuketangQr.ts:
  • yuketangQrStart(fetchLike) → {qrContent, token, expireAt}(expireAt 取自 JWT exp, 并以本地 5 分钟兜底封顶,兼容服务端时钟偏差)
  • yuketangQrPoll(fetchLike, token, {signal?, timeoutMs?}) → {done, cookie?, message?, aborted?, timedOut?} (单次长轮询超时/取消都返回结构化结果,不抛)
  • runYuketangQrLogin({fetchLike, signal, pollTimeoutMs?, now?, onPhase?}):状态机 「取码 → 有效期内长轮询(超时自动重发)→ 过期重建 → 成功/取消/报错」
  • 会话 Cookie 复用 login.ts 的 captureCookies(优先 x-onethu-set-cookie,回退 x-onethu-set-cookie-hops 取 l),成功后 yuketangBuildCookie 补齐清华固定字段。
  • 设置页 Settings.tsx:YktQrPanel(qrcode.react 的 QRCodeSVG,不新增依赖); 状态文案:等待扫码 / 已过期(自动刷新)/ 成功 / 失败;「刷新二维码」「取消」; 组件卸载/取消 AbortController.abort() 干净中止。短信登录 + 手填 Cookie 保留为折叠备选。
  • 单测 tools/ykt-qr-test.mjs(离线):未完成态 / 单次超时 / 超时重发 / 取消 / 过期重建 / 成功。

10.4 未在命令行端到端验证

「未扫码确认 → 拿不到会话」为主路径,命令行无法真机扫码,只能验证到 pre-info 返回 + 未扫码时长轮询挂起(短超时得 timedOut);真实登录需用户手机扫码。


酒狐 · 2026-09-18

十一、R5 增量:外部作业「提交状态」真实判定(2026-09-18)

11.1 问题

用户实测:三源作业明明已提交,界面全部显示「未提交」。根因: apps/desktop/src/state/exthw.ts 的 toHomework() 把 submitted 硬编码为 false。

11.2 类型

ExternalHomework 新增 必填 submitted: boolean(三源各自查真实状态;查询失败/ 不可判定时保守 false),以及可选 submittedCount? / totalCount?(进度展示,目前雨课堂有精确值)。

11.3 三源判定(实测)

雨课堂(每个 type 19/20 叶子各查一次;并发上限 4,单条失败只跳过)

GET https://pro.yuketang.cn/mooc-api/v1/lms/exercise/get_exercise_list/{leaf_type_id}/
    ?classroom_id={classroom_id}&term=latest&uv_id={uv_id}
请求头:XTBZ: ykt(**必须**,否则 XTBZ IS REQUIRED)
- leaf_type_id = 学习日志 activity.content.leaf_type_id;classroom_id = activity.classroom_id - 判定:data.answer_count > 0 或 任一 data.problems[].user.my_answer.content 非空 → 已提交 - 进度:submittedCount = 有作答内容的题目数,totalCount = 题目数 - ⚠️ 「试卷」(type 20)此接口返回 No permissions(error_code=20009)→ 无法判定,保守未提交(见 11.5)

TUOJ(每作业 1 次 ranklist;先 POST /api/user/lookup 拿自己)

POST /api/user/lookup                       (无 body)→ {user:{_id, username}}
GET  /api/course/{cid}/contest/{tid}/ranklist → ranklist.players[]
- 在 players[] 中按 _id === me._id 或 username === me.username 命中 → details 非空即已提交 - 回退:lookup 失败时用凭据里的 username 匹配 - ⚠️ GET /api/user/ojName 返回的是 OJ 昵称(实测 TUOJ-WEB-1),不是学号,不能用于匹配

Tyche(每作业 1 次;不用 ProblemStatus)

GET http://166.111.236.164:6080/tyche/task/Status?tid={tid}&gid={gid}   (**不带 all=true**)
- 不带 all=true 时只返回当前用户的提交 → submissionCount > 0 即已提交 - ⚠️ task/ProblemStatus 不是用户维度:实测其 statusList[] 的 sid/uid/submitedTime 恒为 null、result/score 恒为 2/100(与是否提交无关),不能用于判定。 - result 码含义(取自 js/tyche.js 的 resultString()): 0 等待评测 / 1 正在运行 / 2 评测通过 / 3 部分通过 / 4 格式错误 / 5 空间超限 / 6 时间超限 / 7 输出超限 / 8 没有输出 / 9 答案错误 / 10 运行错误 / 11 非法调用 / 12 比对失败 / 13 编译错误 / 其它 系统错误。

11.4 UI

toHomework() 改为透传真实 submitted;AssignmentsPage(未提交/已提交/已批改/全部)与 Today/HomeWidgets(未交作业计数与列表)已统一按 h.submitted 过滤,无需改动即自动生效。 未配置凭据时零回归;单源失败只降级该源(沿用 allSettled 隔离)。

11.5 已知限制

  • 雨课堂试卷类(含「新生数学选课指导测试」)无公开的提交状态接口(试过 leaf_info、 v/rain_exam/*、c27/…/exam/statistic/*、mooc-api/…/exam/* 等均无权限/404)→ 恒显示未提交。
  • 进度条(submittedCount/totalCount)仅雨课堂可用;TUOJ/Tyche 只有提交数,无题数。

11.6 验证

  • pnpm --filter @onethu/core typecheck、pnpm build 通过。
  • tools/exthw-status-test.mjs(离线 mock):29 通过 / 0 失败。
  • tools/exthw-smoke.mjs 真连:雨课堂「第一次作业」submitted=true 5/5;TUOJ homework 1 submitted=true; Tyche 作业一 submitted=true 6;新生数学测试(试卷)submitted=false(见 11.5)。

十二、R7 增量:TUOJ 统一认证修复 + 「已逾期」独立分类(2026-09-19)

12.1 BUG:TUOJ 统一认证登录报「未检测到有效的统一认证会话」(P0)

现象:dev3 版桌面端,OneTHU 本体已登录清华账号(learn/info 全部正常),设置页点 TUOJ「统一认证登录」→ 报错 TUOJ:需先登录清华统一认证(未检测到有效的统一认证会话,请先在 OneTHU 登录清华账号)。

根因分析(酒狐 2026-09-19 代码侦查,待实测确认): - R3 时代(dev2/main)登录链 = demoLogin 直连 id.tsinghua.edu.cn CAS → 共享 jar 的 id 桶里有真实直连 SSO 会话 → tuojRoam 第②步直连漫游表单成功。 - dev3 登录链统一到 thu-info-lib(apps/desktop/src/lib/infoLib.ts):lib 全部请求走 nativeFetch(Rust reqwest,原生分域 cookie 仓 + 原生跟随重定向),主干是 WebVPN 包装(「WebVPN 即网络」)。逐跳 Set-Cookie 虽回灌 JS jar,但 id 桶里是经包装域解码 的副本(cookie 名/域可能被 wengine 改写),并非 CAS 直连会话。 - tuojRoam(packages/core/src/exthw/tuojCas.ts)第②步 http.request(tsinghua.url) 不带 direct:true,物理上直连 id.tsinghua.edu.cn,但携带的 id 桶 cookie CAS 不认 → 返回登录表单页(id="sm2publicKey")→ 抛上述错误。

修复要求(诊断优先): 1. 在失败路径补诊断日志(复用 HttpClient debug 通道 / /tmp/onethu-debug.log): 打出 id 桶 cookie 名单、lastFinalUrl、CAS 页面特征,供真机验证根因。 2. 修复方案(按优先级): - A(首选):tuojRoam 前置「确保直连 id 会话」——复用现成模式:dorm/library 已有「按 lib roam('id') 账密直登 id」先例(CampusSession.xkCredentials / InfoClient #idCredentials,见 packages/core/src/info/client.ts;重启恢复无密码 时 InfoClient 自动回退 SSO 发票路径)。落地为:无直连会话 → 账密直登 id(凭据从 会话取,零用户输入)→ 再走漫游表单 → 票据 → TUOJ 回调。 - B(兜底):直登也不可用时,给出可操作文案(引导重新输入清华密码或回退账号 密码登录 TUOJ),不得静默失败。 3. 验收: - pnpm --filter @onethu/core typecheck、pnpm build、node tools/exthw-smoke.mjs 过; node tools/tuoj-cas-test.mjs CLI 回归过。 - 桌面端(霖实测):OneTHU 已登录状态下一键统一认证成功,全程零输入。 - 账号密码回退路径不回归。

12.2 「已逾期」独立分类(P1)

需求:逾期未交的作业不再混在「未完成」里,单独成类。

落点:apps/desktop/src/pages/learn/AssignmentsPage.tsx(分组页)。 - Filter 增加 "overdue";分组规则: - overdue = !submitted && deadline < now(时间解析用 shared.tsx 的 parseLearnTime,注意校验外部源的 ISO deadline 兼容性) - unfinished 语义改为「进行中」= !submitted && deadline >= now - Tab 顺序:进行中 / 已逾期 / 已提交 / 已批改 / 全部;默认选中「进行中」。 - Header 计数同步(如 进行中 X · 逾期 Y · 已交 Z · 已批 W · 外部 N)。 - 空状态文案:「没有已逾期未交的作业。」/「没有进行中的作业。」 - 边界(不得自行扩大):Today.tsx / HomeWidgets.tsx 的「未交作业」卡语义保持 「全部未提交(含逾期)」不动;shared.tsx 状态 chip 已有红色「已截止」样式,复用即可。 - 验收:typecheck + pnpm build + tools/exthw-status-test.mjs(离线 29 用例)不回归; 霖桌面实测出现「已逾期」tab 且归类正确。

12.3 开发约定(沿用)

  • ⚠️ 不得使用 git stash 做对照实验;对照应使用 git show HEAD:path 或 git worktree。
  • plugins/ 是禁改目录;已知既有报错 apps/desktop/src/plugins/rust.ts(118,53) TS2345 非本项目引入,typecheck 以 @onethu/core 为准。
  • 两个子项分别 commit(fix(exthw): ... / feat(learn): ...),便于回滚。

十三、R8 增量:雨课堂试卷(type 20)提交状态——已实现(2026-09-19)

13.1 接口(酒狐逆向 + 实测)

试卷封面接口(现代考试系统,c27 系):

GET https://pro.yuketang.cn/v/exam/cover
    ?exam_id={activity.content.leaf_type_id}
    &classroom_id={activity.classroom_id}
    &sku_id={activity.content.sku_id}
请求头:Cookie + XTBZ: ykt
- exam_id 就是学习日志里 type 20 活动的 content.leaf_type_id(与 37186/37189 实测对上)。 - 响应 data 关键字段:title / problem_count / start_time / deadline(ms) / show_score_time / result:
"result": { "status": 5, "unfinished_count": 0, "score": 60,
            "objective_grade": 60, "subjective_grade": 0,
            "score_finish": true, "incorrect_count": 13 }
- 实测样本(2026-09-19): - 数学选课指导测试:status=5, unfinished_count=0, score=60(霖已交,此前恒显未提交) - 分级考试:status=6, unfinished_count=31, problem_count=31(未交)

13.2 判定规则

  • submitted = result 非空 且 result.unfinished_count < problem_count (result 缺失/null → 未提交;HTTP 失败 → 该条跳过保持未提交,不拖垮整源)
  • 进度:(problem_count - unfinished_count) / problem_count
  • score:result.score(仅展示用;show_score_time 前可能未出分,不参与判定)

13.3 实现要求

  • packages/core/src/exthw/yuketang.ts:type 20 分支改走本接口(替换原 get_exercise_list 必失败路径);并发上限 4、单条失败跳过,与习题路径一致。
  • docs/ 本节由实现者补充「实现纪要」小节(改动文件、决策、验证输出)。
  • ⚠️ 旧 /quiz/* 家族是 2017 年遗留系统,ID 空间不对应(实测 quiz_id=37189 返回 无关旧卷「WORD POWER」),不得使用。

13.4 验收

  • pnpm --filter @onethu/core typecheck + pnpm build 过。
  • set -a; source ~/.onethu-creds.env; set +a; node tools/exthw-smoke.mjs 真连: 数学选课指导测试 submitted=true、分级考试 submitted=false,习题/其他源无回归。
  • 离线 tools/exthw-status-test.mjs 29 用例不回归(如涉及 mock 需同步补 type 20 用例)。

13.5 实现纪要(R8,2026-09-19)

改动文件 - packages/core/src/exthw/yuketang.ts - 新增 fetchYktExamStatus():GET /v/exam/cover?exam_id={leaf_type_id}&classroom_id=…&sku_id=…, 请求头带 XTBZ: ykt;判定 result 非空且 result.unfinished_count < problem_count; 进度 = problem_count - unfinished_count / problem_count(映射到既有 submittedCount / totalCount)。 - YktItem 增 isExam / skuId(取自 activity.content.sku_id);type 20 在既有 mapLimited(并发上限 4)里改走上述接口,type 19 维持 get_exercise_list 不变。 - result 缺失/null、字段不可解析或请求失败 → 该条 catch 后保守保持 submitted=false, 单条失败不影响同源其他条目。 - tools/exthw-status-test.mjs:雨课堂 mock 同步拆分「作业 / 试卷」两路,新增 type 20 用例 (已交 / 未交 / result 缺失 / 请求报错)及参数、请求头断言。 - 本文件新增 13.5 实现纪要。

关键决策 - 未使用 status 字段参与判定(13.2 只认 unfinished_count < problem_count),避免过拟合样本。 - sku_id 缺失时不跳过请求,仅省略该 query 参数;服务端若强校验则报错 → 保守未提交, 与直接跳过等价,但保留了缺参时仍能判定的可能。 - score 不参与判定;ExternalHomework 不新增 score 字段(不扩大范围), 进度信息复用既有 submittedCount / totalCount。

验证输出摘要(2026-09-19) - pnpm --filter @onethu/core typecheck:通过(tsc --noEmit 无输出)。 - pnpm build:通过(apps/desktop ✓ built in 5.09s → Done)。 - 真连 node tools/exthw-smoke.mjs(雨课堂 5 条): - [exam] 2026级本科新生数学选课指导测试 submitted=true 31/31 ✅ - [exam] Mathematics Course Placement Exam… submitted=false ?/31 ✅ - 作业无回归:第一次作业 true 5/5、第一周作业 false ?/10、微积分第一周作业 true 11/11; TUOJ true;Tyche 会话失效(已知情况,不计失败)。 - 离线 node tools/exthw-status-test.mjs:38 通过 / 0 失败 (原 29 项不回归,新增 9 项 type 20 断言)。

十四、R9 增量:旁听课堂标注 + 考试标签与分数展示(2026-09-19)

14.1 侦查结论(酒狐实测)

  • 旁听标记:GET /v2/api/web/courses/list?identity=2 → data.list[],每条含 role 与 classroom_id。实测霖的 11 个课堂:10 个 role=5(正式选课)+ 线代-4(3201641)role=6。 结合霖自述「旁听一节线代」+ 考试系统正规课堂 user_role=5,定案:role 5=正式、6=旁听。
  • 考试分数:R8 的 /v/exam/cover 已返回 total_score(卷面满分)与 result.score / result.score_finish。数学选课指导测试 score=60/total=100/score_finish=true。
  • ExternalHomework.kind("homework"|"exam")R1 已存在,本次只是透出到 UI。

14.2 实现要求

  1. types.ts:ExternalHomework 增加
  2. audited?: boolean(旁听课堂的作业)
  3. score?: number、totalScore?: number(考试得分/卷面满分;仅已提交且 score_finish 时给)
  4. yuketang.ts:
  5. courses/list 结果建 Map<classroom_id, role>,role===6 的课堂产出条目 audited: true (其他未知 role 不标,保守)。
  6. type 20(exam)路径:cover 返回且 submitted && result.score_finish !== false && typeof result.score === "number" && typeof data.total_score === "number" 时设 score / totalScore(未提交/未出分不设,避免 0 分误导)。
  7. UI(apps/desktop/src/pages/learn/shared.tsx 的 HomeworkRow):
  8. 标题行 src-badge 旁:kind==="exam" 显示小徽标「考试」;audited 显示小徽标「旁听」 (样式可仿 src-badge,新 class 放 global.css,如 .tag-exam .tag-audit,低调配色)。
  9. 状态 chip:已提交且有 score 时显示「已提交 · 60/100」(对齐已批改「已批改 · 分数」的既有语义)。
  10. Today/HomeWidgets 不动。
  11. 禁改 plugins/;禁 git stash。

14.3 验收

  • typecheck + pnpm build 过;node tools/exthw-status-test.mjs 无回归(mock 需补 role=6 课堂与 exam score 用例)。
  • set -a; source ~/.onethu-creds.env; set +a; node tools/exthw-smoke.mjs:线代-4 条目带 旁听标记(如工具支持打印),数学选课指导测试 submitted=true 且 score=60/total=100。
  • 实现纪要补进本节末尾。

14.4 实现纪要(R9,2026-09-19)

改动文件 - packages/core/src/exthw/types.ts:ExternalHomework 新增 audited? / score? / totalScore?。 - packages/core/src/learn/types.ts:Homework 新增同名透传字段(kind? 一并从 ExternalHomework.kind 透出到 UI);全部可选,网络学堂作业与插件 API 零影响。 - packages/core/src/exthw/yuketang.ts: - courses/list 先建 Map<classroom_id, role>,条目按 classroom_id 命中 role===6 时置 audited: true(role 5 及未知 role 不标,保守)。 - fetchYktExamStatus 读 data.total_score 与 result.score/score_finish;仅 submitted && score_finish !== false && typeof score === "number" && typeof total_score === "number" 时回传 score / totalScore,由 mapLimited 写入条目。抽出共用 YktStatusResult 接口(作业/试卷两条路径类型一致,避免 TS 条件分支联合类型取不到可选字段)。 - apps/desktop/src/state/exthw.ts:toHomework() 透传 kind / audited / score / totalScore。 - apps/desktop/src/pages/learn/shared.tsx:HomeworkRow 标题行 src-badge 旁加 「考试」tag-exam(kind==="exam")与「旁听」tag-audit(audited)小徽标;状态 chip 在 submitted && score !== undefined 时显示「已提交 · 60/100」(totalScore 缺失则只显示分数), 与既有已批改「已批改 · 分数」语义对齐。Today/HomeWidgets 未改动。 - apps/desktop/src/styles/global.css:新增 .tag-exam / .tag-audit(仿 .src-badge 规格,amber / accent 低调配色)。 - tools/exthw-status-test.mjs:雨课堂 mock 课程表补 role(1=正式 role5、2=旁听 role6), 新增旁听作业、未出分(score_finish:false)、缺 total_score、零分已出分等用例。 - tools/exthw-smoke.mjs:真连打印补 [考试] / [旁听] 标注与 score=x/y。 - 本文件新增 14.4 实现纪要。

关键决策 - 旁听仅认 role===6(14.1 定案),role 5 与未知值一律不标;audited 只在 true 时写入 (undefined 表示不标),避免 UI 出现「非旁听」冗余态。 - 分数采用「三重门槛」(已提交 + 未明确未出分 + score/total_score 均为数字)才写, 未提交/未出分/缺满分一律不设 → 不显示 0 分,也不出现「60/?」;score=0 且已出分时 照实显示 0/100。 - 未新增 ExternalHomework 必填字段,也未改 toHomework 既有语义;Homework 扩展全可选, 保证零回归与插件 learn.homework() API 兼容。 - 徽标只落在「全部作业」页共用的 HomeworkRow;Today/HomeWidgets 有自己的行组件, 按 14.2 要求不动。

验证输出摘要(2026-09-19) - pnpm --filter @onethu/core typecheck:通过(tsc --noEmit 无输出)。 - pnpm build:通过(apps/desktop ✓ built in 5.78s → Done)。 - 离线 node tools/exthw-status-test.mjs:50 通过 / 0 失败(原 38 项不回归, 新增 12 项 role=6 / exam score 断言)。 - 真连 set -a; source ~/.onethu-creds.env; set +a; node tools/exthw-smoke.mjs: - [homework] [旁听] submitted=false ?/10 | 2026秋-线性代数-4 | 第一周作业 ✅(线代-4 role=6) - [exam] [考试] submitted=true 31/31 score=60/100 | 2026级本科新生数学选课指导测试 ✅ - [exam] [考试] submitted=false ?/31 | Mathematics Course Placement Exam… ✅ - 作业无回归:第一次作业 true 5/5、微积分第一周作业 true 11/11;TUOJ true 5/?; Tyche 会话失效(已知情况,不计失败)。

十五、R10 增量:标签进「今日」、外部行操作按钮、计数简化、TUOJ CAS 二次修复(2026-09-19)

15.1 TUOJ 统一认证仍失败(P0)——根因分析与修复要求

霖实测报错「直连会话建立后仍未通过 CAS 校验」(= R7 兜底文案 ensureTried 分支)。 酒狐代码侦查结论(两处问题): 1. 误判 bug(高嫌疑主因):tuojRoam ② 步 ensure 成功(ensureOk=true,账密直登 POST check 拿到 302+ticket,id 直连桶已有 TGT)后重试 GET 表单 URL,CAS 在会话 活着时可能返回 checkSingle 指纹确认页(200,URL 仍是 /do/off/ui/auth/login/form/<uuid>)——isCasLoginPage 不认它,但 finalUrl 的 form 路径正则会命中 → 被当「仍是登录页」抛错。该次登录实际无效。 → 修复:②步进入分支前、ensure 重试后,都先测 /name="sm2publicKey"|name="i_pass"|checkSingle/ 三形态;checkSingle 页 → 走确认 POST(同 InfoClient.#idCheckSingle 逻辑:checkSingle action + fingerGenPrint(=session finger3) + redirect manual → 302 ticket → 跟锚点/Location 直连兑付)。为此在 InfoClient 暴露公共方法(如 confirmIdCheckSingle(formUrl): Promise<boolean>,内部复用 #idCheckSingle), tuojRoam 经 deps 注入调用(保持 exthw 不直接依赖 InfoClient 类型)。 2. 文案误导:ensureTried ? "直连会话建立后..." 分支不区分 ensureOk。改为: - ensureOk=false 且无内存凭据 →「OneTHU 内存中没有清华密码(重启恢复/未记住 密码),无法自动建立直连会话…」 - ensureOk=false 有凭据 →「自动登录清华统一认证未成功(详情见诊断日志)…」 - ensureOk=true 仍失败 →「直连会话已建立但 CAS 校验未通过(详情见诊断日志)…」 - detail(第二参数)带 casDiag 全量。 3. 诊断日志在 Windows 上静默丢失:lib.rs log_debug 等 3 处写 /tmp/onethu-debug.log,Windows 语义为「当前盘根 \tmp\」,父目录不存在时 File::create 失败被 let _ 吞掉 → 霖机器上没有任何日志。→ 修复:写前 create_dir_all(parent)(3 处统一走一个小 helper)。 4. 验证:tools/tuoj-cas-test.mjs 离线用例补 checkSingle 三形态(mock http: ①首 GET=密码页→ensure=ok→重试=确认页→确认 POST→302 ticket→兑付→课程列表 ok; ②确认 POST 无 finger3 失败→文案分支正确;③ensureOk=false 无凭据文案)。 真机由霖复测;若仍失败,读 D:\tmp\onethu-debug.log 的 [TUOJ-CAS] 行定位。

15.2 今日页补标签(R9 遗留)

「今日」页与 HomeWidgets「未交作业」卡的行组件补 kind==="exam" → 「考试」徽标、 audited → 「旁听」徽标(复用 R9 .tag-exam/.tag-audit 样式;今日页空间小, 只加徽标不加分数)。

15.3 作业页外部行补「提交提醒 / 收藏」按钮

  • HomeworkRow 的 remind && !external 与星标的 external 排除逻辑放开:
  • 提醒:HwRemindButton 按 h.id 键控,外部 id(ext:source:...)稳定可用; 确认提醒调度链路不依赖 learn 详情页数据(deadline/title 都有),仅去掉门禁。
  • 星标:外部作业用稳定 key(与现有 star key 同构: ext:{source}:{id}~{title}~{courseName} 或复用其现有拼接规则), 收藏后行为与网络学堂作业一致(列表高亮/排序如有)。
  • 行点击导航保持现状(外部=开官方详情页)。

15.4 作业页顶部计数简化

现状「2026秋 · 进行中 2 · 逾期 1 · 已交 4 · 已批 1 · 外部 6」信息过于繁杂。改为: - header(PageHead meta)只留学期文本; - 计数并入 SegmentedOverflow 各 tab 标签:「进行中 2」「已逾期 1」「已交 4」 「已批 1」「全部 8」(「外部 N」删除——已融入各组无信息量)。

15.5 验收

  • typecheck + build 过;exthw-status-test 无回归;tuoj-cas-test 含新用例全绿;
  • exthw-smoke 真连无回归;UI 部分需霖桌面复测(今日徽标/按钮/计数)。
  • 实现纪要补进本节末尾。

15.6 实现纪要·TUOJ CAS checkSingle 二次修复(fix,2026-09-19)

  • packages/core/src/exthw/tuojCas.ts
  • 新增 isCheckSinglePage / isCasInterstitial(sm2publicKey / i_pass / checkSingle 三形态,form 路径命中也算);tuojRoam 进入分支前与 ensure 重试后都先判三形态, checkSingle 页直接走确认,不再因 URL 停在 form 路径误抛「仍是登录页」。
  • 新增 tuojCasFailMessage:无凭据 / 有凭据直登失败 / 直连成功但校验失败三支文案; TuojRoamDeps 增 hasIdCredentials、confirmIdCheckSingle(函数类型注入, exthw 不 import InfoClient 类型)。
  • 诊断 emitCasDiag feats 增 checkSingle。
  • packages/core/src/info/client.ts
  • 暴露 confirmIdCheckSingle(formUrl)(直连优先、不可达退 WebVPN 包装,复用 #idCheckSingle + #consumeIdTicketUrl)与 hasIdCredentials()。
  • apps/desktop/src/state/exthw.ts:tuojCas 注入上述两方法。
  • apps/desktop/src-tauri/src/lib.rs:新增 open_debug_log() helper,log_debug / thos_log / venue_log 三处写点统一走它,写前 create_dir_all(parent)——修复 Windows 下 /tmp(= 当前盘根 \tmp\)父目录不存在导致日志被 let _ 静默吞掉。
  • tools/tuoj-cas-test.mjs:新增 ⑤ checkSingle 三形态 mock 用例(真实 InfoClient + mock HttpClient;sm-crypto 以空壳导出绕过 Node ESM 命名导出限制),另补 isCheckSinglePage 纯函数用例;Tyche 探针顺延为 ⑥。

15.7 实现纪要·标签/按钮/计数(feat,2026-09-19)

  • 今日页 / 未交作业卡徽标:apps/desktop/src/components/HomeWidgets.tsx 的 HomeworkRows 行标题补 kind==="exam"→「考试」、audited→「旁听」徽标 (复用 R9 .tag-exam/.tag-audit)。今日页即用该组件,故一处覆盖 15.2 两处要求。
  • 作业页外部行操作按钮:apps/desktop/src/pages/learn/shared.tsx 的 HomeworkRow 去掉 remind && !external 与星标 external ? null 两处门禁。提醒按 h.id (ext:source:...)键控;星标复用现有拼接 enc(courseId, id, title, courseName, sem) (外部即 ext:source~ext:id~title~课程名~学期,稳定唯一)。行点击导航保持 「外部=开官方详情页」。
  • 作业页顶部计数简化:apps/desktop/src/pages/learn/AssignmentsPage.tsx 的 PageHead meta 只留学期文本;计数留在 SegmentedOverflow 各 tab(已含 tab-count),标签缩写为 「已交 / 已批」;删除「外部 N」。

验证:pnpm --filter @onethu/core typecheck ✅;pnpm --filter @onethu/desktop typecheck ✅; pnpm build ✅;node tools/exthw-status-test.mjs 50/0 ✅;node tools/tuoj-cas-test.mjs 23/0(含新增 mock 用例)✅;node tools/exthw-smoke.mjs 2/3 源成功(Tyche fetch failed 为已知会话失效,不计失败)✅;rustfmt 解析 lib.rs 通过(本机缺 gdk-3.0,cargo check 无法在容器内完成,待霖本机/CI 复核)。

遗留风险:外部作业提醒仅落铃铛状态,systemCal/island 仍只读网络学堂 getLearnSnapshot(),暂不生成系统日历事件/胶囊文案;外部作业收藏为 kind:"assignment" 原子,从收藏夹打开会走网络学堂详情路由(列表内星标高亮正常,跳转待后续按需处理); UI 三处需霖桌面复测。

十六、R11 增量:今日页减负、TUOJ 自动登录、新手引导改造(2026-09-19)

16.1 今日页去掉来源徽标(移动端空间优化)

  • HomeWidgets.tsx 未交作业行删除 src-badge(雨课堂/TUOJ/Tyche 字样,约 197 行), 保留 R9/R10 的「考试」「旁听」徽标——来源只在「全部作业」页显示。
  • AssignmentsPage/HomeworkRow 不动(来源+标签全显)。

16.2 TUOJ 统一认证自动登录(酒狐侦查定案:仅 TUOJ 支持统一认证)

  • 侦查结论(2026-09-19):Tyche 登录页为账密+验证码表单(user/Login),无 CAS/SSO 入口(tyche.js 中 "cas" 全是 case 语句);雨课堂走自有扫码/短信。统一认证自动 登录只做 TUOJ。
  • 行为:extHw 刷新时若 TUOJ 源未配置 → 静默自动尝试一次 tuojCas 漫游:
  • 成功 → 自动写入配置(同手动登录效果),设置页显示「统一认证自动登录 ✅」;
  • 漫游成功但课程列表 401/403 → 状态「统一认证已通过,但 TUOJ 未返回课程(可能 未注册/未选课)」——条幅式提示,不算错误(部分 THUer 没有 TUOJ 账号);
  • 漫游失败(无内存凭据/CAS 未过)→ 静默,不弹错误,设置页引导手动;
  • 频控:失败后 24h 内不重复自动尝试(localStorage 时间戳);已配置则不走自动。
  • 与 R10 的 CAS 修复同链路,不需要新的 CAS 端点。

16.3 新手引导条幅改造(ExtHwGuide)

  • 显示条件保持「未配置任何源 && 未点忽略」;内容改为:
  • 主文案:「接入多平台作业聚合」(说明可聚合雨课堂/TUOJ/Tyche 作业);
  • 主按钮「登录雨课堂」→ 弹出登录通道 modal(UI 参考校园卡充值界面的卡片/ 布局风格,代码里找 recharge 相关组件复用样式),内含两通道: ①扫码登录(微信扫码,复用 runYuketangQrLogin/qrcode.react)②手机验证码 (复用 yuketangSendSmsCode / yuketangVerifyLogin);
  • 次按钮「去设置」→ 跳设置页 extHw 区;保留「知道了」持久忽略;
  • TUOJ 自动登录结果状态在设置页 extHw 区展示(成功/无账号/失败+手动入口)。

16.4 约定与验收

  • 每子项独立 commit(refactor(home) / feat(exthw) / feat(learn) 或合并酌情,报告里说明);
  • typecheck + pnpm build + exthw-status-test + tuoj-cas-test + exthw-smoke 全绿 (Tyche cookie 过期不算失败);UI 部分注明待霖桌面复测。
  • 实现纪要补进本节末尾。

16.5 实现纪要(2026-09-19)

  • 16.1 今日页去来源徽标:apps/desktop/src/components/HomeWidgets.tsx 的 HomeworkRows 行标题删除 src-badge 一行,保留 tag-exam / tag-audit;随之 删除不再使用的 extHwSourceName 导入。AssignmentsPage/shared.tsx HomeworkRow 未动(来源+标签全显),符合 16.1 范围约定。
  • 16.2 TUOJ 统一认证自动登录:
  • core:packages/core/src/exthw/tuojCas.ts 给 TuojCasError 增 stage (cas/courses)与 httpStatus;课程列表 401/403 记为 stage="courses", 并导出纯函数 isTuojNoCoursesError(e)(仅 401/403 判「无课程」,非 JSON 不误判)。 经 exthw/index.ts、core/index.ts 导出。
  • desktop:apps/desktop/src/state/exthw.ts 新增 tuojAuto 状态(idle/running/ ok/no-courses/failed,终态落盘 onethu.exthw.tuojAuto.v1);refreshExtHw() 在组装源之前调用 maybeAutoTuojCas()——TUOJ 未配置且不在 24h 频控内时静默漫游 一次。成功写入 tuoj:{cookie,via:"cas"}(同手动登录);no-courses 不写凭据 (TUOJ 保持未配置 → 引导横幅得以展示「无账号」条幅提示);其余失败只记状态 (含原因),任何分支都不抛出、不弹错。TuojAutoStatus 并入 ExtHwSnapshot。
  • 设置页:ExtHwSection 展示自动登录结果(成功「统一认证自动登录 ✅」/ 无账号提示 / 失败+手动入口);手动 CAS/账密登录成功即 clearTuojAutoStatus() 复位。
  • 16.3 新手引导条幅改造:新增 apps/desktop/src/components/ExtHwLoginModal.tsx: 把设置页 YktQrPanel 抽到该模块共用,并实现「登录雨课堂」通道 modal(微信扫码 / 手机验证码),遮罩与卡片布局沿用 pages/info/CardTab.tsx 充值弹窗同款样式。设置页 改为从该模块导入 YktQrPanel。AssignmentsPage 的 ExtHwGuide 主文案改为 「接入多平台作业聚合」,主按钮「登录雨课堂」开 modal,次按钮「去设置」经 requestExtHwScroll() 跳设置页并滚动到 #settings-exthw,保留「知道了」持久忽略; TUOJ no-courses 时横幅追加一行无账号提示。
  • 验证:pnpm --filter @onethu/core typecheck ✅;pnpm --filter @onethu/desktop typecheck ✅;pnpm build ✅;node tools/exthw-status-test.mjs 50/0 ✅; node tools/tuoj-cas-test.mjs 29/0(新增 ⑤-5 无课程分类 6 例)✅; node tools/exthw-smoke.mjs 2/3 源成功(Tyche status=login 为已知会话失效, 不计失败)✅。
  • 遗留风险:16.1/16.3 及设置页自动登录状态均为 UI,待霖桌面复测;自动登录依赖 内存清华凭据或 jar 内 id 会话,两者皆无时静默失败并进入 24h 频控(设置页可手动登录); no-courses 不写凭据,用户若后续注册 TUOJ 需等频控窗口过后自动重试或手动登录。

十七、R12 增量:TUOJ 失效自动重漫游 + 三源退出登录(2026-09-19)

17.1 TUOJ「已配置但会话失效」未自动重漫游(R11 16.2 遗漏项,霖实测 BUG)

  • 根因:maybeAutoTuojCas 首行 if (isTuojConfigured()) return —— 「之前手动登录过、 cookie 现已失效」的状态永不触发自动漫游,刷新即报「会话失效(HTTP 401)请去设置页」。
  • 修复:TUOJ 拉取失败且为会话失效(HTTP 401/403,与现有源错误判定同源)时:
  • 调自动漫游(新增 force 语义:绕过 isTuojConfigured 前置;频控保持—— failed/no-courses 24h 内不重复,kind=ok 不受限);
  • 漫游成功 → 覆盖保存 cookie → 自动重试一次该源拉取(用户无感知即可恢复);
  • 漫游失败 → 维持原 401 错误与设置页引导不变。
  • 防循环:单次刷新流程内至多自动漫游一次;重试拉取仍失败不再进入第二轮漫游。

17.2 外部作业源退出登录入口

  • ExtHwSection 三源(雨课堂/TUOJ/Tyche)各加「退出登录」按钮:
  • 仅该源已配置时显示;
  • 点击 → 确认 → 清除该源凭据(新增 per-source 清除,如 removeExtHwCreds(source);TUOJ 同时 clearTuojAutoStatus() 复位自动登录状态);
  • 状态文案随之复位(「未登录」),不影响其他源。

17.3 验收

  • typecheck + build + exthw-status-test + tuoj-cas-test + exthw-smoke 全绿;
  • 16.1 场景补离线 mock(已配置但 401 → force 漫游 → 成功重拉);
  • UI 待霖桌面复测。实现纪要补进本节末尾。

17.4 实现纪要(2026-09-19 落地)

  • 17.1 自动重漫游:
  • core:exthw/tuoj.ts 401/403 改抛类型化 TuojSessionError(isTuojSessionError 判定, 文案不变);exthw/index.ts 新增 refreshExternalHomework({ getCreds, fetchLike, http, rerouteTuoj }) —— 三源 allSettled 后,TUOJ 若因会话失效被拒则调 rerouteTuoj() 一次, 成功即以新凭据重建 TUOJ 源重拉一次;重拉仍失败不再进第二轮(防循环)。core 不碰存储, 凭据经 getCreds() 每次现读,重漫游动作由宿主注入;失败分支永不抛出。
  • desktop:maybeAutoTuojCas({ force }) —— force=true 绕过 isTuojConfigured 前置 (「已配置但 cookie 失效」),频控保持(failed/no-courses 24h,ok 不受限);refreshExtHw 改为调用 core 编排,rerouteTuoj: () => maybeAutoTuojCas({ force: true })。
  • 测试:tools/exthw-status-test.mjs 新增 3 组离线 mock(①401→force 漫游→重拉成功、 ②重拉仍 401 只漫游一次、③漫游失败维持原 401 错误),共 17 例。
  • 17.2 退出登录:
  • state:removeExtHwCreds(source) 只清该源(保留 days 与其他源);TUOJ 额外 markTuojLoggedOut()。⚠️ 仅 clearTuojAutoStatus() 不足:「未配置」会让下次刷新 自动漫游把刚退出的登录立刻补回,故新增持久抑制标记 onethu.exthw.tuojLogout.v1; 手动 CAS/账密登录成功仍走 clearTuojAutoStatus(),同时解除抑制。
  • UI:ExtHwSection 三源各加「退出登录」按钮(仅该源已配置时显示),confirmOk 确认后 清凭据 + 清空对应表单字段(防随后「保存」写回)+ 刷新;状态文案复位「未登录」。
  • 验证:pnpm --filter @onethu/core typecheck ✅;pnpm build ✅; node tools/exthw-status-test.mjs 67/0 ✅(原 50/0 + 17);node tools/tuoj-cas-test.mjs 29/0 ✅;node tools/exthw-smoke.mjs 2/3 源成功(Tyche status=login 为已知会话失效, 不计失败)✅。
  • 遗留风险:退出登录只清本机凭据并抑制自动漫游,HttpClient jar 内已建立的 TUOJ 会话 未主动清除(jar 为全局共享,无按域删除 API);UI 待霖桌面复测。

十八、R13 增量:雨课堂独立分类,OJ 平台归大类(2026-09-19)

18.1 背景与源分类元数据

  • 雨课堂是每个 THUer 必有的平台 → 独立为基础分类;TUOJ/Tyche 等评测平台不是人人 有账号,且清华内部 OJ 十分混乱、远不止这两个 → 统一归入「OJ 平台」大类,未来新增 OJ 只注册 source 并标 category,设置页自动归组。
  • core:源注册表(createExternalSources / ExtHwSourceId)加分类元数据 category: "courseware" | "oj"——yuketang=courseware,tuoj/tyche=oj。凭据存储、 拉取链路、作业展示均不动(纯信息架构调整)。

18.2 设置页 ExtHwSection 分组

  • 拆两个子区: ①「雨课堂」——副标题点明「清华课程平台,人人可用」; ②「OJ 平台」——组说明「按个人情况登录,不是每个 THUer 都有 OJ 账号(如部分平台 仅计算机系同学可用)」;TUOJ / Tyche 归入此组。
  • 各组内保留现有登录/退出/状态展示,交互不变。

18.3 引导条幅文案调整(R11 16.3 微调)

  • 主按钮仍是「登录雨课堂」(人人有、最通用);
  • 说明文字改为「雨课堂人人可用;OJ 平台(TUOJ、Tyche 等)按个人情况在设置页登录」 之类的两段式表述,不再把三类平台并列平铺。

18.4 验收

  • typecheck + build + exthw-status-test + tuoj-cas-test + exthw-smoke 全绿;
  • UI 待霖桌面复测。实现纪要补进本节末尾。

18.5 实现纪要(2026-09-19)

  • core 元数据:exthw/types.ts 新增 ExtHwCategory = "courseware" | "oj"、 SOURCE_CATEGORIES(yuketang=courseware,tuoj/tyche=oj)、SOURCE_CATEGORY_NAMES (雨课堂 / OJ 平台)与 RegisteredHomeworkSource(基础源 + category)。 createExternalSources 组装时经 withCategory() 按注册表附 category——三源工厂、 凭据存储格式、拉取链路与作业展示零改动;exthw/index.ts、core/index.ts 导出新符号。
  • 设置页分组:Settings.tsx 的 ExtHwSection 拆两个子区——「雨课堂」(副标题 「清华课程平台,人人可用」)与「OJ 平台」(组说明「按个人情况登录,不是每个 THUer 都有 OJ 账号(如部分平台仅计算机系同学可用)」);TUOJ / Tyche 归入 OJ 组, 组内登录 / 退出 / 状态展示与交互不变。
  • 引导条幅:AssignmentsPage.tsx 的 ExtHwGuide 文案改两段式——首行「雨课堂人人 可用;OJ 平台(TUOJ、Tyche 等)按个人情况在设置页登录。」,次行说明 DDL 合并去向; 主按钮仍为「登录雨课堂」,不再把三类平台并列平铺。
  • 验证:pnpm --filter @onethu/core typecheck ✅;pnpm --filter @onethu/desktop typecheck ✅;pnpm build ✅;node tools/exthw-status-test.mjs 67/0 ✅; node tools/tuoj-cas-test.mjs 29/0 ✅;node tools/exthw-smoke.mjs 2/3 源成功 (Tyche status=login 为已知会话失效,不计失败)✅。
  • 遗留风险:新增源仍需在设置页补对应登录块(category 元数据已就位,当前自动归组 覆盖分组标题与状态口径,尚未把登录表单完全数据驱动);UI 待霖桌面复测。旧用户凭据 加密存储格式未动,升级后配置原样生效。

十九、R14 增量:设置页 extHw 视觉收敛(雨课堂独立 + OJ 默认折叠)(2026-09-19)

19.1 雨课堂独立成块

  • 雨课堂从「外部作业源」聚合 Card 中独立:单独 Card(或与 OJ 明显分离的独立区块), 自己的 SectionHead/标题层级,与 R13 子区分组相比视觉层级更明确分离(不再在同一 Card 内堆叠)。

19.2 OJ 平台组默认折叠

  • 「OJ 平台」区块默认折叠收起,标题行形如「OJ 平台 ▸」:
  • 标题行常显各源接入状态(如 TUOJ ✅ / Tyche 未登录 之类的紧凑徽标或文案), 收起状态下亦可直接查看各源接入状态;
  • 点击展开才显示 TUOJ/Tyche 的登录表单与详情(再次点击可收起,支持双向切换);
  • 展开状态不必持久化(默认收起即可;可选:以 localStorage 记录展开状态,非强制要求)。
  • 已有的自动登录状态提示(统一认证成功/无课程/失败)随表单一同在展开区显示。

19.3 验收

  • typecheck + build + exthw-status-test + tuoj-cas-test + exthw-smoke 全绿;
  • UI 待霖桌面复测。实现纪要补进本节末尾。

19.4 实现纪要(R14)

  • 雨课堂独立:Settings.tsx 的 ExtHwSection 不再用单张聚合 Card;改为「外部作业源」 总述 + 雨课堂独立 Card(自带 SectionHead「雨课堂」,副标题取 EXT_HW_CATEGORY_DESCS.courseware)与 OJ 平台独立 Card,两卡之间有明显标题层级分隔。
  • OJ 组折叠:新增 ojOpen 状态(默认 false)。OJ Card 顶部为整行 <button> 标题行(带 aria-expanded),常显紧凑徽标 TUOJ ✅/未登录、Tyche ✅/未登录;点击 展开/收起 TUOJ 与 Tyche 登录表单。统一认证自动登录提示(成功 / 无课程 / 失败)仍在 展开区内展示。展开态不持久化(按需求下限)。
  • 共用控件:天数、保存、立即刷新、高级 Cookie、三源状态行从聚合 Card 移入独立 「抓取范围与刷新」Card 并始终可见,避免被 OJ 折叠隐藏。登录 / 凭据 / 自动漫游逻辑零改动。
  • 样式:global.css 新增 .exthw-oj-head / .exthw-oj-caret / .exthw-oj-badges / .exthw-badge(.is-on) / .exthw-oj-hint / .exthw-oj-body,沿用既有主题变量。
  • 验证:pnpm --filter @onethu/core typecheck ✅;pnpm --filter @onethu/desktop typecheck ✅;pnpm build ✅;node tools/exthw-status-test.mjs 67/0 ✅; node tools/tuoj-cas-test.mjs 29/0 ✅;node tools/exthw-smoke.mjs 2/3 源成功 (Tyche status=login 为已知会话失效,不计失败)✅。
  • 遗留风险:OJ 展开态未持久化(每次进设置页默认收起,符合需求);UI 待霖桌面复测; 新增源仍需在设置页补登录块(category 元数据已就位,登录表单尚未完全数据驱动)。

二十、R15 增量:新增两个 OJ + 设置页排版整治(2026-09-19)

20.1 侦查结论(酒狐 2026-09-19 实测)

A. 经典 TUOJ = https://oj.cs.tsinghua.edu.cn - 与现有 AI TUOJ(https://ai.tuoj.thusaac.com)同一套代码:首页 HTML 同构, /api/course/list、/api/user/oauth/info 等接口行为一致(未登录均 401 {"error":"Not Logged In"})。 - 唯一差异:/api/user/oauth/info 返回的 CAS 漫游回调不同—— 经典版 .../login/form/<appid>/0?/api/user/tsinghua/login; AI 版 .../login/form/<appid>/0?/api/user/tsinghua/roaming/AI-TUOJ。 → tuojCas.ts 本就从服务端响应取 url,故只需把 TUOJ_BASE 参数化即可复用全部漫游逻辑。 - 支持清华统一认证 → 同样接入自动漫游。

B. DSA-OJ = https://dsa.cs.tsinghua.edu.cn/oj/(数据结构课 OJ,邮箱登录) - 老式 Bootstrap/jQuery 站;接口(POST,form-urlencoded,会话靠 cookie): - 会话检查 user.php {action:"checklogin"} → {islogin:boolean} - 登录 user.php {action:"login", username, password} → {error:0} 成功 - 课程列表 course.php {action:"usercourses", type:1} → {error:0, courseList:[{courseId, name, ownerName, startDate, endDate, nextDeadline, permissionManager, studentCount}]} - 课程详情/作业 course.php {action:"courseinfo", course_id} → {error:0, courseList:[{myRole, assignmentList:[{id, name, endDate, status, isAfterDeadline, isResultPublished, ...}], ...}]} - 作业 DDL = assignment.endDate(站点标注 "(UTC+8)",预计 "YYYY-MM-DD HH:MM:SS", 实现需容错解析);作业名 = assignment.name - 无统一认证(邮箱+密码);边界:未选课 → courseList 空;未登录 → error≠0。

20.2 新增两个源

  • 源 id:tuojClassic(经典 TUOJ)、dsa(DSA OJ);均归 category: "oj"。
  • 展示名建议:tuoj→「TUOJ(AI 版)」、tuojClassic→「TUOJ(经典版)」、dsa→「DSA OJ」、 tyche→「Tyche」(如改动影响现有测试断言,一并更新)。
  • 实现:
  • tuoj.ts 的 createTuojSource 参数化 base / id / name(现硬编码 BASE);
  • tuojCas.ts 的漫游入口参数化 base(CAS url 仍取服务端响应),支持经典版一键统一认证;
  • 新增 dsa.ts:登录(邮箱+密码,存 cookie)+ 课程列表 + 逐课作业(DDL/标题/课程名), 只读;会话失效判定(checklogin 返回 false / error≠0)→ 类型化错误,供设置页引导重登;
  • types.ts 登记两个新源 + 名称 + category;index.ts 组装。
  • 自动登录:经典 TUOJ 复用现有「统一认证自动漫游」机制(R11/R12),需把硬编码的 tuoj 泛化为可按源执行;DSA OJ 无统一认证,仅手动账密。

20.3 设置页排版整治(霖:展开后仍显混乱)

  • 最少打扰原则:大幅精简灰色注释文本——删除重复/常识性说明;仅在需要用户动作 时出现提示(如自动登录失败、无课程、会话失效)。
  • 统一交互范式:OJ 组内每个源统一为一行紧凑布局: 源名 + 状态徽标(已登录/未登录)…… 主操作按钮(登录/退出) + 「更多」折叠; 备选登录方式(账密/短信等)一律收进该源的「更多」折叠,不再穿插大段说明文字。
  • 保留 R14 的 OJ 组默认折叠 + 标题行状态徽标(徽标需覆盖 4 个源)。
  • 雨课堂块维持 R14 的独立 Card,但同样按上述范式精简注释。

20.4 验收

  • typecheck + build + exthw-status-test(补 tuojClassic/dsa 的 mock 用例)+ tuoj-cas-test
  • exthw-smoke 全绿;DSA/经典 TUOJ 真机联调待霖提供账号后补 smoke 断言。
  • 实现纪要补进本节末尾。

20.5 实现纪要(R15)

  • 源登记(packages/core/src/exthw/types.ts):ExtHwSourceId 扩为 yuketang | tuoj | tuojClassic | tyche | dsa;新增 TuojSourceId/TuojCreds; SOURCE_NAMES 改为 tuoj→「TUOJ(AI 版)」、tuojClassic→「TUOJ(经典版)」、 dsa→「DSA OJ」;SOURCE_CATEGORIES 四源均归 oj。
  • TUOJ 客户端参数化:tuoj.ts 新增 CLASSIC_BASE = https://oj.cs.tsinghua.edu.cn 与 TuojSourceConfig { base, id, name };createTuojSource(cred, fetchLike, days, config?) 缺省仍为 AI 版。为保持 tools/*.mjs 可直引本文件,此处只保留 type-only 相对导入 (name 由组装层传 SOURCE_NAMES[id],缺省回退 "TUOJ")。tuojCas.ts 的 TuojRoamDeps 增加 base?,oauth/info、CAS 表单跳转、课程校验与 jar cookie 全部按 base 走;CAS url 仍取服务端响应,故经典版无需额外分支。login.ts 的 tuojLogin 增可选 base(经典版账密备选)。
  • 新源 dsa.ts:user.php(checklogin / login)、course.php(usercourses / courseinfo), 全 POST form-urlencoded + Cookie;DsaSessionError/isDsaSessionError 类型化会话失效; dsaCheckLogin;parseDsaDate 容错解析 endDate(10/13 位时间戳、ISO 带 Z/±HH:MM 按绝对 时刻、YYYY-MM-DD[ T]/YYYY/MM/DD 无时区按本地时间、Date.parse 兜底,失败返回 NaN)。 提交状态:20.1 未确认 assignment.status 语义 → 保守 submitted:false(待真机补判定); 逐课程隔离,坏 endDate 条目跳过。
  • 组装与重漫游泛化(index.ts):createExternalSources 组装 5 源;buildTuojSource 统一处理 AI/经典(优先带 CookieJar 的 HttpClient,强制直连);refreshExternalHomework 的 rerouteTuoj 改为 (source: TuojSourceId) => Promise<boolean>,对任一 TUOJ 系源 401/403 触发一次按源重漫游并重试,结果新增 reroutedSources(reroutedTuoj 语义保持兼容)。
  • desktop state:凭据读写/退出登录覆盖 5 源;自动漫游按源分键 (onethu.exthw.tuojClassicAuto.v1 / …tuojClassicLogout.v1,AI 版沿用旧键), tuojAuto 快照改为 Record<TuojSourceId, TuojAutoStatus>;refreshExtHw 依次对 AI/经典静默尝试一次自动漫游,rerouteTuoj(source) 按源 force。
  • 设置页排版整治(Settings.tsx + global.css):新增 OjSourceRow 统一行范式 (源名 + 状态徽标 …… 主操作 + 「更多」折叠);OJ 组标题行徽标覆盖 4 源;雨课堂独立 Card 同范式;删除大段常识性灰色说明与 R13 组说明常量,仅在会话失效 / 自动登录失败 / 无课程时显示 .exthw-note;短信、账号密码等备选收进「更多」;「抓取范围与刷新」Card 的 高级区补经典 TUOJ / DSA 的 Cookie 粘贴框,状态行改为紧凑的「需重新登录」标记。
  • 验证:pnpm --filter @onethu/core typecheck ✅;pnpm --filter @onethu/desktop typecheck ✅;pnpm build ✅;node tools/exthw-status-test.mjs 119/0 ✅(新增经典 TUOJ、 DSA endDate/会话/登录、经典重漫游用例);node tools/tuoj-cas-test.mjs 36/0 ✅(新增经典 oauth/info 真连探针与经典 base 漫游 mock);node tools/exthw-smoke.mjs 2/3 源成功 (Tyche status=login 为已知会话失效;经典 TUOJ / DSA 无凭据静默跳过、不报错)✅。
  • 遗留风险:DSA endDate 真实格式仍待真机确认(当前已覆盖常见形态,未知格式条目会被 跳过);DSA 提交状态保守为未提交,可能显示为「未交」,待确认 assignment.status 语义后 补判定;DSA / 经典 TUOJ 真机联调与 smoke 断言待霖提供账号;经典 TUOJ 启动时也会静默尝试 一次统一认证漫游(与 AI 版同机制,失败 24h 频控、不打扰);UI 待霖桌面复测。

二十一、R16 增量:雨课堂「已批改」判定 + 作业直链(2026-09-19)

21.1 雨课堂已批改判定(霖报 BUG1)

  • 现状:toHomework 中 graded: false 硬编码 → 外部作业永不进「已批改」。
  • 实测判别器(2026-09-19,线代第一次作业 vs 微积分第一周作业, 接口 GET /mooc-api/v1/lms/exercise/get_exercise_list/{leaf_type_id}/?...,头 XTBZ: ykt): | 状态 | data.problems[].user.status | user.my_score | user.comment | |---|---|---|---| | 已批改 | 4 | 真实分(≥0,如 30.00/0.00) | 有教师评语 | | 已提交未批 | 3 | -1.00(占位) | 空 | | 未提交 | undefined | undefined | undefined |
  • 修复:
  • ExternalHomework 加 graded?: boolean;
  • yuketang 作业(type 19)状态查询里判 graded,规则(保守,混合场景不误判): graded = submitted && 不存在「已作答但未批改」的题, 其中「已作答」= user.my_answer.content 非空或 answer_count>0; 「未批改」= user.status === 3 或 my_score === -1(含 "-1.00");
  • 试卷(type 20):graded = submitted && 已出分(复用 R9 的 score 条件);
  • toHomework:graded: e.graded ?? false(网络学堂路径不受影响)。
  • 目标:线代第一次作业进入「已批改」;微积分第一周作业留在「已提交」。

21.2 雨课堂作业直链(霖报 BUG2)

  • 现状:url = ${base}/v2/web/studentLog/{cid}?leaf_id=… → 落到课程日志页,需再点一次。
  • 实测正确深链(从雨课堂前端 pc.js 逆向,2026-09-19):
  • 作业(type 19):${base}/subject?type=5&classroom={cid}&id={leaf_id}&sku_id={sku_id}&exercise_id={leaf_type_id}
  • 试卷(type 20):${base}/subject?type=6&classroom={cid}&id={leaf_id}&sku_id={sku_id}&exam_id={leaf_type_id}
  • 实测均 HTTP 200(带 cookie);id 取活动 content.leaf_id,sku_id 取 content.sku_id。
  • 修复:yuketang 客户端按上述构造 url(缺 leaf_id/sku_id 时回退旧链接)。

21.3 验收

  • typecheck + build + exthw-status-test(补 graded 三态 mock:status4=已批改 / status3=已交未批 / 无 user=未交)+ tuoj-cas-test + exthw-smoke 全绿;真机待霖复测(线代进已批改 + 点击直进作业页)。
  • 实现纪要补进本节末尾。

21.4 实现纪要(2026-09-19,R16)

  • 改动文件:
  • packages/core/src/exthw/types.ts:ExternalHomework 新增 graded?: boolean。
  • packages/core/src/exthw/yuketang.ts:
    • YktStatusResult 加 graded?;新增纯函数 isUnscoredPlaceholder(数字/字符串 -1、 -1.00 均判占位);
    • fetchYktStatus:answeredThis = content 非空 || answer_count>0, answeredUngraded ||= answeredThis && (user.status===3 || my_score 为 -1 占位); graded = submitted && problems.length>0 && !answeredUngraded (无题目明细时保守 false);
    • fetchYktExamStatus:graded 恒为布尔,仅「已提交且已出分(复用 R9 score 条件)」置 true;
    • 直链:type 19 → /subject?type=5&…&exercise_id={leaf_type_id}, type 20 → /subject?type=6&…&exam_id={leaf_type_id};leaf_id/sku_id/leaf_type_id 任一缺失回退 …/studentLog/{cid}[?leaf_id=…](保留旧行为)。
  • apps/desktop/src/state/exthw.ts:toHomework 改 graded: e.graded ?? false (网络学堂路径不经过此函数,行为不变)。
  • tools/exthw-status-test.mjs:雨课堂 mock 新增 4 条作业(已批改 status4 / 已交未批 status3 含数字 -1 / 混合批改 / 缺 sku_id 回退)+ 试卷 graded 断言,共 134 通过 / 0 失败。
  • 关键决策:
  • 严格按 21.1 判别器实现「无未批改题即已批改」;缺 problems 明细时不判已批改(保守)。
  • 直链 classroom 取活动 content.classroom_id ?? 课程 classroom_id(即 docs 的 {cid}, 与状态查询同口径),id 取 content.leaf_id,sku_id 取 content.sku_id。
  • 未改 plugins/、未动网络学堂(learn)链路。
  • 真机实测(tools/exthw-smoke.mjs + 临时 graded 打印,2026-09-19):
  • 线代-1 第一次作业 submitted=true 5/5 graded=true(进「已批改」)✓;
  • 微积分A(1)-1 第一周作业 submitted=true 11/11 graded=false(留「已提交」)✓;
  • 试卷(数学选课指导测试)graded=true score=60/100,未交考试 graded=false;
  • 直链均为 /subject?type=5|6&… 形(实测 HTTP 200);Tyche 会话失效(已知)。
  • 遗留风险:graded 依赖接口返回 problems[].user 明细,若某次响应省略 status/my_score 而仅给 answer_count,会按「无未批改标记」判为已批改(当前真机未出现);UI 端「已批改」 分组与角标待霖桌面复测。

二十二、R16b 修正:雨课堂学生端直链(霖实测反馈,2026-09-19)

22.1 问题

  • R16 21.2 采用的 ${base}/subject?type=5&… 实为教师批改入口:霖实测点开线代作业落到批改界面; 无头浏览器(Windows Edge + CDP,带霖 cookie)实测 /subject?type=5&… → 302 到 /v2/web/forbidden「您没有权限查看该页面」。
  • 根因:21.2 的 /subject?... 是从 pc.js 里教师组件(含 canCorrect/handleCorrectSubject)逆出的,误当学生链。

22.2 正确学生端直链(无头浏览器实测,2026-09-19)

  • 雨课堂新版学生端在 /ai-workspace 应用,路由(aiworkspace.*.js): /lms-graph/:classroom_id 的子路由 exercise/:leaf_id(作业)、quiz/:leaf_id(试卷)。
  • 作业(type 19):${base}/ai-workspace/lms-graph/{classroom_id}/exercise/{leaf_id}
  • 试卷(type 20):${base}/ai-workspace/lms-graph/{classroom_id}/quiz/{leaf_id}
  • 实测(带 cookie,Edge headless):
  • 作业:线代第一次作业 / 微积分第一周作业(未批)/ 线代4 第一周作业(未提交)均正常打开学生作业页(题目导航 + 分值 + 考核截止时间)。
  • 试卷:数学选课指导测试(已交)→ 正常显示「试卷得分 60/100」;新生数学分级考试(未交)→ 正常打开。
  • 反例:同 leaf_id 用 exercise/ 打开试卷 → 报「页面不可用」;用 quiz/ 打开作业 → 同样不可用。两者必须分开。
  • ?is_chapter=1 可选(前端会自动补 node_id,实测 302 到 ?is_chapter=1&node_id=…);建议带上,侧栏显示「目录」更贴近霖点击后的形态。
  • sku_id / node_id / exercise_id 不需要。

22.3 修复

  • yuketang.ts:url 改为上述学生端深链;缺 leaf_id 时回退旧 studentLog 链接。
  • 同步更新测试里对 url 的断言;docs 补实现纪要。

22.4 备注(R16 21.1 已通过真机数据验证)

  • graded 判定无需改:真数据实测 线代第一次作业 graded=true(status=4)、微积分第一周作业 graded=false(status=3)、 线代4 第一周作业 false(未交)、数学选课指导测试 true(已出分 60/100)。

22.5 实现纪要(R16b,2026-09-19)

  • packages/core/src/exthw/yuketang.ts:url 构造由 /subject?type=5|6&… 改为 ${base}/ai-workspace/lms-graph/{classroom_id}/exercise|quiz/{leaf_id}?is_chapter=1; 条件由「leaf_id + sku_id + leaf_type_id 齐备」放宽为「仅需 leaf_id」;缺 leaf_id 回退 ${base}/v2/web/studentLog/{classroom_id}(不再附 ?leaf_id=)。头部注释同步更新, 标注 /subject 为教师批改入口(学生 302 /forbidden)已弃用。未改 graded 逻辑(21.1 已验证)。
  • tools/exthw-status-test.mjs:直链断言改为 ai-workspace 学生链;新增「缺 leaf 作业」用例 (有 sku_id/leaf_type_id、无 leaf_id → 回退 studentLog),并调整条数断言(14→15、hwCalls 7→8); 「缺 sku 作业」改为断言仍走 exercise 深链,佐证 sku_id 不再需要。
  • tools/exthw-smoke.mjs:无 url 断言,仅打印 url,无需改动;真机冒烟可见 url 已为 ai-workspace 形。
  • 验证:typecheck / build / exthw-status-test / tuoj-cas-test 全绿;真机 smoke 中雨课堂条目 url 均为 …/ai-workspace/lms-graph/…/exercise|quiz/…?is_chapter=1(Tyche 会话失效为已知问题,与本修正无关)。

二十三、R17:移动端(Android)三问题诊断与修复(2026-09-19,霖真机反馈)

23.0 真机排障环境(复现方式,供后续参考)

  • 设备:REDMI K90 Pro Max(model 25102RKBEC),app.onethu.desktop v0.9.0,校园网 WiFi(183.172.x)。
  • adb:WSL 看不到 USB(无 usbipd)→ 用 Windows 侧 platform-tools,命令形如 cd /d C:\temp\platform-tools && adb.exe -P 5137 <cmd>(WSL mirrored 网络下 5037 与 WSL adb 抢端口, 必须换端口;adb.exe 不能在 UNC cwd 下执行)。手机需开「USB 调试」,点按还需「USB 调试(安全设置)」。
  • 安卓日志出口:安卓无 /tmp(存在但 app 不可写)→ log_debug 落 logcat tag=onethu; Rust println! 落 logcat tag=RustStdoutStderr([NATIVE-HOP*]/[NATIVE-BODY] 都在)。真机排障 = adb logcat。
  • UI 元素定位:adb shell uiautomator dump 能导出 WebView DOM 文本与 bounds(可用于点按/断言界面文案)。

23.1 问题② 雨课堂扫码登录必失败(P0,代码 bug)

  • 现象:二维码正常显示,不扫码约 20s 后即报 网络错误: error sending request for url (https://pro.yuketang.cn/api/v3/user/login/app-web-login)(真机实测)。
  • 根因:extHwLogin.* 全走 universalFetch → tauriFetch → Rust http_request,该命令 timeout_ms 默认 20s;而 app-web-login 是长轮询(未扫码时服务端挂住不返回,实测 >40s)→ 20s 被 reqwest 超时。 reqwest Error 的 Display 只输出 error sending request for url (...)(原因在 source() 链里,被吞), 于是 core 把它当硬错误(非 timedOut)→ 状态机直接退出。取二维码的 GET 正常,故出现「二维码可正常显示、扫码却无效」的现象。 桌面端同一 bug(扫码耗时短于 20s 时才可能成功)。
  • 修复:
  • apps/desktop/src/lib/transport.ts:tauriFetch 支持 init.signal(abort → reject JS promise,Rust 请求自然结束) 与可选 init.timeoutMs(透传 http_request 的 timeout_ms)。
  • packages/core/src/exthw/yuketangQr.ts:yuketangQrPoll 调用时传 timeoutMs = pollTimeoutMs + 10000 (默认 28s → 38s),传输层不再提前超时;signal 生效后 28s 正常返回 timedOut → 按原状态机语义重发。
  • 验收:手机/桌面扫码登录在 20s 后继续轮询(不再报错),扫码确认后写入 cookie。

23.2 问题① 雨课堂短信验证码必失败(服务端已加图形验证码)

  • 现象:点「发送验证码」按钮短暂置灰,反馈文案出现在页面屏幕外(见 23.4),内容为 发送验证码失败:发送验证码失败:发送短信验证码失败。
  • 根因(逆向 fe-static-yuketang.yuketang.cn/fe/static/vue/2.2.680/login.js):官方登录页发短信前先取 TencentCaptcha(appid 2091064951)的 randstr/ticket,失败再走 hCaptcha(sitekey 50f6f060-…) 拿 hcaptcha_token,然后才 POST {mobile, login, hcaptcha_token, randstr, ticket}。三项均为空 → 服务端拒绝(status_code 300408)。与设备无关(PC 侧同样复现)。
  • 修复:停用短信通道——不再提供「手机验证码」入口(置灰 + 说明「雨课堂已启用图形验证码,短信登录暂不可用, 请用微信扫码」),文案指向扫码。不做 captcha 内嵌。

23.3 问题③ TUOJ 统一认证在手机上必失败(设备信任 / 2FA)

  • 现象:自动登录未成功(TUOJ:需先登录清华统一认证(自动登录清华统一认证未成功,详情见诊断日志)…)。
  • 诊断(logcat 实录):[TUOJ-CAS] ensure-direct-id threw: id 服务登录触发二次认证,请先在应用内重新登录一次(建立设备信任)后重试。 即 InfoClient.ensureDirectIdLogin(直连 id 账密登录)触发 2FA。
  • 关联证据:每次启动都是 FINGER3 空 → 设备指纹轮换(仅此一次;完成 2FA 请选信任设备)。 该标记 _fpRotated 只在 store.saveSession(localStorage)落盘,未进 persist() 快照与 appData 文件镜像 → 每次启动都重新轮换设备指纹 → id 端每次都当新设备 → 永远 2FA;用户即便完成 2FA + 信任设备, 下次启动指纹又变,信任立即失效(霖实测:2FA+信任后仍失败)。
  • 修复:
  • _fpRotated(及 SessionData 新增字段)纳入 persist() 快照与 SESSION_FILE 镜像,保证「只轮换一次」。
  • 登录成功(含 2FA 完成)后确认 finger3 被捕获并持久化;boot 日志补 FINGER3 空/新签发/沿用旧值 len=N。
  • tuojCasFailMessage 增加 2FA 分支:直接透出可操作文案 「需要二次认证:请在 OneTHU 里重新登录清华账号并勾选『信任此设备』后重试」,不再只说"详情见诊断日志"。
  • 用户重新登录成功(建立设备信任)后,自动重试一次 TUOJ 漫游(复用 R12 force 语义)。
  • 验收:手机上完成一次「2FA + 信任设备」后,重启 App 指纹不再轮换、静默重登成功、TUOJ 统一认证一次通过。

23.4 通用诊断/体验改进

  • http_request / http_native 的 map_err 拼接 error source 链(如 网络错误: error sending request for url (…): operation timed out),杜绝无法定位原因的网络错误。
  • 设置页操作反馈 msg 就近显示(当前渲染在页面底部,真机实测在屏幕外,用户误认为未发生任何反馈)。

23.5 验证清单

  • pnpm --filter @onethu/core typecheck + pnpm build + tools/exthw-status-test.mjs + tools/tuoj-cas-test.mjs 全绿; 新增「QR 传输超时 → 继续轮询」单测。
  • 真机:扫码登录成功;短信入口已停用且有说明;完成 2FA+信任后 TUOJ 通过。

二十四、R17b + R18:扫码登录抗后台冻结 + 官方网页登录(WebView)通道(2026-09-19)

24.0 真机定位(霖实测,证据链完整)

  • R17 上线后报错已能看清原因:网络错误: error sending request for url (…/app-web-login): client error (SendRequest): connection error: connection aborted。
  • 决定性实验:① 手机上扫码(扫码即切到微信 → OneTHU 退后台)→ 失败;② 用平板扫同一张码(OneTHU 保持前台)→ 成功。
  • logcat 佐证(Xiaomi/HyperOS):SmartPower … invisible->background / background->idle、WebView sandboxed 进程 service end → idle、 期间还有 ConnectivityServiceImpl: teardown wifi。即:App 退到后台后,MIUI 冻结或限制其进程与网络,长轮询连接随之中断 (connection aborted),状态机将其判为硬错误并退出 → 该次扫码失效。桌面端无此机制,故不复现。
  • 结论:问题不在接口、不在超时,而在移动端后台冻结下的连接生命周期。

24.1 R17b:扫码长轮询对「连接中断」必须具备可恢复性

  • packages/core/src/exthw/yuketangQr.ts:yuketangQrPoll 对传输层错误(connection aborted / operation timed out / network error 等,即非 HTTP 响应类错误)一律按 {done:false, timedOut:true} 处理 → 状态机用同一 token 继续轮询, 直到二维码过期(~5min)或被取消。不得因一次连接中断就退出(这是"退后台即失败"的直接原因)。
  • 注意:HTTP 层错误(4xx/5xx、code!=0 的业务失败)仍按原有语义返回,不可吞。
  • 诊断:把原始错误串保留在 message 里(UI 只在最终失败时展示)。
  • YktQrPanel / 设置页扫码面板:不在回前台时重建二维码(重建即更换 token,已扫描的二维码随之作废); 仅当面板仍开着时保持轮询即可(轮询恢复后同 token 应能取回已确认的登录)。
  • 验收:手机扫码(退后台再回来)→ 仍能完成登录;不扫码时长时间保持轮询不报错;取消/过期路径不变。

24.2 R18:雨课堂「官方网页登录」通道(WebView,含短信验证码)

  • 背景:雨课堂官方登录页发短信前必须先过 TencentCaptcha / hCaptcha(交互式,纯接口不可行,见 23.2)。
  • 方案:在雨课堂卡片里加「用官方网页登录(支持短信)」入口 → 打开一个应用内 WebView 窗口指向 https://pro.yuketang.cn/web,用户在其中完成扫码或"手机号+图形验证码+短信"登录;随后把该 WebView 中 pro.yuketang.cn 的 Cookie 取出,经 yuketangBuildCookie 补齐清华固定字段后保存为雨课堂凭据(复用现有 saveExtHwCreds 链路),关闭窗口并刷新外部作业。
  • 实现要点:
  • 复用仓库已有的原生窗口 + Cookie 通道(参考设置页「打开电子身份」的原生 WebView 与 Android android.webkit.CookieManager 相关代码);Android 侧读 CookieManager.getCookie("https://pro.yuketang.cn/"), 桌面侧走 wry/tauri 的 webview cookie 读取(读不到时回退:窗口内 document.cookie 可见部分 + 提示用 「高级:手动粘贴 Cookie」)。
  • 取 Cookie 动作由用户点「我已登录,读取会话」触发(避免与页面跳转竞态),并在取不到时给出明确回退提示。
  • 该通道与扫码/手动粘贴并列;短信入口保持停用(23.2)但在本通道内自然可用。
  • 验收:手机上用官方网页登录(扫码或短信)→ 读回 Cookie → 外部作业正常拉取;桌面端同样可用或明确回退。

24.3 验证清单

  • pnpm --filter @onethu/core typecheck + pnpm build + tools/exthw-status-test.mjs + tools/tuoj-cas-test.mjs + tools/ykt-qr-test.mjs 全绿; ykt-qr-test.mjs 补「connection aborted → 继续轮询(同 token)」用例。
  • 真机:① 扫码后切微信再回 OneTHU → 登录成功;② 官方网页通道用短信验证码登录成功并读回 Cookie。

24.4 实现记录(2026-09-19)

  • 24.1:yuketangQrPoll 的 catch 里,除「主动取消 / 本地计时超时」外的任何异常(connection aborted / connection reset / operation timed out / network error …)一律返回 {done:false, timedOut:true} 并保留原始 错误串于 message;HTTP 层错误(4xx/5xx、code!=0)不抛异常,语义不变。状态机用同一 token 重发。 YktQrPanel 的 effect 只依赖手动刷新 nonce,不监听回前台,故不会重建二维码。
  • 24.2:雨课堂卡片新增「官方网页登录」入口 → 打开应用内 WebView(桌面原生窗口 yktlogin / Android 插件 Dialog WebView)指向 pro.yuketang.cn/web;用户点「我已登录,读取会话」触发读取:
  • 桌面:WebviewWindow::cookies_for_url("https://pro.yuketang.cn/")(含 HttpOnly;Windows 用异步命令避免 WebView2 死锁)。
  • Android:android.webkit.CookieManager.getCookie(...)(Tauri 的 cookies_for_url 在 Android 恒空)。
  • 取回的 name=value; … 经 core 新增的 yuketangCookieFromHeader(内部调 yuketangBuildCookie 补清华固定字段) 解析为凭据串,复用 saveExtHwCreds 保存并刷新;成功后关窗。
  • 回退:未取到 sessionid / 平台不支持 / 浏览器预览 → UI 明确提示改用「高级:手动粘贴 Cookie」, 不阻塞;直接短信入口保持停用,但在官方网页通道内自然可用。

二十五、R18b:WebView 通道修复(霖真机/真机实测反馈,2026-09-19)

25.1 问题(霖实测)

  1. 移动端 WebView 仅显示一条细缝(AlertDialog + 裸 WebView,内容高度塌陷);且官方网页版未做移动适配。
  2. 扫码后无反应(R17b 之后不再报错,但登录不完成)。
  3. 桌面 exe 的 WebView 窗口纯白、缩放不重绘(黑底)、无法关闭,必须通过任务管理器强制结束进程 → 主线程阻塞。

25.2 诊断

  • ① OnethuMobilePlugin.kt openYktWebLogin:AlertDialog.setView(web) + 预先设 layoutParams 无效(AlertDialog 内容区自管高度)→ 塌陷。 另需 settings.useWideViewPort/loadWithOverviewMode 让桌面版页面缩放可用。
  • ② 扫码确认是投递给"当时正在等待的那条长连接"的:App 退后台被 MIUI 冻结 → 连接被掐 → 确认丢失 → 后续轮询永远等不到。 R17b 只是让它不再报错,并未解决"扫码时必须有人在轮询"。
  • ③ 桌面 open_ykt_window 自建 WebviewWindowBuilder + WebviewUrl::External 在 Windows 上阻塞主线程(整窗不重绘、事件循环僵住 → 无法关闭)。 仓库里已有验证可用的窗口方案(open_eid_window / open_sports_window:WebviewWindow + 注入脚本 + document.title 回传通道),应复用而非自建。

25.3 修复要求

  1. 移动端 WebView:改为全屏 Dialog(或 Dialog + Window 设 MATCH_PARENT),WebView 用 MATCH_PARENT 显式铺满; 开启 useWideViewPort=true、loadWithOverviewMode=true、JS、DOM storage、第三方 Cookie;底部放固定可见的「我已登录,读取会话」与「关闭」按钮(不用 AlertDialog 的按钮条); 关闭时才 destroy()。
  2. 桌面端窗口:改用仓库已验证的窗口方案(参考 open_eid_window/open_sports_window 的 WebviewWindow 创建方式与 title 回传), 确保窗口可关闭、可重绘、不阻塞主线程;cookie 读取仍走 cookies_for_url(异步命令,不在主线程执行)。 若仍无法稳定:桌面端隐藏「官方网页登录」入口(桌面二维码本来就可用),只保留手机端。
  3. 扫码可用性(至少):二维码面板加明确提示「扫码请用另一台设备(平板/电脑微信),并保持本页在前台」; 并研究「扫码期间用 Android 前台服务(带通知)保活进程,避免 MIUI 冻结」的可行性(见 25.4,需霖确认)。
  4. 短信入口维持停用;官方网页通道内的短信+图形验证码即为短信登录路径。

25.4 待霖确认

  • 是否接受前台服务保活方案(扫码期间常驻一条通知)以支持"本机扫码"?若接受,作为 R18c 实施。

25.5 R18b 实现记录(2026-09-19)

  • 25.3.1 移动端全屏 WebView:OnethuMobilePlugin.kt 的 openYktWebLogin 弃用 AlertDialog.setView(web)(内容区自管高度 → 塌陷为一条细缝),改为 Dialog(Theme_Black_NoTitleBar_Fullscreen) + Window.setLayout(MATCH_PARENT, MATCH_PARENT); 根布局竖向 LinearLayout,WebView weight=1 显式铺满,底部固定按钮条常显 「我已登录,读取会话」「关闭」(显式配色,不依赖主题)。开启 useWideViewPort / loadWithOverviewMode / setSupportZoom / builtInZoomControls(官方网页版未做移动适配), JS、DOM storage、第三方 Cookie 照旧。关闭(按钮 / 返回键 / 点外部)才 web.destroy(), 结果只回传一次:点「我已登录」先读 CookieManager 原文再关,直接关闭回传空串。 Rust 移动端 open_ykt_window 改用 run_mobile_plugin_async 等待,并把 cookie 透传给前端;前端 openYuketangWebLogin() 拿到会话即直接保存。
  • 25.3.2 桌面端窗口:open_ykt_window(desktop)改为复用 open_eid_window / open_sports_window 已验证方案——.initialization_script(...) 在远程登录页右下角注入 「我已登录,读取会话」按钮,点击写 document.title = "ONETHU_YKT_READY";后台线程轮询 w.title(),命中后在非主线程用 cookies_for_url 读 Cookie,取到 sessionid 则 emit ykt-cookie 并关窗,否则复位标题可重试(最长 10 分钟)。前端 YktWebLoginPanel 监听 ykt-cookie 自动保存;面板按钮 read_ykt_cookies 保留为回退。 兜底:本机无法验证 Windows exe 是否仍阻塞,按 25.3.2 要求桌面端隐藏「官方网页登录」 入口(YKT_WEB_LOGIN_AVAILABLE = isTauri && isAndroidHost),只保留 Android;桌面二维码通道 不变。窗口代码已按已验证方案重写,Windows 真机复测稳定后去掉 UI 隐藏即可恢复。
  • 25.3.3 扫码提示:YktQrPanel(设置页与登录弹窗共用)二维码下方加常显提示 「建议用另一台设备(平板 / 电脑微信)扫码,并保持本页在前台;本机扫码会切走 App, 可能被系统冻结导致登录失败」。
  • 25.3.4 短信入口:维持停用;官方网页通道内的短信 + 图形验证码路径不变。
  • 验证:pnpm --filter @onethu/core typecheck、pnpm build、tools/exthw-status-test.mjs (135 通过)、tools/tuoj-cas-test.mjs(38 通过)、tools/ykt-qr-test.mjs(全通过)全绿。 Android 真机 / Windows exe 行为待霖复测。

25.6 前台服务保活可行性调研(对应 25.4,R18c 提案,本轮未实施)

  • 目标:扫码期间让 OneTHU 进程不被 MIUI/HyperOS 冻结,使「本机扫码」也可行。 (R17b 已让长轮询在连接被掐后可恢复,但退后台后进程与网络被限制,确认仍可能丢失。)
  • 方案:Android 前台服务(Foreground Service)+ 常驻通知;扫码面板挂载即启动, 完成 / 取消 / 过期即停止。
  • 可行性(结论:可行、成本可控):
  • 路径成熟:startForegroundService() → startForeground(id, notification),进程获得 PROC_FOREGROUND 优先级,常规后台限制下不被冻结,长轮询可继续。
  • 需要新增(均在仓库 Android 侧,不碰 apps/desktop/src/plugins/): AndroidManifest.xml 加 FOREGROUND_SERVICE;API 34+ 再加 FOREGROUND_SERVICE_DATA_SYNC + android:foregroundServiceType="dataSync"; API 33+ 通知需 POST_NOTIFICATIONS 运行时权限;一个 Service 子类 + NotificationChannel; 插件命令 startQrKeepAlive / stopQrKeepAlive(由 YktQrPanel 生命周期调用)。
  • 风险 / 限制:Android 14+ dataSync 前台服务有每日累计时长上限(5 分钟足以覆盖扫码流程); MIUI/HyperOS/EMUI 仍可能要求「自启动」白名单,前台服务能显著改善但无法给出 100% 保证, 需真机实测;常驻通知对用户可见(5 分钟内可接受);上架合规层面需说明 dataSync 用途 (本项目侧载为主,影响小)。
  • 本轮处理:不实施;以 25.3.3 的「另一台设备扫码 + 保持前台」零成本引导缓解, 待霖确认是否接受「扫码期间常驻一条通知」后作为 R18c 实施。

二十六、R18c:扫码期间前台服务保活(Android,霖已确认执行)

26.1 目标

让「本机扫码」可行:扫码面板打开期间,用 Android 前台服务(常驻通知) 保住进程优先级, 使 OneTHU 切到微信扫码时不致被 MIUI/HyperOS 冻结,长轮询(R17b 已可恢复连接)得以持续到确认投递。 霖已确认接受「扫码期间常驻一条通知」的代价。

26.2 实施要求

  1. Manifest(apps/desktop/src-tauri/gen/android 的实际工程 → 模板侧):
  2. <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
  3. API 34+:<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
  4. API 33+:<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
  5. <service android:name=".QrKeepAliveService" android:exported="false" android:foregroundServiceType="dataSync" />
  6. ⚠️ gen/android 不入库(本机软链到 /home/lin/onethu-android);Manifest/模板改动同时改本地工程与 apps/desktop/src-tauri/plugins/onethu-mobile/android 侧可复用的部分,并在构建脚本中保持幂等。 若无法同时维护两处,则把 Manifest 变更写进构建脚本的幂等 sed(与既有 rootDirRel 处理同风格)并记入 TOOLS.md。
  7. Kotlin(OnethuMobilePlugin.kt):
  8. QrKeepAliveService:onStartCommand 里 startForeground(id, notification);通知渠道「扫码保活」, 标题「OneTHU 正在等待雨课堂扫码」,点击回到 App;stopSelf() 支持停止。
  9. 插件命令 startQrKeepAlive / stopQrKeepAlive(幂等,重复调用安全);API 33+ 先请求 POST_NOTIFICATIONS 运行时权限(拒绝时返回 { ok:false, reason:"notifications-denied" } 而不是抛错)。
  10. 服务启动失败 / 权限被拒 → 前端静默降级(不阻塞扫码,仍用现有「另一台设备」提示)。
  11. 前端:YktQrPanel(设置页与登录弹窗共用)在二维码就绪(phase:"qr")时 startQrKeepAlive, 在成功 / 取消 / 过期 / 卸载时 stopQrKeepAlive;仅 Android(isAndroidHost)调用。
  12. 保活生效时,提示文案改为「已开启扫码保活:本机扫码也可用(切到微信期间请勿清理通知)」; 未生效(权限被拒 / 非 Android)时保留原「另一台设备扫码」提示。
  13. 非 Android 平台零行为变化(不 invoke、不报错)。
  14. 不做:不改桌面端窗口策略(桌面入口仍隐藏,桌面用二维码);不改短信停用策略。

26.3 验证

  • pnpm --filter @onethu/core typecheck + pnpm build + tools/exthw-status-test.mjs + tools/tuoj-cas-test.mjs + tools/ykt-qr-test.mjs 全绿;ykt-qr-test.mjs 补「保活启停随面板生命周期」用例(可用 stub 断言调用序列)。
  • Android 构建必须通过(bash /home/lin/tools/build-android.sh 出 APK),以验证 Kotlin/Manifest 改动可编译打包。
  • 真机(霖复测):本机扫码 → 切微信扫码 → 回 App → 登录成功。

26.4 R18c 实现记录(2026-09-19)

  • 26.4.1 清单与前台服务(仓库内插件库,manifest merger 合并)
  • 权限与 Service 声明放在仓库内插件 Android 库清单 apps/desktop/src-tauri/plugins/onethu-mobile/android/src/main/AndroidManifest.xml: FOREGROUND_SERVICE、FOREGROUND_SERVICE_DATA_SYNC、POST_NOTIFICATIONS,以及 <service android:name="app.onethu.mobile.QrKeepAliveService" android:exported="false" android:foregroundServiceType="dataSync" />。由 manifest merger 合并进 app(gen/android 实际工程不入库), 无需改本地工程或构建脚本 sed;已核对合并后清单含上述条目。
  • 新增 QrKeepAliveService.kt(插件包 app.onethu.mobile):通知渠道「扫码保活」,常驻通知标题 「OneTHU 正在等待雨课堂扫码」,点击经 launch intent 回到 App;API 29+ 以 startForeground(id, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC) 启动,低版本回退两参; onStartCommand 幂等,onDestroy 清 running 标记。
  • consumer-rules.pro 增 keep 规则(release 开 R8,服务类 / 插件类不可被裁剪改名)。
  • 26.4.2 插件命令与权限:OnethuMobilePlugin.kt 新增 startQrKeepAlive / stopQrKeepAlive(幂等: 已运行时 start 短路返回 already-on,未生效时 stop 不 invoke);@TauriPlugin(permissions=[Permission(alias="notifications", strings=[POST_NOTIFICATIONS])]),API 33+ 先 requestPermissionForAliases,拒绝时 resolve { ok:false, reason:"notifications-denied" }(不抛错);服务启动异常同样 resolve { ok:false, reason:… },前端静默降级。
  • 26.4.3 主 crate 命令:lib.rs 新增 start_qr_keep_alive / stop_qr_keep_alive (mobile:run_mobile_plugin_async("startQrKeepAlive" / "stopQrKeepAlive");desktop:返回 { ok:false, reason:"not-android" }),并注册进 generate_handler。
  • 26.4.4 前端:新增纯逻辑模块 apps/desktop/src/lib/qrKeepAlive.ts(createQrKeepAlive + bindQrKeepAlive,注入 invoke / isAndroid 便于 stub 测试;幂等、非 Android 零行为、失败不抛)。 YktQrPanel 在 phase:"qr" 时 start、expired / 成功 / 取消 / 卸载时 stop;仅 isAndroidHost 生效; 保活生效时提示改为「已开启扫码保活:本机扫码也可用(切到微信期间请勿清理通知)」,未生效保留原 「另一台设备扫码」提示。非 Android 不 invoke、不报错。
  • 26.4.5 验证:pnpm --filter @onethu/core typecheck、pnpm build、tools/exthw-status-test.mjs(135 通过)、 tools/tuoj-cas-test.mjs(38 通过)、tools/ykt-qr-test.mjs(新增 §11 保活启停生命周期 13 断言)全绿。 Android:aarch64 release 编译 + 打包通过,产出已签名 APK(含 lib/arm64-v8a/libonethu_lib.so、 classes.dex 内 QrKeepAliveService、合并清单三项权限 + dataSync 服务),Kotlin / Manifest / Rust 均实编译通过。 真机(霖复测):本机扫码 → 切微信扫码 → 回 App → 登录成功。

二十七、R19:TUOJ 会话失效自动化 + Tyche「已批改」判定(2026-09-19 霖反馈)

27.1 问题①:两个 TUOJ 源会话过期后必须手动重新登录

  • 现象(霖):AI 版 / 经典版 TUOJ 都会「登录超时,需要重新登录」,必须手动点击一次才能恢复;需评估能否自动化。
  • 现状代码(apps/desktop/src/state/exthw.ts):
  • 自动漫游 maybeAutoTuojCas(source) 只在未配置时触发(isTuojConfigured 前置),或由 UI 的 rerouteTuoj 显式 force;
  • 失败/无账号后 24h 频控(TUOJ_AUTO_THROTTLE_MS);显式退出会抑制自动漫游(EXTHW_TUOJ_LOGOUT_KEYS)。
  • 于是「已配置但 cookie 失效」(TuojSessionError:HTTP 401/403)这条最常见路径没有任何自动恢复:刷新仅报错,直到用户手动登录或点击重试。
  • 要求:
  • 会话失效自动重漫游:各 TUOJ 源的 fetch 抛出 TuojSessionError 时,自动执行一次 force 重漫游(复用 maybeAutoTuojCas(source, { force:true }) 语义),成功后自动重拉该源一次;全程静默(失败不弹窗,仅在设置页留状态)。
  • 频控与抑制:尊重「显式退出」抑制;对 TuojSessionError 触发的重试,24h 频控放宽(例如同一源两次自动重试间隔 ≥10 分钟、每进程最多 N 次),避免因「退后台/网络抖动」耗尽频控额度后不再自动恢复。
  • 重试去重:同一源并发多个请求同时遇到 401 时只触发一次重漫游(共享 in-flight Promise)。
  • 两源独立:AI 版 / 经典版各自计数与状态。
  • 文案:自动恢复失败时,作业页/设置页的提示要明确「已尝试自动重新登录,仍失败:<原因>」,并保留手动「重新登录」入口。

27.2 问题②:Tyche 作业已批改却未标注「已批改」

  • 现象(霖):Tyche 一份作业四个任务每个都 100 通过,OneTHU 中未被识别为「已批改」。
  • 根因(初判):packages/core/src/exthw/tyche.ts 的 toHomework 只填 submitted(由 task/Status 的 submissionCount>0 得出),未包含 graded/score(ExternalHomework.graded 从未赋值)。
  • 待补的接口事实(阻塞项):Tyche 的得分/批改字段名与结构未知——task/Status 只用于提交判定(注释明确 task/ProblemStatus 的 result/score 恒定为非用户维度,不可用)。需要一次带有效会话的探测:
  • GET group/ShowGroup?gid=… 的 tasks[] 是否含 score/result/judgeResult/fullScore 等;
  • GET task/Status?tid=…&gid=… 的 submissionList[] 是否含每题/每任务的得分;
  • Tyche 前端({base}/#!html/Task.html&tid=…)展示「100 通过」用的是哪个字段。
  • ⚠️ 本机 smoke 用的 TYCHE_COOKIE 已失效(GroupList 返回 status:"login"),需霖刷新一份有效 Cookie 后才能探测并据此实现。
  • 判定规则(探测后定稿,预期形如):该 task 的每个子题/提交都有有效得分(≥0)→ 该作业 graded = true;得分与总分一并透出(复用 R9 的 score/totalScore 展示链路)。判定失败保守为 false。

实测(gid=42「2026年秋-程序设计基础」tid=1406「作业一(不计入总分)」): - GET task/Status?tid={tid}&gid={gid} → {status:"success", submissionCount:N, page:1, submissionList:[…]} - submissionList[] 每条:{ sid, pid, tid, gid, uid, name, language, codeLength, time, memory, score, result, outdated, secret, submitedTime } —— pid = 题目、score = 该次提交得分(0/100)、 result = 判题结果(实测 2=通过 / 9=错误),同一 pid 可有多条历史提交。 - group.tasks[] 字段:{tid,gid,title,description,startTime,endTime,judgeEndTime,language,mode,restricted,OOPStatus,uid} —— task 本身不带得分,得分只能从 submissionList 汇总。 - 霖那份作业:4 个不同 pid,最新提交 score 全为 100 → 应判「已批改」。 - 判定规则(定稿): 1. 按 pid 分组,取每组 submitedTime 最新的一条(并列取 sid 最大); 2. graded = submissionCount > 0 && 每个 pid 的最新提交都带数字 score(并排除判题中:result 若为 0/1 视为未判——探测未观测到该值,保守处理并注释); 3. score = Σ 各 pid 最新 score;totalScore = 100 × pid 数(Tyche 每题满分 100,实测佐证);仅 graded 时透出。

27.3 验证

  • 27.1:pnpm --filter @onethu/core typecheck + pnpm build + tools/exthw-status-test.mjs(补「401 → 自动重漫游一次 → 重拉成功」「in-flight 去重」「24h 频控放宽」用例)+ tools/tuoj-cas-test.mjs 全绿。
  • 27.2:在拿到有效 Tyche 会话后,先用一次性探测脚本固化字段(写进本节),再实现 + 补 tools/exthw-status-test.mjs 用例;真机/桌面 smoke 复验霖那份作业显示「已批改 · 100/100」。

27.4 R19 27.1 实现记录(2026-09-19)

  • core:重漫游动作套进程级频控 + in-flight 去重(packages/core/src/exthw/index.ts)
  • refreshExternalHomework 的 rerouteTuoj 调用前先查模块级状态(即进程级,跨多次 refresh 累计): 同一源两次自动重漫游间隔 ≥ TUOJ_SESSION_RETRY_MIN_INTERVAL_MS(10 分钟)、每进程每源最多 TUOJ_SESSION_RETRY_MAX_PER_PROCESS(3)次(含失败尝试,否则永久失败的漫游会每 10 分钟触发一次且无上限); AI 版 / 经典版按源 id 各自计数。新导出 resetTuojSessionRetryState()(离线测试清零用)。
  • 同源并发 401 去重:漫游 Promise 存 tuojSessionInflight[source],后来者直接 await 同一 Promise (不重复计数、不受频控拦截);Promise 结算时自清。
  • 文案:本轮发起(或共享)过重漫游而该源最终仍失败的,errors[source] 加前缀 「已尝试自动重新登录,仍失败:」(后接原 401 原因);被频控拦截 / 超限的保持原提示不加前缀。 设置页(源卡片 exthw-note is-error 行)与作业页条幅直接渲染该文案。
  • desktop:401 触发路径放宽 24h 频控、尊重显式退出(apps/desktop/src/state/exthw.ts)
  • maybeAutoTuojCas(source, { force, relaxThrottle }) 新增 relaxThrottle(仅 refreshExtHw 的 rerouteTuoj 钩子传入):跳过 tuojAutoThrottled 的 24h 频控(改用 core 的 10min/3 次进程级频控), 且尊重 EXTHW_TUOJ_LOGOUT_KEYS 显式退出抑制(用户主动退出的源绝不自动补登录,手动「统一认证登录」可恢复)。 「未配置即自动漫游」与 R17 retryTuojCasAfterLogin 路径行为不变。
  • 全程静默不弹窗:漫游失败仅落 tuojAuto[source].kind="failed"(设置页保留「自动登录未成功…可点统一认证登录重试」+ 手动入口), 重拉失败落 errors[source]。
  • desktop:作业页失败提示(apps/desktop/src/pages/learn/AssignmentsPage.tsx)
  • 新增 ExtHwTuojErrorNote 条幅(browser-hint ext-hw-hint,不弹窗):TUOJ 系任一源 errors 非空时显示 「TUOJ / TUOJ 经典版:<已尝试自动重新登录,仍失败:…>」+「去设置重新登录」按钮(requestExtHwScroll 滚到设置页 extHw 区, 手动「统一认证登录」入口保留);其余源错误仍只在设置页展示(与既有行为一致)。
  • 验证:pnpm --filter @onethu/core typecheck ✓、pnpm build ✓(desktop tsc --noEmit 亦 ✓)、 tools/exthw-status-test.mjs 150 通过 / 0 失败(新增 R19 27.1 五组断言:⑤并发 401 in-flight 去重只漫游一次且两轮均重拉成功、 ⑥发起过漫游仍失败的文案前缀 + 间隔 <10min 不再自动重试且无前缀、⑦放行时钟下每进程每源 3 次上限 + AI 版超限不影响经典版)、 tools/tuoj-cas-test.mjs 38 通过 / 0 失败、tools/ykt-qr-test.mjs 全部通过。桌面侧 relaxThrottle 分支经 typecheck/build 覆盖 (tools/*.mjs 仅直引 core,与既有测试边界一致)。

27.5 R19 27.2 实现记录(2026-09-19)

  • core(packages/core/src/exthw/tyche.ts):
  • fetchTycheStatus 按 27.2b 定稿规则汇总「已批改 / 得分」:submissionList[] 按 pid 分组,每组取 submitedTime 最新一条(并列取 sid 最大;比较兼容数字时间戳与同格式日期串两种形态); graded = submissionCount > 0 && 每个 pid 的最新提交都带数字 score,且 result 为 0/1(判题中, 探测未观测到该值)保守排除并注释;score = Σ 各 pid 最新 score、totalScore = 100 × pid 数 (Tyche 每题满分 100,实测佐证)。存在未判 / 字段缺失 / 状态接口报错 → 保守 graded=false。
  • score/totalScore 仅 graded 时写入 ExternalHomework(沿用 R9 约定:未出分不显示 0 分误导); graded 恒透出(含保守 false)。desktop toHomework(apps/desktop/src/state/exthw.ts)已有 graded/score/totalScore 透传链路,作业页「已批改」徽标与分数展示自动生效,无需 UI 改动。
  • tools:exthw-status-test.mjs 新增 R19 27.2 Tyche 五组断言(四题全 100 → graded=true score=400 totalScore=400;同 pid 多次提交取 submitedTime 最新、并列取 sid 大;最新提交缺 score 不回退旧分 → 保守 false;result=1 判题中保守排除;无提交 / 状态报错 → false 且不透出分数); exthw-smoke.mjs 条目输出补「已批改」标注(复验用)。
  • 验证:pnpm --filter @onethu/core typecheck ✓、pnpm build ✓、tools/exthw-status-test.mjs 169 通过 / 0 失败(150 → 169)、tools/tuoj-cas-test.mjs 38 通过 / 0 失败、tools/ykt-qr-test.mjs 全部通过。
  • 真数据复验(2026-09-19,霖提供有效 TYCHE_COOKIE):tools/exthw-smoke.mjs(仅 Tyche 源)—— gid=42「2026年秋-程序设计基础」tid=1406「作业一(不计入总分)」输出 [已批改] submitted=true score=400/400,与霖端「四个任务每个都 100 通过」一致(4 pid × 100 = 400/400)。

二十八、R20 方案:雨课堂移动端作业体验(霖提出,待批)

28.0 既有范式参照:网络学堂提交 UI(已实现)

  • apps/desktop/src/pages/learn/AssignmentDetailPage.tsx(作业详情页):
  • 「提交情况」卡:已提交/未提交、提交时间/补交时间、我的提交正文预览;
  • 四类附件(作业 / 答案 / 我的提交 / 批改)下载(parseHomeworkAtUrl 同款解析);
  • 「提交作业」卡只在详情页解析出提交表单时渲染(page.hasSubmitForm,由 getHomeworkPageDetail() 解析 viewCj + tijiao 双页得出:<form action> 指向 tjzy / 存在 <input name="fileupload">);
  • 提交区 = Quill 富文本正文 + 单附件选择(自定义文件名)+ 独立「撤回附件」(isDeleted=1); 提交恒走 isDeleted=0(正文 + 所选附件一次覆盖);过截止时间禁用提交。
  • packages/core/src/learn/client.ts:submitHomework() → POST /b/wlxt/kczy/zy/student/tjzy multipart(xszyid / zynr / fileupload / isDeleted)。
  • 范式结论:OneTHU 走的是「原生 UI + 直连站点接口」,且「是否可提交」由页面事实决定,不硬编码。

28.1 雨课堂侧已有事实(2026-09-19 实测,只读)

  • 作业详情:GET /mooc-api/v1/lms/exercise/get_exercise_list/{leaf_type_id}/?classroom_id=&term=latest&uv_id=(头 XTBZ: ykt)
  • exercise 级:name / description / max_retry / late_submission / is_allowed_late_submission / answer_count / score_type / show_answer* / mutual_evaluation_*;
  • problems[]:problem_id / index / content{ Type, TypeText, score, Body(题干 HTML), AllowResults:[text,pic,file], max_retry } / user(我的作答与得分)/ submission_status / content_score / review_detail / appeal_info。
  • ⚠️ 题干加密字体:部分文字包在 <span class="xuetangx-com-encrypted-font">…</span>,真字靠雨课堂自有字体渲染 —— 纯原生(非 WebView)渲染会花屏,必须把字体/CSS 一起处理,或题干走 WebView。
  • ⚠️ 提交接口尚未确认:pc.js 里 exercise 相关端点只见 learning_plan/problem_submit、 quiz/entire_quiz_submit(试卷)等;作业(exercise)的作答提交端点需专项侦查(bundle + 抓包)。

28.2 方案(分三阶段,可单独验收)

  • R20-A 移动端「桌面模式」内嵌浏览(临时方案,小)
  • 移动端点击外部作业(雨课堂)链接 → 不再交由系统浏览器,改在应用内 WebView 桌面模式打开: useWideViewPort + loadWithOverviewMode + 可缩放,另给「在系统浏览器打开」兜底;
  • 复用 R18b 已建好的 WebView 通道(Android 全屏 Dialog / 桌面窗口);只读、零数据链路改动。
  • R20-B 原生雨课堂作业页(只读,中)
  • core yuketang.ts 新增 getExerciseDetail(leafTypeId, classroomId, uvId) → 归一化 YkExerciseDetail { name, description, deadline, maxRetry, lateSubmission, problems[] } + 单测;
  • 新页 YktAssignmentDetailPage:信息条(作业名/截止/重交次数/我的得分与批改状态)+ 题目列表 (题号/题型/分值/我的作答与批改)+ 题干区;
  • 题干渲染采用混合:题干 HTML 交给内嵌小 WebView(与其站点同字体/CSS),或本地加载其字体文件 (需评估字体可获取性与时效);
  • 入口:作业列表 雨课堂 条目在移动端进原生页;桌面端保持外链(现状)。
  • R20-C 原生提交(写操作,大)
  • 前置:专项侦查提交接口(text/pic/file 三种作答、附件上传、重交次数、组作业/互评形态);
  • UI 借鉴网堂提交卡:正文(Quill)+ 附件 + 二次确认 + 过截止禁用 + 提交历史;
  • 红线:试卷(type 6)永不提交;仅"允许且未过截止"的作业开放提交;提交前必须显式确认。

28.3 建议顺序与估时

A(0.5 天,临时方案)→ B(1.5–2 天,只读无风险)→ C(2–3 天,含侦查,写操作风险高,建议单独灰度)。

28.4 R20-A 实现记录(2026-09-19)

  • Kotlin(plugins/onethu-mobile/android/.../OnethuMobilePlugin.kt):新增 openWebModal(url) (@InvokeArg OpenWebModalArgs):全屏 Dialog(R18b 25.3.1 同款布局:MATCH_PARENT + WebView weight=1 铺满 + 底部按钮条常显)加载任意 http(s) URL。桌面模式: userAgentString 复用 tauri.conf 主窗口的 Windows Chrome/79 串(tauri.conf 本身不动)
  • useWideViewPort + loadWithOverviewMode + setSupportZoom/builtInZoomControls;开 JS、 DOM storage、第三方 Cookie(仅作用于本 WebView)。底部固定「在系统浏览器打开」 (ACTION_VIEW + NEW_TASK,取 WebView 当前 URL,失败仅 Toast 不关窗)与「关闭」; scheme 白名单双侧校验(Kotlin 拒非 http(s));setOnDismissListener 里 resolve({}) 一次 并 web.destroy()(按钮/返回键/点外部同一出口)。只读:不注入脚本、不设 JavascriptInterface、不做 Cookie 回读。
  • Rust(apps/desktop/src-tauri/src/lib.rs):新增 open_web_modal 命令并注册:mobile 走 run_mobile_plugin_async("openWebModal", {url})(先做 scheme 校验;async 等待不阻塞工作线程, 同 open_ykt_window 写法);desktop 为防御性 stub(报错,前端降级回系统浏览器)。
  • 前端分流(纯函数 + 薄包装):lib/androidHost.ts 新增零依赖纯函数 isHttpUrl 与 pickExtHwOpenChannel(nav, url, isTauri)(reject / webview / browser);新建 lib/extHwBrowse.ts 的 openExternalHomework(url)——Android 宿主(isTauri + isAndroidNavigator 多信号,复用 R18c-bugfix 判定)→ invoke open_web_modal,失败(含桌面 stub 被误调)降级 openExternal 并留痕;桌面端 / 浏览器预览保持 openExternal 现状; 非 http(s) 一律拒绝(不开 WebView 也不交系统浏览器)。接入两处外部作业点击: pages/learn/shared.tsx HomeworkRow(全部作业/课程详情/搜索外列表)与 components/HomeWidgets.tsx HomeworkRows(今日页未提交作业卡);桌面行为零变化。
  • 与登录通道隔离:openWebModal 与 openYktWebLogin 各自独立创建/销毁 WebView, 无共享状态、无 Cookie 读写,互不影响。
  • 测试:tools/ykt-qr-test.mjs 新增 [13] 节 R20-A 断言(http(s) 白名单大小写; 拒 javascript:·intent:·data:·mailto:·空串;Android 真机 UA 被伪装 → webview; Windows 桌面 → browser;非 Tauri 即便信号像 Android → browser 不 invoke; 非 http(s) 双侧拒绝)。
  • 验证:pnpm --filter @onethu/core typecheck ✓、pnpm build ✓(desktop tsc --noEmit 亦 ✓)、 tools/exthw-status-test.mjs 169 通过 / 0 失败、tools/tuoj-cas-test.mjs 38 通过 / 0 失败、 tools/ykt-qr-test.mjs 全部通过(新增 [13] 节 13 项断言);Android arm64 release APK 构建通过(Kotlin + Rust mobile cfg 均实编译,见提交说明)。

28.4 雨课堂提交接口实测(2026-09-19,只读探测 + pc.js 逆向)

  • 题干加密字体有解:get_exercise_list 响应的 data.font 就是该次作业的字体文件 (实测 https://fe-static-yuketang.yuketang.cn/fe_font/product/exam_font_<hash>.ttf)—— 下载后以 @font-face 应用即可正确显示 <span class="xuetangx-com-encrypted-font"> 里的"乱码"。
  • 题目结构:problems[].content{ ProblemType, TypeText, Body, Options, AllowResults, score, data, Answer }; problems[].user{ my_answer{content, attachment}, remark, comment[]{content,index,name,avatar,attachment}, count } :我的作答、老师批改评语/批注均可获取。
  • 提交接口(官方 pc.js submitProblem 原文):POST /mooc-api/v1/lms/exercise/problem_apply/ body {classroom_id, problem_id, answer},逐题提交。题型 → payload 映射:
  • ProblemType 1 / 6 → answer: [s.toString()];2 / 3 → answer: s;
  • 4(多空填空)→ answers: {num: answer};5(主观题)→ answer: s(JSON,AllowResults text/pic/file);
  • 9 → 外链 OJ(answer_problem_url,不在本站提交)。
  • 组作业:problem_group_apply + exercise_id。
  • 附件/图片上传:候选 /c27/online_courseware/service/upload/、/pc/upload_info/exercise_attachment/ (字段与响应需再确认一次)。
  • 其他:max_retry(本题重交上限)、is_allowed_late_submission、late_submission。

28.5 R20-B/C 设计(仿网堂范式)

  • 页面(原生)YktAssignmentDetailPage:
  • 头部:作业名 / 截止 / 重交上限 / 我的得分与批改状态 / 说明;
  • 题目列表:题号 + 题型 + 分值 + 我的作答状态(未答 / 已交 / 已批 + 得分 + 老师评语);
  • 每题卡片:题干区用本地小 WebView(内联 HTML + data.font 字体 + KaTeX 渲染 $…$,加密字体因此正确显示)
    • 作答区(按题型)+ 本题「提交」(逐题)+ 剩余重交次数。
  • 作答区(R20-C):单选/多选/判断/填空 → 原生控件(选项来自 Options/data); 主观题 → Quill 富文本 + 图片/附件上传(走 upload 接口)→ 组装 answer。
  • 与网堂的差异:网堂是整份作业一次 tjzy 提交;雨课堂是逐题 problem_apply;雨课堂题干有字体反爬。
  • 红线:试卷(type 6)永不提交;题型 9 只跳外链;仅 is_allowed_late_submission/未过截止/未超 max_retry 时开放提交;提交前二次确认并展示将提交的内容。
  • 分步:R20-B(只读:详情 + 题干渲染 + 我的作答/评语)零写风险,先做;R20-C(提交)需先确认 upload 字段,再做灰度。

28.6 R20-A 已知缺陷(霖 22:35 反馈,待修)

  • 移动端内嵌桌面模式只显示页面左 1/3且无法拖动/缩放。
  • 修法:WebView 加 setInitialScale()(按桌面 1200px 宽适配屏宽)+ 双指缩放 (setSupportZoom/builtInZoomControls(true) + setDisplayZoomControls(false))+ 允许水平滚动; 并给「按屏宽缩放 / 实际大小」切换按钮。

28.7 R20-B/C 实施计划(自定义原生 UI 为主,WebView 降为备选)

范围:移动端(Android)优先——桌面端雨课堂网页版体验良好,桌面保持现状(外链);同一页面桌面也可打开,仅入口默认移动端。

阶段 - B1 数据层(core,0.5 天):getExerciseDetail(leafTypeId, classroomId, uvId) → 归一化 YkExerciseDetail{ name, description, deadline, maxRetry, lateAllowed, answerCount, fontUrl, problems[] }; YkProblem{ problemId, index, type, typeText, score, bodyHtml, options?, allowResults, maxRetry, myAnswer?{contentHtml,attachments[]}, myStatus: 未答/已交/已批, myScore?, remark?, comments[] }; 用真实响应快照(脱敏)做单测。 - B2 详情页 UI(1 天):YktAssignmentDetailPage:头部信息条(作业名/截止/重交上限/得分与批改)+ 题目列表 + 我的作答与老师评语;入口 = 移动端作业列表 雨课堂 条目 → 原生页。 - B3 题干渲染(1 天):<ProblemBody> = 本地小 WebView:内联 HTML + @font-face 挂 data.font(下载后缓存到应用数据目录) + 轻量 $…$ LaTeX 渲染;字体失效自动重取,渲染失败降级(去掉加密 span 兜底)。 - C1 客观题作答与提交(1.5 天):题型 1/2/3/4/6 原生控件(选项来自 Options/data)+ 逐题 problem_apply + 二次确认 + 剩余重交次数 + 提交后刷新真实状态;mock 测试断言 payload 形状与题型映射。 - C2 主观题(5)+ 附件(1.5 天):Quill 富文本 + 图片/附件上传(先确认 /c27/online_courseware/service/upload/ 字段) → 组装 answer;展示上次作答与老师评语。 - C3 打磨(0.5–1 天):重交/撤回语义、批改评语展示、桌面入口开关、错误文案。

红线(写死在实现里):试卷(exam/type 6)永不提交;题型 9 只跳外链;仅未过截止、未超 max_retry、 is_allowed_late_submission 允许时渲染提交;提交前二次确认并展示将提交内容;提交后立即刷新真实状态(不乐观更新)。

风险与对策:字体 URL 时效 → 本地缓存 + 失效重取 + 降级;接口变更 → 失败保留原文案 + log_debug 诊断,不静默; 风控 → 逐题提交(与官方一致)、不进行并发高频请求、失败退避;不可逆 → 二次确认 + 显示剩余重交次数。

验收:每阶段 typecheck/build/三个测试全绿 + 手机人工确认;B 验收 = 手机上完整看题(加密文字正常)+ 看到我的作答与评语; C 验收 = 用一道「不计入总分」的作业做灰度提交(霖确认后再放开)。

备选:某题型原生控件成本过高时,该题卡片退化为「内嵌 WebView 单题视图」(复用 B3 组件),其余仍原生。

28.8 R20-B2 实现纪要(2026-09-19)

B2 = 雨课堂作业原生只读详情页:移动端作业列表的雨课堂条目不再整页缩放看桌面版网页, 直达本页;桌面端保持 R20-A 外链现状(入口分流不变),但页面本身桌面也能正常打开。 只读红线落地:全页无任何提交/作答输入入口(提交属 C1/C2);试卷(kind exam / type 20) 进本页同样只读;题型 9(外链 OJ)只渲染外链跳转,无作答/评语区。

  • 数据补齐(core):ExternalHomework 增 leafTypeId?/classroomId?(仅 yuketang 的 fetch() 设置,来自 activity.content.leaf_type_id 与 classroom_id——详情端点必需参数,深链 URL 里没有, 必须随列表透出);Homework 镜像为 externalLeafTypeId?/externalClassroomId?(对齐既有 externalUrl/externalProgress 命名)。详情归一化增补:YkExerciseDetail.lateDeadline? (data.late_submission 毫秒 → 本地 "YYYY-MM-DD HH:MM",缺失/0 不设)、YkProblem.myAnswerAttachments? (my_answer.attachment 非对象/空白名过滤,空数组不设)与 externalUrl?(题型 9 的 content.data.answer_problem_url,仅 http(s))。createYuketangSource 与详情类型透出到包入口 (此前只在 exthw/yuketang.ts,state 层需自行注入凭据/fetch 时应能从 @onethu/core 导入)。
  • state 层薄包装(state/exthw.ts):toHomework 补两字段映射;新增 fetchYktExerciseDetail(leafTypeId, classroomId) = 读 getExtHwCreds().yuketang(未登录 → 抛 明确中文错误)+ universalFetch + creds.days ?? 30 建 createYuketangSource 后调 getExerciseDetail(uvId 回落链:参数 → 凭据 → "2598")。失败原样上抛,页面不得静默忽略。
  • 纯判定/展示(lib/yktDetail.ts,零依赖可 Node 直测,约定同 androidHost.ts): pickYktDetailEntry(androidHost, row)(Android 宿主 + yuketang + 双参数齐备 → native,其余 external)、yktStatusChip(已批改带分/已交未批/未作答)、yktExerciseSummary(total/answered/ graded/scoreSum/整卷徽标;无一题透分时 scoreSum 不设,避免把未出分显示成 0)、 yktTypeText(typeText 缺失按 ProblemType 兜底)、yktIsExternalLinkProblem(type===9)、 yktAttachmentsText。宿主信号采集(isTauri + isAndroidNavigator 多信号,tauri.conf 伪装 Windows UA 也不误判)收在 lib/extHwBrowse.ts isAndroidHostEnv(),纯判定与信号采集分离。
  • 入口接线:shared.tsx HomeworkRow(全部作业/课程详情/搜索)与 HomeWidgets.tsx HomeworkRows (今日页)点击外部作业时先过 pickYktDetailEntry:native → navigate("learn-ykt-detail", {ykt, from}),否则维持 R20-A openExternalHomework(桌面行为零变化)。state/app.tsx 增 Page "learn-ykt-detail" 与 LearnNav.ykt?: YktNav{ leafTypeId, classroomId, externalUrl?, title?, deadline?, courseName?, kind? }——列表行已知信息仅作详情未回来时的头部兜底与「在网页中打开」 备用入口(详情响应无整卷截止字段,截止恒用列表值;详情 name 到达后覆盖 title)。
  • 页面(pages/learn/YktAssignmentDetailPage.tsx + global.css 追加 .ykt-*):移动端优先 单列布局。PageHead(作业名 + 课程·截止 + 返回 + 「在网页中打开」= R20-A 通道备用入口); loading = SkeletonRows;错误 = ErrorNote 保留 core 原始错误文案 + log_debug 留痕 + 重试 + 网页出口提示。头部信息卡:截止倒计时徽标(timeLeft)、整卷批改徽标、考试 tag(exam); 作业信息键值:截止时间 / 补交截止(lateDeadline)/ 重交上限(0 → 「不可重交」,归一化保守口径)/ 是否允许迟交 / 作答进度 X/Y / 我的得分(已批合计,仅在有已批透分时显示)。题目卡:第 N 题 + 题型 + 分值 + 单题批改徽标;题干/我的作答 = 基础 innerHTML 渲染(TODO(B3):xuetangx 加密字体 @font-face + $…$ LaTeX,本版加密 span 显示占位字形属已知形态;雨课堂 CDN 图片交 WebView 原生加载,不走 learn 图片管道);老师评语按纯文本 pre-wrap 渲染(实测为纯文本,不按 HTML 注入); 题型 9 只给「打开外链题目 ↗」(openExternal 系统浏览器——OJ 会话在浏览器不在应用 WebView)。
  • 测试:新增 tools/ykt-detail-ui-test.mjs 55 断言(入口分流 7、展示口径 19、整卷汇总 14、 core 新字段 15:列表 leafTypeId/classroomId、lateDeadline 及缺失/0 回归、附件过滤、题型 9 外链 与非 http(s) 拒收)。toHomework 两行映射与 fetchYktExerciseDetail 依赖 tauri/localStorage, Node 无法加载,由 typecheck + 真机烟测兜底。
  • 验证:pnpm typecheck(core / desktop / info-lib / ui 全绿)、tools/exthw-status-test.mjs 169 通过 / 0 失败、tools/ykt-exercise-detail-test.mjs 79 通过 / 0 失败、 tools/ykt-detail-ui-test.mjs 55 通过 / 0 失败。真机(雨狐侧 Android 构建/安装)验收项: 雨课堂条目直达本页、倒计时与批改徽标、题目/作答/评语完整可读、加密字体占位已知、 「在网页中打开」回 R20-A WebView、桌面点击雨课堂条目仍走系统浏览器。

28.9 R20-B2b 实现纪要(2026-09-20,霖真机反馈修正)

真机反馈:Android 上点雨课堂作业仍打开官方页 WebView(R20-A 备用通道),B2 原生详情页 没触发。审计结论两条:① B2 入口分流存在漏接的点击点:只接了 shared.tsx HomeworkRow 与 HomeWidgets.tsx HomeworkRows,SearchPage.tsx SearchHomeworkRow 直接 navigate 站内详情 (外部源没有站内详情记录,属漏接);② 入口判定的「Android 宿主」门槛本无必要:原生页 桌面同样可用,行数据缺 externalLeafTypeId/externalClassroomId 时回退 external 属正确 兜底,但宿主限制让 PC 永远进不了原生页。

  • 纯判定改三态(lib/yktDetail.ts):新增 pickHomeworkRoute(row)(ykt-native / external-web / internal)——yuketang + leafTypeId/classroomId 齐备 → ykt-native, 不再看宿主,全平台默认原生(PC 同样直达);非雨课堂 / 参数缺失 → external-web; 无 source → internal。pickYktDetailEntry(row) 改单参签名,成为 ykt-native 的两态 投影(旧口径兼容)。判定条件唯一出处收敛到本函数。
  • 唯一执行层(lib/homeworkEntry.ts 新文件):openHomeworkRow(h, {navigate, from, courseName}) 把三态落成动作(learn-ykt-detail 导航 / openExternalHomework / learn-assignment-detail);外部源行无 externalUrl 保持旧口径 no-op。ykt 导航参数拼装 只此一处,点击点不再各写一份。
  • 接线收敛:三处点击点全部改走 openHomeworkRow——shared.tsx HomeworkRow (全部作业/课程详情/搜索外列表)、HomeWidgets.tsx HomeworkRows(今日页/收藏夹作业卡)、 SearchPage.tsx SearchHomeworkRow(全局搜索,本次补接)。extHwBrowse.isAndroidHostEnv 失去全部调用方,随之删除(Android 宿主判定仍活在 pickExtHwOpenChannel 内部)。
  • 「浏览器打开」文案统一(YktAssignmentDetailPage):页头按钮与错误态提示由 「在网页中打开」改「浏览器打开」;行为不变(桌面 = 系统浏览器打开官方页,移动 = R20-A 桌面模式 WebView 通道,分流在 openExternalHomework)。
  • 测试:ykt-detail-ui-test.mjs [1] 改三态全平台口径(9 断言);新增 [5] 入口接线 静态审计 14 断言(三点击点必须走 openHomeworkRow、不得自拼判定/直连详情页, homeworkEntry 三态动作齐全,「浏览器打开」文案回归),构成漏接回归防护。全文件 69 通过 / 0 失败。
  • 验证:pnpm typecheck(core / desktop / info-lib / ui 全绿)、 tools/exthw-status-test.mjs 169 通过 / 0 失败、tools/ykt-exercise-detail-test.mjs 79 通过 / 0 失败、tools/ykt-detail-ui-test.mjs 69 通过 / 0 失败。真机验收项: Android 在全部作业/今日页点雨课堂条目直达本页(参数缺失的行仍回退网页打开);桌面 (PC)点同条目同样进本页;页头「浏览器打开」桌面开系统浏览器、移动回 R20-A WebView。

28.10 R20-B3 实现纪要(2026-09-20:题干渲染——加密字体 / LaTeX / 图片代理)

目标:B2 详情页的题干 / 我的作答从「innerHTML 占位」升级为完整渲染管线: xuetangx-com-encrypted-font 加密字体(data.font 下载缓存 + @font-face)、$…$ 与 $$…$$ LaTeX、图片带会话代理;永不白屏,每一环失败只降级该环。

形态选型:本地内联沙箱文档(iframe srcdoc + sandbox="allow-scripts"),两端一套代码。 对比过「移动复用应用内 WebView 能力」的方案:应用内 WebView(R20-A 通道)是整页级 能力,嵌入单题内容需要为移动端单独实现一套 JS 桥(加载 HTML、双向 postMessage、高度回传、 生命周期管理),而桌面端 iframe 本就可用,等于维护两套宿主胶水。srcdoc iframe 则在 桌面(WebView2 / WKWebView / WebKitGTK)与移动(Android System WebView)上对 srcdoc + sandbox + postMessage + document.fonts 的支持完全同源,零平台分叉;且 opaque origin(不给 allow-same-origin)天然隔离外部平台 HTML:无法访问应用存储 / Cookie / DOM,比同源 innerHTML 少一整层注入面。成本最低且两端一致,故选型确定为此方案。

分层(判定收敛纯逻辑,IO 薄壳,组件只做胶水): - lib/yktBody.ts(纯逻辑,Node 直引可测):LaTeX $…$/$$…$$ 定界扫描(价格 $5 与 $6 不误判、\$ 转义、未配对/失败逐段保原文)、stripEncryptedFontClass 兜底、sanitizeForInlineDoc(script/容器标签/on/javascript: 去活性,与沙箱双保险)、 hardenYktImgs、字体缓存判定纯函数(fontMimeFromBase64 magic / isFontCacheFresh TTL+url 对档+时钟回拨 / fontDownloadAllowed 退避 / yktFontCacheKey 兼容 Rust safe_name 白名单)、buildYktProblemDoc 拼装。 - lib/yktAssets.ts(IO 薄壳):字体「进程内去重 → 磁盘缓存(复用 Rust state_read/state_write,appData/state/,7 天 TTL,失败 10min 退避且退避期 不打网络)→ fetch_binary 下载(带雨课堂 Referer;magic 校验防止 404 页面或 HTML 内容写入 缓存)」;图片代理 fetchYktImageAsDataUrl(仅 yuketang/xuetangx 自有域附加会话 Cookie,不向第三方图床外泄凭据)。 - lib/yktKatex.ts:KaTeX 懒加载胶水——katex.mjs(≈265KB min)与内联 CSS (≈368KB)都走动态 import,Vite 分包,无公式的作业不向主包引入任何字节*; throwOnError: true 让单公式失败由 safeRender 兜回原文、同文档其余公式照常; trust: false 不展开 \href/\url(公式不成为第二个链接通道)。 - components/exthw/ProblemBody.tsx:srcdoc iframe 宿主——高度自适应 (文档脚本 load/ResizeObserver/延时三保险上报,YKT_FRAME_MIN_H/MAX_H 钳制)、 图片直挂失败→代理重试一次→占位框+文件名、字体挂载后 iframe 内 document.fonts 校验失败→force 重取一次(避免反复重试)、文档内链接拦截走系统浏览器外开、 postMessage 全程 ev.source 核对。

KaTeX 离线自包含(vendor):src/vendor/katex/ 内置 katex@0.16.22 官方 dist/katex.mjs + types/katex.d.ts(改名 katex.d.mts)+ MIT LICENSE;katexInlineCss.ts 为生成产物——katex.min.css 全部 20 个 @font-face 改写为仅 woff2 的 data:font/woff2;base64(四类目标 WebView 全支持 woff2,woff/ttf 段丢弃),随文档 字符串进 srcdoc(iframe 是 opaque origin 加载不了包内相对资源),且只进实际渲染出 class="katex 产物的文档(extraCss 门禁,无公式文档零开销)。不用运行时 CDN: 内网/离线环境直接失效,且向第三方域发送公式内容存在泄露风险。

降级链(写死在实现里,验收口径「永不白屏」):① katex 加载失败/单公式解析失败 → $…$ 原样保留;② 字体 404/超时/退避中/非字体 magic → 剥加密 class 普通字体显示原文 (保字优先:允许字形错误,不丢失文字);③ 字体挂上但 iframe 内校验失败 → force 重取一次,仍败维持剥 class 形态;④ 图片直挂失败 → 代理重试一次 → 占位框+文件名。Cookie 通道:getYktCookie() (state 层内存传递,不落日志)。

接入面:YktAssignmentDetailPage 四处正文全走 ProblemBody——题干(普通题 + 题型 9 题干)/ 我的作答 myAnswer.contentHtml / 作业说明 description;fontUrl 用 整卷 YkExerciseDetail.fontUrl。红线不变:全页只读,无任何提交 UI(测试审计 onSubmit/提交答案/submitAnswer 零命中),题型 9 仍只给外链跳转。

测试:新增 tools/ykt-body-test.mjs(125 断言,Node 直引 + 真实 vendor katex 烟测): [1] LaTeX 定界/转义/逐段降级 [2] 加密字体判定与剥 class [3] 字体缓存判定(magic/TTL/ 对档/时钟回拨/退避/键形态)[4] sanitize 与 Cookie 域判定(防第三方外泄)[5] 拼装降级 与 extraCss 门禁 [6] KaTeX 集成烟测(含「只有价格 $ 不注入 CSS」)[7] 接线静态审计 (四处 ProblemBody、无 allow-same-origin、ev.source 核对、强刷仅一次)。回归: pnpm typecheck 全绿(core/desktop/info-lib/ui);tools/exthw-status-test.mjs 276 通过 / 0 失败;tools/ykt-exercise-detail-test.mjs 79 通过 / 0 失败; tools/ykt-detail-ui-test.mjs 69 通过 / 0 失败;pnpm --filter desktop build 通过 (katex 双 chunk 确认懒加载,主包无增量)。

真机验收项(待霖):① 含加密字体的作业——题干真字正常显示(非占位字形); ② 含公式的作业——$…$ 出正式数学排版;③ 断网/字体 404 场景——题干仍出文字(普通 字体),不白屏;④ 大图/防盗链图——占位框显示文件名或代理加载成功;⑤ PC 与 Android 同一页面观感一致。

28.10.1 R20-B3 修复纪要(2026-09-20 霖 PC 实测反馈:加密字体仍乱码)

现象:PC 端题干里 xuetangx-com-encrypted-font 加密段仍是中文乱码原字符;LaTeX 渲染正常 → srcdoc iframe / postMessage / 高度自适应链路皆通,问题聚焦字体环。

排查结论(五向全查): 1. Tauri CSP——排除。tauri.conf.json app.security.csp = null(不注入 CSP), 仓库内也无 meta CSP;srcdoc iframe 未被拦 data: 字体。 2. 下载链路——无法真连复核(环境里 YKT_COOKIE 已过期:basic-info 返回 code=50000、 get_exercise_list 返回 401000「Session not exists」,未尝试任何登录);静态复核: detail.fontUrl(data.font)host 落在 shouldAttachYktCookies 白名单内, fetch_binary 带 Cookie + Referer https://pro.yuketang.cn/ + UA,magic 校验 (wOF2/OTTO/0x00010000)口径正确;且乱码形态与「拿到字体」并存(见根因),可判 下载链路不是本根因。 3. data: URL mime——排除。fontMimeFromBase64 按 magic 判 font/ttf|otf|woff|woff2, 与 vendor katexInlineCss 同口径。 4. 就绪判定——存在缺陷(已补)。原 fontCheck 只查 document.fonts.load 成功与否; 而「@font-face 载入成功」与「span 的 font-family 规则生效」是两件事,前者在规则被 丢弃时照样成功 → 误报 ykt:font-ok,组件不触发强刷,静默保持乱码。 5. font-family 匹配——根因。见下。

根因(引擎级实测复现):YKT_DOC_CSS 里加密 span 规则写的是 font-family:"YktEncrypted",inherit。CSS 全局关键字(inherit/initial/unset/…)不能 作为 font-family 列表的一项——不是"回退到父级字体",而是整条声明在解析期即被引擎 按非法值静默丢弃。后果链:span 计算样式永远是正文字体栈 → 加密码点按普通字体显示 (= 乱码);无人引用 YktEncrypted → @font-face 保持 unloaded(下载与缓存均无效); document.fonts.load 照样成功 → fontCheck 误报 OK → 无任何降级/重试信号。该错误 跨平台同构(Blink/WebKit 同规),PC 与 Android 会同时受影响。实测:Windows Edge (=WebView2 同 Blink 内核)headless 渲染 B3 产物,sandbox="allow-scripts" srcdoc iframe 内 span 计算样式为正文栈、@font-face unloaded、样式表里该规则不存在 (ruleSurvived:false)——与霖的 PC 现象逐项吻合。

修复(apps/desktop/src/lib/yktBody.ts + yktAssets.ts): 1. 新增 YKT_DOC_FONT_STACK 常量(-apple-system,'PingFang SC','Microsoft YaHei', 'Noto Sans CJK SC',system-ui,sans-serif),body 与加密 span 回退栈共用同一常量; span 规则改为 font-family:"YktEncrypted",<YKT_DOC_FONT_STACK>——合法列表、加载 失败/未覆盖字符的观感≈普通正文(保字优先的设计意图至此才真正落地)。 2. fontCheck 就绪判定升级为双确认:document.fonts.load 载入成功后,再取首个 非空加密 span 的 getComputedStyle(...).fontFamily 核对确实含 YktEncrypted, 任一不满足 → ykt:font-fail → 组件既有 force 重取一次路径接管(防同类静默失效)。 3. loadYktFont 失败结果不再驻留进程内记忆(mem.delete)——否则下载失败一次后该 URL 永远命中失败的 Promise,10min 退避窗口形同虚设、重启前再无重试机会。

回归验证:Windows Edge headless 复测修复后产物——span 计算样式 YktEncrypted, -apple-system, 'PingFang SC', …(规则存活)、@font-face 达 loaded、 document.fonts.load('16px "YktEncrypted"') resolve loaded;还原旧规则对照组依旧 ruleSurvived:false + unloaded(根因可复现、修复生效双向成立)。tools/ykt-body-test.mjs 新增 10 断言(span 规则合法性回归网 / 字体栈同源 / 就绪判定双确认 / 失败不驻留记忆), 135 通过 / 0 失败;pnpm typecheck 全绿;exthw-status 276 / ykt-exercise-detail 79 / ykt-detail-ui 69 均 0 失败。

28.10.2 R20-B3 修复纪要(2026-09-20 霖 PC 实测反馈:老师评语渲染两次)

现象:原生详情页老师评语区同一内容出现两处:一处是本项目的结构化「老师评语:…」 (user.remark 渲染),另一处是「盛洁:…」(批注人名 + 完全相同的内容)。

排查与定位(2026-09-20):环境里 YKT_COOKIE 已失效(v3 basic-info code=50000、 get_exercise_list 401000「Session not exists」,见 28.10.1),无法拉新快照复核;按既有 实测字段表(28.4)+ R16 21.1 判别器 + 霖所见形状三方定位:雨课堂已批改题 (status 4)会把同一段评语同时写进 user.remark(总评)与 user.comment[](批注, 带批注人 name/avatar),本项目的两条结构化渲染路径(remark 逐字 / comment[] 逐条带名) 各渲染一次 → 双显。「my_answer.content 内嵌批注块」假设可排除:批注人名前缀 「盛洁:」恰是本项目 comments 渲染里 c.name 的拼接产物,myAnswerHtml 渲染链路 (yktBody)不含该形态,且我的作答正文与评语区在页面上是两个独立区块。

方案(去重收敛在展示层,core 数据不动):core 继续忠实透出 remark / comments 两字段(R20-C 提交链路与未来导出仍要原始数据);apps/desktop/src/lib/yktDetail.ts 新增纯函数 dedupeYktRemarks(p),YktAssignmentDetailPage ProblemCard 评语区改为 渲染去重后口径。同文判定键:去除所有空白 + 冒号全半角折叠;remark 与某条具名批注 同文(含「名:内容」嵌名形态)→ 保留批注行、总评不再单独渲染(批注含批注人 = 渲染信息更全的规整口径,霖看到的「盛洁」得以保留且只出现一次);无名批注与 remark 同文 → 两形态渲染等价,保留 remark、丢批注。不同文的评语一律原样保留(总评 + 逐条批注并存的合法形态不受影响);remark 缺位时批注之间自身去重(首条优先); 返回值不含空数组/空串 → 字段缺省 = 不渲染该层,两层皆空才隐藏整个评语区。 my_answer.content 的 HTML 批注渲染链路(yktBody)完全不动——无结构化评语的题, 题内批注照常显示(不丢任何老师批语的红线)。

测试:tools/ykt-detail-ui-test.mjs 新增 [6] 组 14 断言(霖实测同文形态 / 空白与 冒号全半角折叠 / 嵌名形态 / 无名批注等价形态 / 不同文全保留 / 批注间去重 / 空值形态) + 2 条静态审计(详情页必须走 dedupeYktRemarks、不得再直渲染原始 remark / comments), 85 通过 / 0 失败;pnpm typecheck 全绿;exthw-status 276 / ykt-exercise-detail 79 / ykt-body 135 均 0 失败。

真机验收项(待霖):雨课堂已批改作业详情页评语区应只出现一次「盛洁:…」;总评与 批注不同文的作业两段都在;无结构化评语的旧作业题内批注照常。

28.10.3 R20-B3 纪要(2026-09-20 霖需求:已批改作业在入口显示分数,对齐考试口径)

需求:雨课堂已批改作业(kind: homework,区别于考试 type 20)此前入口行只显 「已批改」,分数要进详情页才能看到;要求像考试(R9「已提交 · 60/100」)一样在列表 入口直接显示。

数据面侦查:作业与试卷数据面天然不同构——考试出分走独立端点 /v/exam/cover (R9 专用),而作业的批改分数就在既有状态查询响应里:fetchYktStatus 用的 get_exercise_list/{leafTypeId}/(与详情同端点,R16 21.1 已建 mock)里 problems[].user.my_score(单题得分,status 4 = 已批改、-1 为未批占位)与题面 content.score(卷面单题满分)同响应可得 → 零额外请求,直接在状态路径聚合。

方案: 1. packages/core/src/exthw/yuketang.ts fetchYktStatus:score = 已批改题 (status 4 且非 -1 占位,真实 0 分照算)的 my_score 合计;totalScore = 题面 content.score 合计;求和经 roundScore(round 到百分位)去浮点尾差。仅整卷 已批改(graded,R16 21.1 判别器)时透出——对齐考试「已出分才给分」口径,未批改 不显示;无一题有有效分 → 不设 score(缺数据不谎报 0 分);题面分值全缺失 → 只给 score 不给 totalScore(入口显示裸分数)。状态与分数同一响应,缓存 / 聚合链路 (fetch → ExternalHomework.score/totalScore → toHomework 既有透传)自动生效。 2. apps/desktop/src/lib/yktDetail.ts 新增纯函数 homeworkEntryScoreText(h):已提交 且带分 → "X/Y"(有满分)/ "X";未提交 / 无分 → "" 不显示。「何时有分」由 core 决定(作业仅整卷已批改、试卷仅已出分),UI 只负责显示——shared.tsx HomeworkRow 的考试分数内联表达式改走该函数(行为零变化),考试与已批改作业自此 共用同一函数同一显示位(「已批改 · 30/40」,chip title 同步「成绩:30/40」)。 PC / 移动共用 HomeworkRow,一套 UI 自动生效(今日页 HomeWidgets 行本就不显示 考试分数,为保持口径一致而不加)。

测试:tools/exthw-status-test.mjs mock 扩展(leaf 102/103/104 补题面分值 + 新增零分 / 缺分 / 无满分三个边界作业),新增 12 断言:合计映射(30+0 含真实 0 分)、 卷面满分合计、未批改 / 混合批改不透分、真实 0 分照透(0/20)、无一题有效分不设 score、题面分值缺失只给 score、请求次数不变(分数随状态同响应零额外请求), 288 通过 / 0 失败;tools/ykt-detail-ui-test.mjs [6] 组新增 11 断言(考试口径回归 60/100、作业 30/40、裸分、真实 0 分、半分尾零、未提交 / 未批改不显示 9 条 + 接线 静态审计 2 条:HomeworkRow 必须走 homeworkEntryScoreText、旧内联拼接不得重新引入), 96 通过 / 0 失败;pnpm typecheck 全绿;ykt-exercise-detail 79 / ykt-body 135 均 0 失败。

真机验收项(待霖):全部作业 / 课程详情 / 搜索的已批改雨课堂作业行显示 「已批改 · 30/40」(无满分作业显示裸分数);已交未批 / 未交不显示分数;考试行 「已提交 · 60/100」不受影响。

28.10.4 R20-B3 fix 纪要(2026-09-21 霖真机验收反馈:主题配色 + 折叠区块)

R20-C1 验收 ① 通过:内嵌 WebView 入口打开的是雨课堂官方提交界面(已登录态), 功能定性可用;验收 ②–⑤(提交后自动刷新 / 试卷与过截止与超次数无入口)待补测。

fix ② 沙箱文档主题适配。需求口径(霖原话要点):黑色主题不止一个,浅色主题也 可能有自定义底色(米白等),必须按实际生效的主题配色调整,不许假设明暗二元。 根因与修法: - 根因:YKT_DOC_CSS 是「浅色定稿」(color:#222),且 body background:transparent 在 Chromium 系上 color-scheme 为 light 的 srcdoc 画布会被刷白——暗色主题下题干白底; - 修法:yktBody 新增 YktDocTheme / DEFAULT_YKT_DOC_THEME(= 旧浅色定稿,未传 theme 时行为逐字节不变)/ sanitizeDocColor(只放行 #hex / rgb() / rgba() / hsl() / color() / transparent 字面量,挡 CSS 注入与畸形令牌)/ yktDocCss(theme); ProblemBody.readYktDocTheme 从 getComputedStyle(:root) 读 --text-1/--text-2/ --border/--accent/--surface-3 实际生效值(主题 = :root[data-theme] 变量覆盖, computed 值即最终值,任何主题插件天然正确),底色取组件容器沿祖先链第一个不透明 background(跟随所在卡片/区块面),经 MutationObserver 观测 data-theme/style 属性、主题 style 元素文本与 head childList 三条写入路径,主题切换即重建文档。

fix ③ 我的作答 / 老师评语可折叠。需求(霖):两区块支持折叠/展开,默认展开、 不记忆(每次进页一律展开,折叠只在当次浏览内有效)。实现:CollapsibleSection 组件(button 折叠头 + aria-expanded + ▾/▸ 小箭头,样式 .ykt-sec-toggle 入 global.css),折叠时内容不渲染(含 ProblemBody iframe,可节省实际开销)。

测试:ykt-body-test 补 [8] 主题配色 20 断言(默认定稿逐字节兼容 / 暗色字面量 注入 / sanitize 注入拦截)→ 154;ykt-detail-ui-test 补 [8] 折叠与主题接线 静态审计 11 断言 → 147;exthw-status 288 / ykt-exercise-detail 86 零改动全绿; pnpm typecheck 四包全绿。

28.11 R20-C1 雨课堂 web 提交器全量侦查纪要(2026-09-21)

侦查方法(不重新下载、零触发):复用前序会话留在 .ykt-recon/ 的学生端 bundle 缓存 (web/1.2.310 三主包 + 978 个 code-split chunk,共 84MB,gitignored)。定位链: aiworkspace.4d1bd7dc.js 路由表 → 学生端 lms-graph/:classroom_id/exercise|quiz/:leaf_id 组件 → 桌面路由组件(module 77392,落在 chunks/69600.c171a5d7.js)只渲染一个 <iframe src="/v2/web/iframe-exercise/{classroomId}/{leafId}?noLeftMenu=1&…"> → 真正提交 UI 在 rainweb(pc.js / web 1.2.310) 的 /iframe-exercise 组件里(module 内联于 pc.43fa16c6.js,端点注册表在 chunks/61467.e136e527.js)。结论 0:官方学生作业页 = /ai-workspace/lms-graph/{cid}/exercise/{leaf_id} 外壳 + /v2/web/iframe-exercise/{cid}/{leaf_id} 内核 iframe,两端 postMessage 交换补交状态;移动端老路由是 /cloud/exercise/cover/{cid}/{leaf_id}/{sku_id}(另一套)。本轮只读逆向,未对任何写端点发请求。

表 A:题型 × 答案形态 × 提交 payload × 端点 × 行为(题型号取 pc.js xz 枚举,权威: all:0 single:1 multi:2 vote:3 blanks:4 subjective:5 judge:6 other:7 anonymousVote:8 oj:9 material:10)

题型 ProblemType 答案形态(前端 _answer) answer 字段形状 端点 行为 / 限制
单选 1 字符串选项号(如 "B" / "1") answer: [s.toString()] problem_apply 逐题提交;非空即可交
判断 6 字符串("true"/"false" 或选项号) answer: [s.toString()] problem_apply 与单选同形(pc.js 正则 /^1\|6$/ 合流)
多选 2 字符串数组(选项号集合) answer: s(数组) problem_apply 数组非空即可交
投票 3 字符串数组;PollingCount 上限,超出 shift() answer: s(数组) problem_apply 同多选;匿名投票 8 未见独立提交路径
填空 4 按 <span class="blank-item"> 切分题干,逐空 {num, answer} answers: { "<num>": "<answer>", … } problem_apply 至少一空非空;空号从 1 起
主观 5 { content:"<HTML>", time:"0", oSubject:{attachments:{filelist:[…]}}, pics?:[…] } answer: JSON 深拷贝(s) problem_apply 富文本 UEditor(toolbar:加粗/斜体/下划线/插图/公式/代码/前景色/背景色/有序无序列表,maximumWords: 20000);正文、附件、图片三者任一非空即可交;oSubject.isuploading 为真时禁交
外链 OJ 9 { code:"", language:"" }(仅本地态) 不发 problem_apply window.open(answer_problem_url) + OJ 站 /mooc-api/v1/lms/oj/submission 红线:雨课堂站内不提交,只跳外链;「开始作答/重新作答」按钮
材料题 10 材料壳 + 子题逐个走各自题型 同子题题型 problem_apply(子题) 有「逐题作答 / 整体作答」两模式、SubmitAllQuestions 为子题级,不是整卷提交
组作业 — 组答案 groupAnswer(同主观形态) answer: groupAnswer + exercise_id problem_group_apply 先 is_last_version 判定 shouldSubmitDirectly / 是否有新提交,再弹 GroupSubmitConfirm 二次确认

表 B:提交器端点地图(pc.js 逆向 + 注册表 chunks/61467 交叉核对)

用途 端点 方法 payload / 说明
作业详情(提交器与原生详情页同源) /mooc-api/v1/lms/exercise/get_exercise_list/{leaf_type_id}/ GET classroom_id / term=latest / uv_id,头 XTBZ: ykt
逐题正式提交 /mooc-api/v1/lms/exercise/problem_apply/ POST {classroom_id:int, problem_id, answer}(题型映射见表 A)
组作业提交 /mooc-api/v1/lms/exercise/problem_group_apply/ POST {classroom_id, problem_id, exercise_id, answer}
组作业版本/新提交判定 /mooc-api/v1/lms/exercise/is_last_version/ GET 回 {is_last, user} → shouldSubmitDirectly = is_last && !is_submitted
试卷整卷提交 /quiz/entire_quiz_submit POST {quiz_id, classroom_id, user_id, results:[{problem_id?, result, time(秒)}]};errcode 0 成功
试卷逐题缓存(服务端草稿) /quiz/cache_each_result POST {results:[{problemID, result, time}], classroom_id, quiz_id};errcode 0 成功
试卷断网重传 /quiz/retry_quiz_submit POST {quiz_id, data:[{problem_id, result, submit_time}], classroom_id};3.5s 退避最多 84 次
试卷元信息 / 状态 / 结果 /quiz/shapes、/quiz/user_quiz_status、/quiz/quiz_info、/quiz/start_quiz、/quiz/refresh_left_time、/quiz/quiz_result、/quiz/personal_result GET/POST 见注册表 studentQuiz 组
作业附件上传(OSS STS) /pc/upload_info/exercise_attachment/ GET query exercise_id + problem_id(二者都有才带);回 data.upload{credentials{AccessKeyId,AccessKeySecret,SecurityToken,BucketName,UploadDir}, cdn_host, region} → ali-oss multipartUpload(partSize 512KB,并发 3),对象名 UploadDir/Date.now()+文件名,回 file_url
通用七牛上传(试卷/PPT 等) /generate_qiniu_token(API.pc.tool.POST_QINIU_TOKEN) POST {bucket_name:"cms-attachment", expired_time:3600} → token 存 sessionStorage.QINIUtoken → POST https://upload.qiniup.com
作业详情统计 / 提交历史 /c27/online_courseware/exercise/statistic/{exercise_id}/{sku_id}/…(result_history、team_result_history、problems、problem_list、open_exercise_problem_info、groups) GET 提交历史、逐题统计、组信息
申诉 /mooc-api/v1/lms/exercise/appeal/、/appeal/history/ POST/GET 批改后申诉
重置 /mooc-api/v1/lms/exercise/reset_exercise/{exercise_id}/{sku_id}/{user_id}/、batch_reset_exercise POST 教师侧重做
知识点练习(非本作业) /mooc-api/v1/lms/exercise/learning_plan/problem_submit/、problem_batch_submit/ POST learning_plan 专用,不可混用

提交粒度与草稿 - 作业(exercise):逐题提交(problem_apply),没有「整卷一次交」端点;SubmitAllQuestions 是材料题子题级。每次提交成功后把响应 data 合并进 problem.user、_refresh=true,并重新拉 详情刷新真实状态(无乐观更新)。 - 试卷(quiz):整卷提交 entire_quiz_submit,且有服务端逐题草稿 cache_each_result。 - 作业草稿只在 localStorage:writeDraft 把整份 problem(含 _answer)写进 localStorage[<routePath 去斜杠换横线>-<userId>],refreshSubmitStatus 每次变更即写; 无任何服务端 save/draft 端点。键含 userId,换设备/换浏览器不互通。 max_retry>1 || max_retry==-1 时进详情会 localStorage.removeItem 清草稿。 - 组作业有独立 GroupEditAnswer 编辑态与 submitConfirmData 二次确认;非组作业点「提交」直接发。

提交 API 细节 / 防重复 - 成功判定:problem_apply 响应 r.success === true;data 含 is_correct、exercise_is_show_answer、 更新后的 user(含 my_answer、submit_time、count、my_count)。失败走 .catch,弹 e.msg || "网络异常"。 - 防重复:客户端 defaultProblemStatus.isloading 置位期间按钮 loading + disabled;提交前 submitStatus(= 答案非空 ∧ 可交)为假时按钮禁用;组作业额外 is_last_version 版本门。 未观察到独立的服务端幂等键;重复提交靠 left_times(重交次数)与官方二次确认约束。 - 试卷错误码:errcode 0 成功;500002(+status_code 802/803)→ 收卷异常弹窗; 500003 → 自动判失败;500006 → 人工判失败;其余按 3.5s 重试、最多 84 次。

max_retry / 允许迟交 / 截止时间在 web 端如何呈现与拦截 - 每题的 left_times = user.count - user.my_count(count>0);count<=0(缺失/0)→ 999(不限次)。 - isBeforeDeadLine = !score_deadline || (score_deadline - now > 0);一旦过了 score_deadline, left_times 被强制置 0(截止时间硬拦截,优先于次数)。 - 提交按钮:disabled = !submitStatus || !problem.left_times || isloading;文案 「提交(不限次)」/「提交(剩余 N 次)」;left_times===0 时换成 已提交/已截止 灰按钮(不可点)。 - 允许迟交(is_allowed_late_submission + late_submission{dict}):不直接改提交按钮,而是由 aiworkspace 外壳的 LateSubmissionStatusBar 显示「请在 {score_deadline} 前完成补交」或 「补交已截止,补交扣分:{deduct_score},作业最终得分:{final_score}」;即补交窗口仍以 score_deadline 为准,late_submission.deduct_score 决定扣分。⚠️ 与 docs 28.1/28.4 把 late_submission 当毫秒时间戳不同:实测它是含 deduct_score 的对象,core 现有 lateDeadline = toNum(data.late_submission) 对其取不到值(不设),待修/待真机复核。 - 题型 9(外链 OJ):未过截止时按钮为「开始作答 / 重新作答」,过截止显示灰「已截止」。

提交后的状态回执与「已提交」判定 - 单题「已提交」= !!problem.user.submit_time(is_submitted computed)。 - 已批改判定仍用 R16 21.1:user.status===4 且 my_score 非 -1 占位;已交未批 status===3。 - 整卷完成度:exerciseList.problems 里 user.submit_time 计数等于题数 → 触发 checkUserApplyStateAfterLeafFinished(学习行为完成上报)。 - submission_status 字段存在于响应,但提交器未用它做「已提交」判定(用 submit_time)。

附件上传通道(作业) - 端点 GET /pc/upload_info/exercise_attachment/?exercise_id=&problem_id= 取 OSS STS 临时凭据 (data.upload.credentials),region:"oss-cn-beijing",走 ali-oss 分片上传,落 cdn_host 域。 - 数量/大小:可上传 1 个附件;默认 ≤100MB(0x6400000),video_1g 作业放宽到 1GB(0x40000000)。 - 类型白名单(exercise,新版组件):.pdf .doc .docx .wps .pages .xls .xlsx .et .csv .numbers .ppt .pptx .dps .key .txt .rtf .jpg .jpeg .png .bmp .tif .gif .rar .zip .7z .tar .mp3 .wav .mp4 .wmv .mov; 音频(mp3/wav)以附件形式交,无独立录音题通道;代码题走 OJ 外链。 - 附件记录形状(挂进 _answer.oSubject.attachments.filelist[]):{fileID, fileName/name, fileSize/size, fileType, fileUrl, …};file_url 取分片上传 requestUrls[0] 去 query。 - 试卷附件走七牛 /generate_qiniu_token(bucket_name:"cms-attachment",token 缓存 sessionStorage), 与作业 OSS STS 是两条通道,不可混用。

与原生详情页数据模型的差距(R20-C2 前待补) - core YkProblem 目前未透出 user.count / user.my_count(重交次数)、user.submit_time(单题已交)、 submission_status;R20-C1 资格判定已补 remainingRetries(count-my_count,count>0 才给), 其余留待原生作答 UI 阶段按需补。 - late_submission 对象语义(deduct_score)与 core 现有 lateDeadline 口径不一致(见上)。

⚠️ 待霖扫码重登后补测(本轮 Cookie 已失效 401000,写端点一律未触发) - 用一道「不计入总分」的作业实测 problem_apply 成功响应字段与 left_times 递减; - 复核 late_submission 真实形状(对象 vs 时间戳)与 score_deadline 是否为补交截止; - 复核作业附件 OSS STS 返回字段(cdn_host/UploadDir)与 1 附件限制; - 组作业 problem_group_apply + is_last_version 二次确认链路。

28.11.1 R20-C1 嵌入式 WebView 提交入口实现纪要(2026-09-21)

范围:第一阶段只做「把用户送进雨课堂官方作答页」——原生 UI 不逐题作答(留 R20-C2)。 资格判定为纯函数;打开前应用内二次确认;打开后官方页的确认/拦截原样保留;关闭/返回 后立即重拉真实状态(禁止乐观更新)。

  • core(packages/core/src/exthw/yuketang.ts):YkProblem.remainingRetries? = user.count - user.my_count,仅 count>0 时给(count<=0/缺失 = 不限/未知,不设; 与 web left_times 同口径),供「未超 max_retry」资格判定。
  • 纯函数(apps/desktop/src/lib/yktDetail.ts):yktSubmitEligibility(input) + parseYktLocalTime。红线:试卷(kind=exam / 活动 type 20 / 旧 /subject 别名 6)与 全题型 9(外链 OJ)永不出口;时间窗:未过截止放行,已过截止须允许补交且未过补交 截止(「允许迟交」是补交分支的门,不否定按时提交);未超 max_retry:所有题都有次数 信息且全部 ≤0 才算超(undefined 不拦),题目全无次数信息时用整卷 maxRetry=0 + 全已提交兜底。返回 {eligible, reason?, remainingRetries?}。
  • 执行层(apps/desktop/src/lib/yktSubmitWebview.ts):openYktSubmitWebview(url, cookie) —— invoke open_ykt_submit_window;桌面端以 ykt-submit-closed 事件等窗口关闭,移动端 命令本身关闭才 resolve;浏览器预览明确报错。Cookie 只作 invoke 参数内存传递,不打印 / 不落盘;非 http(s) 拒绝。
  • 详情页(apps/desktop/src/pages/learn/YktAssignmentDetailPage.tsx):页头「作答 / 提交」 主按钮仅在 eligibility.eligible && externalUrl 时渲染;点击 → confirmOk 二次确认 (说明将打开官方作答页、官方逻辑原样保留)→ 应用内 WebView 打开 R16b 学生端直链并注入 getYktCookie() → finally 里 setTick(+1) 重拉详情。失败保留原文案 + log_debug (不含 Cookie)。
  • 原生通道:
  • Rust(apps/desktop/src-tauri/src/lib.rs):新增 open_ykt_submit_window 并注册。 桌面端先建在 pro.yuketang.cn 源根,逐条 wry set_cookie(Domain=.yuketang.cn / Path=/)后 navigate 到官方作答页,窗口关闭 emit ykt-submit-closed;移动端转 openWebModal(带 cookie)后 emit 同事件。
  • Kotlin(apps/desktop/src-tauri/plugins/onethu-mobile/android/.../OnethuMobilePlugin.kt): OpenWebModalArgs 增可选 cookie;openWebModal 在 loadUrl 前逐条 CookieManager.setCookie 并 flush(空串 = 不注入,R20-A 只读浏览行为不变); 绝不打印 Cookie 值。
  • 测试:tools/ykt-exercise-detail-test.mjs 补 7 断言(remainingRetries 口径)→ 86; tools/ykt-detail-ui-test.mjs 补 [7] 节 40 断言(红线 / 时间窗 / 次数 / 时间解析 / 详情页 接线静态审计)→ 136;tools/exthw-status-test.mjs 288、tools/ykt-body-test.mjs 135 零改动全绿;pnpm typecheck(core / desktop / info-lib / ui)全绿。
  • 红线落实:入口只打开官方页,不注入脚本、不替代/不绕过官方确认与拦截;试卷 / 全外链题 无入口;关闭后重拉真实状态(无乐观更新);Cookie 不打印/不落盘。
  • 待霖真机验收项:① PC 与 Android 各开一份未过截止作业,详情页出现「作答 / 提交」, 点击二次确认后在应用内 WebView 打开官方作答页(应已登录态,不需重新扫码);② 在其中 完成一题提交并关闭窗口,详情页应自动刷新出「已交未批」/真实状态;③ 试卷详情页无该入口; ④ 过截止且不允许补交的作业无该入口;⑤ 重交次数用完的作业无该入口(真机数据复核)。

二十九、R21-A:Tyche 登录失效静默自动重登(2026-09-20 霖反馈)

29.1 问题与验证码侦查(真连实测,2026-09-20)

  • 现象(霖):Tyche 会话失效后不会自动重登,必须手动输入一次密码,操作繁琐。
  • 现状:Tyche 本就支持账密登录(tycheLogin:GET user/GetToken?username=… 取挑战 token → password = sha1(sha1(明文口令)+token) → POST user/Login 取回 JSESSIONID/username/uid Cookie),但设置页登录成功后只存 Cookie,密码未予保存; 会话失效(实测特征 {"status":"login"},HTTP 200)后没有任何自动恢复,与 R19 27.1 之前 的 TUOJ 属同类问题。
  • 验证码侦查结论(tools/ 真连探针,2026-09-20 实测):
  • GET user/GetToken?username=root → {status:"success", vcode:false, token:<40 位>} —— 常规账号登录不受验证码限制(vcode=false,走完整静默自动重登,无需降级);
  • Login.html 的 #vcode-field 默认 .hide(),仅 GetToken 返回 vcode:true 才 .show()—— 即只有「考场锁定」账号才需要验证码(与 2026-09-18 逆向结论一致);
  • GetToken?username=<不存在用户> → {status:"error", returnFailString:"oops"}(错误经 TYCHE_FAIL 映射中文)。
  • 降级口径(仅考场锁定账号,实测未遇):vcode:true 时 tycheLogin 直接报 「需要验证码(考场锁定),无法免人工登录」,自动重登必然失败——该场景退化为 「已记住密码时重登只需补一次验证码」的人工路径:作业页条幅 / 设置页源卡片展示 「已尝试自动重新登录,仍失败:…需要验证码…」,用户手点「重新登录」进表单补一次即可 (表单有密码回填 + 记住密码,仅缺验证码这一步;如后续真有此账号,再考虑加内嵌 验证码输入框)。普通账号(绝大多数)不受影响。
  • 附带实测:环境里那份 TYCHE_COOKIE 已过期(GroupList 返回 status:"login")——正是 本需求要自动恢复的真实场景。

29.2 方案(对齐 R19 27.1 的 TUOJ 会话失效自动重漫游模式)

  1. 记住用户名+密码:设置页 Tyche 登录表单加「记住密码(会话失效后自动重新登录)」 勾选(默认不勾)。勾选并登录成功后 username+password 随 ExtHwCreds.tyche 存进既有 AES-GCM 信封 onethu.exthw.v1(与现有凭据机制同路:信封整体加密,明文不落盘、 不打印、不进日志、不进 commit;诚实口径与 README 一致——本机混淆,非强安全)。 不勾选则不存密码并覆盖清掉旧存档;「退出」清除整个 tyche 凭据(含密码)。
  2. 会话失效检测:status:"login"(接口未登录特征,实测唯一稳定标志)/ HTTP 401/403(外层 Basic 网关拒绝)/ 非 JSON 响应(跳登录页 HTML)三种表现统一归为 新错误类型 TycheSessionError(isTycheSessionError 判定),其余错误(网络断、5xx、 字段异常)不触发重登。注意 task/Status 的内层 catch 此前会把一切错误吞成「保守未提交」, 会话错误必须冒泡,否则自动重登永远不触发。
  3. 静默自动重登一次 + 自动重拉:refreshExternalHomework 新增 reloginTyche 钩子 (desktop 注入)——tyche 源被 TycheSessionError 拒绝时调用;返回 true(desktop 用 记住的账密走 tycheLogin 重登并覆盖保存凭据)→ core 重新组装该源重拉一次; 单次 refresh 每源至多重登一次(防循环)。
  4. 频控与去重(与 TUOJ 重漫游共用一套机制、独立计数):同源两次自动重登间隔 ≥10 分钟、每进程每源 ≤3 次(含失败尝试);同源并发失效共享 in-flight Promise (只发起一次重登,后来者共享结果,不重复计数、不受频控拦截); resetTycheSessionRetryState() 供离线测试清零。
  5. 显式退出抑制:用户点「退出」→ EXTHW_TYCHE_LOGOUT_KEY(onethu.exthw.tycheLogout.v1) 抑制标记,之后绝不自动重登(手动登录成功即解除);未记住密码也自然不重登 (前置不满足 → 不算「已尝试」,错误文案不带前缀)。
  6. 失败文案与诊断:发起过(或共享过)自动重登而该源最终仍失败的,errors.tyche 加前缀 「已尝试自动重新登录,仍失败:」(后接原失效原因);作业页失败条幅把 tyche 并入渲染(原 ExtHwTuojErrorNote 更名 ExtHwSourceErrorNote),设置页源卡片 同步展示(既有 exthw-note is-error 通道);desktop 全程 log_debug(logLine)留 诊断(跳过原因 / 重登成败 / 失败原因,绝不打印凭据)。

29.3 R21-A 实现记录(2026-09-20)

  • core(packages/core/src/exthw/tyche.ts):TycheSessionError + isTycheSessionError; getJson 的 401/403、非 JSON、status:"login" 三分支改抛会话错误; createTycheSource.fetch 的 task/Status 内层 catch 与单课程组 catch 对会话错误冒泡、 其余照旧吞掉。
  • core(packages/core/src/exthw/index.ts):R19 27.1 的 TUOJ 重漫游编排泛化为 「会话失效自动重登」——状态表扩为 {tuoj, tuojClassic, tyche}(独立计数),常量改 SESSION_RETRY_MIN_INTERVAL_MS/SESSION_RETRY_MAX_PER_PROCESS(10min/3,TUOJ_ 与 TYCHE_ 为兼容别名同值),in-flight 表同步扩容;reloginTyche?: () => Promise<boolean> 钩子、结果新增 reloginTyche: boolean;失败前缀常量更名 SESSION_RELOGIN_FAILED_PREFIX(文案不变)。rerouteTuoj 语义、TUOJ 路径行为与 R19 完全一致(既有 27.1 断言全部原样通过)。
  • core(packages/core/src/exthw/types.ts):ExtHwCreds.tyche 增加 password?: string(勾选「记住密码」才写入;随信封 AES-GCM 落盘)。
  • core(packages/core/src/index.ts):透出 TycheSessionError / isTycheSessionError。
  • desktop(apps/desktop/src/state/exthw.ts):
  • EXTHW_TYCHE_LOGOUT_KEY 显式退出抑制键 + loadTycheReloginFlag()(首次解密时回灌)
    • markTycheLoggedOut()(removeExtHwCreds("tyche") 自动调用)+ clearTycheLogoutSuppress()(手动登录成功调用)+ isTycheReloginSuppressed() / isTychePasswordRemembered()(设置页提示用);
  • maybeAutoTycheRelogin():抑制 / 未记住密码 → false(log_debug 说明);否则用存档 账密 extHwLogin.tyche 重登 → saveExtHwCreds(新 Cookie 连同记住的账密覆盖保存, 下次失效仍可再登)→ true;失败静默 false。频控与去重交给 core,不二次计数;
  • refreshExtHw 注入 reloginTyche: () => maybeAutoTycheRelogin();
  • 诊断走 logLine(log_debug):跳过原因 / 重登成功 / 失败原因(截 200 字符), 不含任何凭据。
  • desktop(apps/desktop/src/pages/Settings.tsx):Tyche 表单加「记住密码」勾选 + 说明文案(密文存本机 / 不进日志 / 退出即清);credsWith 支持 tychePwd(勾选才存, 未勾选覆盖清旧存档);onTycheLogin 成功后按勾选存档密码、解除退出抑制、提示文案 区分「已记住密码,会话失效将自动重登」;「退出」同步清表单态;已登录且已记住密码时 源卡片展示「已记住密码:会话失效将自动重新登录(静默进行,失败才会提示)」。
  • desktop(apps/desktop/src/pages/learn/AssignmentsPage.tsx):失败条幅渲染列表 扩入 tyche(组件更名 ExtHwSourceErrorNote),文案即 core 前缀文案;「去设置重新 登录」入口复用(落到 Tyche 卡片手动登录)。
  • 验证:pnpm --filter @onethu/core typecheck ✓、pnpm --filter @onethu/desktop typecheck ✓、tools/exthw-status-test.mjs 224 通过 / 0 失败(169 → 224,新增 R21-A 十三组断言:⑩类型判定三失效归一 / ⑪login→静默重登→重拉成功且新 Cookie 生效 / ⑫重登失败防循环+文案前缀 / ⑬普通网络错误不重登 / ⑭未注入钩子行为同旧版 / ⑮并发失效 in-flight 去重只登一次两轮均重拉成功 / ⑯同源 ≥10min 频控 + 每进程 3 次上限
  • 超限文案无前缀 + 与 TUOJ 独立计数 / ⑰task/Status 内层会话错误冒泡触发重登 / ⑱状态接口普通错误仍保守吞掉 / ⑲⑳tycheLogin 挑战 token + sha1 双哈希链路 / ㉑vcode=true 拒绝登录且不发 POST / ㉒returnFailString 中文映射)、 tools/ykt-exercise-detail-test.mjs 79 通过 / 0 失败、tools/ykt-detail-ui-test.mjs 69 通过 / 0 失败。
  • 真机/真连验收项(待霖):设置页勾选「记住密码」登录 Tyche → 手动使 Cookie 失效 (或等待过期)→ 刷新作业,观察条幅不出现、Tyche 条目恢复(log_debug 有「自动重登成功」); 输错密码登录后失效 → 条幅出现「已尝试自动重新登录,仍失败:…」;点「退出」后失效 → 不再自动重登(log_debug「用户曾显式退出登录,跳过自动重登」)。

29.4 备注

  • 频控数值与 TUOJ 对齐(≥10min / ≤3 次每进程)而非照搬 24h:会话失效属常态,频控过严会耗尽自动恢复 资格(R19 27.1 同款结论)。
  • 密码安全口径(诚实声明,与 README 58 行一致):AES-GCM 信封只是本地混淆——密钥与 密文同在本机 localStorage,能读存储的人仍可解出明文;它防的是「凭据以肉眼可读形式被 直接读取 / 随备份导出」。明文不落盘、不打印、不进日志、不进 commit;真要强保密需系统级 凭据库(未实现,见 README)。
  • tycheLogin 的挑战/哈希链路(GetToken → sha1(sha1(pwd)+token) → Login)未改动; 自动重登与手动登录走同一函数,行为一致。

30.1 问题与端点侦查(真连 + 前端 bundle 全量挖掘,2026-09-20)

  • 现象(霖):雨课堂(pro.yuketang.cn)会话约 24h 失效,失效后作业页报错,必须重新登录; 多设备(宿舍台式机 / 笔记本)各台均需分别扫码,操作繁琐。
  • 失效特征(真连实测,2026-09-20):环境里 2026-09-19 生成的 YKT_COOKIE 已失效:
  • GET /v2/api/web/courses/list → HTTP 401 + {"errcode":401000,"errmsg":"Session not exists"};
  • GET /api/v3/user/basic-info → HTTP 200 + {"code":50000,"msg":"UNAUTHENTICATED","data":""};
  • GET /v2/api/web/profile/info → HTTP 401 / 401000。 (v2 系与 v3 系失效表现不同:一个返回 401,一个以 200 携带错误码;归一处理时均需识别。)
  • 侦查问题:是否存在可用于静默保活的会话续期 / 刷新 / 心跳端点?
  • 侦查方法:public 前端 bundle 全量端点挖掘(登录壳 /web 起的三份 JS:login-spa.js 459KB、pc.43fa16c6.js 5.4MB、aiworkspace.4d1bd7dc.js 1.3MB,合计挖出 ~1800 个端点字面量), 聚焦 login/refresh/renew/keepalive/heartbeat/token/session 关键词;对可疑端点不做任何 触发(只读侦查,避免风控)。
  • 侦查结论:没有会话续期端点。
  • 登录 SPA 的端点注册表就是六个:pc_web_login:"/pc/web_login"、 pc_web_logout:"/pc/web_logout"、pc_web_login_ewm:"/api/v3/user/login/app-web-pre-info"、 pc_web_login_confirm:"/api/v3/user/login/app-web-login"、 send_sms_login_code:"/pc/login/send_sms_login_code/"、 verify_pwd_login:"/pc/login/verify_pwd_login/"——没有 refresh/renew 家族;
  • pc.js 的 738 个端点里唯一带 "heartbeat" 的是 /video-log/heartbeat/——那是课堂视频 播放心跳,与会话无关;/passport/login 是 public-activities 的无关 API;
  • aiworkspace.js 的 934 个端点里 token 系全部属于考试 / AI 文档服务,与会话续期无关;
  • 即 sessionid 由服务端 Django 会话管理,客户端不具备任何会话续期手段;
  • 附带观察:登录壳匿名访问未见任何 Set-Cookie 下发;app-web-pre-info(二维码 token, JWT、exp≈300s)可匿名调用——扫码登录链路与 R18 24.1 实现一致,无新信息。
  • 结论 → 方案路线(对齐需求第 3 步):没有续期端点,就退而求其次—— ① 6h 周期保活心跳(最轻的已授权请求试探服务端;「是否滑动续期」属实验验证项); ② 会话健康检查与失效原因归一(早发现、准确报因,不误判网络故障); ③ 失效后一键重登(扫码弹窗直达——短信通道受图形验证码限制,无法静默自动重登, 与 R21-A Tyche 的静默路径本质不同); ④ Cookie 导出/导入缓解多设备迁移成本(一台登录,其余导入即用,无需逐台扫码)。 ⚠️ 侦查期间环境里唯一的 cookie 已失效,「心跳能否推迟过期」的活体实验无法进行, 实验协议见 30.5,结论待回填;本轮先落地工程设施。

30.2 方案

  1. 失效归一 YktSessionError(对齐 R21-A Tyche / R19 TUOJ 口径):四特征统一归一—— HTTP 401/403(网关拒绝)、errcode=401000(v2 系死会话)、code=50000 UNAUTHENTICATED (v3 系死会话)、非 JSON(跳登录壳 HTML);isYktSessionError 判定。网络断 / 5xx / 字段异常不误判——网络故障报「会话已失效」会误导用户进行一次无效的重新登录。文案保持关键子串 「会话已失效」「401000」(作业详情链路既有 UI 断言依赖)。
  2. 健康检查 checkSession():GET /api/v3/user/basic-info——全部已授权端点里最轻的 (无列表遍历、无 XTBZ 头要求)。返回 {alive: true|false|null, reason?, userName?, checkedAt}: alive=null 仅网络异常(未知不等于失效,不误报);userName 宽松取归属人姓名 (取不到不设,纯展示)。
  3. 保活心跳(desktop 编排):应用启动 15s 后首查(避开启动刷新高峰),此后每 6h 一次 checkSession;结果进 ExtHwSnapshot.yktSession 供设置页展示;全程 log_debug 诊断(绝不打印 Cookie)。
  4. Cookie 轮换回写(兜底):侦查未见服务端在常规 GET 上轮换 sessionid 的证据,但一旦 轮换而本项目仍持有旧值,就会「莫名失效」。故传输层每响应检查 Set-Cookie 透传头 (x-onethu-set-cookie / -hops 通道,R18 已有),白名单字段 (sessionid/csrftoken/uv_id/university_id/platform_id/platform_type/xtbz/django_language) 有变化 → 合并进会话串 → onCookieRefresh 钩子交 desktop 用既有 AES-GCM 信封存回 (非白名单字段如统计位绝不并入;浏览器原生 fetch 读不到这些头 → 预览态零行为变化)。
  5. 一键重登:失效条幅(作业页)与设置页失效提示直接拉起雨课堂扫码登录弹窗 (ExtHwLoginModal 默认二维码通道)。不做静默自动重登:短信登录被图形验证码挡住 (R17 23.2 侦查结论),扫码必须由人工完成;合理路径是「把重登的入口铺到失败现场」。
  6. Cookie 导出/导入(多设备迁移缓解):导出 = 把当前会话串写成 JSON 文件 (kind:"onethu.yuketang.session"、version:1、sensitive:true + 警示文案:文件 脱离 UI 后仍自带敏感性标注);导入 = 选文件 → 强校验(JSON 合法 / kind / version / 含有效 sessionid)→ 并入凭据(uvId/phone 缺省保留原值)→ 立即刷新。导出走 Tauri save_text_file(WKWebView 无 a[download]),导入走系统文件对话框 + read_file_text(R10 插件导入同款通道)。

30.3 R21-B 实现记录(2026-09-20)

  • core(packages/core/src/exthw/yuketang.ts):
  • YktSessionError + isYktSessionError;getJson 四分支归一(401/403、非 JSON、 code===50000);fetch() 课程列表与 fetchExerciseDetail 的 errcode===401000 分支归一(其余 errcode 维持原通用报错);
  • YktSessionHealth + 源方法 checkSession()(basic-info;失效 reason: http401/http403/errcode=401000/unauthenticated/non-json,网络异常 alive:null);
  • Cookie 轮换:会话串改可变(curCookie),传输层包装 yktFetch 每响应捕获 Set-Cookie(私有 yktCaptureSetCookies——故意不复用 login.captureCookies: yuketang.ts 必须保持零相对导入,离线 Node 单测靠原生 type-stripping 静态导入本模块, .js→.ts 重写钩子注册在静态图解析之后无法生效;两处实现需同步维护,已注释互指)
    • mergeYktCookiePairs 白名单合并 + 可选第 4 参 hooks.onCookieRefresh (既有 3 参调用方零改动);
  • 导出/导入纯函数:buildYktCookieExportJson / parseYktCookieExportJson / YKT_COOKIE_EXPORT_KIND(校验拒绝均带中文原因)。
  • core(packages/core/src/exthw/index.ts、src/index.ts):透出 YktSessionError、 isYktSessionError、YktSessionHealth、YuketangSourceHooks、YktCookieExport、 mergeYktCookiePairs、buildYktCookieExportJson、parseYktCookieExportJson、 YKT_COOKIE_EXPORT_KIND。
  • desktop(apps/desktop/src/state/exthw.ts):YKT_HEARTBEAT_INTERVAL_MS(6h); YktSessionState + 快照新字段 yktSession;runYktSessionCheck()(幂等、永不抛出、 未配置返回 null;结果写快照 + log_debug);startYktHeartbeat()(幂等;15s 后首查 + 6h 周期;useExternalHomework 挂载即启动);saveYktCookieRefresh()(轮换 Cookie 合并 存回,保留 uvId/phone)。
  • desktop(apps/desktop/src/pages/Settings.tsx):雨课堂卡片(已登录态)新增—— 会话健康行(未检查 / 有效含归属人+时间 / 已失效含原因 / 未知)+「检查会话」「导出 Cookie」「导入 Cookie」三按钮 + 失效红色提示块内「一键重登(扫码)」(拉起二维码面板); 导出/导入在浏览器预览明确报错(无 Tauri 通道)。
  • desktop(apps/desktop/src/pages/learn/AssignmentsPage.tsx):失败条幅渲染列表扩入 yuketang;yuketang 失败时加「雨课堂扫码重登」主按钮,直达 ExtHwLoginModal(默认 二维码通道),省去一次跳转设置页的操作;「去设置重新登录」保留。
  • 工具(tools/ykt-session-smoke.mjs):真连冒烟 + 实验载体——打印会话串字段名 (值不打印)、执行一次 checkSession、存活时一并运行课程列表并报告原生 set-cookie 观察; 输出可直接粘进 docs 的结论行。凭据走 YKT_COOKIE/YKT_UV 环境变量,全程不落盘。

30.4 验证(2026-09-20)

  • pnpm --filter @onethu/core typecheck ✓、pnpm --filter @onethu/desktop typecheck ✓。
  • tools/exthw-status-test.mjs 276 通过 / 0 失败(224 → 276,新增 R21-B 七组断言: ①失效归一矩阵——401/403/errcode=401000(列表+详情)/code=50000/非 JSON → YktSessionError 且文案含关键子串,普通 Error / TUOJ 会话错误不误判; ②checkSession 有效路径——alive+归属人+单请求+无 XTBZ 头;③网络断 → alive=null 不谎报; ④Cookie 轮换捕获——白名单合并恰回调一次、非白名单不并入、后续请求立即用新值、 无 Set-Cookie 零回调;⑤mergeYktCookiePairs 纯函数;⑥导出/导入往返 + 五类拒绝; ⑦编排层对 yuketang 失效不加自动重登前缀、不误触发 Tyche 重登)。
  • tools/ykt-exercise-detail-test.mjs 79 通过 / 0 失败(会话失效文案契约未破坏)、 tools/ykt-detail-ui-test.mjs 69 通过 / 0 失败。
  • 真连验证(2026-09-20T05:12:56Z,tools/ykt-session-smoke.mjs):对已失效 Cookie, checkSession 准确判定 alive=false, reason=unauthenticated——失效检测链路 (认证头组装 → basic-info → v3 系 code=50000 归一)端到端成立,与 30.1 侦查特征一致。

30.5 备注:保活有效性实验协议(待霖回填)与工程注记

  • 实验协议(侦查期唯一 cookie 已失效,活体实验无法进行):
  • 任意设备登录雨课堂,取到新鲜 Cookie(或直接用应用内登录);
  • 每 6h 运行一次 node tools/ykt-session-smoke.mjs(或日常使用应用,其内置 6h 心跳), 记录每次的 docs 结论行(ISO 时间 + alive);
  • 判读:若存活时长显著超过 ~24h 基线(霖观察的失效周期)→ 服务端存在「活动滑动 续期」,6h 心跳即有效保活;若仍 ~24h 失效 → 会话是绝对 TTL,心跳只能保证 「失效后 ≤6h 内被发现」,届时可考虑把周期降到 1h 或在作业页刷新前先查一次;
  • 一并记录每次响应的 set-cookie 观察(脚本已内置)——若发现常规 GET 也轮换 sessionid,则白名单回写(30.2-4)由兜底升级为必需路径。
  • 心跳周期取 6h:霖观察失效周期 ~24h,6h 足够密(4 次机会/天);basic-info 极轻, 每天每设备 4 个请求的量级对服务端负载可忽略。
  • 不做静默自动重登的原因:短信通道受图形验证码完全阻断(R17 23.2),扫码必须由人工完成; 与 Tyche(账密 + 无验证码)本质不同。诚实做法是把「扫码重登」入口铺到所有失败现场 (作业页条幅 / 设置页失效提示),而非假定可以自动完成。
  • 导出文件安全口径(诚实声明):导出的 JSON 即完整登录凭据,拿到它 = 拿到账号会话; 文件内 sensitive:true + warn 文案、UI 提示「勿放同步盘/群聊/仓库,导入后删除」都只是 减灾,不是防线;导出文件不应作为安全存储使用。
  • Cookie 轮换回写是无证据的防御性设计(侦查未见轮换行为):若服务端从不轮换,该路径 永远不触发(测试覆盖其纯逻辑);若轮换,凭据不再「莫名失效」。成本极低,收益明确。
  • 「会话已失效」「401000」子串是 UI 断言契约(tools/ykt-exercise-detail-test.mjs), 改文案需同步改断言。

31. R20-C2 雨课堂原生逐题作答方案 v1(2026-09-21 定稿:主观题先行)

霖拍板(2026-09-21):① 第一批只做主观题(霖的作业几乎全为主观题,其他题型出现率低); ② 先出本方案再约 Cookie 实测;③ 主观题上完整富文本编辑器(对齐官方 UEditor 工具栏)。 试卷(quiz)整卷体系本期不做,留后续。OJ 外链题保持「开始作答」跳官方(红线:站内不代提交)。

31.1 目标与验收口径

  • 在原生详情页问题卡内直接完成主观题:富文本正文 + 附件(1 个,≤100MB,官方白名单) → problem_apply 提交 → 无乐观更新(合并响应 data + 重拉详情)→ 按钮状态与官方 逐条对齐(提交 / 提交(剩余N次) / 已提交 / 已截止)。
  • 验收:不计分作业真机提交成功、详情页状态回执正确、left_times 与官方页一致、 官方页可见本应用的提交(互通性)。

31.2 阶段划分

  • P0 数据补齐(core,0.5d,可先行):YkProblem 透出 user.count/my_count/submit_time/ submission_status(单题已交=有 submit_time,与官方判定一致);修 late_submission 口径 (28.11 实测为 {deduct_score} 对象,core lateDeadline 现取不到值)。
  • P1 侦查(0.5d):
  • P1a 离线(无需 Cookie):从 .ykt-recon/ 缓存 bundle 逆向 ① 公式按钮产出的 HTML 形态 (决定本应用编辑器公式插入格式)② 内容插图的上传通道与对象命名(exercise 正文图 ≠ 附件 STS? 还是同一条 STS 通道)③ UEditor 输出的清洗规则(官方渲染端接受哪些标签)。
  • P1b 真机(需霖扫码):不计分作业实测 ① problem_apply 主观 payload 全字段与成功响应 ② upload_info/exercise_attachment STS 字段与 1 附件限制 ③ late_submission 形状复核 ④ left_times 递减与 max_retry 语义 ⑤ 提交后官方页回显。
  • P2 core 提交能力(1d):submitYktProblemSubjective()(answer={content,time:"0",oSubject} 深拷贝语义);getExerciseAttachmentUploadInfo();uploadExerciseAttachment()——不引入 ali-oss SDK,用 core http 自签名 OSS PUT(STS SecurityToken 走 x-oss-security-token 头,对象名 UploadDir/Date.now()+文件名,返回去 query 的 file_url),签名细节以 P1b 实测定稿。
  • P3 UI(2-3d):
  • 富文本编辑器选型 Quill 2(内建 加粗/斜体/下划线/前景色/背景色/有序无序列表/代码块/插图, 与官方工具栏近乎一一对应,产物是干净 HTML;公式用自定义按钮,格式按 P1a 结论,渲染端复用 全站 KaTeX)。禁 maximumWords: 20000 同款上限。
  • 三态非空校验(正文/附件/图片任一非空即可交);isuploading 置位期间禁交(附件上传进度条)。
  • 草稿:沿用官方语义 localStorage(键含 userId + 路由),max_retry>1 || ==-1 进详情即清。
  • 官方页入口保留为兜底(永不移除)。
  • P4 护栏(0.5d):payload 映射/草稿语义/HTML 清洗(XSS:本应用编辑器产物与官方回显都走 现有 sandbox iframe 管道)/OSS 签名单测;tools/ 既有 161+148+86 回归。

31.3 风险登记

风险 缓解
写端点形状未实测,盲写可能产生脏提交 P1b 前不写任何提交代码;只用不计分作业实测
OSS 自签名与 ali-oss 行为差异 P1b 用真凭据验签名;不行再评估引入 SDK(兜底)
公式 HTML 格式不兼容官方渲染端 P1a 离线逆向定格式;实测时官方页回显核对
富文本 XSS 产物与回显统一走 sandbox iframe 渲染管道,不入主文档
late_submission 对象/时间戳口径 P0 只补字段不下结论,P1b 复核后定稿

31.4 待办顺序

P0 → P1a(并行可做)→ 霖扫码 → P1b → P2 → P3 → P4。

31.5 P1a 离线侦查纪要(2026-09-21)

方法:纯本地静态逆向 .ykt-recon/(grep 关键词 + Python 单行文件上下文提取),零网络请求、 零写端点、未动 docs 以外任何文件。bundle 为无换行单行 JS,下列偏移均为「文件内字符偏移(约数)」。 三问结论先行:

  1. 公式:官方 kityformula 插入编辑器的产物是 <img class="kfformula" src="data:image/png;base64,…" data-latex="<LaTeX 源码>" data-display="inline|block">(函数名 changeBase64ToLatex + 全部渲染端 只读 data-latex/data-display 两个属性,src 不参与数学渲染);官方结果页把它替换成 <span class="kfformula-latex"> 包 [mathjax]…[/mathjax] / [mathjaxinline]…[/mathjaxinline] 标记后交给 MathJax(主站回显 = MathJax 3 CDN tex-chtml.js/tex-svg.js,旧页/教师端 = MathJax 2 Hub Typeset)。本应用自研编辑器的公式插入必须产出同款 img.kfformula 标签, 不得直接提交 $…$ 或 [mathjax] 裸标记(旧渲染管线不认)。
  2. 插图通道:UEditor 正文插图(粘贴/拖拽/上传)不走 /pc/upload_info/exercise_attachment/, 而是走 阿里云 OSS 表单直传 + OSS callback:GET /c27/online_courseware/service/get_aliyun_oss_token/?upload_type=ue → POST token.host(OSSAccessKeyId/policy/Signature/key/callback/file)→ callback 返回 {errcode, data:{file_url}} → <img src> 里放的就是 callback 返回的 data.file_url 原文 (imageUrlPrefix 为空串)。附件才走 exercise_attachment STS(新版)/七牛 cms-attachment (旧卡片)//oplat/ueditor/ue(仅光彩班)。
  3. 清洗:官方编辑器侧 getContent() 过两道规则(filterGetContentRules:剥 script+iframe +全部 on* 属性;defaultfilter 输出规则:空内联标签删除、div→p),产物自动包一层 <div class="custom_ueditor_cn_body">…</div>;官方渲染侧不做标签白名单(innerHTML 直接写入 + 属性级清理:剥全部 on*、公式图替换、杂散 <> 转义时仅豁免 span|font|b|strong|i|em|sub|sup|u|s|br|img|code|p|div)。 安全标签集合见下文清单。

31.5.1 公式:插入产物与官方渲染链

  • 插入侧:UEditor 插件 UE.registerUI("kityformula", …) 挂一个 783×386 的对话框 iframe, 加载静态资源 UEDITOR_HOME_URL + "third-party/kityformula/addKityFormula.html"(该 HTML 是静态文件, 不在 JS bundle 缓存内,编辑器内部代码不可见——但产物形态可由渲染端唯一反推)。证据: chunks/50850.f02e1871.js @~0(module 83562)、chunks/73676.c67845ad.js @70567(module 98602)、 chunks/79829.b0f120a6.js @~100(三份 UEditor 库拷贝各注册一次)。
  • 产物形态(由渲染端唯一反推):所有渲染端一律 querySelectorAll("img.kfformula") 后只取 data-latex(LaTeX 源)、data-display("block"→块级公式,其余→行内)、style 三样,src 完全 不读;方法名 changeBase64ToLatex 表明编辑器内 src 是 canvas 转 base64 PNG。即提交进 _answer.content 的公式形态为: <img class="kfformula" src="data:image/png;base64,…" data-latex="\frac{a}{b}" data-display="inline">。
  • 官方渲染链(现代管线,pc.43fa16c6.js module 48234 @4169846 起 + module 37968 @4139946 起): changeBase64ToLatex = IB 导出,逻辑:img.kfformula → <span class="kfformula-latex" style="position:relative;display:{data-display};padding:0 10px;{原style}">,内容为 [mathjax]{latex}[/mathjax](block)或 [mathjaxinline]{latex}[/mathjaxinline](行内); MathJax 3 配置直接把这对方括号标记注册为 tex 定界符(inlineMath:[["[mathjaxinline]","[/mathjaxinline]"], ["\\(","\\)"],["$","$"]], displayMath:[["[mathjax]","[/mathjax]"],["\\[","\\]"],["$$","$$"]], skipHtmlTags:["script","noscript","style","textarea","pre","code"]),按需加载 https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js(默认)或 tex-svg.js,调 MathJax.typesetPromise(elements)。
  • 旧管线(chunks/79829.b0f120a6.js @55449/@94814、chunks/61416.28eadda6.js @26335): 同样的 img.kfformula → span 替换,但标记包成 $…$/$$…$$(79829,jQuery + MathJax.Hub.Queue ["Typeset"] = MathJax 2)或 [mathjax]…[/mathjax](61416,经 markdown 渲染器);另做 http://<hash>.ykt.io/<hash> → https://qn-$1.yuketang.cn/$2 的历史 URL 重写(现代管线同样保留)。
  • 与本项目实现的衔接(修订 §31.2 P3):① 编辑器公式按钮产出 <img class="kfformula" data-latex="…" data-display="inline|block" src="…">,src 使用本地渲染的占位(如 KaTeX 转 SVG data URI,官方渲染端不读 src,但官方页再编辑回填时编辑器要显示这个 img);② 官方页回显由官方 自己的 MathJax 管线渲染,本应用页面内预览继续用全站 KaTeX 渲染 data-latex,两者互不干扰; ③ 官方 bundle 里的 KaTeX(chunks/54338.5e3ff90b.js,1074 处命中)是 AI 助教(correcting-assistant 等)用的,作业回显主链路是 MathJax,不可混淆。

31.5.2 插图 / 附件:上传通道对照(bundle 内四条)

通道 端点 用途 产物 URL 形态 证据
UEditor 正文插图(ossServerUrl 补丁) GET /c27/online_courseware/service/get_aliyun_oss_token/?upload_type=ue → POST {token.host}(OSS 表单直传,带 callback 字段) 粘贴/拖拽/上传进正文的 <img> callback 响应 data.file_url 原文(域名由服务端定,bundle 外;前端 imageUrlPrefix="" 原样拼接) pc.43fa16c6.js @4567482(autoupload 补丁:fd.append("OSSAccessKeyId",r.accessid);fd.append("policy",r.policy);fd.append("Signature",r.signature);fd.append("key",o /*r.dir+Date.now()-文件名*/);fd.append("callback",r.callback);…p({state:"SUCCESS",url:r.data.file_url,…}));UEDITOR_CONFIG 同款于 71561/73676
作业附件(新版 STS,module 79854) GET /pc/upload_info/exercise_attachment/?exercise_id&problem_id → data.upload{region(默认oss-cn-beijing),credentials{AccessKeyId,AccessKeySecret,SecurityToken,BucketName,UploadDir},cdn_host} oSubject.attachments.filelist ali-oss multipartUpload(UploadDir/Date.now()-随机-文件名)(partSize ≤20MB→1MB / ≤200MB→2MB / 其余 5MB,并发 2,超时 180s)后 cdn_host + "/" + 对象名(去 query 签名),失败兜底保留签名 URL pc.43fa16c6.js module 79854 @3277643、组件 50116 @3097873;⚠️ §28.11 表 B 记「partSize 512KB,并发 3」与 79854 逐行读数不符(pc.js 里 524288 仅出现在 DES S-box 常量表),以本轮为准
作业附件(旧卡片,七牛) POST /pc/generate_qiniu_token(API.pc.tool.POST_QINIU_TOKEN,body {bucket_name:"cms-attachment",expired_time:3600},token 存 sessionStorage.QINIUtoken)→ POST https://upload.qiniup.com(FormData {token,key}+file) 课件主观题卡(pics 图片 ≤9 张、附件 ≤1、audio) https://qn-scd1.yuketang.cn/<key>,key=subject_file/<Date.now()>/<classroomId>_<文件名> chunks/30217.35d6055a.js @77400(getToken)/ @79471(postData2)/ @78984(tempAttachment.fileUrl="https://qn-scd1.yuketang.cn/"+t.key);共享 mixin pc.43fa16c6.js @4813668({bucket_name:"cms-attachment",actionPath:"https://upload.qiniup.com",qiNiuURL:"https://qn-scd1.yuketang.cn"});qiniu-js SDK 封装 chunks/11343.d02b8f93.js @~35950
光彩班专用(isGc) POST /oplat/ueditor/ue?action=uploadfile&encode=utf-8(FormData {id:"WU_FILE_0",name,type,lastModifiedDate,size,file}) 同一附件组件在 isGc 时的替代适配器 响应 JSON {url} → fileUrl=url pc.43fa16c6.js @3097873(isGc ? guangcaiAdapter : aliOssAdapter)、module 44573 @3283xxx;另有同域 ?action=uploadimage 自定义 XHR(chunks/32873.49fbf2a0.js @~79300 module 8426,用于字幕等组件,与作业正文无关)
  • filelist 条目完整字段(chunks/30217 @78967,三处组件同构):{fileID(=el-upload uid), fileSize(格式化串), fileType(扩展名), _fileSize(原始字节), fileUrl, fileName};上传前 fileUrl:"" 占位,成功后回填。渲染层归一化(module 62941)另认 taskId||fileID||uid||id 作身份、 hasAnno 为批注标记(展示层追加,非提交字段)。oSubject 完整形态: {content, time:"0", pics:[≤9], audios:[], attachments:{filelist:[≤1]}, isuploading}, 其中 time:"0" 是 web 端恒定的字符串默认值(空白答案工厂 S() 产出,pc.43fa16c6.js @2921078、 @3314236;bundle 内从未改写,秒数语义只存在于试卷整卷体系的 time 字段),复刻时保持 "0" 即可。
  • 对提交的影响:正文插图与附件是两条独立通道——§31.2 P2 的 uploadExerciseAttachment() 继续走 exercise_attachment STS 即可覆盖附件;若 P3 编辑器要支持正文插图,需补一条 get_aliyun_oss_token ?upload_type=ue 的实现(P1b 用真凭据验证 callback 返回的 file_url 具体形态),或首版先不做 正文插图(官方工具栏的插图按钮产出同样可后补)。

31.5.3 清洗规则与安全标签集合

编辑器侧(本应用提交的 content 会经历) - getContent()(pc.43fa16c6.js @4330400):body.innerHTML → UE.filterNode(l, filterGetContentRules) → filterOutputRule(l) → toHtml() → 无 "custom_ueditor_cn_body" 包裹时自动包 <div class="custom_ueditor_cn_body">…</div>。 - filterGetContentRules = {"-":"script iframe","*":{$:{"on*":!1}}}:剥 script/iframe 标签 + 所有元素的全部 on* 事件属性(UEDITOR_CONFIG 尾部,pc.43fa16c6.js @4667387)。 - filterRules(粘贴/inserthtml 输入方向)更严:{"-":"script style object iframe embed link meta base", "*":{$:{"on*":!1}}}。 - defaultfilter 输出规则(chunks/73676 @71049):autoClearEmptyNode 删掉无属性的内联空标签; allowDivTransToP:!0 把裸 div 转 p;p[align] → style="text-align:…"。 - 编辑器内字符数上限 maximumWords:20000 按 getContentLength(true)(纯文本)计(@4649677)。

官方渲染侧(回显本应用的 content) - 不做标签白名单、无 DOMPurify(DOMPurify 仅存在于 html2canvas/snapdom 类截图库与 AI 助教 chunk): Vue domProps:{innerHTML} 直接写入(pc.43fa16c6.js @3109863、@3169653 等),写入前过 module 48234 的 x()(IB)管线:① ykt.io→qn-*.yuketang.cn 重写;② 公式定界符扫描时对 杂散 </> 做 HTML 转义,仅豁免 span|font|b|strong|i|em|sub|sup|u|s|br|img|code|p|div 的标签 字面(module 48234 @4169960 附近正则);③ img.kfformula → [mathjax] span;④ 遍历全部元素剥 on* 属性;⑤ MathJax typeset(skipHtmlTags 含 pre/code/style/textarea)。 - 纯文本预览支线(YO/w @4172836,供 AI/摘要用):非 kfformula 的 <img>→[图片]、 <video>→[视频]、<audio>→[音频]、表格标签→空格——即官方还有「把图当文本占位」的下游, 正文图丢失不影响核心功能,但有损呈现效果。 - 教师端批改/AI 助教另走各自渲染(KaTeX),但同源回显主链如上。

安全标签集合(本应用编辑器产物必须落在其中) - 包裹:<div class="custom_ueditor_cn_body">…</div>(对齐官方 getContent 产物)。 - 块级:p(可带 style="text-align:left|center|right|justify")、pre class="brush:<lang>;toolbar:false" (insertcode 的产物形态,bundle 内 brush:'+n+';toolbar:false 拼串可证)、br。 - 行内:b、i、u、s(/strike)、span style="color:#hex" / span style="background-color:#hex"。 - 列表:ol、ul、li。 - 媒体:img——公式图必须是 img.kfformula[data-latex][data-display];正文插图 src 为 OSS callback 的 file_url。 - 禁止出现:script、iframe(getContent 硬剥)、任何 on* 属性(两侧都剥)、裸 div (会被 outputRule 转 p,本应用直接产 p)、空的内联无属性标签(会被删)、style/link/meta/ object/embed/base 标签(输入方向过滤规则不予放行)。a 链接官方学生端工具栏未开放(仅教师端 79829 配置有 link),首版不做。

31.5.4 对 §31.3 风险表的影响

风险 影响
公式 HTML 格式不兼容官方渲染端 消除:格式已定格为 img.kfformula[data-latex][data-display](31.5.1),渲染端两代管线均只认这两个属性;P1b 真机回显仅做最终确认
OSS 自签名与 ali-oss 行为差异 缩小:STS 字段、multipartUpload 参数(partSize 1/2/5MB、并发 2)、对象名规则、cdn_host 拼 URL 均已从 79854 逐行拿到;仍在:x-oss-security-token 头 vs SDK 内部行为、callback 型 get_aliyun_oss_token?upload_type=ue 返回的 file_url 具体域名/形态需 P1b 实测
富文本 XSS 缩小:官方两道过滤规则 + 渲染侧 on* 剥离已明(31.5.3),我方产物落安全标签集合即双保险;我方渲染仍走 sandbox iframe 管线不变
写端点形状未实测 不变(P1b 事项)
late_submission 口径 不变(P0/P1b 事项)

遗留给 P1b 的验证点:① get_aliyun_oss_token?upload_type=ue 响应与 callback file_url 形态; ② addKityFormula.html 静态资源实抓(不在 JS bundle 内,仅能反推);③ 官方页提交一个 img.kfformula 公式后回显核对(含再编辑回填);④ exercise_attachment STS 实发 PUT 一次对齐签名头。

31.6 P1b 真机实测纪要(2026-09-21,写端点已实发)

实测载体:线性代数-4(旁听课)第一周作业 题1(problem_id 2973357,exercise_id 419219, classroom_id 3201641),主观题 10 分,count=3(剩 3 次),不计入霖的成绩(旁听)。 提交内容为真实解题过程(u+v+w=0 → 线性相关 → 共面),非垃圾数据。

实测结果(全部命中预期): - POST /mooc-api/v1/lms/exercise/problem_apply/ HTTP 200,无 errcode 包裹(成功响应直接 是 data 层字段:count/exercise_is_show_answer/is_show_answer/is_show_explain/my_answer/ my_count/my_score/submit_time)。 - 状态迁移:answer_count 0→1、题级 my_count 0→1(left 3→2 递减确认)、submit_time 出现、user.status=3(已提交未批改)、my_score=-1.00(占位符)。 - 回读 my_answer.content 与提交逐字一致;attachment 为 null(未附)。 - payload 形状与 §28.11 表 A 完全一致:{classroom_id:int, problem_id, answer:{content, time:"0", oSubject:{attachments:{filelist:[]}}}}。

⚠️ 新发现:CSRF 双提交(28.11 表 B 未记录,P2 必须实现): - 写端点必须带 X-CSRFToken 头,否则 403 CSRF Failed: CSRF cookie not set(Django DRF)。 - 校验是双提交:header 值 == cookie csrftoken 值即可,不要求服务端签发——实测 自签随机 token(cookie+header 同值)直接通过。 - 登录接口不下发 csrftoken(YKT_FIXED_COOKIES 也没有);霖手动从浏览器补了真 token (已并入凭据)。P2 实现:凭据无 csrftoken 时自签生成并持久化,双写 cookie+header。 - 官方 SPA 取值路径:X-CSRFToken: getCookie("csrftoken")(pc.js 内联提交器,证据 .ykt-recon/pc.43fa16c6.js)。

霖产品约束(写进 P3 UI 需求):禁止把解题图片放进附件——各科老师多次强调 AI 判卷 读不到附件内容;用户操作习惯是「图片直接拖进输入框」(= UEditor 正文内联插图通道,走 §31.5-② 的 get_aliyun_oss_token 表单直传)。附件通道降级为兜底并加提示文案。

加密字体读题实测(一并):题干加密段 PIL+exam_font 渲染 → PaddleOCR 整段识别读出 「假字」(高置信度)——证实官方字体渲染层还有 GSUB 字形替换(PIL 不跑 shaping), 纯离线自动解密不可行;霖人眼读题是最可靠通道(OneTHU 应用内已有 @font-face 渲染方案, 用户可直接读,无需解密)。

P1b 遗留验证点(并入 P2/P3 实现时同步验证):① get_aliyun_oss_token 表单直传 + callback file_url(插图通道);② exercise_attachment STS PUT 签名头(附件兜底通道); ③ img.kfformula 公式提交后官方回显核对。

31.6.1 插图通道真机实测补记(2026-09-21 17:30,带图重做提交成功)

实测形态(对 §31.5-② 的修正与补全,P2 实现以此为准): - token 包裹:{msg, data:{token:{accessid, bucket, callback(base64 原样), dir(带尾/), expire, host, policy, signature}}}——token 在 data.token,bucket=thu-oplat,host=https://thu-oplat.oss-cn-beijing.aliyuncs.com,dir=ue/<日期>/<user_id>/。 - multipart 字段顺序:OSSAccessKeyId, policy, Signature, key(=dir+Date.now()-文件名), callback, success_action_status=200,file 最后(image/png);请求带 Origin/Referer: https://pro.yuketang.cn/。 - callback 回执:HTTP 200 响应体即回执 {"msg":"","data":{"filename","file_url","error_message":"","size"},"success":true};file_url 为 xuetangx.com 域(https://thu-oplat.xuetangx.com/ue/...,公开可读 HTTP 200,非 OSS host 域)——与 §31.5 预测「域名由服务端定」一致。 - 提交:content 用 <div class="custom_ueditor_cn_body"> 包裹 + <p><img src="file_url" style="max-width:100%;"/></p> 内联;problem_apply 接受,回读一致;重做提交语义实测:my_count 1→2(left 3→1)、submit_time 刷新、旧答案被整体替换。附件 filelist 保持空(霖约束:解题图片禁入附件,AI 判卷读不到)。

至此后插图(正文内联)+ 提交(problem_apply)全链路真机验证完毕,P1b 如实收口:余下仅 img.kfformula 公式回显核对(P3 编辑器实现公式按钮时一并处理)与 exercise_attachment STS PUT(附件兜底通道,降级项)。

32. ⛔ 学术红线:只做「便捷提交接口」,绝不做 AI 代写代交(霖钦定,2026-09-21)

背景:自动代写/代交作业最高可能被判学术不端。R20-C2 及后续一切作业能力,边界如下:

做的(用户亲笔,应用仅执行机械动作): - 用户自己作答:打字、贴图、插公式、排版预览、草稿; - 提交动作必须且只能是用户显式点击「提交」按钮,一次一题,无任何自动化触发; - 应用承担的仅是三个机械动作:内容排版渲染、图片上传、把用户已确认的内容 POST 出去。

不做的(越线即终止): 1. AI(内置 OH 助手 / 任何 agent / 任何 LLM 工具链)生成、改写、润色、补全作业答案内容; 2. AI 触发提交、自动提交、批量提交、定时提交、失败自动重交; 3. 「一键完成作业」「帮我做这道题」类入口(哪怕只是个按钮壳); 4. 从任何非用户输入源(题库/网络/他人答案)抓取答案内容填入作答框。

工程护栏(可测试,非声明性表述): - submitYktProblemSubjective 等提交 API 不得注册进插件宿主(onethu.call 门面)的 可调用工具清单——LLM 工具循环永远摸不到提交能力;护栏测试断言插件工具清单不含 任何 submit* / problem_apply 字样; - UI 层提交按钮点击 → 必须经用户确认对话框(展示剩余次数),无任何代码路径可绕过 用户点击直达提交(表单回车/定时器/事件代理一律不得触发); - 编辑器不提供任何「生成/补全/续写」型 AI 按钮;OH 助手面板与作答编辑器互不引用。

与既有红线的位次:与「不碰 Cookie/凭据」「机器本地路径不入库」同级,任何需求冲突时 本条优先。P3 UI 实现与验收以此节为最高准绳。

32.1 纪律补条 + P3 需求增补(2026-09-21 晚,霖指示)

写端点测试审批门槛:任何针对真实雨课堂写端点(problem_apply / OSS 上传 / 一切 POST)的 测试,必须事先获得霖的明确批准,任何代理/会话不得自行做主。P1b 已收口并经霖官方页 认证,后续实现只做离线 mock 验证。(AI 判卷读正文图片一节无需再实测——霖此前已多次验证; AI 判卷非即时,通常由老师手动触发统一阅卷。)

P3 新增需求:「拍照上传」按钮(霖刚需): - 场景:以往作业流程 = 手机拍照 → 另行传输到电脑 → 再贴进网页编辑器。原生作答 UI 在 移动端可直接省去该步骤:作业编辑器里直接调相机拍解题过程。 - 实现:图片上传区除「粘贴/拖拽/选文件」外,加「拍照上传」——<input type="file" accept="image/*" capture="environment"> 走系统相机;Android WebView 需实测 Tauri v2 的 onShowFileChooser 是否放行相机意图,若不行则在 onethu-mobile Kotlin 插件加 capturePhoto 桥(返回文件路径,前端读取为 bytes 走同一上传管道)。 - 图片预处理(桌面/移动一致):相机照片与超大图等比压到最长边 ≤1600px;照片类 image/jpeg 质量 0.85,截图类保 PNG(文字锐利优先);AI 判卷以读清字迹为准。 - 红线不受影响:拍照上传的仍是用户亲笔写的过程,应用只做「拍摄→压缩→内联上传→ 用户确认提交」,无任何内容生成。

31.7 P3 实现纪要(2026-09-21 晚,主观题原生作答编辑器落地)

新增/改动(提交 R20-C2 P3): - components/exthw/YktSubjectiveEditor.tsx(新):Quill 2 编辑器(react-quill-new 已有依赖), 工具栏对齐官方主观题(粗斜下划线/前后景色/有序无序列表/代码块/插图/公式); 插图四通道(工具栏选图 / 拍照上传 capture=environment / 粘贴 / 拖拽)统一走 官方正文插图通道(core uploadExerciseInlineImage);上传前 yktImagePrep 预处理 (最长边 1600、照片 JPEG 0.85、截图保 PNG、≤400KB 直传);公式按钮 = LaTeX 输入 + KaTeX 实时预览(复用 yktKatex 懒加载分包)→ 编辑器内 SVG 芯片(可读 TeX 源码), 提交序列化为官方 img.kfformula(1px gif src + data-latex,渲染端只读 data-latex)+ custom_ueditor_cn_body 包裹(toSubmitHtml)。 - YktAssignmentDetailPage:主观题(type 5)+ 资格通过 → 逐题「本页作答」面板 (YktAnswerPanel):草稿 localStorage(classroom+题,防抖 600ms,提交成功即清,重进以 服务端 my_answer 回填);提交按钮三态(提交作答 / 提交(剩余 N 次)/ 次数用尽禁用); 确认对话框展示剩余次数与覆盖语义;成功后 setTick 重拉真实状态(禁止乐观更新, 与 R20-C1 同口径);官方页兜底入口保留。 - yktBody.ts:convertKfformulaToTex(在 sanitize 前执行)——我的作答/题干回显 img.kfformula → $…$/$$…$$ 走既有 KaTeX 管线(自提交的公式在沙箱里可见)。 - transport.ts:tauriFetch 补 Uint8Array body 分支(base64 → body_b64 通道;core 手拼 multipart 是合法 BodyInit,浏览器原生 fetch 本就支持,invoke 字符串通道需要这个补丁)。 - state/exthw.ts:submitYktSubjective / uploadYktInlineImage 薄包装(凭据/传输层注入, core 零存储依赖不变);core 侧补四个 P2 类型导出。

护栏(tools/c2-redline-test.mjs,14 断言全绿):插件宿主 + Rust 层无提交 API 字样; 编辑器无 AI 生成类文案;提交必经 confirmOk;无乐观更新;拍照上传在场;图片只走正文 内联通道(无附件 filelist 提交)。ykt-body-test B3 只读红线断言按 C2 修订(渲染层仍零提交 语义;页级提交入口收窄到「onSubmit= 裸处理器/旧文案禁止」,细则归 c2-redline-test)。

测试矩阵:c2-redline 14/0;ykt-body 161/0(原 160+修订1);ykt-submit 99/0; ykt-exercise-detail 134/0;exthw-status 288/0;desktop tsc 0 错误;vite build 通过。

待真机验证(下一轮,需霖配合):① Android WebView onShowFileChooser 是否放行 capture=environment 相机意图(不可行则在 onethu-mobile Kotlin 增加 capturePhoto 桥); ② 编辑器真机提交一次(题目与内容由霖定,写测试仍需霖批准);③ 公式提交后官方页 回显核对(kfformula 回显)。

31.8 P3 UI 首轮体验修订(2026-09-21 晚,霖四条反馈全部落地)

  1. 文案精简:删除编辑器脚注提示、提交行提示、面板头部状态行(状态信息题卡已有 批改徽标,剩余次数并入提交按钮文案)——保留的文本只有:错误提示、上传中状态、 次数用尽提示、按钮本身。
  2. 主题适配:编辑器 CSS 全量令牌化(--bg/--surface/--border/--text-1/2/3/--accent/ --bg-soft/--font-mono)+ Quill snow 硬编码色(stroke #444、picker 白底、placeholder) 逐项重映射到令牌——凝夜等 dark 主题切换令牌即可整块跟随,无需暗色专用覆盖; 公式芯片 SVG 改中性灰半透明(img 无法读取 CSS 变量,取双主题中立色)。
  3. 平台分流上传按钮:桌面 =「📎 从文件上传」(选图);Android 宿主 =「📷 拍照上传」 (capture=environment 直接调用相机)——判定走 isAndroidNavigator 多信号(UA 伪装免疫, 与 androidHost.ts 一致)。
  4. 美化:工具栏/容器 10px 圆角贴卡片风格、hover/active 图标染 --accent、下拉浮层 令牌化 + 圆角 + 柔和投影、公式对话框令牌化 + 输入框聚焦描边 accent、错误条用 --red/--red-soft 语义令牌。

重建部署:exe(18:10 首版 → 本轮 18:2x 修订版)。

31.9 P3 真机 bug 修复(2026-09-21 夜,霖实测两条)

BUG1:插入图片后显示破损图标 - 根因(curl 实测对照,阿里云 OSS Referer 白名单 + 放行空 Referer): | 请求 Referer | 结果 | |---|---| | 无 | 200 | | http://tauri.localhost/(本应用 WebView 来源) | 403 | | https://pro.yuketang.cn/(官方页来源,白名单内) | 200 | - 即:图片上传本身成功(file_url 有效、公开可读),破损发生在渲染环节——WebView 的 tauri.localhost 来源不在 CDN 白名单,被 403。项目既有 yktBody.hardenYktImgs 正是为此存在(给 <img> 补 referrerpolicy="no-referrer" → CDN 见空 Referer 放行), 但只用在只读渲染(题干/我的作答),编辑器插入路径没走。 - 修复三处:① 插入后立即给新 <img> 补 referrerpolicy;② text-change 钩子统一加固 编辑器内所有未加固 <img>(覆盖粘贴/拖拽/受控值重渲染/草稿回填);③ toSubmitHtml 复用 hardenYktImgs,提交态也带 no-referrer(任何渲染方都不再因 Referer 丢图)。

BUG2:输入框点击高亮范围只有一行,明显小于三四行的视觉框 - 根因:Quill 自带 .ql-editor { height: 100% },而百分比高度在只有 min-height 的父级 上不成立 → 编辑器实际高度塌成一行,容器却因 min-height:150px 显示三四行,下方成死区 (点击不聚焦、选区/光标只在首行)。 - 修复:min-height 从 .ql-container 移到 .ql-editor 自身(与应用既有 .rich-editor .ql-editor { min-height: 88px } 同款正确口径),容器高度随内容自适应。

回归护栏:tools/c2-redline-test.mjs 由 14 → 20 断言,新增 6 条钉死这两条 (编辑器 hardenImgs 钩子/插入即补/提交态加固/CSS min-height 落点/容器不承担)。

31.10 R23 杂修批次(霖实测 9 项,2026-09-21)

TUOJ 自动重登失效(过一夜需手动退出重登) - 根因(侦察实录):会话老化时 TUOJ 返回 200 + 登录页 HTML/非 JSON,而失效判据只有 HTTP 401/403 → TuojSessionError;非 JSON 分支抛的是普通 Error,isTuojSessionError 不命中 → 自动重漫游(rerouteTuoj)根本不触发,用户只能手动退出重登。 - 修复两层: 1. packages/core/src/exthw/tuoj.ts:非 JSON 归一为 TuojSessionError(200, …), 让既有 reroute 通道接手(httpStatus 保留、文案给「请重新登录」引导); 2. apps/desktop/src/state/exthw.ts:本会话首次 refreshExtHw 对已配置的 TUOJ CAS 源 主动续期一次(tuojStartupRenewed,force + relaxThrottle)——即「每次打开 OneTHU 自动完成续期」,不再被动等一次失败。尊重「显式退出」抑制;未配置源仍走原首次自动登录。 - 红线:CAS 漫游是只读会话续期,不涉及任何提交(docs §32)。

点旧作业被导向最新作业 - 根因:条目自带 url 为 ${base}/course/{cid}/contest/{tid}(无子路径),TUOJ 官方 SPA 前端路由对裸 contest 路径回落到当前最新 contest;点击链路本身是原样透传,无重取。 - 修复:URL 补 /home 后缀(与用户手工可用地址一致)。

雨课堂部分提交被记为已提交 - 根因:fetchYktStatus 判定为 answer_count > 0 || answered > 0 —— 只交一道题也算整卷已交。 - 修复:有题目明细时按 answered >= problems.length(全部题都有作答)才算已提交; 缺明细(problems 为空)无法逐题核对 → 保留 answer_count > 0 保守判定。 部分作答仍报 submittedCount/totalCount,入口 UI 标注「已完成 x/y 题」,分组归「进行中」。 - 口径依赖 my_answer.content(与 R20-C2 详情页逐题作答同源)。

回归护栏:tools/tuoj-r23-test.mjs(新增 8 断言:URL 带 /home、非 JSON 判会话失效、 启动续期标记在场);tools/exthw-status-test.mjs 288 → 291 断言(部分作答→进行中 + 无明细回落 answer_count 两条新口径)。

31.11 R24 杂修批次(霖实测 3 项,2026-09-22)

点击文件预览整窗白屏(各端;Windows 最明显) - 根因(上游 98f863d 引入的回归):FilePreviewHost 把 PDF 诊断 useEffect 写在 if (!cur) return null 早返回之后 → 点开预览这一次渲染比上一次多一个 Hook, React 直接抛 Rendered more hooks than during the previous render。该异常发生在组件 自身、PreviewErrorBoundary 之内无从兜住 → 整棵应用树卸载 = 整窗白屏。 - 修复:诊断 effect 上移到早返回之前,依赖改为 pdfDiagKey(phase 派生,无条件求值)。 - 护栏:tools/hook-order-test.mjs(新增,全仓 TypeScript AST 判定:组件体内早返回 之后出现 useXxx( 即失败;只看同组件顶层语句,回调内 Hook 不算本口径)+ tools/pdf-render-mode-test.mjs 对 FilePreview 的定点断言。全仓扫描确认这是唯一一处。 - Windows 预览恢复(R24 反转上游 d3bd84d):上游当时把 Windows 预览整体关掉,理由是 「WebView2 点任何预览都白屏」。R24 定位到真因是 Hook 顺序违规——该崩溃在所有平台都会 发生,Windows 只因 WebView2 更敏感而被误判为平台问题。修掉后 Windows 与其他平台一样 尝试渲染;万一仍失败,局部错误边界收在面板内并提示「改用下载 / 另存为」(WIN_PREVIEW_NOTE)。 - 根级错误边界(components/RootErrorBoundary.tsx,挂在 main.tsx 的 App 外): 此前只有 window.error 日志钩子(能记录、不能恢复),一次渲染崩溃就是纯白窗口; 现在变成「可读错误卡片 + 重试 / 重新加载 / 复制错误信息」。 - 护栏:tools/root-boundary-test.mjs;pdf-render-mode-test.mjs 的 Windows 断言同步反转 (不得再出现 winTryPreview 门闸)。

点开过的通知仍是「未读」 - 根因:已读只有服务端 sfyd 一个来源,客户端从不置读;而 beforeViewXs 详情页此前 只在通知有附件时才请求 → 无附件的通知永远置不了读;有附件的也要等下次拉列表才反映, 所以表现为「有时」还是未读。 - 修复两层:① 新增 apps/desktop/src/lib/noticeRead.ts 本地已读覆盖(打开即置读、 跨会话持久化、与服务端 sfyd 取并集,只置读不回退);列表未读分组与详情页 chip 走合并口径; ② 打开详情总是请求 beforeViewXs(服务端侧置读 + 兜底发现附件),无附件声明的通知 抓取失败静默(不打扰)。 - 护栏:tools/notice-read-test.mjs(模块行为 + 列表/详情页接线 + 「不得以有附件为前置」)。

旁听作业(雨课堂 role=6 课堂)单列 - 口径:旁听作业仍属「未完成」,但一律不进正式课程聚合——「全部作业」当前分组下方 单列「旁听作业」一节(不计入页签计数与「全部」总数);首页未交作业总数、课程卡片未交数、 课程页作业栏与计数、首页小组件未交、提醒计划、日程 DDL 入格全部排除旁听。 - 实现:AssignmentsPage 增加 auditAll/auditList(按当前分组同一口径筛选,已忽略组不重复列); 其余六处聚合点逐个点名加 !h.audited。 - 护栏:tools/audited-hw-test.mjs(role=6 透出 + 单列一节 + 七处聚合点清单); tools/hw-ignore-test.mjs 的课程页/分组正则同步(忽略语义未变,R23 起与旁听共用同一张清单)。

31.12 R26 预览批次(霖实测 2 项,2026-09-22)

预览:翻页 → 连续滚动(PDF) - 霖问「预览界面必须要做成翻页吗?有没有直接滚动的可行性」——翻页不是必须的,PDF 预览改为 整篇连续滚动:按总页数铺满页面,滚轮/拖动即翻,滚动时页码跟随(视口上沿判定当前页), 保留 ‹ › 快速跳页、−/+ 缩放与「适应宽度」、系统应用打开。 - 内存策略:一页按面板宽渲染约 1000×1400(≈5MB 位图),几十页全渲染会把 WebView 拖爆, 因此只渲染当前页 ±2 窗口,其余留等比占位(先取本页宽高比占位,滚动条长度稳定), 离开窗口即卸载、滚回来自动重渲染。 - pdf.js 并发渲染竞态(霖实测「第 3 页渲染失败:Cannot use the same canvas during multiple render() operations」):缩放(width 变化)与进出渲染窗口都会重跑渲染效果,同一块 canvas 上 并发 render() 会被 pdf.js 直接拒绝。修法:用 taskRef 跟踪在飞的 RenderTask,发起新渲染前 先 cancel() 并 await 其 promise 结束;效果清理时同样取消;RenderingCancelledException 属于预期路径,不弹给用户(重新进入渲染窗口还会清掉上一轮失败提示)。 - 护栏:tools/preview-scroll-test.mjs(滚动容器/窗口渲染/占位比/跳页缩放/同 canvas 串行化; 旧的一次只画一页实现必须已移除)。

pptx:文字大纲 → 真正渲染幻灯片页面 - 霖澄清「没法预览」= 看不到渲染好的幻灯片页面(此前只有标题+要点大纲)。新增 apps/desktop/src/lib/pptxRender.ts(零第三方依赖,自带极简 XML 解析器,Node 单测直接跑): 幻灯片尺寸 sldSz → 逻辑页宽 960(16:9 时 1pt ≈ 1px);形状按 a:xfrm 绝对定位; 没有显式 xfrm 的占位符按 slide→layout→master 继承位置(真实 pptx 的标题/正文位置写在 版式里);run 字号/粗斜/下划线/颜色(srgbClr 与 schemeClr→theme1.xml 主题色);段落 对齐/层级/项目符号;图片 blip r:embed → rels → media → data URL;a:tbl → 真实表格; p:grpSp 的 chOff/chExt 坐标变换;图表/SmartArt/媒体给占位框并计数(页面提示「N 个图表/ 对象未渲染」,不静默丢内容);备注页文本随页显示。 - 不引第三方的原因:npm 上的 pptx 预览库(pptx-preview)依赖 echarts + lodash + uuid、 单版本、无类型无仓库,对安装包体积与维护都是负担。 - 回退链(逐级,绝不白屏):渲染模型 → 文字大纲(pptx-outline,明说「已回退为文字大纲」) → 内部文件列表 + 下载。 - 旧版格式明确提示:.ppt/.doc/.xls/.dps/.wps/.et(OLE 复合文档,应用内无法渲染)不再显示 笼统的「该格式暂不支持在线预览」,改为「旧版 .ppt 不支持应用内预览,请下载查看(另存为 .pptx 可预览)」。 - 保真边界(明确告知,不假装完整还原):不还原字体(忽略 run 的 latin/ea typeface,用系统 字体)、渐变/图案填充退化为纯色、动画与切换忽略、图表/SmartArt/媒体只给占位框、EMF/WMF 图片 不支持(占位)、单张图片超 8MB 或全部图片累计超 32MB 后不再解码(占位标签「图片过大未渲染」)。 - 真实样本验证(/mnt/d/Downloads/清华大学2025年度演示文稿系列模板5-科技主题.pptx,81MB/32 页): 解析 420ms;444 文本 / 53 图片 / 57 图表类占位,无空白页、无越界几何;28 条零高度连接线 按真实细线渲染(不占「未渲染」计数);图片 data URL 合计由 79.6MB 压到 17.1MB(预算生效), 最大单张 6.31MB;页码字段 slidenum 渲染为真实页码而不是 ‹#›。 - 护栏:tools/pptx-render-test.mjs(Node 内自建最小 pptx:版式继承几何/字号/颜色/项目符号/ 图片/表格/图表占位/备注/主题色/连接线/页码字段,43 断言);tools/preview-scroll-test.mjs 另钉接线与「不得引入 pptx-preview/echarts/jszip/lodash/uuid」。