文档: 架构方案文档(意图识别论证+多主题愿景/论证+文档物理分类+边界清晰化)

squash合并:
- 意图识别层论证(8维度+10业界佐证)
- 多主题上下文管理愿景+并存论证+补充论证(多轮agentic)
- 架构设计文档物理分类(四子目录+INDEX+命名规范+引用同步+边界清晰化)
- 前端架构技术债清单归档
This commit is contained in:
2026-06-19 15:04:04 +08:00
parent f1a06732fd
commit 998a2f243d
73 changed files with 1083 additions and 80 deletions

View File

@@ -0,0 +1,453 @@
# PERF-260619-01 查询效率优化方案
> **状态**:方案设计完成,待评审
> **日期**2026-06-19
> **范围**DevFlow 前后端数据查询链路
---
## 一、探索分析
### 1.1 问题全景
当前 DevFlow 的数据查询链路存在 **6 个核心问题**,分布在后端 SQL 层、前端 store 层、前端视图层三个层面。
### 1.2 问题清单
| # | 层 | 问题 | 严重度 | 代码位置 |
|---|---|---|---|---|
| P1 | 后端 SQL | `list_tasks` 全量查询后内存 `retain` 过滤 project_id | 中 | `commands/task.rs:32-37` |
| P2 | 前端视图 | `ProjectDetail.vue` 全量 `loadTasks()` 后 computed 过滤 | 中 | `ProjectDetail.vue onMounted` |
| P3 | 前端视图 | `TaskDetail.vue` 独立 `projectApi.list()` 拉全量项目仅解析单个项目名 | 低 | `TaskDetail.vue load()` |
| P4 | 前端视图 | `Dashboard.vue` 每次进入全量加载 projects+tasks+ideas | 低 | `Dashboard.vue loadAll()` |
| P5 | 前端 store | 无跨视图缓存/去重,路由切换重复 IPC | 中 | `stores/project.ts` 全局 |
| P6 | 后端 SQL | 无字段投影,列表场景返回完整 Record含 description 大字段) | 低 | CRUD 层全局 |
### 1.3 问题详解
#### P1`list_tasks` 内存过滤(中)
**现状**
```rust
// commands/task.rs:32-37
pub async fn list_tasks(state, project_id: Option<String>) -> Result<Vec<TaskRecord>, String> {
let mut tasks = state.tasks.list_active().await.map_err(err_str)?; // SELECT * FROM tasks WHERE deleted_at IS NULL
if let Some(pid) = project_id {
tasks.retain(|t| t.project_id == pid); // 内存过滤
}
Ok(tasks)
}
```
**影响**:每次按项目筛选任务时,从 SQLite 拉取**全部未删除任务**(含 14 个字段含 description然后在 Rust 端 `retain` 过滤。当前 6 个项目 ~15 个任务时影响极小,但随任务增长线性退化。
**注释中的判断**task.rs:28*"任务量小,无需 SQL 下推,避免新增专用查询方法"* — 当前成立,但需提前规划。
#### P2`ProjectDetail.vue` 全量 loadTasks
**现状**
```typescript
// ProjectDetail.vue onMounted
await store.loadProjects()
await store.loadTasks() // 无参 → 全量拉取所有项目的任务
// 然后 computed 前端过滤
const projectTasks = computed(() =>
store.tasks.filter(t => t.project_id === projectId.value)
)
```
**影响**:进入项目详情页时拉取**所有项目的全部任务**,仅为展示当前项目的任务。这与 `Tasks.vue` 的按项目筛选加载模式B-260615-29 契约)不一致。
**根因**`ProjectDetail.vue` 使用全局 `store.tasks` 单例 + computed 过滤,而非按 project_id 精确拉取。设计意图是"不覆盖全局 tasks 单例"(避免破坏 Tasks 视图的筛选状态),但代价是全量拉取。
#### P3`TaskDetail.vue` 独立拉全量项目列表(低)
**现状**
```typescript
// TaskDetail.vue load()
const [t, ps] = await Promise.all([
taskApi.get(taskId.value), // 拉单个任务 ✓
projectApi.list(), // 拉全部项目 ✗ 仅为解析 project_id → name
])
```
**影响**TaskDetail 绕过 store 直接调 `projectApi.list()`,拉取全部项目(含 description 大字段),仅为从 `projects.find(p => p.id === task.project_id)` 解析一个项目名。
**根因**TaskDetail 设计为"绕 store 独立入口"(有本地 `df-data-changed` 监听补偿),不依赖全局 store 的 projects 数组。
#### P4`Dashboard.vue` 全量三实体加载(低)
**现状**
```typescript
// Dashboard.vue loadAll()
await Promise.all([store.loadProjects(), store.loadTasks(), store.loadIdeas()])
```
**影响**:每次进入 Dashboard 全量拉取三个实体。Dashboard 只需要:
- 项目数 + 活跃任务数(统计)
- 项目列表(名称 + 状态 + updated_at
- 灵感列表前 4 条(标题 + score + status
但拉取了完整 Record含 description / ai_analysis / scores 等大字段)。
**缓解因素**store 是全局单例如果用户先访问过其他页面Tasks/Projects数据已在 store 中。但 Dashboard 的 `loadAll` 无条件覆盖刷新。
#### P5无跨视图缓存/去重(中)
**现状**
- `Tasks.vue onMounted``loadProjects() + loadTasks()`
- `ProjectDetail.vue onMounted``loadProjects() + loadTasks()`
- `Dashboard.vue onMounted``loadProjects() + loadTasks() + loadIdeas()`
- `TaskDetail.vue load()``projectApi.list()`(绕 store
**影响**:路由切换时(如 Tasks → ProjectDetail → Tasks每次 onMounted 都重新发起 IPC 全量拉取。store 虽然是单例(数据在内存),但 `loadXxx` 方法无条件覆盖刷新,没有"数据未过期则跳过"的判断。
**根因**store 的 load 方法无 dirty/timestamp 标记,无法判断"已有数据是否新鲜"。
#### P6无字段投影
**现状**:所有查询返回完整 Record。
- `ProjectRecord`9 字段(含 description 可能很长)
- `TaskRecord`14 字段(含 description + output_json 可能很长)
- `IdeaRecord`13 字段(含 ai_analysis + scores + description
列表场景Tasks/Dashboard/Projects只需要 id/name/status/priority 等少量字段,详情页才需要全字段。
**影响**IPC 传输 + JSON 序列化开销。当前数据量小,影响不明显。
### 1.4 关键约束
| # | 约束 | 来源 | 影响 |
|---|---|---|---|
| C1 | `store.tasks` 必须反映 Tasks 视图当前筛选activeProject不能被无参 `loadTasks()` 覆盖为全量 | B-260615-29 契约 | P2 修复不能简单改为 `loadTasks(projectId)`,因为会覆盖全局 tasks 单例 |
| C2 | 后端 AI 工具执行后 emit `df-data-changed`,前端按 entity 刷新对应列表 | AR-11 事件机制 | 优化时需保留联动;缓存策略需响应此事件失效 |
| C3 | TaskDetail 绕 store 直接调 API有本地 `df-data-changed` 监听补偿 | 设计决策 | P3 修复需考虑是否回归 store 或保持独立 |
| C4 | 任务 status 只能走 `advance_task_atomic` 状态机 | 状态机收口 | 不影响查询优化,但 update_task 白名单已移除 status |
| C5 | SQLite 本地存储,当前数据量小(~6 项目 ~15 任务) | 实际数据 | 短期性能影响极小,优化面向中长期数据增长 |
| C6 | CRUD 层(`impl_repo!` 宏)无 SELECT 列裁剪、无 LIMIT/OFFSET 支持 | 架构现状 | P6 需扩展宏或新增专用查询方法 |
---
## 二、方案设计
### 2.1 设计原则
1. **渐进式**:分阶段实施,每阶段独立可交付、可验证
2. **低风险**:优先做不改 API 契约的内部优化,外部行为不变
3. **数据量驱动**:当前数据量小,不做过度设计;预留扩展点但不提前实现
4. **不破坏 AR-11**:所有缓存/优化方案必须保留 `df-data-changed` 事件联动
### 2.2 分阶段实施计划
---
### Phase 1SQL 下推 + 精确查询(低风险,高 ROI
**目标**:消除后端全量查询 + 内存过滤,前端视图精确拉取所需数据。
**改动点**
#### 1.1 TaskRepo 新增 `list_active_by_project`(后端)
```rust
// crates/df-storage/src/crud/task_repo.rs
impl TaskRepo {
/// 列出指定项目的未删除任务
pub async fn list_active_by_project(&self, project_id: &str) -> Result<Vec<TaskRecord>> {
let conn = self.conn.clone();
let project_id = project_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND project_id = ?1 ORDER BY created_at DESC")
.map_err(storage_err)?;
let rows = stmt.query_map(params![project_id], |row| task_from_row(row)).map_err(storage_err)?;
// ...collect
}).await.map_err(storage_err)?
}
}
```
⚠️ **依赖注意F-260619-01 idea_id**:上方 SQL 硬编码 14 列,与 `list_active()` 完全重复。若 F-260619-01TaskRecord 新增 `idea_id` 字段)先于本方案落地,`task_from_row` 会调 `row.get("idea_id")`,但 SQL 列表无此列 → rusqlite 报错。两案并行时须同步加列,或改用 `SELECT * FROM tasks WHERE ...`rusqlite 允许结果集含 from_row 未消费的多余列)。
#### 1.2 `list_tasks` 命令层路由(后端)
```rust
// commands/task.rs
pub async fn list_tasks(state, project_id: Option<String>) -> Result<Vec<TaskRecord>, String> {
match project_id {
Some(pid) => state.tasks.list_active_by_project(&pid).await.map_err(err_str),
None => state.tasks.list_active().await.map_err(err_str),
}
}
```
**外部行为不变**API 签名 + 返回类型一致,前端零改动。
#### 1.3 `ProjectDetail.vue` 按项目精确拉取(前端)
**问题**:不能直接 `store.loadTasks(projectId)`,因为会覆盖全局 tasks 单例(违反 C1
**方案**ProjectDetail 不用全局 `store.tasks`,改为本地 ref + 精确拉取:
```typescript
// ProjectDetail.vue
const localTasks = ref<TaskRecord[]>([])
onMounted(async () => {
await store.loadProjects()
localTasks.value = await taskApi.list(projectId.value) // 精确拉取
// ...
})
```
**收益**:从全量拉取 → 仅拉取当前项目任务。
⚠️ **必须配套(评审升级)**ProjectDetail 当前**没有** `df-data-changed` 监听——它靠全局 `store.tasks`App.vue 的 `startDataChangedListener` 刷新 store → computed 自动响应)间接获得 AR-11 联动。改为本地 ref 后这条链路**断裂**。必须在 onMounted 中新增本地 `df-data-changed` 监听:
```typescript
// ProjectDetail.vue onMounted 新增(必须,非可选)
let _unlistenDataChanged: (() => void) | null = null
onMounted(async () => {
await store.loadProjects()
localTasks.value = await taskApi.list(projectId.value)
// AR-11 本地监听entity=task 时按当前 projectId 精确刷新
_unlistenDataChanged = await listen<DfDataChangedPayload>('df-data-changed', (event) => {
if (event.payload.entity === 'task') {
taskApi.list(projectId.value).then(ts => { localTasks.value = ts })
}
})
})
onUnmounted(() => {
_unlistenDataChanged?.()
// ...existing unlisten cleanup
})
```
> **注意**ProjectDetail 现有的 `store.startEventListener()` 是**工作流节点事件**监听(`workflowStore.startEventListener`),不是数据变更监听,两者不可混淆。
#### 1.4 `TaskDetail.vue` 改用 store 或 get_project前端
**方案 A**:改用 `store.projects` 复用全局缓存,不再独立 `projectApi.list()`
```typescript
// TaskDetail.vue
const store = useProjectStore()
const projectName = computed(() =>
store.projects.find(p => p.id === task.value?.project_id)?.name ?? task.value?.project_id ?? '—'
)
// load() 只拉单个任务,不再拉全量项目
async function load() {
task.value = await taskApi.get(taskId.value)
}
```
⚠️ **前置条件(评审纠错)**:原方案声称"App.vue 根 onMounted 已有 projects 预加载",经核验**不成立**——App.vue onMounted 只做 appSettings/migrate/theme/locale/Agentic 轮次恢复/AR-11 listener 启动,**未调用 `store.loadProjects()`**。当前 projects 加载分散在各视图 onMounted 中Tasks/ProjectDetail/Dashboard 各自调)。
采用方案 A 必须二选一补齐前置:
- **A-1推荐**App.vue onMounted 加 `await store.loadProjects()`(一行改动,全局受益,所有视图共享缓存)。
- **A-2保守**TaskDetail load() 内加 `if (store.projects.length === 0) await store.loadProjects()` lazy 补偿,不依赖全局预加载。
**方案 B**:两步查询单个项目,替代全量 `projectApi.list()`
```typescript
async function load() {
task.value = await taskApi.get(taskId.value)
// 拿到 task 后按 project_id 查单个项目
if (task.value?.project_id) {
const p = await projectApi.get(task.value.project_id)
projectName.value = p?.name ?? task.value.project_id
}
}
```
方案 B 无先后依赖问题(串行两步,先 task 后 projectIPC 开销为查 1 条 vs 查全量。**不依赖全局 store 状态**,保持 TaskDetail"绕 store 独立入口"的设计一致性。
**推荐**
- 若同时实施 Phase 2 缓存 → **方案 A-1**App.vue 预加载 + store 缓存,全链路最优)
- 若只实施 Phase 1 → **方案 B**(零全局依赖,改动最小,不引入 store 耦合)
---
### Phase 2前端缓存 + 去重(中等风险,中 ROI
**目标**:避免路由切换时的重复 IPC引入脏标记/时间戳缓存。
**改动点**
#### 2.1 store 加载方法增加新鲜度判断
⚠️ **评审补充 — tasks 缓存筛选维度冲突**`loadTasks(projectId?)` 带筛选参数。若 Tasks 视图选了项目 A`store.tasks` 只含项目 A30s 内切到 Dashboard 调 `loadTasks()` 无参全量——缓存命中跳过,但 `store.tasks` 仍是项目 A 子集Dashboard 的 `stats.activeTasks` 统计错误。
**解决方案**tasks 缓存须记录筛选维度,筛选变化时强制刷新:
```typescript
// stores/project/state.ts 新增
export const state = reactive({
// ...existing...
_projectsLoadedAt: 0,
_tasksLoadedAt: 0,
_ideasLoadedAt: 0,
_tasksFilter: undefined as string | undefined, // 当前 tasks 缓存对应的筛选(undefined=全量)
})
// stores/project/projects.ts
const STALE_MS = 30_000 // 30s 内不重复拉取
async function loadProjects(force = false) {
if (!force && Date.now() - state._projectsLoadedAt < STALE_MS && state.projects.length > 0) {
return // 数据新鲜,跳过
}
state.loading = true
state.projects = await projectApi.list()
state._projectsLoadedAt = Date.now()
}
// stores/project/tasks.ts — 筛选维度 + TTL 双判断
async function loadTasks(projectId?: string, force = false) {
const filter = projectId // undefined=全量, string=按项目
// 筛选变化 → 强制刷新(防 Dashboard 拿到 Tasks 视图的子集)
const filterChanged = state._tasksFilter !== filter
if (!force && !filterChanged && Date.now() - state._tasksLoadedAt < STALE_MS) {
return // 数据新鲜 + 筛选一致,跳过
}
state.loading = true
state.tasks = await taskApi.list(filter)
state._tasksLoadedAt = Date.now()
state._tasksFilter = filter
}
```
#### 2.2 AR-11 事件联动失效缓存
⚠️ **评审补充 — AR-11 条件刷新与缓存交互**:当前 AR-11 listener 的 task 分支有 `_activeTaskProject !== undefined` 守卫CR-260615-26。若 Tasks 视图未挂载undefinedentity=task 时不触发 load缓存也不失效。用户之后进 Tasks 时 onMounted 调 `loadTasks`,若缓存未过期则命中跳过——拿到过期数据。
**解决方案**AR-11 失效缓存与条件刷新分离——缓存时间戳无条件清零(下次 load 必拉新load 调用仍守卫:
```typescript
// stores/project.ts startDataChangedListener
if (entity === 'project') {
state._projectsLoadedAt = 0 // 无条件失效缓存
void projectsStore.loadProjects(true) // force 刷新
} else if (entity === 'task') {
state._tasksLoadedAt = 0 // 无条件失效缓存(下次任意视图 loadTasks 必拉新)
if (_activeTaskProject !== undefined) {
void tasksStore.loadTasks(_activeTaskProject === 'all' ? undefined : _activeTaskProject)
}
}
```
#### 2.3 Dashboard loadAll 利用缓存
⚠️ **评审补充 — Dashboard stats 对 tasks 全量的依赖**Dashboard 的 `stats.activeTasks``project.ts:105`)从 `state.tasks``filter(t => t.status === 'in_progress').length`。若 `state.tasks` 是某项目子集Tasks 视图留下的统计偏小。Dashboard loadAll 须确保 tasks 全量:
```typescript
// Dashboard.vue
async function loadAll() {
await Promise.all([
store.loadProjects(), // 命中缓存则跳过
store.loadTasks(), // 无参=全量;筛选维度变化时强制刷新(2.1 逻辑保证)
store.loadIdeas(),
])
}
```
> Dashboard 调 `loadTasks()` 无参filter=undefined若 Tasks 视图留下的 filter 是某项目 id`filterChanged=true` → 强制全量刷新stats 正确。
**风险**
- `df-data-changed` 必须可靠失效缓存(当前 AR-11 已覆盖 create/update/delete/restore/purge/bind_directory
- 用户在外部修改 SQLite如直接操作数据库后缓存不新鲜 — 桌面应用场景概率极低
---
### Phase 3字段投影低优先级远期
**目标**:列表场景只返回需要的字段,减少 IPC 传输 + 序列化开销。
**现状评估**当前数据量极小6 项目 15 任务description 字段平均 ~500 字节,全量传输 < 10KB。**Phase 3 在数据量达到百级之前不需要实施。**
**预留方案**(不实施,仅记录):
```rust
// 方向impl_repo! 宏扩展 columns 参数,或新增 list_summary 方法
pub async fn list_active_summary(&self) -> Result<Vec<ProjectSummary>> {
// SELECT id, name, status, created_at, updated_at FROM projects WHERE ...
}
```
前端新增 `ProjectSummary` / `TaskSummary` 类型,列表视图用 Summary详情页用完整 Record。
**成本**:需新增 DTO 类型 + 前端类型同步 + 视图适配,复杂度较高。
---
### 2.3 优先级矩阵
| Phase | 问题覆盖 | 风险 | ROI | 建议时机 |
|-------|---------|------|-----|---------|
| Phase 1 | P1 + P2 + P3 | 低(不改 API 契约) | 高 | **立即实施** |
| Phase 2 | P4 + P5 | 中(缓存一致性) | 中 | Phase 1 验证后 |
| Phase 3 | P6 | 低(纯新增) | 低(当前数据量) | 数据量达百级时 |
### 2.4 涉及文件
| Phase | 文件 | 改动类型 |
|-------|------|---------|
| 1.1 | `crates/df-storage/src/crud/task_repo.rs` | 新增 `list_active_by_project` 方法 |
| 1.2 | `src-tauri/src/commands/task.rs` | `list_tasks` 路由分支 |
| 1.3 | `src/views/ProjectDetail.vue` | 改用本地 ref + 精确拉取 + **新增 df-data-changed 本地监听** |
| 1.4 | `src/views/TaskDetail.vue` | 方案 B改用 `projectApi.get(id)` 单查;或方案 A-1/A-2 复用 store.projects |
| 1.4-A1 | `src/App.vue` | (方案 A-1onMounted 新增 `store.loadProjects()` 预加载 |
| 2.1 | `src/stores/project/state.ts` | 新增时间戳 + `_tasksFilter` 字段 |
| 2.2 | `src/stores/project/projects.ts` | loadProjects 加新鲜度判断 |
| 2.3 | `src/stores/project/tasks.ts` | loadTasks 加筛选维度 + TTL 双判断 |
| 2.4 | `src/stores/project.ts` | AR-11 listener 无条件失效缓存 + 条件刷新分离 |
| 2.5 | `src/views/Dashboard.vue` | loadAll 利用缓存(筛选维度保证全量 tasks |
### 2.5 验收标准
#### Phase 1
1. `list_tasks(Some(pid))` 走 SQL WHERE 下推,不再内存过滤
2. `ProjectDetail.vue` 不再全量 loadTasks改为按 projectId 精确拉取
3. `ProjectDetail.vue` 新增本地 `df-data-changed` 监听entity=task 时刷新 localTasks
4. `TaskDetail.vue` 不再独立 `projectApi.list()`(改用方案 B 单查或方案 A 复用 store
5. B-260615-29 契约不破坏store.tasks 仍反映 Tasks 视图筛选)
6. AR-11 事件联动正常create/update/delete 后 ProjectDetail + Tasks 列表均刷新)
7. 若选方案 A-1App.vue onMounted 成功预加载 projectsTaskDetail projectName 正确解析
8. 若 F-260619-01 已先行:`list_active_by_project` SQL 含 idea_id 列(或用 SELECT *
9. `cargo check --workspace` EXIT 0 + `vue-tsc` EXIT 0
#### Phase 2
10. 30s 内路由切换不重复 IPCstore 缓存命中)
11. `df-data-changed` 事件正确失效缓存并刷新
12. Dashboard 统计数据准确Tasks 视图筛选某项目后切 Dashboardstats 不偏小)
13. Tasks 视图未挂载时 AR-11 entity=task 事件仍清零缓存(下次进 Tasks 拿新数据)
### 2.6 风险评估
| 风险 | 概率 | 影响 | 缓解措施 |
|------|------|------|---------|
| ProjectDetail 改本地 ref 后 AR-11 不刷新任务 | **高** | 中 | **必须配套**onMounted 新增本地 `df-data-changed` 监听entity=task 时重新 `taskApi.list(projectId)`(详见 1.3 |
| TaskDetail 改用 store.projects 时 projects 未加载 | 中 | 低 | App.vue onMounted **新增** `store.loadProjects()`A-1或 TaskDetail 内 lazy 补偿A-2或改用方案 B 完全规避 |
| Phase 2 缓存筛选维度冲突致 Dashboard stats 错误 | 中 | 中 | tasks 缓存记录 `_tasksFilter`,筛选变化时 `filterChanged=true` 强制刷新(详见 2.1 |
| Phase 2 AR-11 守卫 + 缓存致 Tasks 拿过期数据 | 中 | 低 | AR-11 无条件清零 `_tasksLoadedAt`(下次 load 必拉新load 调用仍守卫(详见 2.2 |
| Phase 2 缓存导致用户看到过期数据 | 低 | 低 | 30s TTL 短 + AR-11 强制失效 + 手动刷新按钮兜底 |
| list_active_by_project SQL 列与 idea_id 不同步 | 中 | 中 | 两案并行时同步加列,或改用 `SELECT *`(详见 1.1 |
| list_active_by_project SQL 注入 | 极低 | 高 | 使用参数化查询 `params![project_id]`,不走 `query` 宏的 field 拼接 |
---
## 三、决策记录
### D1Phase 1 先行Phase 2 视需
**理由**Phase 1 是纯内部优化SQL 下推 + 精确拉取),不改 API 契约风险极低ROI 明确。Phase 2 引入缓存层有一致性风险,待 Phase 1 验证后再评估必要性。
### D2不实施 Phase 3字段投影
**理由**:当前数据量极小(< 10KB 全量传输),字段投影需新增 DTO 类型 + 前端类型同步 + 视图适配,成本远高于收益。当任务数达到 ~200+ 或 description 平均长度 > 5KB 时再评估。
### D3ProjectDetail 用本地 ref 而非改 store 契约
**理由**store.tasks 全局单例 + B-260615-29 筛选契约是硬约束C1。改 store 支持"多任务列表"会引入复杂度哪个视图的数据切换时怎么办。ProjectDetail 用本地 ref + 精确拉取更简单清晰,且 AR-11 可通过本地监听补偿。