fix: harden school imports and add optional feedback diagnostics
This commit is contained in:
@@ -34,13 +34,13 @@
|
|||||||
|
|
||||||
原签名密钥及签名配置不改,新旧 APK 证书及公钥会核对一致。保留 `.tools/android-user/debug.keystore`,不要删除、重建或随源码分发。当前保留的证书是Android Debug类型,release运行模式不代表更换为正式发行证书;提交商店/备案前须确认目标平台是否接受。`dist/TPSchedule-signing-info.json`、证书PEM及公钥PEM只含公开签名信息。
|
原签名密钥及签名配置不改,新旧 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 回调仅允许准确的研究生系统域名。读取或保存失败保留已有数据,可在学校认证页面重试。
|
专用入口的原始302响应指向 HTTP 身份认证地址,携带研究生系统的 `service` 参数。应用将该身份认证地址转到 HTTPS 并完整保留参数。学校 HTTP 回调仅允许准确的研究生系统域名。读取或保存失败保留已有数据,可在学校认证页面重试。
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../domain/app_settings.dart';
|
|||||||
import '../data/snapshot_store.dart';
|
import '../data/snapshot_store.dart';
|
||||||
import '../data/json_schedule_import.dart';
|
import '../data/json_schedule_import.dart';
|
||||||
import '../services/notification_service.dart';
|
import '../services/notification_service.dart';
|
||||||
|
import '../services/diagnostic_log.dart';
|
||||||
import '../services/calendar_service.dart';
|
import '../services/calendar_service.dart';
|
||||||
import '../domain/schedule_engine.dart';
|
import '../domain/schedule_engine.dart';
|
||||||
import '../data/demo_schedule.dart';
|
import '../data/demo_schedule.dart';
|
||||||
@@ -40,6 +41,10 @@ class AppController extends ChangeNotifier {
|
|||||||
await operation();
|
await operation();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error.toString();
|
lastError = error.toString();
|
||||||
|
DiagnosticLog.instance.add(
|
||||||
|
'operation_failed',
|
||||||
|
fields: {'code': error.runtimeType.toString()},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
});
|
});
|
||||||
@@ -57,6 +62,10 @@ class AppController extends ChangeNotifier {
|
|||||||
await store.save(next);
|
await store.save(next);
|
||||||
snapshot = next;
|
snapshot = next;
|
||||||
lastError = null;
|
lastError = null;
|
||||||
|
DiagnosticLog.instance.add(
|
||||||
|
'snapshot_saved',
|
||||||
|
fields: {'count': next.courses.length, 'term': next.schoolTerm?.id},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initialize({bool reconcileServices = true}) => _run(() async {
|
Future<void> initialize({bool reconcileServices = true}) => _run(() async {
|
||||||
@@ -171,6 +180,8 @@ class AppController extends ChangeNotifier {
|
|||||||
schoolTerm: draft.schoolTerm,
|
schoolTerm: draft.schoolTerm,
|
||||||
clearExams:
|
clearExams:
|
||||||
draft.schoolTerm?.school != SupportedSchool.uestcUndergraduate &&
|
draft.schoolTerm?.school != SupportedSchool.uestcUndergraduate &&
|
||||||
|
snapshot.examSchedule?.school !=
|
||||||
|
SupportedSchool.uestcUndergraduate &&
|
||||||
_differentTerm(draft.schoolTerm),
|
_differentTerm(draft.schoolTerm),
|
||||||
settings: _differentTerm(draft.schoolTerm)
|
settings: _differentTerm(draft.schoolTerm)
|
||||||
? snapshot.settings.copyWith(clearSemester: true)
|
? snapshot.settings.copyWith(clearSemester: true)
|
||||||
@@ -359,12 +370,23 @@ class AppController extends ChangeNotifier {
|
|||||||
clock(),
|
clock(),
|
||||||
);
|
);
|
||||||
reminderErrorCode = null;
|
reminderErrorCode = null;
|
||||||
|
DiagnosticLog.instance.add(
|
||||||
|
'reminders_reconciled',
|
||||||
|
fields: {
|
||||||
|
'count': occurrences.length,
|
||||||
|
'status': reminderResult.status.name,
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reminderErrorCode = error is ReminderSetupException
|
reminderErrorCode = error is ReminderSetupException
|
||||||
? error.code
|
? error.code
|
||||||
: error is PlatformException
|
: error is PlatformException
|
||||||
? error.code
|
? error.code
|
||||||
: error.runtimeType.toString();
|
: error.runtimeType.toString();
|
||||||
|
DiagnosticLog.instance.add(
|
||||||
|
'reminders_failed',
|
||||||
|
fields: {'code': error.runtimeType.toString()},
|
||||||
|
);
|
||||||
reminderResult = ReminderResult(
|
reminderResult = ReminderResult(
|
||||||
ReminderStatus.error,
|
ReminderStatus.error,
|
||||||
message: error is ReminderSetupException
|
message: error is ReminderSetupException
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ class ExamRecord {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final typeId = json['examTypeId'];
|
||||||
|
if (typeId != null && (typeId is! int || typeId < 1 || typeId > 3)) {
|
||||||
|
throw const FormatException('考试类型无效');
|
||||||
|
}
|
||||||
final record = ExamRecord(
|
final record = ExamRecord(
|
||||||
code: field('code'),
|
code: field('code'),
|
||||||
name: field('name'),
|
name: field('name'),
|
||||||
@@ -58,7 +62,7 @@ class ExamRecord {
|
|||||||
date: field('date', optional: true),
|
date: field('date', optional: true),
|
||||||
status: field('status', optional: true),
|
status: field('status', optional: true),
|
||||||
notes: field('notes', optional: true),
|
notes: field('notes', optional: true),
|
||||||
examTypeId: json['examTypeId'] as int?,
|
examTypeId: typeId as int?,
|
||||||
);
|
);
|
||||||
if (record.code.isEmpty || record.name.isEmpty) {
|
if (record.code.isEmpty || record.name.isEmpty) {
|
||||||
throw const FormatException('考试课程无效');
|
throw const FormatException('考试课程无效');
|
||||||
@@ -107,7 +111,8 @@ class ExamSchedule {
|
|||||||
? SupportedSchool.uestcGraduate
|
? SupportedSchool.uestcGraduate
|
||||||
: SupportedSchool.fromId(json['schoolId'] as String);
|
: SupportedSchool.fromId(json['schoolId'] as String);
|
||||||
final rawTypes = json['requestedExamTypes'] ?? <int>[];
|
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('考试类型无效');
|
throw const FormatException('考试类型无效');
|
||||||
}
|
}
|
||||||
final types = rawTypes.cast<int>().toSet();
|
final types = rawTypes.cast<int>().toSet();
|
||||||
|
|||||||
@@ -150,11 +150,13 @@ class ExamsPage extends StatelessWidget {
|
|||||||
style: TextStyle(fontSize: 13, color: ink.withValues(alpha: 0.55)),
|
style: TextStyle(fontSize: 13, color: ink.withValues(alpha: 0.55)),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
if (schedule.school == SupportedSchool.uestcUndergraduate)
|
if (schedule.school == SupportedSchool.uestcUndergraduate) ...[
|
||||||
Text(
|
Text(
|
||||||
'查询类型:${UndergraduateExamQuery.fromSchedule(schedule)!.orderedTypes.map((t) => t.label).join('、')}',
|
'查询类型:${UndergraduateExamQuery.fromSchedule(schedule)!.orderedTypes.map((t) => t.label).join('、')}',
|
||||||
style: const TextStyle(fontSize: 12),
|
style: const TextStyle(fontSize: 12),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
if (schedule.records.isEmpty)
|
if (schedule.records.isEmpty)
|
||||||
Text(
|
Text(
|
||||||
schedule.school == SupportedSchool.uestcUndergraduate
|
schedule.school == SupportedSchool.uestcUndergraduate
|
||||||
|
|||||||
@@ -192,6 +192,14 @@ class HttpFeedbackSender implements FeedbackSender {
|
|||||||
bytes.add(attachment.bytes);
|
bytes.add(attachment.bytes);
|
||||||
write('\r\n');
|
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');
|
write('--$boundary--\r\n');
|
||||||
return bytes.takeBytes();
|
return bytes.takeBytes();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:file_picker/file_picker.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../app/app_theme.dart';
|
import '../../app/app_theme.dart';
|
||||||
|
import '../../services/diagnostic_log.dart';
|
||||||
import 'feedback_submission.dart';
|
import 'feedback_submission.dart';
|
||||||
|
|
||||||
typedef FeedbackAttachmentPicker = Future<FeedbackAttachment?> Function();
|
typedef FeedbackAttachmentPicker = Future<FeedbackAttachment?> Function();
|
||||||
@@ -32,6 +33,7 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
|||||||
var _picking = false;
|
var _picking = false;
|
||||||
var _success = false;
|
var _success = false;
|
||||||
var _uncertain = false;
|
var _uncertain = false;
|
||||||
|
var _includeLogs = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
FeedbackSubmission? _pendingSubmission;
|
FeedbackSubmission? _pendingSubmission;
|
||||||
|
|
||||||
@@ -103,6 +105,7 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
|||||||
if (_submitting || _picking) return;
|
if (_submitting || _picking) return;
|
||||||
if (!_uncertain && !(_form.currentState?.validate() ?? false)) return;
|
if (!_uncertain && !(_form.currentState?.validate() ?? false)) return;
|
||||||
FocusScope.of(context).unfocus();
|
FocusScope.of(context).unfocus();
|
||||||
|
setState(() => _submitting = true);
|
||||||
try {
|
try {
|
||||||
_pendingSubmission ??= _kind == FeedbackKind.problem
|
_pendingSubmission ??= _kind == FeedbackKind.problem
|
||||||
? FeedbackSubmission.problem(
|
? FeedbackSubmission.problem(
|
||||||
@@ -111,6 +114,9 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
|||||||
contact: _contact.text,
|
contact: _contact.text,
|
||||||
attachments: _attachments,
|
attachments: _attachments,
|
||||||
limits: widget.limits,
|
limits: widget.limits,
|
||||||
|
diagnosticLog: _includeLogs
|
||||||
|
? await DiagnosticLog.instance.export()
|
||||||
|
: null,
|
||||||
)
|
)
|
||||||
: FeedbackSubmission.development(
|
: FeedbackSubmission.development(
|
||||||
id: FeedbackSubmission.newId(),
|
id: FeedbackSubmission.newId(),
|
||||||
@@ -256,6 +262,24 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
if (_kind == FeedbackKind.problem) ...[
|
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 SizedBox(height: 10),
|
||||||
const Text(
|
const Text(
|
||||||
'图片或视频(选填)',
|
'图片或视频(选填)',
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ class FeedbackSubmission {
|
|||||||
this.contact,
|
this.contact,
|
||||||
this.school,
|
this.school,
|
||||||
this.attachments = const [],
|
this.attachments = const [],
|
||||||
|
this.diagnosticLog,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory FeedbackSubmission.problem({
|
factory FeedbackSubmission.problem({
|
||||||
@@ -110,18 +111,26 @@ class FeedbackSubmission {
|
|||||||
String? contact,
|
String? contact,
|
||||||
List<FeedbackAttachment> attachments = const [],
|
List<FeedbackAttachment> attachments = const [],
|
||||||
FeedbackLimits limits = FeedbackLimits.defaults,
|
FeedbackLimits limits = FeedbackLimits.defaults,
|
||||||
|
Uint8List? diagnosticLog,
|
||||||
}) {
|
}) {
|
||||||
final text = message.trim();
|
final text = message.trim();
|
||||||
if (text.isEmpty) throw const FeedbackException('请填写问题或建议');
|
if (text.isEmpty) throw const FeedbackException('请填写问题或建议');
|
||||||
if (text.length > 5000) throw const FeedbackException('问题或建议不能超过 5000 个字符');
|
if (text.length > 5000) throw const FeedbackException('问题或建议不能超过 5000 个字符');
|
||||||
_validateContact(contact);
|
_validateContact(contact);
|
||||||
validateAttachments(attachments, limits);
|
validateAttachments(attachments, limits);
|
||||||
|
if (diagnosticLog != null &&
|
||||||
|
(diagnosticLog.isEmpty || diagnosticLog.length > 256 * 1024)) {
|
||||||
|
throw const FeedbackException('日志附件不能超过256 KB');
|
||||||
|
}
|
||||||
return FeedbackSubmission._(
|
return FeedbackSubmission._(
|
||||||
id: id,
|
id: id,
|
||||||
kind: FeedbackKind.problem,
|
kind: FeedbackKind.problem,
|
||||||
message: text,
|
message: text,
|
||||||
contact: _optional(contact),
|
contact: _optional(contact),
|
||||||
attachments: List.unmodifiable(attachments),
|
attachments: List.unmodifiable(attachments),
|
||||||
|
diagnosticLog: diagnosticLog == null
|
||||||
|
? null
|
||||||
|
: Uint8List.fromList(diagnosticLog).asUnmodifiableView(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +160,7 @@ class FeedbackSubmission {
|
|||||||
final String? contact;
|
final String? contact;
|
||||||
final String? school;
|
final String? school;
|
||||||
final List<FeedbackAttachment> attachments;
|
final List<FeedbackAttachment> attachments;
|
||||||
|
final Uint8List? diagnosticLog;
|
||||||
|
|
||||||
static String newId() {
|
static String newId() {
|
||||||
final random = Random.secure();
|
final random = Random.secure();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:webview_flutter/webview_flutter.dart';
|
|||||||
import '../../data/json_schedule_import.dart';
|
import '../../data/json_schedule_import.dart';
|
||||||
import '../../domain/exam_schedule.dart';
|
import '../../domain/exam_schedule.dart';
|
||||||
import '../../domain/school_term.dart';
|
import '../../domain/school_term.dart';
|
||||||
|
import '../../services/diagnostic_log.dart';
|
||||||
import 'school_navigation_policy.dart';
|
import 'school_navigation_policy.dart';
|
||||||
import 'school_page_adapter.dart';
|
import 'school_page_adapter.dart';
|
||||||
import 'school_exam_adapter.dart';
|
import 'school_exam_adapter.dart';
|
||||||
@@ -40,7 +41,10 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
await web.loadRequest(upgrade);
|
await web.loadRequest(upgrade);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (generation == _pageGeneration) {
|
if (generation == _pageGeneration) {
|
||||||
_failure('学校身份认证暂时无法打开,请重试。');
|
_failure(
|
||||||
|
'学校身份认证暂时无法打开,请重试。',
|
||||||
|
code: 'authentication_load_failed',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return NavigationDecision.prevent;
|
return NavigationDecision.prevent;
|
||||||
@@ -55,12 +59,15 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
'Blocked school navigation origin: ${uri?.scheme}://${uri?.host}',
|
'Blocked school navigation origin: ${uri?.scheme}://${uri?.host}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (request.isMainFrame) _failure('学校跳转到了其他地址,请返回学校认证入口重试。');
|
if (request.isMainFrame) {
|
||||||
|
_failure('学校跳转到了其他地址,请返回学校认证入口重试。', code: 'blocked_navigation');
|
||||||
|
}
|
||||||
return NavigationDecision.prevent;
|
return NavigationDecision.prevent;
|
||||||
}
|
}
|
||||||
return NavigationDecision.navigate;
|
return NavigationDecision.navigate;
|
||||||
},
|
},
|
||||||
onPageStarted: (_) {
|
onPageStarted: (url) {
|
||||||
|
DiagnosticLog.instance.navigation('graduate', 'started', url);
|
||||||
_poll?.cancel();
|
_poll?.cancel();
|
||||||
_manualRead = false;
|
_manualRead = false;
|
||||||
_pageGeneration++;
|
_pageGeneration++;
|
||||||
@@ -79,7 +86,11 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
if (hasOpened &&
|
if (hasOpened &&
|
||||||
generation == _pageGeneration &&
|
generation == _pageGeneration &&
|
||||||
error.url == current) {
|
error.url == current) {
|
||||||
_failure('学校页面暂时未能加载,请检查网络后重试。');
|
_failure(
|
||||||
|
'学校页面暂时未能加载,请检查网络后重试。',
|
||||||
|
code: 'web_resource_error',
|
||||||
|
status: error.errorCode,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onHttpError: (error) async {
|
onHttpError: (error) async {
|
||||||
@@ -89,7 +100,11 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
generation == _pageGeneration &&
|
generation == _pageGeneration &&
|
||||||
error.request != null &&
|
error.request != null &&
|
||||||
error.request!.uri == current) {
|
error.request!.uri == current) {
|
||||||
_failure('学校请求未完成,请稍后重试。');
|
_failure(
|
||||||
|
'学校请求未完成,请稍后重试。',
|
||||||
|
code: 'http_error',
|
||||||
|
status: error.response?.statusCode,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -175,6 +190,14 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
bool forceAuth = false,
|
bool forceAuth = false,
|
||||||
}) async {
|
}) async {
|
||||||
final serial = ++_openSerial;
|
final serial = ++_openSerial;
|
||||||
|
DiagnosticLog.instance.add(
|
||||||
|
'login_opened',
|
||||||
|
fields: {
|
||||||
|
'school': 'graduate',
|
||||||
|
'phase': exams ? 'exams' : 'timetable',
|
||||||
|
'term': term?.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
final closing = _logoutFuture;
|
final closing = _logoutFuture;
|
||||||
if (closing != null) await closing;
|
if (closing != null) await closing;
|
||||||
if (serial != _openSerial) return;
|
if (serial != _openSerial) return;
|
||||||
@@ -211,7 +234,7 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
await web.loadRequest(connected ? _target : authUrl);
|
await web.loadRequest(connected ? _target : authUrl);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (serial == _openSerial && generation == _pageGeneration) {
|
if (serial == _openSerial && generation == _pageGeneration) {
|
||||||
_failure('学校入口无法打开,请检查校园网络后重试。');
|
_failure('学校入口无法打开,请检查校园网络后重试。', code: 'entry_load_failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,7 +267,7 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
await web.loadRequest(_target);
|
await web.loadRequest(_target);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (serial == _openSerial && generation == _pageGeneration) {
|
if (serial == _openSerial && generation == _pageGeneration) {
|
||||||
_failure('学校学期查询页面暂时无法打开,请重试。');
|
_failure('学校学期查询页面暂时无法打开,请重试。', code: 'query_load_failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -254,11 +277,20 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
_timeout?.cancel();
|
_timeout?.cancel();
|
||||||
_timeout = Timer(
|
_timeout = Timer(
|
||||||
const Duration(seconds: 45),
|
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;
|
if (!hasOpened || _routeClosed) return;
|
||||||
_pageGeneration++;
|
_pageGeneration++;
|
||||||
_reader.invalidate();
|
_reader.invalidate();
|
||||||
@@ -305,7 +337,7 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
_poll?.cancel();
|
_poll?.cancel();
|
||||||
if (_pollsLeft-- <= 0) {
|
if (_pollsLeft-- <= 0) {
|
||||||
if (finding) _failure('查找超时,请检查学校页面后重试。已有数据保持不变。');
|
if (finding) _failure('查找超时,请检查学校页面后重试。已有数据保持不变。', code: 'read_timeout');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_poll = Timer(const Duration(milliseconds: 750), () {
|
_poll = Timer(const Duration(milliseconds: 750), () {
|
||||||
@@ -320,6 +352,9 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Future<void> _loaded(String url, {bool resetPolling = true}) async {
|
Future<void> _loaded(String url, {bool resetPolling = true}) async {
|
||||||
|
if (resetPolling) {
|
||||||
|
DiagnosticLog.instance.navigation('graduate', 'finished', url);
|
||||||
|
}
|
||||||
if (!hasOpened || _routeClosed) return;
|
if (!hasOpened || _routeClosed) return;
|
||||||
final generation = _pageGeneration;
|
final generation = _pageGeneration;
|
||||||
final uri = Uri.tryParse(url);
|
final uri = Uri.tryParse(url);
|
||||||
@@ -360,6 +395,15 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final evidence = await _evidence(uri);
|
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 ||
|
if (!hasOpened ||
|
||||||
generation != _pageGeneration ||
|
generation != _pageGeneration ||
|
||||||
await web.currentUrl() != url) {
|
await web.currentUrl() != url) {
|
||||||
@@ -384,14 +428,26 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
if (requestingExams) {
|
if (requestingExams) {
|
||||||
final draft = SchoolExamAdapter.extract(page.html);
|
final draft = SchoolExamAdapter.extract(page.html);
|
||||||
if (generation != _pageGeneration) return;
|
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;
|
pendingExams = draft;
|
||||||
} else {
|
} else {
|
||||||
// The probe validates the live document's origin. The strict parser
|
// The probe validates the live document's origin. The strict parser
|
||||||
// gets only its recognized fragment with the canonical table path.
|
// gets only its recognized fragment with the canonical table path.
|
||||||
final draft = await _reader.read(_target, () async => page.html);
|
final draft = await _reader.read(_target, () async => page.html);
|
||||||
if (!hasOpened || generation != _pageGeneration) return;
|
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;
|
pendingDraft = draft;
|
||||||
}
|
}
|
||||||
_timeout?.cancel();
|
_timeout?.cancel();
|
||||||
@@ -399,9 +455,27 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
connectionState = SchoolConnectionState.connected;
|
connectionState = SchoolConnectionState.connected;
|
||||||
finding = true;
|
finding = true;
|
||||||
message = '读取成功,正在保存…';
|
message = '读取成功,正在保存…';
|
||||||
|
DiagnosticLog.instance.add(
|
||||||
|
'school_parsed',
|
||||||
|
fields: {
|
||||||
|
'school': 'graduate',
|
||||||
|
'phase': requestingExams ? 'exams' : 'timetable',
|
||||||
|
'count':
|
||||||
|
pendingDraft?.courses.length ??
|
||||||
|
pendingExams?.records.length ??
|
||||||
|
0,
|
||||||
|
},
|
||||||
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return;
|
return;
|
||||||
} catch (_) {
|
} catch (error) {
|
||||||
|
DiagnosticLog.instance.add(
|
||||||
|
'school_parse_rejected',
|
||||||
|
fields: {
|
||||||
|
'school': 'graduate',
|
||||||
|
'code': error.runtimeType.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
if (generation != _pageGeneration) return;
|
if (generation != _pageGeneration) return;
|
||||||
// A recognized table can still be incomplete during asynchronous load.
|
// A recognized table can still be incomplete during asynchronous load.
|
||||||
}
|
}
|
||||||
@@ -411,6 +485,14 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
? null
|
? null
|
||||||
: _flow.onLoaded(uri, authenticated: evidence.authenticated);
|
: _flow.onLoaded(uri, authenticated: evidence.authenticated);
|
||||||
if (next != null) {
|
if (next != null) {
|
||||||
|
DiagnosticLog.instance.add(
|
||||||
|
'school_redirect_requested',
|
||||||
|
fields: {
|
||||||
|
'school': 'graduate',
|
||||||
|
'phase': requestingExams ? 'exams' : 'timetable',
|
||||||
|
'term': selectedTerm?.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
finding = true;
|
finding = true;
|
||||||
_loading = true;
|
_loading = true;
|
||||||
message = _findingMessage;
|
message = _findingMessage;
|
||||||
@@ -420,14 +502,17 @@ class SchoolSession extends ChangeNotifier {
|
|||||||
await web.loadRequest(next);
|
await web.loadRequest(next);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (generation == _pageGeneration) {
|
if (generation == _pageGeneration) {
|
||||||
_failure('课表查询页面暂时无法打开,请重试。');
|
_failure('课表查询页面暂时无法打开,请重试。', code: 'query_load_failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!finding) message = '请在学校网页完成认证;进入研究生系统后会自动读取。';
|
if (!finding) message = '请在学校网页完成认证;进入研究生系统后会自动读取。';
|
||||||
} catch (_) {
|
} catch (error) {
|
||||||
// JavaScript and frame access can fail briefly while the page is changing.
|
DiagnosticLog.instance.add(
|
||||||
|
'school_probe_failed',
|
||||||
|
fields: {'school': 'graduate', 'code': error.runtimeType.toString()},
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (_probeGeneration == generation) _probeGeneration = null;
|
if (_probeGeneration == generation) _probeGeneration = null;
|
||||||
_scheduleProbe(url, generation);
|
_scheduleProbe(url, generation);
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ List<UndergraduateActivity> parseActivityData(
|
|||||||
r'new\s+TaskActivity\s*\((.*?)\)\s*;',
|
r'new\s+TaskActivity\s*\((.*?)\)\s*;',
|
||||||
dotAll: true,
|
dotAll: true,
|
||||||
).allMatches(script).toList();
|
).allMatches(script).toList();
|
||||||
|
if (RegExp(r'\bnew\s+TaskActivity\s*\(').allMatches(script).length !=
|
||||||
|
matches.length) {
|
||||||
|
throw const FormatException('课程活动声明不完整');
|
||||||
|
}
|
||||||
final result = <UndergraduateActivity>[];
|
final result = <UndergraduateActivity>[];
|
||||||
for (var i = 0; i < matches.length; i++) {
|
for (var i = 0; i < matches.length; i++) {
|
||||||
final m = matches[i], args = _strings(m[1]!);
|
final m = matches[i], args = _strings(m[1]!);
|
||||||
@@ -48,9 +52,17 @@ List<UndergraduateActivity> parseActivityData(
|
|||||||
final assignments = RegExp(r'\bindex\s*=\s*([^;]+);')
|
final assignments = RegExp(r'\bindex\s*=\s*([^;]+);')
|
||||||
.allMatches(body)
|
.allMatches(body)
|
||||||
.toList();
|
.toList();
|
||||||
final appends = RegExp(
|
final appendPattern = RegExp(
|
||||||
r'table\d+\.activities\[index\]\s*\[[^;]+?\]\s*=\s*activity\s*;',
|
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) {
|
if (assignments.isEmpty || assignments.length != appends) {
|
||||||
throw const FormatException('课程节次赋值不完整');
|
throw const FormatException('课程节次赋值不完整');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:webview_flutter/webview_flutter.dart';
|
|||||||
import '../../../data/json_schedule_import.dart';
|
import '../../../data/json_schedule_import.dart';
|
||||||
import '../../../domain/exam_schedule.dart';
|
import '../../../domain/exam_schedule.dart';
|
||||||
import '../../../domain/undergraduate_exam_query.dart';
|
import '../../../domain/undergraduate_exam_query.dart';
|
||||||
|
import '../../../services/diagnostic_log.dart';
|
||||||
import 'navigation_policy.dart';
|
import 'navigation_policy.dart';
|
||||||
import 'page_probe.dart';
|
import 'page_probe.dart';
|
||||||
import 'timetable_adapter.dart';
|
import 'timetable_adapter.dart';
|
||||||
@@ -98,7 +99,9 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
uri,
|
uri,
|
||||||
isMainFrame: request.isMainFrame,
|
isMainFrame: request.isMainFrame,
|
||||||
)) {
|
)) {
|
||||||
if (request.isMainFrame) _failure('学校跳转未完成,请重新认证。');
|
if (request.isMainFrame) {
|
||||||
|
_failure('学校跳转未完成,请重新认证。', code: 'blocked_navigation');
|
||||||
|
}
|
||||||
return NavigationDecision.prevent;
|
return NavigationDecision.prevent;
|
||||||
}
|
}
|
||||||
if (request.isMainFrame &&
|
if (request.isMainFrame &&
|
||||||
@@ -107,7 +110,13 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
return NavigationDecision.navigate;
|
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++;
|
_document++;
|
||||||
_poll?.cancel();
|
_poll?.cancel();
|
||||||
_probeKey = null;
|
_probeKey = null;
|
||||||
@@ -125,7 +134,11 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
d = _document,
|
d = _document,
|
||||||
current = await web.currentUrl();
|
current = await web.currentUrl();
|
||||||
if (_valid(g) && d == _document && error.url == current) {
|
if (_valid(g) && d == _document && error.url == current) {
|
||||||
_failure('学校页面加载未完成,请检查网络后重试。');
|
_failure(
|
||||||
|
'学校页面加载未完成,请检查网络后重试。',
|
||||||
|
code: 'web_resource_error',
|
||||||
|
status: error.errorCode,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onHttpError: (error) async {
|
onHttpError: (error) async {
|
||||||
@@ -135,12 +148,20 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
if (_valid(g) &&
|
if (_valid(g) &&
|
||||||
d == _document &&
|
d == _document &&
|
||||||
error.request?.uri.toString() == current) {
|
error.request?.uri.toString() == current) {
|
||||||
_failure('学校请求未完成,请稍后重试。');
|
_failure(
|
||||||
|
'学校请求未完成,请稍后重试。',
|
||||||
|
code: 'http_error',
|
||||||
|
status: error.response?.statusCode,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
Future<void> openTimetable() async {
|
Future<void> openTimetable() async {
|
||||||
|
DiagnosticLog.instance.add(
|
||||||
|
'login_opened',
|
||||||
|
fields: {'school': 'undergraduate', 'phase': 'timetable'},
|
||||||
|
);
|
||||||
await _logoutFuture;
|
await _logoutFuture;
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
_reset();
|
_reset();
|
||||||
@@ -153,6 +174,14 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> openExams(UndergraduateExamQuery value) async {
|
Future<void> openExams(UndergraduateExamQuery value) async {
|
||||||
|
DiagnosticLog.instance.add(
|
||||||
|
'login_opened',
|
||||||
|
fields: {
|
||||||
|
'school': 'undergraduate',
|
||||||
|
'phase': 'exams',
|
||||||
|
'term': value.term.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
await _logoutFuture;
|
await _logoutFuture;
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
_reset();
|
_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;
|
if (_disposed) return;
|
||||||
_reset();
|
_reset();
|
||||||
_failed = true;
|
_failed = true;
|
||||||
@@ -233,6 +271,7 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
uri,
|
uri,
|
||||||
);
|
);
|
||||||
Future<void> _loaded(String url) async {
|
Future<void> _loaded(String url) async {
|
||||||
|
DiagnosticLog.instance.navigation('undergraduate', 'finished', url);
|
||||||
final g = _generation, d = _document, key = '$_generation:$_document';
|
final g = _generation, d = _document, key = '$_generation:$_document';
|
||||||
if (!_valid(g) || _failed || _complete || _probeKey == key) return;
|
if (!_valid(g) || _failed || _complete || _probeKey == key) return;
|
||||||
_probeKey = key;
|
_probeKey = key;
|
||||||
@@ -245,6 +284,14 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final status = await _evidence(uri, false);
|
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;
|
if (!_valid(g) || d != _document) return;
|
||||||
_loading = false;
|
_loading = false;
|
||||||
if (!status.authenticated) {
|
if (!status.authenticated) {
|
||||||
@@ -283,6 +330,14 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
_itemTimeout = null;
|
_itemTimeout = null;
|
||||||
if (_index == query!.orderedTypes.length) {
|
if (_index == query!.orderedTypes.length) {
|
||||||
pendingExams = UndergraduateExamAdapter.merge(query!, _parts);
|
pendingExams = UndergraduateExamAdapter.merge(query!, _parts);
|
||||||
|
DiagnosticLog.instance.add(
|
||||||
|
'school_parsed',
|
||||||
|
fields: {
|
||||||
|
'school': 'undergraduate',
|
||||||
|
'phase': 'exams',
|
||||||
|
'count': pendingExams!.records.length,
|
||||||
|
},
|
||||||
|
);
|
||||||
_complete = true;
|
_complete = true;
|
||||||
_cancelTimers();
|
_cancelTimers();
|
||||||
message = '考试已全部读取,正在保存…';
|
message = '考试已全部读取,正在保存…';
|
||||||
@@ -291,7 +346,9 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
await _requestExam(g);
|
await _requestExam(g);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (_valid(g) && d == _document) _failure('$error');
|
if (_valid(g) && d == _document) {
|
||||||
|
_failure('$error', code: error.runtimeType.toString());
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (_probeKey == key) _probeKey = null;
|
if (_probeKey == key) _probeKey = null;
|
||||||
}
|
}
|
||||||
@@ -300,16 +357,24 @@ class UndergraduateSession extends ChangeNotifier {
|
|||||||
Future<void> _requestExam(int g) async {
|
Future<void> _requestExam(int g) async {
|
||||||
if (!_valid(g) || query == null || _complete) return;
|
if (!_valid(g) || query == null || _complete) return;
|
||||||
final type = query!.orderedTypes[_index];
|
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);
|
_expected = query!.uriFor(type);
|
||||||
_loading = true;
|
_loading = true;
|
||||||
message = '正在读取${type.label}(${_index + 1}/${query!.types.length})';
|
message = '正在读取${type.label}(${_index + 1}/${query!.types.length})';
|
||||||
_emit();
|
_emit();
|
||||||
_itemTimeout?.cancel();
|
_itemTimeout?.cancel();
|
||||||
_itemTimeout = Timer(const Duration(seconds: 45), () {
|
_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), () {
|
_batchTimeout ??= Timer(const Duration(seconds: 120), () {
|
||||||
if (_valid(g)) _failure('考试读取超时,请重试。');
|
if (_valid(g)) _failure('考试读取超时,请重试。', code: 'batch_timeout');
|
||||||
});
|
});
|
||||||
await _navigate(_expected!);
|
await _navigate(_expected!);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import 'package:timezone/data/latest.dart' as tzdata;
|
import 'package:timezone/data/latest.dart' as tzdata;
|
||||||
|
|
||||||
import 'app/app_controller.dart';
|
import 'app/app_controller.dart';
|
||||||
|
import 'app/app_version.dart';
|
||||||
|
import 'services/diagnostic_log.dart';
|
||||||
import 'app/hepan_app.dart';
|
import 'app/hepan_app.dart';
|
||||||
import 'app/tp_startup.dart';
|
import 'app/tp_startup.dart';
|
||||||
import 'data/file_snapshot_store.dart';
|
import 'data/file_snapshot_store.dart';
|
||||||
@@ -37,6 +39,7 @@ Widget buildApplication(
|
|||||||
);
|
);
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
DiagnosticLog.instance.add('app_started', fields: {'version': appVersion});
|
||||||
AppController? controller;
|
AppController? controller;
|
||||||
await launchApplication(
|
await launchApplication(
|
||||||
bootstrap: () async {
|
bootstrap: () async {
|
||||||
@@ -45,8 +48,10 @@ Future<void> main() async {
|
|||||||
final gateway = PluginReminderGateway(
|
final gateway = PluginReminderGateway(
|
||||||
onTap: (key) => selection.value = key,
|
onTap: (key) => selection.value = key,
|
||||||
);
|
);
|
||||||
|
final directory = await getApplicationSupportDirectory();
|
||||||
|
unawaited(DiagnosticLog.instance.attach(directory));
|
||||||
final value = AppController(
|
final value = AppController(
|
||||||
FileSnapshotStore(await getApplicationSupportDirectory()),
|
FileSnapshotStore(directory),
|
||||||
LocalNotificationService(gateway),
|
LocalNotificationService(gateway),
|
||||||
AndroidCalendarService(MethodCalendarGateway()),
|
AndroidCalendarService(MethodCalendarGateway()),
|
||||||
DateTime.now,
|
DateTime.now,
|
||||||
|
|||||||
@@ -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)}]}';
|
'{"schemaVersion":1,"courses":[${const JsonEncoder().convert(old)}]}';
|
||||||
expect(() => JsonScheduleImport.parse(json), throwsFormatException);
|
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';
|
import 'package:hepan_everyday/domain/course.dart';
|
||||||
|
|
||||||
void main() {
|
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(
|
test(
|
||||||
'managed undergraduate identifiers cannot lose their school or term',
|
'managed undergraduate identifiers cannot lose their school or term',
|
||||||
() {
|
() {
|
||||||
|
|||||||
@@ -33,6 +33,27 @@ class FakeFeedbackHttpTransport implements FeedbackHttpTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
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(
|
test(
|
||||||
'matching uncertain delivery is distinct and never exposes server detail',
|
'matching uncertain delivery is distinct and never exposes server detail',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -31,10 +31,29 @@ Future<void> reveal(
|
|||||||
backwards ? -250 : 250,
|
backwards ? -250 : 250,
|
||||||
scrollable: find.byType(Scrollable).first,
|
scrollable: find.byType(Scrollable).first,
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
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', (
|
testWidgets('uncertain delivery retains original payload for status check', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -191,7 +210,7 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.enterText(find.byKey(const Key('feedback-message')), '旧问题');
|
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.tap(find.byKey(const Key('feedback-add-media')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await reveal(tester, find.text('我要参与开发'), backwards: true);
|
await reveal(tester, find.text('我要参与开发'), backwards: true);
|
||||||
@@ -303,7 +322,7 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.enterText(find.byKey(const Key('feedback-message')), '问题');
|
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.tap(find.byKey(const Key('feedback-add-media')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('screen.png'), findsOneWidget);
|
expect(find.text('screen.png'), findsOneWidget);
|
||||||
@@ -312,7 +331,7 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(sender.submissions.single.attachments, hasLength(1));
|
expect(sender.submissions.single.attachments, hasLength(1));
|
||||||
expect(find.text('screen.png'), findsOneWidget);
|
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.tap(find.byTooltip('移除 screen.png'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('screen.png'), findsNothing);
|
expect(find.text('screen.png'), findsNothing);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:hepan_everyday/services/diagnostic_log.dart';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart';
|
import 'package:webview_flutter_platform_interface/webview_flutter_platform_interface.dart';
|
||||||
@@ -26,6 +29,30 @@ void main() {
|
|||||||
platform.delegate.finished(url);
|
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 {
|
test('graduate logout function automatically opens timetable without an exit anchor', () async {
|
||||||
await session.openTimetable(term: SchoolTerm(73));
|
await session.openTimetable(term: SchoolTerm(73));
|
||||||
platform.web.javascript = (script) async =>
|
platform.web.javascript = (script) async =>
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ void main() {
|
|||||||
await finish();
|
await finish();
|
||||||
expect(session.pendingExams!.records, hasLength(12));
|
expect(session.pendingExams!.records, hasLength(12));
|
||||||
expect(session.pendingExams!.requestedExamTypes, {1, 2, 3});
|
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 {
|
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';
|
import 'package:hepan_everyday/domain/school_identity.dart';
|
||||||
|
|
||||||
void main() {
|
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(
|
final uri = Uri.parse(
|
||||||
'https://eams.uestc.edu.cn/eams/courseTableForStd.action',
|
'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));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 |
|
| `contact` | Optional, max 200 characters | Required, nonblank, max 200 characters |
|
||||||
| `school` | Must be omitted | 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 |
|
| `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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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邮件。
|
||||||
@@ -29,7 +29,8 @@ from starlette.concurrency import run_in_threadpool
|
|||||||
MIB = 1024 * 1024
|
MIB = 1024 * 1024
|
||||||
FILE_LIMIT = 8 * MIB
|
FILE_LIMIT = 8 * MIB
|
||||||
TOTAL_FILE_LIMIT = 20 * 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")
|
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")
|
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):
|
async def parse_submission(request):
|
||||||
try:
|
try:
|
||||||
async with request.form(max_files=3, max_fields=5, max_part_size=20000) as form:
|
async with request.form(max_files=4, max_fields=5, max_part_size=20000) as form:
|
||||||
fields, uploads = {}, []
|
fields, uploads, diagnostic_upload = {}, [], None
|
||||||
for name, value in form.multi_items():
|
for name, value in form.multi_items():
|
||||||
if isinstance(value, UploadFile):
|
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":
|
if name != "attachments":
|
||||||
raise ApiError(422, "validation_error", "Unexpected file field.")
|
raise ApiError(422, "validation_error", "Unexpected file field.")
|
||||||
uploads.append(value)
|
uploads.append(value)
|
||||||
@@ -387,10 +393,12 @@ async def parse_submission(request):
|
|||||||
if kind == "problem":
|
if kind == "problem":
|
||||||
if not fields.get("message", "").strip() or "school" in fields:
|
if not fields.get("message", "").strip() or "school" in fields:
|
||||||
raise ApiError(422, "validation_error", "Problem feedback requires message and must omit school.")
|
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()):
|
or not fields.get("contact", "").strip()):
|
||||||
raise ApiError(422, "validation_error", "Development requests require school and contact; omit message and attachments.")
|
raise ApiError(422, "validation_error", "Development requests require school and contact; omit message and attachments.")
|
||||||
attachments, total = [], 0
|
attachments, total = [], 0
|
||||||
|
if len(uploads) > 3:
|
||||||
|
raise ApiError(413, "upload_too_large", "Maximum three media files.")
|
||||||
for upload in uploads:
|
for upload in uploads:
|
||||||
data = await upload.read(FILE_LIMIT + 1)
|
data = await upload.read(FILE_LIMIT + 1)
|
||||||
total += len(data)
|
total += len(data)
|
||||||
@@ -401,6 +409,17 @@ async def parse_submission(request):
|
|||||||
if not valid:
|
if not valid:
|
||||||
raise ApiError(415, "unsupported_media", "Attachment MIME type and file signature must match a supported image or video.")
|
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))
|
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
|
return fields, attachments
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
detail = str(exc.detail).lower()
|
detail = str(exc.detail).lower()
|
||||||
|
|||||||
@@ -62,6 +62,40 @@ def test_success_only_after_sender_accepted_and_retry_is_durable(service):
|
|||||||
assert len(sender.messages) == 1
|
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):
|
def test_reusing_id_with_other_content_rejected(service):
|
||||||
client, sender, _ = service
|
client, sender, _ = service
|
||||||
assert post(client).status_code == 200
|
assert post(client).status_code == 200
|
||||||
|
|||||||
Reference in New Issue
Block a user