docs: 更新协议文档并清理过期的问题追踪文档

This commit is contained in:
qzl
2026-03-27 14:05:14 +08:00
parent c592cc7854
commit 47e2aa3eb9
18 changed files with 1499 additions and 778 deletions
@@ -1,50 +0,0 @@
# Bug: 前端未渲染 events 接口事件
## 日期
- 2026-03-24
## 现象
- 用户反馈:改动后前端无法获取/渲染 `/api/v1/agent/runs/{threadId}/events` 的事件。
- 页面表现为消息流无事件增量或工具执行状态未更新。
## 本次背景
- 本次清理了前端死链路:
- `ToolRegistry`
- `RouteNavigationTool`
- `AiDecisionEngine`
- 当前主链路仍为 AG-UI SSE`AgUiService -> AgUiEvent -> ChatBloc -> HomeChatItemRenderer`
## 影响范围
- Chat 事件流渲染(运行状态、工具调用状态、文本完成事件)
- 可能影响 Home 聊天视图实时反馈
## 初步判断
- 已清理的死链路不在当前主流程中,理论上不应直接导致 SSE 事件无法渲染。
- 更可能的问题点:
1. `runId` 绑定过滤导致事件被丢弃(`shouldDispatch` 为 false
2. `onEvent` 回调异常导致流提前停止
3. SSE `data` 结构变化,`AgUiEvent.fromJson` 解析失败
## 关键代码位置
- `apps/lib/features/chat/data/services/ag_ui_service.dart`
- `apps/lib/features/chat/data/models/ag_ui_event.dart`
- `apps/lib/features/chat/presentation/bloc/chat_bloc.dart`
- `apps/lib/features/home/ui/widgets/home_chat_item_renderer.dart`
## 待执行排查
1.`_streamEventsFromApi` 增加临时诊断日志:`eventType``eventRunId``expectedRunId``shouldDispatch`
2. 捕获并输出 `onEvent` 抛错栈,确认是否由 UI/Bloc 处理异常中断
3. 抓取真实 SSE 帧,核对 `runId/threadId/type/data` 与解析模型一致性
4. 复测 `RUN_STARTED -> TOOL_* -> TEXT_MESSAGE_END -> RUN_FINISHED/RUN_ERROR` 完整链路
## 当前状态
- 状态:待定位
- 优先级:高
@@ -0,0 +1,140 @@
# Repository 缓存层抽象优化
## 问题描述
### 现有架构
```
┌─────────────────────────────────────────┐
│ HybridCacheStore │
│ (Memory + Persistent 二级缓存) │
├─────────────────────────────────────────┤
│ CacheEntry<T> │
│ (value + fetchedAt 时间戳) │
├─────────────────────────────────────────┤
│ CachePolicy │
│ (softTtl / hardTtl / minRefreshInterval)│
├─────────────────────────────────────────┤
│ CacheInvalidator │
│ (统一失效管理) │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ CalendarRepository │ ← 重复实现
│ TodoRepository │ ← 重复实现
│ UsersRepository │ ← 重复实现
│ ... │
└─────────────────────────────────────────┘
```
### 重复内容
| 重复内容 | 例子 |
|---------|------|
| key 命名空间 | `calendar:day:$day``todo:list:pending` |
| 缓存读取逻辑 | `store.read<CacheEntry<...>>(key)` |
| 数据转换 | API 返回 → CacheEntry 包装 |
| 刷新逻辑 | `_refreshDayAndRead()` |
| 强制刷新 | `forceRefresh` 参数处理 |
| 后台刷新防重 | `_refreshInFlight` map |
### 涉及文件
- `apps/lib/features/calendar/data/services/calendar_repository.dart`
- `apps/lib/features/todo/data/todo_repository.dart`
- `apps/lib/features/contacts/data/users/users_repository_impl.dart`
- `apps/lib/features/settings/data/services/user_profile_cache_repository.dart`
## 建议方案
### 1. 抽取 `CachedRepository` 基类
```dart
abstract class CachedRepository<T, R> {
HybridCacheStore get store;
CacheInvalidator get invalidator;
CachePolicy get policy;
String get namespace; // 'calendar', 'todo', etc.
Future<T> getOrLoad(
String key, {
bool forceRefresh = false,
required Future<R> Function() loader,
});
Future<void> invalidate(String key);
String buildKey(String suffix);
}
```
### 2. 各模块简化
```dart
// CalendarRepository
class CalendarRepository extends CachedRepository<List<ScheduleItemModel>, ScheduleItemModel> {
@override
String get namespace => 'calendar';
@override
Future<List<ScheduleItemModel>> getDayEvents(DateTime date, {bool forceRefresh}) {
return getOrLoad(
'day:${_formatDate(date)}',
forceRefresh: forceRefresh,
loader: () => calendarService.getEventsForDay(date),
);
}
String _formatDate(DateTime date) =>
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
// TodoRepository
class TodoRepository extends CachedRepository<List<TodoResponse>, TodoResponse> {
@override
String get namespace => 'todo';
Future<List<TodoResponse>> getPendingTodos({bool forceRefresh = false}) {
return getOrLoad(
'list:pending',
forceRefresh: forceRefresh,
loader: () => api.getPendingTodos(),
);
}
}
```
### 3. 可选:泛型缓存装饰器
```dart
class CachedApiCall<T> {
final HybridCacheStore store;
final CachePolicy policy;
final String key;
final DateTime Function() now;
Future<T> execute(Future<T> Function() loader);
}
```
## 收益
| 收益 | 说明 |
|------|------|
| 减少重复代码 | 各 Repository 移除 60%+ 相似逻辑 |
| 统一缓存行为 | 刷新策略、key 格式、并发控制一致 |
| 易维护 | 修复 bug 或优化逻辑只需改一处 |
| 易测试 | 基类可独立测试,子类继承即可 |
## 前置依赖
- 现有 `HybridCacheStore``CacheEntry``CachePolicy``CacheInvalidator` 已就绪
- 无需引入新依赖
## 状态
- [ ] 待评估优先级
- [ ] 待设计 CachedRepository 基类接口
- [ ] 先在一个 Repository 上试点
- [ ] 推广到其他 Repository
@@ -0,0 +1,90 @@
# AppTheme 硬编码颜色且缺失 Dark Mode
## 问题描述
### 1. 颜色硬编码
`AppTheme` 和各组件大量直接引用 `AppColors` 静态常量,而非 `Theme.of(context).colorScheme`
```dart
// app_theme.dart
appBarTheme: const AppBarTheme(
backgroundColor: AppColors.background, // 硬编码
foregroundColor: AppColors.slate900, // 硬编码
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary, // 硬编码
foregroundColor: AppColors.primaryForeground,
),
),
```
这导致:
- 主题切换时颜色不会改变
- 组件无法响应系统深色模式
- 违反 Flutter Material Design 规范
### 2. 缺失 Dark Mode
`AppTheme` 只有 `light` getter,没有 `dark`
```dart
static ThemeData get light => ThemeData(...);
```
`LinksyApp` 硬编码使用 light
```dart
theme: AppTheme.light,
locale: const Locale('zh'),
```
## 正确做法
### 颜色应使用 ThemeData
```dart
// 正确示例
appBarTheme: AppBarTheme(
backgroundColor: Theme.of(context).colorScheme.surface,
foregroundColor: Theme.of(context).colorScheme.onSurface,
),
// ColorScheme 应由 ThemeData 生成
colorScheme: ColorScheme.fromSeed(
seedColor: AppColors.primary,
brightness: Brightness.light, // 或 Brightness.dark
),
```
### 支持 Dark Mode
```dart
class AppTheme {
static ThemeData get light => ThemeData(
brightness: Brightness.light,
colorScheme: ColorScheme.fromSeed(
seedColor: AppColors.primary,
brightness: Brightness.light,
),
);
static ThemeData get dark => ThemeData(
brightness: Brightness.dark,
colorScheme: ColorScheme.fromSeed(
seedColor: AppColors.primary,
brightness: Brightness.dark,
),
);
}
```
## 相关文件
- `apps/lib/core/theme/app_theme.dart`
- `apps/lib/core/theme/design_tokens.dart`
## 修复优先级
**低** - 当前只有 light 模式,不影响功能
@@ -0,0 +1,43 @@
# AuthSessionBootstrapper 旧代码应删除
## 文件位置
`apps/lib/app/startup/auth_session_bootstrapper.dart`
## 问题描述
`AuthSessionBootstrapper` 是遗留代码,用于在用户登录时同步日历事件和通知提醒。
### 代码问题
```dart
Future<void> syncForAuthState(AuthState state) async {
if (state is! AuthAuthenticated) {
_syncedUserId = null;
return;
}
// 获取180天日历事件并重建通知提醒
final events = await _calendarService.getEventsForRange(start, end);
await _notificationService.rebuildUpcomingReminders(events);
...
}
```
1. **同步逻辑已迁移** - `CalendarService``LocalNotificationService` 应自己管理缓存生命周期,无需登录时手动触发
2. **内存缓存不可靠** - `_syncedUserId` 仅内存存储,App 重启后失效
3. **静默失败** - 同步失败被 `catch (_)` 吞掉,无日志无重试
4. **180 天硬编码** - 时间范围未从配置读取
## 处理方式
**直接删除**
- 删除 `apps/lib/app/startup/auth_session_bootstrapper.dart`
- 确认无调用处后,清理 `startup/` 目录(若为空)
## 相关文件
- `apps/lib/app/startup/auth_session_bootstrapper.dart`
## 修复优先级
**低** - 功能层面暂无影响,但属于应清理的技术债
@@ -0,0 +1,49 @@
# LinksyApp 强制依赖 ChatBloc
## 问题描述
`LinksyApp` (app.dart) 作为应用根节点,被迫在 `MultiBlocProvider` 中注入 `ChatBloc`
```dart
return MultiBlocProvider(
providers: [
BlocProvider<AuthBloc>.value(value: authBloc),
BlocProvider<ChatBloc>(
create: (_) => ChatBloc(apiClient: sl<IApiClient>()),
),
],
...
);
```
这导致:
1. 应用启动时就创建 `ChatBloc` 实例(内存浪费)
2. `LinksyApp` 需要知道"存在 ChatBloc 这个 Feature"
3. 违反单一职责原则:根节点应只负责全局配置,不应了解具体 Feature
## 根本原因
`HomeScreen` 是默认首页,其内部需要 `ChatBloc`。为了让它通过 `context.read<ChatBloc>()` 获取,被迫在根节点提供。
## 正确做法
ChatBloc 应该在路由级别按需注入:
```dart
GoRoute(
path: '/',
builder: (context) => BlocProvider(
create: (_) => ChatBloc(apiClient: sl<IApiClient>()),
child: const HomeScreen(),
),
)
```
## 相关文件
- `apps/lib/app/app.dart`
- `apps/lib/features/home/presentation/screens/home_screen.dart`
## 修复优先级
**中等** - 功能正常但架构不合理,属于技术债
+118
View File
@@ -0,0 +1,118 @@
# main.dart 与认证模块耦合
## 问题描述
当前 `main.dart` 直接依赖了 `AuthBloc``AuthStarted`,违反了依赖反转原则。
## 当前代码
```dart
// main.dart
import 'features/auth/presentation/bloc/auth_bloc.dart';
import 'features/auth/presentation/bloc/auth_event.dart';
void main() async {
// ...
final authBloc = sl<AuthBloc>();
authBloc.add(AuthStarted());
runApp(LinksyApp(authBloc: authBloc));
}
```
## 问题
1. **启动逻辑与认证模块耦合**
- main.dart 需要知道 `AuthBloc` 的存在
- 需要知道 `AuthStarted` 事件
- 需要手动触发启动事件
2. **AuthBloc 被暴露3层**
```
main.dart → LinksyApp → createAppRouter → redirect()
```
每层都传 authBloc,不优雅
## 建议方案
### 1. 启动逻辑下沉到 LinksyApp
```dart
// main.dart
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await configureDependencies();
await AppConstants.init();
runApp(LinksyApp());
}
// app.dart (LinksyApp)
class LinksyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final authBloc = sl<AuthBloc>();
authBloc.add(AuthStarted());
return BlocProvider.value(
value: authBloc,
child: // ...
);
}
}
```
### 2. 路由守卫由 LinksyApp 内部管理
```dart
// app.dart
class LinksyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
final router = GoRouter.of(context);
if (state is AuthUnauthenticated) {
router.go(AppRoutes.authLogin);
} else if (state is AuthAuthenticated) {
if (router.matchedLocation == AppRoutes.authLogin) {
router.go(AppRoutes.homeMain);
}
}
},
child: MaterialApp.router(
routerConfig: createAppRouter(),
),
);
}
}
```
### 3. createAppRouter 不再需要 authBloc 参数
```dart
// app_router.dart
GoRouter createAppRouter() {
return GoRouter(
// 不再有 redirect 回调
// 路由守卫由 BlocListener 在 LinksyApp 统一处理
);
}
```
## 收益
| 收益 | 说明 |
|------|------|
| 解耦 | main.dart 完全不知道 AuthBloc 存在 |
| 单一职责 | LinksyApp 统一管理状态监听和路由跳转 |
| 易测试 | main.dart 不再需要 mock AuthBloc |
## 涉及文件
- `apps/lib/main.dart`
- `apps/lib/app/app.dart`
- `apps/lib/app/router/app_router.dart`
- `apps/lib/features/auth/presentation/bloc/auth_bloc.dart`
## 状态
- [ ] 待修复
@@ -0,0 +1,85 @@
# SharedPreferences 缺少统一管理模型
## 问题描述
当前 `SharedPreferences` 的使用散落各处,缺乏统一的数据模型约束:
### 现状
1. **Key 散落**
- `reminder_notification_callbacks.dart` 中定义:`'calendar_reminder_pending_notification_responses_v1'`
- 各处直接使用字符串 key,容易拼写错误或冲突
2. **重复获取实例**
```dart
final prefs = await SharedPreferences.getInstance(); // 每次都重新获取
```
3. **序列化逻辑分散**
- `ReminderNotificationCallbacks` 自己处理 JSON 序列化/反序列化
- 其他模块可能重复相同逻辑
4. **注册但未统一封装**
- `injection.dart` 只注册了 `SharedPreferences` 实例
- 没有封装成可复用的数据访问层
## 影响
- 维护困难:Key 散落,修改时需要全局搜索
- 容易出错:拼写错误难以发现
- 代码重复:序列化逻辑可能在多处重复实现
- 可测试性差:直接依赖 `SharedPreferences.getInstance()`
## 建议方案
### 1. 创建 `AppPreferences` 数据模型
```dart
class AppPreferences {
static const String _pendingNotificationsKey =
'calendar_reminder_pending_notification_responses_v1';
final SharedPreferences _prefs;
AppPreferences(this._prefs);
List<NotificationResponse> get pendingNotifications {
final list = _prefs.getStringList(_pendingNotificationsKey) ?? [];
return list.map(_decode).toList();
}
Future<void> setPendingNotifications(List<NotificationResponse> value) {
return _prefs.setStringList(_pendingNotificationsKey, value.map(_encode).toList());
}
// 其他偏好设置...
}
```
### 2. 在 injection.dart 中注册
```dart
final sharedPreferences = await SharedPreferences.getInstance();
sl.registerSingleton<AppPreferences>(AppPreferences(sharedPreferences));
```
### 3. 使用方通过接口访问
```dart
// 之前
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(key, value);
// 之后
sl<AppPreferences>().setPendingNotifications(value);
```
## 涉及文件
- `apps/lib/app/di/injection.dart` - 注册逻辑
- `apps/lib/features/notification/data/services/reminder_notification_callbacks.dart` - 主要使用方
- `apps/lib/features/notification/data/services/ios_notification_payload_bridge.dart` - 另一使用方
## 状态
- [ ] 待修复
@@ -0,0 +1,123 @@
# 服务层与 Repository 层职责混乱
## 问题描述
当前 `CalendarService``SettingsUserCache``UserProfileCacheRepository` 等服务/仓库职责边界模糊,存在大量重复逻辑和不必要的封装。
## 问题1SettingsUserCache 不该存在
### 当前结构
```
SettingsUserCache UserProfileCacheRepository
┌─────────────────┐ ┌─────────────────────────┐
│ - _cachedUser │ ←→ │ - HybridCacheStore │
│ - getProfile() │ │ - CachePolicy │
│ - set() │ │ - getProfile() │
│ - invalidate() │ │ - setCached() │
└─────────────────┘ └─────────────────────────┘
```
### 问题
- `SettingsUserCache` 只是给 `UserProfileCacheRepository` 包了一层内存缓存
- 两者的 `getProfile()``invalidate()` 逻辑几乎相同
- 这是重复包装,应该合并
## 问题2Repository 缓存逻辑重复
### 涉及文件
- `apps/lib/features/calendar/data/services/calendar_repository.dart`
- `apps/lib/features/settings/data/services/user_profile_cache_repository.dart`
- `apps/lib/features/todo/data/todo_repository.dart`
### 代码重复率:90%
```dart
// CalendarRepository
Future<List<ScheduleItemModel>> getDayEvents({bool forceRefresh}) async {
if (forceRefresh) return _refreshDayAndRead(...);
final cached = await store.read<CacheEntry<...>>(key);
if (cached == null) return _refreshDayAndRead(...);
final decision = policy.evaluate(now: now(), fetchedAt: cached.fetchedAt);
if (decision.shouldRefreshInBackground) _refreshInBackground();
if (decision.mustBlockForNetwork || !decision.canUseCached) {
return _refreshDayAndRead(...);
}
return cached.value;
}
// UserProfileCacheRepository
Future<UserResponse> getProfile({bool forceRefresh}) async {
if (forceRefresh) return _refreshAndRead();
final cached = await store.read<CacheEntry<...>>(cacheKey);
if (cached == null) return _refreshAndRead();
final decision = policy.evaluate(now: now(), fetchedAt: cached.fetchedAt);
if (decision.shouldRefreshInBackground) _refreshInBackground();
if (decision.mustBlockForNetwork || !decision.canUseCached) {
return _refreshAndRead();
}
return cached.value;
}
```
## 问题3CalendarService 不必要的延迟初始化
```dart
class CalendarService {
CalendarApi? _calendarApi;
CalendarApi get _api {
if (_calendarApi != null) return _calendarApi;
_calendarApi = CalendarApi(_apiClient); // 为什么懒加载?
return _calendarApi;
}
}
```
已经传入了 `IApiClient`,API 还在构造时懒加载,多此一举。
## 问题4:分层不清
| 类名 | 类型 | 问题 |
|------|------|------|
| `CalendarService` | Service | 依赖 Repository,该叫 Repository |
| `UserProfileCacheRepository` | Repository | 名字带 Cache,但 Repository 都带缓存 |
| `SettingsUserCache` | ??? | 内存缓存层,不该独立存在 |
| `TodoRepository` | Repository | 正确 |
## 应该的设计
```
Repository 层(纯数据 + 缓存)
├── CalendarRepository ← 继承 CachedRepository
├── UserProfileRepository ← 继承 CachedRepository
└── TodoRepository ← 继承 CachedRepository
Service 层(业务逻辑 + 跨 Repository 编排)
├── CalendarService ← 只做业务编排,不直接调 API
├── NotificationService ← 跨模块通知逻辑
└── ReminderActionExecutor ← 跨模块提醒执行
```
## 修复步骤
1. **删除** `SettingsUserCache`,合并到 `UserProfileCacheRepository`
2. **抽取** `CachedRepository` 基类(见 `docs/todo/2026-03-27-repository缓存抽象.md`
3. **简化** `CalendarService`,移除不必要的懒加载
4. **统一命名**
- 带缓存的 Repository 统一继承基类
- Service 只做业务编排,不处理缓存
## 涉及文件
- `apps/lib/features/calendar/data/services/calendar_service.dart`
- `apps/lib/features/calendar/data/services/calendar_repository.dart`
- `apps/lib/features/settings/data/services/settings_user_cache.dart`
- `apps/lib/features/settings/data/services/user_profile_cache_repository.dart`
- `apps/lib/features/todo/data/todo_repository.dart`
## 状态
- [ ] 待修复
+34
View File
@@ -0,0 +1,34 @@
# 路由语义混乱:根路径 `/` 定义为登录页
## 问题描述
`app_routes.dart` 中根路径 `/` 被定义为登录页:
```dart
static const authBoot = '/boot';
static const authLogin = '/'; // 根路径是登录页
static const homeMain = '/home'; // 首页反而在 /home
```
这导致:
- `/` 应该指向首页的直觉 expectation 违反
- 根路径无法放置真实首页内容
-`homeMain = '/home'` 语义不一致
## 正确做法
根路径 `/` 应保留给首页,登录页应使用独立路径如 `/login`
```dart
static const authLogin = '/login';
static const homeMain = '/';
```
## 相关文件
- `apps/lib/app/router/app_routes.dart`
- `apps/lib/app/router/app_router.dart`
## 修复优先级
**低** - 功能正常,属于历史遗留设计问题
+106
View File
@@ -0,0 +1,106 @@
# 路由守卫逻辑分散
## 问题描述
当前路由守卫逻辑分散在两处,可能导致判断不一致:
1. `app_router.dart``redirect()` - 核心守卫逻辑
2. `LinksyApp``BlocListener` - 预留了位置但未使用
## 当前代码
```dart
// app_router.dart
GoRouter createAppRouter(AuthBloc authBloc) {
return GoRouter(
refreshListenable: GoRouterRefreshStream(authBloc.stream),
redirect: (context, state) {
final authState = authBloc.state;
final isAuthenticated = authState is AuthAuthenticated;
// ... 守卫判断逻辑
},
);
}
// app.dart (LinksyApp)
BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
// Handle auth state changes if needed ← 预留但未使用
},
)
```
## 问题
| 问题 | 说明 |
|------|------|
| 逻辑分散 | 守卫在 `redirect()`,但 BlocListener 预留了位置 |
| 隐患 | 将来可能有人在两处都加逻辑,导致不一致 |
| 职责不清 | 到底是 redirect 管跳转,还是 BlocListener 管跳转 |
## 建议方案
**方案1:路由守卫集中在 redirect()(当前方案,保持但清理)**
```dart
// app_router.dart
GoRouter createAppRouter() {
return GoRouter(
refreshListenable: GoRouterRefreshStream(sl<AuthBloc>().stream),
redirect: (context, state) {
// 唯一的守卫逻辑
},
);
}
// LinksyApp - 只做副作用,不做路由跳转
BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
// 埋点、Toast 等副作用
},
)
```
**方案2:路由守卫集中在 BlocListener**
```dart
// app_router.dart - 不再有 redirect
GoRouter createAppRouter() {
return GoRouter(
routes: [...],
);
}
// LinksyApp - 唯一的路由守卫入口
BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
final router = GoRouter.of(context);
if (state is AuthUnauthenticated) {
if (!isPublicRoute(router.matchedLocation)) {
router.go(AppRoutes.authLogin);
}
} else if (state is AuthAuthenticated) {
if (router.matchedLocation == AppRoutes.authLogin) {
router.go(AppRoutes.homeMain);
}
}
},
)
```
## 收益
| 收益 | 说明 |
|------|------|
| 单一职责 | 路由跳转只在一处判断 |
| 可维护 | 将来不会有人误在另一处加逻辑 |
| 清晰 | 开发者知道去哪改守卫逻辑 |
## 涉及文件
- `apps/lib/app/app.dart`
- `apps/lib/app/router/app_router.dart`
## 状态
- [ ] 待修复