feat: extend optional HTML feedback to graduate imports in 0.7.8

This commit is contained in:
Codex
2026-10-24 15:15:29 +08:00
parent 9a198fe466
commit a946aa6ec0
24 changed files with 513 additions and 36 deletions
+2 -2
View File
@@ -1,10 +1,10 @@
# TPSchedule
当前Android版本0.7.6+24:新增启动和手动检查更新、更新说明弹窗与浏览器下载,附带app.glowtps.cn/update独立发布后台。详见[更新功能记录](docs/tpschedule-0.7.6-updates.md)及[1Panel/雷池部署说明](server/updates/README.md)。
当前Android版本0.7.8+26:本科和研究生课表/考试均可选附带HTML反馈,保留原签名,个人页展示蜀ICP备2025169276号-5A。详见[0.7.8验证记录](docs/tpschedule-0.7.8-validation.md)及[反馈服务更新说明](server/feedback/html-diagnostic-update.md)。0.7.6起支持启动/手动检查更新与浏览器下载,附带[独立发布后台](server/updates/README.md)。
0.7.0补充:考试页查询类型与卡片保持16像素间距;反馈问题和建议可自选附带最近10分钟的登录/读取/提醒诊断日志。默认不发送,不含密码、Cookie或课表内容,单独作为邮件文本附件。服务端需按[日志附件更新说明](server/feedback/diagnostic-log-update.md)手动更新;真实学校登录及SMTP未由本次离线测试代替验证。玻璃取样偏移仍在调查,未纳入本次修复结论。
学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.6+24,Android 7.0 及以上。
学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.8+26,Android 7.0 及以上。
使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
+1 -1
View File
@@ -1,6 +1,6 @@
# Android 首版验证记录
当前版本为0.7.6+24,应用更新检查与后台说明见[0.7.6记录](tpschedule-0.7.6-updates.md)。本科考试兼容和HTML反馈的验证见[0.7.5记录](tpschedule-0.7.5-validation.md)。两例真实课表缺失字段问题待新页面定位。本科接入记录见[0.7.0记录](tpschedule-0.7.0-validation.md)。下文为首版历史记录。
当前版本为0.7.8+26,本科/研究生HTML反馈及签名、备案信息见[0.7.8记录](tpschedule-0.7.8-validation.md)。应用更新检查与后台说明见[0.7.6记录](tpschedule-0.7.6-updates.md)。本科考试兼容的验证见[0.7.5记录](tpschedule-0.7.5-validation.md)。两例真实课表缺失字段问题待新页面定位。本科接入记录见[0.7.0记录](tpschedule-0.7.0-validation.md)。下文为首版历史记录。
日期:2026-10-13。测试环境:Flutter 3.47.7 / Dart 3.13.5 / JDK 17;Android 16 API 36,Pixel 7 模拟器,1080×2400。首版版本 0.1.0+1,包 ID com.example.hepan_everyday,最低 Android 7.0(API 24)。
+1 -1
View File
@@ -1,6 +1,6 @@
# 学校接入状态 · TPSchedule 0.7.5
0.7.5兼容本科考场/座位合并为“考试情况尚未发布”的记录,新增默认关闭的HTML邮件排查附件及具体课表字段错误码。0.7.4两例课表失败仍需实际页面才能定位,不宣称已解决,见[0.7.5记录](tpschedule-0.7.5-validation.md)。反馈服务器需同步更新,代理请求体至少24MiB。
0.7.8将可选HTML反馈扩展到本科和研究生课表/考试,研究生自动和手动读取在解析前保留安全片段,见[0.7.8记录](tpschedule-0.7.8-validation.md)。反馈服务器需同步更新研究生结构白名单,代理请求体至少24MiB。0.7.5的本科考场未发布兼容、具体课表字段错误码继续保留;0.7.4两例课表失败仍需实际页面定位,不宣称已解决。
0.7.4将本科重复登录的准确学校HTTP继续链接升级为HTTPS,并解决完整页恢复后History API改变实际文档地址、原生历史地址未同步导致的“页面证据无效”。现按实际文档地址抓取且保留同源及精确校验,见[0.7.4记录](tpschedule-0.7.4-validation.md)。
@@ -0,0 +1,49 @@
# 全学校HTML反馈 Implementation Plan
> **For agentic workers:** Use superpowers:executing-plans to implement this plan inline.
**Goal:** 本科和研究生均可将最近一次课表/考试页面作为可选反馈邮件附件。
**Architecture:** 共用SchoolPageDiagnostic,按学校收集已识别的表格与学期证据。扩展反馈服务的惰性HTML白名单,沿用原multipart和SMTP通道。
**Tech Stack:** Flutter/Dart、html、Android、Python/FastAPI。
**Spec:** ../specs/2026-10-24-all-school-html-feedback-design.md
## Global Constraints
- 0.7.8+26,com.tpschedule.app,原签名不变;备案号蜀ICP备2025169276号-5A。
- 默认关闭,日志+HTML独立同意,2MiB,10分钟内存留存。
- 不上传原始学生资料到仓库,不修改软著0.5.4备份。
## Review Focus
- 失败页面仍可排查,清洗后能重现原解析结果。
- 过期/学校切换/重认证/退出不能附带旧页面,离开导入页可反馈。
- 异步旧页面不能覆盖新页面证据。
- 服务端拒绝新的select/option标签中夹带事件、地址和无关字段。
- 版本与证书、公钥、备案信息和交付包一致。
### Task 1: 缓存、采集与反馈界面
Files: mobile/lib/services/school_page_diagnostic.dart、mobile/lib/features/school/school_session.dart、mobile/lib/features/feedback/feedback_page.dart;对应services、school_session_flow和feedback测试。
- [x] 添加并运行失败测试:研究生课表/考试清洗可重读、失败及HTTP可信页、认证剔除、缓存清理与研究生反馈提交。
- [x] 扩展受限片段采集,在研究生解析前留存;更新选项文案。
- [x] 运行针对性测试并核对旧本科行为。
### Task 2: 服务端结构兼容
Files: server/feedback/feedback_service.py、tests/test_feedback.py、tests/fixtures;部署说明。
- [x] 添加并运行失败测试:真实客户端生成的合成研究生附件可发送,活动内容与其他控件拒绝。
- [x] 按准确ID和学期选项扩展白名单;保留旧客户端兼容。
- [x] 运行反馈与更新服务完整回归。
### Task 3: 发布0.7.8
Files: mobile/pubspec.yaml、mobile/lib/app/app_version.dart、mobile/lib/features/profile/profile_page.dart、docs及dist交付。
- [x] 设置0.7.8+26及备案号,完整Flutter测试和静态分析通过。
- [x] 构建正式APK,核对包名、版本、签名MD5、原证书和用户指定RSA公钥。
- [ ] 输出同签名APK、源码、反馈服务更新包和部署说明;核对密钥、旧APK及18项冻结软著文件。
@@ -0,0 +1,15 @@
# 0.7.8:本科、研究生导入页面HTML反馈
用户要求反馈问题时两类学生均可选附带最近一次导入页面HTML;版本定为0.7.8,保留原签名,并展示备案号蜀ICP备2025169276号-5A。沿用现有反馈附件和邮件协议,不另建上传通道。
## 实现范围
- 使用现有共享SchoolPageDiagnostic内存缓存扩展研究生页面;研究生自动/手动读取课表、自动读取考试均在解析前留存已验证的页面片段,失败也可反馈。
- 本科保持原流程。研究生只接受学校现有导航策略中的精确域名、端口和pyxx路径。登录页、密码字段和认证材料不进入附件。
- 保留研究生课表/考试表格的受限ID、节次单元格ID、对应学期选择框;保留解析失败的内容以便排查。去除活动脚本、事件、表单和网络资源。服务端按同样的最小结构白名单校验。
- 选项改为“附带最近一次学校导入页面HTML”,说明适用于本科/研究生;默认关闭,需勾选日志并同意个人信息提示。HTML独立附件、上限2MiB、有效期10分钟,成功发送后清除。切换学校/重新认证/开始新查询/退出清除,离开导入页前往反馈不清除。
- APP版本0.7.8+26,包名com.tpschedule.app不变;证书MD5必须为78DE9119AD14727FA1C5E5E4E68145EE,RSA公钥与用户给出的十进制值核对。原密钥和0.5.4软著冻结文件不修改。
## 方案选择
共用现有HTML缓存和multipart字段diagnostic_html,扩展安全结构即可。另建研究生上传协议会重复客户端/服务端处理;把原始整页HTML加入普通日志会破坏既有隐私边界,因此均不采用。用户已明确要求直接修改,无额外审批步骤。
+25
View File
@@ -0,0 +1,25 @@
# TPSchedule 0.7.8+26:本科、研究生HTML反馈
2026-10-24,F:/program/app。用户要求两类学生均可选择附带导入HTML,并指定版本0.7.8、原证书MD5、公钥及备案号。本记录不包含学生原始网页、真实反馈内容或认证资料。
## 功能
反馈问题页面选项改为“附带最近一次学校导入页面HTML”,明确支持本科/研究生课表及考试;沿用默认关闭、日志联动和个人信息提示。共享内存缓存2MiB、10分钟,成功发送后清除;退出、重新认证、新查询清除,离开导入页转去反馈保留。邮件仍附TPSchedule-school-page.html,不占媒体名额,失败重试保持原编号与内容。
研究生在自动课表、手动读取和考试读取的解析前留存已验证的学校表格片段。清洗后保留tbl/sample-table-1、节次ID、drpxq/xqbh学期选择证据;剔除登录页面、无关账户区域、密码字段、网络资源、认证参数和活动脚本。允许原导航策略中学校精确域名/默认端口的HTTP研究生框架,排除其他域名、端口与userinfo。HTML并非整页逐字备份,缺少可识别表格时不会附带登录页。
反馈服务器扩展最小HTML白名单,保持本科协议和旧客户端兼容,研究生附件仍受同一大小、活动内容和幂等校验。Dockerfile明确只读程序权限,避免上传包权限影响普通用户。部署须更新原feedback服务的feedback_service.py与Dockerfile;更新管理后台updates无需重新配置。详见server/feedback/html-diagnostic-update.md。
## 回归
新增研究生课表/考试重解析、错误字段保留、HTTP框架、认证/事件剔除、期限、会话缓存生命周期、反馈勾选提交,以及客户端真实生成的合成HTML到服务端邮件附件的契约验证。
独立审查发现原生currentUrl等待期间注销导致旧片段重填缓存的问题,自动和手动两条路径均已先复现失败,再移至await返回后校验会话/generation;手动解析返回后再次校验。回归测试覆盖该等待窗口,审查复核通过。
最终Flutter完整套件302项通过,静态分析无问题;反馈与更新服务95项测试通过。正式APK构建成功;Android16/API36模拟器覆盖安装成功,冷启动Status ok、TotalTime2530ms,进程运行且未见AndroidRuntime致命错误。检查了首页、反馈选项和“我的”底部,0.7.8及备案号显示正确。模拟器沿用软件渲染启动参数;真机默认渲染方式未改。
## 版本及署名
应用TPSchedule,包名com.tpschedule.app,版本0.7.8、构建号26。“我的”展示蜀ICP备2025169276号-5A。apksigner验证成品APK签名有效,证书MD5为78DE9119AD14727FA1C5E5E4E68145EE;从新APK提取的证书与0.7.6逐字节相同,RSA公钥十进制模数与用户提供值完全一致。公开签名报告增加模数与指数,便于核对;未改私钥。软著0.5.4冻结材料和旧版安装包保持。
未登录生产服务器、未变更生产配置、未发送实际邮件;真实学校登录和邮件接收由用户安装更新并同步部署反馈服务后复测。
+3 -2
View File
@@ -1,2 +1,3 @@
const appVersion = '0.7.6';
const appBuildNumber = 24;
const appVersion = '0.7.8';
const appBuildNumber = 26;
const appFilingNumber = '蜀ICP备2025169276号-5A';
@@ -120,7 +120,7 @@ class _FeedbackPageState extends State<FeedbackPage> {
_includeHtml &&
html == null) {
throw const FeedbackException(
'最近10分钟没有可用的本科导入页面。请返回学校页面再尝试导入,然后提交反馈;也可以取消附带HTML。',
'最近10分钟没有可用的学校导入页面。请返回本科或研究生学校页面再尝试导入,然后提交反馈;也可以取消附带HTML。',
);
}
_pendingSubmission ??= _kind == FeedbackKind.problem
@@ -304,9 +304,9 @@ class _FeedbackPageState extends State<FeedbackPage> {
CheckboxListTile(
key: const Key('feedback-html'),
contentPadding: EdgeInsets.zero,
title: const Text('附带最近一次本科导入页面HTML'),
title: const Text('附带最近一次学校导入页面HTML'),
subtitle: const Text(
'需先勾选日志。网页可能含姓名、学号、课程、教师和考场信息,仅保留最近10分钟,单独作为邮件附件发送。',
'适用于本科和研究生课表、考试导入。需先勾选日志。网页可能含姓名、学号、课程、教师和考场信息,仅保留最近10分钟,单独作为邮件附件发送。',
),
value: _includeHtml,
onChanged: _submitting || !_includeLogs
@@ -281,6 +281,14 @@ class ProfilePage extends StatelessWidget {
),
),
const SizedBox(height: 6),
Text(
appFilingNumber,
style: TextStyle(
fontSize: 11,
color: ink.withValues(alpha: 0.45),
),
),
const SizedBox(height: 6),
Text(
'今天也有值得期待的小事。',
style: TextStyle(
+17 -5
View File
@@ -7,6 +7,7 @@ import '../../data/json_schedule_import.dart';
import '../../domain/exam_schedule.dart';
import '../../domain/school_term.dart';
import '../../services/diagnostic_log.dart';
import '../../services/school_page_diagnostic.dart';
import 'school_navigation_policy.dart';
import 'school_page_adapter.dart';
import 'school_exam_adapter.dart';
@@ -257,6 +258,7 @@ class SchoolSession extends ChangeNotifier {
final closing = _logoutFuture;
if (closing != null) await closing;
if (serial != _openSerial) return;
SchoolPageDiagnostic.instance.clear();
_timeout?.cancel();
_poll?.cancel();
_manualRead = false;
@@ -303,6 +305,7 @@ class SchoolSession extends ChangeNotifier {
return;
}
final serial = ++_openSerial;
SchoolPageDiagnostic.instance.clear();
_poll?.cancel();
_timeout?.cancel();
_pageGeneration++;
@@ -460,9 +463,8 @@ class SchoolSession extends ChangeNotifier {
'term': selectedTerm?.id,
},
);
if (!hasOpened ||
generation != _pageGeneration ||
await web.currentUrl() != url) {
final liveUrl = await web.currentUrl();
if (!hasOpened || generation != _pageGeneration || liveUrl != url) {
return;
}
if (evidence.terms.isNotEmpty) _liveTerms = evidence.terms;
@@ -480,6 +482,7 @@ class SchoolSession extends ChangeNotifier {
}
for (final page in evidence.pages) {
if (page.kind != (requestingExams ? 'exams' : 'timetable')) continue;
SchoolPageDiagnostic.instance.remember(page.html, page.url);
try {
if (requestingExams) {
final draft = SchoolExamAdapter.extract(page.html);
@@ -586,17 +589,23 @@ class SchoolSession extends ChangeNotifier {
throw UnsupportedSchoolLayout();
}
final evidence = await _evidence(uri);
if (generation != _pageGeneration ||
await web.currentUrl() != uri.toString()) {
final liveUrl = await web.currentUrl();
if (!hasOpened ||
generation != _pageGeneration ||
liveUrl != uri.toString()) {
throw UnsupportedSchoolLayout();
}
for (final page in evidence.pages) {
if (page.kind != 'timetable') continue;
SchoolPageDiagnostic.instance.remember(page.html, page.url);
try {
final draft = await _reader.read(
selectedTerm!.timetableUri,
() async => page.html,
);
if (!hasOpened || generation != _pageGeneration) {
throw UnsupportedSchoolLayout();
}
if (draft.schoolTerm != selectedTerm) continue;
return draft;
} on UnsupportedSchoolLayout {
@@ -648,6 +657,7 @@ class SchoolSession extends ChangeNotifier {
}
void invalidateAuthentication() {
SchoolPageDiagnostic.instance.clear();
_openSerial++;
_pageGeneration++;
_reader.invalidate();
@@ -667,6 +677,7 @@ class SchoolSession extends ChangeNotifier {
}
Future<void> _performLogout() async {
SchoolPageDiagnostic.instance.clear();
_openSerial++;
_pageGeneration++;
_reader.invalidate();
@@ -697,6 +708,7 @@ class SchoolSession extends ChangeNotifier {
@override
void dispose() {
SchoolPageDiagnostic.instance.clear();
hasOpened = false;
_pageGeneration++;
_reader.invalidate();
+50 -10
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html;
/// An opt-in mail attachment, separate from persistent structured logs.
@@ -37,11 +38,18 @@ class SchoolPageDiagnostic {
void remember(String content, Uri uri) {
clear();
try {
if (uri.scheme != 'https' ||
uri.host != 'eams.uestc.edu.cn' ||
uri.port != 443 ||
final undergraduate =
uri.scheme == 'https' &&
uri.port == 443 &&
uri.host == 'eams.uestc.edu.cn' &&
uri.path.startsWith('/eams/');
final graduate =
uri.host == 'yjsjy.uestc.edu.cn' &&
(uri.scheme == 'https' && uri.port == 443 ||
uri.scheme == 'http' && uri.port == 80) &&
(uri.path == '/pyxx' || uri.path.startsWith('/pyxx/'));
if ((!undergraduate && !graduate) ||
uri.userInfo.isNotEmpty ||
!uri.path.startsWith('/eams/') ||
utf8.encode(content).length > maxBytes) {
return;
}
@@ -50,12 +58,35 @@ class SchoolPageDiagnostic {
final tables = doc
.querySelectorAll('table')
.where(
(t) =>
t.id == 'manualArrangeCourseTable' ||
t.querySelector('tr')?.text.contains('课程序号') == true,
(t) => graduate
? const ['tbl', 'sample-table-1'].contains(t.id)
: t.id == 'manualArrangeCourseTable' ||
t.querySelector('tr')?.text.contains('课程序号') == true,
)
.toList();
if (tables.isEmpty) return;
final graduateTerms = <dom.Element>[];
if (graduate) {
for (final table in tables) {
final id = table.id == 'tbl' ? 'drpxq' : 'xqbh';
final original = doc.querySelector('select#$id');
if (original == null) continue;
final select = dom.Element.tag('select')..id = id;
for (final originalOption in original.querySelectorAll('option')) {
final option = dom.Element.tag('option')
..text = originalOption.text;
final value = originalOption.attributes['value'];
if (value != null && RegExp(r'^\d{1,6}$').hasMatch(value)) {
option.attributes['value'] = value;
}
if (originalOption.attributes.containsKey('selected')) {
option.attributes['selected'] = '';
}
select.children.add(option);
}
graduateTerms.add(select);
}
}
final fields = <String, Set<String>>{};
void field(String key, String value) {
if (const ['semester.id', 'examType.id'].contains(key) &&
@@ -83,7 +114,7 @@ class SchoolPageDiagnostic {
.join('\n');
// Only retain the literal course statements our parser reads. All other
// scripts, URL-bearing attributes and form/account fields are omitted.
final statements = _courseStatements(script);
final statements = undergraduate ? _courseStatements(script) : '';
for (final table in tables) {
for (final node in table.querySelectorAll(
'script,style,link,iframe,object,embed,form,input,textarea,select,button,img,svg,video,audio,source,meta,base',
@@ -93,8 +124,16 @@ class SchoolPageDiagnostic {
for (final node in [table, ...table.querySelectorAll('*')]) {
final attrs = Map<String, String>.from(node.attributes);
node.attributes.clear();
if (attrs['id'] == 'manualArrangeCourseTable') {
node.id = 'manualArrangeCourseTable';
final id = attrs['id'];
if (node.localName == 'table' &&
(id == 'manualArrangeCourseTable' ||
graduate && const ['tbl', 'sample-table-1'].contains(id))) {
node.id = id!;
} else if (graduate &&
const ['td', 'th'].contains(node.localName) &&
id != null &&
RegExp(r'^t[0-7][13579]$').hasMatch(id)) {
node.id = id;
}
for (final key in ['rowspan', 'colspan']) {
final value = attrs[key];
@@ -108,6 +147,7 @@ class SchoolPageDiagnostic {
'$marker<html><head><meta charset="utf-8">'
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; base-uri \'none\'; form-action \'none\'">'
'<title>TPSchedule school diagnostic</title></head><body>'
'${graduateTerms.map((t) => t.outerHtml).join()}'
'${fields.entries.expand((e) => e.value.map((v) => '<input type="hidden" name="${e.key}" value="$v">')).join()}'
'${tables.map((t) => t.outerHtml).join()}'
'<script type="application/x-tpschedule-course-data">${statements.replaceAll('<', r'\u003c')}</script></body></html>';
+1 -1
View File
@@ -1,7 +1,7 @@
name: hepan_everyday
description: TPSchedule 学生课表
publish_to: none
version: 0.7.6+24
version: 0.7.8+26
environment:
sdk: ^3.13.5
dependencies:
+34
View File
@@ -37,6 +37,40 @@ Future<void> reveal(
}
void main() {
testWidgets('graduate HTML can be selected, consented and sent with logs', (
tester,
) async {
final store = SchoolPageDiagnostic.instance;
addTearDown(store.clear);
store.remember(
'<select id="drpxq"><option value="73" selected>2026-2027秋</option></select>'
'<table id="tbl"><tr><td id="t11">研究生课程排查内容</td></tr></table>',
Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/73'),
);
final sender = RecordingSender();
await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender)));
await tester.enterText(
find.byKey(const Key('feedback-message')),
'研究生课表导入失败',
);
await reveal(tester, find.byKey(const Key('feedback-html')));
await tester.tap(find.byKey(const Key('feedback-logs')));
await tester.pump();
await tester.tap(find.byKey(const Key('feedback-html')));
await tester.pumpAndSettle();
await tester.tap(find.text('同意附带'));
await tester.pumpAndSettle();
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
expect(sender.submissions, hasLength(1));
expect(sender.submissions.single.diagnosticLog, isNotNull);
expect(
utf8.decode(sender.submissions.single.diagnosticHtml!),
contains('研究生课程排查内容'),
);
expect(store.export(), isNull);
});
testWidgets('HTML is opt in, confirmed, and stays identical on retry', (
tester,
) async {
@@ -2,6 +2,8 @@ import 'dart:async';
import 'dart:convert';
import 'package:hepan_everyday/services/diagnostic_log.dart';
import 'package:hepan_everyday/services/school_page_diagnostic.dart';
import 'package:hepan_everyday/features/school/school_page_adapter.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart';
@@ -29,6 +31,132 @@ void main() {
platform.delegate.finished(url);
}
test(
'graduate auto read retains HTML after leaving and clears on next query',
() async {
final cache = SchoolPageDiagnostic.instance;
addTearDown(cache.clear);
await session.openTimetable(term: SchoolTerm(73));
platform.web.javascript = (_) async =>
schoolPageEvidence(topUrl: platform.web.url!, html: fixture());
navigate(SchoolSession.timetableUrl.toString());
await flushSchoolEvents();
expect(session.pendingDraft, isNotNull);
expect(cache.export(), isNotNull);
session.setReadingActive(false);
expect(cache.export(), isNotNull);
await session.openExams(term: SchoolTerm(73));
expect(cache.export(), isNull);
},
);
test(
'failed graduate manual parse retains HTML and logout clears it',
() async {
final cache = SchoolPageDiagnostic.instance;
addTearDown(cache.clear);
await session.openTimetable(term: SchoolTerm(73));
platform.web.url = SchoolSession.timetableUrl.toString();
platform.web.javascript = (_) async => schoolPageEvidence(
topUrl: platform.web.url!,
html: fixture().replaceFirst('1-3周', '未知周'),
);
await expectLater(
session.readTimetable(),
throwsA(isA<UnsupportedSchoolLayout>()),
);
expect(cache.export(), isNotNull);
expect(utf8.decode(cache.export()!), contains('未知周'));
session.invalidateAuthentication();
expect(cache.export(), isNull);
},
);
test('graduate exam read retains the recognized exam fragment', () async {
final cache = SchoolPageDiagnostic.instance;
addTearDown(cache.clear);
await session.openExams(term: SchoolTerm(73));
platform.web.javascript = (_) async => schoolPageEvidence(
topUrl: platform.web.url!,
html: examHtml(),
kind: 'exams',
);
navigate(SchoolSession.examUrl.toString());
await flushSchoolEvents();
expect(session.pendingExams, isNotNull);
expect(cache.export(), isNotNull);
expect(
SchoolExamAdapter.extract(utf8.decode(cache.export()!))
.records
.single
.name,
'矩阵理论',
);
await session.logout();
expect(cache.export(), isNull);
});
test(
'stale graduate probe cannot replace the current diagnostic attachment',
() async {
final cache = SchoolPageDiagnostic.instance;
addTearDown(cache.clear);
await session.openTimetable(term: SchoolTerm(73));
final old = Completer<Object>();
platform.web.javascript = (_) => old.future;
const home = 'https://yjsjy.uestc.edu.cn/pyxx/home/index';
navigate(home);
await flushSchoolEvents();
platform.web.javascript = (_) async => schoolPageEvidence(
topUrl: platform.web.url!,
html: fixture().replaceAll('示例楼B212', '当前教室A101'),
);
navigate(SchoolSession.timetableUrl.toString());
await flushSchoolEvents();
final current = cache.export();
expect(current, isNotNull);
old.complete(schoolPageEvidence(topUrl: home, html: fixture()));
await flushSchoolEvents();
expect(cache.export(), current);
expect(utf8.decode(cache.export()!), contains('当前教室A101'));
},
);
for (final manual in [false, true]) {
test(
'logout during final URL check rejects stale ${manual ? 'manual' : 'automatic'} HTML',
() async {
final cache = SchoolPageDiagnostic.instance;
addTearDown(cache.clear);
await session.openTimetable(term: SchoolTerm(73));
final url = SchoolSession.timetableUrl.toString();
platform.web.url = url;
platform.web.javascript = (_) async =>
schoolPageEvidence(topUrl: url, html: fixture());
final waitingUrl = Completer<String?>();
var reads = 0;
platform.web.readCurrentUrl = () async {
reads++;
if (reads == (manual ? 2 : 3)) return waitingUrl.future;
return platform.web.url;
};
final operation = manual
? expectLater(
session.readTimetable(),
throwsA(isA<UnsupportedSchoolLayout>()),
)
: session.readCurrentPage();
await flushSchoolEvents();
expect(reads, manual ? 2 : 3);
session.invalidateAuthentication();
waitingUrl.complete(url);
await operation;
expect(cache.export(), isNull);
expect(session.pendingDraft, isNull);
},
);
}
test('login diagnostic breadcrumbs distinguish probe and network failures without raw details', () async {
await session.openTimetable(term: SchoolTerm(73));
session.setReadingActive(true);
@@ -4,8 +4,118 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/services/school_page_diagnostic.dart';
import 'package:hepan_everyday/features/school/undergraduate/timetable_adapter.dart';
import 'package:hepan_everyday/features/school/school_page_adapter.dart';
import 'package:hepan_everyday/features/school/school_exam_adapter.dart';
import '../school/recognized_timetable_test.dart' show fixture;
import '../school/exam_adapter_test.dart' show examHtml;
void exportContractFixture(String name, List<int> bytes) {
final directory = Platform.environment['TPSCHEDULE_DIAGNOSTIC_EXPORT_DIR'];
if (directory != null) File('$directory/$name').writeAsBytesSync(bytes);
}
void main() {
test(
'graduate timetable preserves term, periods and course parser evidence',
() {
final store = SchoolPageDiagnostic();
addTearDown(store.clear);
final uri = Uri.parse(
'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/73',
);
store.remember(
'''<header>ACCOUNT_SECRET</header>${fixture()}
<input name="ticket" value="AUTH_SECRET"><script>const cookie="AUTH_SECRET";</script>''',
uri,
);
expect(store.export(), isNotNull);
final output = utf8.decode(store.export()!);
exportContractFixture('client-graduate-timetable.html', store.export()!);
final draft = SchoolPageAdapter().extract(output, uri);
expect(draft.schoolTerm!.id, 73);
expect(draft.courses, hasLength(4));
expect(
draft.courses
.singleWhere((c) => c.weekday == 2 && c.startPeriod == 3)
.teacher,
'示例教师乙、示例教师丙',
);
expect(output, isNot(contains('SECRET')));
expect(output, isNot(contains('name="ticket"')));
},
);
test('graduate exams preserve pending arrangements and selected term', () {
final store = SchoolPageDiagnostic();
addTearDown(store.clear);
store.remember(
examHtml(room: '示例考场'),
Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/pygl/kskcxx/index?xq=73'),
);
expect(store.export(), isNotNull);
exportContractFixture('client-graduate-exams.html', store.export()!);
final exam = SchoolExamAdapter.extract(utf8.decode(store.export()!));
expect(exam.termId, '73');
expect(exam.records.single.room, '示例考场');
expect(exam.records.single.time, isEmpty);
});
test(
'graduate HTTP frame and malformed parser fields remain diagnosable',
() {
final store = SchoolPageDiagnostic();
addTearDown(store.clear);
final uri = Uri.parse('http://yjsjy.uestc.edu.cn/pyxx/home/index');
final malformed = fixture().replaceFirst('1-3周', '未知周');
store.remember(malformed, uri);
expect(store.export(), isNotNull);
final output = utf8.decode(store.export()!);
expect(output, contains('未知周'));
expect(
() => SchoolPageAdapter().extract(
output,
Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/73'),
),
throwsA(isA<UnsupportedSchoolLayout>()),
);
for (final address in [
'https://yjsjy.uestc.edu.cn.evil.test/pyxx/home/index',
'https://yjsjy.uestc.edu.cn:444/pyxx/home/index',
'http://yjsjy.uestc.edu.cn:8080/pyxx/home/index',
'https://user@yjsjy.uestc.edu.cn/pyxx/home/index',
'https://yjsjy.uestc.edu.cn/other/index',
]) {
store.remember(fixture(), Uri.parse(address));
expect(store.export(), isNull, reason: address);
}
store.remember('<input type="password">${fixture()}', uri);
expect(store.export(), isNull);
},
);
test('graduate export removes events, URLs and account controls and expires at ten minutes', () {
var now = DateTime.utc(2026, 10, 24);
final store = SchoolPageDiagnostic(clock: () => now);
addTearDown(store.clear);
store.remember(
fixture()
.replaceFirst(
'<select id="drpxq">',
'<select id="drpxq" onchange="AUTH_SECRET()">',
)
.replaceFirst(
'示例教师甲',
'<a href="https://evil.test/?ticket=AUTH_SECRET" onclick="AUTH_SECRET()">示例教师甲</a><input name="cookie" value="AUTH_SECRET">',
),
Uri.parse('https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/73'),
);
expect(store.export(), isNotNull);
final output = utf8.decode(store.export()!);
expect(output, isNot(contains('AUTH_SECRET')));
expect(output, isNot(contains('evil.test')));
expect(output, isNot(contains('onchange')));
expect(output, contains('示例教师甲'));
now = now.add(const Duration(minutes: 10));
expect(store.export(), isNull);
});
final uri = Uri.parse(
'https://eams.uestc.edu.cn/eams/courseTableForStd.action',
);
+2 -1
View File
@@ -47,6 +47,7 @@ class FakeSchoolWeb extends PlatformWebViewController {
String? documentUrl;
Future<Object> Function(String)? javascript;
Future<void> Function(Uri)? request;
Future<String?> Function()? readCurrentUrl;
@override
Future<void> setJavaScriptMode(JavaScriptMode mode) async {}
@override
@@ -61,7 +62,7 @@ class FakeSchoolWeb extends PlatformWebViewController {
}
@override
Future<String?> currentUrl() async => url;
Future<String?> currentUrl() => readCurrentUrl?.call() ?? Future.value(url);
@override
Future<Object> runJavaScriptReturningResult(String script) =>
script == 'location.href'
+5
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.serialization import pkcs7
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography import x509
@@ -109,6 +110,10 @@ def main():
'publicKeyBits': current.public_key().key_size,
'certificateNote': 'Existing Android Debug certificate preserved; store/filing acceptance must be checked.',
}
if isinstance(current.public_key(), rsa.RSAPublicKey):
numbers = current.public_key().public_numbers()
details['publicKeyModulusDecimal'] = str(numbers.n)
details['publicKeyExponent'] = numbers.e
(output / 'TPSchedule-signing-info.json').write_text(
json.dumps(details, ensure_ascii=False, indent=2), encoding='utf-8')
(output / 'TPSchedule-signing-certificate.pem').write_bytes(
+2
View File
@@ -7,6 +7,8 @@ RUN pip install --no-cache-dir -r requirements.txt \
&& useradd --system --uid 10001 --create-home feedback \
&& mkdir /app/data && chown feedback:feedback /app/data
COPY feedback_service.py main.py ./
RUN chmod 0755 /app \
&& chmod 0644 /app/requirements.txt /app/feedback_service.py /app/main.py
USER feedback
EXPOSE 8000
# The host uses localhost port publishing; only that trusted proxy may supply client IP headers.
+1 -1
View File
@@ -17,7 +17,7 @@ Prepared for Ubuntu + 1Panel on `43.138.57.216`. Public endpoint: `https://app.g
| `school` | Must be omitted | Required, nonblank, max 200 characters |
| `attachments` | Optional, max 3 files, 8 MiB each, 20 MiB aggregate | Must be omitted |
| `diagnostics` | Optional single UTF-8 text file, max 256 KiB, independent of media count; starts with `TPSchedule diagnostic log` followed by a newline | Must be omitted |
| `diagnostic_html` | Optional single UTF-8 HTML file, max 2 MiB, independent of media count; starts with `<!doctype html><!-- TPSchedule school diagnostic -->` | Must be omitted |
| `diagnostic_html` | Optional single UTF-8 inert HTML file for undergraduate or graduate imports, max 2 MiB, independent of media count; starts with `<!doctype html><!-- TPSchedule school diagnostic -->` | Must be omitted |
The app defaults diagnostics to off. When selected it snapshots the preceding ten minutes of structured app/login/import/reminder events, at most 500 entries. It excludes credentials, cookies, raw URLs, HTML, timetable contents and feedback text. The server attaches this file as `TPSchedule-diagnostics.txt`; retries must use the identical snapshot and submission ID. The server hashes it with the other attachments for idempotency, and does not log its contents.
+14 -2
View File
@@ -110,7 +110,7 @@ class ApiError(Exception):
class SchoolHtmlValidator(HTMLParser):
"""Accept only the inert diagnostic schema emitted by the app, not web pages."""
tags = set(('html head meta title body input table thead tbody tfoot tr td th caption colgroup col '
'span div p b i strong em u small br hr a font center nobr label s sub sup pre code').split())
'span div p b i strong em u small br hr a font center nobr label s sub sup pre code select option').split())
void = {'meta', 'input', 'br', 'hr', 'col'}
csp = "default-src 'none'; base-uri 'none'; form-action 'none'"
@@ -145,9 +145,21 @@ class SchoolHtmlValidator(HTMLParser):
elif tag == 'script':
if values != {'type': 'application/x-tpschedule-course-data'}:
raise ValueError('script')
elif tag == 'select':
if (self.stack[-1:] != ['body'] or set(values) != {'id'}
or values['id'] not in {'drpxq', 'xqbh'}):
raise ValueError('semester selector')
elif tag == 'option':
if (self.stack[-1:] != ['select'] or set(values) - {'value', 'selected'}
or 'value' in values and not re.fullmatch(r'\d{1,6}', values['value'] or '')
or 'selected' in values and values['selected'] not in {'', 'selected'}):
raise ValueError('semester option')
else:
for key, value in attrs:
if key == 'id' and tag == 'table' and value == 'manualArrangeCourseTable':
if (key == 'id' and tag == 'table'
and value in {'manualArrangeCourseTable', 'tbl', 'sample-table-1'}):
continue
if key == 'id' and tag in {'td', 'th'} and re.fullmatch(r't[0-7][13579]', value or ''):
continue
if key in {'rowspan', 'colspan'} and re.fullmatch(r'\d{1,2}', value or ''):
continue
+7 -7
View File
@@ -1,28 +1,28 @@
# 0.7.5:可选本科导入HTML邮件附件更新
# 0.7.8:本科、研究生可选导入HTML邮件附件
本次更新为你已部署的同一个反馈服务增加 `diagnostic_html` 文件字段。客户端默认关闭,需要学生在“反馈与共建→反馈问题和建议”勾选日志、勾选HTML并同意提示;邮件除日志外另附 `TPSchedule-school-page.html`,不占3个图片/视频名额。
本次更新沿用你已部署的反馈服务及 `diagnostic_html` 文件字段,新增研究生课表/考试结构兼容,本科继续可用。客户端默认关闭,需要学生在“反馈与共建→反馈问题和建议”勾选日志、“附带最近一次学校导入页面HTML”并同意提示;邮件除日志外另附 `TPSchedule-school-page.html`,不占3个图片/视频名额。更新的是Compose服务`feedback`,与独立更新管理后台的`updates`服务不同。
## 你需要在1Panel执行的更新
1. 下载并解压 `TPSchedule-feedback-server.zip`,找到其中 `feedback/feedback_service.py`,覆盖服务器原部署目录中的同名文件,例如 `/opt/app-feedback/feedback_service.py`。不要覆盖服务器 `.env`,保留原 SMTP 密码、`IDEMPOTENCY_SECRET` 和数据库数据卷。
1. 下载并解压 `TPSchedule-feedback-server-0.7.8.zip`,找到其中 `feedback/feedback_service.py` 和 `feedback/Dockerfile`,覆盖服务器原部署目录中的同名文件,例如 `/opt/app-feedback/`。Dockerfile增加普通用户所需的明确读取权限,避免上传/解压后文件为600导致无法启动。不要覆盖服务器 `.env`,保留原 SMTP 密码、`IDEMPOTENCY_SECRET` 和数据库数据卷。
2. 在1Panel的 `app.glowtps.cn` 站点配置中,把原 `client_max_body_size 21m;` 改成 `client_max_body_size 24m;`,验证并重载OpenResty配置。雷池该站点如果有请求体大小限制,也提高到至少24MiB(若单位为字节则25165824)。原域名、证书、80/443→10080→18080转发不变。
3. 在原目录执行:
```sh
cd /opt/app-feedback
docker compose up -d --build feedback
docker compose up -d --build --force-recreate feedback
docker compose ps
curl --fail --max-time 10 http://127.0.0.1:18080/healthz
```
健康检查返回 `{"status":"ok"}` 后再测试APP。不要删除数据卷,不要另建新的Compose项目。
4. 同学安装0.7.5,在学校网页中打开课表并点击一次“导入当前课表”,失败后返回“我的→反馈与共建”。在10分钟内写明错误并勾选日志、HTML,确认后提交。考试读取失败同样保留最后一次捕获的考试表格。到 `tps-david@glowtps.cn` 核对两个附件;此步骤会真实发送邮件,由你操作。
4. 同学覆盖安装0.7.8:本科在学校网页中打开课表并点击一次“导入当前课表”;研究生在APP中选学期后等待自动读取,手动“读取当前页面”也支持。失败后返回“我的→反馈与共建”,在10分钟内写明错误并勾选日志、HTML,确认后提交。两类学生的考试读取失败同样保留最后一次捕获的考试表格。没有捕获到可识别学校表格时会提示返回学校页面重试,不发送登录整页。到 `tps-david@glowtps.cn` 核对两个附件;此步骤会真实发送邮件,由你操作。
## 数据与兼容性
- HTML只含本科课表/课程名单或考试表格、学期/考试类型数字,以及解析需要的课程字面量脚本;移除学校页面账户头部、表单、链接地址、事件处理和无关脚本,不采集Cookie。附件中的课程脚本不执行,页面附带禁止网络请求的CSP。
- 本科HTML只含课表/课程名单或考试表格、学期/考试类型数字,以及解析需要的课程字面量脚本;研究生只含课表/考试表格、受限节次ID及对应学期选择信息,保留排查需要的解析结构。移除学校页面账户头部、其他表单、链接地址、事件处理和无关脚本,不采集Cookie。附件中的课程脚本不执行,页面附带禁止网络请求的CSP。
- 课程、教师、教室、考场及可能存在的个人信息用于排查,仍应按个人资料妥善处理。HTML不是原始整页的逐字备份。
- 未提交的采集缓存仅在APP内存保留最多10分钟;退出学校登录、重新认证或开始新的导入会清除。勾选后实际提交时冻结附件,失败重试沿用同一编号和字节;重试表单关闭或APP结束后该冻结副本释放。
- HTML最多2MiB,日志256KiB,媒体仍最多3个、每个8MiB、合计20MiB。总HTTP请求上限为20MiB+256KiB+2MiB+64KiB;代理放行24MiB。
- 旧客户端不带新字段仍可使用。未更新服务端时,新附件可能被拒绝;APP保留内容并提示更新,取消HTML后仍可只发日志。
- 旧客户端不带新字段仍可使用,旧本科HTML继续接受。仅更新APP而不更新反馈服务时,研究生附件可能被旧服务拒绝;应先更新服务,取消HTML后仍可只发日志。
- 本工程只准备并测试代码,没有登录你的服务器、改配置或发送真实邮件。
@@ -0,0 +1,5 @@
<!doctype html><!-- TPSchedule school diagnostic --><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; base-uri 'none'; form-action 'none'"><title>TPSchedule school diagnostic</title></head><body><select id="xqbh"><option value="73" selected="">2026-2027秋</option></select><table id="sample-table-1"><thead><tr>
<th>id</th><th>序号</th><th>学号</th><th>姓名</th><th>课程代码</th><th>课程名称</th><th>考核方式</th><th>考生编号</th><th>考场顺序号</th><th>考场</th><th>座位号</th><th>考试时间</th>
</tr></thead><tbody><tr>
<td>1</td><td>1</td><td>private-id</td><td>private-name</td><td>MATH</td><td>矩阵理论</td><td>笔试</td><td></td><td></td><td>示例考场</td><td></td><td></td>
</tr></tbody></table><script type="application/x-tpschedule-course-data"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><!-- TPSchedule school diagnostic --><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; base-uri 'none'; form-action 'none'"><title>TPSchedule school diagnostic</title></head><body><select id="drpxq"><option value="73" selected="">2026-2027秋</option></select><table id="tbl"><tbody><tr><th>节次</th><th>星期一</th><th>星期二</th><th>星期三</th><th>星期四</th><th>星期五</th><th>星期六</th><th>星期日</th></tr><tr><td id="t01">1-2节</td><td id="t11"></td><td id="t21"></td><td id="t31"></td><td id="t41"></td><td id="t51">101/示例集成电路设计(1班)/32/2/示例教师甲/1-3周/1~2节/示例楼B212<br>101/示例集成电路设计(1班)/32/2/示例教师甲/6-6周/1~2节/示例楼B212<br>101/示例集成电路设计(1班)/32/2/示例教师甲/7-9周/1~2节/示例楼B212</td><td id="t61"></td><td id="t71"></td></tr><tr><td id="t03">3-4节</td><td id="t13">101/示例集成电路设计(1班)/32/2/示例教师甲/1-5周/3~4节/示例楼B212</td><td id="t23">102/示例信号检测与估计(2班)/32/2/示例教师乙、示例教师丙/1-5周/3~4节/示例楼B102<br>102/示例信号检测与估计(2班)/32/2/示例教师乙、示例教师丙/7-9周/3~4节/示例楼B102</td><td id="t33"></td><td id="t43"></td><td id="t53"></td><td id="t63"></td><td id="t73"></td></tr><tr><td id="t05">5-6节</td><td id="t15"></td><td id="t25"></td><td id="t35"></td><td id="t45"></td><td id="t55"></td><td id="t65"></td><td id="t75"></td></tr><tr><td id="t07">7-8节</td><td id="t17"></td><td id="t27"></td><td id="t37"></td><td id="t47"></td><td id="t57"></td><td id="t67"></td><td id="t77"></td></tr><tr><td id="t09">9-11节</td><td id="t19"></td><td id="t29">103/示例现代化理论与实践(3班)/36/2/示例教师丁/1-12周/9~11节/示例楼B107</td><td id="t39"></td><td id="t49"></td><td id="t59"></td><td id="t69"></td><td id="t79"></td></tr></tbody></table><script type="application/x-tpschedule-course-data"></script></body></html>
+29
View File
@@ -31,6 +31,35 @@ def test_real_client_generated_synthetic_html_is_accepted_as_attachment(service)
assert attachment.get_payload(decode=True) == page
@pytest.mark.parametrize('fixture', ['client-graduate-timetable.html', 'client-graduate-exams.html'])
def test_real_graduate_client_html_is_mailed_without_changing_its_evidence(service, fixture):
client, sender, _ = service
page = (Path(__file__).parent / 'fixtures' / fixture).read_bytes()
fields = [('submission_id', (None, ID)), ('type', (None, 'problem')), ('message', (None, 'Graduate import failed'))]
response = client.post('/v1/feedback', files=fields + [('diagnostic_html', ('page.html', page, 'text/html'))])
assert response.status_code == 200
attachment = list(sender.messages[0].iter_attachments())[0]
assert attachment.get_filename() == 'TPSchedule-school-page.html'
assert attachment.get_payload(decode=True) == page
@pytest.mark.parametrize('extra', [
'<select id="account"><option value="73">Account</option></select>',
'<select id="drpxq" onchange="steal()"><option value="73">Term</option></select>',
'<select id="xqbh"><option value="https://evil.test/">Term</option></select>',
'<option value="73" selected>Outside semester selector</option>',
'<select id="drpxq"><option value="73" selected onclick="steal()">Term</option></select>',
'<table id="account"><tr><td id="token">Secret</td></tr></table>',
])
def test_graduate_diagnostics_reject_unrelated_or_active_controls(service, extra):
client, sender, _ = service
page = HTML.replace(b'</body>', extra.encode() + b'</body>')
fields = [('submission_id', (None, ID)), ('type', (None, 'problem')), ('message', (None, 'Graduate import failed'))]
response = client.post('/v1/feedback', files=fields + [('diagnostic_html', ('page.html', page, 'text/html'))])
assert response.status_code == 415
assert not sender.messages
@pytest.mark.parametrize('extra', [
'<script>alert(1)</script>', '<img src="https://evil.test/">',
'<table onclick="alert(1)"></table>', '<meta http-equiv="refresh" content="0;url=https://evil.test/">',