# P1-6 触发器双 API 合并可行性

> 反馈背景：Neo 倾向合并双 API（`/admin/ai/triggers` vs `/trigger-jobs`），
> "看下数据获取的泛用性，如果合适则合并"。
> 本文档基于现状代码评估泛用性、字段差异、合并方案与回归风险。

调研日期：2026-05-04
调研范围：`apps/backend/app/routers/{admin_triggers,trigger_jobs,admin_ai}.py` / `apps/backend/app/services/ai/admin_service.py` / `apps/admin-web/src/api/{adminAI,triggerJobs,triggers}.ts` / `apps/admin-web/src/pages/{AITriggers,TriggerJobs,TriggerManager}.tsx` / 测试库 `biz.{trigger_jobs,ai_trigger_jobs}` 实际数据

---

## 一、双 API 字段对比（实际是 3 个 API）

调研中发现**当前实际有 3 个相关 API**，不是 2 个，先把全图列清楚：

| API 路径 | 文件 | 数据源 | 用途 |
|---|---|---|---|
| `GET /api/trigger-jobs` | `routers/trigger_jobs.py` | `biz.trigger_jobs` | 通用：所有定时任务（业务+AI 混合） |
| `PATCH /api/trigger-jobs/:id/config` | 同上 | `biz.trigger_jobs` | 通用：改 cron / interval |
| `POST /api/trigger-jobs/:id/run` | 同上 | `biz.trigger_jobs` | 通用：手动执行 |
| `GET /api/admin/ai/triggers` | `routers/admin_ai.py:400` | `biz.trigger_jobs` (filter `job_type LIKE 'ai_%' OR job_name='task_generator'`) | AI 视角：仅 AI 相关触发器 |
| `PATCH /api/admin/ai/triggers/:id` | 同上 | `biz.trigger_jobs` | AI 视角：改 cron / status / description |
| `GET /api/admin/triggers/unified` | `routers/admin_triggers.py` | 聚合 `biz.trigger_jobs` + `biz.ai_trigger_jobs` + `public.scheduled_tasks` | 只读：跨数据源全景视图 |

**核心事实**：
- `/admin/ai/triggers` 与 `/trigger-jobs` 操作的是**同一张表 `biz.trigger_jobs`**，前者只是加了 `WHERE job_type LIKE 'ai_%'` 过滤
- `/admin/triggers/unified` 是只读聚合视图，跨 3 张表，与上述两个 API 不同源
- `biz.ai_trigger_jobs` 是 AI 调用链历史记录表（事件实例），**不是触发器配置表**；当前测试库 0 条记录

### 1.1 GET 响应字段对比

| 字段 | `/trigger-jobs` (TriggerJobItem) | `/admin/ai/triggers` (TriggerItem) |
|---|---|---|
| id | int | int |
| job_name | string | string |
| job_type | **string** | **string** |
| trigger_condition | string | string |
| trigger_config | dict\|null | dict（不可为 null） |
| last_run_at | string\|null | string\|null |
| next_run_at | string\|null | string\|null |
| status | string | string |
| description | string\|null | string\|null |
| last_error | string\|null | string\|null |
| **created_at** | **string\|null** ✅ | **未返回** ❌ |

**差异 = 1 个字段**：`/admin/ai/triggers` 不返回 `created_at`。两个返回值**95% 重合**。

### 1.2 PATCH 入参字段对比

| 字段 | `/trigger-jobs/:id/config` (UpdateTriggerConfigRequest) | `/admin/ai/triggers/:id` (TriggerUpdateRequest) |
|---|---|---|
| cron_expression | ✅ | ✅ |
| interval_seconds | ✅ | ❌ |
| status | ❌ | ✅ |
| description | ❌ | ✅ |
| 校验规则 | "至少一个字段" + cron 正则 + interval ≥ 1 | 无 model_validator，cron 正则用 `jsonb_set` 写入 |

**差异显著**：
- `/admin/ai/triggers` 支持改 status（启停）和 description，**`/trigger-jobs` 不支持**
- `/trigger-jobs` 支持改 interval，**`/admin/ai/triggers` 不支持**
- 这是**互补的字段集**，不是冗余

