From 8ed52312fbd37d7f26756a4dbcd19281fac58e57 Mon Sep 17 00:00:00 2001 From: shenlei Date: Wed, 16 Sep 2026 17:05:15 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=99=BB=E5=BD=95=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=90=8E=E9=A6=96=E9=A1=B5=E6=81=A2=E5=A4=8D=E8=AF=BE=E7=A8=8B?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E4=B8=8E=E5=A4=8D=E4=B9=A0=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拉取合并时还原当前课程与分段位置,位置只前进不后退,跳过其他设备已完成的课 - 由云端掌握项重建复习卡片,并纠正旧客户端写坏的首次复习到期时间 - 同 checkpoint 时以证据更多的一方为准;推送缺卡片项时不再用当前时间作到期时间 - 登录/注册及合并逻辑升级后强制全量拉取一次 Co-Authored-By: Claude Opus 5 --- kouyu_english/lib/core/app_state.dart | 67 ++++++++ .../lib/core/sync/sync_coordinator.dart | 27 +++- kouyu_english/lib/core/sync/sync_merger.dart | 115 +++++++++---- kouyu_english/test/sync_test.dart | 153 +++++++++++++++++- 4 files changed, 324 insertions(+), 38 deletions(-) diff --git a/kouyu_english/lib/core/app_state.dart b/kouyu_english/lib/core/app_state.dart index 72d2bdc..b353d61 100644 --- a/kouyu_english/lib/core/app_state.dart +++ b/kouyu_english/lib/core/app_state.dart @@ -1081,6 +1081,73 @@ class AppState extends ChangeNotifier { SyncCoordinator.instance.triggerBackgroundSync(this); } + /// Merges lesson progress pulled from the sync server. + /// + /// Completed lessons/segments are unioned, and the learner's position only + /// ever moves forward: a device that has not caught up yet must never drag + /// another device back to an earlier lesson or segment. + bool mergeSyncedLessonProgress({ + required Iterable completedLessons, + required Iterable completedSegments, + required String remoteActiveLessonId, + }) { + var changed = false; + final completedBefore = completedLessonIds.toSet(); + for (final id in completedLessons) { + if (completedLessonIds.add(id)) changed = true; + } + for (final id in completedSegments) { + if (completedSegmentIds.add(id)) changed = true; + } + if (this.completedLessons != completedLessonIds.length) { + this.completedLessons = completedLessonIds.length; + changed = true; + } + + int lessonIndex(String id) => + a0SeedLessons.indexWhere((lesson) => lesson.id == id); + + var targetIndex = lessonIndex(activeLessonId); + final remoteIndex = lessonIndex(remoteActiveLessonId); + if (remoteIndex > targetIndex && isLessonUnlocked(remoteActiveLessonId)) { + targetIndex = remoteIndex; + } + // The server's active lesson can itself be stale. Skip lessons that only + // became complete through this pull, but leave a lesson alone when the + // learner deliberately reopened it locally after finishing it. + while (targetIndex >= 0 && + targetIndex < a0SeedLessons.length - 1 && + !completedBefore.contains(a0SeedLessons[targetIndex].id) && + completedLessonIds.contains(a0SeedLessons[targetIndex].id)) { + targetIndex++; + } + + var positionChanged = false; + if (targetIndex >= 0 && a0SeedLessons[targetIndex].id != activeLessonId) { + activeLessonId = a0SeedLessons[targetIndex].id; + positionChanged = true; + } + + for (final lesson in a0SeedLessons) { + final segments = lesson.segments; + var firstOpen = segments.indexWhere( + (segment) => !completedSegmentIds.contains(segment.id), + ); + if (firstOpen < 0) firstOpen = segments.length - 1; + if (firstOpen > activeSegmentIndexFor(lesson.id)) { + activeSegmentIndexes[lesson.id] = firstOpen; + if (lesson.id == activeLessonId) positionChanged = true; + changed = true; + } + } + + if (positionChanged) { + _resetLessonFlow(); + changed = true; + } + return changed; + } + void _resetLessonFlow() { lessonStep = LessonStep.preview; previewIndex = 0; diff --git a/kouyu_english/lib/core/sync/sync_coordinator.dart b/kouyu_english/lib/core/sync/sync_coordinator.dart index fd58251..989adb7 100644 --- a/kouyu_english/lib/core/sync/sync_coordinator.dart +++ b/kouyu_english/lib/core/sync/sync_coordinator.dart @@ -14,6 +14,11 @@ import 'sync_service.dart'; class SyncCoordinator extends ChangeNotifier { static const _prefKey = 'sync_config_v1'; + /// 合并逻辑版本。升级后丢弃旧的增量同步时间戳,强制全量拉取一次, + /// 让旧版本已经跳过的课程位置和复习卡片能被重新合并。 + static const _mergeVersion = 2; + static const _mergeVersionKey = 'mergeVersion'; + static final SyncCoordinator instance = SyncCoordinator._(); SyncCoordinator._({SyncService? service}) : _service = service ?? SyncService(); @@ -49,6 +54,9 @@ class SyncCoordinator extends ChangeNotifier { if (raw != null && raw.isNotEmpty) { final map = jsonDecode(raw) as Map; _config = SyncConfig.fromJson(map); + if ((map[_mergeVersionKey] as int? ?? 1) < _mergeVersion) { + _config = _withoutLastSyncTime(_config); + } } } catch (e) { debugPrint('[SyncCoordinator] init error: $e'); @@ -61,12 +69,23 @@ class SyncCoordinator extends ChangeNotifier { Future _saveConfig() async { try { final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_prefKey, jsonEncode(_config.toJson())); + await prefs.setString( + _prefKey, + jsonEncode({..._config.toJson(), _mergeVersionKey: _mergeVersion}), + ); } catch (e) { debugPrint('[SyncCoordinator] save config error: $e'); } } + static SyncConfig _withoutLastSyncTime(SyncConfig config) => SyncConfig( + serverUrl: config.serverUrl, + token: config.token, + username: config.username, + userId: config.userId, + autoSyncEnabled: config.autoSyncEnabled, + ); + /// 更新服务器地址 Future updateServerUrl(String newUrl) async { _config = _config.copyWith(serverUrl: newUrl.trim()); @@ -98,7 +117,8 @@ class SyncCoordinator extends ChangeNotifier { username: username, password: password, ); - _config = _config.copyWith( + // 新登录的账号必须全量拉取,不能沿用之前的增量时间戳 + _config = _withoutLastSyncTime(_config).copyWith( serverUrl: serverUrl.trim(), token: auth.token, username: auth.username, @@ -132,7 +152,8 @@ class SyncCoordinator extends ChangeNotifier { username: username, password: password, ); - _config = _config.copyWith( + // 新登录的账号必须全量拉取,不能沿用之前的增量时间戳 + _config = _withoutLastSyncTime(_config).copyWith( serverUrl: serverUrl.trim(), token: auth.token, username: auth.username, diff --git a/kouyu_english/lib/core/sync/sync_merger.dart b/kouyu_english/lib/core/sync/sync_merger.dart index d9df367..0b71866 100644 --- a/kouyu_english/lib/core/sync/sync_merger.dart +++ b/kouyu_english/lib/core/sync/sync_merger.dart @@ -1,3 +1,4 @@ +import '../a0_core.dart'; import '../models.dart'; import '../app_state.dart'; import 'sync_models.dart'; @@ -20,8 +21,11 @@ class SyncMerger { final masteryUpdates = state.mastery.values.map((m) { // 查找对应复习到期时间 final review = state.reviewQueue.where((r) => r.id == m.id).firstOrNull; - final dueAt = review?.dueAt.toUtc().toIso8601String() ?? - DateTime.now().toUtc().toIso8601String(); + final dueAt = (review?.dueAt ?? + m.firstTaughtAt?.add(_firstReviewDelay) ?? + DateTime.now()) + .toUtc() + .toIso8601String(); final attempts = review?.attempts ?? 0; final successfulReviews = review?.successfulReviews ?? 0; @@ -71,60 +75,60 @@ class SyncMerger { static bool applyPullResponse(AppState state, SyncPullResponse response) { var changed = false; - // 1. 合并课程关卡 (Union) + // 1. 合并课程关卡 (Union),学习位置只前进不后退 if (response.progress != null) { final p = response.progress!; - for (final id in p.completedLessonIds) { - if (!state.completedLessonIds.contains(id)) { - state.completedLessonIds.add(id); - changed = true; - } - } - for (final sid in p.completedSegmentIds) { - if (!state.completedSegmentIds.contains(sid)) { - state.completedSegmentIds.add(sid); - changed = true; - } - } - if (state.completedLessonIds.length != state.completedLessons) { - state.completedLessons = state.completedLessonIds.length; + if (state.mergeSyncedLessonProgress( + completedLessons: p.completedLessonIds, + completedSegments: p.completedSegmentIds, + remoteActiveLessonId: p.activeLessonId, + )) { changed = true; } } - // 2. 合并复习掌握项 (Max Checkpoint) + // 2. 合并复习掌握项 (Max Checkpoint;同 Checkpoint 时证据更多者更新) for (final m in response.masteryUpdates) { final local = state.mastery[m.itemId]; + final status = _parseMasteryStatus(m.status); + final evidence = (m.payload['evidence'] as List? ?? []) + .whereType() + .map(_parseEvidenceKind) + .toList(); + final firstTaughtAt = m.payload['firstTaughtAt'] is String + ? DateTime.tryParse(m.payload['firstTaughtAt'] as String) + : null; + final needsReview = m.payload['needsReview'] as bool?; if (local == null) { // 本地没有,直接添加 - final status = _parseMasteryStatus(m.status); - final evidence = (m.payload['evidence'] as List? ?? []) - .whereType() - .map(_parseEvidenceKind) - .toList(); - state.mastery[m.itemId] = MasteryItem( id: m.itemId, label: m.payload['label'] as String? ?? m.itemId, status: status, evidence: evidence, - needsReview: m.payload['needsReview'] as bool? ?? false, + needsReview: needsReview ?? false, checkpoint: m.checkpoint, - firstTaughtAt: m.payload['firstTaughtAt'] != null - ? DateTime.tryParse(m.payload['firstTaughtAt'] as String) - : null, + firstTaughtAt: firstTaughtAt, ); changed = true; - } else if (m.checkpoint > local.checkpoint) { - // 云端 Checkpoint 更高,升级本地状态 - final status = _parseMasteryStatus(m.status); + } else if (m.checkpoint > local.checkpoint || + (m.checkpoint == local.checkpoint && + evidence.length > local.evidence.length)) { + // 云端进度更新(证据只追加不删除,条数更多说明学得更多) state.mastery[m.itemId] = local.copyWith( checkpoint: m.checkpoint, status: status, - needsReview: m.payload['needsReview'] as bool? ?? local.needsReview, + evidence: evidence.length > local.evidence.length ? evidence : null, + needsReview: needsReview ?? local.needsReview, + firstTaughtAt: local.firstTaughtAt ?? firstTaughtAt, ); changed = true; + } else if (local.firstTaughtAt == null && firstTaughtAt != null) { + state.mastery[m.itemId] = local.copyWith(firstTaughtAt: firstTaughtAt); + changed = true; } + + if (_mergeReviewItem(state, m, firstTaughtAt)) changed = true; } // 3. 合并用户偏好设置 (按需合并) @@ -146,6 +150,53 @@ class SyncMerger { return changed; } + /// 与 AppState 引入新课目标时的首次复习间隔保持一致 + static const _firstReviewDelay = Duration(days: 1); + + /// 首页的到期复习数来自 reviewQueue,因此云端掌握项也要还原为复习卡片 + static bool _mergeReviewItem( + AppState state, + SyncMasteryItemPayload m, + DateTime? firstTaughtAt, + ) { + // 只有在课程里正式引入过、或复习过的项目才会有复习卡片 + if (firstTaughtAt == null && m.attempts == 0) return false; + // 从未复习过的卡片到期时间固定为首次学习后一天;旧版本客户端会把 + // 缺失卡片的 due_at 写成推送时刻,这里据此纠正 + final dueAt = m.attempts == 0 && firstTaughtAt != null + ? firstTaughtAt.add(_firstReviewDelay) + : DateTime.tryParse(m.dueAt); + if (dueAt == null) return false; + + final index = state.reviewQueue.indexWhere((r) => r.id == m.itemId); + if (index < 0) { + final template = coreReviewTemplate(m.itemId); + state.reviewQueue.add( + ReviewItem( + id: m.itemId, + target: a0CoreItems[m.itemId] ?? m.itemId, + prompt: template.prompt, + hint: template.hint, + dueAt: dueAt.toLocal(), + skill: template.skill, + attempts: m.attempts, + successfulReviews: m.successfulReviews, + ), + ); + return true; + } + final local = state.reviewQueue[index]; + if (m.attempts > local.attempts) { + state.reviewQueue[index] = local.copyWith( + dueAt: dueAt.toLocal(), + attempts: m.attempts, + successfulReviews: m.successfulReviews, + ); + return true; + } + return false; + } + static MasteryStatus _parseMasteryStatus(String str) { return MasteryStatus.values.where((e) => e.name == str).firstOrNull ?? MasteryStatus.newItem; diff --git a/kouyu_english/test/sync_test.dart b/kouyu_english/test/sync_test.dart index be311b5..e569764 100644 --- a/kouyu_english/test/sync_test.dart +++ b/kouyu_english/test/sync_test.dart @@ -169,6 +169,130 @@ void main() { }); }); + group('SyncMerger restores home page position on a fresh device', () { + // Mirrors what the server held for a real account: lesson a0-01 was + // finished on one device, then another device pushed its stale position + // (active lesson a0-01, review due_at overwritten with the push time). + SyncPullResponse stalePull() => const SyncPullResponse( + serverTime: '2026-09-16T08:00:00Z', + progress: SyncProgressPayload( + activeLessonId: 'a0-01', + completedLessonIds: ['a0-01'], + completedSegmentIds: ['a0-01-a'], + updatedAt: '2026-09-16T07:54:27Z', + ), + masteryUpdates: [ + SyncMasteryItemPayload( + itemId: 'A0-P03', + checkpoint: 0, + status: 'recall', + dueAt: '2026-09-16T07:55:13Z', + payload: { + 'label': 'A0-P03', + 'evidence': ['exposure', 'assisted', 'independentSuccess'], + 'firstTaughtAt': '2026-09-16T07:49:45Z', + }, + updatedAt: '2026-09-16T07:54:27Z', + ), + ], + ); + + test('advances past lessons completed on another device', () { + final state = AppState(); + state.lessonStep = LessonStep.speaking; + + final changed = SyncMerger.applyPullResponse(state, stalePull()); + + expect(changed, isTrue); + expect(state.completedLessonIds, contains('a0-01')); + expect(state.activeLessonId, 'a0-02'); + expect(state.activeSegmentIndexFor('a0-01'), 0); + expect(state.lessonStep, LessonStep.preview); + }); + + test('keeps a completed lesson the learner reopened locally', () { + final state = AppState(); + state.completedLessonIds.addAll(['a0-01', 'a0-02']); + state.completedLessons = 2; + state.activeLessonId = 'a0-01'; + + SyncMerger.applyPullResponse(state, stalePull()); + + expect(state.activeLessonId, 'a0-01'); + }); + + test('never moves the position backwards', () { + final state = AppState(); + state.completedLessonIds.addAll(['a0-01', 'a0-02']); + state.activeLessonId = 'a0-03'; + + SyncMerger.applyPullResponse(state, stalePull()); + + expect(state.activeLessonId, 'a0-03'); + }); + + test('adopts a further remote lesson and segment position', () { + final state = AppState(); + final pull = SyncPullResponse( + serverTime: '2026-09-16T08:00:00Z', + progress: SyncProgressPayload( + activeLessonId: 'a0-04', + completedLessonIds: const ['a0-01', 'a0-02', 'a0-03'], + completedSegmentIds: const ['a0-01-a', 'a0-02-a', 'a0-03-a', 'a0-04-a'], + updatedAt: '2026-09-16T07:54:27Z', + ), + ); + + SyncMerger.applyPullResponse(state, pull); + + expect(state.activeLessonId, 'a0-04'); + expect(state.activeSegmentIndexFor('a0-04'), 1); + }); + + test('rebuilds review cards and repairs never-reviewed due dates', () { + final state = AppState(); + + SyncMerger.applyPullResponse(state, stalePull()); + + final review = state.reviewQueue.singleWhere((r) => r.id == 'A0-P03'); + expect( + review.dueAt.toUtc(), + DateTime.parse('2026-09-17T07:49:45Z'), + ); + expect(state.mastery['A0-P03']?.status, MasteryStatus.recall); + }); + + test('equal checkpoint with more remote evidence updates local status', () { + final state = AppState(); + state.mastery['A0-P03'] = const MasteryItem( + id: 'A0-P03', + label: 'A0-P03', + status: MasteryStatus.newItem, + evidence: [EvidenceKind.exposure], + ); + + SyncMerger.applyPullResponse(state, stalePull()); + + expect(state.mastery['A0-P03']?.status, MasteryStatus.recall); + expect(state.mastery['A0-P03']?.evidence.length, 3); + }); + + test('push without a review card derives due date from first teaching', () { + final state = AppState(); + state.mastery['A0-P01'] = MasteryItem( + id: 'A0-P01', + label: 'A0-P01', + status: MasteryStatus.newItem, + evidence: const [], + firstTaughtAt: DateTime.parse('2026-09-16T07:49:45Z'), + ); + + final req = SyncMerger.buildPushRequest(state); + + expect(req.masteryUpdates.single.dueAt, '2026-09-17T07:49:45.000Z'); + }); + }); + group('SyncService HTTP operations', () { test('testConnection returns true on 200 health check', () async { final mockClient = MockClient((request) async { @@ -278,7 +402,7 @@ void main() { group('SyncCoordinator Integration', () { test('login, syncNow and logout lifecycle', () async { - final mockClient = MockClient((request) async { + Future handler(http.Request request) async { if (request.url.path == '/api/v1/auth/login') { return http.Response( jsonEncode({ @@ -325,9 +449,9 @@ void main() { ); } return http.Response('Not Found', 404); - }); + } - final service = SyncService(client: mockClient); + final service = SyncService(client: MockClient(handler)); final coordinator = SyncCoordinator.createForTesting(service: service); await coordinator.init(); @@ -349,6 +473,29 @@ void main() { expect(coordinator.lastSyncTime, isNotNull); expect(state.completedLessonIds, contains('a0-01')); + final pullQueries = >[]; + final relogCoordinator = SyncCoordinator.createForTesting( + service: SyncService( + client: MockClient((request) async { + if (request.url.path == '/api/v1/sync/pull') { + pullQueries.add(request.url.queryParameters); + } + return handler(request); + }), + ), + ); + await relogCoordinator.init(); + await relogCoordinator.syncNow(AppState()); + expect(relogCoordinator.lastSyncTime, isNotNull); + await relogCoordinator.login( + serverUrl: 'http://127.0.0.1:8080', + username: 'tester', + password: 'password123', + ); + await relogCoordinator.syncNow(AppState()); + // A fresh login must pull everything, not only changes since last sync. + expect(pullQueries.last.containsKey('since'), isFalse); + await coordinator.logout(); expect(coordinator.isLoggedIn, isFalse); expect(coordinator.config.token, isNull);