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
+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!);
}