fix: harden school imports and add optional feedback diagnostics

This commit is contained in:
Codex
2026-10-15 10:37:32 +08:00
parent 674df1ceb3
commit 7299f4d8b6
25 changed files with 722 additions and 42 deletions
+4 -4
View File
@@ -34,13 +34,13 @@
原签名密钥及签名配置不改,新旧 APK 证书及公钥会核对一致。保留 `.tools/android-user/debug.keystore`,不要删除、重建或随源码分发。当前保留的证书是Android Debug类型,release运行模式不代表更换为正式发行证书;提交商店/备案前须确认目标平台是否接受。`dist/TPSchedule-signing-info.json`、证书PEM及公钥PEM只含公开签名信息。
在「我的」点击「选择学校」,目前提供「电子科技大学(研究生)」。选中后应用访问 [研究生系统专用认证入口](https://yjsjy.uestc.edu.cn/pyxx/jzsso/login),学生在学校原网页填写账号、密码和验证码;应用没有自己的密码表单,不读取或注入密码。首次认证完成后选择学校学期,确认后显示“正在查找课表,请稍候”,自动打开对应课表查询并导入保存。取消学校选择不开始登录,也不改已有课表。首次课表刷新、考试更新或更新学期共用学校选择入口;已有学校连接或学校学期时沿用原学校及学期。
在「我的」点击「选择学校」,可选择「电子科技大学(研究生)」或「电子科技大学(本科生)」。研究生沿用[研究生系统专用认证入口](https://yjsjy.uestc.edu.cn/pyxx/jzsso/login),认证并选择学期后自动查找课表。本科生进入[本科教务系统](https://eams.uestc.edu.cn/eams/),自行在网页选择学期并打开课表,再点击「导入当前课表」;考试则在APP选择学期和一种或多种考试类型后自动读取。账号、密码和验证码均在学校原网页填写,应用不读取或注入密码。取消学校选择不开始登录,也不改已有课表;成功查询的学校及选项会保存在本机供刷新使用。
学校目录集中在 `mobile/lib/features/school/school_selection_page.dart`,当前只列出已接入的学校。以后新增学校需增加相应认证和课表/考试解析适配,再加入目录;当前所有学校数据模型仍按电子科技大学研究生系统处理。
学校目录集中在 `mobile/lib/features/school/school_selection_page.dart`,当前只列出已接入的学校。学校与学期有明确的来源标记,本科独立认证和解析模块位于 `mobile/lib/features/school/undergraduate/`;以后新增学校需增加相应认证和课表/考试解析适配,再加入目录。
学期73为2026-2027年秋、72为2025-2026年春、71为2025-2026年秋。课表路径为 `/pyxx/pygl/xskbcx/index/{学期编号}`;考试路径为 `/pyxx/pygl/kskcxx/index?xq={学期编号}`。选择成功后保存在本机,课表右上角刷新及考试更新沿用该学期。「个人设置 → 更新学期」已连接时立即选择,未连接时先认证。读取的 HTML 必须与所选学期一致,取消或保存失败不改原数据。
研究生学期73为2026-2027年秋、72为2025-2026年春、71为2025-2026年秋。课表路径为 `/pyxx/pygl/xskbcx/index/{学期编号}`;考试路径为 `/pyxx/pygl/kskcxx/index?xq={学期编号}`。本科学期523为2026-2027第一学期,503为2025-2026第二学期,相邻学期编号相差20;考试路径为 `/eams/stdExamTable!examTable.action?semester.id={编号}&examType.id={类型}`,类型1期末、2期中、3补考缓考。本科考试刷新沿用上次成功的学期及类型。「个人设置 → 更新学期」按所选学校进入相应流程。读取失败、取消或保存失败不改原数据。
成功导入不同学期会移除另一个页面的旧学期数据并清除旧开始日,避免混用;同学期刷新保留开始日。课表页在已导入但未设置开始日时,显示课程数量及“设置学期开始日”入口,直接进入日期设置。页面标题分别为课表、考试、TPSchedule。
研究生同源数据沿用原有跨学期更新规则;本科考试查询独立保存,不清空当前课表和学期开始日。更换课表来源或学期后需重新设置开始日,同学期刷新保留日期。课表页在已导入但未设置开始日时,显示课程数量及“设置学期开始日”入口,直接进入日期设置。页面标题分别为课表、考试、TPSchedule。
专用入口的原始302响应指向 HTTP 身份认证地址,携带研究生系统的 `service` 参数。应用将该身份认证地址转到 HTTPS 并完整保留参数。学校 HTTP 回调仅允许准确的研究生系统域名。读取或保存失败保留已有数据,可在学校认证页面重试。
+22
View File
@@ -6,6 +6,7 @@ import '../domain/app_settings.dart';
import '../data/snapshot_store.dart';
import '../data/json_schedule_import.dart';
import '../services/notification_service.dart';
import '../services/diagnostic_log.dart';
import '../services/calendar_service.dart';
import '../domain/schedule_engine.dart';
import '../data/demo_schedule.dart';
@@ -40,6 +41,10 @@ class AppController extends ChangeNotifier {
await operation();
} catch (error) {
lastError = error.toString();
DiagnosticLog.instance.add(
'operation_failed',
fields: {'code': error.runtimeType.toString()},
);
}
notifyListeners();
});
@@ -57,6 +62,10 @@ class AppController extends ChangeNotifier {
await store.save(next);
snapshot = next;
lastError = null;
DiagnosticLog.instance.add(
'snapshot_saved',
fields: {'count': next.courses.length, 'term': next.schoolTerm?.id},
);
}
Future<void> initialize({bool reconcileServices = true}) => _run(() async {
@@ -171,6 +180,8 @@ class AppController extends ChangeNotifier {
schoolTerm: draft.schoolTerm,
clearExams:
draft.schoolTerm?.school != SupportedSchool.uestcUndergraduate &&
snapshot.examSchedule?.school !=
SupportedSchool.uestcUndergraduate &&
_differentTerm(draft.schoolTerm),
settings: _differentTerm(draft.schoolTerm)
? snapshot.settings.copyWith(clearSemester: true)
@@ -359,12 +370,23 @@ class AppController extends ChangeNotifier {
clock(),
);
reminderErrorCode = null;
DiagnosticLog.instance.add(
'reminders_reconciled',
fields: {
'count': occurrences.length,
'status': reminderResult.status.name,
},
);
} catch (error) {
reminderErrorCode = error is ReminderSetupException
? error.code
: error is PlatformException
? error.code
: error.runtimeType.toString();
DiagnosticLog.instance.add(
'reminders_failed',
fields: {'code': error.runtimeType.toString()},
);
reminderResult = ReminderResult(
ReminderStatus.error,
message: error is ReminderSetupException
+7 -2
View File
@@ -46,6 +46,10 @@ class ExamRecord {
return value;
}
final typeId = json['examTypeId'];
if (typeId != null && (typeId is! int || typeId < 1 || typeId > 3)) {
throw const FormatException('考试类型无效');
}
final record = ExamRecord(
code: field('code'),
name: field('name'),
@@ -58,7 +62,7 @@ class ExamRecord {
date: field('date', optional: true),
status: field('status', optional: true),
notes: field('notes', optional: true),
examTypeId: json['examTypeId'] as int?,
examTypeId: typeId as int?,
);
if (record.code.isEmpty || record.name.isEmpty) {
throw const FormatException('考试课程无效');
@@ -107,7 +111,8 @@ class ExamSchedule {
? SupportedSchool.uestcGraduate
: SupportedSchool.fromId(json['schoolId'] as String);
final rawTypes = json['requestedExamTypes'] ?? <int>[];
if (rawTypes is! List || rawTypes.any((t) => t is! int || t < 1 || t > 3)) {
if (rawTypes is! List ||
rawTypes.any((t) => t is! int || t < 1 || t > 3)) {
throw const FormatException('考试类型无效');
}
final types = rawTypes.cast<int>().toSet();
+3 -1
View File
@@ -150,11 +150,13 @@ class ExamsPage extends StatelessWidget {
style: TextStyle(fontSize: 13, color: ink.withValues(alpha: 0.55)),
),
const SizedBox(height: 18),
if (schedule.school == SupportedSchool.uestcUndergraduate)
if (schedule.school == SupportedSchool.uestcUndergraduate) ...[
Text(
'查询类型:${UndergraduateExamQuery.fromSchedule(schedule)!.orderedTypes.map((t) => t.label).join('、')}',
style: const TextStyle(fontSize: 12),
),
const SizedBox(height: 16),
],
if (schedule.records.isEmpty)
Text(
schedule.school == SupportedSchool.uestcUndergraduate
@@ -192,6 +192,14 @@ class HttpFeedbackSender implements FeedbackSender {
bytes.add(attachment.bytes);
write('\r\n');
}
if (submission.diagnosticLog != null) {
write(
'--$boundary\r\nContent-Disposition: form-data; name="diagnostics"; filename="TPSchedule-diagnostics.txt"\r\n'
'Content-Type: text/plain; charset=utf-8\r\n\r\n',
);
bytes.add(submission.diagnosticLog!);
write('\r\n');
}
write('--$boundary--\r\n');
return bytes.takeBytes();
}
@@ -2,6 +2,7 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import '../../app/app_theme.dart';
import '../../services/diagnostic_log.dart';
import 'feedback_submission.dart';
typedef FeedbackAttachmentPicker = Future<FeedbackAttachment?> Function();
@@ -32,6 +33,7 @@ class _FeedbackPageState extends State<FeedbackPage> {
var _picking = false;
var _success = false;
var _uncertain = false;
var _includeLogs = false;
String? _error;
FeedbackSubmission? _pendingSubmission;
@@ -103,6 +105,7 @@ class _FeedbackPageState extends State<FeedbackPage> {
if (_submitting || _picking) return;
if (!_uncertain && !(_form.currentState?.validate() ?? false)) return;
FocusScope.of(context).unfocus();
setState(() => _submitting = true);
try {
_pendingSubmission ??= _kind == FeedbackKind.problem
? FeedbackSubmission.problem(
@@ -111,6 +114,9 @@ class _FeedbackPageState extends State<FeedbackPage> {
contact: _contact.text,
attachments: _attachments,
limits: widget.limits,
diagnosticLog: _includeLogs
? await DiagnosticLog.instance.export()
: null,
)
: FeedbackSubmission.development(
id: FeedbackSubmission.newId(),
@@ -256,6 +262,24 @@ class _FeedbackPageState extends State<FeedbackPage> {
: null,
),
if (_kind == FeedbackKind.problem) ...[
CheckboxListTile(
key: const Key('feedback-logs'),
contentPadding: EdgeInsets.zero,
title: const Text('附带近10分钟诊断日志'),
subtitle: const Text(
'包含登录跳转、课表读取及错误状态,帮助排查问题;不包含密码、验证码或课表内容。',
),
value: _includeLogs,
onChanged: _submitting
? null
: (value) {
setState(() {
_includeLogs = value ?? false;
_pendingSubmission = null;
_error = null;
});
},
),
const SizedBox(height: 10),
const Text(
'图片或视频(选填)',
@@ -102,6 +102,7 @@ class FeedbackSubmission {
this.contact,
this.school,
this.attachments = const [],
this.diagnosticLog,
});
factory FeedbackSubmission.problem({
@@ -110,18 +111,26 @@ class FeedbackSubmission {
String? contact,
List<FeedbackAttachment> attachments = const [],
FeedbackLimits limits = FeedbackLimits.defaults,
Uint8List? diagnosticLog,
}) {
final text = message.trim();
if (text.isEmpty) throw const FeedbackException('请填写问题或建议');
if (text.length > 5000) throw const FeedbackException('问题或建议不能超过 5000 个字符');
_validateContact(contact);
validateAttachments(attachments, limits);
if (diagnosticLog != null &&
(diagnosticLog.isEmpty || diagnosticLog.length > 256 * 1024)) {
throw const FeedbackException('日志附件不能超过256 KB');
}
return FeedbackSubmission._(
id: id,
kind: FeedbackKind.problem,
message: text,
contact: _optional(contact),
attachments: List.unmodifiable(attachments),
diagnosticLog: diagnosticLog == null
? null
: Uint8List.fromList(diagnosticLog).asUnmodifiableView(),
);
}
@@ -151,6 +160,7 @@ class FeedbackSubmission {
final String? contact;
final String? school;
final List<FeedbackAttachment> attachments;
final Uint8List? diagnosticLog;
static String newId() {
final random = Random.secure();
+101 -16
View File
@@ -6,6 +6,7 @@ import 'package:webview_flutter/webview_flutter.dart';
import '../../data/json_schedule_import.dart';
import '../../domain/exam_schedule.dart';
import '../../domain/school_term.dart';
import '../../services/diagnostic_log.dart';
import 'school_navigation_policy.dart';
import 'school_page_adapter.dart';
import 'school_exam_adapter.dart';
@@ -40,7 +41,10 @@ class SchoolSession extends ChangeNotifier {
await web.loadRequest(upgrade);
} catch (_) {
if (generation == _pageGeneration) {
_failure('学校身份认证暂时无法打开,请重试。');
_failure(
'学校身份认证暂时无法打开,请重试。',
code: 'authentication_load_failed',
);
}
}
return NavigationDecision.prevent;
@@ -55,12 +59,15 @@ class SchoolSession extends ChangeNotifier {
'Blocked school navigation origin: ${uri?.scheme}://${uri?.host}',
);
}
if (request.isMainFrame) _failure('学校跳转到了其他地址,请返回学校认证入口重试。');
if (request.isMainFrame) {
_failure('学校跳转到了其他地址,请返回学校认证入口重试。', code: 'blocked_navigation');
}
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
onPageStarted: (_) {
onPageStarted: (url) {
DiagnosticLog.instance.navigation('graduate', 'started', url);
_poll?.cancel();
_manualRead = false;
_pageGeneration++;
@@ -79,7 +86,11 @@ class SchoolSession extends ChangeNotifier {
if (hasOpened &&
generation == _pageGeneration &&
error.url == current) {
_failure('学校页面暂时未能加载,请检查网络后重试。');
_failure(
'学校页面暂时未能加载,请检查网络后重试。',
code: 'web_resource_error',
status: error.errorCode,
);
}
},
onHttpError: (error) async {
@@ -89,7 +100,11 @@ class SchoolSession extends ChangeNotifier {
generation == _pageGeneration &&
error.request != null &&
error.request!.uri == current) {
_failure('学校请求未完成,请稍后重试。');
_failure(
'学校请求未完成,请稍后重试。',
code: 'http_error',
status: error.response?.statusCode,
);
}
},
),
@@ -175,6 +190,14 @@ class SchoolSession extends ChangeNotifier {
bool forceAuth = false,
}) async {
final serial = ++_openSerial;
DiagnosticLog.instance.add(
'login_opened',
fields: {
'school': 'graduate',
'phase': exams ? 'exams' : 'timetable',
'term': term?.id,
},
);
final closing = _logoutFuture;
if (closing != null) await closing;
if (serial != _openSerial) return;
@@ -211,7 +234,7 @@ class SchoolSession extends ChangeNotifier {
await web.loadRequest(connected ? _target : authUrl);
} catch (_) {
if (serial == _openSerial && generation == _pageGeneration) {
_failure('学校入口无法打开,请检查校园网络后重试。');
_failure('学校入口无法打开,请检查校园网络后重试。', code: 'entry_load_failed');
}
}
}
@@ -244,7 +267,7 @@ class SchoolSession extends ChangeNotifier {
await web.loadRequest(_target);
} catch (_) {
if (serial == _openSerial && generation == _pageGeneration) {
_failure('学校学期查询页面暂时无法打开,请重试。');
_failure('学校学期查询页面暂时无法打开,请重试。', code: 'query_load_failed');
}
}
}
@@ -254,11 +277,20 @@ class SchoolSession extends ChangeNotifier {
_timeout?.cancel();
_timeout = Timer(
const Duration(seconds: 45),
() => _failure('查找超时,请检查学校页面后重试。已有数据保持不变。'),
() => _failure('查找超时,请检查学校页面后重试。已有数据保持不变。', code: 'read_timeout'),
);
}
void _failure(String text) {
void _failure(String text, {String code = 'flow_failure', int? status}) {
DiagnosticLog.instance.add(
'school_read_failed',
fields: {
'school': 'graduate',
'code': code,
'status': status,
'term': selectedTerm?.id,
},
);
if (!hasOpened || _routeClosed) return;
_pageGeneration++;
_reader.invalidate();
@@ -305,7 +337,7 @@ class SchoolSession extends ChangeNotifier {
}
_poll?.cancel();
if (_pollsLeft-- <= 0) {
if (finding) _failure('查找超时,请检查学校页面后重试。已有数据保持不变。');
if (finding) _failure('查找超时,请检查学校页面后重试。已有数据保持不变。', code: 'read_timeout');
return;
}
_poll = Timer(const Duration(milliseconds: 750), () {
@@ -320,6 +352,9 @@ class SchoolSession extends ChangeNotifier {
);
Future<void> _loaded(String url, {bool resetPolling = true}) async {
if (resetPolling) {
DiagnosticLog.instance.navigation('graduate', 'finished', url);
}
if (!hasOpened || _routeClosed) return;
final generation = _pageGeneration;
final uri = Uri.tryParse(url);
@@ -360,6 +395,15 @@ class SchoolSession extends ChangeNotifier {
}
try {
final evidence = await _evidence(uri);
DiagnosticLog.instance.add(
'school_evidence',
fields: {
'school': 'graduate',
'result': evidence.authenticated,
'count': evidence.pages.length,
'term': selectedTerm?.id,
},
);
if (!hasOpened ||
generation != _pageGeneration ||
await web.currentUrl() != url) {
@@ -384,14 +428,26 @@ class SchoolSession extends ChangeNotifier {
if (requestingExams) {
final draft = SchoolExamAdapter.extract(page.html);
if (generation != _pageGeneration) return;
if (draft.termId != selectedTerm?.id.toString()) continue;
if (draft.termId != selectedTerm?.id.toString()) {
DiagnosticLog.instance.add(
'term_mismatch',
fields: {'school': 'graduate'},
);
continue;
}
pendingExams = draft;
} else {
// The probe validates the live document's origin. The strict parser
// gets only its recognized fragment with the canonical table path.
final draft = await _reader.read(_target, () async => page.html);
if (!hasOpened || generation != _pageGeneration) return;
if (draft.schoolTerm != selectedTerm) continue;
if (draft.schoolTerm != selectedTerm) {
DiagnosticLog.instance.add(
'term_mismatch',
fields: {'school': 'graduate'},
);
continue;
}
pendingDraft = draft;
}
_timeout?.cancel();
@@ -399,9 +455,27 @@ class SchoolSession extends ChangeNotifier {
connectionState = SchoolConnectionState.connected;
finding = true;
message = '读取成功,正在保存…';
DiagnosticLog.instance.add(
'school_parsed',
fields: {
'school': 'graduate',
'phase': requestingExams ? 'exams' : 'timetable',
'count':
pendingDraft?.courses.length ??
pendingExams?.records.length ??
0,
},
);
notifyListeners();
return;
} catch (_) {
} catch (error) {
DiagnosticLog.instance.add(
'school_parse_rejected',
fields: {
'school': 'graduate',
'code': error.runtimeType.toString(),
},
);
if (generation != _pageGeneration) return;
// A recognized table can still be incomplete during asynchronous load.
}
@@ -411,6 +485,14 @@ class SchoolSession extends ChangeNotifier {
? null
: _flow.onLoaded(uri, authenticated: evidence.authenticated);
if (next != null) {
DiagnosticLog.instance.add(
'school_redirect_requested',
fields: {
'school': 'graduate',
'phase': requestingExams ? 'exams' : 'timetable',
'term': selectedTerm?.id,
},
);
finding = true;
_loading = true;
message = _findingMessage;
@@ -420,14 +502,17 @@ class SchoolSession extends ChangeNotifier {
await web.loadRequest(next);
} catch (_) {
if (generation == _pageGeneration) {
_failure('课表查询页面暂时无法打开,请重试。');
_failure('课表查询页面暂时无法打开,请重试。', code: 'query_load_failed');
}
}
return;
}
if (!finding) message = '请在学校网页完成认证;进入研究生系统后会自动读取。';
} catch (_) {
// JavaScript and frame access can fail briefly while the page is changing.
} catch (error) {
DiagnosticLog.instance.add(
'school_probe_failed',
fields: {'school': 'graduate', 'code': error.runtimeType.toString()},
);
} finally {
if (_probeGeneration == generation) _probeGeneration = null;
_scheduleProbe(url, generation);
@@ -21,6 +21,10 @@ List<UndergraduateActivity> parseActivityData(
r'new\s+TaskActivity\s*\((.*?)\)\s*;',
dotAll: true,
).allMatches(script).toList();
if (RegExp(r'\bnew\s+TaskActivity\s*\(').allMatches(script).length !=
matches.length) {
throw const FormatException('课程活动声明不完整');
}
final result = <UndergraduateActivity>[];
for (var i = 0; i < matches.length; i++) {
final m = matches[i], args = _strings(m[1]!);
@@ -48,9 +52,17 @@ List<UndergraduateActivity> parseActivityData(
final assignments = RegExp(r'\bindex\s*=\s*([^;]+);')
.allMatches(body)
.toList();
final appends = RegExp(
final appendPattern = RegExp(
r'table\d+\.activities\[index\]\s*\[[^;]+?\]\s*=\s*activity\s*;',
).allMatches(body).length;
);
final appends = appendPattern.allMatches(body).length;
final unconsumed = body
.replaceAll(appendPattern, '')
.replaceAll(RegExp(r'\bindex\s*=\s*[^;]+;'), '');
if (RegExp(r'\btable\d+\.activities\s*\[|\bindex\s*=')
.hasMatch(unconsumed)) {
throw const FormatException('存在无法识别的课程节次赋值');
}
if (assignments.isEmpty || assignments.length != appends) {
throw const FormatException('课程节次赋值不完整');
}
@@ -6,6 +6,7 @@ import 'package:webview_flutter/webview_flutter.dart';
import '../../../data/json_schedule_import.dart';
import '../../../domain/exam_schedule.dart';
import '../../../domain/undergraduate_exam_query.dart';
import '../../../services/diagnostic_log.dart';
import 'navigation_policy.dart';
import 'page_probe.dart';
import 'timetable_adapter.dart';
@@ -98,7 +99,9 @@ class UndergraduateSession extends ChangeNotifier {
uri,
isMainFrame: request.isMainFrame,
)) {
if (request.isMainFrame) _failure('学校跳转未完成,请重新认证。');
if (request.isMainFrame) {
_failure('学校跳转未完成,请重新认证。', code: 'blocked_navigation');
}
return NavigationDecision.prevent;
}
if (request.isMainFrame &&
@@ -107,7 +110,13 @@ class UndergraduateSession extends ChangeNotifier {
}
return NavigationDecision.navigate;
},
onPageStarted: (_) {
onPageStarted: (url) {
DiagnosticLog.instance.navigation('undergraduate', 'started', url);
// Within a batch, type-to-type loads retain their generation. Once
// published, any new document invalidates the deferred save result.
if (pendingExams != null) {
_reset();
}
_document++;
_poll?.cancel();
_probeKey = null;
@@ -125,7 +134,11 @@ class UndergraduateSession extends ChangeNotifier {
d = _document,
current = await web.currentUrl();
if (_valid(g) && d == _document && error.url == current) {
_failure('学校页面加载未完成,请检查网络后重试。');
_failure(
'学校页面加载未完成,请检查网络后重试。',
code: 'web_resource_error',
status: error.errorCode,
);
}
},
onHttpError: (error) async {
@@ -135,12 +148,20 @@ class UndergraduateSession extends ChangeNotifier {
if (_valid(g) &&
d == _document &&
error.request?.uri.toString() == current) {
_failure('学校请求未完成,请稍后重试。');
_failure(
'学校请求未完成,请稍后重试。',
code: 'http_error',
status: error.response?.statusCode,
);
}
},
),
);
Future<void> openTimetable() async {
DiagnosticLog.instance.add(
'login_opened',
fields: {'school': 'undergraduate', 'phase': 'timetable'},
);
await _logoutFuture;
if (_disposed) return;
_reset();
@@ -153,6 +174,14 @@ class UndergraduateSession extends ChangeNotifier {
}
Future<void> openExams(UndergraduateExamQuery value) async {
DiagnosticLog.instance.add(
'login_opened',
fields: {
'school': 'undergraduate',
'phase': 'exams',
'term': value.term.id,
},
);
await _logoutFuture;
if (_disposed) return;
_reset();
@@ -196,7 +225,16 @@ class UndergraduateSession extends ChangeNotifier {
}
}
void _failure(String text) {
void _failure(String text, {String code = 'read_failure', int? status}) {
DiagnosticLog.instance.add(
'school_read_failed',
fields: {
'school': 'undergraduate',
'code': code,
'status': status,
'term': query?.term.id,
},
);
if (_disposed) return;
_reset();
_failed = true;
@@ -233,6 +271,7 @@ class UndergraduateSession extends ChangeNotifier {
uri,
);
Future<void> _loaded(String url) async {
DiagnosticLog.instance.navigation('undergraduate', 'finished', url);
final g = _generation, d = _document, key = '$_generation:$_document';
if (!_valid(g) || _failed || _complete || _probeKey == key) return;
_probeKey = key;
@@ -245,6 +284,14 @@ class UndergraduateSession extends ChangeNotifier {
return;
}
final status = await _evidence(uri, false);
DiagnosticLog.instance.add(
'school_evidence',
fields: {
'school': 'undergraduate',
'result': status.authenticated,
'count': status.pages.length,
},
);
if (!_valid(g) || d != _document) return;
_loading = false;
if (!status.authenticated) {
@@ -283,6 +330,14 @@ class UndergraduateSession extends ChangeNotifier {
_itemTimeout = null;
if (_index == query!.orderedTypes.length) {
pendingExams = UndergraduateExamAdapter.merge(query!, _parts);
DiagnosticLog.instance.add(
'school_parsed',
fields: {
'school': 'undergraduate',
'phase': 'exams',
'count': pendingExams!.records.length,
},
);
_complete = true;
_cancelTimers();
message = '考试已全部读取,正在保存…';
@@ -291,7 +346,9 @@ class UndergraduateSession extends ChangeNotifier {
await _requestExam(g);
}
} catch (error) {
if (_valid(g) && d == _document) _failure('$error');
if (_valid(g) && d == _document) {
_failure('$error', code: error.runtimeType.toString());
}
} finally {
if (_probeKey == key) _probeKey = null;
}
@@ -300,16 +357,24 @@ class UndergraduateSession extends ChangeNotifier {
Future<void> _requestExam(int g) async {
if (!_valid(g) || query == null || _complete) return;
final type = query!.orderedTypes[_index];
DiagnosticLog.instance.add(
'school_read_requested',
fields: {
'school': 'undergraduate',
'term': query!.term.id,
'type': type.id,
},
);
_expected = query!.uriFor(type);
_loading = true;
message = '正在读取${type.label}(${_index + 1}/${query!.types.length})';
_emit();
_itemTimeout?.cancel();
_itemTimeout = Timer(const Duration(seconds: 45), () {
if (_valid(g)) _failure('${type.label}读取超时,请重试。');
if (_valid(g)) _failure('${type.label}读取超时,请重试。', code: 'item_timeout');
});
_batchTimeout ??= Timer(const Duration(seconds: 120), () {
if (_valid(g)) _failure('考试读取超时,请重试。');
if (_valid(g)) _failure('考试读取超时,请重试。', code: 'batch_timeout');
});
await _navigate(_expected!);
}
+6 -1
View File
@@ -5,6 +5,8 @@ import 'package:path_provider/path_provider.dart';
import 'package:timezone/data/latest.dart' as tzdata;
import 'app/app_controller.dart';
import 'app/app_version.dart';
import 'services/diagnostic_log.dart';
import 'app/hepan_app.dart';
import 'app/tp_startup.dart';
import 'data/file_snapshot_store.dart';
@@ -37,6 +39,7 @@ Widget buildApplication(
);
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
DiagnosticLog.instance.add('app_started', fields: {'version': appVersion});
AppController? controller;
await launchApplication(
bootstrap: () async {
@@ -45,8 +48,10 @@ Future<void> main() async {
final gateway = PluginReminderGateway(
onTap: (key) => selection.value = key,
);
final directory = await getApplicationSupportDirectory();
unawaited(DiagnosticLog.instance.attach(directory));
final value = AppController(
FileSnapshotStore(await getApplicationSupportDirectory()),
FileSnapshotStore(directory),
LocalNotificationService(gateway),
AndroidCalendarService(MethodCalendarGateway()),
DateTime.now,
+156
View File
@@ -0,0 +1,156 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
/// Small, structured breadcrumbs. Never accepts raw URLs, HTML or exceptions.
class DiagnosticLog {
DiagnosticLog({DateTime Function()? clock}) : clock = clock ?? DateTime.now;
static final instance = DiagnosticLog();
static const maxBytes = 256 * 1024;
static const _keys = {
'school',
'phase',
'page',
'count',
'term',
'type',
'status',
'code',
'version',
'result',
};
final DateTime Function() clock;
final _entries = <Map<String, dynamic>>[];
File? _file;
Future<void> _writes = Future.value();
Timer? _timer;
Map<String, Object> _safe(Map fields) => {
for (final e in fields.entries.take(6))
if (_keys.contains(e.key) &&
(e.value is bool ||
e.value is int ||
e.value is String &&
RegExp(r'^[A-Za-z0-9_.-]{1,40}$').hasMatch(e.value)))
e.key as String: e.value as Object,
};
void _trim() {
final now = clock().toUtc();
_entries.removeWhere((e) {
final at = DateTime.tryParse(e['at'] as String? ?? '');
return at == null ||
at.isAfter(now) ||
now.difference(at) > const Duration(minutes: 10);
});
if (_entries.length > 500) _entries.removeRange(0, _entries.length - 500);
}
void add(String event, {Map<String, Object?> fields = const {}}) {
if (!RegExp(r'^[a-z_]{1,64}$').hasMatch(event)) return;
_trim();
final safe = _safe(fields);
if (_entries.isNotEmpty) {
final last = _entries.last;
final at = DateTime.tryParse(last['at'] as String? ?? '');
final previous = Map<String, dynamic>.from(last)
..remove('at')
..remove('event');
if (last['event'] == event &&
jsonEncode(previous) == jsonEncode(safe) &&
at != null &&
clock().toUtc().difference(at) < const Duration(seconds: 3)) {
return;
}
}
_entries.add({
'at': clock().toUtc().toIso8601String(),
'event': event,
...safe,
});
_trim();
unawaited(flush());
}
void navigation(String school, String phase, String url) {
final uri = Uri.tryParse(url);
final page = uri == null
? 'unknown'
: uri.host == 'idas.uestc.edu.cn'
? 'identity'
: uri.host == 'yjsjy.uestc.edu.cn'
? uri.path.contains('xskbcx')
? 'timetable'
: uri.path.contains('kskcxx')
? 'exams'
: 'graduate_portal'
: uri.host == 'eams.uestc.edu.cn'
? uri.path.contains('stdExamTable')
? 'exams'
: uri.path.contains('courseTable')
? 'timetable'
: 'undergraduate_portal'
: 'external';
add('navigation_$phase', fields: {'school': school, 'page': page});
}
Future<void> attach(Directory directory) async {
final file = File('${directory.path}/diagnostics-recent.json');
try {
if (await file.exists() && await file.length() <= maxBytes) {
final saved = jsonDecode(await file.readAsString());
if (saved is List) {
final restored = <Map<String, dynamic>>[];
for (final row in saved.take(500)) {
if (row is Map &&
row['at'] is String &&
row['event'] is String &&
RegExp(r'^[a-z_]{1,64}$').hasMatch(row['event'])) {
restored.add({
'at': row['at'],
'event': row['event'],
..._safe(row),
});
}
}
_entries.insertAll(0, restored);
}
}
} catch (_) {
/* Diagnostics must never block startup or feedback. */
}
_file = file;
_timer?.cancel();
_timer = Timer.periodic(
const Duration(minutes: 1),
(_) => unawaited(flush()),
);
await flush();
}
Future<void> flush() {
_trim();
final file = _file;
if (file == null) return Future.value();
final text = jsonEncode(_entries);
_writes = _writes.then((_) async {
try {
await file.writeAsString(text, flush: true);
} catch (_) {}
});
return _writes;
}
Future<Uint8List> export() async {
_trim();
await flush();
final header =
'TPSchedule diagnostic log\nWindow: last 10 minutes (UTC timestamps)\n'
'Up to 500 recent events; no passwords, cookies, HTML or feedback text.\n';
return Uint8List.fromList(
utf8.encode('$header${_entries.map(jsonEncode).join('\n')}\n'),
);
}
void dispose() => _timer?.cancel();
}
@@ -109,4 +109,24 @@ void main() {
'{"schemaVersion":1,"courses":[${const JsonEncoder().convert(old)}]}';
expect(() => JsonScheduleImport.parse(json), throwsFormatException);
});
test('graduate timetable changes preserve independently saved undergraduate exams', () async {
await controller.initialize();
await controller.applyExams(exams());
await controller.applyImport(
ImportDraft(
courses: [course()],
explicitlyEmpty: false,
source: course().source,
parserVersion: 'test',
schoolTerm: SchoolTerm(72),
),
confirmedEmpty: false,
);
expect(controller.lastError, isNull);
expect(
controller.snapshot.examSchedule?.school,
SupportedSchool.uestcUndergraduate,
);
expect(controller.snapshot.examSchedule?.termId, '503');
});
}
@@ -7,6 +7,17 @@ import 'package:hepan_everyday/domain/app_snapshot.dart';
import 'package:hepan_everyday/domain/course.dart';
void main() {
test(
'optional exam types reject invalid values even in graduate snapshots',
() {
final saved = ExamSchedule(
semesterLabel: '2026秋',
termId: '73',
records: [const ExamRecord(code: 'A', name: '课程', examTypeId: 99)],
).toJson();
expect(() => ExamSchedule.fromJson(saved), throwsFormatException);
},
);
test(
'managed undergraduate identifiers cannot lose their school or term',
() {
@@ -33,6 +33,27 @@ class FakeFeedbackHttpTransport implements FeedbackHttpTransport {
}
void main() {
test('diagnostics use a separate UTF8 attachment field', () async {
final transport = FakeFeedbackHttpTransport();
await HttpFeedbackSender(
endpoint: 'https://example.com/v1/feedback',
transport: transport,
).send(
FeedbackSubmission.problem(
id: 'same-id',
message: '问题',
diagnosticLog: Uint8List.fromList(
utf8.encode('TPSchedule diagnostic log\nlogin_opened'),
),
),
);
final body = utf8.decode(transport.body!);
expect(
body,
contains('name="diagnostics"; filename="TPSchedule-diagnostics.txt"'),
);
expect(body, contains('login_opened'));
});
test(
'matching uncertain delivery is distinct and never exposes server detail',
() async {
+23 -4
View File
@@ -31,10 +31,29 @@ Future<void> reveal(
backwards ? -250 : 250,
scrollable: find.byType(Scrollable).first,
);
await tester.pump();
await tester.pumpAndSettle();
}
void main() {
testWidgets('logs are opt in and included only when checked', (tester) async {
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-logs')));
expect(
tester
.widget<CheckboxListTile>(find.byKey(const Key('feedback-logs')))
.value,
isFalse,
);
await tester.tap(find.byKey(const Key('feedback-logs')));
await tester.pump();
await reveal(tester, find.byKey(const Key('feedback-submit')));
await tester.tap(find.byKey(const Key('feedback-submit')));
await tester.pumpAndSettle();
expect(sender.submissions.single.diagnosticLog, isNotNull);
expect(sender.submissions.single.attachments, isEmpty);
});
testWidgets('uncertain delivery retains original payload for status check', (
tester,
) async {
@@ -191,7 +210,7 @@ void main() {
),
);
await tester.enterText(find.byKey(const Key('feedback-message')), '旧问题');
await tester.ensureVisible(find.byKey(const Key('feedback-add-media')));
await reveal(tester, find.byKey(const Key('feedback-add-media')));
await tester.tap(find.byKey(const Key('feedback-add-media')));
await tester.pumpAndSettle();
await reveal(tester, find.text('我要参与开发'), backwards: true);
@@ -303,7 +322,7 @@ void main() {
),
);
await tester.enterText(find.byKey(const Key('feedback-message')), '问题');
await tester.ensureVisible(find.byKey(const Key('feedback-add-media')));
await reveal(tester, find.byKey(const Key('feedback-add-media')));
await tester.tap(find.byKey(const Key('feedback-add-media')));
await tester.pumpAndSettle();
expect(find.text('screen.png'), findsOneWidget);
@@ -312,7 +331,7 @@ void main() {
await tester.pumpAndSettle();
expect(sender.submissions.single.attachments, hasLength(1));
expect(find.text('screen.png'), findsOneWidget);
await tester.ensureVisible(find.byTooltip('移除 screen.png'));
await reveal(tester, find.byTooltip('移除 screen.png'), backwards: true);
await tester.tap(find.byTooltip('移除 screen.png'));
await tester.pumpAndSettle();
expect(find.text('screen.png'), findsNothing);
@@ -1,4 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'package:hepan_everyday/services/diagnostic_log.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart';
@@ -26,6 +29,30 @@ void main() {
platform.delegate.finished(url);
}
test('login diagnostic breadcrumbs distinguish probe and network failures without raw details', () async {
await session.openTimetable(term: SchoolTerm(73));
session.setReadingActive(true);
platform.web.javascript = (_) async => throw StateError('PRIVATE_TICKET');
navigate('https://yjsjy.uestc.edu.cn/pyxx/home/index');
await flushSchoolEvents();
var log = utf8.decode(await DiagnosticLog.instance.export());
expect(log, contains('school_probe_failed'));
expect(log, isNot(contains('PRIVATE_TICKET')));
platform.delegate.resourceError(
WebResourceError(
errorCode: -2,
description: 'PRIVATE_NETWORK_DETAIL',
isForMainFrame: true,
url: platform.web.url,
),
);
await flushSchoolEvents();
log = utf8.decode(await DiagnosticLog.instance.export());
expect(log, contains('web_resource_error'));
expect(log, contains('"status":-2'));
expect(log, isNot(contains('PRIVATE_NETWORK_DETAIL')));
});
test('graduate logout function automatically opens timetable without an exit anchor', () async {
await session.openTimetable(term: SchoolTerm(73));
platform.web.javascript = (script) async =>
@@ -82,6 +82,10 @@ void main() {
await finish();
expect(session.pendingExams!.records, hasLength(12));
expect(session.pendingExams!.requestedExamTypes, {1, 2, 3});
final completedGeneration = session.pageGeneration;
platform.delegate.started('https://eams.uestc.edu.cn/eams/');
expect(session.pendingExams, isNull);
expect(session.pageGeneration, greaterThan(completedGeneration));
},
);
test('last type failure never publishes partial results', () async {
@@ -9,6 +9,21 @@ import 'package:hepan_everyday/data/schedule_file_import.dart';
import 'package:hepan_everyday/domain/school_identity.dart';
void main() {
test('unrecognized activity writes and incomplete declarations reject the entire timetable', () {
const valid =
'activity = new TaskActivity("T", "教师", "C", "课程(C.01)", "R", "教室", "010"); '
'index = 0*unitCount+0; table0.activities[index][table0.activities[index].length]=activity;';
for (final extra in [
'table0.activities[11][table0.activities[11].length]=activity;',
'activity = new TaskActivity("T", "教师", "C", "另一课程(C.02)", "R", "教室", "010")\n'
'index = 0*unitCount+1; table0.activities[index][table0.activities[index].length]=activity;',
]) {
expect(
() => parseActivityData('$valid $extra', unitCount: 12),
throwsFormatException,
);
}
});
final uri = Uri.parse(
'https://eams.uestc.edu.cn/eams/courseTableForStd.action',
);
@@ -0,0 +1,53 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/services/diagnostic_log.dart';
void main() {
test(
'logs retain only ten minutes across reopening and omit login secrets',
() async {
final directory = await Directory.systemTemp.createTemp('tp-log-test');
addTearDown(() => directory.delete(recursive: true));
var now = DateTime.utc(2026, 10, 15, 10);
final log = DiagnosticLog(clock: () => now);
addTearDown(log.dispose);
await log.attach(directory);
log.add('old_event');
now = now.add(const Duration(minutes: 11));
log.navigation(
'graduate',
'finished',
'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/73?ticket=SECRET&username=PERSON',
);
log.add(
'read_failed',
fields: {'code': 'no_timetable', 'password': 'SECRET'},
);
await log.flush();
final restored = DiagnosticLog(clock: () => now);
addTearDown(restored.dispose);
await restored.attach(directory);
final text = utf8.decode(await restored.export());
expect(text, contains('navigation_finished'));
expect(text, contains('timetable'));
expect(text, contains('no_timetable'));
for (final value in [
'old_event',
'SECRET',
'PERSON',
'ticket',
'"password"',
'https://',
]) {
expect(text, isNot(contains(value)));
}
now = now.add(const Duration(minutes: 11));
expect(
utf8.decode(await restored.export()),
isNot(contains('no_timetable')),
);
},
);
}
@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hepan_everyday/app/app_controller.dart';
import 'package:hepan_everyday/app/app_theme.dart';
import 'package:hepan_everyday/domain/app_snapshot.dart';
import 'package:hepan_everyday/domain/exam_schedule.dart';
import 'package:hepan_everyday/domain/school_identity.dart';
import 'package:hepan_everyday/features/exams/exams_page.dart';
import '../support/fakes.dart';
void main() {
testWidgets('query description has breathing room above exam cards', (
tester,
) async {
final controller = AppController(
MemoryStore(),
FakeNotifications(),
FakeCalendar(),
DateTime.now,
);
addTearDown(controller.dispose);
controller.snapshot = AppSnapshot(
examSchedule: ExamSchedule(
semesterLabel: '2025-2026第二学期',
termId: '503',
school: SupportedSchool.uestcUndergraduate,
requestedExamTypes: {1},
records: [const ExamRecord(code: 'C1', name: '测试考试', examTypeId: 1)],
),
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: ExamsPage(controller: controller, onSync: () {}),
),
),
);
final description = tester.getRect(find.text('查询类型:期末考试'));
final card = tester.getRect(find.byType(SurfaceCard).first);
expect(card.top - description.bottom, greaterThanOrEqualTo(12));
});
}
+5
View File
@@ -16,6 +16,11 @@ Prepared for Ubuntu + 1Panel on `43.138.57.216`. Public endpoint: `https://app.g
| `contact` | Optional, max 200 characters | Required, nonblank, max 200 characters |
| `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 |
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.
To enable this field on an existing deployment, replace the service source and rebuild the same Compose service, keeping `.env`, the idempotency secret and database volume. Existing clients without diagnostics remain compatible. Do not alter the SafeLine/OpenResty routing for this update. See [日志附件更新说明](diagnostic-log-update.md).
Accepted MIME types: `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/heic`, `image/heif`, `video/mp4`, `video/quicktime`, `video/x-m4v`, `video/webm`. Send an explicit matching MIME type; `application/octet-stream` is rejected. Container signatures must match. MOV/MP4/M4V/HEIC require an initial valid `ftyp` box with a recognized compatible brand; legacy MOV without `ftyp` is rejected. Checks identify file formats; they do not certify an attachment is harmless or a complete playable file.
+15
View File
@@ -0,0 +1,15 @@
# 可选诊断日志附件更新
APP 的「反馈问题和建议」新增默认关闭的「附带近10分钟诊断日志」。勾选后,反馈邮件多一个 `TPSchedule-diagnostics.txt`,不占用3个图片/视频名额。加入开发的申请不发送日志。
日志包含认证入口打开、页面开始/结束跳转、识别登录状态、请求读取课表/考试、解析成功/失败类别、保存及提醒调度状态。页面地址只保留「身份认证、研究生首页、课表、考试」等类别,不保存完整URL及查询参数。不记录密码、验证码、Cookie、HTML、课程/师生姓名、反馈正文。日志有UTC时间,仅保留运行期间最近10分钟、最多500条;运行时每分钟裁剪,重启时丢弃过期记录。重试使用首次提交时冻结的附件。
## 现有 Ubuntu + 1Panel + 雷池部署
1. 在1Panel找到原反馈服务的目录,例如 `/opt/app-feedback`。保留原 `.env`、Compose项目名和数据库数据卷,不重建SMTP密码及 `IDEMPOTENCY_SECRET`。
2. 用本版本的 `feedback_service.py` 覆盖同名文件;同时保留本工程的 `main.py`、依赖及Docker构建文件。若统一上传整个部署目录,排除 `.deps`、测试缓存、本机文件及 `.env`,不要覆盖服务器原配置。
3. 在该目录运行 `docker compose up -d --build feedback`,或在1Panel中重建同一Compose服务。等待健康检查通过。不要执行删除数据卷的操作。
4. 雷池80/443 → OpenResty10080 → `127.0.0.1:18080` 的原转发关系保持;原 `client_max_body_size 21m` 上限足以容纳20 MiB媒体和256 KiB日志,不需更换域名或端口。
5. 安装新APP,尝试登录并复现问题,再进入反馈页写问题、勾选日志、提交。到 `tps-david@glowtps.cn` 核对文本附件及时间;该步骤才会真实发送邮件。
若服务器仍为旧版,勾选日志可能被拒绝,APP不会显示发送成功,填写内容保留。不勾选日志的旧协议继续可用。本次仅准备与测试部署代码,未连接服务器或实发SMTP邮件。
+23 -4
View File
@@ -29,7 +29,8 @@ from starlette.concurrency import run_in_threadpool
MIB = 1024 * 1024
FILE_LIMIT = 8 * MIB
TOTAL_FILE_LIMIT = 20 * MIB
BODY_LIMIT = TOTAL_FILE_LIMIT + 64 * 1024
LOG_LIMIT = 256 * 1024
BODY_LIMIT = TOTAL_FILE_LIMIT + LOG_LIMIT + 64 * 1024
ID_PATTERN = re.compile(r"[A-Za-z0-9_-]{16,128}\Z")
ADDRESS_PATTERN = re.compile(r"[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,63}\Z")
@@ -362,10 +363,15 @@ def safe_filename(filename, extension):
async def parse_submission(request):
try:
async with request.form(max_files=3, max_fields=5, max_part_size=20000) as form:
fields, uploads = {}, []
async with request.form(max_files=4, max_fields=5, max_part_size=20000) as form:
fields, uploads, diagnostic_upload = {}, [], None
for name, value in form.multi_items():
if isinstance(value, UploadFile):
if name == "diagnostics":
if diagnostic_upload is not None:
raise ApiError(422, "validation_error", "Only one diagnostic log is allowed.")
diagnostic_upload = value
continue
if name != "attachments":
raise ApiError(422, "validation_error", "Unexpected file field.")
uploads.append(value)
@@ -387,10 +393,12 @@ async def parse_submission(request):
if kind == "problem":
if not fields.get("message", "").strip() or "school" in fields:
raise ApiError(422, "validation_error", "Problem feedback requires message and must omit school.")
elif ("message" in fields or uploads or not fields.get("school", "").strip()
elif ("message" in fields or uploads or diagnostic_upload or not fields.get("school", "").strip()
or not fields.get("contact", "").strip()):
raise ApiError(422, "validation_error", "Development requests require school and contact; omit message and attachments.")
attachments, total = [], 0
if len(uploads) > 3:
raise ApiError(413, "upload_too_large", "Maximum three media files.")
for upload in uploads:
data = await upload.read(FILE_LIMIT + 1)
total += len(data)
@@ -401,6 +409,17 @@ async def parse_submission(request):
if not valid:
raise ApiError(415, "unsupported_media", "Attachment MIME type and file signature must match a supported image or video.")
attachments.append((safe_filename(upload.filename, extension), mime, data))
if diagnostic_upload is not None:
data = await diagnostic_upload.read(LOG_LIMIT + 1)
if len(data) > LOG_LIMIT:
raise ApiError(413, "upload_too_large", "Diagnostic log exceeds 256 KiB.")
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
raise ApiError(415, "unsupported_media", "Diagnostic log must be UTF-8 text.") from None
if (diagnostic_upload.content_type or '').split(';')[0].strip().lower() != 'text/plain' or '\x00' in text or not text.startswith('TPSchedule diagnostic log\n'):
raise ApiError(415, "unsupported_media", "Invalid diagnostic log.")
attachments.append(('TPSchedule-diagnostics.txt', 'text/plain', data))
return fields, attachments
except HTTPException as exc:
detail = str(exc.detail).lower()
+34
View File
@@ -62,6 +62,40 @@ def test_success_only_after_sender_accepted_and_retry_is_durable(service):
assert len(sender.messages) == 1
def test_diagnostic_log_is_extra_email_attachment_and_retry_stays_idempotent(service):
client, sender, _ = service
log = b'TPSchedule diagnostic log\n{"event":"login_opened"}\n'
files = [("submission_id", (None, ID)), ("type", (None, "problem")),
("message", (None, "Login stopped"))] + [
("attachments", (f"image-{i}.png", PNG, "image/png")) for i in range(3)] + [
("diagnostics", ("TPSchedule-diagnostics.txt", log, "text/plain; charset=utf-8"))]
assert client.post('/v1/feedback', files=files).status_code == 200
attachments = list(sender.messages[0].iter_attachments())
assert len(attachments) == 4
assert attachments[-1].get_filename() == 'TPSchedule-diagnostics.txt'
assert attachments[-1].get_payload(decode=True) == log
assert client.post('/v1/feedback', files=files).status_code == 200
assert len(sender.messages) == 1
@pytest.mark.parametrize('name,kind,data,mime,status', [
('diagnostics', 'problem', b'not a log', 'text/plain', 415),
('diagnostics', 'problem', b'TPSchedule diagnostic log\n\xff', 'text/plain', 415),
('diagnostics', 'problem', b'TPSchedule diagnostic log\n' + b'x' * (256 * 1024), 'text/plain', 413),
('diagnostics', 'development', b'TPSchedule diagnostic log\n', 'text/plain', 422),
], ids=['header', 'utf8', 'oversize', 'development'])
def test_invalid_diagnostics_never_send(service, name, kind, data, mime, status):
client, sender, _ = service
fields = [('submission_id', (None, ID)), ('type', (None, kind))]
if kind == 'problem':
fields += [('message', (None, 'Cannot import'))]
else:
fields += [('school', (None, 'University')), ('contact', (None, 'QQ123'))]
response = client.post('/v1/feedback', files=fields + [(name, ('log.txt', data, mime))])
assert response.status_code == status
assert not sender.messages
def test_reusing_id_with_other_content_rejected(service):
client, sender, _ = service
assert post(client).status_code == 200