### 1.3 排序与过滤差异

| 维度 | `/trigger-jobs` | `/admin/ai/triggers` |
|---|---|---|
| WHERE | 无 | `job_type LIKE 'ai_%' OR job_name = 'task_generator'` |
| ORDER BY | `id` | `trigger_condition DESC, job_name` |

### 1.4 测试库实际数据（biz.trigger_jobs 9 行）

```
id  job_name                       job_type                       condition  status
1   task_generator                 task_generator                 cron       enabled  ← AI 列表也包含（job_name 命中）
2   task_expiry_check              task_expiry_check              interval   enabled
3   recall_completion_check        recall_completion_check        event      enabled
4   note_reclassify_backfill       note_reclassify_backfill       event      enabled
57  ai_consumption_settled         ai_consumption_settled         event      enabled  ← AI
58  ai_note_created                ai_note_created                event      enabled  ← AI
59  ai_task_assigned               ai_task_assigned               event      enabled  ← AI
60  ai_dws_completed               ai_dws_completed               event      enabled  ← AI
61  ai_dws_prewarm_1000            ai_dws_prewarm                 cron       enabled  ← AI
```

`/admin/ai/triggers` 命中 6 行（5 个 ai_ 前缀 + 1 个 task_generator）；
`/trigger-jobs` 命中全部 9 行；
`/admin/triggers/unified` 命中 9 行（biz）+ 0 行（ai_trigger_jobs，0 条记录）+ N 行 etl。

---

## 二、前端依赖分析

### 2.1 三个页面的数据消费

| 页面 | 数据源 | 消费的字段 |
|---|---|---|
| `pages/AITriggers.tsx` (独立) | `listTriggers()` → `/admin/ai/triggers` | id / job_name / trigger_condition / trigger_config.cron_expression / trigger_config.event_name / status / description / last_run_at / next_run_at / last_error |
| `pages/TriggerJobs.tsx` (独立) | `fetchTriggerJobs()` → `/trigger-jobs` | id / job_name / trigger_condition / trigger_config.\*（cron+interval+event_name）/ status / description / last_run_at / next_run_at / last_error |
| `pages/TriggerManager.tsx` (容器) | 4 个 Tab：<br>- `all` Tab → `fetchUnifiedTriggers()` → `/admin/triggers/unified`<br>- `biz` Tab → `fetchTriggerJobs()` → `/trigger-jobs`（**BizTriggersTab 内嵌**）<br>- `ai` Tab → 嵌入 `<AITriggers />` + `<AIOperations />` + `<AITriggerJobs />` 三个组件<br>- `etl` Tab → `fetchSchedules()` → ETL API | 按 Tab 分别展示 |

### 2.2 字段消费交集

`AITriggers.tsx` 与 `TriggerJobs.tsx` / `BizTriggersTab` 消费的字段**完全相同**（除 `created_at` 在 AITriggers 未消费）。

### 2.3 操作能力消费

| 操作 | AITriggers (in TriggerManager AI Tab) | BizTriggersTab (in TriggerManager biz Tab) | TriggerJobs (独立页面) |
|---|---|---|---|
| 启停（status 切换） | ✅ Switch 组件 | ❌ | ❌ |
| 改 cron | ✅ Modal | ✅ Modal | ❌（只有列表 + run） |
| 改 interval | ❌ | ✅ Modal | ❌ |
| 改 description | ✅ Modal | ❌ | ❌ |
| 手动执行 | ❌ | ❌ | ✅ Button |
| 清空所有任务 | ❌ | ❌ | ✅（业务专用） |

**核心事实**：三个页面消费字段几乎一致，但**操作能力是互补的**。`/admin/ai/triggers` PATCH 比 `/trigger-jobs/:id/config` PATCH 多了 status / description，少了 interval；前端 UI 也按各自支持的字段开放对应控件。

### 2.4 路由注册情况（admin-web 主导航）

`apps/admin-web` 当前 4 处入口：
- 独立页面 `TriggerJobs.tsx`（路径见路由表）
- 独立页面 `AITriggers.tsx`
- 聚合容器 `TriggerManager.tsx` Tab "biz" 嵌套 `BizTriggersTab`（与 `TriggerJobs.tsx` 几乎重复）
- 聚合容器 `TriggerManager.tsx` Tab "ai" 嵌套 `<AITriggers />`

**已经有冗余**：`BizTriggersTab` 的 columns 与 `TriggerJobs.tsx` 重复定义。这是当前需要先收敛的 UI 层债务。

---

## 三、数据获取泛用性评估

### 3.1 三个 API 的"可替代性"矩阵

| 场景 | 可用 API | 评估 |
|---|---|---|
| 列出全部触发器（业务+AI） | `/trigger-jobs` ✅ / `/admin/triggers/unified`（聚合，包含 etl） | 单源场景下 `/trigger-jobs` 已够用 |
| 仅看 AI 触发器 | `/admin/ai/triggers` ✅ / `/trigger-jobs` + 前端过滤 | 后端过滤省一次网络传输；前端过滤更灵活 |
| 跨表全景（biz + ai_jobs + etl） | 仅 `/admin/triggers/unified` | 只读，无替代 |
| 改 status / description | 仅 `/admin/ai/triggers` PATCH | 缺口（`/trigger-jobs` 不支持） |
| 改 interval | 仅 `/trigger-jobs/:id/config` PATCH | 缺口（`/admin/ai/triggers` 不支持） |
| 手动 run | 仅 `/trigger-jobs/:id/run` POST | 缺口 |

**结论**：GET 端可合并（`/trigger-jobs?job_type_prefix=ai_` 即可替代 `/admin/ai/triggers`）；PATCH 端**必须先合并字段集**才能合并 API；POST run 端 `/admin/ai/triggers` 本就没有，不冲突。

### 3.2 泛用性瓶颈

`/trigger-jobs` 的 PATCH 当前**故意不暴露 status / description**，原因推测：

1. 业务触发器的启停可能影响 ETL 调度，需要更严格的权限控制（admin_ai 路由有 `_require_admin()` 守卫）
2. description 是给后台管理员看的"说明"，业务侧可能不希望随便改

如果直接合并，需要先决策：**`/trigger-jobs` PATCH 是否允许改 status / description**？

---

## 四、合并方案对比

### 方案 A · 完全合并（删除 `/admin/ai/triggers`）

#### 步骤
1. 扩展 `apps/backend/app/schemas/trigger_jobs.py::UpdateTriggerConfigRequest` 加 `status` / `description` 字段
2. `apps/backend/app/routers/trigger_jobs.py::update_trigger_config` 加白名单：仅当 `status_new in (enabled, disabled)` 时通过；description 直接 UPDATE
3. `GET /api/trigger-jobs` 加 query 参数 `?job_type_prefix=ai_`，等价于 `/admin/ai/triggers` 的过滤
4. `apps/admin-web/src/api/adminAI.ts` 删除 `listTriggers` / `updateTrigger` 函数
5. `apps/admin-web/src/pages/AITriggers.tsx` 改用 `triggerJobs.ts` 的 `fetchTriggerJobs({ jobTypePrefix: "ai_" })` + 新 PATCH 函数
6. 删除后端 `/admin/ai/triggers` 路由
7. 同步收敛 `BizTriggersTab` 与 `TriggerJobs.tsx` 的 column 重复定义

#### 评估
| 维度 | 值 |
|---|---|
| 工作量 | 中（schemas/路由/前端 API 层/2 个组件改造） |
| 回归范围 | AITriggers 页面端到端 + TriggerManager AI/biz Tab + TriggerJobs 独立页 + `__tests__/adminAiAppTypes.test.ts` 类的对齐测试 |
| 长期维护成本 | 低（单源真相） |
| 风险点 | 1）改 status / description 的权限放开后，业务触发器（id 1-4）可能被误改；建议加守卫"业务触发器 status 改动需二次确认"。2）admin-web 之外的调用方（如 mcp-server）需排查 |

### 方案 B · 字段子集合并（保留两 API，底层 service 合并）

