diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 52219d3..1556179 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -196,3 +196,23 @@ Home 首页历史消息加载与滚动策略属于高回归模块,必须遵循 - controller-level state transition tests - widget-level unread indicator and scroll behavior tests - route-return stability tests when navigation behavior changes + +## 11) Cache & Repository Rules (MUST) + +前端缓存与数据访问属于高回归区域,必须遵循以下约束: + +- **MUST** route feature data reads/writes through repository layer when cache, invalidation, or optimistic update is involved. + - Feature/UI code **MUST NOT** call raw `*Api` methods directly for mutation paths that affect list/detail consistency. + - Exceptions are allowed only for bootstrapping or truly stateless read operations, and must be documented in code review notes. +- **MUST** keep cache key ownership centralized in repository classes. + - UI/Bloc/Cubit **MUST NOT** hardcode cache keys or perform ad-hoc cache writes. +- **MUST** define cache invalidation at mutation boundaries (create/update/delete/archive/complete/reorder). + - Mutation success must either update cache atomically or invalidate and trigger deterministic refresh. +- **MUST** preserve route-return consistency for data freshness. + - Pages that mutate entity data must return an explicit changed signal to caller routes. + - Caller list pages must consume that signal and refresh using repository path. +- **MUST** ensure list item widgets that carry local interaction state use stable identity keys (e.g. `ValueKey(entity.id)`) to prevent state leakage across reused cells. +- **MUST** add/maintain regression tests when changing cache/repository behavior: + - repository tests for optimistic update + rollback + invalidation + - route-return refresh tests for list/detail/edit flows + - widget tests for stable keyed interaction state where applicable diff --git a/apps/lib/core/cache/cache_invalidator.dart b/apps/lib/core/cache/cache_invalidator.dart index c61d3bc..b7ba30e 100644 --- a/apps/lib/core/cache/cache_invalidator.dart +++ b/apps/lib/core/cache/cache_invalidator.dart @@ -1,12 +1,19 @@ +import 'dart:async'; + import 'hybrid_cache_store.dart'; class CacheInvalidator { + final HybridCacheStore? _store; final Set _invalidated = {}; - CacheInvalidator({HybridCacheStore? store}); + CacheInvalidator({HybridCacheStore? store}) : _store = store; void invalidate(String key) { _invalidated.add(key); + final store = _store; + if (store != null) { + unawaited(store.remove(key)); + } } void invalidateCalendarDay(DateTime date) { diff --git a/apps/lib/features/calendar/reminders/reminder_action_executor.dart b/apps/lib/features/calendar/reminders/reminder_action_executor.dart index 365c69d..4b0c5cc 100644 --- a/apps/lib/features/calendar/reminders/reminder_action_executor.dart +++ b/apps/lib/features/calendar/reminders/reminder_action_executor.dart @@ -1,7 +1,5 @@ import 'dart:math'; -import 'package:flutter/widgets.dart'; - import '../data/services/calendar_service.dart'; import '../../../core/notifications/local_notification_service.dart'; import 'models/reminder_action.dart'; @@ -13,23 +11,16 @@ class ReminderActionExecutor { final LocalNotificationService _notificationService; final ReminderOutboxStore _outboxStore; final Random _random; - final bool Function() _isAppActive; ReminderActionExecutor({ required CalendarService calendarService, required LocalNotificationService notificationService, required ReminderOutboxStore outboxStore, Random? random, - bool Function()? isAppActive, }) : _calendarService = calendarService, _notificationService = notificationService, _outboxStore = outboxStore, - _random = random ?? Random(), - _isAppActive = - isAppActive ?? - (() => - WidgetsBinding.instance.lifecycleState == - AppLifecycleState.resumed); + _random = random ?? Random(); Future handleAction({ required ReminderAction action, @@ -95,9 +86,11 @@ class ReminderActionExecutor { } Future _archiveEvent(String eventId, ReminderAction action) async { - if (_isAppActive()) { + try { await _calendarService.archiveEvent(eventId); return; + } catch (_) { + // fall through to enqueue local outbox for retry } final opId = diff --git a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart index 85b705c..f5a7b69 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart @@ -304,9 +304,14 @@ class _CalendarDayWeekScreenState extends State if (isNotToday) const SizedBox(width: 8), AppPressable( borderRadius: BorderRadius.circular(AppRadius.full), - onTap: () => context.push( - '${AppRoutes.calendarEventCreate}?date=${formatYmd(_selectedDate)}', - ), + onTap: () async { + final changed = await context.push( + '${AppRoutes.calendarEventCreate}?date=${formatYmd(_selectedDate)}', + ); + if (changed == true) { + await _loadEvents(forceRefresh: true); + } + }, child: Container( width: 36, height: 36, @@ -625,8 +630,14 @@ class _CalendarDayWeekScreenState extends State height: tapHeight, child: GestureDetector( behavior: HitTestBehavior.translucent, - onTap: () => - context.push(AppRoutes.calendarEventDetail(layout.event.id)), + onTap: () async { + final changed = await context.push( + AppRoutes.calendarEventDetail(layout.event.id), + ); + if (changed == true) { + await _loadEvents(forceRefresh: true); + } + }, child: Stack( children: [ Positioned( diff --git a/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart index 8c1e6eb..842ae29 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart @@ -481,7 +481,7 @@ class _CalendarEventDetailScreenState extends State { if (!mounted) { return; } - context.pop(); + context.pop(true); } Future _archiveEvent() async { @@ -496,9 +496,8 @@ class _CalendarEventDetailScreenState extends State { } try { await sl().archiveEvent(widget.eventId); - await _loadEvent(); if (mounted) { - Toast.show(context, '已归档', type: ToastType.success); + context.pop(true); } } catch (e) { if (mounted) { diff --git a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart index e113f76..610f1ec 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart @@ -150,7 +150,14 @@ class _CalendarMonthScreenState extends State const Spacer(), AppPressable( borderRadius: BorderRadius.circular(AppRadius.full), - onTap: () => context.push(AppRoutes.calendarEventCreate), + onTap: () async { + final changed = await context.push( + AppRoutes.calendarEventCreate, + ); + if (changed == true) { + await _loadMonthEvents(forceRefresh: true); + } + }, child: Container( width: 36, height: 36, @@ -345,9 +352,14 @@ class _CalendarMonthScreenState extends State ); return AppPressable( borderRadius: BorderRadius.circular(AppRadius.sm), - onTap: () { + onTap: () async { _calendarManager.setSelectedDate(date); - context.push('/calendar/events/${event.id}'); + final changed = await context.push( + '/calendar/events/${event.id}', + ); + if (changed == true) { + await _loadMonthEvents(forceRefresh: true); + } }, child: Container( margin: const EdgeInsets.only(bottom: 2), diff --git a/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart index 07ed0b8..9242869 100644 --- a/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart +++ b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart @@ -751,7 +751,7 @@ class _CreateEventSheetState extends State widget.onSaved?.call(); if (mounted) { - Navigator.pop(context); + Navigator.pop(context, true); } } catch (e) { if (mounted) { diff --git a/apps/lib/features/home/ui/navigation/home_return_policy.dart b/apps/lib/features/home/ui/navigation/home_return_policy.dart index d752f2c..1c4e219 100644 --- a/apps/lib/features/home/ui/navigation/home_return_policy.dart +++ b/apps/lib/features/home/ui/navigation/home_return_policy.dart @@ -37,7 +37,13 @@ void returnToHomePreserveState( context.pop(); return; case HomeReturnAction.goHome: + context.go(AppRoutes.homeMain); + return; case HomeReturnAction.goHomeForDock: + if (context.canPop()) { + context.pop(); + return; + } context.go(AppRoutes.homeMain); return; } diff --git a/apps/lib/features/todo/data/todo_repository.dart b/apps/lib/features/todo/data/todo_repository.dart index ee7fa08..cebdf6d 100644 --- a/apps/lib/features/todo/data/todo_repository.dart +++ b/apps/lib/features/todo/data/todo_repository.dart @@ -46,11 +46,7 @@ class TodoRepository { ); if (cached != null) { final next = cached.value - .map( - (todo) => todo.id == id - ? todo.copyWith(status: 'completed', completedAt: now()) - : todo, - ) + .where((todo) => todo.id != id) .toList(growable: false); await store.write>>( pendingListKey, @@ -58,9 +54,9 @@ class TodoRepository { ); } - invalidator.invalidate(pendingListKey); try { await api.completeTodo(id); + invalidator.invalidate(pendingListKey); } catch (error) { if (cached != null) { await store.write>>( diff --git a/apps/lib/features/todo/ui/screens/todo_detail_screen.dart b/apps/lib/features/todo/ui/screens/todo_detail_screen.dart index 4c44524..b3dc750 100644 --- a/apps/lib/features/todo/ui/screens/todo_detail_screen.dart +++ b/apps/lib/features/todo/ui/screens/todo_detail_screen.dart @@ -29,6 +29,7 @@ class _TodoDetailScreenState extends State { TodoResponse? _todo; bool _isLoading = true; + bool _didMutate = false; String? _error; @override @@ -122,7 +123,7 @@ class _TodoDetailScreenState extends State { Widget _buildHeader() { return BackTitlePageHeader( title: '待办详情', - onBack: () => context.pop(), + onBack: () => context.pop(_didMutate), trailing: _buildHeaderMenu(), ); } @@ -379,10 +380,11 @@ class _TodoDetailScreenState extends State { } final changed = await context.push(AppRoutes.todoEdit(_todo!.id)); if (changed == true) { - await _loadTodo(); - if (mounted && _error != null) { - Toast.show(context, '刷新失败: $_error', type: ToastType.error); + _didMutate = true; + if (!mounted) { + return; } + context.pop(true); } } @@ -398,7 +400,7 @@ class _TodoDetailScreenState extends State { try { await _todoApi.deleteTodo(_todo!.id); if (mounted) { - context.pop(); + context.pop(true); } } catch (e) { if (mounted) { diff --git a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart index cd46678..fa17f69 100644 --- a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart +++ b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart @@ -268,11 +268,8 @@ class _TodoQuadrantsScreenState extends State { Future _completeTodo(TodoResponse todo) async { try { await _todoRepository.completeTodo(todo.id); - if (mounted) { - Toast.show(context, '已完成', type: ToastType.success); - } try { - await _loadTodos(); + await _loadTodos(showPageLoader: false); } catch (_) { // ignore reload error } @@ -283,14 +280,17 @@ class _TodoQuadrantsScreenState extends State { } } - void _navigateToDetail(TodoResponse todo) { - context.push(AppRoutes.todoDetail(todo.id)); + Future _navigateToDetail(TodoResponse todo) async { + final changed = await context.push(AppRoutes.todoDetail(todo.id)); + if (changed == true) { + await _loadTodos(showPageLoader: false); + } } Future _addTodo() async { final created = await context.push(AppRoutes.todoCreate); if (created == true) { - await _loadTodos(); + await _loadTodos(showPageLoader: false); } } @@ -326,25 +326,6 @@ class _TodoQuadrantsScreenState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ - AppPressable( - borderRadius: BorderRadius.circular(AppRadius.full), - onTap: _loadTodos, - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: AppColors.messageBtnWrap, - borderRadius: BorderRadius.circular(AppRadius.full), - border: Border.all(color: AppColors.messageBtnBorder), - ), - child: const Icon( - LucideIcons.refreshCcw, - size: 18, - color: AppColors.slate600, - ), - ), - ), - const SizedBox(width: AppSpacing.sm), AppPressable( borderRadius: BorderRadius.circular(AppRadius.full), onTap: _addTodo, @@ -448,6 +429,7 @@ class _TodoQuadrantsScreenState extends State { horizontal: AppSpacing.sm, ), child: _TodoItemWidget( + key: ValueKey(item.id), item: item, onComplete: () => _completeTodo(item), onTap: () => _navigateToDetail(item), @@ -603,6 +585,7 @@ class _TodoItemWidget extends StatefulWidget { final VoidCallback onTap; const _TodoItemWidget({ + super.key, required this.item, required this.onComplete, required this.onTap, diff --git a/apps/test/features/calendar/reminders/reminder_action_executor_test.dart b/apps/test/features/calendar/reminders/reminder_action_executor_test.dart index 20b02ae..f9a5090 100644 --- a/apps/test/features/calendar/reminders/reminder_action_executor_test.dart +++ b/apps/test/features/calendar/reminders/reminder_action_executor_test.dart @@ -30,7 +30,6 @@ void main() { calendarService: calendarService, notificationService: notificationService, outboxStore: outboxStore, - isAppActive: () => true, ); }); @@ -58,50 +57,13 @@ void main() { expect(pending, isEmpty); }); - test( - 'archive action should send remote archive immediately when app active', - () async { - when( - () => notificationService.cancelEventReminder('evt_live'), - ).thenAnswer((_) async {}); - when( - () => calendarService.archiveEvent('evt_live'), - ).thenAnswer((_) async => null); - - executor = ReminderActionExecutor( - calendarService: calendarService, - notificationService: notificationService, - outboxStore: outboxStore, - isAppActive: () => true, - ); - - await executor.handleAction( - action: ReminderAction.archive, - payload: ReminderPayload( - eventId: 'evt_live', - title: 'sync', - startAt: DateTime.parse('2026-03-18T16:00:00+08:00'), - timezone: 'Asia/Shanghai', - ), - ); - - verify(() => calendarService.archiveEvent('evt_live')).called(1); - final pending = await outboxStore.listPending(); - expect(pending, isEmpty); - }, - ); - - test('archive in inactive app writes pending outbox item', () async { + test('archive failure writes pending outbox item', () async { when( () => notificationService.cancelEventReminder('evt_1'), ).thenAnswer((_) async {}); - - executor = ReminderActionExecutor( - calendarService: calendarService, - notificationService: notificationService, - outboxStore: outboxStore, - isAppActive: () => false, - ); + when( + () => calendarService.archiveEvent('evt_1'), + ).thenThrow(Exception('offline')); await executor.handleAction( action: ReminderAction.archive, @@ -117,6 +79,7 @@ void main() { expect(pending.length, 1); expect(pending.first.eventId, 'evt_1'); expect(pending.first.state, ReminderOutboxState.pending); + verify(() => calendarService.archiveEvent('evt_1')).called(1); }); test('snooze reschedules +10m when event not expired', () async { diff --git a/apps/test/features/todo/todo_repository_test.dart b/apps/test/features/todo/todo_repository_test.dart index eab12ee..ffa633d 100644 --- a/apps/test/features/todo/todo_repository_test.dart +++ b/apps/test/features/todo/todo_repository_test.dart @@ -12,7 +12,7 @@ class _MockTodoApi extends Mock implements TodoApi {} void main() { test( - 'complete todo should optimistically update and invalidate pending list key', + 'complete todo should optimistically remove item and invalidate pending list key', () async { final api = _MockTodoApi(); final store = HybridCacheStore( @@ -50,7 +50,7 @@ void main() { final updated = await store.read>>( TodoRepository.pendingListKey, ); - expect(updated?.value.first.status, 'completed'); + expect(updated, isNull); expect(invalidator.wasInvalidated(TodoRepository.pendingListKey), true); }, ); diff --git a/backend/src/v1/todo/service.py b/backend/src/v1/todo/service.py index 2852365..c0e5bb2 100644 --- a/backend/src/v1/todo/service.py +++ b/backend/src/v1/todo/service.py @@ -170,6 +170,7 @@ class TodoService(BaseService): ) await self._session.commit() + await self._session.refresh(todo) except SQLAlchemyError: await self._session.rollback() raise HTTPException(status_code=503, detail="Todo service unavailable") diff --git a/backend/tests/unit/v1/todo/test_service.py b/backend/tests/unit/v1/todo/test_service.py new file mode 100644 index 0000000..f303bdc --- /dev/null +++ b/backend/tests/unit/v1/todo/test_service.py @@ -0,0 +1,62 @@ +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID, uuid4 + +import pytest + +from core.auth.models import CurrentUser +from models.todos import Todo, TodoStatus +from v1.todo.schemas import TodoUpdate +from v1.todo.service import TodoService + + +def _create_mock_todo() -> Todo: + todo = MagicMock(spec=Todo) + todo.id = uuid4() + todo.owner_id = UUID("00000000-0000-0000-0000-000000000001") + todo.title = "Test Todo" + todo.description = None + todo.priority = 1 + todo.order = 0 + todo.status = TodoStatus.PENDING + todo.completed_at = None + now = datetime(2026, 3, 20, 8, 0, 0, tzinfo=timezone.utc) + todo.created_at = now + todo.updated_at = now + return todo + + +@pytest.mark.asyncio +async def test_update_refreshes_todo_before_building_response() -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + todo = _create_mock_todo() + + repository = AsyncMock() + repository.get_by_id.return_value = todo + repository.update.return_value = todo + repository.get_schedule_items.return_value = [] + + schedule_item_repository = AsyncMock() + + session = AsyncMock() + service = TodoService( + repository=repository, + schedule_item_repository=schedule_item_repository, + session=session, + current_user=CurrentUser(id=user_id), + ) + + await service.update( + todo.id, + TodoUpdate( + title="Updated", + description=None, + priority=None, + order=None, + status=None, + schedule_item_ids=None, + ), + ) + + session.commit.assert_awaited_once() + session.refresh.assert_awaited_once_with(todo)