diff --git a/README.md b/README.md index 8ed9886..c00fd39 100644 --- a/README.md +++ b/README.md @@ -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 回调仅允许准确的研究生系统域名。读取或保存失败保留已有数据,可在学校认证页面重试。 diff --git a/mobile/lib/app/app_controller.dart b/mobile/lib/app/app_controller.dart index fb54168..1d30290 100644 --- a/mobile/lib/app/app_controller.dart +++ b/mobile/lib/app/app_controller.dart @@ -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 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 diff --git a/mobile/lib/domain/exam_schedule.dart b/mobile/lib/domain/exam_schedule.dart index 6d79aa4..9d5bdea 100644 --- a/mobile/lib/domain/exam_schedule.dart +++ b/mobile/lib/domain/exam_schedule.dart @@ -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'] ?? []; - 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().toSet(); diff --git a/mobile/lib/features/exams/exams_page.dart b/mobile/lib/features/exams/exams_page.dart index 8d99559..0399a3c 100644 --- a/mobile/lib/features/exams/exams_page.dart +++ b/mobile/lib/features/exams/exams_page.dart @@ -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 diff --git a/mobile/lib/features/feedback/feedback_http_sender.dart b/mobile/lib/features/feedback/feedback_http_sender.dart index 757484e..17658d5 100644 --- a/mobile/lib/features/feedback/feedback_http_sender.dart +++ b/mobile/lib/features/feedback/feedback_http_sender.dart @@ -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(); } diff --git a/mobile/lib/features/feedback/feedback_page.dart b/mobile/lib/features/feedback/feedback_page.dart index a764aec..c945f37 100644 --- a/mobile/lib/features/feedback/feedback_page.dart +++ b/mobile/lib/features/feedback/feedback_page.dart @@ -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 Function(); @@ -32,6 +33,7 @@ class _FeedbackPageState extends State { var _picking = false; var _success = false; var _uncertain = false; + var _includeLogs = false; String? _error; FeedbackSubmission? _pendingSubmission; @@ -103,6 +105,7 @@ class _FeedbackPageState extends State { 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 { 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 { : 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( '图片或视频(选填)', diff --git a/mobile/lib/features/feedback/feedback_submission.dart b/mobile/lib/features/feedback/feedback_submission.dart index 51210aa..73f3931 100644 --- a/mobile/lib/features/feedback/feedback_submission.dart +++ b/mobile/lib/features/feedback/feedback_submission.dart @@ -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 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 attachments; + final Uint8List? diagnosticLog; static String newId() { final random = Random.secure(); diff --git a/mobile/lib/features/school/school_session.dart b/mobile/lib/features/school/school_session.dart index 357d464..f74c1a4 100644 --- a/mobile/lib/features/school/school_session.dart +++ b/mobile/lib/features/school/school_session.dart @@ -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 _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); diff --git a/mobile/lib/features/school/undergraduate/activity_data.dart b/mobile/lib/features/school/undergraduate/activity_data.dart index ad3379a..dd67090 100644 --- a/mobile/lib/features/school/undergraduate/activity_data.dart +++ b/mobile/lib/features/school/undergraduate/activity_data.dart @@ -21,6 +21,10 @@ List 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 = []; for (var i = 0; i < matches.length; i++) { final m = matches[i], args = _strings(m[1]!); @@ -48,9 +52,17 @@ List 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('课程节次赋值不完整'); } diff --git a/mobile/lib/features/school/undergraduate/session.dart b/mobile/lib/features/school/undergraduate/session.dart index a05ea10..0d2873f 100644 --- a/mobile/lib/features/school/undergraduate/session.dart +++ b/mobile/lib/features/school/undergraduate/session.dart @@ -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 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 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 _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 _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!); } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index addf1e7..56caaba 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -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 main() async { WidgetsFlutterBinding.ensureInitialized(); + DiagnosticLog.instance.add('app_started', fields: {'version': appVersion}); AppController? controller; await launchApplication( bootstrap: () async { @@ -45,8 +48,10 @@ Future 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, diff --git a/mobile/lib/services/diagnostic_log.dart b/mobile/lib/services/diagnostic_log.dart new file mode 100644 index 0000000..c27b013 --- /dev/null +++ b/mobile/lib/services/diagnostic_log.dart @@ -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 = >[]; + File? _file; + Future _writes = Future.value(); + Timer? _timer; + + Map _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 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.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 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 = >[]; + 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 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 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(); +} diff --git a/mobile/test/app/undergraduate_controller_test.dart b/mobile/test/app/undergraduate_controller_test.dart index ed133cc..050f3c4 100644 --- a/mobile/test/app/undergraduate_controller_test.dart +++ b/mobile/test/app/undergraduate_controller_test.dart @@ -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'); + }); } diff --git a/mobile/test/domain/undergraduate_models_test.dart b/mobile/test/domain/undergraduate_models_test.dart index 2c0199c..9a07cc9 100644 --- a/mobile/test/domain/undergraduate_models_test.dart +++ b/mobile/test/domain/undergraduate_models_test.dart @@ -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', () { diff --git a/mobile/test/features/feedback_http_sender_test.dart b/mobile/test/features/feedback_http_sender_test.dart index 6e86b13..411540b 100644 --- a/mobile/test/features/feedback_http_sender_test.dart +++ b/mobile/test/features/feedback_http_sender_test.dart @@ -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 { diff --git a/mobile/test/features/feedback_test.dart b/mobile/test/features/feedback_test.dart index 64570f2..bc72c31 100644 --- a/mobile/test/features/feedback_test.dart +++ b/mobile/test/features/feedback_test.dart @@ -31,10 +31,29 @@ Future 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(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); diff --git a/mobile/test/school/school_session_flow_test.dart b/mobile/test/school/school_session_flow_test.dart index f418a4b..b1883ea 100644 --- a/mobile/test/school/school_session_flow_test.dart +++ b/mobile/test/school/school_session_flow_test.dart @@ -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 => diff --git a/mobile/test/school/undergraduate_exam_batch_test.dart b/mobile/test/school/undergraduate_exam_batch_test.dart index 49fc0ac..c0caa42 100644 --- a/mobile/test/school/undergraduate_exam_batch_test.dart +++ b/mobile/test/school/undergraduate_exam_batch_test.dart @@ -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 { diff --git a/mobile/test/school/undergraduate_timetable_adapter_test.dart b/mobile/test/school/undergraduate_timetable_adapter_test.dart index 7055593..7cc468b 100644 --- a/mobile/test/school/undergraduate_timetable_adapter_test.dart +++ b/mobile/test/school/undergraduate_timetable_adapter_test.dart @@ -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', ); diff --git a/mobile/test/services/diagnostic_log_test.dart b/mobile/test/services/diagnostic_log_test.dart new file mode 100644 index 0000000..b0c9218 --- /dev/null +++ b/mobile/test/services/diagnostic_log_test.dart @@ -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')), + ); + }, + ); +} diff --git a/mobile/test/widgets/exam_query_spacing_test.dart b/mobile/test/widgets/exam_query_spacing_test.dart new file mode 100644 index 0000000..e0d2630 --- /dev/null +++ b/mobile/test/widgets/exam_query_spacing_test.dart @@ -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)); + }); +} diff --git a/server/feedback/README.md b/server/feedback/README.md index 420f77f..e570c53 100644 --- a/server/feedback/README.md +++ b/server/feedback/README.md @@ -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. diff --git a/server/feedback/diagnostic-log-update.md b/server/feedback/diagnostic-log-update.md new file mode 100644 index 0000000..8b65806 --- /dev/null +++ b/server/feedback/diagnostic-log-update.md @@ -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邮件。 diff --git a/server/feedback/feedback_service.py b/server/feedback/feedback_service.py index 8e4cc35..77cdb4b 100644 --- a/server/feedback/feedback_service.py +++ b/server/feedback/feedback_service.py @@ -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() diff --git a/server/feedback/tests/test_feedback.py b/server/feedback/tests/test_feedback.py index 18ebd23..e42fbd7 100644 --- a/server/feedback/tests/test_feedback.py +++ b/server/feedback/tests/test_feedback.py @@ -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