#### 步骤
1. 新建 `apps/backend/app/services/trigger_service.py`，集中 `list_triggers(filter)` / `update_trigger(id, fields)` 两个内核函数
2. `routers/admin_ai.py::list_triggers` 与 `routers/trigger_jobs.py::get_trigger_jobs` 都调用 `trigger_service.list_triggers()`，参数不同
3. 两个 PATCH 端点都调用 `trigger_service.update_trigger(...)`，前者传 status/description 子集，后者传 cron/interval 子集
4. 前端代码不动

#### 评估
| 维度 | 值 |
|---|---|
| 工作量 | 中（后端重构，前端零改动） |
| 回归范围 | 后端 service 层单测 + 路由集成测试 |
| 长期维护成本 | 中（API 两份仍在，但底层一致） |
| 风险点 | 没有真正解决 Neo 反馈的"双 API"困惑，前端开发仍要在两个 API 客户端之间选 |

### 方案 C · 不合并，只补文档边界

#### 步骤
1. 在 `apps/backend/app/routers/admin_ai.py` 与 `trigger_jobs.py` 顶部 docstring 互引："本路由 PATCH 仅改 status/description（业务/AI 都可），改 cron/interval 请用 /trigger-jobs"
2. 同步 `apps/backend/CLAUDE.md` 增加一节"触发器 API 边界"

#### 评估
| 维度 | 值 |
|---|---|
| 工作量 | 极低（< 50 行注释） |
| 长期维护成本 | 高（每次新人都要重新理解两 API 边界） |

---

## 五、推荐实施步骤

**推荐方案 A（完全合并）**，分 4 阶段：

### 阶段 1 · 后端 API 扩展（D+0）

1. `apps/backend/app/schemas/trigger_jobs.py::UpdateTriggerConfigRequest` 新增字段：
   ```python
   status: Literal["enabled", "disabled"] | None = None
   description: str | None = None
   ```
   model_validator 改为"四选一即可"
2. `apps/backend/app/routers/trigger_jobs.py::update_trigger_config`：
   - 增加 status / description UPDATE 分支
   - 新增 query 参数 `?job_type_prefix=str` for GET
   - 新增 query 参数 `?include_event=bool`（默认 true）便于按需排除
3. 在 admin_ai.py 的 `/admin/ai/triggers` GET/PATCH 下加 deprecation header（`Deprecation: true`）

### 阶段 2 · 前端切换调用（D+1）

1. `apps/admin-web/src/api/triggerJobs.ts::UpdateTriggerConfigReq` 加 status/description
2. `apps/admin-web/src/api/triggerJobs.ts::fetchTriggerJobs` 加可选 `params: { job_type_prefix?: string }`
3. `apps/admin-web/src/pages/AITriggers.tsx`:
   - import 改为 `triggerJobs.ts`
   - `listTriggers()` → `fetchTriggerJobs({ job_type_prefix: 'ai_' })`
   - `updateTrigger(id, body)` → `updateTriggerConfig(id, body)`
4. 收敛 `pages/TriggerManager.tsx::BizTriggersTab` columns 重复定义：直接 `import TriggerJobs` 或抽公共组件 `<TriggerJobsTable filter={...} />`

### 阶段 3 · 删除旧 API（D+2，灰度后）

1. 删除 `apps/backend/app/routers/admin_ai.py` 中 `/triggers` 两个端点
2. 删除 `apps/backend/app/services/ai/admin_service.py::list_triggers` / `update_trigger`
3. 删除 `apps/admin-web/src/api/adminAI.ts` 中 `listTriggers` / `updateTrigger` / `TriggerItem` / `TriggerUpdateRequest`
4. 跑全量回归（pytest backend + admin-web 端到端）

### 阶段 4 · 文档同步（D+2）

1. `apps/backend/CLAUDE.md` 触发器章节加"统一 API: /api/trigger-jobs"
2. 写审计 `docs/audit/changes/<日期>__trigger-api-merge.md`
3. 顺手补：`/admin/triggers/unified` 仍保留（跨表只读视图），但在 docstring 标明"如果只看 biz 表用 /trigger-jobs，跨 etl/ai_jobs 时再用 unified"

---

## 六、给 Neo 的决策清单

