# 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 1：SQL 下推 + 精确查询（低风险，高 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-01（TaskRecord 新增 `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 后 project），IPC 开销为查 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` 只含项目 A），30s 内切到 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 视图未挂载（undefined），entity=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-1）onMounted 新增 `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-1：App.vue onMounted 成功预加载 projects，TaskDetail 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 内路由切换不重复 IPC（store 缓存命中）
11. `df-data-changed` 事件正确失效缓存并刷新
12. Dashboard 统计数据准确（Tasks 视图筛选某项目后切 Dashboard，stats 不偏小）
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 拼接 |

---

## 三、决策记录

### D1：Phase 1 先行，Phase 2 视需

**理由**：Phase 1 是纯内部优化（SQL 下推 + 精确拉取），不改 API 契约，风险极低，ROI 明确。Phase 2 引入缓存层有一致性风险，待 Phase 1 验证后再评估必要性。

### D2：不实施 Phase 3（字段投影）

**理由**：当前数据量极小（< 10KB 全量传输），字段投影需新增 DTO 类型 + 前端类型同步 + 视图适配，成本远高于收益。当任务数达到 ~200+ 或 description 平均长度 > 5KB 时再评估。

### D3：ProjectDetail 用本地 ref 而非改 store 契约

**理由**：store.tasks 全局单例 + B-260615-29 筛选契约是硬约束（C1）。改 store 支持"多任务列表"会引入复杂度（哪个视图的数据？切换时怎么办？）。ProjectDetail 用本地 ref + 精确拉取更简单清晰，且 AR-11 可通过本地监听补偿。
