diff --git a/lib/main.dart b/lib/main.dart index 0065bca..c9dabcc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -91,9 +91,12 @@ Future _realMain(SharedPreferences prefs) async { // Cloud-sync push hook: after any binding mutation, best-effort push the new // state (throttled). Wired via post-construction setter to avoid a circular // dependency between ThirdPartyAuthService and SyncService. - thirdPartyAuthService.onBindingsChanged = () { - return syncService.pushIfDue(); + thirdPartyAuthService.onBindingsChanged = ({force = false}) { + return force ? syncService.forcePush() : syncService.pushIfDue(); }; + // Cloud-sync tombstone hook: record a deletion so the next LWW merge does + // not resurrect an older remote copy of the unbound platform. + thirdPartyAuthService.onUnbind = syncService.recordTombstone; // Cloud-sync pull hook: after a fresh SSO login, pull cloud bindings onto // this device (or surface the master-password restore prompt). @@ -166,7 +169,7 @@ Future _realMain(SharedPreferences prefs) async { ); } - if (thirdPartyAuthService.hasEgateBinding) { + if (thirdPartyAuthService.hasCpdailyBinding) { await scheduleService.fetchAll(); } if (authService.isLoggedIn || diff --git a/lib/models/third_party_account.dart b/lib/models/third_party_account.dart index 90f8ad2..52c4c68 100644 --- a/lib/models/third_party_account.dart +++ b/lib/models/third_party_account.dart @@ -1,23 +1,37 @@ enum ThirdPartyPlatform { gradescope, hydro, - egate; + cpdaily; + /// Stable id used for storage keys and cloud-sync payloads. Note: the + /// backend bind route for cpdaily is still 'egate' (unrenamed); see + /// [apiPath]. String get id => name; String get label => switch (this) { ThirdPartyPlatform.gradescope => 'Gradescope', ThirdPartyPlatform.hydro => 'Hydro', - ThirdPartyPlatform.egate => 'eGate / IDS', + ThirdPartyPlatform.cpdaily => 'CpDaily / IDS', }; + /// Backend bind/renew route name. cpdaily maps to 'egate' (the backend + /// route was not renamed); all others map to their own [id]. + String get apiPath => switch (this) { + ThirdPartyPlatform.cpdaily => 'egate', + _ => id, + }; + + /// Parse a platform id, accepting the legacy 'egate' alias for cpdaily. static ThirdPartyPlatform? fromId(String id) { - for (final p in ThirdPartyPlatform.values) { - if (p.id == id) return p; - } - return null; + return switch (id) { + 'gradescope' => ThirdPartyPlatform.gradescope, + 'hydro' => ThirdPartyPlatform.hydro, + 'cpdaily' || 'egate' => ThirdPartyPlatform.cpdaily, + _ => null, + }; } } + class ThirdPartyAccount { final ThirdPartyPlatform platform; final String account; @@ -36,6 +50,20 @@ class ThirdPartyAccount { final bool autoRenew; final String? password; + /// Wall-clock timestamp of the most recent local mutation of this account + /// (bind / rebind / renew / raw update). Used by the cloud-sync merge to do + /// per-account last-writer-wins with [deviceId] as a deterministic + /// tie-breaker. Defaults to [boundAt] for accounts created before this + /// field existed (back-compat: treated as "ancient" so any newer write + /// wins). + final DateTime updatedAt; + + /// Stable id of the device that produced the current [updatedAt] bump. Used + /// as the LWW tie-breaker so two devices with skewed clocks still converge + /// deterministically. Empty for legacy accounts; newer devices always win + /// against an empty deviceId. + final String deviceId; + const ThirdPartyAccount({ required this.platform, required this.account, @@ -50,24 +78,36 @@ class ThirdPartyAccount { required this.boundAt, this.autoRenew = false, this.password, - }); + DateTime? updatedAt, + String? deviceId, + }) : updatedAt = updatedAt ?? boundAt, + deviceId = deviceId ?? ''; DateTime? get expireAt => expire == null ? null : DateTime.fromMillisecondsSinceEpoch(expire! * 1000); - bool get isExpired { - final at = expireAt; - return at != null && at.isBefore(DateTime.now()); - } + bool get isExpired => + expireAt != null && DateTime.now().isAfter(expireAt!); String get displayName { if (name != null && name!.isNotEmpty) return name!; - if (email != null && email!.isNotEmpty) return email!; - if (sid != null && sid!.isNotEmpty) return sid!; return account; } + /// Comparison key for LWW merge: newer [updatedAt] wins; on a tie the + /// lexicographically-larger [deviceId] wins (deterministic, total order). + /// An empty deviceId is treated as oldest of all so legacy data loses to + /// any real device write. + int compareVersionTo(ThirdPartyAccount other) { + final c = updatedAt.compareTo(other.updatedAt); + if (c != 0) return c; + // Empty deviceId always loses. + if (deviceId.isEmpty && other.deviceId.isNotEmpty) return -1; + if (deviceId.isNotEmpty && other.deviceId.isEmpty) return 1; + return deviceId.compareTo(other.deviceId); + } + Map toJson() => { 'platform': platform.id, 'account': account, @@ -82,9 +122,13 @@ class ThirdPartyAccount { 'boundAt': boundAt.toIso8601String(), 'autoRenew': autoRenew, if (password != null) 'password': password, + 'updatedAt': updatedAt.toIso8601String(), + 'deviceId': deviceId, }; factory ThirdPartyAccount.fromJson(Map json) { + final boundAt = + DateTime.tryParse(json['boundAt'] as String? ?? '') ?? DateTime.now(); return ThirdPartyAccount( platform: ThirdPartyPlatform.fromId(json['platform'] as String? ?? '') ?? ThirdPartyPlatform.gradescope, @@ -98,10 +142,14 @@ class ThirdPartyAccount { hydroOrigin: json['hydroOrigin'] as String?, hydroDomains: (json['hydroDomains'] as List?)?.map((e) => e as String).toList(), - boundAt: - DateTime.tryParse(json['boundAt'] as String? ?? '') ?? DateTime.now(), + boundAt: boundAt, autoRenew: json['autoRenew'] as bool? ?? false, password: json['password'] as String?, + // Back-compat: pre-v2 blobs had no updatedAt/deviceId. Fall back to + // boundAt / empty so they merge as "older than any real write". + updatedAt: + DateTime.tryParse(json['updatedAt'] as String? ?? '') ?? boundAt, + deviceId: json['deviceId'] as String? ?? '', ); } @@ -116,6 +164,8 @@ class ThirdPartyAccount { List? hydroDomains, bool? autoRenew, String? password, + DateTime? updatedAt, + String? deviceId, }) { return ThirdPartyAccount( platform: platform, @@ -131,6 +181,8 @@ class ThirdPartyAccount { boundAt: boundAt, autoRenew: autoRenew ?? this.autoRenew, password: password ?? this.password, + updatedAt: updatedAt ?? this.updatedAt, + deviceId: deviceId ?? this.deviceId, ); } } diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 9c4ba41..9d2408d 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -394,17 +394,18 @@ class _HomePageState extends State with TickerProviderStateMixin { if (cookieType == null) return cookies; final sp = ServiceProvider.of(context); - // ecourse and student-leave both authenticate against the eGate/IDS - // SSO, so they share the eGate binding's CpDaily cookies (which always - // include CASTGC via [ThirdPartyAuthService.egateCookies]). Without an - // eGate binding there is nothing to inject and the webview opens - // unauthenticated — the UI should steer the user to bind eGate first. - final String rawCookies = sp.thirdPartyAuthService.egateCookies(); - if (rawCookies.isEmpty) return cookies; - - final domain = 'ids.shanghaitech.edu.cn'; - - for (final part in rawCookies.split(';')) { + // ecourse / student-leave / eams webviews all authenticate against the + // CpDaily session, whose cookies are exposed by the cpdaily session node + // (the CASTGC-bearing cookie set that webviews consume directly). Read + // it through the unified [CookieProvider] view so the source is abstracted. + final cp = sp.thirdPartyAuthService.cpdailyNode.cookieProvider; + if (cp == null || cp.isEmpty) return cookies; + + final domain = cp.domain.isNotEmpty + ? cp.domain + : 'ids.shanghaitech.edu.cn'; + + for (final part in cp.cookies.split(';')) { final idx = part.indexOf('='); if (idx > 0) { final key = part.substring(0, idx).trim(); diff --git a/lib/pages/oa_gym_page.dart b/lib/pages/oa_gym_page.dart index b9ed739..da30ff7 100644 --- a/lib/pages/oa_gym_page.dart +++ b/lib/pages/oa_gym_page.dart @@ -92,7 +92,7 @@ class _OaGymPageState extends State body: ListenableBuilder( listenable: Listenable.merge([auth, tpAuth]), builder: (context, _) { - final ready = auth.isLoggedIn && tpAuth.hasEgateBinding; + final ready = auth.isLoggedIn && tpAuth.hasCpdailyBinding; return ready ? Column( children: [ @@ -973,15 +973,15 @@ class _ProfileTabState extends State<_ProfileTab> { Widget build(BuildContext context) { final sp = ServiceProvider.of(context); final tpAuth = sp.thirdPartyAuthService; - final egate = tpAuth.egateBinding; + final cpdaily = tpAuth.cpdailyBinding; - // Identity card prefers eGate binding (real name + student id) over the + // Identity card prefers cpdaily binding (real name + student id) over the // primary SSO account, whose userName/userId are Casdoor UUIDs with no // meaning in the OA booking context. - final displayName = egate?.name?.isNotEmpty == true - ? egate!.name! - : (egate?.account.isNotEmpty == true ? egate!.account : 'TechPie 用户'); - final studentId = egate?.sid ?? ''; + final displayName = cpdaily?.name?.isNotEmpty == true + ? cpdaily!.name! + : (cpdaily?.account.isNotEmpty == true ? cpdaily!.account : 'TechPie 用户'); + final studentId = cpdaily?.sid ?? ''; final avatarText = displayName.characters.firstOrNull ?? 'U'; return ListView( diff --git a/lib/pages/schedule_page.dart b/lib/pages/schedule_page.dart index 88b1c2a..78785ab 100644 --- a/lib/pages/schedule_page.dart +++ b/lib/pages/schedule_page.dart @@ -729,7 +729,7 @@ class _SchedulePageState extends State { ? 0 : adaptiveTopBarHeight() + MediaQuery.viewPaddingOf(context).top, ), - child: !auth.isLoggedIn || !tpAuth.hasEgateBinding + child: !auth.isLoggedIn || !tpAuth.hasCpdailyBinding ? Center( child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index af94773..95ce26e 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -106,7 +106,7 @@ class _SettingsPageState extends State { ].join(' · '), ), ), - _EgateBindingTile(tpAuth: tpAuth), + _CpdailyBindingTile(tpAuth: tpAuth), ListTile( leading: const Icon(Icons.account_tree_outlined), title: const Text('Linked accounts'), @@ -558,15 +558,15 @@ class _AdaptiveSwitchTile extends StatelessWidget { } } -class _EgateBindingTile extends StatelessWidget { +class _CpdailyBindingTile extends StatelessWidget { final ThirdPartyAuthService tpAuth; - const _EgateBindingTile({required this.tpAuth}); + const _CpdailyBindingTile({required this.tpAuth}); @override Widget build(BuildContext context) { - final egate = tpAuth.account(ThirdPartyPlatform.egate); - final bound = egate != null; + final cpdaily = tpAuth.account(ThirdPartyPlatform.cpdaily); + final bound = cpdaily != null; final theme = Theme.of(context); return ListTile( @@ -574,10 +574,10 @@ class _EgateBindingTile extends StatelessWidget { bound ? Icons.vpn_key : Icons.vpn_key_outlined, color: bound ? theme.colorScheme.primary : null, ), - title: const Text('eGate / IDS'), + title: const Text('CpDaily / IDS'), subtitle: Text( bound - ? '已绑定 · ${egate.name ?? egate.sid ?? egate.account}' + ? '已绑定 · ${cpdaily.name ?? cpdaily.sid ?? cpdaily.account}' : '未绑定 · 需要绑定以启用课表和考试功能', ), trailing: bound diff --git a/lib/pages/third_party_accounts_page.dart b/lib/pages/third_party_accounts_page.dart index d75ed1d..ee83e4f 100644 --- a/lib/pages/third_party_accounts_page.dart +++ b/lib/pages/third_party_accounts_page.dart @@ -120,7 +120,7 @@ class _ThirdPartyTile extends StatelessWidget { IconData get _icon => switch (platform) { ThirdPartyPlatform.gradescope => Icons.grading_outlined, ThirdPartyPlatform.hydro => Icons.terminal_outlined, - ThirdPartyPlatform.egate => Icons.vpn_key_outlined, + ThirdPartyPlatform.cpdaily => Icons.vpn_key_outlined, }; @override diff --git a/lib/pages/third_party_bind_page.dart b/lib/pages/third_party_bind_page.dart index eb8c22a..c409ce2 100644 --- a/lib/pages/third_party_bind_page.dart +++ b/lib/pages/third_party_bind_page.dart @@ -39,17 +39,17 @@ class _ThirdPartyBindPageState extends State { bool _autoRenew = false; String? _inlineError; - // eGate SMS state - int _egateLoginMethod = 0; // 0 = password, 1 = SMS - final _egatePhoneCtrl = TextEditingController(); - final _egateCodeCtrl = TextEditingController(); + // cpdaily SMS state + int _cpdailyLoginMethod = 0; // 0 = password, 1 = SMS + final _cpdailyPhoneCtrl = TextEditingController(); + final _cpdailyCodeCtrl = TextEditingController(); bool _sendingSms = false; int _smsCooldown = 0; Timer? _smsCooldownTimer; bool get _isHydro => widget.platform == ThirdPartyPlatform.hydro; bool get _isGradescope => widget.platform == ThirdPartyPlatform.gradescope; - bool get _isEgate => widget.platform == ThirdPartyPlatform.egate; + bool get _isCpdaily => widget.platform == ThirdPartyPlatform.cpdaily; Future _dismissKeyboard() async { FocusManager.instance.primaryFocus?.unfocus(); @@ -64,8 +64,8 @@ class _ThirdPartyBindPageState extends State { _passwordCtrl.dispose(); _hydroOriginCtrl.dispose(); _hydroDomainsCtrl.dispose(); - _egatePhoneCtrl.dispose(); - _egateCodeCtrl.dispose(); + _cpdailyPhoneCtrl.dispose(); + _cpdailyCodeCtrl.dispose(); _smsCooldownTimer?.cancel(); super.dispose(); } @@ -104,17 +104,17 @@ class _ThirdPartyBindPageState extends State { setState(() => _autoRenew = ok == true); } - // -- eGate SMS methods -- + // -- cpdaily SMS methods -- - Future _sendEgateSms() async { - final phone = _egatePhoneCtrl.text.trim(); + Future _sendCpdailySms() async { + final phone = _cpdailyPhoneCtrl.text.trim(); if (phone.isEmpty) return; setState(() => _sendingSms = true); try { await ServiceProvider.of(context) .thirdPartyAuthService - .sendEgateSmsCode(phone); + .sendCpdailySmsCode(phone); if (mounted) { setState(() => _inlineError = null); _smsCooldown = 60; @@ -159,11 +159,11 @@ class _ThirdPartyBindPageState extends State { } try { - if (_isEgate && _egateLoginMethod == 1) { + if (_isCpdaily && _cpdailyLoginMethod == 1) { // SMS mode - await tpAuth.bindEgateSms( - phone: _egatePhoneCtrl.text.trim(), - code: _egateCodeCtrl.text.trim(), + await tpAuth.bindCpdailySms( + phone: _cpdailyPhoneCtrl.text.trim(), + code: _cpdailyCodeCtrl.text.trim(), ); } else { await tpAuth.bind( @@ -215,12 +215,12 @@ class _ThirdPartyBindPageState extends State { } bool _validateForSubmit() { - if (_isEgate && _egateLoginMethod == 1) { + if (_isCpdaily && _cpdailyLoginMethod == 1) { // SMS mode validation String? message; - if (_egatePhoneCtrl.text.trim().isEmpty) { + if (_cpdailyPhoneCtrl.text.trim().isEmpty) { message = '请填写手机号码'; - } else if (_egateCodeCtrl.text.trim().isEmpty) { + } else if (_cpdailyCodeCtrl.text.trim().isEmpty) { message = '请填写验证码'; } setState(() => _inlineError = message); @@ -277,19 +277,19 @@ class _ThirdPartyBindPageState extends State { _InlineBindFeedback(message: _inlineError!), const SizedBox(height: 12), ], - if (_isEgate) ...[ - // eGate: tabbed password / SMS interface + if (_isCpdaily) ...[ + // cpdaily: tabbed password / SMS interface SegmentedButton( segments: const [ ButtonSegment(value: 0, label: Text('密码登录')), ButtonSegment(value: 1, label: Text('短信登录')), ], - selected: {_egateLoginMethod}, + selected: {_cpdailyLoginMethod}, onSelectionChanged: (v) => - setState(() => _egateLoginMethod = v.first), + setState(() => _cpdailyLoginMethod = v.first), ), const SizedBox(height: 16), - if (_egateLoginMethod == 0) ...[ + if (_cpdailyLoginMethod == 0) ...[ TextFormField( controller: _accountCtrl, decoration: const InputDecoration( @@ -317,7 +317,7 @@ class _ThirdPartyBindPageState extends State { ), ] else ...[ TextFormField( - controller: _egatePhoneCtrl, + controller: _cpdailyPhoneCtrl, keyboardType: TextInputType.phone, decoration: const InputDecoration( labelText: '手机号码', @@ -331,7 +331,7 @@ class _ThirdPartyBindPageState extends State { children: [ Expanded( child: TextFormField( - controller: _egateCodeCtrl, + controller: _cpdailyCodeCtrl, keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: '验证码', @@ -345,7 +345,7 @@ class _ThirdPartyBindPageState extends State { FilledButton.tonal( onPressed: (_smsCooldown > 0 || _sendingSms) ? null - : () => unawaited(_sendEgateSms()), + : () => unawaited(_sendCpdailySms()), style: FilledButton.styleFrom( minimumSize: const Size(100, 56), ), @@ -504,19 +504,19 @@ class _ThirdPartyBindPageState extends State { _InlineBindFeedback(message: _inlineError!), const SizedBox(height: 16), ], - if (_isEgate) ...[ + if (_isCpdaily) ...[ IosNativeSegmentedControl( - value: _egateLoginMethod, + value: _cpdailyLoginMethod, segments: const ['密码登录', '短信登录'], onChanged: (value) { setState(() { - _egateLoginMethod = value; + _cpdailyLoginMethod = value; _inlineError = null; }); }, ), const SizedBox(height: 18), - if (_egateLoginMethod == 0) + if (_cpdailyLoginMethod == 0) IosNativeTextFieldGroup( items: [ IosNativeTextFieldGroupItem( @@ -538,7 +538,7 @@ class _ThirdPartyBindPageState extends State { items: [ IosNativeTextFieldGroupItem( placeholder: '手机号码', - controller: _egatePhoneCtrl, + controller: _cpdailyPhoneCtrl, keyboardType: TextInputType.phone, textInputAction: TextInputAction.next, ), @@ -552,7 +552,7 @@ class _ThirdPartyBindPageState extends State { items: [ IosNativeTextFieldGroupItem( placeholder: '验证码', - controller: _egateCodeCtrl, + controller: _cpdailyCodeCtrl, keyboardType: TextInputType.number, textInputAction: TextInputAction.done, onSubmitted: (_) => unawaited(_submit()), @@ -564,7 +564,7 @@ class _ThirdPartyBindPageState extends State { FilledButton.tonal( onPressed: (_smsCooldown > 0 || _sendingSms) ? null - : () => unawaited(_sendEgateSms()), + : () => unawaited(_sendCpdailySms()), style: FilledButton.styleFrom( minimumSize: const Size(100, 56), ), @@ -639,7 +639,7 @@ class _ThirdPartyBindPageState extends State { ), ), ], - if (!_isEgate || _egateLoginMethod == 0) ...[ + if (!_isCpdaily || _cpdailyLoginMethod == 0) ...[ const SizedBox(height: 16), MergeSemantics( child: ListTile( diff --git a/lib/services/assignment_service.dart b/lib/services/assignment_service.dart index 7b0c896..5808150 100644 --- a/lib/services/assignment_service.dart +++ b/lib/services/assignment_service.dart @@ -12,6 +12,7 @@ import 'api_base_url.dart'; import 'auth_service.dart'; import 'http_client.dart'; import 'schedule_service.dart'; +import 'session/session_tree.dart'; import 'storage_service.dart'; import 'third_party_auth_service.dart'; @@ -60,30 +61,58 @@ class AssignmentService extends ChangeNotifier { // Refetch when bindings or auth change *after* initial app boot. // The initial fetch is kicked off explicitly from main.dart so we // don't double-fire during service initialization. - _tpAuth.addListener(_onDepsChanged); - _auth.addListener(_onDepsChanged); - _schedule.addListener(_onDepsChanged); + _tpAuth.addListener(_onBindingsOrAuthChanged); + _auth.addListener(_onBindingsOrAuthChanged); + _schedule.addListener(_onScheduleChanged); } bool _autoRefetchEnabled = false; + // Track the last semester we refetched for, so schedule notifies that + // don't change the semester (loading flips, course_table updates, errors) + // do NOT trigger a redundant assignment refetch. + String? _lastRefetchedSemesterId; /// Allow auto-refetch on auth/binding changes. Call after the first /// explicit fetch from app boot has been kicked off. - void enableAutoRefetch() => _autoRefetchEnabled = true; + void enableAutoRefetch() { + _autoRefetchEnabled = true; + // Seed so the first schedule notify (which doesn't change the semester) + // doesn't trigger a redundant refetch of the same semester. + _lastRefetchedSemesterId = _schedule.selectedSemesterId; + } + + /// Auth or binding changed — always refetch (tokens, accounts differ). + void _onBindingsOrAuthChanged() { + if (!_autoRefetchEnabled) return; + _lastRefetchedSemesterId = _schedule.selectedSemesterId; + unawaited(fetchAssignments()); + } - void _onDepsChanged() { + /// Schedule changed — only refetch if the selected semester actually + /// changed, not on every loading/error/course_table flip. This prevents + /// a cascade of redundant blackboard+exam fetches during a semester switch. + /// Also defers the refetch while selectSemester is mid-fetch (its + /// course_table request primes the EAMS session; firing exam_table + /// concurrently would race on EAMS's stateful session and fail with + /// "Failed to extract numeric ids"). + void _onScheduleChanged() { if (!_autoRefetchEnabled) return; + if (_schedule.suppressAssignmentRefetch) return; + final currentSemester = _schedule.selectedSemesterId; + if (currentSemester == _lastRefetchedSemesterId) return; + _lastRefetchedSemesterId = currentSemester; unawaited(fetchAssignments()); } @override void dispose() { - _tpAuth.removeListener(_onDepsChanged); - _auth.removeListener(_onDepsChanged); - _schedule.removeListener(_onDepsChanged); + _tpAuth.removeListener(_onBindingsOrAuthChanged); + _auth.removeListener(_onBindingsOrAuthChanged); + _schedule.removeListener(_onScheduleChanged); super.dispose(); } + /// Clear cached + in-memory deadlines (called on primary logout). Future clearCache() async { _assignments = []; @@ -180,7 +209,7 @@ class AssignmentService extends ChangeNotifier { final futures = >[]; - if (_tpAuth.hasEgateBinding) { + if (_tpAuth.hasCpdailyBinding) { futures.add( _fetchBlackboard().then((items) { if (items != null) successfulResults['blackboard'] = items; @@ -209,8 +238,8 @@ class AssignmentService extends ChangeNotifier { }), ); break; - case ThirdPartyPlatform.egate: - // eGate provides CpDaily session, not deadline data — skip. + case ThirdPartyPlatform.cpdaily: + // cpdaily provides the CpDaily session, not deadline data — skip. break; } } @@ -257,11 +286,11 @@ class AssignmentService extends ChangeNotifier { final successfulResults = >{}; Future? future; - if (platformId == 'blackboard' && _tpAuth.hasEgateBinding) { + if (platformId == 'blackboard' && _tpAuth.hasCpdailyBinding) { future = _fetchBlackboard().then((items) { if (items != null) successfulResults['blackboard'] = items; }); - } else if (platformId == 'exam' && _tpAuth.hasEgateBinding) { + } else if (platformId == 'exam' && _tpAuth.hasCpdailyBinding) { future = _fetchExamTable().then((items) { if (items != null) successfulResults['exam'] = items; }); @@ -302,28 +331,28 @@ class AssignmentService extends ChangeNotifier { notifyListeners(); } - // -- Per-platform fetchers -- - Future?> _fetchBlackboard() async { - final egate = _tpAuth.egateBinding; - if (egate == null) return null; - final tgc = (egate.raw['tgc'] as String?) ?? ''; - if (tgc.isEmpty) return null; - - Future doFetch(String token) => _http.post( - Uri.parse('$_baseUrl/deadlines/blackboard'), - headers: _jsonHeaders(), - body: jsonEncode({'token': token}), - tag: 'deadlines:blackboard', - ); + final node = _tpAuth.elearningNode; + // withCookie handles initial minting if the downstream cookie isn't set. + if (!_tpAuth.hasCpdailyBinding) return null; try { - var resp = await doFetch(tgc); - if (resp.statusCode == 401 && await _tpAuth.renewEgateBinding()) { - final newTgc = - (_tpAuth.egateBinding?.raw['tgc'] as String?) ?? tgc; - resp = await doFetch(newTgc); - } + final resp = await _tpAuth.sessionTree.withCookie( + node, + (cp) async { + // cp.cookies is the elearning cookie string minted from the + // cpdaily CASTGC. The backend uses it directly to query + // Blackboard (no SSO bounce needed). + final r = await _http.post( + Uri.parse('$_baseUrl/deadlines/blackboard'), + headers: _jsonHeaders(), + body: jsonEncode({'token': cp.cookies}), + tag: 'deadlines:blackboard', + ); + return CookieAction(r, expired: r.statusCode == 401); + }, + ); + if (resp == null) return null; return _parseDeadlinesResponse(resp, 'blackboard'); } catch (e) { _platformErrors['blackboard'] = '同步失败,请检查网络或稍后重试'; @@ -333,32 +362,30 @@ class AssignmentService extends ChangeNotifier { Future?> _fetchExamTable() async { final semesterId = _selectedSemesterId(); - if (!_tpAuth.hasEgateBinding || + final node = _tpAuth.eamsNode; + if (!_tpAuth.hasCpdailyBinding || semesterId == null || semesterId.isEmpty) { return null; } - Map buildBody() => { - 'semester_id': semesterId, - 'cookies': _tpAuth.egateCookies(), - }; - try { - var resp = await _http.post( - Uri.parse('$_baseUrl/schedule/exam_table'), - headers: _jsonHeaders(), - body: jsonEncode(buildBody()), - tag: 'schedule:exam_table', + final resp = await _tpAuth.sessionTree.withCookie( + node, + (cp) async { + final r = await _http.post( + Uri.parse('$_baseUrl/schedule/exam_table'), + headers: _jsonHeaders(), + body: jsonEncode({ + 'semester_id': semesterId, + 'cookies': cp.cookies, + }), + tag: 'schedule:exam_table', + ); + return CookieAction(r, expired: r.statusCode == 401); + }, ); - if (resp.statusCode == 401 && await _tpAuth.renewEgateBinding()) { - resp = await _http.post( - Uri.parse('$_baseUrl/schedule/exam_table'), - headers: _jsonHeaders(), - body: jsonEncode(buildBody()), - tag: 'schedule:exam_table:retry', - ); - } + if (resp == null) return null; return _parseExamTableResponse(resp); } catch (e) { _platformErrors['exam'] = '同步失败,请检查网络或稍后重试'; @@ -367,13 +394,23 @@ class AssignmentService extends ChangeNotifier { } Future?> _fetchGradescope(ThirdPartyAccount acc) async { + final node = _tpAuth.gradescopeNode; + if (!node.isAvailable) return null; try { - final resp = await _http.post( - Uri.parse('$_baseUrl/deadlines/gradescope'), - headers: _jsonHeaders(), - body: jsonEncode({'token': acc.token}), - tag: 'deadlines:gradescope', + final resp = await _tpAuth.sessionTree.withCookie( + node, + (cp) async { + // cp.cookies is the gradescope bearer token. + final r = await _http.post( + Uri.parse('$_baseUrl/deadlines/gradescope'), + headers: _jsonHeaders(), + body: jsonEncode({'token': cp.cookies}), + tag: 'deadlines:gradescope', + ); + return CookieAction(r, expired: r.statusCode == 401); + }, ); + if (resp == null) return null; if (resp.statusCode == 401) { await _tpAuth.unbind(ThirdPartyPlatform.gradescope); _platformErrors['gradescope'] = 'token 已失效,请重新绑定'; @@ -394,20 +431,31 @@ class AssignmentService extends ChangeNotifier { return null; } + final node = _tpAuth.hydroNode; + if (!node.isAvailable) return null; + final all = []; var hadError = false; for (final domain in domains) { final url = '${origin.replaceAll(RegExp(r'/+$'), '')}/d/$domain'; try { - final resp = await _http.post( - Uri.parse('$_baseUrl/deadlines/hydro'), - headers: _jsonHeaders(), - body: jsonEncode({ - 'token': acc.token, - 'args': {'url': url}, - }), - tag: 'deadlines:hydro:$domain', + final resp = await _tpAuth.sessionTree.withCookie( + node, + (cp) async { + // cp.cookies is the hydro sid cookie. + final r = await _http.post( + Uri.parse('$_baseUrl/deadlines/hydro'), + headers: _jsonHeaders(), + body: jsonEncode({ + 'token': cp.cookies, + 'args': {'url': url}, + }), + tag: 'deadlines:hydro:$domain', + ); + return CookieAction(r, expired: r.statusCode == 401); + }, ); + if (resp == null) return null; if (resp.statusCode == 401) { await _tpAuth.unbind(ThirdPartyPlatform.hydro); _platformErrors['hydro'] = 'token 已失效,请重新绑定'; diff --git a/lib/services/debug_logger.dart b/lib/services/debug_logger.dart index c508283..228e39d 100644 --- a/lib/services/debug_logger.dart +++ b/lib/services/debug_logger.dart @@ -51,19 +51,24 @@ class DebugLogger extends ChangeNotifier { if (_entries.length >= _maxEntries) { _entries.removeAt(0); } - _entries.add( - LogEntry( - timestamp: DateTime.now(), - method: method, - url: url, - statusCode: statusCode, - requestBody: redactSensitive(requestBody), - responseBody: redactSensitive(responseBody), - error: error, - tag: tag, - ), + final entry = LogEntry( + timestamp: DateTime.now(), + method: method, + url: url, + statusCode: statusCode, + requestBody: redactSensitive(requestBody), + responseBody: redactSensitive(responseBody), + error: error, + tag: tag, ); + _entries.add(entry); notifyListeners(); + if (kDebugMode) { + debugPrint( + '[HTTP] ${entry.method} ${entry.url} ' + '${entry.statusCode ?? '—'} ${entry.tag ?? ''}', + ); + } } static const _sensitiveKeys = { diff --git a/lib/services/http_client.dart b/lib/services/http_client.dart index 23f59b4..236ffb1 100644 --- a/lib/services/http_client.dart +++ b/lib/services/http_client.dart @@ -8,7 +8,8 @@ class LoggingHttpClient { final http.Client _inner; final DebugLogger _logger; - LoggingHttpClient(this._logger) : _inner = http.Client(); + LoggingHttpClient(this._logger, {http.Client? inner}) + : _inner = inner ?? http.Client(); Future get( Uri url, { diff --git a/lib/services/oa_gym_service.dart b/lib/services/oa_gym_service.dart index de6e8b6..9c9256b 100644 --- a/lib/services/oa_gym_service.dart +++ b/lib/services/oa_gym_service.dart @@ -6,6 +6,8 @@ import 'package:http/http.dart' as http; import '../models/oa_gym.dart'; import 'api_base_url.dart'; import 'auth_service.dart'; +import 'session/cookie_provider.dart'; +import 'session/session_tree.dart'; import 'storage_service.dart'; import 'third_party_auth_service.dart'; @@ -56,14 +58,14 @@ class OaGymService extends ChangeNotifier { OaBookingProfile bookingProfile() { final saved = _storage.loadOaBookingProfile(); - // Fall back to the eGate binding's real name (not the primary SSO + // Fall back to the cpdaily binding's real name (not the primary SSO // account, whose userName is a Casdoor UUID). Phone is not available - // from eGate, so the user must still fill it in manually. - final egateName = _tpAuth.egateBinding?.name; + // from cpdaily, so the user must still fill it in manually. + final cpdailyName = _tpAuth.cpdailyBinding?.name; return saved.copyWith( name: saved.name.isNotEmpty ? saved.name - : (egateName?.isNotEmpty == true ? egateName! : ''), + : (cpdailyName?.isNotEmpty == true ? cpdailyName! : ''), phone: saved.phone.isNotEmpty ? saved.phone : '', ); } @@ -90,7 +92,6 @@ class OaGymService extends ChangeNotifier { () => _postJson( 'oa/gym/availability', { - 'auth': _authPayload(), 'sports': sports.map((sport) => sport.id).toList(), 'date': date, 'startSlot': startSlot, @@ -125,7 +126,7 @@ class OaGymService extends ChangeNotifier { }) async { final auth = _requireAuth(); final profile = bookingProfile(); - final studentId = _tpAuth.egateStudentId; + final studentId = _tpAuth.cpdailyStudentId; final userName = profile.name.isNotEmpty ? profile.name : (auth.session?.userName ?? ''); final phone = profile.phone.isNotEmpty @@ -142,7 +143,6 @@ class OaGymService extends ChangeNotifier { () => _postJson( 'oa/gym/book', { - 'auth': _authPayload(), 'booking': { 'sport': sport.id, 'date': date, @@ -178,7 +178,6 @@ class OaGymService extends ChangeNotifier { () => _postJson( 'oa/gym/search', { - 'auth': _authPayload(), 'startDate': startDate, 'endDate': endDate, 'venueNames': venueNames.toList(), @@ -206,7 +205,7 @@ class OaGymService extends ChangeNotifier { Future _ensureMetadata() async { if (_metadataReady) return; - final data = await _postJson('oa/gym/metadata', {'auth': _authPayload()}); + final data = await _postJson('oa/gym/metadata', const {}); final payload = (data['data'] as Map?)?.cast() ?? data; _venues = _stringMap(payload['venues']); _allVenues = _stringMap(payload['allVenues']); @@ -241,67 +240,65 @@ class OaGymService extends ChangeNotifier { } /// Guard for any gym call: requires a logged-in primary account AND a - /// bound eGate account (the source of the CpDaily/CASTGC session the OA + /// bound cpdaily account (the source of the CpDaily/CASTGC session the OA /// system authenticates against). Returns the AuthService so callers can /// also read identity fields (name/phone) from the primary session. AuthService _requireAuth() { if (!_auth.isLoggedIn) { throw OaGymException('请先登录 TechPie 主账号'); } - if (!_tpAuth.hasEgateBinding) { + if (!_tpAuth.hasCpdailyBinding) { throw OaGymException('场馆预约需要绑定 eGate 账号,请在「第三方账号」中绑定'); } return _auth; } - /// CpDaily auth payload built entirely from the eGate binding — the same - /// source Schedule/Assignment use. No CASTGC ever leaves the primary - /// SSO session (which has none). - Map _authPayload() { - _requireAuth(); - final cookies = _tpAuth.egateCookies(); - if (cookies.isEmpty) { - throw OaGymException('当前 eGate 登录态已失效,请重新绑定 eGate'); - } - final egate = _tpAuth.egateBinding!; - final raw = egate.raw; + /// CpDaily auth payload built from a [CookieProvider] snapshot plus the + /// cpdaily node's raw session fields (tgc/sessionToken/userId/tenantId). The + /// cookie + epoch captured at request time drive the storm-safe renew-retry + /// in [_postJson]. + Map _authPayload(CookieProvider cp) { + final raw = _tpAuth.cpdailyNode.rawFields; return { 'tgc': (raw['tgc'] as String?) ?? '', - 'cookies': cookies, + 'cookies': cp.cookies, 'sessionToken': (raw['sessionToken'] as String?) ?? '', 'userId': (raw['userId'] as String?) ?? '', 'tenantId': (raw['tenantId'] as String?) ?? '', }; } + /// POST `$_baseUrl/[path]` with CpDaily auth + [extra] body fields. On 401 + /// the cpdaily node is renewed exactly once (single-flighted across all + /// concurrent callers) and the request retried with the fresh cookie. Future> _postJson( String path, - Map body, + Map extra, ) async { - var response = await _client - .post( - Uri.parse('$_baseUrl/$path'), - headers: const {'Content-Type': 'application/json; charset=UTF-8'}, - body: jsonEncode(body), - ) - .timeout(const Duration(seconds: 30)); - - // 401 → CpDaily session expired: renew the eGate binding once, then retry. - if (response.statusCode == 401) { - _sessionReady = false; - if (await _tpAuth.renewEgateBinding()) { - response = await _client + _requireAuth(); + final node = _tpAuth.cpdailyNode; + final response = await _tpAuth.sessionTree.withCookie( + node, + (cp) async { + final body = {...extra, 'auth': _authPayload(cp)}; + final r = await _client .post( Uri.parse('$_baseUrl/$path'), headers: const { 'Content-Type': 'application/json; charset=UTF-8', }, - body: jsonEncode(body..['auth'] = _authPayload()), + body: jsonEncode(body), ) .timeout(const Duration(seconds: 30)); - } + return CookieAction(r, expired: r.statusCode == 401); + }, + ); + if (response == null) { + throw OaGymException('当前 eGate 登录态已失效,请重新绑定 eGate'); + } + if (response.statusCode == 401) { + _sessionReady = false; } - final decoded = response.body.isEmpty ? {} : (jsonDecode(response.body) as Map).cast(); diff --git a/lib/services/schedule_service.dart b/lib/services/schedule_service.dart index a9534d1..1fc813a 100644 --- a/lib/services/schedule_service.dart +++ b/lib/services/schedule_service.dart @@ -7,6 +7,8 @@ import '../models/course_table.dart'; import 'api_base_url.dart'; import 'auth_service.dart'; import 'http_client.dart'; +import 'session/cookie_provider.dart'; +import 'session/session_tree.dart'; import 'storage_service.dart'; import 'third_party_auth_service.dart'; @@ -21,6 +23,11 @@ class ScheduleService extends ChangeNotifier { String? _selectedSemesterId; bool _loading = false; String? _error; + // While true, AssignmentService should NOT refetch on our notifies — + // selectSemester sets this during its own network fetch to avoid a + // concurrent exam_table + course_table race on EAMS's stateful session. + bool _suppressAssignmentRefetch = false; + bool get suppressAssignmentRefetch => _suppressAssignmentRefetch; String get _baseUrl => apiBaseUrl(_storage); @@ -44,15 +51,16 @@ class ScheduleService extends ChangeNotifier { 'Content-Type': 'application/json; charset=UTF-8', }; - Map _authBody() { - final cookies = _tpAuth.egateCookies(); - return { - 'studentId': _tpAuth.egateStudentId, - 'cookies': cookies, - }; - } + /// Auth payload built from a [CookieProvider] snapshot captured at request + /// time. The epoch captured alongside is what makes the renew-retry + /// storm-safe (see [_postWithRetry]). + Map _authBody(CookieProvider cp) => { + // eams downstream cookie; studentId comes from the cpdaily binding. + 'studentId': _tpAuth.cpdailyNode.account?.sid ?? '', + 'cookies': cp.cookies, + }; - bool get _hasEgateBinding => _tpAuth.hasEgateBinding; + bool get _hasCpdailyBinding => _tpAuth.cpdailyNode.isAvailable; Future loadCachedData() async { _semesterInfo = _storage.loadSemesters(); @@ -67,7 +75,7 @@ class ScheduleService extends ChangeNotifier { Future fetchAll() async { if (_loading) return; // 避免启动时并发重复调用 - if (!_hasEgateBinding) return; + if (!_hasCpdailyBinding) return; _loading = true; _error = null; notifyListeners(); @@ -92,7 +100,7 @@ class ScheduleService extends ChangeNotifier { Future fetchSemesters() async { final resp = await _postWithRetry( '$_baseUrl/schedule/semesters', - _authBody(), + const {}, 'fetchSemesters', ); final data = jsonDecode(resp.body) as Map; @@ -106,8 +114,7 @@ class ScheduleService extends ChangeNotifier { } Future fetchCourseTable(String semesterId) async { - final body = { - ..._authBody(), + final extra = { 'semester_id': semesterId, if (_semesterInfo?.tableId.isNotEmpty == true) 'table_id': _semesterInfo!.tableId, @@ -115,7 +122,7 @@ class ScheduleService extends ChangeNotifier { final resp = await _postWithRetry( '$_baseUrl/schedule/course_table', - body, + extra, 'fetchCourseTable', ); final data = jsonDecode(resp.body) as Map; @@ -160,11 +167,11 @@ class ScheduleService extends ChangeNotifier { String semester, String cacheKey, ) async { - final body = {..._authBody(), 'year': year, 'semester': semester}; + final extra = {'year': year, 'semester': semester}; final resp = await _postWithRetry( '$_baseUrl/schedule/term_begin', - body, + extra, 'fetchTermBegin', ); final data = jsonDecode(resp.body) as Map; @@ -181,16 +188,28 @@ class ScheduleService extends ChangeNotifier { } Future selectSemester(String semesterId) async { + // No-op if the semester is already selected. + if (_selectedSemesterId == semesterId) return; _selectedSemesterId = semesterId; await _storage.setSelectedSemester(semesterId); - notifyListeners(); - // Load cached data for new semester first + // Load cached data for the new semester so the UI updates instantly. _courseTable = _storage.loadCourseTable(semesterId); _termBegin = _storage.loadTermBegin(semesterId); + // Notify the cached-swap UI update. AssignmentService._onScheduleChanged + // sees the semester changed and would fire fetchAssignments here — but + // that would race our own fetchCourseTable below (both hit + // courseTableForStd.action on the same EAMS session, and concurrent + // access to EAMS's stateful Spring/Struts session returns a partially + // initialized page → "Failed to extract numeric ids"). We suppress the + // assignment refetch during our own fetch and fire it once at the end. + _suppressAssignmentRefetch = true; notifyListeners(); - if (!_hasEgateBinding) return; + if (!_hasCpdailyBinding) { + _suppressAssignmentRefetch = false; + return; + } _loading = true; _error = null; @@ -205,39 +224,49 @@ class ScheduleService extends ChangeNotifier { _error = e.toString(); } finally { _loading = false; + _suppressAssignmentRefetch = false; + // This final notify fires _onScheduleChanged again. Because + // _suppressAssignmentRefetch is now false, AssignmentService will + // refetch (the EAMS session is primed by our fetchCourseTable above, + // so exam_table succeeds on the first try). notifyListeners(); } } + /// POST [url] with CpDaily auth + [extra] body fields. On 401 the eams + /// node is renewed exactly once (single-flighted across all concurrent + /// callers) and the request retried with the fresh cookie. For a stale + /// parent tgc, [SessionTree.withCookie] falls back to renewing the + /// cpdaily parent then re-minting the eams cookie. Throws on any non-200 + /// after the retry budget is exhausted. Future _postWithRetry( String url, - Map body, + Map extra, String tag, ) async { - var resp = await _http.post( - Uri.parse(url), - headers: _jsonHeaders(), - body: jsonEncode(body), - tag: tag, - ); - - if (resp.statusCode == 401) { - // CpDaily session expired — renew the eGate binding once and retry. - if (await _tpAuth.renewEgateBinding()) { - final newBody = {...body, ..._authBody()}; - resp = await _http.post( + final node = _tpAuth.eamsNode; + final resp = await _tpAuth.sessionTree.withCookie( + node, + (cp) async { + final body = {..._authBody(cp), ...extra}; + final r = await _http.post( Uri.parse(url), headers: _jsonHeaders(), - body: jsonEncode(newBody), - tag: '$tag-retry', + body: jsonEncode(body), + tag: tag, ); - } + return CookieAction( + r, + expired: r.statusCode == 401, + ); + }, + ); + if (resp == null) { + throw Exception('cpdaily session unavailable'); } - if (resp.statusCode != 200) { throw Exception('Request failed with status ${resp.statusCode}'); } - return resp; } } diff --git a/lib/services/session/cookie_provider.dart b/lib/services/session/cookie_provider.dart new file mode 100644 index 0000000..80372bc --- /dev/null +++ b/lib/services/session/cookie_provider.dart @@ -0,0 +1,26 @@ +import 'package:flutter/foundation.dart'; + +/// A read-only snapshot of cookies + identity a downstream consumer (campus +/// service, webview feature) injects into its requests. +/// +/// Implementations: [CpdailyCookieProvider] (CASTGC-bearing CpDaily session), +/// [IdsCookieProvider] (IDS SSO cookies minted from the CpDaily session), and +/// a no-op empty view for leaf nodes that expose no cookies (Gradescope/Hydro +/// work via bearer tokens server-side, not browser cookies). +@immutable +class CookieProvider { + final String cookies; + final String studentId; + final String domain; + + const CookieProvider({ + required this.cookies, + this.studentId = '', + this.domain = '', + }); + + /// Empty provider — callers treat [isEmpty] as "session unavailable". + static const CookieProvider empty = CookieProvider(cookies: ''); + + bool get isEmpty => cookies.isEmpty; +} diff --git a/lib/services/session/session_node.dart b/lib/services/session/session_node.dart new file mode 100644 index 0000000..3d98dec --- /dev/null +++ b/lib/services/session/session_node.dart @@ -0,0 +1,454 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import '../../models/third_party_account.dart'; +import '../http_client.dart'; +import 'cookie_provider.dart'; + +/// Callback the facade ([ThirdPartyAuthService]) installs so a top-level +/// node can persist a refreshed [ThirdPartyAccount] back into secure storage +/// and fire the external change notification (listeners + cloud-sync push +/// hook) in one place. Returns nothing; the node owns the in-memory account +/// afterwards. Only meaningful for top-level nodes (renewMode cpdailySession +/// or password); non-top-level nodes skip persist (derived cookies are +/// ephemeral). +typedef PersistAccount = Future Function(ThirdPartyAccount updated); + +/// Callback a non-top-level node installs to persist its derived downstream +/// cookie into secure storage (so cold start can skip the SSO bounce) or +/// clear it (when the parent renews and invalidates it). Receives the node +/// id and the cookie string (null to clear). +typedef PersistDerivedCookie = Future Function( + String nodeId, + String? cookie, +); + +/// Callback to read the current API base URL (depends on storage settings). +typedef BaseUrlGetter = String Function(); + +/// How a [SessionNode] renews its credentials. +enum RenewMode { + /// CpDaily session keep-alive: POST /auth/renew with stored + /// sessionToken/tgc/userId/tenantId. Top-level only. + cpdailySession, + + /// Password re-authentication: POST /auth/third-party/`` with + /// account/password. Top-level only (gradescope, hydro). + password, + + /// Downstream cookie minting: POST /auth/third-party/`` with the parent + /// node's tgc. Non-top-level only (eams, elearning). + parentCookie, +} + +/// One node in the unified session tree. +/// +/// Topology: +/// ``` +/// SessionTree +/// ├── cpdaily (top-level, account+password/SMS bind, /auth/renew) +/// │ ├── eams (child, /auth/third-party/eams, parent tgc) +/// │ └── elearning (child, /auth/third-party/elearning, parent tgc) +/// ├── gradescope (top-level, account+password bind, bearer token) +/// └── hydro (top-level, account+password bind, sid cookie) +/// ``` +/// +/// Every node exposes: +/// - [cookieProvider] — the [CookieProvider] view downstream apps read. For +/// top-level nodes the credential comes from the bound account (cpdaily: +/// CASTGC+cookies; gradescope/hydro: bearer token). For non-top-level +/// nodes it comes from the derived downstream cookie string. +/// - [renew()] — refresh this node's credentials. Single-flighted: concurrent +/// callers share ONE in-flight renew and observe the same result. +/// - [epoch] — monotonically increasing, bumped on every successful renew. +/// Callers capture the epoch when they read cookies, then pass it to +/// [renewIfNeeded] so a concurrent renew that already refreshed the cookie +/// is NOT re-triggered (anti-renew-storm). +/// +/// The 401-renew-retry pattern (with two-level parent fallback for child +/// nodes) lives in [SessionTree.withCookie]. +class SessionNode extends ChangeNotifier { + SessionNode({ + required this.id, + required this.persist, + required this.http, + required this.baseUrl, + this.parent, + this.renewPath, + this.renewMode, + this.apiPath, + this.persistDerived, + }); + + /// Stable identifier (matches storage key / platform id, except cpdaily + /// whose storage id is 'cpdaily' but whose backend bind route is 'egate'). + final String id; + + /// Optional parent — set when this node's session is derived from another + /// (e.g. eams/elearning cookies are minted from the cpdaily CASTGC). Null + /// for roots. + SessionNode? parent; + + /// Full backend renew path (e.g. '/auth/renew', '/auth/third-party/eams'). + final String? renewPath; + + /// How this node renews. Null only for nodes that never renew. + final RenewMode? renewMode; + + /// Backend bind route name. For cpdaily this is 'egate' (the backend route + /// was not renamed); for gradescope/hydro it equals [id]. Null for + /// non-top-level nodes. + final String? apiPath; + final PersistAccount persist; + final PersistDerivedCookie? persistDerived; + final LoggingHttpClient http; + final BaseUrlGetter baseUrl; + + final List _children = []; + List get children => List.unmodifiable(_children); + + /// Top-level nodes hold the bound account; non-top-level nodes leave this + /// null (their credential is the ephemeral [_derivedCookie]). + ThirdPartyAccount? _account; + ThirdPartyAccount? get account => _account; + + /// Non-top-level nodes hold the downstream cookie string minted by the + /// last successful renew; top-level nodes leave this null. + String? _derivedCookie; + + /// Raw fields from the bound account (top-level only). Downstream services + /// (OA gym) and child nodes read tgc/sessionToken/userId/tenantId through + /// this. Returns an empty map for non-top-level nodes. + Map get rawFields => _account?.raw ?? const {}; + + /// Set the bound account. Only meaningful for top-level nodes; calling on + /// a non-top-level node is a no-op. + void setAccount(ThirdPartyAccount? acc) { + if (parent != null) return; // non-top-level: no account + _account = acc; + notifyListeners(); + } + + /// Hydrate the derived cookie from persistent storage at boot. Only + /// meaningful for non-top-level nodes; calling on a top-level node is a + /// no-op. Does NOT notify — this is a boot-time hydration, not a state + /// change the UI needs to react to. + void setDerivedCookie(String? cookie) { + if (parent == null) return; // top-level: no derived cookie + _derivedCookie = (cookie != null && cookie.isNotEmpty) ? cookie : null; + } + + void attachChild(SessionNode child) { + child.parent = this; + _children.add(child); + } + + void detachChild(SessionNode child) { + if (child.parent == this) child.parent = null; + _children.remove(child); + } + + // -- Unified cookieProvider -- + + /// The cookie view exposed to downstream consumers. For top-level nodes + /// the credential is extracted from the bound account; for non-top-level + /// nodes it is the derived downstream cookie. Null when no usable + /// credential is available. + CookieProvider? get cookieProvider { + if (parent == null) { + // Top-level + final acc = _account; + if (acc == null) return null; + final cookie = _cookieFromAccount(acc); + if (cookie.isEmpty) return null; + return CookieProvider( + cookies: cookie, + studentId: acc.sid ?? '', + domain: _domain, + ); + } + // Non-top-level: derived cookie + final c = _derivedCookie; + if (c == null || c.isEmpty) return null; + return CookieProvider(cookies: c, domain: _domain); + } + + /// Top-level credential extraction: cpdaily concatenates CASTGC onto the + /// session cookies; gradescope/hydro use the bearer token directly. + String _cookieFromAccount(ThirdPartyAccount acc) { + if (id == 'cpdaily') { + final base = (acc.raw['cookies'] as String?) ?? ''; + final tgc = (acc.raw['tgc'] as String?) ?? ''; + return tgc.isEmpty + ? base + : (base.isNotEmpty ? '$base; CASTGC=$tgc' : 'CASTGC=$tgc'); + } + return acc.token; + } + + String get _domain => switch (id) { + 'cpdaily' => 'ids.shanghaitech.edu.cn', + 'eams' => 'eams.shanghaitech.edu.cn', + 'elearning' => 'elearning.shanghaitech.edu.cn', + _ => '', + }; + + /// True when this node has a usable session. + bool get isAvailable { + if (parent == null) { + final cp = cookieProvider; + return _account != null && cp != null && !cp.isEmpty; + } + return (parent?.isAvailable ?? false) && + _derivedCookie != null && + _derivedCookie!.isNotEmpty; + } + + // -- Renewal: single-flight + stale-epoch skip -- + + int _epoch = 0; + Future? _renewInFlight; + + /// Whether the last [doRenew] failure was a credential-level error + /// (HTTP 401 from the renew endpoint), as opposed to a server error (500) + /// or network issue. [SessionTree.withCookie] uses this to decide whether + /// the two-level parent-renew fallback is worth attempting: a 500 from the + /// downstream endpoint won't be fixed by re-minting the parent tgc, so we + /// skip the escalation entirely. + bool _lastRenewWasCredentialError = false; + bool get lastRenewWasCredentialError => _lastRenewWasCredentialError; + + /// Current epoch. Bumped after every successful [renew]. Callers capture + /// this when reading cookies and pass it to [renewIfNeeded]. + int get epoch => _epoch; + + /// Bump epoch and cascade to children (clear their derived cookies). + /// Does NOT call notifyListeners() on this node — the caller is + /// responsible for notifying: top-level nodes are notified by + /// [persist]→[setAccount] (which fires before this), and non-top-level + /// nodes call notifyListeners() explicitly after this. This avoids a + /// double-notify storm where both persist and markRenewed fire on the + /// same node, each cascading through SessionTree → ThirdPartyAuthService + /// → AssignmentService.fetchAssignments + SyncService.pushIfDue. + @protected + void markRenewed() { + _epoch++; + // Cascade: parent renewed → children's derived cookies are now stale. + for (final child in _children) { + child.onParentRenewed(); + } + } + + /// Called by the parent's [markRenewed] when the parent's credentials + /// changed. Non-top-level nodes clear their derived cookie (it was minted + /// from the old parent credential and is now invalid); top-level nodes + /// are unaffected. + @protected + void onParentRenewed() { + if (parent != null) { + _derivedCookie = null; + // Clear persisted cookie too — it was minted from the old parent tgc + // and is now invalid. The next withCookie call will re-mint. + final pd = persistDerived; + if (pd != null) { + unawaited(pd(id, null)); + } + notifyListeners(); + } + } + + /// Mode-specific renew. MUST persist refreshed credentials (top-level) or + /// mint the downstream cookie (non-top-level) and call [markRenewed] on + /// success. Returns true on success, false on failure. + Future doRenew() async { + switch (renewMode) { + case RenewMode.cpdailySession: + return _renewCpdailySession(); + case RenewMode.password: + return _renewWithPassword(); + case RenewMode.parentCookie: + return _renewWithParentCookie(); + case null: + return false; + } + } + + /// CpDaily keep-alive: POST /auth/renew with stored session fields. + Future _renewCpdailySession() async { + final acc = _account; + if (acc == null) return false; + try { + final resp = await http.post( + Uri.parse('${baseUrl()}${renewPath ?? '/auth/renew'}'), + headers: {'Content-Type': 'application/json; charset=UTF-8'}, + body: jsonEncode({ + 'sessionToken': acc.raw['sessionToken'] ?? '', + 'tgc': acc.raw['tgc'] ?? '', + 'userId': acc.raw['userId'] ?? '', + 'tenantId': acc.raw['tenantId'] ?? '', + }), + tag: 'cpdailyRenew', + ); + if (resp.statusCode != 200) return false; + final data = jsonDecode(resp.body) as Map; + if (data['success'] != true) return false; + + final newRaw = { + ...acc.raw, + 'sessionToken': + data['sessionToken'] as String? ?? acc.raw['sessionToken'] ?? '', + 'tgc': data['tgc'] as String? ?? acc.raw['tgc'] ?? '', + 'userId': data['userId'] as String? ?? acc.raw['userId'] ?? '', + 'tenantId': + data['tenantId'] as String? ?? acc.raw['tenantId'] ?? '', + 'cookies': data['cookies'] as String? ?? acc.raw['cookies'] ?? '', + }; + final updated = ThirdPartyAccount( + platform: acc.platform, + account: acc.account, + sid: acc.sid, + name: acc.name, + email: acc.email, + token: acc.token, + expire: acc.expire, + raw: newRaw, + hydroOrigin: acc.hydroOrigin, + hydroDomains: acc.hydroDomains, + boundAt: acc.boundAt, + autoRenew: acc.autoRenew, + password: acc.password, + ); + _account = updated; + await persist(updated); + markRenewed(); + return true; + } catch (_) { + return false; + } + } + + /// Password re-authentication (gradescope, hydro): POST the stored + /// account+password, receive a fresh token. + Future _renewWithPassword() async { + final acc = _account; + if (acc == null || !acc.autoRenew) return false; + final pw = acc.password; + if (pw == null || pw.isEmpty) return false; + try { + final body = { + 'account': acc.account, + 'password': pw, + }; + if (id == 'hydro' && + acc.hydroOrigin != null && + acc.hydroOrigin!.isNotEmpty) { + body['args'] = {'url': acc.hydroOrigin}; + } + final resp = await http.post( + Uri.parse('${baseUrl()}${renewPath ?? '/auth/third-party/$id'}'), + headers: {'Content-Type': 'application/json; charset=UTF-8'}, + body: jsonEncode(body), + tag: 'thirdPartyRenew:$id', + ); + if (resp.statusCode != 200) return false; + final data = jsonDecode(resp.body) as Map; + if (data['success'] != true) return false; + final d = (data['data'] as Map?)?.cast() ?? const {}; + final token = d['token'] as String?; + if (token == null || token.isEmpty) return false; + final renewed = ThirdPartyAccount( + platform: acc.platform, + account: acc.account, + sid: d['sid'] as String? ?? acc.sid, + name: d['name'] as String? ?? acc.name, + email: d['email'] as String? ?? acc.email, + token: token, + expire: (d['expire'] as num?)?.toInt() ?? acc.expire, + raw: (d['raw'] as Map?)?.cast() ?? acc.raw, + hydroOrigin: acc.hydroOrigin, + hydroDomains: acc.hydroDomains, + boundAt: acc.boundAt, + autoRenew: true, + password: pw, + ); + _account = renewed; + await persist(renewed); + markRenewed(); + return true; + } catch (_) { + return false; + } + } + + /// Downstream cookie minting (eams, elearning): POST the parent's tgc, + /// receive a downstream cookie string. + Future _renewWithParentCookie() async { + final tgc = parent?.rawFields['tgc'] as String? ?? ''; + if (tgc.isEmpty) { + _lastRenewWasCredentialError = true; + return false; + } + try { + final resp = await http.post( + Uri.parse('${baseUrl()}${renewPath ?? '/auth/third-party/$id'}'), + headers: {'Content-Type': 'application/json; charset=UTF-8'}, + body: jsonEncode({'tgc': tgc}), + tag: 'downstreamRenew:$id', + ); + // 401 → parent tgc is stale/invalid → credential error (worth + // escalating to parent renew). 5xx → server/transient failure → + // NOT a credential error (escalation won't help). + _lastRenewWasCredentialError = resp.statusCode == 401; + if (resp.statusCode != 200) return false; + final data = jsonDecode(resp.body) as Map; + if (data['success'] != true) return false; + final d = (data['data'] as Map?)?.cast() ?? const {}; + final cookie = d['token'] as String?; + if (cookie == null || cookie.isEmpty) return false; + _derivedCookie = cookie; + // Persist so cold start can skip the SSO bounce. + final pd = persistDerived; + if (pd != null) { + unawaited(pd(id, cookie)); + } + // markRenewed cascades to children (none here) but does NOT notify + // this node — notify explicitly for the downstream cookie change. + markRenewed(); + notifyListeners(); + return true; + } catch (_) { + _lastRenewWasCredentialError = false; + return false; + } + } + + /// Refresh this node's credentials. Single-flighted: concurrent callers + /// share one in-flight renew and observe the same result. + Future renew() { + if (_renewInFlight != null) return _renewInFlight!; + final f = doRenew().whenComplete(() => _renewInFlight = null); + _renewInFlight = f; + return f; + } + + /// Renew only if no renew has completed since [beforeEpoch]. This is the + /// anti-storm gate: a caller that captured cookies at epoch N, hit a 401, + /// and now wants to renew will skip if another caller already renewed + /// (epoch > N) — it just re-reads the fresh cookies instead. + /// + /// Returns true if a renew ran and succeeded OR was already done by a + /// concurrent caller (epoch advanced). Returns false only when a renew + /// actually ran and failed, or the node is unavailable. + Future renewIfNeeded(int beforeEpoch) async { + if (!isAvailable) return false; + // A concurrent renew already advanced past the caller's snapshot — + // the cookies the caller will re-read are already fresh. Skip. + if (_epoch > beforeEpoch) return true; + return renew(); + } + + @override + String toString() => 'SessionNode($id)'; +} diff --git a/lib/services/session/session_tree.dart b/lib/services/session/session_tree.dart new file mode 100644 index 0000000..9c1b467 --- /dev/null +++ b/lib/services/session/session_tree.dart @@ -0,0 +1,233 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../../models/third_party_account.dart'; +import '../http_client.dart'; +import 'cookie_provider.dart'; +import 'session_node.dart'; + +/// The unified session tree. +/// +/// Topology (built once at construction): +/// ``` +/// SessionTree +/// ├── cpdaily (top-level, account+password/SMS bind, /auth/renew) +/// │ ├── eams (child, /auth/third-party/eams, parent tgc) +/// │ └── elearning (child, /auth/third-party/elearning, parent tgc) +/// ├── gradescope (top-level, account+password bind, bearer token) +/// └── hydro (top-level, account+password bind, sid cookie) +/// ``` +/// +/// The facade ([ThirdPartyAuthService]) feeds account mutations in via +/// [setAccount]. Child nodes (eams/elearning) carry no account — their +/// credential is a derived cookie minted on demand from the parent's tgc. +class SessionTree extends ChangeNotifier { + SessionTree({ + required this.persist, + required this.http, + required this.baseUrl, + this.persistDerived, + }) { + cpdaily = SessionNode( + id: 'cpdaily', + persist: persist, + http: http, + baseUrl: baseUrl, + renewPath: '/auth/renew', + renewMode: RenewMode.cpdailySession, + apiPath: 'egate', + ); + gradescope = SessionNode( + id: 'gradescope', + persist: persist, + http: http, + baseUrl: baseUrl, + renewPath: '/auth/third-party/gradescope', + renewMode: RenewMode.password, + apiPath: 'gradescope', + ); + hydro = SessionNode( + id: 'hydro', + persist: persist, + http: http, + baseUrl: baseUrl, + renewPath: '/auth/third-party/hydro', + renewMode: RenewMode.password, + apiPath: 'hydro', + ); + eams = SessionNode( + id: 'eams', + persist: persist, + http: http, + baseUrl: baseUrl, + parent: cpdaily, + renewPath: '/auth/third-party/eams', + renewMode: RenewMode.parentCookie, + persistDerived: persistDerived, + ); + elearning = SessionNode( + id: 'elearning', + persist: persist, + http: http, + baseUrl: baseUrl, + parent: cpdaily, + renewPath: '/auth/third-party/elearning', + renewMode: RenewMode.parentCookie, + persistDerived: persistDerived, + ); + cpdaily.attachChild(eams); + cpdaily.attachChild(elearning); + + // Only top-level node notifications propagate up to the tree (facade → + // UI / sync / auto-refetch). Child nodes (eams/elearning) mint cookies + // as a side-effect of withCookie — their notifyListeners would otherwise + // trigger _onDepsChanged → fetchAssignments → re-mint, a feedback loop. + // Child notifications still fire for direct listeners on the node itself + // (e.g. withCookie's epoch checks read node state directly, not via tree). + for (final n in [cpdaily, gradescope, hydro]) { + n.addListener(notifyListeners); + } + } + final PersistAccount persist; + final PersistDerivedCookie? persistDerived; + final LoggingHttpClient http; + final BaseUrlGetter baseUrl; + + late final SessionNode cpdaily; + late final SessionNode gradescope; + late final SessionNode hydro; + late final SessionNode eams; + late final SessionNode elearning; + + /// All top-level nodes in a stable order. + List get roots => [cpdaily, gradescope, hydro]; + + /// Lookup a top-level node by [ThirdPartyPlatform]. Child nodes (eams/ + /// elearning) are accessed directly via [eams]/[elearning]. + SessionNode nodeFor(ThirdPartyPlatform p) => switch (p) { + ThirdPartyPlatform.cpdaily => cpdaily, + ThirdPartyPlatform.gradescope => gradescope, + ThirdPartyPlatform.hydro => hydro, + }; + + /// Feed an account mutation from the facade into the matching top-level + /// node. Used by bind/unbind/replaceAll/updateRaw. Child nodes ignore + /// this (they carry no account). + void setAccount(ThirdPartyPlatform p, ThirdPartyAccount? acc) { + nodeFor(p).setAccount(acc); + } + + /// Hydrate a child node's derived cookie from persistent storage at boot. + /// Called by the facade after accounts are loaded so cold start can skip + /// the SSO bounce if a valid derived cookie is still on disk. + void setDerivedCookie(String nodeId, String? cookie) { + final node = switch (nodeId) { + 'eams' => eams, + 'elearning' => elearning, + _ => null, + }; + node?.setDerivedCookie(cookie); + } + + // -- The anti-storm 401-renew-retry helper (two-level) -- + + /// Run [action] with the cookie view from [node]. On a response the caller + /// flags as expired (via [isExpired]), renew the node exactly once (shared + /// across all concurrent callers) and retry with the fresh cookie. If a + /// concurrent renew already advanced the cookie epoch since [action] + /// captured it, skip the renew entirely and just retry with the new cookie. + /// + /// For non-top-level nodes (eams/elearning), a failed first-level retry + /// triggers a second-level fallback ONLY if the child's renew failure was + /// a credential error (HTTP 401 = parent tgc stale). Server errors (5xx) + /// and network failures do NOT escalate — re-minting the parent tgc won't + /// fix a broken backend, so the escalation is skipped to avoid wasteful + /// /auth/renew calls. + /// + /// Retry budget: at most 1 child renew (first level) + 1 parent renew + + /// 1 child re-mint (second level) per withCookie call. The single-flight + /// gate on each node ensures concurrent callers share these renews. + /// + /// [action] receives the current [CookieProvider] (non-null — callers + /// gate on [node.isAvailable] first) and returns its result + whether the + /// result should be treated as "cookie expired, please renew+retry". + /// + /// Returns the (possibly retried) result, or null if the node was + /// unavailable or renew failed. + Future withCookie( + SessionNode node, + Future> Function(CookieProvider provider) action, + ) async { + // First level: single-node renew-retry (handles initial minting + 401). + var result = await _renewRetry(node, action); + if (result != null) return result; + + // Second level: for child nodes whose first-level retry returned null. + // ONLY escalate to parent renew if the child's renew failure was a + // credential error (HTTP 401 = parent tgc stale). A 500 from the + // downstream endpoint is a server error — re-minting the parent tgc + // won't fix it, so we skip the escalation and return null immediately. + // This prevents wasteful /auth/renew calls on transient backend errors. + if (node.parent == null) return null; + if (!node.lastRenewWasCredentialError) return null; + final parentOk = await node.parent!.renewIfNeeded(node.parent!.epoch); + if (!parentOk) return null; + final childOk = await node.renew(); + if (!childOk) return null; + final cp = node.cookieProvider; + if (cp == null) return null; + final retried = await action(cp); + return retried.value; + } + + /// Single-node 401-renew-retry. If the node is not yet available (e.g. a + /// child node whose downstream cookie hasn't been minted), attempt an + /// initial renew first. Returns null if the renew failed (caller may + /// attempt two-level fallback for child nodes). + Future _renewRetry( + SessionNode node, + Future> Function(CookieProvider provider) action, + ) async { + // If not available, try to mint credentials first (initial minting for + // child nodes, or a no-op for top-level nodes that are already bound). + if (!node.isAvailable) { + final ok = await node.renew(); + if (!ok) return null; + } + var cp = node.cookieProvider; + if (cp == null) return null; + + var result = await action(cp); + if (!result.expired) return result.value; + + // 401: renew if the cookie hasn't already been refreshed since we read it. + final beforeEpoch = node.epoch; + final ok = await node.renewIfNeeded(beforeEpoch); + if (!ok) return null; + + cp = node.cookieProvider; + if (cp == null) return null; + final retried = await action(cp); + return retried.value; + } + + @override + void dispose() { + for (final n in [cpdaily, gradescope, hydro]) { + n.removeListener(notifyListeners); + } + for (final n in [cpdaily, gradescope, hydro, eams, elearning]) { + n.dispose(); + } + super.dispose(); + } +} + +/// The result of a cookie-bearing action, tagged by the caller so +/// [SessionTree.withCookie] knows whether to trigger a renew+retry. +class CookieAction { + final T value; + final bool expired; + const CookieAction(this.value, {required this.expired}); +} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index ac5d084..7a8d898 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:math'; // NOTE: import the OHOS package, not the upstream `flutter_secure_storage`. // Despite the name, `flutter_secure_storage_ohos` is a hard fork (declares @@ -26,6 +27,8 @@ class StorageService { static const _syncEnabledKey = 'sync_enabled'; static const _syncLastAtKey = 'sync_last_at'; static const _syncMasterKeyKey = 'sync_master_key'; // secure storage + static const _deviceIdKey = 'device_id'; + final FlutterSecureStorage _secure; final SharedPreferences _prefs; @@ -76,7 +79,26 @@ class StorageService { Future> loadAllThirdPartyAccounts() async { final result = []; for (final p in ThirdPartyPlatform.values) { - final acc = await loadThirdPartyAccount(p); + var acc = await loadThirdPartyAccount(p); + // One-time migration: cpdaily was previously stored under the legacy + // 'egate' key (platform id before the rename). If the new key is empty + // but the legacy key has data, adopt it and delete the old key. + if (p == ThirdPartyPlatform.cpdaily && acc == null) { + const legacyKey = '${_thirdPartyKeyPrefix}egate'; + final raw = await _secure.read(key: legacyKey); + if (raw != null) { + try { + acc = ThirdPartyAccount.fromJson( + jsonDecode(raw) as Map, + ); + await _secure.write(key: _thirdPartyKey(p), value: raw); + await _secure.delete(key: legacyKey); + } catch (_) { + // Corrupt legacy entry — leave it; clearThirdPartyAccount can + // still remove it via the fromId alias path. + } + } + } if (acc != null) result.add(acc); } return result; @@ -92,6 +114,31 @@ class StorageService { } } + // Derived downstream cookies + // CASTGC, persisted so cold start skips the SSO bounce. Keyed by node id. + static const _derivedCookieKeyPrefix = 'derived_cookie_'; + + Future saveDerivedCookie(String nodeId, String cookie) async { + await _secure.write( + key: '$_derivedCookieKeyPrefix$nodeId', + value: cookie, + ); + } + + Future loadDerivedCookie(String nodeId) async { + return _secure.read(key: '$_derivedCookieKeyPrefix$nodeId'); + } + + Future clearDerivedCookie(String nodeId) async { + await _secure.delete(key: '$_derivedCookieKeyPrefix$nodeId'); + } + + Future clearAllDerivedCookies() async { + for (final id in const ['eams', 'elearning']) { + await _secure.delete(key: '$_derivedCookieKeyPrefix$id'); + } + } + // SharedPreferences for non-sensitive data bool get debugMode => _prefs.getBool(_debugModeKey) ?? false; Future setDebugMode(bool value) => _prefs.setBool(_debugModeKey, value); @@ -131,6 +178,22 @@ class StorageService { _secure.write(key: _syncMasterKeyKey, value: serialized); Future clearSyncMasterKey() => _secure.delete(key: _syncMasterKeyKey); + // Stable per-device identifier used as the cloud-sync LWW tie-breaker. + // Generated lazily on first access (16 random bytes → 32 hex chars) and + // persisted in SharedPreferences; never leaves the device except inside + // encrypted sync blobs. + Future ensureDeviceId() async { + var id = _prefs.getString(_deviceIdKey); + if (id == null || id.isEmpty) { + final rnd = Random.secure(); + id = List.generate(16, (_) => rnd.nextInt(256)) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + await _prefs.setString(_deviceIdKey, id); + } + return id; + } + // Schedule cache static const _semestersKey = 'schedule_semesters'; static const _courseTablePrefix = 'schedule_course_table_'; diff --git a/lib/services/sync_envelope.dart b/lib/services/sync_envelope.dart new file mode 100644 index 0000000..9e045b1 --- /dev/null +++ b/lib/services/sync_envelope.dart @@ -0,0 +1,267 @@ +import 'dart:convert'; + +import '../models/third_party_account.dart'; + +/// Schema version of the cloud-sync blob's plaintext envelope. +/// +/// History: +/// v0 — legacy: a bare JSON array of [ThirdPartyAccount.toJson] objects, +/// no envelope. Auto-migrated to v1 on first read. +/// v1 — introduced the `{v, accounts}` envelope. No tombstones; deletions +/// were represented by absence (the "deleted binding resurrects on +/// next pull" bug). Auto-migrated to v2 on first read. +/// v2 — current: `{v, accounts, tombstones}`. Deletions carry a tombstone +/// so per-platform LWW merge can distinguish "deleted on device A" +/// from "never had it on device A". Per-account `updatedAt` + +/// `deviceId` drive the merge. +class SyncSchema { + /// Current schema version produced by this build. + static const int current = 2; + + SyncSchema._(); + + /// Migrate a decoded plaintext JSON value to [current] and return the + /// normalized envelope. Accepts any historical shape: + /// - a [List] (v0 bare array) → wrapped as v1 then migrated + /// - a [Map] with `v` → run the migration ladder up to [current] + /// Returns `null` only if the input is structurally unrecognizable. + static SyncEnvelope? migrate(dynamic decoded) { + // v0: bare array of account objects. + if (decoded is List) { + final accounts = decoded + .whereType>() + .map((e) => ThirdPartyAccount.fromJson(e.cast())) + .toList(); + return SyncEnvelope( + v: current, + accounts: accounts, + tombstones: const [], + ); + } + if (decoded is! Map) return null; + final m = decoded.cast(); + var v = (m['v'] as num?)?.toInt() ?? 1; + // v1 → v2: add empty tombstones list. Accounts already carry + // updatedAt/deviceId with back-compat defaults from fromJson. + if (v < 2) { + // Nothing to transform in the account objects themselves; the v2 + // shape only adds the tombstones field. + v = 2; + } + final rawAccounts = + m['accounts'] is List ? m['accounts'] as List : const []; + final accounts = rawAccounts + .whereType>() + .map((e) => ThirdPartyAccount.fromJson(e.cast())) + .toList(); + final rawTombs = + m['tombstones'] is List ? m['tombstones'] as List : const []; + final tombstones = rawTombs + .whereType>() + .map((e) => SyncTombstone.fromJson(e.cast())) + .toList(); + return SyncEnvelope(v: current, accounts: accounts, tombstones: tombstones); + } +} + +/// A record that a platform was deliberately unbound on some device at +/// [deletedAt]. Used by the LWW merge so a deletion on device A is not +/// silently undone when device B (which never had the binding) pushes its +/// older state. +class SyncTombstone { + final ThirdPartyPlatform platform; + final DateTime deletedAt; + final String deviceId; + + const SyncTombstone({ + required this.platform, + required this.deletedAt, + required this.deviceId, + }); + + /// Compare two tombstones by recency (newer wins); tie-break by deviceId + /// identically to [ThirdPartyAccount.compareVersionTo]. + int compareVersionTo(SyncTombstone other) { + final c = deletedAt.compareTo(other.deletedAt); + if (c != 0) return c; + if (deviceId.isEmpty && other.deviceId.isNotEmpty) return -1; + if (deviceId.isNotEmpty && other.deviceId.isEmpty) return 1; + return deviceId.compareTo(other.deviceId); + } + + Map toJson() => { + 'platform': platform.id, + 'deletedAt': deletedAt.toIso8601String(), + 'deviceId': deviceId, + }; + + factory SyncTombstone.fromJson(Map json) { + return SyncTombstone( + platform: + ThirdPartyPlatform.fromId(json['platform'] as String? ?? '') ?? + ThirdPartyPlatform.gradescope, + deletedAt: + DateTime.tryParse(json['deletedAt'] as String? ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0), + deviceId: json['deviceId'] as String? ?? '', + ); + } +} + +/// The normalized cloud-sync plaintext: a versioned envelope of accounts + +/// deletion tombstones. Always at [SyncSchema.current] after [SyncSchema.migrate]. +class SyncEnvelope { + final int v; + final List accounts; + final List tombstones; + + const SyncEnvelope({ + required this.v, + required this.accounts, + required this.tombstones, + }); + + String encode() => jsonEncode({ + 'v': v, + 'accounts': accounts.map((a) => a.toJson()).toList(), + 'tombstones': tombstones.map((t) => t.toJson()).toList(), + }); + + static SyncEnvelope? decode(String plaintext) { + dynamic decoded; + try { + decoded = jsonDecode(plaintext); + } catch (_) { + return null; + } + return SyncSchema.migrate(decoded); + } + + /// Build an envelope from the local account list, preserving tombstones + /// the caller already tracks. Accounts with no deviceId/updatedAt are + /// left as-is — the caller is expected to have touched them. + factory SyncEnvelope.fromLocal({ + required Iterable accounts, + required Iterable tombstones, + }) { + return SyncEnvelope( + v: SyncSchema.current, + accounts: accounts.toList(), + tombstones: tombstones.toList(), + ); + } + + /// Per-platform LWW merge of this (local) envelope with [remote] (cloud). + /// + /// For each platform in [ThirdPartyPlatform.values]: + /// - gather the local account (or its absence), the remote account (or + /// its absence), the local tombstone (if any), and the remote tombstone + /// (if any); + /// - pick the newest event across {account.updatedAt, tombstone.deletedAt} + /// using [ThirdPartyAccount.compareVersionTo] / + /// [SyncTombstone.compareVersionTo] with deviceId tie-break; + /// - if a tombstone wins, the platform is absent in the result and the + /// tombstone is carried forward; + /// - if an account wins, that account is in the result and any older + /// tombstone for the platform is dropped. + /// + /// Returns a new envelope at [SyncSchema.current]. The caller is responsible + /// for applying the account side-effects locally and for pushing the merged + /// envelope to the cloud. + SyncEnvelope mergeWith(SyncEnvelope remote) { + final localByPlatform = { + for (final a in accounts) a.platform: a, + }; + final remoteByPlatform = { + for (final a in remote.accounts) a.platform: a, + }; + final localTombByPlatform = { + for (final t in tombstones) t.platform: t, + }; + final remoteTombByPlatform = { + for (final t in remote.tombstones) t.platform: t, + }; + + final mergedAccounts = []; + final mergedTombstones = []; + + for (final p in ThirdPartyPlatform.values) { + final localAcc = localByPlatform[p]; + final remoteAcc = remoteByPlatform[p]; + final localTomb = localTombByPlatform[p]; + final remoteTomb = remoteTombByPlatform[p]; + + // Collect candidate events: (kind, version-key, payload). + // kind 0 = tombstone, kind 1 = account. + final candidates = <_MergeCandidate>[]; + if (localAcc != null) { + candidates.add(_MergeCandidate(1, localAcc.deviceId, localAcc.updatedAt, localAcc)); + } + if (remoteAcc != null) { + candidates.add(_MergeCandidate(1, remoteAcc.deviceId, remoteAcc.updatedAt, remoteAcc)); + } + if (localTomb != null) { + candidates.add(_MergeCandidate(0, localTomb.deviceId, localTomb.deletedAt, localTomb)); + } + if (remoteTomb != null) { + candidates.add(_MergeCandidate(0, remoteTomb.deviceId, remoteTomb.deletedAt, remoteTomb)); + } + if (candidates.isEmpty) continue; + + candidates.sort((a, b) { + final c = a.ts.compareTo(b.ts); + if (c != 0) return c; + // Empty deviceId loses; else lexicographic. + final ad = a.deviceId, bd = b.deviceId; + if (ad.isEmpty && bd.isNotEmpty) return -1; + if (ad.isNotEmpty && bd.isEmpty) return 1; + return ad.compareTo(bd); + }); + + // The sort put the newest event last (ts asc, then deviceId asc). The + // winner is that last element. One policy override: if the newest + // tombstone shares the winning (ts, deviceId) with an account, prefer + // the tombstone (err toward not resurrecting a deletion). + final winner = candidates.last; + final hasAccount = candidates.any( + (c) => c.kind == 1 && c.ts == winner.ts && c.deviceId == winner.deviceId, + ); + final hasTomb = candidates.any( + (c) => + c.kind == 0 && c.ts == winner.ts && c.deviceId == winner.deviceId, + ); + if (winner.kind == 1 && hasTomb) { + // Account won the sort but a tombstone shares its version key → + // the tombstone wins by policy. + final tomb = candidates.firstWhere( + (c) => + c.kind == 0 && + c.ts == winner.ts && + c.deviceId == winner.deviceId, + ); + mergedTombstones.add(tomb.payload as SyncTombstone); + } else if (winner.kind == 0) { + mergedTombstones.add(winner.payload as SyncTombstone); + } else { + mergedAccounts.add(winner.payload as ThirdPartyAccount); + } + // hasAccount is tracked for the policy check above; no further use. + assert(hasAccount || winner.kind == 0); + } + + return SyncEnvelope( + v: SyncSchema.current, + accounts: mergedAccounts, + tombstones: mergedTombstones, + ); + } + +} + +class _MergeCandidate { + final int kind; // 0 = tombstone, 1 = account + final String deviceId; + final DateTime ts; + final Object payload; + _MergeCandidate(this.kind, this.deviceId, this.ts, this.payload); +} diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart index be88ed8..62fa9f2 100644 --- a/lib/services/sync_service.dart +++ b/lib/services/sync_service.dart @@ -7,6 +7,7 @@ import '../models/third_party_account.dart'; import 'auth_service.dart'; import 'storage_service.dart'; import 'sync_crypto.dart'; +import 'sync_envelope.dart'; import 'third_party_auth_service.dart'; /// Thrown by [SyncService.pull] when the cloud has a sync blob but this device @@ -75,11 +76,17 @@ class SyncService extends ChangeNotifier { final ThirdPartyAuthService _tpAuth; final StorageService _storage; final http.Client _client; - CachedSyncKey? _cachedKey; bool _needsRestore = false; DateTime? _lastSyncAt; String? _lastError; + // Stable per-device id, loaded once at boot and stamped onto every + // locally-touched account / tombstone so the LWW merge converges. + String _deviceId = ''; + // In-memory tombstones for platforms unbound on THIS device since the last + // push. Cleared after a successful push. Survives across pulls so a remote + // account older than our deletion is not resurrected. + final List _tombstones = []; SyncService(this._auth, this._tpAuth, this._storage, {http.Client? client}) : _client = client ?? http.Client(); @@ -92,12 +99,14 @@ class SyncService extends ChangeNotifier { /// "立即备份/恢复" toasts). Null when the last call succeeded. String? get lastError => _lastError; - /// Load the cached derived key (if any) from secure storage. Call at boot. + /// Load the cached derived key (if any) from secure storage and the stable + /// device id from SharedPreferences. Call at boot. Future loadCachedKey() async { final s = await _storage.loadSyncMasterKey(); _cachedKey = await CachedSyncKey.fromStorageString(s); final iso = _storage.syncLastAt; _lastSyncAt = iso == null ? null : DateTime.tryParse(iso); + _deviceId = await _storage.ensureDeviceId(); notifyListeners(); } @@ -369,29 +378,53 @@ class SyncService extends ChangeNotifier { /// Does not decrypt — just checks presence. Cheap probe for the UI. Future cloudHasBlob() async => (await _readBlob()) != null; - String _serializeAccounts() { - final list = _tpAuth.accounts.map((a) => a.toJson()).toList(); - return jsonEncode(list); + /// Serialize the current local bindings + pending tombstones into the v2 + /// envelope plaintext. Accounts are stamped with this device's id and a + /// fresh [updatedAt] only via [touchLocal]; already-stamped accounts pass + /// through unchanged. + String _serializeEnvelope() { + final env = SyncEnvelope.fromLocal( + accounts: _tpAuth.accounts, + tombstones: _tombstones, + ); + return env.encode(); } - List _parseAccounts(String json) { - final list = jsonDecode(json) as List; - return list - .map( - (e) => - ThirdPartyAccount.fromJson((e as Map).cast()), - ) - .toList(); + /// Stamp [acc] with this device's id and the current wall clock, producing + /// a new [ThirdPartyAccount] that wins LWW against any older copy. Called + /// by [ThirdPartyAuthService] mutation paths via the public [touchLocal]. + ThirdPartyAccount touchLocal(ThirdPartyAccount acc) { + return acc.copyWith( + updatedAt: DateTime.now(), + deviceId: _deviceId, + ); + } + + /// Record that [platform] was deliberately unbound on this device. The + /// tombstone is carried in the next push and participates in LWW merge so a + /// remote account older than this deletion is not resurrected on pull. + void recordTombstone(ThirdPartyPlatform platform) { + final now = DateTime.now(); + // Replace any existing tombstone for the same platform (keep newest). + _tombstones.removeWhere((t) => t.platform == platform); + _tombstones.add( + SyncTombstone( + platform: platform, + deletedAt: now, + deviceId: _deviceId, + ), + ); } /// Push current local bindings to the cloud (encrypted). Requires a cached /// key (i.e. the device has already been set up / restored). Returns a result - /// whose [msg] carries Casdoor's error text on failure. + /// whose [msg] carries Casdoor's error text on failure. On success pending + /// tombstones are cleared (they are now persisted in the cloud blob). Future push() async { if (!enabled || _cachedKey == null) { return const SyncCasdoorResult(false, msg: '云同步未开启或缺少主密码'); } - final payload = _serializeAccounts(); + final payload = _serializeEnvelope(); // Reuse the cached salt so other devices' cached keys keep working. final salted = base64.encode(_cachedKey!.salt); final inner = await SyncCrypto.encrypt(payload, _cachedKey!.key); @@ -399,6 +432,7 @@ class SyncService extends ChangeNotifier { final res = await _writeBlob(blob); _lastError = res.ok ? null : res.msg; if (res.ok) { + _tombstones.clear(); _lastSyncAt = DateTime.now(); await _storage.setSyncLastAt(_lastSyncAt!.toIso8601String()); notifyListeners(); @@ -406,8 +440,12 @@ class SyncService extends ChangeNotifier { return res; } - /// Pull the cloud blob and restore bindings locally. Throws - /// [NeedMasterPassword] if no key is cached on this device. + /// Pull the cloud blob and merge it with the local state using per-platform + /// last-writer-wins (with [deviceId] tie-break and tombstones for + /// deletions). Unlike a blind overwrite, a local binding newer than the + /// cloud copy is preserved, and a cloud deletion (tombstone) newer than a + /// local binding removes it locally. Throws [NeedMasterPassword] if no key + /// is cached on this device. Future pull() async { if (!enabled) return; final blob = await _readBlob(); @@ -429,14 +467,83 @@ class SyncService extends ChangeNotifier { notifyListeners(); throw NeedMasterPassword(); } - final accounts = _parseAccounts(plain); - await _tpAuth.replaceAll(accounts); + final remote = SyncEnvelope.decode(plain); + if (remote == null) return; + final local = SyncEnvelope.fromLocal( + accounts: _tpAuth.accounts, + tombstones: _tombstones, + ); + final merged = local.mergeWith(remote); + // Apply the merged account set locally. applySyncMerge does NOT record + // tombstones (the merge already accounted for them); it just writes the + // winning account per platform and clears platforms whose tombstone won. + final removed = ThirdPartyPlatform.values + .where( + (p) => !merged.accounts.any((a) => a.platform == p), + ) + .toSet(); + await _tpAuth.applySyncMerge(merged.accounts, removed); + // Adopt the merged tombstone set so the next push propagates any remote + // tombstones this device didn't have. Local tombstones that won are + // already in [merged.tombstones]; ones that lost (an account won) are + // correctly absent. + _tombstones + ..clear() + ..addAll(merged.tombstones); _lastSyncAt = DateTime.now(); await _storage.setSyncLastAt(_lastSyncAt!.toIso8601String()); _needsRestore = false; + // If the merge produced a state that differs from the cloud (e.g. a local + // account won), push the merged envelope back so other devices converge. + if (!_envelopeEquals(local, merged)) { + await _writeMergedEnvelope(merged); + } notifyListeners(); } + /// Encrypt [env] and write it to the cloud, reusing the cached salt. + Future _writeMergedEnvelope(SyncEnvelope env) async { + final payload = env.encode(); + final salted = base64.encode(_cachedKey!.salt); + final inner = await SyncCrypto.encrypt(payload, _cachedKey!.key); + final blob = '$salted.$inner'; + final res = await _writeBlob(blob); + _lastError = res.ok ? null : res.msg; + if (res.ok) { + _lastSyncAt = DateTime.now(); + await _storage.setSyncLastAt(_lastSyncAt!.toIso8601String()); + } + return res; + } + + /// Cheap structural equality between two envelopes — enough to decide + /// whether a pull changed anything worth pushing back. Compares the + /// encoded form; envelope encode is deterministic. + bool _envelopeEquals(SyncEnvelope a, SyncEnvelope b) { + if (a.accounts.length != b.accounts.length) return false; + if (a.tombstones.length != b.tombstones.length) return false; + // Account order is platform-stable within an envelope. + for (var i = 0; i < a.accounts.length; i++) { + final ax = a.accounts[i], bx = b.accounts[i]; + if (ax.platform != bx.platform) return false; + if (ax.token != bx.token || + ax.account != bx.account || + ax.updatedAt != bx.updatedAt || + ax.deviceId != bx.deviceId) { + return false; + } + } + for (var i = 0; i < a.tombstones.length; i++) { + final ta = a.tombstones[i], tb = b.tombstones[i]; + if (ta.platform != tb.platform || + ta.deletedAt != tb.deletedAt || + ta.deviceId != tb.deviceId) { + return false; + } + } + return true; + } + /// First-time setup on a device that has no cloud blob yet: derive a key /// from [password] (fresh salt), cache it, and push current bindings. Future setupWithMasterPassword(String password) async { @@ -450,7 +557,7 @@ class SyncService extends ChangeNotifier { message: '云端已存在备份,请改用「恢复」并输入主密码', ); } - final payload = _serializeAccounts(); + final payload = _serializeEnvelope(); final blob = await SyncCrypto.encryptWithSalt(payload, password); final salt = SyncCrypto.extractSalt(blob)!; final key = await SyncCrypto.deriveKey(password, salt); @@ -472,7 +579,10 @@ class SyncService extends ChangeNotifier { } /// Restore on a device that has a cloud blob but no cached key: verify - /// [password] decrypts the blob, cache the key, pull bindings. + /// [password] decrypts the blob, cache the key, then merge (same LWW as + /// [pull]) so a non-empty local state is not blindly overwritten. On a + /// truly fresh device the local side is empty and the merge result equals + /// the cloud snapshot. Future restoreWithMasterPassword(String password) async { final blob = await _readBlob(); if (blob == null) { @@ -485,13 +595,30 @@ class SyncService extends ChangeNotifier { final salt = SyncCrypto.extractSalt(blob)!; final key = await SyncCrypto.deriveKey(password, salt); await _cacheKey(CachedSyncKey(salt, key)); - final accounts = _parseAccounts(plain); - await _tpAuth.replaceAll(accounts); + final remote = SyncEnvelope.decode(plain); + if (remote == null) { + return const SyncOutcome(ok: false, message: '云端备份格式损坏'); + } + final local = SyncEnvelope.fromLocal( + accounts: _tpAuth.accounts, + tombstones: _tombstones, + ); + final merged = local.mergeWith(remote); + final removed = ThirdPartyPlatform.values + .where((p) => !merged.accounts.any((a) => a.platform == p)) + .toSet(); + await _tpAuth.applySyncMerge(merged.accounts, removed); + _tombstones + ..clear() + ..addAll(merged.tombstones); await _storage.setSyncEnabled(true); _lastSyncAt = DateTime.now(); await _storage.setSyncLastAt(_lastSyncAt!.toIso8601String()); _needsRestore = false; _lastError = null; + // Push the merged envelope back so the cloud reflects this device's + // local winners (and so other devices converge on the next pull). + await _writeMergedEnvelope(merged); notifyListeners(); return const SyncOutcome(ok: true, message: '已从云端恢复绑定'); } @@ -539,6 +666,7 @@ class SyncService extends ChangeNotifier { return SyncOutcome(ok: false, message: res.describe('清除云端备份失败')); } await _clearCachedKey(); + _tombstones.clear(); await _storage.setSyncEnabled(false); await _storage.setSyncLastAt(''); _lastSyncAt = null; @@ -570,4 +698,19 @@ class SyncService extends ChangeNotifier { // Background sync failures are non-fatal; the next explicit action retries. } } + + /// Force-push current bindings to the cloud, bypassing the throttle. + /// Used after unbind/clearAll so a binding removal is immediately + /// reflected in the cloud blob — without this, a throttled pushIfDue + /// skip would leave the stale binding in the cloud, and the next boot's + /// pull would restore it. + Future forcePush() async { + if (!enabled || _cachedKey == null) return; + _lastPushAt = DateTime.now(); + try { + await push(); + } catch (_) { + // Non-fatal — next explicit sync retries. + } + } } diff --git a/lib/services/third_party_auth_service.dart b/lib/services/third_party_auth_service.dart index 15c6202..6d6fe4e 100644 --- a/lib/services/third_party_auth_service.dart +++ b/lib/services/third_party_auth_service.dart @@ -6,6 +6,8 @@ import 'package:flutter/foundation.dart'; import '../models/third_party_account.dart'; import 'api_base_url.dart'; import 'http_client.dart'; +import 'session/session_node.dart'; +import 'session/session_tree.dart'; import 'storage_service.dart'; class ThirdPartyBindException implements Exception { @@ -20,122 +22,176 @@ class ThirdPartyAuthService extends ChangeNotifier { final StorageService _storage; final LoggingHttpClient _http; - final Map _accounts = {}; bool _initialized = false; - - // SMS context for eGate binding flow (set by sendEgateSmsCode). - Map? _egateSmsContext; - - ThirdPartyAuthService(this._storage, this._http); + bool _suppressSyncPush = false; + // SMS context for cpdaily binding flow (set by sendCpdailySmsCode). + Map? _cpdailySmsContext; + + late final SessionTree _tree; + // Stable per-device id, loaded in [initialize]. Stamped onto every locally + // mutated account so the cloud-sync LWW merge converges. + String _deviceId = ''; + + + ThirdPartyAuthService(this._storage, this._http) { + _tree = SessionTree( + persist: _persistAccount, + http: _http, + baseUrl: () => apiBaseUrl(_storage), + persistDerived: _persistDerivedCookie, + ); + // Tree node notifications propagate to this service's listeners (UI, + // AssignmentService auto-refetch, etc.) and the cloud-sync push hook. + _tree.addListener(_onTreeChanged); + } String get _baseUrl => apiBaseUrl(_storage); bool get initialized => _initialized; - List get boundPlatforms => _accounts.keys.toList(); - Iterable get accounts => _accounts.values; - ThirdPartyAccount? account(ThirdPartyPlatform p) => _accounts[p]; - /// Post-construction hook fired after any binding mutation (bind / unbind / - /// raw update / replaceAll). Wired by main.dart to [SyncService.pushIfDue] - /// so the cloud backup stays current. Null until wired; safe to call. - Future Function()? onBindingsChanged; + // -- SessionTree access (new unified API) -- + + /// The unified session tree. Callers that want node-level control (e.g. + /// [SessionTree.withCookie] for 401-renew-retry) go through here. + SessionTree get sessionTree => _tree; + + /// Convenience: the CpDaily session node — parent of [eamsNode] and + /// [elearningNode]. Source of CASTGC / CpDaily cookies. + SessionNode get cpdailyNode => _tree.cpdaily; + + /// Convenience: the EAMS downstream node (child of cpdaily). + SessionNode get eamsNode => _tree.eams; + + /// Convenience: the eLearning downstream node (child of cpdaily). + SessionNode get elearningNode => _tree.elearning; + + /// Convenience: the Gradescope top-level node. + SessionNode get gradescopeNode => _tree.gradescope; + + /// Convenience: the Hydro top-level node. + SessionNode get hydroNode => _tree.hydro; + + List get boundPlatforms => + _accountsSnapshot.map((a) => a.platform).toList(); + Iterable get accounts => _accountsSnapshot; + ThirdPartyAccount? account(ThirdPartyPlatform p) => _nodeAccount(p); + + List get _accountsSnapshot => [ + _tree.cpdaily.account, + _tree.gradescope.account, + _tree.hydro.account, + ].whereType().toList(); + + ThirdPartyAccount? _nodeAccount(ThirdPartyPlatform p) => switch (p) { + ThirdPartyPlatform.cpdaily => _tree.cpdaily.account, + ThirdPartyPlatform.gradescope => _tree.gradescope.account, + ThirdPartyPlatform.hydro => _tree.hydro.account, + }; - void _notifyChanged() { + /// Post-construction hook fired after any binding mutation (bind / unbind / + /// raw update / replaceAll / node-initiated renew). Wired by main.dart to + /// [SyncService.pushIfDue] so the cloud backup stays current. Null until + /// wired; safe to call. When [force] is true the caller (unbind/clearAll) + /// wants the push to bypass the throttle so a removal is immediately + /// reflected in the cloud blob. + Future Function({bool force})? onBindingsChanged; + /// Hook fired when a platform is deliberately unbound. Wired by + /// [SyncService] to record a tombstone so the deletion survives the next + /// LWW merge (instead of being resurrected by an older remote copy). + /// Null until wired; safe to call. + void Function(ThirdPartyPlatform platform)? onUnbind; + + void _onTreeChanged({bool force = false}) { + // While [applySyncMerge] is replaying a merged snapshot, suppress ALL + // downstream effects — the merge path notifies once at the end and + // writes the merged envelope itself. This avoids an N+1 notify storm + // (one per setAccount) that would each cascade into + // AssignmentService.fetchAssignments + SyncService.pushIfDue. + if (_suppressSyncPush) return; + // A node changed (renew / setAccount) — re-notify our listeners and push + // to cloud sync. This is the single funnel for all mutations now. notifyListeners(); final hook = onBindingsChanged; if (hook != null) { - // Fire-and-forget; the hook is throttled and swallows its own errors. - unawaited(hook()); + unawaited(hook(force: force)); } } - // -- eGate binding (single source of CASTGC / CpDaily session) -- + /// Persist a (possibly refreshed) account into secure storage AND sync the + /// matching [SessionNode]'s in-memory state. Installed as the tree's + /// [PersistAccount] callback so node-initiated renews flow through here. + Future _persistAccount(ThirdPartyAccount updated) async { + // Stamp the renewed/refreshed account with this device's id + a fresh + // updatedAt so the cloud-sync LWW merge treats it as the newest version. + final touched = _touch(updated); + await _storage.saveThirdPartyAccount(touched); + // Re-sync the node's in-memory copy so UI + cookieProvider see the + // stamped version. setAccount notifies; _onTreeChanged funnels it. + _tree.setAccount(touched.platform, touched); + } + + /// Persist/clear a child node's derived cookie. Installed as the tree's + /// [PersistDerivedCookie] callback so eams/elearning cookie minting and + /// parent-renew cascades flow through storage. + Future _persistDerivedCookie(String nodeId, String? cookie) async { + if (cookie == null) { + await _storage.clearDerivedCookie(nodeId); + } else { + await _storage.saveDerivedCookie(nodeId, cookie); + } + } + + /// Stamp [acc] with this device's id + current time, for LWW merge. + ThirdPartyAccount _touch(ThirdPartyAccount acc) { + return acc.copyWith(updatedAt: DateTime.now(), deviceId: _deviceId); + } + + // -- CpDaily binding (single source of CASTGC / CpDaily session) -- // - // The eGate binding is the ONLY place CASTGC lives in the new architecture. - // The primary GeekPie SSO session has no tgc/cookies; every campus-system - // feature (schedule, blackboard, exam, oa-gym, webview features) must read - // its CpDaily session through these accessors instead of touching - // `AuthService.session` fields directly. + // The cpdaily binding is the ONLY place CASTGC lives in the new + // architecture. The primary GeekPie SSO session has no tgc/cookies; every + // campus-system feature (schedule, blackboard, exam, oa-gym, webview + // features) must read its CpDaily session through these accessors instead + // of touching `AuthService.session` fields directly. - /// True when an eGate / IDS binding exists. This is the gate every + /// True when a cpdaily binding exists. This is the gate every /// CASTGC-dependent feature must check before doing work. - bool get hasEgateBinding => _accounts[ThirdPartyPlatform.egate] != null; + bool get hasCpdailyBinding => _tree.cpdaily.account != null; - /// The bound eGate account, or null. - ThirdPartyAccount? get egateBinding => _accounts[ThirdPartyPlatform.egate]; + /// The bound cpdaily account, or null. + ThirdPartyAccount? get cpdailyBinding => _tree.cpdaily.account; /// Cookie string for campus-system requests, always ending with /// `CASTGC=` when a tgc is present (the form CpDaily/EAMS expects). /// Returns '' when there is no binding or no tgc — callers should treat /// that as "session unavailable". - String egateCookies() { - final acc = _accounts[ThirdPartyPlatform.egate]; - if (acc == null) return ''; - final raw = acc.raw; - final baseCookies = (raw['cookies'] as String?) ?? ''; - final tgc = (raw['tgc'] as String?) ?? ''; - return tgc.isEmpty - ? baseCookies - : (baseCookies.isNotEmpty - ? '$baseCookies; CASTGC=$tgc' - : 'CASTGC=$tgc'); - } - - /// Student id surfaced by the eGate binding, or '' if unbound. - String get egateStudentId => - _accounts[ThirdPartyPlatform.egate]?.sid ?? ''; - - /// Best-effort renewal of the eGate binding's CpDaily session via - /// `/api/auth/renew`. On success the refreshed raw data is persisted back - /// into the binding and listeners are notified. Returns true on success. - Future renewEgateBinding() async { - final acc = _accounts[ThirdPartyPlatform.egate]; - if (acc == null) return false; - try { - final resp = await _http.post( - Uri.parse('$_baseUrl/auth/renew'), - headers: {'Content-Type': 'application/json; charset=UTF-8'}, - body: jsonEncode({ - 'sessionToken': acc.raw['sessionToken'] ?? '', - 'tgc': acc.raw['tgc'] ?? '', - 'userId': acc.raw['userId'] ?? '', - 'tenantId': acc.raw['tenantId'] ?? '', - }), - tag: 'egateCpDailyRenew', - ); + String cpdailyCookies() => _tree.cpdaily.cookieProvider?.cookies ?? ''; - if (resp.statusCode != 200) return false; - final data = jsonDecode(resp.body) as Map; - if (data['success'] != true) return false; - - await updateRaw( - ThirdPartyPlatform.egate, - { - ...acc.raw, - 'sessionToken': - data['sessionToken'] as String? ?? acc.raw['sessionToken'] ?? '', - 'tgc': data['tgc'] as String? ?? acc.raw['tgc'] ?? '', - 'userId': data['userId'] as String? ?? acc.raw['userId'] ?? '', - 'tenantId': - data['tenantId'] as String? ?? acc.raw['tenantId'] ?? '', - 'cookies': data['cookies'] as String? ?? acc.raw['cookies'] ?? '', - }, - ); - return true; - } catch (_) { - return false; - } - } + /// Student id surfaced by the cpdaily binding, or '' if unbound. + String get cpdailyStudentId => _tree.cpdaily.account?.sid ?? ''; + /// Best-effort renewal of the cpdaily binding's CpDaily session. Delegates + /// to [SessionNode.renew], which is single-flighted: concurrent callers + /// (two services hitting 401 at once) share ONE `/auth/renew` POST. On + /// success the refreshed raw is persisted and listeners notified via the + /// tree → [Service._onTreeChanged] path. Returns true on success. Future initialize() async { + _deviceId = await _storage.ensureDeviceId(); final loaded = await _storage.loadAllThirdPartyAccounts(); - _accounts - ..clear() - ..addEntries(loaded.map((a) => MapEntry(a.platform, a))); + // Seed each node with its persisted account. setAccount routes to the + // matching node and notifies (boot hydration — the sync hook is not wired + // yet, so no cloud push fires). + for (final acc in loaded) { + _tree.setAccount(acc.platform, acc); + } + // Hydrate child node derived cookies so cold start can skip the SSO + // bounce. These are best-effort — if stale, withCookie's 401 retry will + // re-mint transparently. + for (final id in const ['eams', 'elearning']) { + final cookie = await _storage.loadDerivedCookie(id); + _tree.setDerivedCookie(id, cookie); + } _initialized = true; - // Boot hydration is not a user-driven mutation — notify listeners but do - // NOT trigger a cloud push (the hook is not wired yet at this point, and - // a pull may follow that should take precedence). notifyListeners(); } @@ -157,8 +213,10 @@ class ThirdPartyAuthService extends ChangeNotifier { body['args'] = {'url': hydroOrigin}; } + // cpdaily binds via the legacy 'egate' backend route; others use their id. + final route = platform.apiPath; final resp = await _http.post( - Uri.parse('$_baseUrl/auth/third-party/${platform.id}'), + Uri.parse('$_baseUrl/auth/third-party/$route'), headers: {'Content-Type': 'application/json; charset=UTF-8'}, body: jsonEncode(body), tag: 'thirdPartyBind:${platform.id}', @@ -207,16 +265,31 @@ class ThirdPartyAuthService extends ChangeNotifier { password: autoRenew ? password : null, ); - await _storage.saveThirdPartyAccount(acc); - _accounts[platform] = acc; - _notifyChanged(); - return acc; + final touched = _touch(acc); + await _storage.saveThirdPartyAccount(touched); + _tree.setAccount(platform, touched); + return touched; } Future unbind(ThirdPartyPlatform platform) async { - _accounts.remove(platform); + _tree.setAccount(platform, null); await _storage.clearThirdPartyAccount(platform); - _notifyChanged(); + // Unbinding cpdaily invalidates all downstream derived cookies. + if (platform == ThirdPartyPlatform.cpdaily) { + for (final id in const ['eams', 'elearning']) { + _tree.setDerivedCookie(id, null); + await _storage.clearDerivedCookie(id); + } + } + // Record a tombstone for the cloud-sync merge so this deletion is not + // resurrected by an older remote copy on the next pull. + final unbindHook = onUnbind; + if (unbindHook != null) unbindHook(platform); + // Force-push so the removal is immediately reflected in the cloud blob, + // bypassing the pushIfDue throttle. Without this, a throttled skip would + // leave the stale binding in the cloud and the next boot's pull would + // restore it. + _onTreeChanged(force: true); } /// Replace the entire in-memory + persisted binding set in one shot. Used by @@ -224,16 +297,78 @@ class ThirdPartyAuthService extends ChangeNotifier { /// existing platform binding, writes each entry in [next] to secure storage, /// and rebuilds the in-memory map. Fires a single notification. Future replaceAll(List next) async { + // Clear storage for every platform first. for (final p in ThirdPartyPlatform.values) { await _storage.clearThirdPartyAccount(p); } - _accounts - ..clear() - ..addEntries(next.map((a) => MapEntry(a.platform, a))); - for (final a in next) { - await _storage.saveThirdPartyAccount(a); + // Downstream derived cookies are invalidated when bindings are replaced. + for (final id in const ['eams', 'elearning']) { + _tree.setDerivedCookie(id, null); + await _storage.clearDerivedCookie(id); + } + // Then seed each node + persist. setAccount notifies per-node; the tree + // listener funnels into a single _onTreeChanged (throttled by SyncService). + final byPlatform = { + for (final a in next) a.platform: a, + }; + for (final p in ThirdPartyPlatform.values) { + _tree.setAccount(p, byPlatform[p]); + final a = byPlatform[p]; + if (a != null) await _storage.saveThirdPartyAccount(a); } - _notifyChanged(); + } + + /// Apply a merged account set coming from the cloud-sync LWW merge. For + /// each platform: if a kept account is present, persist + set it; if the + /// platform is in [removed], clear it. Unlike [replaceAll], this does NOT + /// clear every platform first — it writes per-platform diffs and skips the + /// cloud-sync push hook (the sync path pushes the merged envelope itself), + /// avoiding a redundant throttled push right after an explicit one. + Future applySyncMerge( + List kept, + Set removed, + ) async { + _suppressSyncPush = true; + try { + final byPlatform = { + for (final a in kept) a.platform: a, + }; + for (final p in ThirdPartyPlatform.values) { + final next = byPlatform[p]; + final cur = _nodeAccount(p); + // Skip no-op writes: if the merged account equals the current one + // (same token, same updatedAt), don't touch storage or fire notifies. + if (next != null && cur != null && _accountEqual(cur, next)) continue; + if (next == null && cur == null) continue; + if (next != null) { + await _storage.saveThirdPartyAccount(next); + _tree.setAccount(p, next); + } else { + // Clearing cpdaily invalidates downstream derived cookies. + if (p == ThirdPartyPlatform.cpdaily && cur != null) { + for (final id in const ['eams', 'elearning']) { + _tree.setDerivedCookie(id, null); + await _storage.clearDerivedCookie(id); + } + } + _tree.setAccount(p, null); + await _storage.clearThirdPartyAccount(p); + } + } + } finally { + _suppressSyncPush = false; + } + // One notification for the whole merge. + notifyListeners(); + } + + /// Cheap structural equality used by [applySyncMerge] to skip no-op writes. + static bool _accountEqual(ThirdPartyAccount a, ThirdPartyAccount b) { + return a.token == b.token && + a.account == b.account && + a.expire == b.expire && + a.updatedAt == b.updatedAt && + a.deviceId == b.deviceId; } /// Update the raw data of a bound account (e.g. after CpDaily session renewal). @@ -241,73 +376,59 @@ class ThirdPartyAuthService extends ChangeNotifier { ThirdPartyPlatform platform, Map newRaw, ) async { - final acc = _accounts[platform]; + final acc = _nodeAccount(platform); if (acc == null) return; - final updated = ThirdPartyAccount( - platform: acc.platform, - account: acc.account, - sid: acc.sid, - name: acc.name, - email: acc.email, - token: acc.token, - expire: acc.expire, - raw: newRaw, - hydroOrigin: acc.hydroOrigin, - hydroDomains: acc.hydroDomains, - boundAt: acc.boundAt, - autoRenew: acc.autoRenew, - password: acc.password, - ); - await _storage.saveThirdPartyAccount(updated); - _accounts[platform] = updated; - _notifyChanged(); + final touched = _touch(acc.copyWith(raw: newRaw)); + await _storage.saveThirdPartyAccount(touched); + _tree.setAccount(platform, touched); } - // -- eGate SMS binding flow -- + // -- CpDaily SMS binding flow -- - /// Step 1: Send an SMS verification code for eGate binding. + /// Step 1: Send an SMS verification code for cpdaily binding. /// Reuses the existing /api/auth/mobile/send-sms endpoint. - Future sendEgateSmsCode(String phone) async { + Future sendCpdailySmsCode(String phone) async { final resp = await _http.post( Uri.parse('$_baseUrl/auth/mobile/send-sms'), headers: {'Content-Type': 'application/json; charset=UTF-8'}, body: jsonEncode({'phone': phone}), - tag: 'egateSendSms', + tag: 'cpdailySendSms', ); final data = jsonDecode(resp.body) as Map; if (data['success'] != true) { throw ThirdPartyBindException( - ThirdPartyPlatform.egate, + ThirdPartyPlatform.cpdaily, data['error'] as String? ?? 'Failed to send SMS', ); } - _egateSmsContext = data['context'] as Map?; + _cpdailySmsContext = data['context'] as Map?; } - /// Step 2: Complete eGate binding via SMS verification code. - Future bindEgateSms({ + /// Step 2: Complete cpdaily binding via SMS verification code. + Future bindCpdailySms({ required String phone, required String code, bool autoRenew = false, }) async { - if (_egateSmsContext == null) { + if (_cpdailySmsContext == null) { throw ThirdPartyBindException( - ThirdPartyPlatform.egate, + ThirdPartyPlatform.cpdaily, 'Send SMS code first', ); } + // cpdaily binds via the legacy 'egate' backend route. final resp = await _http.post( Uri.parse('$_baseUrl/auth/third-party/egate'), headers: {'Content-Type': 'application/json; charset=UTF-8'}, body: jsonEncode({ 'phone': phone, 'code': code, - 'context': _egateSmsContext, + 'context': _cpdailySmsContext, }), - tag: 'egateBindSms', + tag: 'cpdailyBindSms', ); Map data; @@ -315,14 +436,14 @@ class ThirdPartyAuthService extends ChangeNotifier { data = jsonDecode(resp.body) as Map; } catch (_) { throw ThirdPartyBindException( - ThirdPartyPlatform.egate, + ThirdPartyPlatform.cpdaily, 'Invalid response (status ${resp.statusCode})', ); } if (data['success'] != true) { throw ThirdPartyBindException( - ThirdPartyPlatform.egate, + ThirdPartyPlatform.cpdaily, (data['error'] as String?) ?? 'login failed (${resp.statusCode})', ); } @@ -331,13 +452,13 @@ class ThirdPartyAuthService extends ChangeNotifier { final token = d['token'] as String?; if (token == null || token.isEmpty) { throw ThirdPartyBindException( - ThirdPartyPlatform.egate, + ThirdPartyPlatform.cpdaily, 'response missing token', ); } final acc = ThirdPartyAccount( - platform: ThirdPartyPlatform.egate, + platform: ThirdPartyPlatform.cpdaily, account: phone, sid: d['sid'] as String?, name: d['name'] as String?, @@ -349,11 +470,11 @@ class ThirdPartyAuthService extends ChangeNotifier { autoRenew: false, // SMS binding does not support auto-renew ); - await _storage.saveThirdPartyAccount(acc); - _accounts[ThirdPartyPlatform.egate] = acc; - _egateSmsContext = null; - _notifyChanged(); - return acc; + final touched = _touch(acc); + await _storage.saveThirdPartyAccount(touched); + _tree.setAccount(ThirdPartyPlatform.cpdaily, touched); + _cpdailySmsContext = null; + return touched; } /// Boot-time best-effort renewal: for each bound account whose token is @@ -366,13 +487,13 @@ class ThirdPartyAuthService extends ChangeNotifier { Duration window = const Duration(hours: 48), }) async { final cutoff = DateTime.now().add(window); - final snapshot = _accounts.values.toList(); + final snapshot = _accountsSnapshot; final failed = []; for (final acc in snapshot) { if (!acc.autoRenew) continue; - // eGate tokens are renewed via /api/auth/renew using stored tgc, + // cpdaily tokens are renewed via /api/auth/renew using stored tgc, // not via password re-authentication — skip here. - if (acc.platform == ThirdPartyPlatform.egate) continue; + if (acc.platform == ThirdPartyPlatform.cpdaily) continue; final pw = acc.password; if (pw == null || pw.isEmpty) continue; final at = acc.expireAt; @@ -395,8 +516,22 @@ class ThirdPartyAuthService extends ChangeNotifier { } Future clearAll() async { - _accounts.clear(); + final unbindHook = onUnbind; + for (final p in ThirdPartyPlatform.values) { + if (_nodeAccount(p) != null) { + if (unbindHook != null) unbindHook(p); + } + _tree.setAccount(p, null); + } await _storage.clearAllThirdPartyAccounts(); - _notifyChanged(); + await _storage.clearAllDerivedCookies(); + _onTreeChanged(force: true); + } + + @override + void dispose() { + _tree.removeListener(_onTreeChanged); + _tree.dispose(); + super.dispose(); } } diff --git a/test/assignment_service_test.dart b/test/assignment_service_test.dart index 75cafd7..4d514af 100644 --- a/test/assignment_service_test.dart +++ b/test/assignment_service_test.dart @@ -49,7 +49,7 @@ void main() { // primary UserSession. Bind one with the tgc the test expects. await storage.saveThirdPartyAccount( ThirdPartyAccount( - platform: ThirdPartyPlatform.egate, + platform: ThirdPartyPlatform.cpdaily, account: 'student', sid: 'student', token: 'session', @@ -73,6 +73,15 @@ void main() { ); final httpClient = _AssignmentHttpClient((url, _) { + if (url.path.endsWith('/auth/third-party/elearning')) { + return http.Response( + jsonEncode({ + 'success': true, + 'data': {'token': 'session_id=elearning-mock'}, + }), + 200, + ); + } if (url.path.endsWith('/deadlines/blackboard')) { return http.Response( jsonEncode({ @@ -141,7 +150,7 @@ void main() { // Exam fetch reads cookies from the eGate binding, not UserSession. await storage.saveThirdPartyAccount( ThirdPartyAccount( - platform: ThirdPartyPlatform.egate, + platform: ThirdPartyPlatform.cpdaily, account: 'student', sid: 'student', token: 'session', @@ -159,6 +168,24 @@ void main() { final seenBodies = >[]; final httpClient = _AssignmentHttpClient((url, body) { seenBodies.add(body); + if (url.path.endsWith('/auth/third-party/elearning')) { + return http.Response( + jsonEncode({ + 'success': true, + 'data': {'token': 'session_id=elearning-mock'}, + }), + 200, + ); + } + if (url.path.endsWith('/auth/third-party/eams')) { + return http.Response( + jsonEncode({ + 'success': true, + 'data': {'token': 'JSESSIONID=eams-mock'}, + }), + 200, + ); + } if (url.path.endsWith('/deadlines/blackboard')) { return http.Response( jsonEncode({'success': true, 'data': []}), @@ -228,7 +255,7 @@ void main() { contains( predicate>((body) { return body['semester_id'] == '263' && - body['cookies'] == 'SESSION=abc; CASTGC=tgc'; + body['cookies'] == 'JSESSIONID=eams-mock'; }), ), ); diff --git a/test/oa_gym_service_test.dart b/test/oa_gym_service_test.dart index eab93a9..4392b90 100644 --- a/test/oa_gym_service_test.dart +++ b/test/oa_gym_service_test.dart @@ -235,7 +235,7 @@ Future<_Fixture> _serviceFixture({ if (bindEgate) { await storage.saveThirdPartyAccount( ThirdPartyAccount( - platform: ThirdPartyPlatform.egate, + platform: ThirdPartyPlatform.cpdaily, account: '20240001', sid: '20240001', token: 'session', diff --git a/test/session_storm_test.dart b/test/session_storm_test.dart new file mode 100644 index 0000000..1a516a9 --- /dev/null +++ b/test/session_storm_test.dart @@ -0,0 +1,348 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:techpie/models/third_party_account.dart'; +import 'package:techpie/services/debug_logger.dart'; +import 'package:techpie/services/http_client.dart'; +import 'package:techpie/services/session/session_tree.dart'; + +/// Verifies the anti-renew-storm guarantees of the unified SessionNode tree: +/// 1. Concurrent renew() calls share ONE in-flight renew (single-flight). +/// 2. renewIfNeeded(beforeEpoch) skips when a concurrent renew already +/// advanced the epoch — the caller re-reads fresh cookies instead. +/// 3. withCookie retries a 401 exactly once with the fresh cookie, and two +/// concurrent withCookie callers share a single renew. +/// 4. Child nodes (eams/elearning) mint downstream cookies from the parent +/// cpdaily tgc; parent renew cascades to clear child derived cookies. +/// 5. Two-level retry: a child 401 caused by a stale parent tgc triggers +/// parent renew → child re-mint → retry. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _CountingClient client; + late LoggingHttpClient httpClient; + late SessionTree tree; + + /// Seed a cpdaily account so the cpdaily node is available. The renew + /// endpoint (/auth/renew) is mocked to rotate tgc + cookies each call. + ThirdPartyAccount seedAccount({String tgc = 'tgc-v0'}) => + ThirdPartyAccount( + platform: ThirdPartyPlatform.cpdaily, + account: 'student', + sid: '2024xxxx', + name: 'Test', + token: 'tok', + raw: { + 'sessionToken': 'st-v0', + 'tgc': tgc, + 'userId': 'u1', + 'tenantId': 't1', + 'cookies': 'JSESSIONID=js-v0', + }, + boundAt: DateTime.now(), + ); + + setUp(() { + client = _CountingClient(); + final logger = DebugLogger(); + httpClient = LoggingHttpClient(logger, inner: client); + tree = SessionTree( + persist: (_) async {}, + http: httpClient, + baseUrl: () => 'https://backend.test', + ); + tree.cpdaily.setAccount(seedAccount()); + }); + + test('concurrent renew() calls share one in-flight POST (single-flight)', + () async { + final results = await Future.wait([ + tree.cpdaily.renew(), + tree.cpdaily.renew(), + tree.cpdaily.renew(), + ]); + expect(results, [true, true, true]); + expect(client.renewCalls, 1); + // Cookie advanced to v1. + expect( + tree.cpdaily.cookieProvider!.cookies, + contains('CASTGC=tgc-v1'), + ); + }); + + test('renewIfNeeded skips when epoch already advanced by a concurrent renew', + () async { + final epochBefore = tree.cpdaily.epoch; + // Fire a renew in the background (don't await yet). + final pending = tree.cpdaily.renew(); + // While it's in-flight, renewIfNeeded(epochBefore) should join the same + // in-flight renew rather than starting a second one. + final second = tree.cpdaily.renewIfNeeded(epochBefore); + await Future.wait([pending, second]); + expect(client.renewCalls, 1); + }); + + test('renewIfNeeded does NOT skip when epoch is still current', () async { + final epochBefore = tree.cpdaily.epoch; + // No concurrent renew — renewIfNeeded must actually run. + final ok = await tree.cpdaily.renewIfNeeded(epochBefore); + expect(ok, true); + expect(client.renewCalls, 1); + expect(tree.cpdaily.epoch, greaterThan(epochBefore)); + }); + + test('withCookie retries 401 once with refreshed cookie', () async { + // First request to /fetch returns 401; retry (after renew) returns 200. + client.fetchStatuses = [401, 200]; + client.fetchBodies = ['{}', jsonEncode({'ok': true})]; + + final resp = await tree.withCookie( + tree.cpdaily, + (cp) async { + final r = await httpClient.post( + Uri.parse('https://backend.test/fetch'), + body: jsonEncode({'cookies': cp.cookies}), + ); + return CookieAction(r, expired: r.statusCode == 401); + }, + ); + expect(resp, isNotNull); + expect(resp!.statusCode, 200); + expect(client.renewCalls, 1); + expect(client.fetchCalls, 2); + }); + + test('two concurrent withCookie callers share ONE renew on simultaneous 401', + () async { + // Both callers get 401 on first attempt, 200 after renew. + client.fetchStatuses = [401, 401, 200, 200]; + client.fetchBodies = ['{}', '{}', '{"a":1}', '{"b":2}']; + + final results = await Future.wait([ + tree.withCookie( + tree.cpdaily, + (cp) async { + final r = await httpClient.post( + Uri.parse('https://backend.test/fetch'), + body: jsonEncode({'cookies': cp.cookies}), + ); + return CookieAction(r, expired: r.statusCode == 401); + }, + ), + tree.withCookie( + tree.cpdaily, + (cp) async { + final r = await httpClient.post( + Uri.parse('https://backend.test/fetch'), + body: jsonEncode({'cookies': cp.cookies}), + ); + return CookieAction(r, expired: r.statusCode == 401); + }, + ), + ]); + + // Exactly one renew across both concurrent 401-retry cycles. + expect(client.renewCalls, 1); + expect( + results.every((r) => r != null && r.statusCode == 200), + true, + reason: 'both callers should succeed after the shared renew', + ); + }); + + // -- Child node (eams/elearning) downstream renew tests -- + + test('eams doRenew mints downstream cookie from parent tgc', () async { + // Downstream renew returns a token (cookie string). + final ok = await tree.eams.renew(); + expect(ok, true); + expect(client.eamsCalls, 1); + expect(tree.eams.cookieProvider, isNotNull); + expect(tree.eams.cookieProvider!.cookies, 'JSESSIONID=eams-v1'); + // The renew POST body used the parent's current tgc. + expect(client.lastEamsBody?['tgc'], 'tgc-v0'); + }); + + test('eams isAvailable is false until doRenew succeeds', () { + expect(tree.eams.isAvailable, false); + }); + + test('eams cookieProvider is null until doRenew succeeds', () { + expect(tree.eams.cookieProvider, isNull); + }); + + test('parent cpdaily renew clears eams derived cookie (cascade)', () async { + // First mint the eams cookie. + await tree.eams.renew(); + expect(tree.eams.isAvailable, true); + // Now renew the parent — the child's derived cookie must be cleared. + final ok = await tree.cpdaily.renew(); + expect(ok, true); + expect(tree.eams.isAvailable, false); + expect(tree.eams.cookieProvider, isNull); + }); + + test('child 401 retry: eams renew succeeds (single-level, fresh parent)', + () async { + // Mint the eams cookie first. + await tree.eams.renew(); + expect(client.eamsCalls, 1); + + // Fetch returns 401 (eams cookie stale), then 200 after re-mint. + client.fetchStatuses = [401, 200]; + client.fetchBodies = ['{}', jsonEncode({'ok': true})]; + + final resp = await tree.withCookie( + tree.eams, + (cp) async { + final r = await httpClient.post( + Uri.parse('https://backend.test/fetch'), + body: jsonEncode({'cookies': cp.cookies}), + ); + return CookieAction(r, expired: r.statusCode == 401); + }, + ); + + // Single-level retry: eams renew (2nd eams call) succeeds because the + // parent tgc is still fresh. No parent renew needed. + expect(resp, isNotNull); + expect(resp!.statusCode, 200); + expect(client.renewCalls, 0); + expect(client.eamsCalls, 2); + }); + + test( + 'two-level retry: eams renew 401 (stale parent tgc) triggers parent renew', + () async { + // Reset parent to a stale tgc that the eams endpoint rejects. + tree.cpdaily.setAccount(seedAccount(tgc: 'tgc-stale')); + // Configure eams renew to fail on the stale tgc, then succeed after the + // parent renews (rotating tgc to v1). + client.eamsStatuses = [401, 200]; + client.eamsTokens = [null, 'JSESSIONID=eams-fresh']; + + // Configure the fetch path to return 200 (the fetch itself is fine once + // the eams cookie is minted). + client.fetchStatuses = [200]; + client.fetchBodies = [jsonEncode({'ok': true})]; + + final resp = await tree.withCookie( + tree.eams, + (cp) async { + final r = await httpClient.post( + Uri.parse('https://backend.test/fetch'), + body: jsonEncode({'cookies': cp.cookies}), + ); + return CookieAction(r, expired: r.statusCode == 401); + }, + ); + + expect(resp, isNotNull); + expect(resp!.statusCode, 200); + // Parent was renewed (tgc rotated to v1). + expect(client.renewCalls, 1); + // Eams was minted twice: first (failed 401), then after parent renew. + expect(client.eamsCalls, 2); + expect( + tree.eams.cookieProvider!.cookies, + 'JSESSIONID=eams-fresh', + ); + }); + + test('server error (500) does NOT trigger parent renew escalation', () async { + // Eams renew returns 500 (backend error). The two-level fallback must + // NOT escalate to parent renew — a server error isn't a credential + // issue, and re-minting the parent tgc won't fix it. + client.eamsStatuses = [500]; + + final resp = await tree.withCookie( + tree.eams, + (cp) async { + final r = await httpClient.post( + Uri.parse('https://backend.test/fetch'), + body: jsonEncode({'cookies': cp.cookies}), + ); + return CookieAction(r, expired: r.statusCode == 401); + }, + ); + + expect(resp, isNull); + // Parent renew was NOT called (no escalation on 500). + expect(client.renewCalls, 0); + // Eams renew was attempted once (first-level minting). + expect(client.eamsCalls, 1); + }); +} + +/// Mock HTTP client that counts /auth/renew and /auth/third-party/eams calls +/// and serves a configurable sequence of statuses/bodies for other paths. +/// The renew response rotates tgc + cookies by appending a version suffix so +/// each renew is observable. +class _CountingClient extends http.BaseClient { + int renewCalls = 0; + int eamsCalls = 0; + int fetchCalls = 0; + List fetchStatuses = const []; + List fetchBodies = const []; + List eamsStatuses = const []; + List eamsTokens = const []; + Map? lastEamsBody; + int _renewVersion = 0; + int _eamsVersion = 0; + + @override + Future send(http.BaseRequest request) async { + final url = request.url.toString(); + + if (url.endsWith('/auth/renew')) { + renewCalls++; + _renewVersion++; + final resp = jsonEncode({ + 'success': true, + 'sessionToken': 'st-v$_renewVersion', + 'tgc': 'tgc-v$_renewVersion', + 'userId': 'u1', + 'tenantId': 't1', + 'cookies': 'JSESSIONID=js-v$_renewVersion', + }); + return _resp(resp, 200); + } + + if (url.endsWith('/auth/third-party/eams')) { + eamsCalls++; + final body = request is http.Request + ? jsonDecode(request.body) as Map + : {}; + lastEamsBody = body; + _eamsVersion++; + final idx = eamsCalls - 1; + final status = idx < eamsStatuses.length ? eamsStatuses[idx] : 200; + final token = idx < eamsTokens.length + ? eamsTokens[idx] + : 'JSESSIONID=eams-v$_eamsVersion'; + final resp = jsonEncode({ + 'success': true, + 'data': {'token': token}, + }); + return _resp(resp, status); + } + + // Other paths (fetch) consume from the configured sequences. + fetchCalls++; + final idx = fetchCalls - 1; + final status = idx < fetchStatuses.length ? fetchStatuses[idx] : 200; + final respBody = idx < fetchBodies.length ? fetchBodies[idx] : '{}'; + return _resp(respBody, status); + } + + http.StreamedResponse _resp(String body, int status) { + final bytes = utf8.encode(body); + return http.StreamedResponse( + http.ByteStream.fromBytes(bytes), + status, + request: null, + headers: {'content-type': 'application/json'}, + ); + } +} diff --git a/test/sync_crypto_test.dart b/test/sync_crypto_test.dart index 60e762a..5979f51 100644 --- a/test/sync_crypto_test.dart +++ b/test/sync_crypto_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:techpie/models/third_party_account.dart'; import 'package:techpie/services/sync_crypto.dart'; void main() { @@ -8,7 +9,7 @@ void main() { test('encryptWithSalt -> decryptWithSalt recovers plaintext', () async { const password = 'correct horse battery staple'; const payload = - '[{"platform":"egate","account":"13800000000","token":"tgc-secret"}]'; + '[{"platform":"cpdaily","account":"13800000000","token":"tgc-secret"}]'; final blob = await SyncCrypto.encryptWithSalt(payload, password); expect(blob.contains('.'), isTrue); @@ -16,6 +17,11 @@ void main() { final recovered = await SyncCrypto.decryptWithSalt(blob, password); expect(recovered, payload); }); + test('ThirdPartyPlatform.fromId resolves legacy egate alias to cpdaily', + () { + expect(ThirdPartyPlatform.fromId('egate'), ThirdPartyPlatform.cpdaily); + expect(ThirdPartyPlatform.fromId('cpdaily'), ThirdPartyPlatform.cpdaily); + }); test('wrong master password fails authentication (returns null)', () async { diff --git a/test/sync_envelope_test.dart b/test/sync_envelope_test.dart new file mode 100644 index 0000000..dde6212 --- /dev/null +++ b/test/sync_envelope_test.dart @@ -0,0 +1,298 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:techpie/models/third_party_account.dart'; +import 'package:techpie/services/sync_envelope.dart'; + +ThirdPartyAccount _acc({ + required ThirdPartyPlatform platform, + String token = 't', + DateTime? updatedAt, + String deviceId = '', + DateTime? boundAt, +}) { + final b = boundAt ?? DateTime.utc(2026); + return ThirdPartyAccount( + platform: platform, + account: 'a', + token: token, + boundAt: b, + updatedAt: updatedAt ?? b, + deviceId: deviceId, + ); +} + +void main() { + group('SyncSchema.migrate', () { + test('v0 bare array is wrapped into a v2 envelope', () { + final v0 = jsonEncode([ + {'platform': 'gradescope', 'account': 'a', 'token': 't1'}, + ]); + final env = SyncEnvelope.decode(v0); + expect(env, isNotNull); + expect(env!.v, SyncSchema.current); + expect(env.accounts, hasLength(1)); + expect(env.accounts.first.platform, ThirdPartyPlatform.gradescope); + expect(env.accounts.first.token, 't1'); + expect(env.tombstones, isEmpty); + // v0 accounts have no updatedAt/deviceId → back-compat defaults. + expect(env.accounts.first.deviceId, ''); + }); + + test('v1 envelope (no tombstones) migrates to v2 with empty tombstones', () { + final v1 = jsonEncode({ + 'v': 1, + 'accounts': [ + {'platform': 'hydro', 'account': 'h', 'token': 'tok'}, + ], + }); + final env = SyncEnvelope.decode(v1); + expect(env!.v, SyncSchema.current); + expect(env.accounts.first.platform, ThirdPartyPlatform.hydro); + expect(env.tombstones, isEmpty); + }); + + test('v2 envelope round-trips accounts + tombstones', () { + final original = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.cpdaily, + token: 'tgc', + updatedAt: DateTime.utc(2026, 1, 2), + deviceId: 'devA', + ), + ], + tombstones: [ + SyncTombstone( + platform: ThirdPartyPlatform.gradescope, + deletedAt: DateTime.utc(2026, 1, 3), + deviceId: 'devB', + ), + ], + ); + final decoded = SyncEnvelope.decode(original.encode()); + expect(decoded!.v, SyncSchema.current); + expect(decoded.accounts, hasLength(1)); + expect(decoded.accounts.first.token, 'tgc'); + expect(decoded.accounts.first.deviceId, 'devA'); + expect(decoded.tombstones, hasLength(1)); + expect(decoded.tombstones.first.platform, ThirdPartyPlatform.gradescope); + expect(decoded.tombstones.first.deviceId, 'devB'); + }); + + test('garbage plaintext returns null', () { + expect(SyncEnvelope.decode('not json'), isNull); + expect(SyncEnvelope.decode('123'), isNull); + }); + }); + + group('SyncEnvelope.mergeWith (LWW)', () { + test('newer local account wins over older remote', () { + final local = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.gradescope, + token: 'local-new', + updatedAt: DateTime.utc(2026, 1, 5), + deviceId: 'A', + ), + ], + tombstones: const [], + ); + final remote = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.gradescope, + token: 'remote-old', + updatedAt: DateTime.utc(2026, 1, 1), + deviceId: 'B', + ), + ], + tombstones: const [], + ); + final merged = local.mergeWith(remote); + expect(merged.accounts, hasLength(1)); + expect(merged.accounts.first.token, 'local-new'); + }); + + test('remote account on a platform absent locally is adopted', () { + final local = const SyncEnvelope( + v: SyncSchema.current, + accounts: [], + tombstones: [], + ); + final remote = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.hydro, + token: 'remote-only', + updatedAt: DateTime.utc(2026, 1, 1), + deviceId: 'B', + ), + ], + tombstones: const [], + ); + final merged = local.mergeWith(remote); + expect(merged.accounts, hasLength(1)); + expect(merged.accounts.first.token, 'remote-only'); + }); + + test('tombstone newer than account removes the account', () { + final local = SyncEnvelope( + v: SyncSchema.current, + accounts: const [], + tombstones: [ + SyncTombstone( + platform: ThirdPartyPlatform.gradescope, + deletedAt: DateTime.utc(2026, 1, 5), + deviceId: 'A', + ), + ], + ); + final remote = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.gradescope, + token: 'stale', + updatedAt: DateTime.utc(2026, 1, 1), + deviceId: 'B', + ), + ], + tombstones: const [], + ); + final merged = local.mergeWith(remote); + expect(merged.accounts, isEmpty); + expect(merged.tombstones, hasLength(1)); + expect(merged.tombstones.first.platform, ThirdPartyPlatform.gradescope); + }); + + test('account newer than tombstone resurrects the account', () { + final local = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.hydro, + token: 'rebind', + updatedAt: DateTime.utc(2026, 1, 10), + deviceId: 'A', + ), + ], + tombstones: const [], + ); + final remote = SyncEnvelope( + v: SyncSchema.current, + accounts: const [], + tombstones: [ + SyncTombstone( + platform: ThirdPartyPlatform.hydro, + deletedAt: DateTime.utc(2026, 1, 1), + deviceId: 'B', + ), + ], + ); + final merged = local.mergeWith(remote); + expect(merged.accounts, hasLength(1)); + expect(merged.accounts.first.token, 'rebind'); + // Old tombstone is dropped — the account won. + expect(merged.tombstones, isEmpty); + }); + + test('equal timestamps tie-break by deviceId (larger wins)', () { + final ts = DateTime.utc(2026, 1, 1); + final local = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.cpdaily, + token: 'A', + updatedAt: ts, + deviceId: 'aaa', + ), + ], + tombstones: const [], + ); + final remote = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.cpdaily, + token: 'B', + updatedAt: ts, + deviceId: 'zzz', + ), + ], + tombstones: const [], + ); + final merged = local.mergeWith(remote); + // 'zzz' > 'aaa' → remote wins. + expect(merged.accounts.first.token, 'B'); + }); + + test('empty deviceId always loses to a real one', () { + final ts = DateTime.utc(2026, 1, 1); + final local = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.cpdaily, + token: 'legacy', + updatedAt: ts, + deviceId: '', + ), + ], + tombstones: const [], + ); + final remote = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.cpdaily, + token: 'real', + updatedAt: ts, + deviceId: 'dev', + ), + ], + tombstones: const [], + ); + final merged = local.mergeWith(remote); + expect(merged.accounts.first.token, 'real'); + }); + + test('independent platforms on both sides are both kept', () { + final local = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.gradescope, + token: 'gs', + updatedAt: DateTime.utc(2026, 1, 1), + deviceId: 'A', + ), + ], + tombstones: const [], + ); + final remote = SyncEnvelope( + v: SyncSchema.current, + accounts: [ + _acc( + platform: ThirdPartyPlatform.hydro, + token: 'hy', + updatedAt: DateTime.utc(2026, 1, 1), + deviceId: 'B', + ), + ], + tombstones: const [], + ); + final merged = local.mergeWith(remote); + expect(merged.accounts, hasLength(2)); + final platforms = merged.accounts.map((a) => a.platform).toSet(); + expect(platforms, contains(ThirdPartyPlatform.gradescope)); + expect(platforms, contains(ThirdPartyPlatform.hydro)); + }); + }); +} diff --git a/test/sync_service_test.dart b/test/sync_service_test.dart index 19a9f2f..2fc6864 100644 --- a/test/sync_service_test.dart +++ b/test/sync_service_test.dart @@ -59,7 +59,7 @@ void main() { final fx = await _Fixture.withSession(); await fx.tpAuth.replaceAll([ ThirdPartyAccount( - platform: ThirdPartyPlatform.egate, + platform: ThirdPartyPlatform.cpdaily, account: '13800000000', sid: '20240001', token: 'tgc-secret', @@ -73,7 +73,7 @@ void main() { final outcome = await fx2.sync.restoreWithMasterPassword('wrong'); expect(outcome.ok, isFalse); expect(outcome.message, contains('不正确')); - expect(fx2.tpAuth.account(ThirdPartyPlatform.egate), isNull); + expect(fx2.tpAuth.account(ThirdPartyPlatform.cpdaily), isNull); }); test('push writes current bindings; disable clears the cloud blob', @@ -124,6 +124,79 @@ void main() { throwsA(isA()), ); }); + // -- LWW merge behavior (the bug these guard against) ---------------------- + + test( + 'pull does NOT overwrite a locally-newer binding with an older cloud copy', + () async { + // Device A: set up sync with a gradescope binding (old updatedAt). + final fx = await _Fixture.withSession(); + await fx.tpAuth.replaceAll([ + ThirdPartyAccount( + platform: ThirdPartyPlatform.gradescope, + account: 'a@b', + token: 'cloud-old', + boundAt: DateTime.utc(2026, 1, 1), + ), + ]); + await fx.sync.setupWithMasterPassword('pw'); + + // Device B: restore, then locally rebind a NEWER token. + final fx2 = await _Fixture.withSession(server: fx.server); + await fx2.sync.restoreWithMasterPassword('pw'); + // Bump the local binding's updatedAt to "now" via the real bind path. + await fx2.tpAuth.replaceAll([ + ThirdPartyAccount( + platform: ThirdPartyPlatform.gradescope, + account: 'a@b', + token: 'local-new', + boundAt: DateTime.utc(2026, 1, 10), + updatedAt: DateTime.utc(2026, 1, 10), + deviceId: 'devB', + ), + ]); + // Manually stamp deviceId on device 2 so the touch helper works. The + // fixture loads deviceId lazily; ensureDeviceId already ran in init. + + // Pull from cloud (which still has 'cloud-old'). The merge must keep + // 'local-new' because its updatedAt is newer. + await fx2.sync.pull(); + expect( + fx2.tpAuth.account(ThirdPartyPlatform.gradescope)?.token, + 'local-new', + reason: 'a newer local binding must survive a pull of older cloud data', + ); + }); + + test( + 'a deletion (tombstone) on device A is not resurrected when device B pulls', + () async { + // Device A: bind gradescope, set up sync. + final fx = await _Fixture.withSession(); + await fx.tpAuth.replaceAll([ + ThirdPartyAccount( + platform: ThirdPartyPlatform.gradescope, + account: 'a@b', + token: 't', + boundAt: DateTime.utc(2026, 1, 1), + ), + ]); + await fx.sync.setupWithMasterPassword('pw'); + + // Device A: unbind gradescope. This records a tombstone + force-pushes. + await fx.tpAuth.unbind(ThirdPartyPlatform.gradescope); + // Cloud blob now carries a tombstone, no gradescope account. + + // Device B: restore (gets the post-deletion state) — should have no + // gradescope binding. + final fx2 = await _Fixture.withSession(server: fx.server); + await fx2.sync.restoreWithMasterPassword('pw'); + expect( + fx2.tpAuth.account(ThirdPartyPlatform.gradescope), + isNull, + reason: 'tombstone on device A must remove the binding on device B', + ); + }); } // --------------------------------------------------------------------------- @@ -227,6 +300,11 @@ class _Fixture { final auth = AuthService(storage, httpClient, uniAuth); final tpAuth = ThirdPartyAuthService(storage, httpClient); final sync = SyncService(auth, tpAuth, storage, client: srv.toHttpClient()); + // Mirror main.dart wiring so tombstones are recorded + pushes fire. + tpAuth.onBindingsChanged = ({force = false}) { + return force ? sync.forcePush() : sync.pushIfDue(); + }; + tpAuth.onUnbind = sync.recordTombstone; await auth.loadSession(); await tpAuth.initialize(); await sync.loadCachedKey();