| 问 | 选项 | 推荐 |
|---|---|---|
| Q1 | 采纳方案 A（完全合并），还是方案 B（保留双 API 仅合并底层）？ | **方案 A** |
| Q2 | 合并后 `/trigger-jobs` PATCH 允许改 `status`，业务触发器（id 1-4，task_generator/task_expiry_check 等）是否需要"业务触发器禁止禁用"守卫？ | 推荐加一个白名单：`PROTECTED_JOB_NAMES = {"task_generator"}`，禁用时 422 |
| Q3 | `/admin/triggers/unified` 是否保留？（它聚合了 ai_trigger_jobs + scheduled_tasks，与单表 API 不同源） | 保留（跨数据源场景仍需要） |
| Q4 | 合并后是否把独立页面 `pages/AITriggers.tsx` 也删掉，仅保留 `TriggerManager.tsx` 的 Tab 视图？ | 推荐删（已被 TriggerManager 覆盖） |
| Q5 | `pages/TriggerJobs.tsx` 独立页面是否也合并到 `TriggerManager.tsx`？路由表清理？ | 推荐合并；路由表保留旧路径 redirect → TriggerManager?tab=biz |
| Q6 | 灰度策略：直接 D+0 删除旧 API，还是 D+0 加 deprecation header → D+7 删除？ | 推荐 D+0 加 header，D+2 删除（admin-web 是单仓库唯一调用方，可短窗口） |

---

## 附录：关键文件清单

### 后端路由
- `apps/backend/app/routers/trigger_jobs.py` — 通用 trigger_jobs API（保留并扩展）
- `apps/backend/app/routers/admin_ai.py:400-440` — `/admin/ai/triggers` GET/PATCH（待删除）
- `apps/backend/app/routers/admin_triggers.py` — `/admin/triggers/unified` 跨数据源聚合（保留）

### 后端 service
- `apps/backend/app/services/ai/admin_service.py:752-820` — `list_triggers` / `update_trigger`（待删除，逻辑迁移到 trigger_jobs 路由）
- `apps/backend/app/services/trigger_scheduler.py:346` — `list_trigger_jobs`（保留，是基础设施）

### 后端 schema
- `apps/backend/app/schemas/trigger_jobs.py` — `TriggerJobItem` / `UpdateTriggerConfigRequest`（扩展 status/description）
- `apps/backend/app/schemas/admin_ai.py:250-269` — `TriggerItem` / `TriggerUpdateRequest`（待删除）
- `apps/backend/app/schemas/admin_triggers.py:14` — `UnifiedTriggerItem`（保留）

### 前端 API
- `apps/admin-web/src/api/triggerJobs.ts` — 主 API 客户端（扩展）
- `apps/admin-web/src/api/adminAI.ts:337-366` — `listTriggers` / `updateTrigger` / `TriggerItem`（待删除）
- `apps/admin-web/src/api/triggers.ts` — `fetchUnifiedTriggers`（保留）

### 前端页面
- `apps/admin-web/src/pages/TriggerManager.tsx` — 容器页（保留）
- `apps/admin-web/src/pages/AITriggers.tsx` — 改用 triggerJobs API 后保留组件，删 listTriggers 调用
- `apps/admin-web/src/pages/TriggerJobs.tsx` — 独立页面（建议删除并 redirect）
- `apps/admin-web/src/pages/AITriggerJobs.tsx` — 注意：这是 ai_trigger_jobs 历史日志页面，与本调研 API 无关（用 `/admin/ai/trigger-jobs` 是另一组端点）

### 数据库
- `db/zqyy_app/schemas/biz.sql:344-360` — `biz.trigger_jobs` 表定义
- `db/zqyy_app/schemas/biz.sql:48-150` — `biz.ai_trigger_jobs` 表定义（事件历史，与触发器配置不同）

### 测试
- `apps/admin-web/src/__tests__/adminAiAppTypes.test.ts` — 现有对齐测试（合并后扩展，加上 trigger PATCH 字段对齐用例）

### 审计参考
- `docs/audit/changes/2026-03-23__trigger-jobs-admin-web-miniprogram-cleanup.md` — 上次 trigger_jobs 改动
- `docs/audit/changes/2026-03-24__trigger-jobs-clear-task-interaction.md` — clear-all 交互改动
