# P18:管理后台 — 任务引擎运营看板与参数管理 > 版本:v2.1-reviewed | 日期:2026-03-24 | 作者:AI(待评审) > 依赖:P17(助教客户归属与任务生成引擎)、P10(租户管理后台,部分复用) > 状态:评审通过,可进入实施 --- ## 0. 文档背景与目标 ### 0.1 问题来源 P17 实现了助教客户归属、任务生成、客户转移三大引擎能力,但所有数据仅存在于数据库中,运营团队无法: - 查看客户转移日志(谁被转给了谁、为什么、三重保护检查结果) - 审核 `pending_review` 状态的任务(转移次数超限后需人工介入) - 按门店调整任务生成参数(召回阈值、转移保护参数等) - 监控任务生成器的运行健康度(生成/替换/跳过/转移统计) 同时,现有 `http://localhost:5173/trigger-jobs` 定时任务页面功能较基础(仅展示 + 手动执行),需要评估是否扩展为更完整的调度监控面板。 ### 0.2 本文档目标 1. 定义 admin-web 中 P17 相关的三个新页面(转移日志、待审核任务、参数管理) 2. 评估现有 trigger-jobs 页面的扩展需求 3. 明确后端 API 需求(含 Pydantic schema 定义) 4. 给出优先级排序和实施建议 5. 明确技术实现方案(前端组件结构、路由注册、权限控制) ### 0.3 技术栈概要 | 层 | 技术 | |----|------| | 前端 | React 19 + Vite 6 + Ant Design 5 + axios + Zustand + react-router-dom 7 | | API 客户端 | `apps/admin-web/src/api/client.ts`,baseURL = `/api`,JWT 自动附加 + 401 自动刷新 | | 后端 | FastAPI + Pydantic v2 + asyncpg | | 认证 | JWT(`CurrentUser` 含 user_id / site_id / roles),`Depends(get_current_user)` | | 响应包装 | 全局中间件自动 `{ code: 0, data:
}`,前端 axios 拦截器自动解包 | --- ## 1. 现有 trigger-jobs 页面评估 ### 1.1 当前能力 | 功能 | 状态 | |------|------| | 列出所有定时任务(名称、触发方式、配置、状态) | ✅ 已实现 | | 展示上次/下次执行时间 | ✅ 已实现 | | 展示最近错误信息 | ✅ 已实现 | | 手动执行按钮(需确认) | ✅ 已实现 | | 刷新按钮 | ✅ 已实现 | ### 1.2 缺失能力(待评估) | 功能 | 优先级 | 说明 | |------|--------|------| | 执行历史记录 | 中 | 当前只有 last_run_at,无法查看历史执行记录和每次的统计结果 | | 任务启用/禁用开关 | 中 | 当前只能查看状态,无法在界面上切换 | | 执行结果统计 | 高 | task_generator 的 created/replaced/skipped/transferred 统计应可视化 | | Cron 表达式可编辑 | 低 | 当前触发配置只读,修改需直接改数据库 | | 执行耗时监控 | 低 | 无执行耗时记录 | ### 1.3 建议 trigger-jobs 页面的核心定位是"调度监控",不宜过度膨胀。建议: - 短期:在现有页面增加"最近执行结果"列(展示 task_generator 的 stats JSON) - 中期:新增"执行历史"抽屉(点击任务名展开,展示最近 N 次执行记录) - 长期:考虑是否需要独立的"调度中心"页面(类似 Airflow UI) > **决策(O6)**:暂不新增 `biz.trigger_job_execution_log` 表。短期通过在 `trigger_jobs` 表新增 `last_stats JSONB` 字段记录最近一次执行统计即可满足需求。中期再评估是否需要完整执行历史表。 --- ## 2. 新增页面设计 ### 2.1 页面一:客户转移日志(Transfer Log) **路由**:`/task-engine/transfer-log` **目标用户**:运营管理员(超级管理员看全部,门店管理员看本店) **数据源**:`biz.coach_task_transfer_log` **展示内容**: | 列 | 数据来源 | 说明 | |----|---------|------| | 转移时间 | `created_at` | 降序排列 | | 门店 | `site_id` → `biz.sites.site_name` | JOIN 门店表 | | 客户 | `member_id` → 会员昵称/手机号 | FDW 关联 ETL 库会员维度表 | | 原助教 | `from_assistant_id` → 助教姓名 | FDW 关联 ETL 库助教维度表 | | 新助教 | `to_assistant_id` → 助教姓名 | 同上 | | 转移原因 | `transfer_reason` | 文本展示 | | 转移得分 | `transfer_score` | 数值,保留 2 位小数 | | 保护检查 | `guard_checks` | JSON → 三项检查结果(✅/❌) | **筛选条件**: - 门店(下拉选择,门店管理员自动锁定本店) - 时间范围(日期选择器,默认最近 7 天) - 助教(搜索框,支持原助教/新助教) **操作**: - 无写操作,纯查看 - 支持导出 CSV(后续迭代) > **决策(O3)**:转移日志不关联 WBI/NCI/RS 快照。原因:(1) 转移时刻的指数值可通过 `transfer_reason` 文本和 `transfer_score` 间接推断;(2) 存储快照会增加 `coach_task_transfer_log` 表宽度,且历史指数可从 DWS 层按日期回溯。如后续运营反馈需要,可在 `guard_checks` JSONB 中追加指数快照字段,无需 DDL 变更。 --- ### 2.2 页面二:待审核任务(Pending Review) **路由**:`/task-engine/pending-review` **目标用户**:运营管理员(超级管理员看全部,门店管理员看本店) **数据源**:`biz.coach_tasks WHERE status = 'pending_review'` **展示内容**: | 列 | 数据来源 | 说明 | |----|---------|------| | 创建时间 | `created_at` | 降序排列 | | 门店 | `site_id` → 门店名称 | | | 客户 | `member_id` → 会员昵称 | FDW 关联 | | 当前助教 | `assistant_id` → 助教姓名 | FDW 关联 | | 任务类型 | `task_type` | 中文映射(见下方枚举表) | | 累计转移次数 | `transfer_count` | | | 优先级分 | `priority_score` | WBI/NCI 分值 | **任务类型中文映射**: | task_type | 中文 | |-----------|------| | `high_priority_recall` | 高优先召回 | | `priority_recall` | 优先召回 | | `follow_up_visit` | 客户回访 | | `relationship_building` | 关系构建 | **操作**: | 操作 | 说明 | |------|------| | 重新分配 | 弹窗选择目标助教 → 调用 reassign API → 原任务标记 `transferred`,新任务 `active` | | 关闭任务 | 弹窗填写关闭原因 → 调用 close API → 原任务标记 `inactive` | | 查看转移历史 | 抽屉展示该客户的所有转移记录(复用转移日志 API) | > **决策(O1)**:重新分配的候选助教列表获取方式 — 复用 P17 的转移候选逻辑。后端新增 `GET /api/admin/task-engine/pending-review/{task_id}/candidates` 端点,内部调用 `fdw_queries.get_pool_assistants(member_id)` 获取 POOL 助教列表,按转移得分排序返回。前端展示为下拉选择框,显示助教姓名 + 转移得分。若 POOL 为空,候选列表返回空数组,界面提示"该客户暂无符合转移条件的助教,请联系 ETL 团队确认数据覆盖情况",不提供降级到全店助教的选项(P17 明确规定 UNASSIGNED 助教永远不分配)。若确实有紧急人工干预需求,可提供"强制指定"按钮,需额外二次确认弹窗,且操作记录中标注 `source: "manual_override"`,写入 `transfer_log.transfer_reason`。 --- ### 2.3 页面三:任务引擎参数管理(Task Engine Config) **路由**:`/task-engine/config` **目标用户**:超级管理员(门店管理员只读) **数据源**:`biz.cfg_task_generator_params` **展示内容**: 表格形式,按参数分组,每行展示全局默认值 + 各门店覆盖值: | 列 | 说明 | |----|------| | 参数名 | `param_key`,中文描述 | | 全局默认值 | `site_id IS NULL` 的记录 | | 门店覆盖值 | 特定 `site_id` 的记录(无覆盖时显示"使用默认"灰色标签) | | 说明 | `description` 字段 | | 操作 | 编辑 / 删除覆盖 | **参数列表**(来自 P17 第 5 节): | 参数 | 默认值 | 中文说明 | 校验规则 | |------|--------|---------|----------| | `high_priority_recall_threshold` | 7.0 | 高优先召回阈值 | 0-10,numeric | | `priority_recall_threshold` | 5.0 | 优先召回阈值 | 0-10,numeric | | `rs_min_for_relationship` | 1.0 | 关系构建 RS 下限 | 0-10,numeric | | `rs_max_for_relationship` | 6.0 | 关系构建 RS 上限 | 0-10,> rs_min | | `consecutive_recall_fail_cycles` | 3 | 连续失败触发转移的轮数 | 1-10,integer | | `min_wbi_for_transfer` | 5.0 | 触发转移的最低 WBI | 0-10,numeric | | `guard_assistant_coverage_ratio` | 0.5 | 门店助教绑定率保护阈值 | 0-1,numeric | | `guard_new_assistant_days` | 10 | 新助教入驻保护天数 | 1-90,integer | | `transfer_score_w_rs` | 0.5 | 转移排序 RS 权重 | 0-1,三项之和 = 1 | | `transfer_score_w_ms` | 0.3 | 转移排序 MS 权重 | 0-1,三项之和 = 1 | | `transfer_score_w_ml` | 0.2 | 转移排序 ML 权重 | 0-1,三项之和 = 1 | | `max_transfer_count` | 2 | 单客户最大转移次数 | 1-5,integer | | `follow_up_visit_retention_hours` | 48 | 回访任务保留时长(小时) | 1-168,integer | **操作**: | 操作 | 说明 | 权限 | |------|------|------| | 编辑参数值 | 行内编辑,保存后立即生效 | 超级管理员 | | 新增门店覆盖 | 选择门店 + 参数,设置覆盖值 | 超级管理员 | | 删除门店覆盖 | 恢复使用全局默认值 | 超级管理员 | | 重置为默认 | 将全局默认值恢复为 P17 定义的初始值 | 超级管理员 | **前端校验规则**: - 权重参数(`w_rs` + `w_ms` + `w_ml`)之和必须 = 1.0(容差 0.001)。前端将三个权重参数合并为一个"权重配置"卡片,同时展示三个输入框,整体保存。后端 PUT 接口对权重类参数做联合校验:收到任一权重参数修改时,自动读取当前另外两个权重的值做求和校验 - 阈值参数范围 0-10(与指数分值范围一致) - `rs_max_for_relationship` 必须 > `rs_min_for_relationship` - 修改后弹出确认对话框,展示变更前后对比 > **决策(O2)**:参数修改直接生效,不需要审批流程。原因:(1) 参数修改频率极低(预计每月 1-2 次);(2) 修改记录通过 `updated_at` 字段和后端日志可追溯;(3) 引入审批流程会增加不必要的复杂度。后续如需审批,可在 `cfg_task_generator_params` 表新增 `updated_by` 字段记录操作人。 --- ## 3. 后端 API 需求 ### 3.1 转移日志 API **路由文件**:`apps/backend/app/routers/admin_task_engine.py`(新建) ``` GET /api/admin/task-engine/transfer-log Query: site_id?, from_date?, to_date?, assistant_id?, page=1, page_size=20 Response: { items: TransferLogItem[], total: int } 权限: get_current_user(门店管理员自动按 site_id 过滤) GET /api/admin/task-engine/transfer-log/{member_id}/history Response: { items: TransferLogItem[] } 权限: get_current_user ``` **SQL 示例(转移日志列表)**: ```sql SELECT tl.id, tl.site_id, tl.member_id, tl.from_assistant_id, tl.to_assistant_id, tl.transfer_reason, tl.transfer_score, tl.guard_checks, tl.created_at, s.site_name FROM biz.coach_task_transfer_log tl JOIN biz.sites s ON s.site_id = tl.site_id WHERE ($1::bigint IS NULL OR tl.site_id = $1) AND ($2::date IS NULL OR tl.created_at >= $2) AND ($3::date IS NULL OR tl.created_at < $3 + INTERVAL '1 day') AND ($4::bigint IS NULL OR tl.from_assistant_id = $4 OR tl.to_assistant_id = $4) ORDER BY tl.created_at DESC LIMIT $5 OFFSET $6; ``` > **姓名关联实现方式**:由于 postgres_fdw 不传递 GUC 参数,不能在 admin-web 后端直接用 FDW JOIN。实现上采用 Python 层批量合并:先从 `biz.coach_task_transfer_log` 取分页数据,然后收集所有 `member_id` 和 `assistant_id`,批量调用 `fdw_queries.get_member_names(member_ids)` 和新增的 `fdw_queries.get_assistant_names(assistant_ids)` 工具函数(通过 `_fdw_context()` 创建独立 ETL 连接),最后在 Python 层做字典合并。待审核任务列表同理。避免在 SQL 里做跨库 JOIN。 ### 3.2 待审核任务 API ``` GET /api/admin/task-engine/pending-review Query: site_id?, page=1, page_size=20 Response: { items: PendingReviewItem[], total: int } 权限: get_current_user GET /api/admin/task-engine/pending-review/{task_id}/candidates Response: { candidates: CandidateAssistant[] } 权限: get_current_user 说明: 返回可接收转移的候选助教列表(POOL 助教 + 降级全店助教) POST /api/admin/task-engine/pending-review/{task_id}/reassign Body: { to_assistant_id: int } Response: { success: bool, new_task_id: int } 权限: get_current_user + roles 包含 'super_admin' 副作用: 原任务 status → 'transferred',新建 active 任务,写入 transfer_log POST /api/admin/task-engine/pending-review/{task_id}/close Body: { reason: str } Response: { success: bool } 权限: get_current_user + roles 包含 'super_admin' 副作用: 任务 status → 'inactive',abandon_reason = reason ``` ### 3.3 参数管理 API ``` GET /api/admin/task-engine/config Query: site_id?(不传返回全局默认 + 所有门店覆盖) Response: { params: ConfigParam[] } 权限: get_current_user PUT /api/admin/task-engine/config/{param_id} Body: { param_value: float } Response: { success: bool } 权限: get_current_user + roles 包含 'super_admin' POST /api/admin/task-engine/config Body: { site_id: int, param_key: str, param_value: float } Response: { success: bool, id: int } 权限: get_current_user + roles 包含 'super_admin' DELETE /api/admin/task-engine/config/{param_id} Response: { success: bool } 权限: get_current_user + roles 包含 'super_admin' 约束: 不允许删除全局默认值(site_id IS NULL),仅允许删除门店覆盖 ``` --- ## 4. Pydantic Schema 定义 **文件位置**:`apps/backend/app/schemas/admin_task_engine.py`(新建) ```python from __future__ import annotations from datetime import datetime from pydantic import BaseModel, Field # ── 转移日志 ── class TransferLogItem(BaseModel): id: int site_id: int site_name: str = "" member_id: int member_name: str = "" # FDW 关联 from_assistant_id: int from_assistant_name: str = "" # FDW 关联 to_assistant_id: int to_assistant_name: str = "" # FDW 关联 transfer_reason: str | None = None transfer_score: float | None = None guard_checks: dict | None = None created_at: datetime class TransferLogPage(BaseModel): items: list[TransferLogItem] total: int # ── 待审核任务 ── class PendingReviewItem(BaseModel): id: int site_id: int site_name: str = "" member_id: int member_name: str = "" assistant_id: int assistant_name: str = "" task_type: str task_type_label: str = "" # 中文映射 transfer_count: int = 0 priority_score: float | None = None created_at: datetime class PendingReviewPage(BaseModel): items: list[PendingReviewItem] total: int class CandidateAssistant(BaseModel): assistant_id: int assistant_name: str = "" rs_display: float = 0 ms_display: float = 0 ml_display: float = 0 transfer_score: float = 0 # w_rs*rs + w_ms*ms + w_ml*ml source: str = "pool" # "pool" | "manual_override"(强制指定标记) class CandidateListResponse(BaseModel): candidates: list[CandidateAssistant] class ReassignRequest(BaseModel): to_assistant_id: int class ReassignResponse(BaseModel): success: bool new_task_id: int | None = None class CloseRequest(BaseModel): reason: str = Field(..., min_length=1, max_length=500) class CloseResponse(BaseModel): success: bool # ── 参数管理 ── class ConfigParam(BaseModel): id: int site_id: int | None = None site_name: str | None = None # site_id 非空时关联 param_key: str param_value: float description: str | None = None updated_at: datetime class ConfigParamList(BaseModel): params: list[ConfigParam] class ConfigParamUpdate(BaseModel): param_value: float class ConfigParamCreate(BaseModel): site_id: int param_key: str = Field(..., max_length=64) param_value: float class ConfigParamResponse(BaseModel): success: bool id: int | None = None ``` --- ## 5. 权限控制方案 > **决策(O5)**:采用双层权限模型 — 超级管理员(`super_admin`)全局访问 + 门店管理员(`site_admin`)本店只读。 ### 5.1 权限矩阵 | 页面/操作 | super_admin | site_admin | |-----------|-------------|------------| | 转移日志 — 查看全部 | ✅ | ❌ | | 转移日志 — 查看本店 | ✅ | ✅ | | 待审核任务 — 查看全部 | ✅ | ❌ | | 待审核任务 — 查看本店 | ✅ | ✅ | | 待审核任务 — 重新分配 | ✅ | ❌ | | 待审核任务 — 关闭任务 | ✅ | ❌ | | 参数管理 — 查看 | ✅ | ✅(只读) | | 参数管理 — 编辑/新增/删除 | ✅ | ❌ | ### 5.2 后端实现 > **与 P10 账号体系的关系**:P10 租户管理后台(`/tenant-admins`)的门店管理员账号与本页面的 `site_admin` 角色使用同一套 JWT 体系,通过 `roles` 字段区分权限范围。`site_admin` 在 P18 页面为只读访问,在 P10 页面可能有写操作权限。两者面向的用户群不同(P18 面向平台运营,P10 面向门店管理员),但共享认证基础设施。 ```python # apps/backend/app/auth/dependencies.py 中已有 CurrentUser(roles=[...]) # 新增权限检查辅助函数: from fastapi import HTTPException, status def require_super_admin(user: CurrentUser) -> None: """写操作权限检查:仅超级管理员可执行。""" if "super_admin" not in user.roles: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="仅超级管理员可执行此操作", ) def filter_by_site(user: CurrentUser, query_site_id: int | None) -> int | None: """读操作门店过滤:门店管理员强制锁定本店。""" if "super_admin" in user.roles: return query_site_id # 超级管理员可查看任意门店 return user.site_id # 门店管理员强制本店 ``` ### 5.3 前端实现 ```typescript // 在页面组件中检查权限,控制操作按钮显示 const isSuperAdmin = user.roles.includes('super_admin'); // 转移日志:门店筛选器 // super_admin → 显示全部门店下拉 // site_admin → 隐藏门店筛选器,API 自动按 site_id 过滤 // 待审核任务:操作列 // super_admin → 显示"重新分配"和"关闭"按钮 // site_admin → 不显示操作列 // 参数管理:编辑按钮 // super_admin → 显示编辑/新增/删除按钮 // site_admin → 隐藏所有写操作按钮 ``` --- ## 6. 前端实现方案 ### 6.1 导航结构 在 `apps/admin-web/src/App.tsx` 的 `NAV_ITEMS` 数组中新增"任务引擎"菜单组: ```typescript // 新增 import import { ApartmentOutlined } from "@ant-design/icons"; import TransferLog from "./pages/TransferLog"; import PendingReview from "./pages/PendingReview"; import TaskEngineConfig from "./pages/TaskEngineConfig"; // NAV_ITEMS 中新增(插入在"定时任务"之后) { key: "task-engine-group", icon: