# `/settings` 命令全链路审核报告

**审核方**：门下省  
**日期**：2026-03-20  
**审核范围**：`/settings`、`/menu` 命令，`SettingsService`，`SettingsCapability`/`UIBuilder` 接口，`FeishuChannel` 实现  
**构建验证**：`go build ./...` ✅ | `go vet ./...` ✅ | `go test ./agent/ ./channel/` ✅

---

## 一、问题汇总

| # | 严重程度 | 文件 | 问题描述 |
|---|---------|------|---------|
| 1 | 🔴 P0 | command_builtin.go | `channelFinder` 为 nil 时 `/settings` panic |
| 2 | 🔴 P0 | command_builtin.go | `/settings set` 无 schema 校验，任意 key/value 均可写入 |
| 3 | 🟡 P1 | command_builtin.go | `settingsCmd.Concurrent()` 对写操作返回 true，违反项目惯例 |
| 4 | 🟡 P1 | command_builtin.go | `/settings set` 命令 value 被全部 lowercased，破坏大小写敏感值 |
| 5 | 🟡 P1 | command_builtin.go | help 文本未包含 `/settings`、`/menu`、`/browse` 等新命令 |
| 6 | 🟡 P1 | settings.go | `SubmitSettings()` 方法已定义但无调用方，死代码 |
| 7 | 🟢 P2 | settings.go | `agent` 包直接 import `xbot/channel`，违背 channelFinder 依赖反转设计 |
| 8 | 🟢 P2 | feishu.go | `BuildSettingsUI()` 与 `BuildTextSettingsUI()` 大量逻辑重复 |
| 9 | 🟢 P2 | feishu.go | `HandleSettingSubmit` 不校验 value 是否合法（select 类型范围、toggle 布尔值） |
| 10 | 🟢 P2 | settings.go | `GetSettingsUI` 返回 "当前渠道没有可配置的设置项" 与 "当前渠道不支持设置" 语义模糊 |

---

## 二、逐项详细审核

### P0-1: `channelFinder` 为 nil 时 `/settings` panic

**文件**：`agent/command_builtin.go` L436-439

**现状**：

```go
func (c *settingsCmd) Execute(ctx context.Context, a *Agent, msg bus.InboundMessage) (*bus.OutboundMessage, error) {
    // ...
    ch, ok := a.channelFinder(msg.Channel)  // ← 若 channelFinder == nil → panic
    if !ok {
        return &bus.OutboundMessage{...Content: "当前渠道不支持设置"}, nil
    }
```

`Agent.channelFinder` 是 `func(string) (channel.Channel, bool)` 类型，默认值为 nil。虽然 `main.go` 中无条件调用了 `SetChannelFinder(disp.GetChannel)`，但：
- 测试环境若不设 `channelFinder`，任何 `/settings` 触发即 panic
- 未来若有代码路径创建 Agent 但未注入 finder，同样 panic

**修复建议**：

```go
if a.channelFinder == nil {
    return &bus.OutboundMessage{Channel: msg.Channel, ChatID: msg.ChatID,
        Content: "SettingsService: channelFinder 未注入"}, nil
}
```

**影响**：运行时 panic，服务不可用。

---

### P0-2: `/settings set` 无 schema 校验

**文件**：`agent/command_builtin.go` L427-435

**现状**：

```go
if strings.HasPrefix(args, "set ") {
    setParts := strings.Fields(strings.TrimPrefix(args, "set "))
    if len(setParts) < 2 {
        return ... "用法：...", nil
    }
    key := setParts[0]
    value := strings.Join(setParts[1:], " ")
    err := a.settingsSvc.SetSetting(msg.Channel, msg.SenderID, key, value)
```

`SetSetting` 直接写入数据库，不检查：
1. key 是否在 `SettingsSchema()` 中定义
2. value 是否符合 schema 约束（select 类型只能从 Options 中选，toggle 只能 true/false）
3. value 类型是否匹配 SettingType（number 类型应验证数字）

用户可执行 `/settings set nonexistent_key anything`，数据被写入但永远不生效。

**修复建议**：

`/settings set` 应先获取 channel 的 schema，校验 key 存在性和 value 合法性：

```go
if strings.HasPrefix(args, "set ") {
    setParts := strings.Fields(strings.TrimPrefix(args, "set "))
    if len(setParts) < 2 {
        return ... "用法：...", nil
    }
    key := setParts[0]
    value := strings.Join(setParts[1:], " ")

    // Schema 校验
    ch, ok := a.channelFinder(msg.Channel)
    if !ok {
        return ... "当前渠道不支持设置", nil
    }
    if err := a.settingsSvc.ValidateSetting(ch, key, value); err != nil {
        return ... fmt.Sprintf("设置无效：%v", err), nil
    }
    err := a.settingsSvc.SetSetting(msg.Channel, msg.SenderID, key, value)
```

在 `SettingsService` 中新增 `ValidateSetting`：

```go
func (s *SettingsService) ValidateSetting(ch channel.Channel, key, value string) error {
    sc, ok := ch.(channel.SettingsCapability)
    if !ok {
        return fmt.Errorf("当前渠道不支持设置")
    }
    for _, def := range sc.SettingsSchema() {
        if def.Key != key { continue }
        switch def.Type {
        case channel.SettingTypeSelect:
            for _, opt := range def.Options {
                if opt.Value == value { return nil }
            }
            return fmt.Errorf("无效值 %q，可选: %v", value, def.Options)
        case channel.SettingTypeToggle:
            if value != "true" && value != "false" {
                return fmt.Errorf("toggle 类型只接受 true 或 false")
            }
        case channel.SettingTypeNumber:
            if _, err := strconv.ParseFloat(value, 64); err != nil {
                return fmt.Errorf("number 类型需要数字值")
            }
        }
        return nil
    }
    return fmt.Errorf("未知设置项: %s", key)
}
```

**影响**：数据污染，用户困惑。

---

### P1-3: `Concurrent()` 标记不当

**文件**：`agent/command_builtin.go` L415

**现状**：

```go
func (c *settingsCmd) Concurrent() bool { return true }
```

项目中 `/settings set` 会执行数据库写入（`SetSetting`）。对比项目中其他写命令：

| 命令 | 操作 | Concurrent() |
|------|------|-------------|
| `/new` | 清空 session | false |
| `/set-llm` | 写 LLM 配置 | false |
| `/compress` | 压缩 session | false |
| `/install` | 安装 skill/agent | false |
| `/settings set` | 写用户设置 | **true** ← 不一致 |

**修复建议**：拆分为两个命令，或将整个 `settingsCmd` 改为 `Concurrent() = false`。

推荐拆分：
- `settingsListCmd` → `Concurrent() = true`（只读）
- `settingsSetCmd` → `Concurrent() = false`（写操作）

**影响**：并发下可能产生不可预期的写入顺序，虽然 SQLite 序列化写入不会数据损坏，但与项目惯例不一致。

---

### P1-4: value 被 lowercased

**文件**：`agent/command_builtin.go` L424-425

**现状**：

```go
args := strings.TrimPrefix(strings.ToLower(content), "/settings ")
```

`content` 被 `ToLower()` 后，`/settings set language English` 的 value 变成 `"english"`。

当前 schema 中的 option value 全部是小写（`"zh"`, `"en"`, `"ja"`, `"concise"`, `"detailed"`），所以暂时无影响。但一旦有大小写敏感的设置项（如用户自定义的名称、API endpoint），此行为会导致数据错误。

**修复建议**：

```go
content := strings.TrimSpace(msg.Content)
// 只对命令部分做大小写不敏感匹配，保留原始 value
lower := strings.ToLower(content)
args := strings.TrimPrefix(strings.TrimSpace(strings.TrimPrefix(lower, "/settings ")), "")
```

但更简洁的做法是在解析 key 时 lowercased，value 保留原文：

```go
if strings.HasPrefix(args, "set ") {
    rest := strings.TrimPrefix(content, "/settings ")  // 原始 content
    rest = strings.TrimSpace(rest)
    rest = strings.TrimPrefix(rest, "set ")  // 或 "SET "
    setParts := strings.Fields(rest)
    key := strings.ToLower(setParts[0])       // key 小写匹配
    value := strings.Join(setParts[1:], " ")  // value 保留原文
```

**影响**：当前 schema 无大小写敏感值，暂无实际影响，但属于定时炸弹。

---

### P1-5: `/help` 文本缺少新命令

**文件**：`agent/command_builtin.go` L55-68

**现状**：`/help` 输出中无以下命令：

- `/settings` — 个人设置
- `/menu` — 主菜单
- `/browse` — 浏览市场
- `/publish` — 发布
- `/install` — 安装
- `/uninstall` — 卸载
- `/my` — 我的 skills/agents

**修复建议**：在 help 文本中追加这些命令说明。

---

### P1-6: `SubmitSettings()` 死代码

**文件**：`agent/settings.go` L60-96

`SubmitSettings` 方法实现了完整的 schema-aware 提交流程（委托 `SettingsCapability.HandleSettingSubmit` 或解析 `key=value` 文本），但没有任何命令或代码路径调用它。

`/settings set` 命令绕过了 `SubmitSettings`，直接调用 `SetSetting`。

**修复建议**：

方案 A（推荐）：`/settings set` 命令改为调用 `SubmitSettings`，并补充 schema 校验逻辑到 `SubmitSettings` 内部。

方案 B：删除 `SubmitSettings`，将其校验逻辑合并到 `SetSetting` 或新建的 `ValidateSetting` 中。

---

### P2-7: `agent` 包直接 import `xbot/channel`

**文件**：`agent/settings.go` L5

**现状**：

```go
import (
    "xbot/channel"
    "xbot/storage/sqlite"
)
```

`GetSettingsUI` 方法接收 `channel.Channel` 接口并对其做 type assertion。项目在 `main.go` 中刻意使用 `channelFinder` 回调避免 `agent` → `channel` 的直接依赖。`settings.go` 打破了这个隔离层。

**风险**：如果未来 `channel` 包需要引用 `agent` 包的任何类型，将形成循环依赖。

**修复建议**：

将 `channel.Channel` 参数改为使用 `channelFinder` 回调获取的 `channel.Channel`，但保持接口在 `agent` 包内定义（`SettingsUIBuilder` 接口），由 `main.go` 的适配器桥接。或者接受当前状态但加注释标注风险。

当前项目结构下循环依赖概率低，标记为 P2。

---

### P2-8: `BuildSettingsUI` 逻辑重复

**文件**：`channel/feishu.go` L858-908 vs `channel/capability.go` L49-84

两个函数实现几乎相同的逻辑（按 category 分组、渲染 label/value/options），仅格式细节不同：

| 特性 | `BuildTextSettingsUI` | `FeishuChannel.BuildSettingsUI` |
|------|----------------------|-------------------------------|
| 标题 | `# ⚙️ 设置` | `## ⚙️ 设置` |
| 选项展示 | `` `value` `` | `✓ Label /  Label` |
| 末尾提示 | `---\n使用 /settings set...` | `---\n使用 /settings set...` |

**修复建议**：

抽取公共渲染逻辑到 `channel` 包的辅助函数（如 `renderSettingsMarkdown(schema, values, optionRenderer)`），让 `FeishuChannel.BuildSettingsUI` 调用它，仅传入自定义的 option 渲染器。

---

### P2-9: `HandleSettingSubmit` 不校验 value

**文件**：`channel/feishu.go` L834-856

**现状**：`HandleSettingSubmit` 只做 JSON 解析和 `key=value` 文本解析，不校验 value 是否在 schema Options 范围内。

由于此方法当前为死代码（P1-6），实际暂无影响。但一旦被启用，同样存在 P0-2 的 schema 绕过问题。

**修复建议**：`HandleSettingSubmit` 内部应引用 `SettingsSchema()` 进行校验。

---

### P2-10: 错误信息语义模糊

**文件**：`agent/command_builtin.go` L437-439 vs `agent/settings.go` L47

两处返回不同的"不支持"提示：

1. `channelFinder` 找不到 channel → `"当前渠道不支持设置"`
2. channel 存在但无 `SettingsCapability` → `"当前渠道没有可配置的设置项"`

从用户视角，两者含义相同（设置不可用），但表述不一致。

**修复建议**：统一措辞为 `"当前渠道不支持设置"`。

---

## 三、修复优先级建议

| 阶段 | 任务 | 工作量 |
|------|------|--------|
| **立即修复** | P0-1 nil guard、P0-2 schema 校验 | ~1h |
| **本轮跟进** | P1-3 拆分命令、P1-4 value 大小写、P1-5 help 文本、P1-6 死代码清理 | ~1h |
| **后续优化** | P2-7 依赖隔离、P2-8 去重、P2-9 HandleSettingSubmit 校验、P2-10 措辞统一 | ~2h |

---

## 四、正面评价

1. **接口设计清晰**：`SettingsCapability` + `UIBuilder` 分离关注点，channel 可按需实现
2. **向后兼容好**：不实现 `SettingsCapability` 的 channel 不会出错，有优雅降级
3. **测试覆盖**：`GetSettings`、`GetSettingsUI`、`SubmitSettings` 均有单元测试
4. **`BuildTextSettingsUI` 作为 fallback** 设计合理，避免强制所有 channel 实现自定义 UI
5. **`channelFinder` 回调模式**（main.go 中注入）方向正确，仅 `settings.go` 未遵循

---

*门下省 · 审核完毕*
