# 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:
- `all` Tab → `fetchUnifiedTriggers()` → `/admin/triggers/unified`
- `biz` Tab → `fetchTriggerJobs()` → `/trigger-jobs`(**BizTriggersTab 内嵌**)
- `ai` Tab → 嵌入 `` + `` + `` 三个组件
- `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" 嵌套 ``
**已经有冗余**:`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` 或抽公共组件 ``
### 阶段 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 交互改动