# 架构债迁移设计方案（2026-06-29）

> 创建: 2026-06-29 | 状态: 设计草案
> 关联: [全量走查报告-2026-06-28](../05-代码审查/全量走查报告-2026-06-28.md) §架构缺陷 #4/#6/#8 + SQLite 单连接 #14
> 适用范围: 本次推进剩余项 #8/#15/#18/#19 的迁移路径设计

---

## 一、概览

走查报告识别出 4 项架构债，影响可维护性或扩展性。本设计为每项给出**目标架构 + 迁移路径 + 风险评估**，**不在本批次实施代码**（每项都需独立 PR + e2e 验证）。

| 编号 | 问题 | 风险 | 建议时机 |
|------|------|------|---------|
| #15 | AI 核心状态机焊死 src-tauri | 高（重构 agentic loop） | Phase 4 之前 |
| #8 | IPC 错误拍平为 String | 中（前后端协议改造） | 下个迭代 |
| #18 | 配置四源并存 | 中（跨 crate 改读） | 与 #15 协同 |
| #19 | SQLite 单连接 Mutex | 中（DB 层基础） | 性能瓶颈出现时 |

---

## 二、#15 — AI 核心状态机抽离

### 现状

```
src-tauri/src/commands/ai/
├── mod.rs         ← AiSession / PerConvState / AiChatEvent / SessionState（应属 df-ai）
├── agentic/mod.rs ← run_agentic_loop 主循环（应属 df-ai）
├── commands.rs    ← IPC 层（应留在 src-tauri）
├── tool_registry  ← 工具注册（IPC 层依赖，留在 src-tauri）
└── ...

crates/df-ai/src/
├── provider.rs        ← LlmProvider trait（正确）
├── openai_compat.rs   ← HTTP（正确）
├── anthropic_compat.rs← HTTP（正确）
├── coordinator.rs     ← 空壳（应承担 agentic loop）
└── ...
```

### 目标架构

```
crates/df-ai-session/    ← 新 crate（避免 df-ai 引 reqwest 同时引会话状态）
├── session.rs           ← AiSession / PerConvState / SessionState
├── agentic_loop.rs      ← run_agentic_loop（纯逻辑，依赖 LlmProvider trait）
├── events.rs            ← AiChatEvent enum
└── approvals.rs         ← PendingApproval / ApprovalKind

src-tauri/src/commands/ai/
├── mod.rs               ← 仅 IPC glue（invoke_handler 注册 + AppState 转发）
├── commands.rs          ← ai_send_message / ai_approve / ai_reject
└── tool_registry.rs     ← 工具注册（仍依赖 df-storage / df-types）
```

### 迁移路径（4 阶段）

**阶段 1**：创建 `df-ai-session` crate，仅迁移类型定义（`AiSession` / `AiChatEvent` 等），不改逻辑。src-tauri 改为 re-export。

**阶段 2**：迁移 `run_agentic_loop` 函数体。保持现有调用方接口不变（`pub async fn run_agentic_loop(...)`），仅改归属。

**阶段 3**：把 tool_registry 中的工具注册逻辑拆分为「工具定义」（可迁移）和「工具 handler」（依赖 IPC 层，保留）。

**阶段 4**：src-tauri 仅保留 IPC glue，所有 AI 状态机测试可在 df-ai-session 独立运行。

### 风险

- **循环依赖**：`agentic_loop` 需要 LlmProvider（df-ai-core）+ AiToolRegistry。AiToolRegistry 的 handler 依赖 src-tauri 的 storage/commands。**解决**：把 AiToolRegistry trait 下沉到 df-ai-core，handler 实现留在 src-tauri。
- **测试覆盖**：现有 AI 测试都在 src-tauri 集成测试，迁移后需补 df-ai-session 的单测。

---

## 三、#8 — IPC 错误结构化

### 现状

```rust
// src-tauri/src/commands/mod.rs:26
pub fn err_str<E: ToString>(e: E) -> String { e.to_string() }

// 所有 IPC 命令
async fn xxx() -> Result<T, String> { ... .map_err(err_str) }
```

df-types/error.rs 的 11 个变体（`NotFound`/`Validation`/`InvalidState { current, expected }`）在 IPC 边界全部 `.to_string()` 拍平。前端只能拿到字符串，无法分辨错误类型。

### 目标架构

```rust
// df-types/src/error.rs 新增 Serialize
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "kind", content = "data")]
pub enum IpcError {
    NotFound { entity: String, id: String },
    Validation { field: String, reason: String },
    InvalidState { entity: String, current: String, expected: Vec<String> },
    Unauthorized { reason: String },
    Internal { message: String },
}

// IPC 命令改为
async fn xxx() -> Result<T, IpcError> { ... }
```

Tauri invoke 自动 serialize 为 JSON：
```json
{ "kind": "invalid_state", "data": { "entity": "task", "current": "todo", "expected": ["in_progress"] } }
```

### 前端消费

```ts
// src/api/types.ts
type IpcError =
  | { kind: 'not_found'; data: { entity: string; id: string } }
  | { kind: 'validation'; data: { field: string; reason: string } }
  | { kind: 'invalid_state'; data: { entity: string; current: string; expected: string[] } }
  | { kind: 'unauthorized'; data: { reason: string } }
  | { kind: 'internal'; data: { message: string } }

// 使用
try { await invoke(...) }
catch (e: unknown) {
  const err = e as IpcError
  if (err.kind === 'not_found') router.push('/projects')
  else if (err.kind === 'invalid_state') toast.warning(`当前 ${err.data.current}，期望 ${err.data.expected.join(',')}`)
}
```

### 迁移路径

**阶段 1**：df-types 新增 `IpcError` enum + `From<df_types::error::Error> for IpcError`。

**阶段 2**：src-tauri IPC 命令逐个从 `Result<T, String>` 改为 `Result<T, IpcError>`。**保持向后兼容**：旧前端读 `err.message` 时降级为字符串（IpcError 实现 `Display` 输出 message）。

**阶段 3**：前端 api 层加 `IpcError` 类型 + 在关键 view（Projects/Tasks/Ideas）按 kind 分支处理。

**阶段 4**：删除 `err_str` 辅助函数。

### 风险

- **Tauri invoke 错误序列化**：Tauri 默认把 `Result::Err` 经 JSON 传前端，但前端 `invoke().catch()` 拿到的是 `string` 或 `object`？**需验证**：Tauri v2 对自定义 error 类型的序列化行为。
- **向后兼容**：旧前端代码 `e?.toString()` 需仍工作。IpcError 的 Display 实现保证字符串友好。

---

## 四、#18 — 配置四源统一

### 现状（散落 env::var 读取）

| 读取点 | 用途 | 性质 |
|--------|------|------|
| `df-relay/src/main.rs:17` `DF_RELAY_ADDR` | relay 监听地址 | 启动配置 |
| `df-relay/src/relay.rs:38` `DF_RELAY_TOKEN` | relay 鉴权 | 启动配置（panic 兜底） |
| `df-nodes/src/script_node.rs:141,155` `DF_SCRIPT_BLACKLIST`/`WHITELIST` | 脚本策略 | 运行时读取 |
| `df-nodes/src/ai_node.rs:389-398` `GLM_BASE_URL`/`GLM_API_KEY`/`GLM_MODEL` | AINode 兜底 provider | 运行时读取 |
| `src-tauri/src/commands/ai/tool_registry.rs:2780` `DEVFLOW_ENV_PROBE_ENABLED` | 环境探测开关 | 运行时读取 |
| `src-tauri/src/main.rs:116-121` `APPDATA`/`HOME` | OS 路径 | 系统变量 |
| `df-execute/src/env_snapshot.rs:216,253` `SHELL`/`LANG` | Shell 探测 | 系统变量 |

### 目标架构

**不强行统一所有读取**（OS 路径 / Shell 探测等系统变量性质不同），而是：

1. **df-types 新增 `AppConfig` struct**（集中定义项目级配置项）
2. **启动时一次性读取 env + DB KV**，构造 `AppConfig` 实例
3. **AppState 持有 `Arc<AppConfig>`**，各模块经 state 读取
4. **文档化配置矩阵**：env / DB KV / 默认值 / panic 的关系

```rust
// df-types/src/config.rs
pub struct AppConfig {
    pub relay: RelayConfig,
    pub script_policy: ScriptPolicyConfig,
    pub ai_node: AiNodeConfig,
    pub env_probe: bool,
}

pub struct RelayConfig {
    pub addr: String,         // 默认 "127.0.0.1:8080"
    pub token: String,        // 缺失 panic
}

pub struct ScriptPolicyConfig {
    pub blacklist: Vec<String>,  // 默认 ["rm","del","format","shutdown","mkfs","dd"]
    pub whitelist: Vec<String>,  // 默认 []
}

// src-tauri/src/state.rs
pub struct AppState {
    pub config: Arc<AppConfig>,
    // ...
}
```

### 迁移路径

**阶段 1**：df-types 定义 `AppConfig`，在 src-tauri 启动时构造。

**阶段 2**：逐个替换散落的 `std::env::var` 为 `state.config.xxx`。**保持 env var 作为 override**（向后兼容）。

**阶段 3**：前端 Settings 增加「环境变量覆盖」可视化。

### 风险

- **df-relay 独立 binary**：relay 是独立进程，不经 src-tauri AppState。**解决**：df-relay 自己构造 `RelayConfig`（共享 df-types::config 类型）。

---

## 五、#19 — SQLite 连接池

### 现状

```rust
// crates/df-storage/src/db.rs:13-16
pub struct Database {
    conn: Arc<Mutex<Connection>>,   // 单连接，所有读写串行
}
```

注释自承 `TODO: 考虑使用 r2d2 连接池替代单连接 Mutex`。AI 对话高峰（多 tool_calls 并发 + SSE 落库）下排队。

### 目标架构

```rust
pub struct Database {
    pool: r2d2::Pool<SqliteConnectionManager>,
}

impl Database {
    pub fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
        self.pool.get().map_err(...)
    }
}
```

### 迁移路径

**阶段 1**：df-storage 加 `r2d2` + `r2d2_sqlite` 依赖，改 `Database` 内部为 pool。

**阶段 2**：所有 Repo 的 `conn.blocking_lock()` 改为 `pool.get()?`（spawn_blocking 内获取连接）。

**阶段 3**：WAL 模式 + 合理 pool_size（桌面应用 4-8 即可）。

### 风险

- **WAL 已开**（`db.rs:22 PRAGMA journal_mode=WAL`），读写不互斥，pool 能真正并行。
- **事务原子性**：现有批量插入用 `unchecked_transaction()`，pool 模式下需确认 `PooledConnection` 的 transaction 行为一致。
- **测试改造**：所有 Repo 测试的 `Database::open_in_memory()` 需改为 pool 模式。

---

## 六、本批次已落地（参考）

| 编号 | 改动 | 文件 |
|------|------|------|
| #17 | TaskStatus 字符串常量从 enum 派生（`as_str` 改 const fn） | `crates/df-types/src/types.rs` + `crates/df-nodes/src/task_state_machine.rs` |
| #16 | df-project 真分层（加状态机 + 业务约束 + 测试） | `crates/df-project/src/manager.rs` + `src-tauri/src/commands/idea.rs` |
| #18 部分 | 文档化配置矩阵（本设计文档） | 本文件 |

## 七、推进顺序建议

1. **#8 IPC 错误结构化** — 风险中等，收益直接（前端可差异化处理）
2. **#15 AI 状态机抽离** — 风险高但收益最大（解锁 CLI/独立测试），建议在 Phase 4 之前
3. **#18 配置统一** — 与 #15 协同（新 crate 也需要读配置）
4. **#19 连接池** — 性能瓶颈出现时再改（当前单用户桌面应用未必触发）
