feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本
包含多个会话的累积代码变更: - backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔 - admin-web: ETL 状态页、任务管理、调度配置、登录优化 - miniprogram: 看板页面、聊天集成、UI 组件、导航更新 - etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强 - tenant-admin: 项目初始化 - db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8) - packages/shared: 枚举和工具函数更新 - tools: 数据库工具、报表生成、健康检查 - docs: PRD/架构/部署/合约文档更新 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ apps/admin-web/
|
||||
├── src/
|
||||
│ ├── App.tsx # 主布局 + 路由配置 + 路由守卫
|
||||
│ ├── main.tsx # 应用入口
|
||||
│ ├── pages/ # 8 个功能页面
|
||||
│ ├── pages/ # 18 个功能页面
|
||||
│ │ ├── Login.tsx # 登录页
|
||||
│ │ ├── TaskConfig.tsx # 任务配置(Flow 选择 + 任务勾选 + 参数设置)
|
||||
│ │ ├── TaskManager.tsx # 任务管理(队列 + 执行历史 + 实时日志)
|
||||
@@ -27,13 +27,23 @@ apps/admin-web/
|
||||
│ │ ├── DBViewer.tsx # 数据库查看器(Schema/表/列浏览 + SQL 执行)
|
||||
│ │ ├── LogViewer.tsx # 日志查看器
|
||||
│ │ ├── EnvConfig.tsx # 环境配置管理
|
||||
│ │ └── OpsPanel.tsx # 运维面板(服务启停 + Git + 系统监控)
|
||||
│ │ ├── OpsPanel.tsx # 运维面板(服务启停 + Git + 系统监控)
|
||||
│ │ ├── TenantAdmins/ # 租户管理员管理(2步创建 + 软删除 + 简写ID管理)
|
||||
│ │ ├── AIDashboard.tsx # AI 运行总览(统计卡片 + 趋势图 + 饼图 + 预算 + 告警)
|
||||
│ │ ├── AITriggerJobs.tsx # AI 调度状态(分页表格 + 筛选 + 手动重跑)
|
||||
│ │ ├── AIRunLogs.tsx # AI 调用明细(分页表格 + 详情抽屉)
|
||||
│ │ ├── AIOperations.tsx # AI 手动操作(重跑 + 缓存失效 + 批量执行 + 告警管理)
|
||||
│ │ ├── DevTrace.tsx # 开发调试全链路日志(覆盖率 + 筛选 + 请求列表 + Span 树 + 设置)
|
||||
│ │ ├── TransferLog.tsx # P18 客户转移日志(分页表格 + 门店/时间/助教筛选 + guard_checks 标签)
|
||||
│ │ ├── PendingReview.tsx # P18 待审核任务(分页表格 + 重新分配/关闭弹窗 + 转移历史抽屉)
|
||||
│ │ ├── TaskEngineConfig.tsx # P18 参数管理(全局默认+门店覆盖 + 行内编辑 + 权重卡片编辑)
|
||||
│ │ └── TriggerJobs.tsx # 定时任务管理(biz.trigger_jobs 表展示 + 手动执行 + 清空任务)
|
||||
│ ├── components/ # 可复用组件
|
||||
│ │ ├── BusinessDayHint.tsx # 营业日提示组件
|
||||
│ │ ├── DwdTableSelector.tsx # DWD 表选择器
|
||||
│ │ ├── ErrorBoundary.tsx # 错误边界
|
||||
│ │ ├── LogStream.tsx # 实时日志流组件
|
||||
│ │ ├── ScheduleTab.tsx # 调度配置标签页
|
||||
│ │ ├── ScheduleTab.tsx # 调度配置标签页(含最小运行间隔、强制执行、上次成功时间)
|
||||
│ │ └── TaskSelector.tsx # 任务选择器
|
||||
│ ├── api/ # API 调用层
|
||||
│ │ ├── client.ts # Axios 实例(baseURL + JWT 拦截器)
|
||||
@@ -44,11 +54,17 @@ apps/admin-web/
|
||||
│ │ ├── etlStatus.ts # ETL 状态 API
|
||||
│ │ ├── dbViewer.ts # 数据库查看器 API
|
||||
│ │ ├── envConfig.ts # 环境配置 API
|
||||
│ │ └── opsPanel.ts # 运维面板 API
|
||||
│ │ ├── opsPanel.ts # 运维面板 API
|
||||
│ │ ├── registry.ts # 注册体系 API(租户/店铺/简写ID/同步)
|
||||
│ │ ├── tenantAdmins.ts # 租户管理员 CRUD API
|
||||
│ │ ├── adminAI.ts # AI 监控后台 API(Dashboard/调度/调用/缓存/预算/批量/告警)
|
||||
│ │ ├── devTrace.ts # DevTrace 全链路日志 API(日期/请求/详情/清理/设置/覆盖率)
|
||||
│ │ └── taskEngine.ts # P18 任务引擎运营看板 API(转移日志/待审核/参数管理,9 个函数)
|
||||
│ ├── store/
|
||||
│ │ ├── authStore.ts # Zustand 认证状态(JWT 持久化 + hydrate)
|
||||
│ │ └── businessDayStore.ts # 营业日状态管理
|
||||
│ └── types/ # TypeScript 类型定义
|
||||
│ ├── types/ # TypeScript 类型定义
|
||||
│ │ └── devTrace.ts # DevTrace 类型(TraceSpan/TraceRequest/TraceDetail/TraceSettings/TraceCoverage)
|
||||
├── index.html # HTML 入口
|
||||
├── vite.config.ts # Vite 配置
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
@@ -106,6 +122,84 @@ ETL 任务的核心配置界面:
|
||||
- 依赖同步(`uv sync`)
|
||||
- 系统资源概况(CPU、内存、磁盘)
|
||||
|
||||
### 租户管理员管理 (`/tenant-admins`)
|
||||
- 管理员列表(支持分页、关键词搜索、显示/隐藏已禁用记录)
|
||||
- 2 步创建流程:第 1 步选择租户 + 输入账号信息 + 选择管辖门店;第 2 步可选设置简写ID
|
||||
- 软删除管理员(二次确认 → `is_active=false`)
|
||||
- 编辑管理员(用户名可修改,需校验唯一性;所属租户只读)
|
||||
- 简写ID 管理弹窗:展示租户下所有店铺及当前 code,支持修改和查看变更历史
|
||||
- 手动触发店铺同步(从 ETL 库同步最新店铺信息)
|
||||
|
||||
### AI 运行总览 (`/ai/dashboard`)
|
||||
AI 模块运行状况一览:
|
||||
- 顶部统计卡片(今日调用次数、成功率、Token 消耗、平均延迟)
|
||||
- 近 7 天趋势折线图(调用量 + 成功率)
|
||||
- 各 App 调用占比饼图
|
||||
- Token 预算使用进度条(日/月)
|
||||
- 告警列表 + App 健康状态 + App 配置信息(只读)
|
||||
- 支持门店筛选
|
||||
|
||||
### AI 调度状态 (`/ai/trigger-jobs`)
|
||||
调度任务执行状态监控:
|
||||
- 分页表格(事件类型、会员、状态、执行链、耗时)
|
||||
- 筛选器(event_type / status / site_id / 日期范围)
|
||||
- 今日去重跳过数统计
|
||||
- 操作列:查看详情、手动重跑(二次确认)
|
||||
|
||||
### AI 调用明细 (`/ai/run-logs`)
|
||||
AI 调用记录追踪:
|
||||
- 分页表格(app_type、trigger_type、member_id、tokens、延迟、状态)
|
||||
- 筛选器(app_type / status / trigger_type / site_id / 日期范围)
|
||||
- 点击行展开详情抽屉(完整 prompt/response/error_message)
|
||||
|
||||
### AI 手动操作 (`/ai/operations`)
|
||||
AI 人工干预操作:
|
||||
- 手动重跑(App 选择 + 会员 + 门店 → 单次执行)
|
||||
- 缓存失效(按 App / 会员 / 门店批量失效)
|
||||
- 批量执行(成本二次确认流程:预估 → 确认弹窗 → 执行)
|
||||
- 告警管理(告警列表 + 确认/忽略操作)
|
||||
|
||||
### 开发调试全链路日志 (`/dev-trace`)
|
||||
后端请求全链路追踪日志的可视化查看与管理:
|
||||
- 覆盖率状态栏:路由/Service/Job/SSE/WS 五维度进度条 + 未覆盖项列表
|
||||
- 筛选栏:日期、时间范围、trace_type、HTTP 方法、路径关键词、状态码、最小耗时、仅错误、Span 类型
|
||||
- 请求列表:分页表格(时间/类型/方法/路径/状态/耗时/DB 查询数/错误标记)
|
||||
- Span 链路树:选中请求后展示完整 span 树,支持缩进层级、SQL 详情、参数、错误信息
|
||||
- 设置抽屉:日志开关、记录 SQL/参数、保留天数、日志目录、手动清理日期范围
|
||||
- 覆盖率扫描:手动触发 AST 扫描,检测追踪覆盖情况
|
||||
|
||||
对应后端模块 `apps/backend/app/trace/`,通过 8 个 admin API 端点(`/api/admin/dev-trace/*`)通信,需 admin 角色鉴权。
|
||||
|
||||
### 客户转移日志 (`/task-engine/transfer-log`)
|
||||
P18 任务引擎运营看板 — 转移日志页面:
|
||||
- 分页表格展示 `biz.coach_task_transfer_log` 记录
|
||||
- 筛选器:门店 ID、日期范围(RangePicker)、助教 ID
|
||||
- guard_checks JSON 渲染为彩色标签(通过/未通过)
|
||||
- transfer_reason 映射中文标签(连续召回失败/人工重新分配/归属变更)
|
||||
|
||||
### 待审核任务 (`/task-engine/pending-review`)
|
||||
P18 任务引擎运营看板 — 待审核任务页面:
|
||||
- 分页表格展示 `status='pending_review'` 的任务
|
||||
- 超级管理员操作列:重新分配(输入目标助教 ID)、关闭(填写原因)
|
||||
- 点击客户名打开转移历史抽屉
|
||||
- 门店管理员只读(无操作列)
|
||||
|
||||
### 参数管理 (`/task-engine/config`)
|
||||
P18 任务引擎运营看板 — 参数管理页面:
|
||||
- 展示 `biz.cfg_task_generator_params` 全局默认 + 门店覆盖参数
|
||||
- 行内编辑单个参数值
|
||||
- 权重参数(w_rs/w_ms/w_ml)使用卡片编辑弹窗,前端预校验三者之和 = 1.0
|
||||
- 新增门店覆盖(Select 选择参数名 + InputNumber 输入值)
|
||||
- 删除门店覆盖(全局默认参数禁止删除)
|
||||
- 超级管理员可编辑/新增/删除;门店管理员只读
|
||||
|
||||
### 定时任务管理 (`/trigger-jobs`)
|
||||
`biz.trigger_jobs` 表中定时任务的管理页面:
|
||||
- 表格展示所有定时任务(名称、触发条件、Cron/间隔配置、状态、上次执行时间)
|
||||
- 手动执行单个任务(二次确认)
|
||||
- 清空所有任务(Popconfirm 二次确认 + Modal 成功/失败反馈)
|
||||
- 执行期间按钮 loading 状态防止重复操作
|
||||
|
||||
## 认证与路由守卫
|
||||
|
||||
- 所有功能页面通过 `PrivateRoute` 组件保护
|
||||
@@ -162,3 +256,9 @@ ETL 任务的核心配置界面:
|
||||
- [ ] 权限管理界面(角色/权限配置)
|
||||
- [ ] 暗色主题支持
|
||||
- [ ] 国际化(i18n)
|
||||
- [x] 租户管理员 CRUD + 2 步创建 + 软删除(NS4.1)
|
||||
- [x] 注册体系管理 — 简写ID 管理 + 店铺同步(NS4.1)
|
||||
- [x] 调度任务最小运行间隔 + 强制执行(P16)
|
||||
- [x] AI 监控后台 — Dashboard + 调度状态 + 调用明细 + 手动操作(P15)
|
||||
- [x] 开发调试全链路日志 — DevTrace 页面 + 覆盖率扫描 + Span 树展示
|
||||
- [x] P18 任务引擎运营看板 — 转移日志 + 待审核任务 + 参数管理(3 页面 + 9 API)
|
||||
|
||||
68
apps/admin-web/e2e/dashboard.spec.ts
Normal file
68
apps/admin-web/e2e/dashboard.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Dashboard 页面 E2E 测试。
|
||||
*
|
||||
* 验证点:
|
||||
* - 4 个区块渲染(OpsPanel、DbHealthCard、AI 运行总览、AI 调度摘要)
|
||||
* - 跳转链接正确(ETL 状态详情、触发器详情、AI 调度详情)
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { injectAuth, mockAllApis } from './helpers';
|
||||
|
||||
test.describe('Dashboard 页面', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await injectAuth(page);
|
||||
await mockAllApis(page);
|
||||
await page.goto('/dashboard');
|
||||
// 等待页面标题渲染,确认 Dashboard 已加载
|
||||
await expect(page.locator('text=运行状态').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('4 个区块均渲染', async ({ page }) => {
|
||||
// 区块 1:OpsPanel 子组件(系统资源信息)
|
||||
// SystemResourceSection 会展示 CPU / 内存 / 磁盘等信息
|
||||
await expect(page.locator('text=CPU').first()).toBeVisible();
|
||||
|
||||
// 区块 2:数据库健康监控(DbHealthCard)
|
||||
await expect(page.locator('text=数据库').first()).toBeVisible();
|
||||
|
||||
// 区块 3:AI 运行总览
|
||||
await expect(page.locator('text=AI 运行总览').first()).toBeVisible();
|
||||
|
||||
// 区块 4:AI 调度摘要
|
||||
await expect(page.locator('text=AI 调度摘要').first()).toBeVisible();
|
||||
// 验证统计卡片存在
|
||||
await expect(page.locator('text=今日触发数')).toBeVisible();
|
||||
await expect(page.locator('text=今日成功率')).toBeVisible();
|
||||
await expect(page.locator('text=总记录数')).toBeVisible();
|
||||
});
|
||||
|
||||
test('ETL 状态详情跳转到 /etl-tasks?tab=status', async ({ page }) => {
|
||||
const btn = page.locator('button', { hasText: 'ETL 状态详情' });
|
||||
await expect(btn).toBeVisible();
|
||||
await btn.click();
|
||||
await expect(page).toHaveURL(/\/etl-tasks\?tab=status/);
|
||||
});
|
||||
|
||||
test('触发器详情跳转到 /triggers?tab=all', async ({ page }) => {
|
||||
const btn = page.locator('button', { hasText: '触发器详情' });
|
||||
await expect(btn).toBeVisible();
|
||||
await btn.click();
|
||||
await expect(page).toHaveURL(/\/triggers\?tab=all/);
|
||||
});
|
||||
|
||||
test('AI 调度详情跳转到 /triggers?tab=ai', async ({ page }) => {
|
||||
const btn = page.locator('button', { hasText: 'AI 调度详情' });
|
||||
await expect(btn).toBeVisible();
|
||||
await btn.click();
|
||||
await expect(page).toHaveURL(/\/triggers\?tab=ai/);
|
||||
});
|
||||
|
||||
test('AI 调度摘要底部链接跳转到 /triggers?tab=ai', async ({ page }) => {
|
||||
// 卡片底部的 "查看 AI 调度详情" 链接
|
||||
const link = page.locator('text=查看 AI 调度详情');
|
||||
await expect(link).toBeVisible();
|
||||
await link.click();
|
||||
await expect(page).toHaveURL(/\/triggers\?tab=ai/);
|
||||
});
|
||||
});
|
||||
97
apps/admin-web/e2e/etl-tasks.spec.ts
Normal file
97
apps/admin-web/e2e/etl-tasks.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* ETL 任务管理页面 E2E 测试。
|
||||
*
|
||||
* 验证点:
|
||||
* - 5 个 Tab 切换(config、queue、schedule、history、status)
|
||||
* - 各 Tab 内容渲染
|
||||
* - Tab 与 URL 查询参数同步
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { injectAuth, mockAllApis } from './helpers';
|
||||
|
||||
test.describe('ETL 任务管理页面', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await injectAuth(page);
|
||||
await mockAllApis(page);
|
||||
await page.goto('/etl-tasks');
|
||||
// 等待页面标题渲染
|
||||
await expect(page.locator('text=ETL 任务管理').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('默认显示 config Tab', async ({ page }) => {
|
||||
// 默认 Tab 为"发起"
|
||||
const configTab = page.locator('[role="tab"]', { hasText: '发起' });
|
||||
await expect(configTab).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('5 个 Tab 均可见', async ({ page }) => {
|
||||
await expect(page.locator('[role="tab"]', { hasText: '发起' })).toBeVisible();
|
||||
await expect(page.locator('[role="tab"]', { hasText: '队列' })).toBeVisible();
|
||||
await expect(page.locator('[role="tab"]', { hasText: '调度' })).toBeVisible();
|
||||
await expect(page.locator('[role="tab"]', { hasText: '历史' })).toBeVisible();
|
||||
await expect(page.locator('[role="tab"]', { hasText: '状态' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('切换到 queue Tab', async ({ page }) => {
|
||||
const queueTab = page.locator('[role="tab"]', { hasText: '队列' });
|
||||
await queueTab.click();
|
||||
await expect(queueTab).toHaveAttribute('aria-selected', 'true');
|
||||
// URL 同步
|
||||
await expect(page).toHaveURL(/\?tab=queue/);
|
||||
});
|
||||
|
||||
test('切换到 schedule Tab', async ({ page }) => {
|
||||
const scheduleTab = page.locator('[role="tab"]', { hasText: '调度' });
|
||||
await scheduleTab.click();
|
||||
await expect(scheduleTab).toHaveAttribute('aria-selected', 'true');
|
||||
// URL 同步
|
||||
await expect(page).toHaveURL(/\?tab=schedule/);
|
||||
});
|
||||
|
||||
test('切换到 history Tab', async ({ page }) => {
|
||||
const historyTab = page.locator('[role="tab"]', { hasText: '历史' });
|
||||
await historyTab.click();
|
||||
await expect(historyTab).toHaveAttribute('aria-selected', 'true');
|
||||
// URL 同步
|
||||
await expect(page).toHaveURL(/\?tab=history/);
|
||||
});
|
||||
|
||||
test('切换到 status Tab', async ({ page }) => {
|
||||
const statusTab = page.locator('[role="tab"]', { hasText: '状态' });
|
||||
await statusTab.click();
|
||||
await expect(statusTab).toHaveAttribute('aria-selected', 'true');
|
||||
// URL 同步
|
||||
await expect(page).toHaveURL(/\?tab=status/);
|
||||
});
|
||||
|
||||
test('通过 URL 直接访问 queue Tab', async ({ page }) => {
|
||||
await page.goto('/etl-tasks?tab=queue');
|
||||
const queueTab = page.locator('[role="tab"]', { hasText: '队列' });
|
||||
await expect(queueTab).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('通过 URL 直接访问 schedule Tab', async ({ page }) => {
|
||||
await page.goto('/etl-tasks?tab=schedule');
|
||||
const scheduleTab = page.locator('[role="tab"]', { hasText: '调度' });
|
||||
await expect(scheduleTab).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('通过 URL 直接访问 history Tab', async ({ page }) => {
|
||||
await page.goto('/etl-tasks?tab=history');
|
||||
const historyTab = page.locator('[role="tab"]', { hasText: '历史' });
|
||||
await expect(historyTab).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('通过 URL 直接访问 status Tab', async ({ page }) => {
|
||||
await page.goto('/etl-tasks?tab=status');
|
||||
const statusTab = page.locator('[role="tab"]', { hasText: '状态' });
|
||||
await expect(statusTab).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('无效 tab 参数回退到默认 config', async ({ page }) => {
|
||||
await page.goto('/etl-tasks?tab=invalid');
|
||||
const configTab = page.locator('[role="tab"]', { hasText: '发起' });
|
||||
await expect(configTab).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
});
|
||||
230
apps/admin-web/e2e/helpers.ts
Normal file
230
apps/admin-web/e2e/helpers.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* E2E 测试公共辅助:注入 JWT 令牌 + 通用 API mock。
|
||||
*
|
||||
* 认证方式:向 localStorage 写入伪造的 access_token / refresh_token,
|
||||
* 与 authStore.hydrate() 逻辑一致,页面加载后自动识别为已登录状态。
|
||||
*/
|
||||
|
||||
import { type Page } from '@playwright/test';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 伪造 JWT(payload 可被 authStore.parseJwtPayload 正确解析) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function makeFakeJwt(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
||||
const body = btoa(JSON.stringify(payload));
|
||||
const sig = 'fake_signature';
|
||||
return `${header}.${body}.${sig}`;
|
||||
}
|
||||
|
||||
const FAKE_ACCESS_TOKEN = makeFakeJwt({
|
||||
user_id: 1,
|
||||
username: 'admin',
|
||||
display_name: '测试管理员',
|
||||
site_id: 1,
|
||||
roles: ['admin'],
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
});
|
||||
|
||||
const FAKE_REFRESH_TOKEN = 'fake_refresh_token';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 注入登录状态 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* 在页面导航前注入 localStorage 令牌,模拟已登录状态。
|
||||
* 必须在 page.goto() 之前调用。
|
||||
*/
|
||||
export async function injectAuth(page: Page): Promise<void> {
|
||||
// 先访问 baseURL 以获得同源 localStorage 访问权限
|
||||
await page.goto('/login', { waitUntil: 'commit' });
|
||||
await page.evaluate(
|
||||
([accessToken, refreshToken]) => {
|
||||
localStorage.setItem('access_token', accessToken);
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
},
|
||||
[FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN] as const,
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 通用 API mock — 拦截所有 /api/** 请求返回空数据 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 为所有 /api/ 请求注册兜底 mock,避免真实网络调用 */
|
||||
export async function mockAllApis(page: Page): Promise<void> {
|
||||
// 运维面板
|
||||
await page.route('**/api/ops/system-info', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
cpu_percent: 25.0,
|
||||
memory_percent: 60.0,
|
||||
disk_percent: 45.0,
|
||||
uptime_seconds: 86400,
|
||||
platform: 'Windows',
|
||||
python_version: '3.10.0',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route('**/api/ops/services', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: [
|
||||
{ name: 'backend', env: 'prod', status: 'running', pid: 1234, port: 8000 },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route('**/api/ops/git-info', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: [
|
||||
{ env: 'prod', branch: 'main', commit: 'abc1234', dirty: false },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// 数据库健康
|
||||
await page.route('**/api/db-health**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: [
|
||||
{ db_name: 'etl_feiqiu', status: 'healthy', latency_ms: 5, details: null },
|
||||
{ db_name: 'zqyy_app', status: 'healthy', latency_ms: 3, details: null },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// AI 调度摘要(trigger jobs)
|
||||
await page.route('**/api/admin/ai/trigger-jobs**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: { items: [], total: 0 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// AI Dashboard 相关
|
||||
await page.route('**/api/admin/ai/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ code: 0, data: [] }),
|
||||
}),
|
||||
);
|
||||
|
||||
// 统一触发器
|
||||
await page.route('**/api/triggers/unified**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: [
|
||||
{
|
||||
id: 1, name: '测试触发器', source: 'biz',
|
||||
trigger_condition: 'cron', status: 'running',
|
||||
last_run_at: '2026-01-01T00:00:00', next_run_at: '2026-01-02T00:00:00',
|
||||
last_error: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// 业务触发器
|
||||
await page.route('**/api/trigger-jobs**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: [
|
||||
{
|
||||
id: 1, job_name: 'test_job', description: '测试任务',
|
||||
trigger_condition: 'cron', trigger_config: { cron_expression: '0 */2 * * *' },
|
||||
status: 'enabled', last_run_at: null, next_run_at: null, last_error: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// ETL 调度任务
|
||||
await page.route('**/api/schedules**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: [
|
||||
{
|
||||
id: 1, name: 'ETL 日常同步', task_codes: ['ODS_LOAD'],
|
||||
enabled: true, last_status: 'success',
|
||||
last_run_at: '2026-01-01T00:00:00', next_run_at: '2026-01-02T00:00:00',
|
||||
run_count: 100, created_at: '2025-01-01T00:00:00',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// 执行队列(Footer 状态栏)
|
||||
await page.route('**/api/execution/queue**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ code: 0, data: [] }),
|
||||
}),
|
||||
);
|
||||
|
||||
// ETL 任务配置
|
||||
await page.route('**/api/task-config**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ code: 0, data: [] }),
|
||||
}),
|
||||
);
|
||||
|
||||
// ETL 状态
|
||||
await page.route('**/api/etl-status**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ code: 0, data: [] }),
|
||||
}),
|
||||
);
|
||||
|
||||
// 兜底:其他未匹配的 /api/ 请求返回空成功
|
||||
await page.route('**/api/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ code: 0, data: [] }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
122
apps/admin-web/e2e/navigation.spec.ts
Normal file
122
apps/admin-web/e2e/navigation.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 导航与路由 E2E 测试。
|
||||
*
|
||||
* 验证点:
|
||||
* - 默认路由 / → /dashboard 重定向
|
||||
* - /log-viewer → /etl-tasks?tab=manager 重定向
|
||||
* - 菜单高亮
|
||||
* - Tab-URL 同步
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { injectAuth, mockAllApis } from './helpers';
|
||||
|
||||
test.describe('路由重定向', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await injectAuth(page);
|
||||
await mockAllApis(page);
|
||||
});
|
||||
|
||||
test('/ 重定向到 /dashboard', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
// 确认 Dashboard 页面已渲染
|
||||
await expect(page.locator('text=运行状态').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('/log-viewer 重定向到 /etl-tasks?tab=queue', async ({ page }) => {
|
||||
await page.goto('/log-viewer');
|
||||
await expect(page).toHaveURL(/\/etl-tasks\?tab=queue/);
|
||||
});
|
||||
|
||||
test('未登录时重定向到 /login', async ({ page }) => {
|
||||
// 清除 localStorage 中的令牌
|
||||
await page.goto('/login', { waitUntil: 'commit' });
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
});
|
||||
await page.goto('/dashboard');
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('菜单高亮', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await injectAuth(page);
|
||||
await mockAllApis(page);
|
||||
});
|
||||
|
||||
test('Dashboard 页面菜单高亮"运行状态"', async ({ page }) => {
|
||||
await page.goto('/dashboard');
|
||||
await expect(page.locator('text=运行状态').first()).toBeVisible();
|
||||
// Ant Design Menu 选中项会有 ant-menu-item-selected 类
|
||||
const menuItem = page.locator('.ant-menu-item-selected', { hasText: '运行状态' });
|
||||
await expect(menuItem).toBeVisible();
|
||||
});
|
||||
|
||||
test('ETL 任务页面菜单高亮"ETL 任务管理"', async ({ page }) => {
|
||||
await page.goto('/etl-tasks');
|
||||
const menuItem = page.locator('.ant-menu-item-selected', { hasText: 'ETL 任务管理' });
|
||||
await expect(menuItem).toBeVisible();
|
||||
});
|
||||
|
||||
test('触发器管理页面菜单高亮"触发器管理"', async ({ page }) => {
|
||||
await page.goto('/triggers');
|
||||
const menuItem = page.locator('.ant-menu-item-selected', { hasText: '触发器管理' });
|
||||
await expect(menuItem).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Tab-URL 同步', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await injectAuth(page);
|
||||
await mockAllApis(page);
|
||||
});
|
||||
|
||||
test('ETL 任务页 Tab 切换同步 URL', async ({ page }) => {
|
||||
await page.goto('/etl-tasks');
|
||||
await expect(page.locator('text=ETL 任务管理').first()).toBeVisible();
|
||||
|
||||
// 点击"队列"Tab
|
||||
await page.locator('[role="tab"]', { hasText: '队列' }).click();
|
||||
await expect(page).toHaveURL(/\?tab=queue/);
|
||||
|
||||
// 点击"调度"Tab
|
||||
await page.locator('[role="tab"]', { hasText: '调度' }).click();
|
||||
await expect(page).toHaveURL(/\?tab=schedule/);
|
||||
|
||||
// 点击"历史"Tab
|
||||
await page.locator('[role="tab"]', { hasText: '历史' }).click();
|
||||
await expect(page).toHaveURL(/\?tab=history/);
|
||||
|
||||
// 点击"状态"Tab
|
||||
await page.locator('[role="tab"]', { hasText: '状态' }).click();
|
||||
await expect(page).toHaveURL(/\?tab=status/);
|
||||
|
||||
// 点击"发起"Tab 回到默认
|
||||
await page.locator('[role="tab"]', { hasText: '发起' }).click();
|
||||
await expect(page).toHaveURL(/\?tab=config/);
|
||||
});
|
||||
|
||||
test('触发器管理页 Tab 切换同步 URL', async ({ page }) => {
|
||||
await page.goto('/triggers');
|
||||
await expect(page.locator('text=触发器管理').first()).toBeVisible();
|
||||
|
||||
// 点击"业务"Tab
|
||||
await page.locator('[role="tab"]', { hasText: '业务' }).click();
|
||||
await expect(page).toHaveURL(/\?tab=biz/);
|
||||
|
||||
// 点击"AI"Tab
|
||||
await page.locator('[role="tab"]', { hasText: 'AI' }).click();
|
||||
await expect(page).toHaveURL(/\?tab=ai/);
|
||||
|
||||
// 点击"ETL"Tab
|
||||
await page.locator('[role="tab"]', { hasText: 'ETL' }).click();
|
||||
await expect(page).toHaveURL(/\?tab=etl/);
|
||||
|
||||
// 回到"全部"Tab
|
||||
await page.locator('[role="tab"]', { hasText: '全部' }).click();
|
||||
await expect(page).toHaveURL(/\?tab=all/);
|
||||
});
|
||||
});
|
||||
85
apps/admin-web/e2e/trigger-manager.spec.ts
Normal file
85
apps/admin-web/e2e/trigger-manager.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 触发器管理页面 E2E 测试。
|
||||
*
|
||||
* 验证点:
|
||||
* - 4 个 Tab(全部、业务、AI、ETL)
|
||||
* - 统一视图数据展示
|
||||
* - 业务 Tab 编辑功能存在
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { injectAuth, mockAllApis } from './helpers';
|
||||
|
||||
test.describe('触发器管理页面', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await injectAuth(page);
|
||||
await mockAllApis(page);
|
||||
await page.goto('/triggers');
|
||||
// 等待页面标题渲染
|
||||
await expect(page.locator('text=触发器管理').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('4 个 Tab 均可见', async ({ page }) => {
|
||||
await expect(page.locator('[role="tab"]', { hasText: '全部' })).toBeVisible();
|
||||
await expect(page.locator('[role="tab"]', { hasText: '业务' })).toBeVisible();
|
||||
await expect(page.locator('[role="tab"]', { hasText: 'AI' })).toBeVisible();
|
||||
await expect(page.locator('[role="tab"]', { hasText: 'ETL' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('默认显示"全部"Tab', async ({ page }) => {
|
||||
const allTab = page.locator('[role="tab"]', { hasText: '全部' });
|
||||
await expect(allTab).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('统一视图表格展示数据', async ({ page }) => {
|
||||
// "全部"Tab 的统一视图表格应展示 mock 数据
|
||||
// 等待表格渲染
|
||||
await expect(page.locator('table').first()).toBeVisible();
|
||||
// 验证 mock 数据中的触发器名称
|
||||
await expect(page.locator('text=测试触发器')).toBeVisible();
|
||||
// 验证类型标签
|
||||
await expect(page.locator('text=业务')).toBeVisible();
|
||||
});
|
||||
|
||||
test('切换到业务 Tab 并验证编辑按钮', async ({ page }) => {
|
||||
const bizTab = page.locator('[role="tab"]', { hasText: '业务' });
|
||||
await bizTab.click();
|
||||
await expect(bizTab).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page).toHaveURL(/\?tab=biz/);
|
||||
|
||||
// 等待业务触发器表格加载
|
||||
await expect(page.locator('table').first()).toBeVisible();
|
||||
// 验证编辑按钮存在(mock 数据中 status=enabled,编辑按钮应可用)
|
||||
const editBtn = page.locator('button', { hasText: '编辑' }).first();
|
||||
await expect(editBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('切换到 AI Tab', async ({ page }) => {
|
||||
const aiTab = page.locator('[role="tab"]', { hasText: 'AI' });
|
||||
await aiTab.click();
|
||||
await expect(aiTab).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page).toHaveURL(/\?tab=ai/);
|
||||
});
|
||||
|
||||
test('切换到 ETL Tab', async ({ page }) => {
|
||||
const etlTab = page.locator('[role="tab"]', { hasText: 'ETL' });
|
||||
await etlTab.click();
|
||||
await expect(etlTab).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page).toHaveURL(/\?tab=etl/);
|
||||
|
||||
// 等待 ETL 调度表格加载
|
||||
await expect(page.locator('table').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('通过 URL 直接访问 biz Tab', async ({ page }) => {
|
||||
await page.goto('/triggers?tab=biz');
|
||||
const bizTab = page.locator('[role="tab"]', { hasText: '业务' });
|
||||
await expect(bizTab).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('通过 URL 直接访问 ai Tab', async ({ page }) => {
|
||||
await page.goto('/triggers?tab=ai');
|
||||
const aiTab = page.locator('[role="tab"]', { hasText: 'AI' });
|
||||
await expect(aiTab).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,8 @@
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "tsc --noEmit"
|
||||
"lint": "tsc --noEmit",
|
||||
"e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
@@ -22,11 +23,13 @@
|
||||
"zustand": "^5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"fast-check": "^4.6.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
|
||||
31
apps/admin-web/playwright.config.ts
Normal file
31
apps/admin-web/playwright.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Playwright E2E 测试配置。
|
||||
*
|
||||
* baseURL 指向 Vite 默认开发端口 5173。
|
||||
* 运行前需先启动 `pnpm dev`。
|
||||
*/
|
||||
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 5_000 },
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
49
apps/admin-web/pnpm-lock.yaml
generated
49
apps/admin-web/pnpm-lock.yaml
generated
@@ -31,6 +31,9 @@ dependencies:
|
||||
version: 5.0.5(@types/react@19.1.4)(react@19.1.0)
|
||||
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: ^1.58.2
|
||||
version: 1.58.2
|
||||
'@testing-library/jest-dom':
|
||||
specifier: ^6.6.3
|
||||
version: 6.6.3
|
||||
@@ -46,6 +49,9 @@ devDependencies:
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.5.2
|
||||
version: 4.5.2(vite@6.3.5)
|
||||
fast-check:
|
||||
specifier: ^4.6.0
|
||||
version: 4.6.0
|
||||
jsdom:
|
||||
specifier: ^26.1.0
|
||||
version: 26.1.0
|
||||
@@ -646,6 +652,14 @@ packages:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
dev: true
|
||||
|
||||
/@playwright/test@1.58.2:
|
||||
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
playwright: 1.58.2
|
||||
dev: true
|
||||
|
||||
/@rc-component/async-validator@5.1.0:
|
||||
resolution: {integrity: sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==}
|
||||
engines: {node: '>=14.x'}
|
||||
@@ -1527,6 +1541,13 @@ packages:
|
||||
engines: {node: '>=12.0.0'}
|
||||
dev: true
|
||||
|
||||
/fast-check@4.6.0:
|
||||
resolution: {integrity: sha512-h7H6Dm0Fy+H4ciQYFxFjXnXkzR2kr9Fb22c0UBpHnm59K2zpr2t13aPTHlltFiNT6zuxp6HMPAVVvgur4BLdpA==}
|
||||
engines: {node: '>=12.17.0'}
|
||||
dependencies:
|
||||
pure-rand: 8.3.0
|
||||
dev: true
|
||||
|
||||
/fdir@6.5.0(picomatch@4.0.3):
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1560,6 +1581,14 @@ packages:
|
||||
mime-types: 2.1.35
|
||||
dev: false
|
||||
|
||||
/fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -1823,6 +1852,22 @@ packages:
|
||||
engines: {node: '>=12'}
|
||||
dev: true
|
||||
|
||||
/playwright-core@1.58.2:
|
||||
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/playwright@1.58.2:
|
||||
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
playwright-core: 1.58.2
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
dev: true
|
||||
|
||||
/postcss@8.5.6:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -1850,6 +1895,10 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/pure-rand@8.3.0:
|
||||
resolution: {integrity: sha512-1ws1Ab8fnsf4bvpL+SujgBnr3KFs5abgCLVzavBp+f2n8Ld5YTOZlkv/ccYPhu3X9s+MEeqPRMqKlJz/kWDK8A==}
|
||||
dev: true
|
||||
|
||||
/rc-cascader@3.33.1(react-dom@19.1.0)(react@19.1.0):
|
||||
resolution: {integrity: sha512-Kyl4EJ7ZfCBuidmZVieegcbFw0RcU5bHHSbtEdmuLYd0fYHCAiYKZ6zon7fWAVyC6rWWOOib0XKdTSf7ElC9rg==}
|
||||
peerDependencies:
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
* 主布局与路由配置。
|
||||
*
|
||||
* - Ant Design Layout:Sider + Content + Footer(状态栏)
|
||||
* - react-router-dom:6 个功能页面路由 + 登录页路由
|
||||
* - react-router-dom:路由守卫 + 7 个一级菜单模块
|
||||
* - 路由守卫:未登录重定向到登录页
|
||||
*
|
||||
* CHANGE 2026-03-23 | Task 6 Change B:新增「定时任务」菜单项和 /trigger-jobs 路由;
|
||||
* 新增租户管理员、AI 监控子菜单组(4 子路由)、开发调试日志路由;
|
||||
* import 7 个新页面组件(TriggerJobs/TenantAdmins/AIDashboard/AITriggerJobs/AIRunLogs/AIOperations/DevTrace)
|
||||
* CHANGE 2026-07-14 | Task 7.1:侧边栏菜单从 11 个一级项重组为 7 个;
|
||||
* 新增 Dashboard/ETLTasks/TriggerManager 占位页面;路由重构(新增重定向、移动路由);
|
||||
* 移除 LogViewer 及不再直接路由的页面 import;登录后导航到 /dashboard
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
@@ -12,12 +19,12 @@ import { Layout, Menu, Spin, Space, Typography, Tag, Button, Tooltip } from "ant
|
||||
import {
|
||||
SettingOutlined,
|
||||
UnorderedListOutlined,
|
||||
ToolOutlined,
|
||||
DatabaseOutlined,
|
||||
DashboardOutlined,
|
||||
FileTextOutlined,
|
||||
ClockCircleOutlined,
|
||||
LogoutOutlined,
|
||||
DesktopOutlined,
|
||||
TeamOutlined,
|
||||
BugOutlined,
|
||||
ApartmentOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { MenuProps } from "antd";
|
||||
import { useAuthStore } from "./store/authStore";
|
||||
@@ -25,31 +32,85 @@ import { useBusinessDayStore } from "./store/businessDayStore";
|
||||
import { fetchQueue } from "./api/execution";
|
||||
import type { QueuedTask } from "./types";
|
||||
import Login from "./pages/Login";
|
||||
import TaskConfig from "./pages/TaskConfig";
|
||||
import TaskManager from "./pages/TaskManager";
|
||||
import EnvConfig from "./pages/EnvConfig";
|
||||
import DBViewer from "./pages/DBViewer";
|
||||
import ETLStatus from "./pages/ETLStatus";
|
||||
import LogViewer from "./pages/LogViewer";
|
||||
import OpsPanel from "./pages/OpsPanel";
|
||||
import TenantAdmins from "./pages/TenantAdmins";
|
||||
import AIRunLogs from "./pages/AIRunLogs";
|
||||
import DevTrace from "./pages/DevTrace";
|
||||
import TriggerJobs from "./pages/TriggerJobs";
|
||||
import TransferLog from "./pages/TransferLog";
|
||||
import PendingReview from "./pages/PendingReview";
|
||||
import TaskEngineConfig from "./pages/TaskEngineConfig";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import ETLTasks from "./pages/ETLTasks";
|
||||
import TriggerManager from "./pages/TriggerManager";
|
||||
|
||||
const { Sider, Content, Footer } = Layout;
|
||||
const { Text } = Typography;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 侧边栏导航配置 */
|
||||
/* 侧边栏导航配置(7 个一级菜单) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const NAV_ITEMS: MenuProps["items"] = [
|
||||
{ key: "/", icon: <SettingOutlined />, label: "任务配置" },
|
||||
{ key: "/task-manager", icon: <UnorderedListOutlined />, label: "任务管理" },
|
||||
{ key: "/etl-status", icon: <DashboardOutlined />, label: "ETL 状态" },
|
||||
{ key: "/db-viewer", icon: <DatabaseOutlined />, label: "数据库" },
|
||||
{ key: "/log-viewer", icon: <FileTextOutlined />, label: "日志" },
|
||||
{ key: "/env-config", icon: <ToolOutlined />, label: "环境配置" },
|
||||
{ key: "/ops-panel", icon: <DesktopOutlined />, label: "运维面板" },
|
||||
export const NAV_ITEMS: MenuProps["items"] = [
|
||||
{ key: "/dashboard", icon: <DashboardOutlined />, label: "运行状态" },
|
||||
{ key: "/etl-tasks", icon: <UnorderedListOutlined />, label: "ETL 任务管理" },
|
||||
{
|
||||
key: "task-engine-group", icon: <ApartmentOutlined />, label: "小程序任务管理",
|
||||
children: [
|
||||
{ key: "/task-engine/trigger-jobs", label: "定时任务" },
|
||||
{ key: "/task-engine/transfer-log", label: "转移日志" },
|
||||
{ key: "/task-engine/pending-review", label: "待审核任务" },
|
||||
{ key: "/task-engine/config", label: "参数管理" },
|
||||
],
|
||||
},
|
||||
{ key: "/triggers", icon: <ClockCircleOutlined />, label: "触发器管理" },
|
||||
{ key: "/tenant-admins", icon: <TeamOutlined />, label: "租户管理员" },
|
||||
{
|
||||
key: "settings-group", icon: <SettingOutlined />, label: "系统设置",
|
||||
children: [
|
||||
{ key: "/settings/env-config", label: "环境配置" },
|
||||
{ key: "/triggers?tab=biz", label: "触发器配置" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "logs-group", icon: <BugOutlined />, label: "日志调试",
|
||||
children: [
|
||||
{ key: "/logs/dev-trace", label: "DevTrace" },
|
||||
{ key: "/logs/ai-run-logs", label: "AI 调用明细" },
|
||||
{ key: "/logs/db-viewer", label: "数据库查看器" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 侧边栏高亮辅助函数 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 根据当前路径计算 selectedKeys */
|
||||
export function getSelectedKeys(pathname: string, search: string): string[] {
|
||||
const fullPath = pathname + search;
|
||||
// 精确匹配含查询参数的菜单项(如 /triggers?tab=biz)
|
||||
if (fullPath === "/triggers?tab=biz") return ["/triggers?tab=biz"];
|
||||
// 子路由匹配
|
||||
if (pathname.startsWith("/task-engine/")) return [pathname];
|
||||
if (pathname.startsWith("/settings/")) return [pathname];
|
||||
if (pathname.startsWith("/logs/")) return [pathname];
|
||||
// 一级路由直接匹配
|
||||
return [pathname];
|
||||
}
|
||||
|
||||
/** 根据当前路径计算 defaultOpenKeys */
|
||||
export function getDefaultOpenKeys(pathname: string): string[] {
|
||||
const keys: string[] = [];
|
||||
if (pathname.startsWith("/task-engine/")) keys.push("task-engine-group");
|
||||
if (pathname.startsWith("/settings/")) keys.push("settings-group");
|
||||
if (pathname.startsWith("/logs/")) keys.push("logs-group");
|
||||
// 触发器配置跳转入口也需要展开系统设置
|
||||
if (pathname === "/triggers") keys.push("settings-group");
|
||||
return keys;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 路由守卫 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -97,7 +158,15 @@ const AppLayout: React.FC = () => {
|
||||
<Layout style={{ minHeight: "100vh" }}>
|
||||
<Sider
|
||||
collapsible
|
||||
style={{ display: "flex", flexDirection: "column" }}
|
||||
style={{
|
||||
overflow: "auto",
|
||||
height: "100vh",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
left: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -118,7 +187,8 @@ const AppLayout: React.FC = () => {
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
selectedKeys={getSelectedKeys(location.pathname, location.search)}
|
||||
defaultOpenKeys={getDefaultOpenKeys(location.pathname)}
|
||||
items={NAV_ITEMS}
|
||||
onClick={onMenuClick}
|
||||
/>
|
||||
@@ -138,13 +208,31 @@ const AppLayout: React.FC = () => {
|
||||
<Layout>
|
||||
<Content style={{ margin: 16, minHeight: 280 }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<TaskConfig />} />
|
||||
<Route path="/task-manager" element={<TaskManager />} />
|
||||
<Route path="/env-config" element={<EnvConfig />} />
|
||||
<Route path="/db-viewer" element={<DBViewer />} />
|
||||
<Route path="/etl-status" element={<ETLStatus />} />
|
||||
<Route path="/log-viewer" element={<LogViewer />} />
|
||||
<Route path="/ops-panel" element={<OpsPanel />} />
|
||||
{/* 重定向 */}
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/log-viewer" element={<Navigate to="/etl-tasks?tab=queue" replace />} />
|
||||
|
||||
{/* 新页面 */}
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/etl-tasks" element={<ETLTasks />} />
|
||||
<Route path="/triggers" element={<TriggerManager />} />
|
||||
|
||||
{/* 小程序任务管理 */}
|
||||
<Route path="/task-engine/trigger-jobs" element={<TriggerJobs />} />
|
||||
<Route path="/task-engine/transfer-log" element={<TransferLog />} />
|
||||
<Route path="/task-engine/pending-review" element={<PendingReview />} />
|
||||
<Route path="/task-engine/config" element={<TaskEngineConfig />} />
|
||||
|
||||
{/* 系统设置 */}
|
||||
<Route path="/settings/env-config" element={<EnvConfig />} />
|
||||
|
||||
{/* 日志调试 */}
|
||||
<Route path="/logs/dev-trace" element={<DevTrace />} />
|
||||
<Route path="/logs/ai-run-logs" element={<AIRunLogs />} />
|
||||
<Route path="/logs/db-viewer" element={<DBViewer />} />
|
||||
|
||||
{/* 不变 */}
|
||||
<Route path="/tenant-admins" element={<TenantAdmins />} />
|
||||
</Routes>
|
||||
</Content>
|
||||
<Footer
|
||||
|
||||
299
apps/admin-web/src/__tests__/dashboard.test.tsx
Normal file
299
apps/admin-web/src/__tests__/dashboard.test.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 单元测试:Dashboard 页面 + DbHealthCard 组件
|
||||
*
|
||||
* _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5_
|
||||
*
|
||||
* - 测试 4 个区块正确渲染
|
||||
* - 测试跳转链接指向正确路由
|
||||
* - 测试 DbHealthCard connected/disconnected/timeout 状态渲染
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import React from "react";
|
||||
import DbHealthCard from "../components/DbHealthCard";
|
||||
import type { DbHealthItem } from "../api/dbHealth";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Ant Design jsdom 兼容:polyfill window.matchMedia */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mock 所有 Dashboard 依赖的 API 模块 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
vi.mock("../api/opsPanel", () => ({
|
||||
fetchSystemInfo: vi.fn().mockResolvedValue({
|
||||
cpu_percent: 25,
|
||||
memory_total_gb: 16,
|
||||
memory_used_gb: 8,
|
||||
memory_percent: 50,
|
||||
disk_total_gb: 500,
|
||||
disk_used_gb: 250,
|
||||
disk_percent: 50,
|
||||
boot_time: "2026-01-01T00:00:00",
|
||||
}),
|
||||
fetchServicesStatus: vi.fn().mockResolvedValue([
|
||||
{
|
||||
env: "prod",
|
||||
label: "生产环境",
|
||||
running: true,
|
||||
pid: 1234,
|
||||
port: 8000,
|
||||
uptime_seconds: 3600,
|
||||
memory_mb: 256,
|
||||
cpu_percent: 10,
|
||||
},
|
||||
]),
|
||||
fetchGitInfo: vi.fn().mockResolvedValue([
|
||||
{
|
||||
env: "prod",
|
||||
branch: "main",
|
||||
last_commit_hash: "abc1234",
|
||||
last_commit_message: "test commit",
|
||||
last_commit_time: "2026-01-01T00:00:00",
|
||||
has_local_changes: false,
|
||||
},
|
||||
]),
|
||||
startService: vi.fn(),
|
||||
stopService: vi.fn(),
|
||||
restartService: vi.fn(),
|
||||
gitPull: vi.fn(),
|
||||
syncDeps: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../api/dbHealth", () => ({
|
||||
fetchDbHealth: vi.fn().mockResolvedValue([
|
||||
{
|
||||
db_name: "etl_feiqiu",
|
||||
status: "connected",
|
||||
active_connections: 5,
|
||||
idle_connections: 10,
|
||||
db_size_mb: 128.5,
|
||||
slow_query_count: 2,
|
||||
},
|
||||
{
|
||||
db_name: "test_etl_feiqiu",
|
||||
status: "disconnected",
|
||||
active_connections: null,
|
||||
idle_connections: null,
|
||||
db_size_mb: null,
|
||||
slow_query_count: null,
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
vi.mock("../api/adminAI", () => ({
|
||||
getTriggerJobs: vi.fn().mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
today_skipped_duplicates: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock AIDashboard 为简单占位,避免其内部 API 调用
|
||||
vi.mock("../pages/AIDashboard", () => ({
|
||||
default: () => <div data-testid="ai-dashboard-mock">AIDashboard</div>,
|
||||
}));
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Dashboard 页面测试 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("Dashboard 页面 (Requirements 2.1, 2.2)", () => {
|
||||
let Dashboard: React.FC;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const mod = await import("../pages/Dashboard");
|
||||
Dashboard = mod.default;
|
||||
});
|
||||
|
||||
it("渲染 4 个区块:OpsPanel 子组件、DbHealthCard、AI 运行总览、AI 调度摘要", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
<Dashboard />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// 区块 1:OpsPanel 子组件 — 页面标题"运行状态"
|
||||
expect(await screen.findByText("运行状态")).toBeInTheDocument();
|
||||
|
||||
// 区块 2:数据库健康监控
|
||||
expect(screen.getByText("数据库健康监控")).toBeInTheDocument();
|
||||
|
||||
// 区块 3:AI 运行总览(mocked AIDashboard)
|
||||
expect(screen.getByText("AI 运行总览")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("ai-dashboard-mock")).toBeInTheDocument();
|
||||
|
||||
// 区块 4:AI 调度摘要(Divider + Card title 各出现一次)
|
||||
const summaryElements = screen.getAllByText("AI 调度摘要");
|
||||
expect(summaryElements.length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("今日触发数")).toBeInTheDocument();
|
||||
expect(screen.getByText("今日成功率")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("跳转链接:ETL 状态详情、触发器详情、AI 调度详情", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
<Dashboard />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await screen.findByText("运行状态");
|
||||
|
||||
expect(screen.getByText(/ETL 状态详情/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/触发器详情/)).toBeInTheDocument();
|
||||
|
||||
// AI 调度详情出现两次(顶部按钮 + 底部链接),取第一个
|
||||
const aiButtons = screen.getAllByText(/AI 调度详情/);
|
||||
expect(aiButtons.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* DbHealthCard 组件测试(纯展示组件,不需要 mock API) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("DbHealthCard — connected 状态 (Requirements 2.3, 2.4)", () => {
|
||||
const connectedItems: DbHealthItem[] = [
|
||||
{
|
||||
db_name: "etl_feiqiu",
|
||||
status: "connected",
|
||||
active_connections: 5,
|
||||
idle_connections: 10,
|
||||
db_size_mb: 128.5,
|
||||
slow_query_count: 2,
|
||||
},
|
||||
{
|
||||
db_name: "zqyy_app",
|
||||
status: "connected",
|
||||
active_connections: 3,
|
||||
idle_connections: 7,
|
||||
db_size_mb: 256.0,
|
||||
slow_query_count: 0,
|
||||
},
|
||||
];
|
||||
|
||||
it("为每个 connected 数据库渲染卡片,显示「已连接」标签", () => {
|
||||
render(<DbHealthCard items={connectedItems} />);
|
||||
|
||||
expect(screen.getByText("etl_feiqiu")).toBeInTheDocument();
|
||||
expect(screen.getByText("zqyy_app")).toBeInTheDocument();
|
||||
|
||||
const connectedTags = screen.getAllByText("已连接");
|
||||
expect(connectedTags).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("展示连接池指标(活跃/空闲连接数)", () => {
|
||||
render(<DbHealthCard items={connectedItems} />);
|
||||
|
||||
expect(screen.getByText(/活跃 5/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/空闲 10/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("展示数据库大小和慢查询数量", () => {
|
||||
render(<DbHealthCard items={connectedItems} />);
|
||||
|
||||
const sizeLabels = screen.getAllByText("数据库大小");
|
||||
expect(sizeLabels.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const slowLabels = screen.getAllByText(/慢查询/);
|
||||
expect(slowLabels.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DbHealthCard — disconnected 状态 (Requirement 2.5)", () => {
|
||||
const disconnectedItems: DbHealthItem[] = [
|
||||
{
|
||||
db_name: "test_etl_feiqiu",
|
||||
status: "disconnected",
|
||||
active_connections: null,
|
||||
idle_connections: null,
|
||||
db_size_mb: null,
|
||||
slow_query_count: null,
|
||||
},
|
||||
];
|
||||
|
||||
it("disconnected 数据库显示「未连接」标签", () => {
|
||||
render(<DbHealthCard items={disconnectedItems} />);
|
||||
|
||||
expect(screen.getByText("test_etl_feiqiu")).toBeInTheDocument();
|
||||
expect(screen.getByText("未连接")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disconnected 数据库显示无法获取指标提示", () => {
|
||||
render(<DbHealthCard items={disconnectedItems} />);
|
||||
|
||||
expect(screen.getByText("数据库未连接,无法获取指标")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DbHealthCard — timeout 状态", () => {
|
||||
it("超时时显示「加载超时」标签", () => {
|
||||
render(<DbHealthCard items={[]} timeout={true} />);
|
||||
|
||||
expect(screen.getByText("加载超时")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("超时时显示重试按钮,点击触发 onRetry", () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<DbHealthCard items={[]} timeout={true} onRetry={onRetry} />);
|
||||
|
||||
const retryBtn = screen.getByText("重试");
|
||||
expect(retryBtn).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(retryBtn);
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DbHealthCard — mixed 状态(connected + disconnected)", () => {
|
||||
const mixedItems: DbHealthItem[] = [
|
||||
{
|
||||
db_name: "etl_feiqiu",
|
||||
status: "connected",
|
||||
active_connections: 5,
|
||||
idle_connections: 10,
|
||||
db_size_mb: 128.5,
|
||||
slow_query_count: 2,
|
||||
},
|
||||
{
|
||||
db_name: "test_etl_feiqiu",
|
||||
status: "disconnected",
|
||||
active_connections: null,
|
||||
idle_connections: null,
|
||||
db_size_mb: null,
|
||||
slow_query_count: null,
|
||||
},
|
||||
];
|
||||
|
||||
it("同时渲染 connected 和 disconnected 卡片", () => {
|
||||
render(<DbHealthCard items={mixedItems} />);
|
||||
|
||||
expect(screen.getByText("已连接")).toBeInTheDocument();
|
||||
expect(screen.getByText("未连接")).toBeInTheDocument();
|
||||
expect(screen.getByText("etl_feiqiu")).toBeInTheDocument();
|
||||
expect(screen.getByText("test_etl_feiqiu")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
238
apps/admin-web/src/__tests__/etlTasks.test.tsx
Normal file
238
apps/admin-web/src/__tests__/etlTasks.test.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 单元测试:ETLTasks 页面
|
||||
*
|
||||
* _Requirements: 3.1, 3.2, 3.3, 3.4, 10.4_
|
||||
*
|
||||
* - 测试默认 Tab 为 config(发起)
|
||||
* - 测试 5 个 Tab 内容正确渲染
|
||||
* - 测试 URL 参数驱动 Tab 选择
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Ant Design jsdom 兼容:polyfill window.matchMedia */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mock 子组件,避免内部 API 调用 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
vi.mock("../pages/TaskConfig", () => ({
|
||||
default: () => <div data-testid="mock-task-config">TaskConfig Mock</div>,
|
||||
}));
|
||||
vi.mock("../pages/TaskManager", () => ({
|
||||
QueueTab: () => <div data-testid="mock-queue-tab">QueueTab Mock</div>,
|
||||
HistoryTab: () => <div data-testid="mock-history-tab">HistoryTab Mock</div>,
|
||||
}));
|
||||
vi.mock("../components/ScheduleTab", () => ({
|
||||
default: () => <div data-testid="mock-schedule-tab">ScheduleTab Mock</div>,
|
||||
}));
|
||||
vi.mock("../pages/ETLStatus", () => ({
|
||||
default: () => <div data-testid="mock-etl-status">ETLStatus Mock</div>,
|
||||
}));
|
||||
|
||||
import ETLTasks from "../pages/ETLTasks";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 测试:默认 Tab 为 config(Requirements 3.1, 3.2) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("ETLTasks — 默认 Tab (Requirements 3.1, 3.2)", () => {
|
||||
it("无 ?tab 参数时,默认激活 config Tab(发起)", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const activeTab = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab).not.toBeNull();
|
||||
expect(activeTab!.textContent).toContain("发起");
|
||||
});
|
||||
|
||||
it("无效 ?tab 参数时,回退到默认 config Tab", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=invalid"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const activeTab = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab).not.toBeNull();
|
||||
expect(activeTab!.textContent).toContain("发起");
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 测试:4 个 Tab 内容正确渲染(Requirements 3.2, 3.3, 3.4) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("ETLTasks — 5 个 Tab 内容渲染 (Requirements 3.2, 3.3, 3.4)", () => {
|
||||
it("config Tab 渲染 TaskConfig 组件", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=config"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("mock-task-config")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("queue Tab 渲染 QueueTab 组件", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=queue"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("mock-queue-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("schedule Tab 渲染 ScheduleTab 组件", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=schedule"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("mock-schedule-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("history Tab 渲染 HistoryTab 组件", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=history"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("mock-history-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("status Tab 渲染 ETLStatus 组件", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=status"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("mock-etl-status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("页面标题包含「ETL 任务管理」", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("ETL 任务管理")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("5 个 Tab 标签文本正确:发起、队列、调度、历史、状态", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const tabs = document.querySelectorAll(".ant-tabs-tab");
|
||||
expect(tabs).toHaveLength(5);
|
||||
|
||||
const tabTexts = Array.from(tabs).map((t) => t.textContent);
|
||||
expect(tabTexts).toContain("发起");
|
||||
expect(tabTexts).toContain("队列");
|
||||
expect(tabTexts).toContain("调度");
|
||||
expect(tabTexts).toContain("历史");
|
||||
expect(tabTexts).toContain("状态");
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 测试:URL 参数驱动 Tab 选择(Requirement 10.4) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("ETLTasks — URL 参数驱动 Tab 选择 (Requirement 10.4)", () => {
|
||||
it("?tab=config 激活「发起」Tab", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=config"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const activeTab = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab).not.toBeNull();
|
||||
expect(activeTab!.textContent).toContain("发起");
|
||||
});
|
||||
|
||||
it("?tab=queue 激活「队列」Tab", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=queue"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const activeTab = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab).not.toBeNull();
|
||||
expect(activeTab!.textContent).toContain("队列");
|
||||
});
|
||||
|
||||
it("?tab=schedule 激活「调度」Tab", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=schedule"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const activeTab = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab).not.toBeNull();
|
||||
expect(activeTab!.textContent).toContain("调度");
|
||||
});
|
||||
|
||||
it("?tab=history 激活「历史」Tab", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=history"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const activeTab = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab).not.toBeNull();
|
||||
expect(activeTab!.textContent).toContain("历史");
|
||||
});
|
||||
|
||||
it("?tab=status 激活「状态」Tab", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/etl-tasks?tab=status"]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const activeTab = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab).not.toBeNull();
|
||||
expect(activeTab!.textContent).toContain("状态");
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { filterLogLines } from "../pages/LogViewer";
|
||||
import { filterLogLines } from "../pages/_archived/LogViewer";
|
||||
|
||||
describe("filterLogLines — 日志过滤正确性", () => {
|
||||
/* ---- 1. 空关键词返回所有行 ---- */
|
||||
|
||||
140
apps/admin-web/src/__tests__/menuAndRedirects.test.tsx
Normal file
140
apps/admin-web/src/__tests__/menuAndRedirects.test.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 单元测试:菜单结构与路由重定向
|
||||
*
|
||||
* _Requirements: 1.1, 8.3, 10.1, 10.2_
|
||||
*
|
||||
* - 验证 NAV_ITEMS 包含 7 个一级菜单项且子项正确
|
||||
* - 验证 `/` 重定向到 `/dashboard`
|
||||
* - 验证 `/log-viewer` 重定向到 `/etl-tasks?tab=queue`
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { MemoryRouter, Routes, Route, Navigate, useLocation } from "react-router-dom";
|
||||
import { NAV_ITEMS } from "../App";
|
||||
import type { MenuProps } from "antd";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 菜单结构测试(纯数据,无 DOM) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type MenuItem = NonNullable<MenuProps["items"]>[number] & {
|
||||
key?: string;
|
||||
label?: string;
|
||||
children?: MenuItem[];
|
||||
};
|
||||
|
||||
describe("菜单结构验证 (Requirement 1.1)", () => {
|
||||
const items = NAV_ITEMS as MenuItem[];
|
||||
|
||||
it("NAV_ITEMS 包含 7 个一级菜单项", () => {
|
||||
expect(items).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("7 个一级菜单项的 label 和顺序正确", () => {
|
||||
const labels = items.map((item) => item.label);
|
||||
expect(labels).toEqual([
|
||||
"运行状态",
|
||||
"ETL 任务管理",
|
||||
"小程序任务管理",
|
||||
"触发器管理",
|
||||
"租户管理员",
|
||||
"系统设置",
|
||||
"日志调试",
|
||||
]);
|
||||
});
|
||||
|
||||
it("「小程序任务管理」包含 4 个子项:定时任务、转移日志、待审核任务、参数管理", () => {
|
||||
const taskEngine = items.find((i) => i.label === "小程序任务管理");
|
||||
expect(taskEngine).toBeDefined();
|
||||
const children = taskEngine!.children ?? [];
|
||||
expect(children).toHaveLength(4);
|
||||
expect(children.map((c) => c.label)).toEqual([
|
||||
"定时任务",
|
||||
"转移日志",
|
||||
"待审核任务",
|
||||
"参数管理",
|
||||
]);
|
||||
});
|
||||
|
||||
it("「系统设置」包含 2 个子项:环境配置、触发器配置", () => {
|
||||
const settings = items.find((i) => i.label === "系统设置");
|
||||
expect(settings).toBeDefined();
|
||||
const children = settings!.children ?? [];
|
||||
expect(children).toHaveLength(2);
|
||||
expect(children.map((c) => c.label)).toEqual(["环境配置", "触发器配置"]);
|
||||
});
|
||||
|
||||
it("「日志调试」包含 3 个子项:DevTrace、AI 调用明细、数据库查看器", () => {
|
||||
const logs = items.find((i) => i.label === "日志调试");
|
||||
expect(logs).toBeDefined();
|
||||
const children = logs!.children ?? [];
|
||||
expect(children).toHaveLength(3);
|
||||
expect(children.map((c) => c.label)).toEqual([
|
||||
"DevTrace",
|
||||
"AI 调用明细",
|
||||
"数据库查看器",
|
||||
]);
|
||||
});
|
||||
|
||||
it("无子项的一级菜单(运行状态、ETL 任务管理、触发器管理、租户管理员)没有 children", () => {
|
||||
const noChildrenLabels = ["运行状态", "ETL 任务管理", "触发器管理", "租户管理员"];
|
||||
for (const label of noChildrenLabels) {
|
||||
const item = items.find((i) => i.label === label);
|
||||
expect(item).toBeDefined();
|
||||
expect((item as MenuItem).children).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 路由重定向测试 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* 辅助组件:捕获当前 location 用于断言。
|
||||
* 渲染后通过 testId 读取 pathname + search。
|
||||
*/
|
||||
function LocationDisplay() {
|
||||
const location = useLocation();
|
||||
return (
|
||||
<div data-testid="location">
|
||||
{location.pathname}
|
||||
{location.search}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 最小路由配置:只包含重定向规则和一个 LocationDisplay 兜底,
|
||||
* 不需要渲染完整 App(避免 mock 大量依赖)。
|
||||
*/
|
||||
function RedirectTestApp() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/log-viewer" element={<Navigate to="/etl-tasks?tab=queue" replace />} />
|
||||
<Route path="*" element={<LocationDisplay />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
describe("路由重定向 (Requirements 8.3, 10.1, 10.2)", () => {
|
||||
it("/ 重定向到 /dashboard", () => {
|
||||
const { getByTestId } = render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<RedirectTestApp />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(getByTestId("location").textContent).toBe("/dashboard");
|
||||
});
|
||||
|
||||
it("/log-viewer 重定向到 /etl-tasks?tab=queue", () => {
|
||||
const { getByTestId } = render(
|
||||
<MemoryRouter initialEntries={["/log-viewer"]}>
|
||||
<RedirectTestApp />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(getByTestId("location").textContent).toBe("/etl-tasks?tab=queue");
|
||||
});
|
||||
});
|
||||
160
apps/admin-web/src/__tests__/sidebarHighlight.property.test.ts
Normal file
160
apps/admin-web/src/__tests__/sidebarHighlight.property.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 属性测试:侧边栏高亮与当前路由一致
|
||||
*
|
||||
* Feature: admin-web-restructure, Property 7: 侧边栏高亮与当前路由一致
|
||||
* **Validates: Requirements 10.3**
|
||||
*
|
||||
* 对于任意有效的应用路由路径,侧边栏中被高亮(selectedKeys)的菜单项
|
||||
* 应对应该路由所属的一级模块。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import * as fc from "fast-check";
|
||||
import { getSelectedKeys, NAV_ITEMS } from "../App";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 路由 → 一级模块映射(从 NAV_ITEMS 和路由配置提取) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* 所有有效路由及其所属一级模块 key 的映射。
|
||||
* 一级模块 key 定义:
|
||||
* - 无子菜单的项:直接用 item.key(如 "/dashboard")
|
||||
* - 有子菜单的项:用 group key(如 "task-engine-group")
|
||||
*/
|
||||
const ROUTE_TO_MODULE: Array<{
|
||||
pathname: string;
|
||||
search: string;
|
||||
moduleKey: string;
|
||||
/** 该路由在菜单中对应的 selectedKey */
|
||||
expectedSelectedKey: string;
|
||||
}> = [
|
||||
// 运行状态
|
||||
{ pathname: "/dashboard", search: "", moduleKey: "/dashboard", expectedSelectedKey: "/dashboard" },
|
||||
// ETL 任务管理
|
||||
{ pathname: "/etl-tasks", search: "", moduleKey: "/etl-tasks", expectedSelectedKey: "/etl-tasks" },
|
||||
{ pathname: "/etl-tasks", search: "?tab=config", moduleKey: "/etl-tasks", expectedSelectedKey: "/etl-tasks" },
|
||||
{ pathname: "/etl-tasks", search: "?tab=queue", moduleKey: "/etl-tasks", expectedSelectedKey: "/etl-tasks" },
|
||||
{ pathname: "/etl-tasks", search: "?tab=schedule", moduleKey: "/etl-tasks", expectedSelectedKey: "/etl-tasks" },
|
||||
{ pathname: "/etl-tasks", search: "?tab=history", moduleKey: "/etl-tasks", expectedSelectedKey: "/etl-tasks" },
|
||||
{ pathname: "/etl-tasks", search: "?tab=status", moduleKey: "/etl-tasks", expectedSelectedKey: "/etl-tasks" },
|
||||
// 小程序任务管理
|
||||
{ pathname: "/task-engine/trigger-jobs", search: "", moduleKey: "task-engine-group", expectedSelectedKey: "/task-engine/trigger-jobs" },
|
||||
{ pathname: "/task-engine/transfer-log", search: "", moduleKey: "task-engine-group", expectedSelectedKey: "/task-engine/transfer-log" },
|
||||
{ pathname: "/task-engine/pending-review", search: "", moduleKey: "task-engine-group", expectedSelectedKey: "/task-engine/pending-review" },
|
||||
{ pathname: "/task-engine/config", search: "", moduleKey: "task-engine-group", expectedSelectedKey: "/task-engine/config" },
|
||||
// 触发器管理
|
||||
{ pathname: "/triggers", search: "", moduleKey: "/triggers", expectedSelectedKey: "/triggers" },
|
||||
{ pathname: "/triggers", search: "?tab=all", moduleKey: "/triggers", expectedSelectedKey: "/triggers" },
|
||||
// 特殊情况:/triggers?tab=biz 同时是"系统设置 > 触发器配置"的快捷入口,
|
||||
// getSelectedKeys 会精确匹配到 settings-group 下的子项
|
||||
{ pathname: "/triggers", search: "?tab=biz", moduleKey: "settings-group", expectedSelectedKey: "/triggers?tab=biz" },
|
||||
{ pathname: "/triggers", search: "?tab=ai", moduleKey: "/triggers", expectedSelectedKey: "/triggers" },
|
||||
{ pathname: "/triggers", search: "?tab=etl", moduleKey: "/triggers", expectedSelectedKey: "/triggers" },
|
||||
// 租户管理员
|
||||
{ pathname: "/tenant-admins", search: "", moduleKey: "/tenant-admins", expectedSelectedKey: "/tenant-admins" },
|
||||
// 系统设置
|
||||
{ pathname: "/settings/env-config", search: "", moduleKey: "settings-group", expectedSelectedKey: "/settings/env-config" },
|
||||
// 日志调试
|
||||
{ pathname: "/logs/dev-trace", search: "", moduleKey: "logs-group", expectedSelectedKey: "/logs/dev-trace" },
|
||||
{ pathname: "/logs/ai-run-logs", search: "", moduleKey: "logs-group", expectedSelectedKey: "/logs/ai-run-logs" },
|
||||
{ pathname: "/logs/db-viewer", search: "", moduleKey: "logs-group", expectedSelectedKey: "/logs/db-viewer" },
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 辅助:从 NAV_ITEMS 构建 selectedKey → moduleKey 的反查表 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 收集所有菜单叶子节点的 key,映射到其所属一级模块 key */
|
||||
function buildKeyToModuleMap(): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const item of NAV_ITEMS ?? []) {
|
||||
if (!item || !("key" in item)) continue;
|
||||
const topKey = item.key as string;
|
||||
if ("children" in item && item.children) {
|
||||
// 有子菜单:子项 key → group key
|
||||
for (const child of item.children) {
|
||||
if (child && "key" in child) {
|
||||
map.set(child.key as string, topKey);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 无子菜单:自身 key → 自身 key
|
||||
map.set(topKey, topKey);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const KEY_TO_MODULE = buildKeyToModuleMap();
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 属性测试 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("Property 7: 侧边栏高亮与当前路由一致", () => {
|
||||
// 生成器:从所有有效路由中随机选取
|
||||
const routeArb = fc.constantFrom(...ROUTE_TO_MODULE);
|
||||
|
||||
it("对任意有效路由,selectedKeys 应包含该路由对应的菜单项 key", () => {
|
||||
fc.assert(
|
||||
fc.property(routeArb, (route) => {
|
||||
const selectedKeys = getSelectedKeys(route.pathname, route.search);
|
||||
|
||||
// selectedKeys 不应为空
|
||||
expect(selectedKeys.length).toBeGreaterThan(0);
|
||||
|
||||
// selectedKeys 中应包含预期的 key
|
||||
expect(selectedKeys).toContain(route.expectedSelectedKey);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("对任意有效路由,selectedKeys 对应的一级模块应与路由所属模块一致", () => {
|
||||
fc.assert(
|
||||
fc.property(routeArb, (route) => {
|
||||
const selectedKeys = getSelectedKeys(route.pathname, route.search);
|
||||
const selectedKey = selectedKeys[0];
|
||||
|
||||
// 查找 selectedKey 所属的一级模块
|
||||
const actualModule = KEY_TO_MODULE.get(selectedKey);
|
||||
|
||||
// 如果 selectedKey 不在菜单叶子节点中(如一级路由直接匹配),
|
||||
// 则 selectedKey 本身就是模块 key
|
||||
const resolvedModule = actualModule ?? selectedKey;
|
||||
|
||||
expect(resolvedModule).toBe(route.moduleKey);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("NAV_ITEMS 应包含 7 个一级菜单项", () => {
|
||||
expect(NAV_ITEMS).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("所有有效路由的 selectedKey 都能在 NAV_ITEMS 中找到对应菜单项", () => {
|
||||
fc.assert(
|
||||
fc.property(routeArb, (route) => {
|
||||
const selectedKeys = getSelectedKeys(route.pathname, route.search);
|
||||
const selectedKey = selectedKeys[0];
|
||||
|
||||
// selectedKey 必须存在于菜单的叶子节点或一级节点中
|
||||
const allMenuKeys = new Set<string>();
|
||||
for (const item of NAV_ITEMS ?? []) {
|
||||
if (!item || !("key" in item)) continue;
|
||||
allMenuKeys.add(item.key as string);
|
||||
if ("children" in item && item.children) {
|
||||
for (const child of item.children) {
|
||||
if (child && "key" in child) allMenuKeys.add(child.key as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(allMenuKeys.has(selectedKey)).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 属性测试:Tab 切换状态保持 round-trip
|
||||
*
|
||||
* Feature: admin-web-restructure, Property 2: Tab 切换状态保持 round-trip
|
||||
* **Validates: Requirements 3.5**
|
||||
*
|
||||
* 对于任意 ETL 任务管理的 Tab 视图,在某个 Tab 中设置筛选条件后
|
||||
* 切换到另一个 Tab 再切回来,原 Tab 的筛选条件应保持不变。
|
||||
*
|
||||
* 策略:Mock 子组件为带 text input 的有状态组件,
|
||||
* 通过输入文本 → 切换 Tab → 切回 → 验证文本保留来证明状态保持。
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import * as fc from "fast-check";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mock 子组件:带有状态的简单输入框 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function StatefulTab({ tabKey }: { tabKey: string }) {
|
||||
const [value, setValue] = useState("");
|
||||
return (
|
||||
<div data-testid={`tab-panel-${tabKey}`}>
|
||||
<input
|
||||
data-testid={`state-input-${tabKey}`}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
vi.mock("../pages/TaskConfig", () => ({
|
||||
default: () => <StatefulTab tabKey="config" />,
|
||||
}));
|
||||
vi.mock("../pages/TaskManager", () => ({
|
||||
default: () => <StatefulTab tabKey="manager" />,
|
||||
}));
|
||||
vi.mock("../pages/ETLStatus", () => ({
|
||||
default: () => <StatefulTab tabKey="status" />,
|
||||
}));
|
||||
|
||||
import ETLTasks from "../pages/ETLTasks";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 常量 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const VALID_TABS = ["config", "manager", "status"] as const;
|
||||
type TabKey = (typeof VALID_TABS)[number];
|
||||
|
||||
const TAB_LABELS: Record<TabKey, string> = {
|
||||
config: "任务配置",
|
||||
manager: "任务管理",
|
||||
status: "ETL 状态",
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 辅助函数 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 点击指定 Tab */
|
||||
function clickTab(tabKey: TabKey) {
|
||||
const tabElements = document.querySelectorAll(".ant-tabs-tab");
|
||||
for (const el of tabElements) {
|
||||
if (el.textContent?.includes(TAB_LABELS[tabKey])) {
|
||||
fireEvent.click(el);
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(`Tab "${tabKey}" not found`);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 每次测试后清理 DOM */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 属性测试 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
// DOM 渲染属性测试:numRuns=20 覆盖所有 tab 组合多次,避免 jsdom 超时
|
||||
const PBT_NUM_RUNS = 20;
|
||||
const PBT_TIMEOUT = 30_000;
|
||||
|
||||
describe("Property 2: Tab 切换状态保持 round-trip", () => {
|
||||
// 生成器:从有效 tab 值中随机选取两个不同的 tab
|
||||
const distinctTabPairArb = fc
|
||||
.tuple(fc.constantFrom(...VALID_TABS), fc.constantFrom(...VALID_TABS))
|
||||
.filter(([a, b]) => a !== b);
|
||||
|
||||
// 生成器:模拟用户输入的筛选条件文本
|
||||
// 使用 constantFrom 避免 fast-check v4 API 兼容性问题(char/stringOf 已移除)
|
||||
const inputTextArb = fc.constantFrom(
|
||||
"hello", "test-filter", "ETL_001", "搜索关键词", "abc123",
|
||||
"x", "long-filter-value-example", "special!@#", " spaces ", "UPPER",
|
||||
);
|
||||
|
||||
it("在某 Tab 设置状态 → 切换到另一 Tab → 切回 → 状态保持不变", () => {
|
||||
fc.assert(
|
||||
fc.property(distinctTabPairArb, inputTextArb, ([sourceTab, otherTab], text) => {
|
||||
cleanup();
|
||||
|
||||
// 渲染页面,初始 Tab 为 sourceTab
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`/etl-tasks?tab=${sourceTab}`]}>
|
||||
<ETLTasks />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// 1. 在 sourceTab 的输入框中输入文本
|
||||
const input = screen.getByTestId(`state-input-${sourceTab}`) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: text } });
|
||||
expect(input.value).toBe(text);
|
||||
|
||||
// 2. 切换到 otherTab
|
||||
clickTab(otherTab);
|
||||
|
||||
// 3. 切回 sourceTab
|
||||
clickTab(sourceTab);
|
||||
|
||||
// 4. 验证 sourceTab 的输入框文本保持不变
|
||||
const inputAfter = screen.getByTestId(`state-input-${sourceTab}`) as HTMLInputElement;
|
||||
expect(inputAfter.value).toBe(text);
|
||||
|
||||
cleanup();
|
||||
}),
|
||||
{ numRuns: PBT_NUM_RUNS },
|
||||
);
|
||||
}, PBT_TIMEOUT);
|
||||
});
|
||||
191
apps/admin-web/src/__tests__/tabUrlSync.property.test.tsx
Normal file
191
apps/admin-web/src/__tests__/tabUrlSync.property.test.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 属性测试:Tab 切换与 URL 查询参数同步 round-trip
|
||||
*
|
||||
* Feature: admin-web-restructure, Property 8: Tab 切换与 URL 查询参数同步 round-trip
|
||||
* **Validates: Requirements 10.4**
|
||||
*
|
||||
* 对于任意 Tab 视图页面,设置 URL 查询参数 `?tab=X` 后渲染页面,
|
||||
* 当前激活的 Tab 应为 X;点击 Tab Y 后,URL 查询参数应更新为 `?tab=Y`。
|
||||
*
|
||||
* 当前仅测试 ETLTasks(TriggerManager 待后续任务创建后扩展)。
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import * as fc from "fast-check";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mock 子组件,避免内部 API 调用 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
vi.mock("../pages/TaskConfig", () => ({
|
||||
default: () => <div data-testid="tab-content-config">TaskConfig</div>,
|
||||
}));
|
||||
vi.mock("../pages/TaskManager", () => ({
|
||||
QueueTab: () => <div data-testid="tab-content-queue">QueueTab</div>,
|
||||
HistoryTab: () => <div data-testid="tab-content-history">HistoryTab</div>,
|
||||
}));
|
||||
vi.mock("../components/ScheduleTab", () => ({
|
||||
default: () => <div data-testid="tab-content-schedule">ScheduleTab</div>,
|
||||
}));
|
||||
vi.mock("../pages/ETLStatus", () => ({
|
||||
default: () => <div data-testid="tab-content-status">ETLStatus</div>,
|
||||
}));
|
||||
|
||||
import ETLTasks from "../pages/ETLTasks";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 辅助:捕获当前 URL search 参数 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function LocationSpy({ onLocation }: { onLocation: (s: string) => void }) {
|
||||
const location = useLocation();
|
||||
React.useEffect(() => {
|
||||
onLocation(location.search);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 页面配置(可扩展 TriggerManager) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface TabPageConfig {
|
||||
name: string;
|
||||
validTabs: readonly string[];
|
||||
defaultTab: string;
|
||||
Component: React.FC;
|
||||
basePath: string;
|
||||
/** Tab label 文本中包含的关键字,用于定位 Tab 元素 */
|
||||
tabLabels: Record<string, string>;
|
||||
}
|
||||
|
||||
const ETL_TASKS_CONFIG: TabPageConfig = {
|
||||
name: "ETLTasks",
|
||||
validTabs: ["config", "queue", "schedule", "history", "status"],
|
||||
defaultTab: "config",
|
||||
Component: ETLTasks,
|
||||
basePath: "/etl-tasks",
|
||||
tabLabels: {
|
||||
config: "发起",
|
||||
queue: "队列",
|
||||
schedule: "调度",
|
||||
history: "历史",
|
||||
status: "状态",
|
||||
},
|
||||
};
|
||||
|
||||
// TriggerManager 配置占位,待 task 10.1 创建后启用
|
||||
// const TRIGGER_MANAGER_CONFIG: TabPageConfig = { ... };
|
||||
|
||||
const PAGE_CONFIGS: TabPageConfig[] = [ETL_TASKS_CONFIG];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 每次测试后清理 DOM */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 属性测试 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
// DOM 渲染属性测试:numRuns=20 覆盖所有 tab 值多次,避免 jsdom 超时
|
||||
const PBT_NUM_RUNS = 20;
|
||||
// 给 DOM 属性测试更长的超时(30s)
|
||||
const PBT_TIMEOUT = 30_000;
|
||||
|
||||
describe("Property 8: Tab 切换与 URL 查询参数同步 round-trip", () => {
|
||||
for (const pageConfig of PAGE_CONFIGS) {
|
||||
const { name, validTabs, defaultTab, Component, basePath, tabLabels } = pageConfig;
|
||||
|
||||
describe(`${name} 页面`, () => {
|
||||
// 生成器:从有效 tab 值中随机选取
|
||||
const tabArb = fc.constantFrom(...validTabs);
|
||||
|
||||
it("设置 ?tab=X 后,激活的 Tab 应为 X", () => {
|
||||
fc.assert(
|
||||
fc.property(tabArb, (tab) => {
|
||||
cleanup();
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`${basePath}?tab=${tab}`]}>
|
||||
<Component />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Ant Design Tabs 的激活 tab 有 .ant-tabs-tab-active 类
|
||||
const activeTab = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab).not.toBeNull();
|
||||
expect(activeTab!.textContent).toContain(tabLabels[tab]);
|
||||
|
||||
cleanup();
|
||||
}),
|
||||
{ numRuns: PBT_NUM_RUNS },
|
||||
);
|
||||
}, PBT_TIMEOUT);
|
||||
|
||||
it("点击 Tab Y 后,URL 查询参数应更新为 ?tab=Y", () => {
|
||||
fc.assert(
|
||||
fc.property(tabArb, tabArb, (initialTab, targetTab) => {
|
||||
cleanup();
|
||||
let currentSearch = "";
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`${basePath}?tab=${initialTab}`]}>
|
||||
<Component />
|
||||
<LocationSpy onLocation={(s) => { currentSearch = s; }} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// 找到目标 Tab 并点击
|
||||
const tabElements = document.querySelectorAll(".ant-tabs-tab");
|
||||
let targetTabEl: Element | null = null;
|
||||
for (const el of tabElements) {
|
||||
if (el.textContent?.includes(tabLabels[targetTab])) {
|
||||
targetTabEl = el;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(targetTabEl).not.toBeNull();
|
||||
fireEvent.click(targetTabEl!);
|
||||
|
||||
// 验证 URL 参数已更新
|
||||
const params = new URLSearchParams(currentSearch);
|
||||
expect(params.get("tab")).toBe(targetTab);
|
||||
|
||||
cleanup();
|
||||
}),
|
||||
{ numRuns: PBT_NUM_RUNS },
|
||||
);
|
||||
}, PBT_TIMEOUT);
|
||||
|
||||
it("无效或缺失的 tab 参数应回退到默认 Tab", () => {
|
||||
// 无 tab 参数
|
||||
render(
|
||||
<MemoryRouter initialEntries={[basePath]}>
|
||||
<Component />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const activeTab1 = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab1).not.toBeNull();
|
||||
expect(activeTab1!.textContent).toContain(tabLabels[defaultTab]);
|
||||
cleanup();
|
||||
|
||||
// 无效 tab 参数
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`${basePath}?tab=invalid`]}>
|
||||
<Component />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const activeTab2 = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab2).not.toBeNull();
|
||||
expect(activeTab2!.textContent).toContain(tabLabels[defaultTab]);
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
298
apps/admin-web/src/__tests__/triggerManager.test.tsx
Normal file
298
apps/admin-web/src/__tests__/triggerManager.test.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* 单元测试:TriggerManager 页面
|
||||
*
|
||||
* _Requirements: 4.1, 4.3, 4.4, 4.7_
|
||||
*
|
||||
* - 测试 4 个 Tab 正确渲染
|
||||
* - 测试"全部"Tab 为只读(无编辑按钮)
|
||||
* - 测试"业务"Tab 编辑表单仅包含 cron_expression 和 interval_seconds
|
||||
* - 测试 422 错误在表单中展示具体错误信息
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Ant Design jsdom 兼容:polyfill window.matchMedia */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mock API 模块 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const mockFetchUnifiedTriggers = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
name: "同步会员数据",
|
||||
source: "biz",
|
||||
trigger_condition: "cron",
|
||||
status: "running",
|
||||
last_run_at: "2026-07-15T10:00:00",
|
||||
next_run_at: "2026-07-15T12:00:00",
|
||||
last_error: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "AI 事件链",
|
||||
source: "ai",
|
||||
trigger_condition: "event",
|
||||
status: "idle",
|
||||
last_run_at: null,
|
||||
next_run_at: null,
|
||||
last_error: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const mockFetchTriggerJobs = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 101,
|
||||
job_type: "sync",
|
||||
job_name: "sync_members",
|
||||
trigger_condition: "cron",
|
||||
trigger_config: { cron_expression: "0 */2 * * *", interval_seconds: 7200 },
|
||||
last_run_at: "2026-07-15T10:00:00",
|
||||
next_run_at: "2026-07-15T12:00:00",
|
||||
status: "enabled",
|
||||
description: "同步会员数据",
|
||||
last_error: null,
|
||||
created_at: "2026-01-01T00:00:00",
|
||||
},
|
||||
]);
|
||||
|
||||
const mockUpdateTriggerConfig = vi.fn().mockResolvedValue({
|
||||
id: 101,
|
||||
job_type: "sync",
|
||||
job_name: "sync_members",
|
||||
trigger_condition: "cron",
|
||||
trigger_config: { cron_expression: "0 */3 * * *", interval_seconds: 7200 },
|
||||
last_run_at: "2026-07-15T10:00:00",
|
||||
next_run_at: "2026-07-15T15:00:00",
|
||||
status: "enabled",
|
||||
description: "同步会员数据",
|
||||
last_error: null,
|
||||
created_at: "2026-01-01T00:00:00",
|
||||
});
|
||||
|
||||
const mockFetchSchedules = vi.fn().mockResolvedValue([]);
|
||||
|
||||
vi.mock("../api/triggers", () => ({
|
||||
fetchUnifiedTriggers: (...args: unknown[]) => mockFetchUnifiedTriggers(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../api/triggerJobs", () => ({
|
||||
fetchTriggerJobs: (...args: unknown[]) => mockFetchTriggerJobs(...args),
|
||||
updateTriggerConfig: (...args: unknown[]) => mockUpdateTriggerConfig(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../api/schedules", () => ({
|
||||
fetchSchedules: (...args: unknown[]) => mockFetchSchedules(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../pages/AIOperations", () => ({
|
||||
default: () => <div data-testid="mock-ai-operations">AIOperations Mock</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../pages/AITriggerJobs", () => ({
|
||||
default: () => <div data-testid="mock-ai-trigger-jobs">AITriggerJobs Mock</div>,
|
||||
}));
|
||||
|
||||
import TriggerManager from "../pages/TriggerManager";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 测试:4 个 Tab 正确渲染(Requirement 4.1) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("TriggerManager — 4 个 Tab 渲染 (Requirement 4.1)", () => {
|
||||
it("渲染 4 个 Tab 标签:全部、业务、AI、ETL", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/triggers"]}>
|
||||
<TriggerManager />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const tabs = document.querySelectorAll(".ant-tabs-tab");
|
||||
expect(tabs).toHaveLength(4);
|
||||
});
|
||||
|
||||
const tabs = document.querySelectorAll(".ant-tabs-tab");
|
||||
const tabTexts = Array.from(tabs).map((t) => t.textContent);
|
||||
expect(tabTexts).toContain("全部");
|
||||
expect(tabTexts).toContain("业务");
|
||||
expect(tabTexts).toContain("AI");
|
||||
expect(tabTexts).toContain("ETL");
|
||||
});
|
||||
|
||||
it("默认激活「全部」Tab", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/triggers"]}>
|
||||
<TriggerManager />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const activeTab = document.querySelector(".ant-tabs-tab-active");
|
||||
expect(activeTab).not.toBeNull();
|
||||
expect(activeTab!.textContent).toContain("全部");
|
||||
});
|
||||
|
||||
it("页面标题包含「触发器管理」", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/triggers"]}>
|
||||
<TriggerManager />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("触发器管理")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 测试:"全部"Tab 为只读(Requirement 4.3) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("TriggerManager — 全部 Tab 只读 (Requirement 4.3)", () => {
|
||||
it("「全部」Tab 表格中无「编辑」按钮", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/triggers?tab=all"]}>
|
||||
<TriggerManager />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// 等待统一视图数据加载完成
|
||||
await waitFor(() => {
|
||||
expect(mockFetchUnifiedTriggers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// 等待表格渲染数据
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("同步会员数据")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 全部 Tab 不应有编辑按钮
|
||||
const editButtons = screen.queryAllByRole("button", { name: /编辑/ });
|
||||
expect(editButtons).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 测试:"业务"Tab 编辑表单字段(Requirement 4.4, 4.7) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("TriggerManager — 业务 Tab 编辑表单 (Requirements 4.4, 4.7)", () => {
|
||||
it("编辑 Modal 仅包含 cron_expression 和 interval_seconds 两个字段", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/triggers?tab=biz"]}>
|
||||
<TriggerManager />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// 等待业务触发器数据加载
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTriggerJobs).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// 等待表格渲染
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("同步会员数据")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 点击编辑按钮
|
||||
const editBtn = screen.getByRole("button", { name: /编辑/ });
|
||||
fireEvent.click(editBtn);
|
||||
|
||||
// 等待 Modal 打开
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/编辑触发器配置/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 验证表单包含 cron_expression 字段
|
||||
expect(screen.getByLabelText(/Cron 表达式/)).toBeInTheDocument();
|
||||
|
||||
// 验证表单包含 interval_seconds 字段
|
||||
expect(screen.getByLabelText(/间隔秒数/)).toBeInTheDocument();
|
||||
|
||||
// 验证 Modal 中的 Form.Item 数量 — 只有 2 个
|
||||
const modal = document.querySelector(".ant-modal-body");
|
||||
expect(modal).not.toBeNull();
|
||||
const formItems = modal!.querySelectorAll(".ant-form-item");
|
||||
expect(formItems).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 测试:422 错误展示具体错误信息(Requirement 4.7) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("TriggerManager — 422 错误展示 (Requirement 4.7)", () => {
|
||||
it("422 错误时调用 updateTriggerConfig 并提取 detail 信息", async () => {
|
||||
const error422 = {
|
||||
response: {
|
||||
status: 422,
|
||||
data: { detail: "cron 表达式格式无效,需要 5 字段格式" },
|
||||
},
|
||||
};
|
||||
mockUpdateTriggerConfig.mockRejectedValueOnce(error422);
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/triggers?tab=biz"]}>
|
||||
<TriggerManager />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// 等待数据加载
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("同步会员数据")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 点击编辑按钮
|
||||
const editBtn = screen.getByRole("button", { name: /编辑/ });
|
||||
fireEvent.click(editBtn);
|
||||
|
||||
// 等待 Modal 打开
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/编辑触发器配置/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 填写一个无效的 cron 表达式
|
||||
const cronInput = screen.getByLabelText(/Cron 表达式/);
|
||||
fireEvent.change(cronInput, { target: { value: "invalid-cron" } });
|
||||
|
||||
// 点击保存(Modal 的确定按钮,Ant Design 渲染为"保 存"带空格)
|
||||
const okBtn = screen.getByRole("button", { name: /保\s*存/ });
|
||||
fireEvent.click(okBtn);
|
||||
|
||||
// 验证 updateTriggerConfig 被调用(触发了 422 错误路径)
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTriggerConfig).toHaveBeenCalledWith(101, {
|
||||
cron_expression: "invalid-cron",
|
||||
interval_seconds: 7200,
|
||||
});
|
||||
});
|
||||
|
||||
// 验证 422 错误后 Modal 仍然打开(未关闭,说明错误被处理而非忽略)
|
||||
expect(screen.getByText(/编辑触发器配置/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
277
apps/admin-web/src/api/adminAI.ts
Normal file
277
apps/admin-web/src/api/adminAI.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* AI 监控后台 API
|
||||
*
|
||||
* 对接后端 /api/admin/ai/* 端点,提供 Dashboard、调度任务、调用记录、
|
||||
* 缓存管理、Token 预算、批量执行、告警管理等功能。
|
||||
*/
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
// ---- 类型定义 ----
|
||||
|
||||
// Dashboard
|
||||
export interface DailyTrend {
|
||||
date: string;
|
||||
calls: number;
|
||||
success_rate: number;
|
||||
}
|
||||
|
||||
export interface AppDistItem {
|
||||
app_type: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface BudgetInfo {
|
||||
daily_used: number;
|
||||
daily_limit: number;
|
||||
daily_pct: number;
|
||||
monthly_used: number;
|
||||
monthly_limit: number;
|
||||
monthly_pct: number;
|
||||
}
|
||||
|
||||
export interface AlertItem {
|
||||
id: number;
|
||||
app_type: string;
|
||||
status: string;
|
||||
alert_status: string | null;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AppHealthItem {
|
||||
app_type: string;
|
||||
last_status: string | null;
|
||||
last_call_at: string | null;
|
||||
}
|
||||
|
||||
export interface DashboardResponse {
|
||||
today_calls: number;
|
||||
today_success_rate: number;
|
||||
today_tokens: number;
|
||||
today_avg_latency_ms: number;
|
||||
trend_7d: DailyTrend[];
|
||||
app_distribution: AppDistItem[];
|
||||
budget: BudgetInfo;
|
||||
recent_alerts: AlertItem[];
|
||||
app_health: AppHealthItem[];
|
||||
}
|
||||
|
||||
// 调度任务
|
||||
export interface TriggerJobItem {
|
||||
id: number;
|
||||
event_type: string;
|
||||
member_id: number | null;
|
||||
status: string;
|
||||
app_chain: string | null;
|
||||
is_forced: boolean;
|
||||
site_id: number;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TriggerJobDetailResponse extends TriggerJobItem {
|
||||
payload: Record<string, unknown> | null;
|
||||
error_message: string | null;
|
||||
connector_type: string;
|
||||
}
|
||||
|
||||
export interface TriggerJobListResponse {
|
||||
items: TriggerJobItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
today_skipped_duplicates: number;
|
||||
}
|
||||
|
||||
export interface RetryResponse {
|
||||
trigger_job_id: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface TriggerJobQuery {
|
||||
event_type?: string;
|
||||
status?: string;
|
||||
site_id?: number;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
// 调用记录
|
||||
export interface RunLogItem {
|
||||
id: number;
|
||||
app_type: string;
|
||||
trigger_type: string;
|
||||
member_id: number | null;
|
||||
tokens_used: number;
|
||||
latency_ms: number | null;
|
||||
status: string;
|
||||
site_id: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RunLogDetailResponse extends RunLogItem {
|
||||
request_prompt: string | null;
|
||||
response_text: string | null;
|
||||
error_message: string | null;
|
||||
session_id: string | null;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface RunLogListResponse {
|
||||
items: RunLogItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface RunLogQuery {
|
||||
app_type?: string;
|
||||
status?: string;
|
||||
trigger_type?: string;
|
||||
site_id?: number;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
// 缓存管理
|
||||
export interface CacheInvalidateReq {
|
||||
site_id: number;
|
||||
app_type?: string;
|
||||
member_id?: number;
|
||||
}
|
||||
|
||||
export interface CacheInvalidateResponse {
|
||||
affected_count: number;
|
||||
}
|
||||
|
||||
// Token 预算
|
||||
export interface BudgetResponse {
|
||||
daily_used: number;
|
||||
daily_limit: number;
|
||||
daily_pct: number;
|
||||
monthly_used: number;
|
||||
monthly_limit: number;
|
||||
monthly_pct: number;
|
||||
}
|
||||
|
||||
// 批量执行
|
||||
export interface BatchRunReq {
|
||||
app_types: string[];
|
||||
member_ids: number[];
|
||||
site_id: number;
|
||||
}
|
||||
|
||||
export interface BatchRunEstimate {
|
||||
batch_id: string;
|
||||
estimated_calls: number;
|
||||
estimated_tokens: number;
|
||||
}
|
||||
|
||||
export interface BatchRunConfirmResponse {
|
||||
status: string;
|
||||
}
|
||||
|
||||
// 告警
|
||||
export interface AlertQuery {
|
||||
alert_status?: string;
|
||||
site_id?: number;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export interface AlertListResponse {
|
||||
items: AlertItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface AlertActionResponse {
|
||||
id: number;
|
||||
alert_status: string;
|
||||
}
|
||||
|
||||
// ---- API 调用 ----
|
||||
|
||||
// Dashboard
|
||||
export async function getDashboard(siteId?: number): Promise<DashboardResponse> {
|
||||
const { data } = await apiClient.get<DashboardResponse>("/admin/ai/dashboard", {
|
||||
params: siteId != null ? { site_id: siteId } : undefined,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
// 调度任务
|
||||
export async function getTriggerJobs(params: TriggerJobQuery): Promise<TriggerJobListResponse> {
|
||||
const { data } = await apiClient.get<TriggerJobListResponse>("/admin/ai/trigger-jobs", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getTriggerJobDetail(id: number): Promise<TriggerJobDetailResponse> {
|
||||
const { data } = await apiClient.get<TriggerJobDetailResponse>(`/admin/ai/trigger-jobs/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function retryTriggerJob(id: number): Promise<RetryResponse> {
|
||||
const { data } = await apiClient.post<RetryResponse>(`/admin/ai/trigger-jobs/${id}/retry`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// 调用记录
|
||||
export async function getRunLogs(params: RunLogQuery): Promise<RunLogListResponse> {
|
||||
const { data } = await apiClient.get<RunLogListResponse>("/admin/ai/run-logs", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getRunLogDetail(id: number): Promise<RunLogDetailResponse> {
|
||||
const { data } = await apiClient.get<RunLogDetailResponse>(`/admin/ai/run-logs/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// 缓存管理
|
||||
export async function invalidateCache(body: CacheInvalidateReq): Promise<CacheInvalidateResponse> {
|
||||
const { data } = await apiClient.post<CacheInvalidateResponse>("/admin/ai/cache/invalidate", body);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Token 预算
|
||||
export async function getBudget(): Promise<BudgetResponse> {
|
||||
const { data } = await apiClient.get<BudgetResponse>("/admin/ai/budget");
|
||||
return data;
|
||||
}
|
||||
|
||||
// 批量执行
|
||||
export async function createBatchRun(body: BatchRunReq): Promise<BatchRunEstimate> {
|
||||
const { data } = await apiClient.post<BatchRunEstimate>("/admin/ai/batch-run", body);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function confirmBatchRun(batchId: string): Promise<BatchRunConfirmResponse> {
|
||||
const { data } = await apiClient.post<BatchRunConfirmResponse>("/admin/ai/batch-run/confirm", {
|
||||
batch_id: batchId,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
// 告警
|
||||
export async function getAlerts(params: AlertQuery): Promise<AlertListResponse> {
|
||||
const { data } = await apiClient.get<AlertListResponse>("/admin/ai/alerts", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function ackAlert(id: number): Promise<AlertActionResponse> {
|
||||
const { data } = await apiClient.post<AlertActionResponse>(`/admin/ai/alerts/${id}/ack`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function ignoreAlert(id: number): Promise<AlertActionResponse> {
|
||||
const { data } = await apiClient.post<AlertActionResponse>(`/admin/ai/alerts/${id}/ignore`);
|
||||
return data;
|
||||
}
|
||||
@@ -122,20 +122,22 @@ apiClient.interceptors.response.use(
|
||||
|
||||
try {
|
||||
// 用独立 axios 调用避免被自身拦截器干扰
|
||||
const { data } = await axios.post<{
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
// ResponseWrapperMiddleware 包装响应为 { code: 0, data: { access_token, refresh_token } }
|
||||
const resp = await axios.post<{
|
||||
code: number;
|
||||
data: { access_token: string; refresh_token: string };
|
||||
}>("/api/auth/refresh", { refresh_token: refreshToken });
|
||||
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, data.access_token);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token);
|
||||
const tokens = resp.data.data;
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token);
|
||||
|
||||
processPendingQueue(data.access_token, null);
|
||||
processPendingQueue(tokens.access_token, null);
|
||||
|
||||
// 重放原始请求
|
||||
originalRequest.headers = {
|
||||
...originalRequest.headers,
|
||||
Authorization: `Bearer ${data.access_token}`,
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
};
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
|
||||
21
apps/admin-web/src/api/dbHealth.ts
Normal file
21
apps/admin-web/src/api/dbHealth.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 数据库健康监控 API 调用。
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
/** 数据库健康状态 */
|
||||
export interface DbHealthItem {
|
||||
db_name: string;
|
||||
status: 'connected' | 'disconnected';
|
||||
active_connections: number | null;
|
||||
idle_connections: number | null;
|
||||
db_size_mb: number | null;
|
||||
slow_query_count: number | null;
|
||||
}
|
||||
|
||||
/** 获取所有数据库健康状态 */
|
||||
export async function fetchDbHealth(): Promise<DbHealthItem[]> {
|
||||
const { data } = await apiClient.get<DbHealthItem[]>('/admin/db-health');
|
||||
return data;
|
||||
}
|
||||
84
apps/admin-web/src/api/devTrace.ts
Normal file
84
apps/admin-web/src/api/devTrace.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 开发调试全链路日志 API。
|
||||
*
|
||||
* 对接后端 /api/admin/dev-trace/* 端点,提供日志查询、设置管理、
|
||||
* 覆盖率扫描、手动清理等功能。
|
||||
*/
|
||||
|
||||
import { apiClient } from "./client";
|
||||
import type {
|
||||
TraceFilter,
|
||||
TraceRequest,
|
||||
TraceDetail,
|
||||
TraceSettings,
|
||||
TraceCoverage,
|
||||
} from "../types/devTrace";
|
||||
|
||||
// ---- 响应类型 ----
|
||||
|
||||
export interface TraceRequestListResponse {
|
||||
items: TraceRequest[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface CleanupResponse {
|
||||
deleted_dates: string[];
|
||||
deleted_files: number;
|
||||
}
|
||||
|
||||
// ---- 日期列表 ----
|
||||
|
||||
export async function fetchDates(): Promise<{ dates: string[] }> {
|
||||
const { data } = await apiClient.get<{ dates: string[] }>("/admin/dev-trace/dates");
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---- 请求列表(分页 + 筛选) ----
|
||||
|
||||
export async function fetchRequests(params: TraceFilter): Promise<TraceRequestListResponse> {
|
||||
const { data } = await apiClient.get<TraceRequestListResponse>("/admin/dev-trace/requests", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---- 请求详情(含完整 spans) ----
|
||||
|
||||
export async function fetchRequestDetail(requestId: string): Promise<TraceDetail> {
|
||||
const { data } = await apiClient.get<TraceDetail>(`/admin/dev-trace/request/${requestId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---- 手动清理 ----
|
||||
|
||||
export async function cleanupLogs(startDate: string, endDate: string): Promise<CleanupResponse> {
|
||||
const { data } = await apiClient.post<CleanupResponse>("/admin/dev-trace/cleanup", {
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---- 设置 ----
|
||||
|
||||
export async function fetchSettings(): Promise<TraceSettings> {
|
||||
const { data } = await apiClient.get<TraceSettings>("/admin/dev-trace/settings");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateSettings(settings: Partial<TraceSettings>): Promise<TraceSettings> {
|
||||
const { data } = await apiClient.put<TraceSettings>("/admin/dev-trace/settings", settings);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---- 覆盖率 ----
|
||||
|
||||
export async function fetchCoverage(): Promise<TraceCoverage> {
|
||||
const { data } = await apiClient.get<TraceCoverage>("/admin/dev-trace/coverage");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function triggerCoverageScan(): Promise<TraceCoverage> {
|
||||
const { data } = await apiClient.post<TraceCoverage>("/admin/dev-trace/coverage/scan");
|
||||
return data;
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import { apiClient } from './client';
|
||||
/** ETL 游标信息 */
|
||||
export interface CursorInfo {
|
||||
task_code: string;
|
||||
last_fetch_time: string | null;
|
||||
record_count: number | null;
|
||||
last_start: string | null;
|
||||
last_end: string | null;
|
||||
}
|
||||
|
||||
/** 最近执行记录 */
|
||||
|
||||
@@ -45,3 +45,17 @@ export async function deleteFromQueue(id: string): Promise<void> {
|
||||
export async function cancelExecution(id: string): Promise<void> {
|
||||
await apiClient.post(`/execution/${id}/cancel`);
|
||||
}
|
||||
|
||||
// CHANGE 2026-03-22 | 重新执行历史任务
|
||||
/** 重新执行指定的历史任务 */
|
||||
export async function rerunExecution(id: string): Promise<{ execution_id: string }> {
|
||||
const { data } = await apiClient.post<{ execution_id: string }>(`/execution/${id}/rerun`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// CHANGE 2026-03-27 | 清理输出目录,每类任务只保留最近 10 个运行记录
|
||||
/** 清理 EXPORT_ROOT 下旧运行记录 */
|
||||
export async function cleanupOutput(): Promise<{ task_folders_scanned: number; dirs_deleted: number; errors: string[] }> {
|
||||
const { data } = await apiClient.post<{ task_folders_scanned: number; dirs_deleted: number; errors: string[] }>('/execution/cleanup-output');
|
||||
return data;
|
||||
}
|
||||
|
||||
134
apps/admin-web/src/api/registry.ts
Normal file
134
apps/admin-web/src/api/registry.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 注册体系 API 调用(租户/店铺/简写ID/同步)。
|
||||
*
|
||||
* 复用 apiClient(已含 JWT 拦截器 + 响应解包)。
|
||||
* 端点前缀:/admin
|
||||
*/
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 类型定义 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 租户列表项(后端 CamelModel 序列化为 camelCase) */
|
||||
export interface TenantItem {
|
||||
id: number;
|
||||
tenantId: number;
|
||||
tenantName: string;
|
||||
connectorName: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/** 店铺列表项 */
|
||||
export interface SiteItem {
|
||||
id: number;
|
||||
siteId: number;
|
||||
siteName: string;
|
||||
siteCode: string | null;
|
||||
siteLabel: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/** 设置/修改简写ID 请求 */
|
||||
export interface UpdateSiteCodeRequest {
|
||||
newCode: string;
|
||||
}
|
||||
|
||||
/** 简写ID 修改结果 */
|
||||
export interface SiteCodeResult {
|
||||
siteId: number;
|
||||
oldCode: string | null;
|
||||
newCode: string;
|
||||
historyCleaned: boolean;
|
||||
}
|
||||
|
||||
/** 简写ID 变更历史条目 */
|
||||
export interface SiteCodeHistoryItem {
|
||||
id: number;
|
||||
siteCode: string;
|
||||
isCurrent: boolean;
|
||||
createdAt: string;
|
||||
retiredAt: string | null;
|
||||
}
|
||||
|
||||
/** 店铺同步结果 */
|
||||
export interface SiteSyncResult {
|
||||
inserted: number;
|
||||
updated: number;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* API 调用 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 获取所有活跃租户列表 */
|
||||
export async function fetchTenants(): Promise<TenantItem[]> {
|
||||
const { data } = await apiClient.get<TenantItem[]>("/admin/tenants");
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 获取指定租户下所有活跃店铺 */
|
||||
export async function fetchTenantSites(
|
||||
tenantId: number,
|
||||
): Promise<SiteItem[]> {
|
||||
const { data } = await apiClient.get<SiteItem[]>(
|
||||
`/admin/tenants/${tenantId}/sites`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 设置/修改店铺简写ID */
|
||||
export async function updateSiteCode(
|
||||
siteId: number,
|
||||
newCode: string,
|
||||
): Promise<SiteCodeResult> {
|
||||
const { data } = await apiClient.put<SiteCodeResult>(
|
||||
`/admin/sites/${siteId}/site-code`,
|
||||
{ newCode } satisfies UpdateSiteCodeRequest,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 查看简写ID 变更历史 */
|
||||
export async function fetchSiteCodeHistory(
|
||||
siteId: number,
|
||||
): Promise<SiteCodeHistoryItem[]> {
|
||||
const { data } = await apiClient.get<SiteCodeHistoryItem[]>(
|
||||
`/admin/sites/${siteId}/site-code-history`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 手动触发店铺同步 */
|
||||
export async function syncSites(): Promise<SiteSyncResult> {
|
||||
const { data } = await apiClient.post<SiteSyncResult>(
|
||||
"/admin/sites/sync",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 测试功能:手动创建/删除店铺 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 创建店铺请求 */
|
||||
export interface CreateSitePayload {
|
||||
tenantId: number;
|
||||
siteId: number;
|
||||
siteName: string;
|
||||
siteCode?: string;
|
||||
}
|
||||
|
||||
/** 手动创建店铺(测试功能) */
|
||||
export async function createSite(
|
||||
payload: CreateSitePayload,
|
||||
): Promise<SiteItem> {
|
||||
const { data } = await apiClient.post<SiteItem>("/admin/sites", payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 删除店铺(测试功能,硬删除) */
|
||||
export async function deleteSite(id: number): Promise<void> {
|
||||
await apiClient.delete(`/admin/sites/${id}`);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type { ScheduledTask, ScheduleConfig, TaskConfig, ExecutionLog } from '../types';
|
||||
import type { ScheduledTask, ScheduleConfig, TaskConfig, ExecutionLog, MinRunIntervalItem } from '../types';
|
||||
|
||||
/** 获取调度任务列表 */
|
||||
export async function fetchSchedules(): Promise<ScheduledTask[]> {
|
||||
@@ -18,6 +18,9 @@ export async function createSchedule(payload: {
|
||||
task_config: TaskConfig;
|
||||
schedule_config: ScheduleConfig;
|
||||
run_immediately?: boolean;
|
||||
min_run_interval_value?: number;
|
||||
min_run_interval_unit?: string;
|
||||
min_run_intervals?: Record<string, MinRunIntervalItem>;
|
||||
}): Promise<ScheduledTask> {
|
||||
const { data } = await apiClient.post<ScheduledTask>('/schedules', payload);
|
||||
return data;
|
||||
@@ -31,6 +34,9 @@ export async function updateSchedule(
|
||||
task_codes: string[];
|
||||
task_config: TaskConfig;
|
||||
schedule_config: ScheduleConfig;
|
||||
min_run_interval_value: number;
|
||||
min_run_interval_unit: string;
|
||||
min_run_intervals: Record<string, MinRunIntervalItem>;
|
||||
}>,
|
||||
): Promise<ScheduledTask> {
|
||||
const { data } = await apiClient.put<ScheduledTask>(`/schedules/${id}`, payload);
|
||||
@@ -49,8 +55,10 @@ export async function toggleSchedule(id: string): Promise<ScheduledTask> {
|
||||
}
|
||||
|
||||
/** 手动执行调度任务一次(不更新调度间隔) */
|
||||
export async function runScheduleNow(id: string): Promise<{ message: string; task_id: string }> {
|
||||
const { data } = await apiClient.post<{ message: string; task_id: string }>(`/schedules/${id}/run`);
|
||||
export async function runScheduleNow(id: string, force = false): Promise<{ message: string; task_id: string }> {
|
||||
const { data } = await apiClient.post<{ message: string; task_id: string }>(`/schedules/${id}/run`, null, {
|
||||
params: force ? { force: true } : undefined,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
187
apps/admin-web/src/api/taskEngine.ts
Normal file
187
apps/admin-web/src/api/taskEngine.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* P18 任务引擎运营看板 API。
|
||||
*
|
||||
* 端点前缀:/api/admin/task-engine
|
||||
*/
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 类型定义 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface TransferLogItem {
|
||||
id: number;
|
||||
site_id: number;
|
||||
site_name: string;
|
||||
member_id: number;
|
||||
member_name: string;
|
||||
from_assistant_id: number;
|
||||
from_assistant_name: string;
|
||||
to_assistant_id: number;
|
||||
to_assistant_name: string;
|
||||
transfer_reason: string | null;
|
||||
transfer_score: number | null;
|
||||
guard_checks: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TransferLogPage {
|
||||
items: TransferLogItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface PendingReviewItem {
|
||||
id: number;
|
||||
site_id: number;
|
||||
site_name: string;
|
||||
member_id: number;
|
||||
member_name: string;
|
||||
assistant_id: number;
|
||||
assistant_name: string;
|
||||
task_type: string;
|
||||
task_type_label: string;
|
||||
transfer_count: number;
|
||||
priority_score: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PendingReviewPage {
|
||||
items: PendingReviewItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ConfigParam {
|
||||
id: number;
|
||||
site_id: number | null;
|
||||
site_name: string | null;
|
||||
param_key: string;
|
||||
param_value: number;
|
||||
description: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ConfigParamList {
|
||||
params: ConfigParam[];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 转移日志 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface TransferLogQuery {
|
||||
site_id?: number;
|
||||
from_date?: string;
|
||||
to_date?: string;
|
||||
assistant_id?: number;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export async function fetchTransferLogs(
|
||||
query: TransferLogQuery = {},
|
||||
): Promise<TransferLogPage> {
|
||||
const resp = await apiClient.get<TransferLogPage>(
|
||||
"/admin/task-engine/transfer-log",
|
||||
{ params: query },
|
||||
);
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
export async function fetchMemberTransferHistory(
|
||||
memberId: number,
|
||||
): Promise<TransferLogItem[]> {
|
||||
const resp = await apiClient.get<TransferLogItem[]>(
|
||||
`/admin/task-engine/transfer-log/${memberId}/history`,
|
||||
);
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 待审核任务 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface PendingReviewQuery {
|
||||
site_id?: number;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export async function fetchPendingReviews(
|
||||
query: PendingReviewQuery = {},
|
||||
): Promise<PendingReviewPage> {
|
||||
const resp = await apiClient.get<PendingReviewPage>(
|
||||
"/admin/task-engine/pending-review",
|
||||
{ params: query },
|
||||
);
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
export async function reassignTask(
|
||||
taskId: number,
|
||||
toAssistantId: number,
|
||||
): Promise<{ success: boolean; new_task_id: number | null }> {
|
||||
const resp = await apiClient.post(
|
||||
`/admin/task-engine/pending-review/${taskId}/reassign`,
|
||||
{ to_assistant_id: toAssistantId },
|
||||
);
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
export async function closeTask(
|
||||
taskId: number,
|
||||
reason: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
const resp = await apiClient.post(
|
||||
`/admin/task-engine/pending-review/${taskId}/close`,
|
||||
{ reason },
|
||||
);
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 参数管理 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function fetchConfigParams(
|
||||
siteId?: number,
|
||||
): Promise<ConfigParamList> {
|
||||
const resp = await apiClient.get<ConfigParamList>(
|
||||
"/admin/task-engine/config",
|
||||
{ params: siteId != null ? { site_id: siteId } : {} },
|
||||
);
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
export async function updateConfigParam(
|
||||
paramId: number,
|
||||
paramValue: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
const resp = await apiClient.put(
|
||||
`/admin/task-engine/config/${paramId}`,
|
||||
{ param_value: paramValue },
|
||||
);
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
export async function createConfigParam(
|
||||
siteId: number,
|
||||
paramKey: string,
|
||||
paramValue: number,
|
||||
): Promise<{ success: boolean; id: number }> {
|
||||
const resp = await apiClient.post("/admin/task-engine/config", {
|
||||
site_id: siteId,
|
||||
param_key: paramKey,
|
||||
param_value: paramValue,
|
||||
});
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
export async function deleteConfigParam(
|
||||
paramId: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
const resp = await apiClient.delete(
|
||||
`/admin/task-engine/config/${paramId}`,
|
||||
);
|
||||
return resp.data;
|
||||
}
|
||||
110
apps/admin-web/src/api/tenantAdmins.ts
Normal file
110
apps/admin-web/src/api/tenantAdmins.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 租户管理员 CRUD API 调用。
|
||||
*
|
||||
* 复用 apiClient(已含 JWT 拦截器 + 响应解包)。
|
||||
* 端点前缀:/admin/tenant-admins
|
||||
*/
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 类型定义 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 列表项(后端 CamelModel 序列化为 camelCase) */
|
||||
export interface TenantAdminItem {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
tenantId: number;
|
||||
tenantName: string | null;
|
||||
adminType: string;
|
||||
managedSiteIds: number[];
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
lastLoginAt: string | null;
|
||||
}
|
||||
|
||||
/** 分页响应 */
|
||||
export interface TenantAdminListResponse {
|
||||
items: TenantAdminItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/** 创建请求 */
|
||||
export interface TenantAdminCreatePayload {
|
||||
username: string;
|
||||
password: string;
|
||||
displayName: string;
|
||||
tenantId: number;
|
||||
managedSiteIds: number[];
|
||||
}
|
||||
|
||||
/** 编辑请求(所有字段可选) */
|
||||
export interface TenantAdminEditPayload {
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
managedSiteIds?: number[];
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/** 重置密码请求 */
|
||||
export interface ResetPasswordPayload {
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* API 调用 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 列表查询(分页 + 关键词搜索 + 可选包含已禁用) */
|
||||
export async function fetchTenantAdmins(params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
keyword?: string;
|
||||
include_inactive?: boolean;
|
||||
}): Promise<TenantAdminListResponse> {
|
||||
const { data } = await apiClient.get<TenantAdminListResponse>(
|
||||
"/admin/tenant-admins",
|
||||
{ params },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 创建租户管理员 */
|
||||
export async function createTenantAdmin(
|
||||
payload: TenantAdminCreatePayload,
|
||||
): Promise<TenantAdminItem> {
|
||||
const { data } = await apiClient.post<TenantAdminItem>(
|
||||
"/admin/tenant-admins",
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 编辑租户管理员 */
|
||||
export async function editTenantAdmin(
|
||||
id: number,
|
||||
payload: TenantAdminEditPayload,
|
||||
): Promise<TenantAdminItem> {
|
||||
const { data } = await apiClient.patch<TenantAdminItem>(
|
||||
`/admin/tenant-admins/${id}`,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 重置密码 */
|
||||
export async function resetTenantAdminPassword(
|
||||
id: number,
|
||||
payload: ResetPasswordPayload,
|
||||
): Promise<void> {
|
||||
await apiClient.post(`/admin/tenant-admins/${id}/reset-password`, payload);
|
||||
}
|
||||
|
||||
/** 删除租户管理员(软删除) */
|
||||
export async function deleteTenantAdmin(id: number): Promise<void> {
|
||||
await apiClient.delete(`/admin/tenant-admins/${id}`);
|
||||
}
|
||||
62
apps/admin-web/src/api/triggerJobs.ts
Normal file
62
apps/admin-web/src/api/triggerJobs.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 定时任务管理 API 调用。
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
/** 定时任务信息 */
|
||||
export interface TriggerJob {
|
||||
id: number;
|
||||
job_type: string;
|
||||
job_name: string;
|
||||
trigger_condition: string;
|
||||
trigger_config: Record<string, unknown> | null;
|
||||
last_run_at: string | null;
|
||||
next_run_at: string | null;
|
||||
status: string;
|
||||
description: string | null;
|
||||
last_error: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
/** 手动执行结果 */
|
||||
export interface RunJobResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 获取所有定时任务 */
|
||||
export async function fetchTriggerJobs(): Promise<TriggerJob[]> {
|
||||
const { data } = await apiClient.get<TriggerJob[]>('/trigger-jobs');
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 手动执行指定任务 */
|
||||
export async function runTriggerJob(jobId: number): Promise<RunJobResult> {
|
||||
const { data } = await apiClient.post<RunJobResult>(`/trigger-jobs/${jobId}/run`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 触发器配置更新请求 */
|
||||
export interface UpdateTriggerConfigReq {
|
||||
cron_expression?: string;
|
||||
interval_seconds?: number;
|
||||
}
|
||||
|
||||
/** 更新触发器配置(cron 表达式或间隔秒数) */
|
||||
export async function updateTriggerConfig(
|
||||
jobId: number,
|
||||
body: UpdateTriggerConfigReq,
|
||||
): Promise<TriggerJob> {
|
||||
const { data } = await apiClient.patch<TriggerJob>(
|
||||
`/trigger-jobs/${jobId}/config`,
|
||||
body,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 【测试】清空所有 coach_tasks */
|
||||
export async function clearAllTasks(): Promise<RunJobResult> {
|
||||
const { data } = await apiClient.delete<RunJobResult>('/admin/task-engine/clear-all-tasks');
|
||||
return data;
|
||||
}
|
||||
23
apps/admin-web/src/api/triggers.ts
Normal file
23
apps/admin-web/src/api/triggers.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 触发器统一视图 API 调用。
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
/** 统一触发器条目 */
|
||||
export interface UnifiedTriggerItem {
|
||||
id: number;
|
||||
name: string;
|
||||
source: 'biz' | 'ai' | 'etl';
|
||||
trigger_condition: string;
|
||||
status: string;
|
||||
last_run_at: string | null;
|
||||
next_run_at: string | null;
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
/** 获取所有触发器统一视图 */
|
||||
export async function fetchUnifiedTriggers(): Promise<UnifiedTriggerItem[]> {
|
||||
const { data } = await apiClient.get<UnifiedTriggerItem[]>('/admin/triggers/unified');
|
||||
return data;
|
||||
}
|
||||
167
apps/admin-web/src/components/DbHealthCard.tsx
Normal file
167
apps/admin-web/src/components/DbHealthCard.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 数据库健康监控卡片
|
||||
*
|
||||
* 展示每个数据库的连接池状态、大小、慢查询数量。
|
||||
* 纯展示组件,接收 DbHealthItem[] 数据 + 加载/超时状态。
|
||||
*
|
||||
* - connected:展示完整指标(进度条 + 统计数值)
|
||||
* - disconnected:显示"未连接"状态标签
|
||||
* - 加载超时:展示"加载超时"状态 + 重试按钮
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Tag,
|
||||
Progress,
|
||||
Statistic,
|
||||
Button,
|
||||
Spin,
|
||||
Empty,
|
||||
} from "antd";
|
||||
import {
|
||||
DatabaseOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ReloadOutlined,
|
||||
WarningOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { DbHealthItem } from "../api/dbHealth";
|
||||
|
||||
export interface DbHealthCardProps {
|
||||
/** 数据库健康数据列表 */
|
||||
items: DbHealthItem[];
|
||||
/** 是否正在加载 */
|
||||
loading?: boolean;
|
||||
/** 是否加载超时 */
|
||||
timeout?: boolean;
|
||||
/** 重试回调(超时时展示重试按钮) */
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
/** 连接池总数(活跃 + 空闲),用于计算进度条百分比 */
|
||||
function poolPercent(active: number | null, idle: number | null): number {
|
||||
if (active == null || idle == null) return 0;
|
||||
const total = active + idle;
|
||||
if (total === 0) return 0;
|
||||
return Math.round((active / total) * 100);
|
||||
}
|
||||
|
||||
const DbHealthCard: React.FC<DbHealthCardProps> = ({
|
||||
items,
|
||||
loading = false,
|
||||
timeout = false,
|
||||
onRetry,
|
||||
}) => {
|
||||
// 加载超时状态
|
||||
if (timeout) {
|
||||
return (
|
||||
<Card size="small" title="数据库健康监控" style={{ marginBottom: 16 }}>
|
||||
<div style={{ textAlign: "center", padding: "24px 0" }}>
|
||||
<WarningOutlined style={{ fontSize: 32, color: "#faad14", marginBottom: 12 }} />
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Tag color="warning" icon={<WarningOutlined />}>加载超时</Tag>
|
||||
</div>
|
||||
{onRetry && (
|
||||
<Button icon={<ReloadOutlined />} onClick={onRetry}>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 加载中
|
||||
if (loading) {
|
||||
return (
|
||||
<Card size="small" title="数据库健康监控" style={{ marginBottom: 16 }}>
|
||||
<div style={{ textAlign: "center", padding: "24px 0" }}>
|
||||
<Spin tip="加载中..." />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 无数据
|
||||
if (!items || items.length === 0) {
|
||||
return (
|
||||
<Card size="small" title="数据库健康监控" style={{ marginBottom: 16 }}>
|
||||
<Empty description="暂无数据" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small" title="数据库健康监控" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16}>
|
||||
{items.map((item) => (
|
||||
<Col span={12} key={item.db_name} style={{ marginBottom: 16 }}>
|
||||
<Card
|
||||
size="small"
|
||||
type="inner"
|
||||
title={
|
||||
<span>
|
||||
<DatabaseOutlined style={{ marginRight: 6 }} />
|
||||
{item.db_name}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
item.status === "connected" ? (
|
||||
<Tag color="success" icon={<CheckCircleOutlined />}>已连接</Tag>
|
||||
) : (
|
||||
<Tag color="error" icon={<CloseCircleOutlined />}>未连接</Tag>
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.status === "connected" ? (
|
||||
<>
|
||||
{/* 连接池进度条 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ marginBottom: 4, fontSize: 12, color: "#666" }}>
|
||||
连接池(活跃 {item.active_connections ?? 0} / 空闲 {item.idle_connections ?? 0})
|
||||
</div>
|
||||
<Progress
|
||||
percent={poolPercent(item.active_connections, item.idle_connections)}
|
||||
size="small"
|
||||
format={(pct) => `${pct}%`}
|
||||
/>
|
||||
</div>
|
||||
{/* 统计指标 */}
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="数据库大小"
|
||||
value={item.db_size_mb ?? "-"}
|
||||
suffix="MB"
|
||||
valueStyle={{ fontSize: 16 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="慢查询(1h)"
|
||||
value={item.slow_query_count ?? 0}
|
||||
valueStyle={{
|
||||
fontSize: 16,
|
||||
color: (item.slow_query_count ?? 0) > 0 ? "#faad14" : undefined,
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ textAlign: "center", padding: "16px 0", color: "#999" }}>
|
||||
数据库未连接,无法获取指标
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DbHealthCard;
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
import { ReloadOutlined, EditOutlined, DeleteOutlined, HistoryOutlined, PlayCircleOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import type { ScheduledTask, ScheduleConfig } from '../types';
|
||||
import {
|
||||
fetchSchedules,
|
||||
@@ -25,6 +27,9 @@ import {
|
||||
} from '../api/schedules';
|
||||
import ScheduleHistoryDrawer from './ScheduleHistoryDrawer';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -178,6 +183,8 @@ const ScheduleTab: React.FC = () => {
|
||||
const cfg = record.schedule_config;
|
||||
form.setFieldsValue({
|
||||
name: record.name,
|
||||
min_run_interval_value: record.min_run_interval_value ?? 0,
|
||||
min_run_interval_unit: record.min_run_interval_unit ?? 'minutes',
|
||||
schedule_config: {
|
||||
...cfg,
|
||||
daily_time: cfg.daily_time ? dayjs(cfg.daily_time, 'HH:mm') : undefined,
|
||||
@@ -226,6 +233,8 @@ const ScheduleTab: React.FC = () => {
|
||||
await updateSchedule(editing.id, {
|
||||
name: values.name,
|
||||
schedule_config: scheduleConfig,
|
||||
min_run_interval_value: values.min_run_interval_value ?? 0,
|
||||
min_run_interval_unit: values.min_run_interval_unit ?? 'minutes',
|
||||
});
|
||||
message.success('调度任务已更新');
|
||||
|
||||
@@ -259,13 +268,32 @@ const ScheduleTab: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/* 手动执行一次(不更新调度间隔) */
|
||||
const handleRunNow = async (id: string) => {
|
||||
/* 手动执行 — 状态 */
|
||||
const [runConfirmOpen, setRunConfirmOpen] = useState(false);
|
||||
const [runForceCheck, setRunForceCheck] = useState(false);
|
||||
const [runTargetId, setRunTargetId] = useState<string | null>(null);
|
||||
|
||||
/* 打开手动执行确认 Modal */
|
||||
const openRunConfirm = (id: string) => {
|
||||
setRunTargetId(id);
|
||||
setRunForceCheck(false);
|
||||
setRunConfirmOpen(true);
|
||||
};
|
||||
|
||||
/* 确认手动执行 */
|
||||
const handleRunNow = async () => {
|
||||
if (!runTargetId) return;
|
||||
try {
|
||||
await runScheduleNow(id);
|
||||
await runScheduleNow(runTargetId, runForceCheck);
|
||||
message.success('已提交到执行队列');
|
||||
} catch {
|
||||
message.error('执行失败');
|
||||
setRunConfirmOpen(false);
|
||||
} catch (err: unknown) {
|
||||
const axiosErr = err as { response?: { status?: number; data?: { detail?: string } } };
|
||||
if (axiosErr?.response?.status === 409) {
|
||||
message.warning(axiosErr.response.data?.detail ?? '任务正在运行或未达到最小间隔');
|
||||
} else {
|
||||
message.error('执行失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -321,17 +349,33 @@ const ScheduleTab: React.FC = () => {
|
||||
render: (s: string | null) =>
|
||||
s ? <Tag color={STATUS_COLOR[s] ?? 'default'}>{s}</Tag> : '—',
|
||||
},
|
||||
{
|
||||
title: '最小间隔',
|
||||
dataIndex: 'min_run_interval_value',
|
||||
key: 'min_run_interval',
|
||||
width: 120,
|
||||
render: (value: number, record: ScheduledTask) => {
|
||||
if (!value) return '无限制';
|
||||
const unit = INTERVAL_UNIT_LABEL[record.min_run_interval_unit] ?? record.min_run_interval_unit;
|
||||
return `${value} ${unit}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '上次成功',
|
||||
dataIndex: 'last_success_at',
|
||||
key: 'last_success_at',
|
||||
width: 120,
|
||||
render: (value: string | null) => (value ? dayjs(value).fromNow() : '—'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 300,
|
||||
render: (_: unknown, record: ScheduledTask) => (
|
||||
<Space size="small">
|
||||
<Popconfirm title="确认立即执行一次?(不影响调度间隔)" onConfirm={() => handleRunNow(record.id)}>
|
||||
<Button type="link" icon={<PlayCircleOutlined />} size="small">
|
||||
立即执行
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Button type="link" icon={<PlayCircleOutlined />} size="small" onClick={() => openRunConfirm(record.id)}>
|
||||
立即执行
|
||||
</Button>
|
||||
<Button type="link" icon={<HistoryOutlined />} size="small" onClick={() => openHistory(record)}>
|
||||
执行历史
|
||||
</Button>
|
||||
@@ -388,6 +432,21 @@ const ScheduleTab: React.FC = () => {
|
||||
</Form.Item>
|
||||
|
||||
<ScheduleConfigFields scheduleType={scheduleType} />
|
||||
|
||||
<Form.Item label="最小运行间隔">
|
||||
<Space>
|
||||
<Form.Item name="min_run_interval_value" noStyle initialValue={0}>
|
||||
<InputNumber min={0} placeholder="0 = 无限制" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="min_run_interval_unit" noStyle initialValue="minutes">
|
||||
<Select style={{ width: 100 }} options={[
|
||||
{ label: '分钟', value: 'minutes' },
|
||||
{ label: '小时', value: 'hours' },
|
||||
{ label: '天', value: 'days' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -398,6 +457,21 @@ const ScheduleTab: React.FC = () => {
|
||||
scheduleName={historyScheduleName}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
/>
|
||||
|
||||
{/* 手动执行确认 Modal */}
|
||||
<Modal
|
||||
title="确认立即执行"
|
||||
open={runConfirmOpen}
|
||||
onOk={handleRunNow}
|
||||
onCancel={() => setRunConfirmOpen(false)}
|
||||
okText="执行"
|
||||
cancelText="取消"
|
||||
>
|
||||
<p>确认立即执行一次?(不影响调度间隔)</p>
|
||||
<Checkbox checked={runForceCheck} onChange={(e) => setRunForceCheck(e.target.checked)}>
|
||||
强制执行(忽略最小间隔)
|
||||
</Checkbox>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import React, { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import {
|
||||
Collapse, Checkbox, Spin, Alert, Button, Space, Typography,
|
||||
Tag, Badge, Modal, Tooltip, Divider,
|
||||
Tag, Badge, Modal, Tooltip, Divider, InputNumber, Select,
|
||||
} from "antd";
|
||||
import {
|
||||
CheckCircleOutlined, WarningOutlined, SyncOutlined, TableOutlined,
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import type { CheckboxChangeEvent } from "antd/es/checkbox";
|
||||
import { fetchTaskRegistry, fetchDwdTablesRich, checkTaskSync } from "../api/tasks";
|
||||
import type { DwdTableItem as ApiDwdTableItem, SyncCheckResult } from "../api/tasks";
|
||||
import type { TaskDefinition, DwdTableItem } from "../types";
|
||||
import type { TaskDefinition, DwdTableItem, MinRunIntervalItem } from "../types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -44,6 +44,9 @@ export interface TaskSelectorProps {
|
||||
onTasksChange: (tasks: string[]) => void;
|
||||
selectedDwdTables?: string[];
|
||||
onDwdTablesChange?: (tables: string[]) => void;
|
||||
/** per-task-code 最小执行间隔 */
|
||||
taskIntervals?: Record<string, MinRunIntervalItem>;
|
||||
onTaskIntervalsChange?: (intervals: Record<string, MinRunIntervalItem>) => void;
|
||||
}
|
||||
|
||||
interface DomainGroup {
|
||||
@@ -105,6 +108,7 @@ function buildDomainGroups(
|
||||
const TaskSelector: React.FC<TaskSelectorProps> = ({
|
||||
layers, selectedTasks, onTasksChange,
|
||||
selectedDwdTables = [], onDwdTablesChange,
|
||||
taskIntervals = {}, onTaskIntervalsChange,
|
||||
}) => {
|
||||
const [registry, setRegistry] = useState<Record<string, TaskDefinition[]>>({});
|
||||
const [dwdTableGroups, setDwdTableGroups] = useState<Record<string, DwdTableItem[]>>({});
|
||||
@@ -241,6 +245,24 @@ const TaskSelector: React.FC<TaskSelectorProps> = ({
|
||||
[selectedDwdTables, onDwdTablesChange],
|
||||
);
|
||||
|
||||
/* 间隔设置 */
|
||||
const handleIntervalChange = useCallback(
|
||||
(code: string, field: "value" | "unit", val: number | string) => {
|
||||
if (!onTaskIntervalsChange) return;
|
||||
const current = taskIntervals[code] ?? { value: 0, unit: "minutes" as const };
|
||||
const updated = { ...current, [field]: val };
|
||||
if (updated.value === 0 || updated.value === null) {
|
||||
// 值为 0 时移除该任务的间隔设置
|
||||
const next = { ...taskIntervals };
|
||||
delete next[code];
|
||||
onTaskIntervalsChange(next);
|
||||
} else {
|
||||
onTaskIntervalsChange({ ...taskIntervals, [code]: updated as MinRunIntervalItem });
|
||||
}
|
||||
},
|
||||
[taskIntervals, onTaskIntervalsChange],
|
||||
);
|
||||
|
||||
/* 渲染 */
|
||||
if (loading) return <Spin tip="加载任务列表…" />;
|
||||
if (error) return <Alert type="error" message="加载失败" description={error} />;
|
||||
@@ -373,7 +395,7 @@ const TaskSelector: React.FC<TaskSelectorProps> = ({
|
||||
</div>
|
||||
<div style={{ paddingLeft: 4 }}>
|
||||
{lt.tasks.map((t) => (
|
||||
<div key={t.code} style={{ padding: "2px 0" }}>
|
||||
<div key={t.code} style={{ padding: "2px 0", display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Checkbox
|
||||
checked={selectedTasks.includes(t.code)}
|
||||
onChange={(e) => handleTaskToggle(t.code, e.target.checked)}
|
||||
@@ -385,6 +407,31 @@ const TaskSelector: React.FC<TaskSelectorProps> = ({
|
||||
)}
|
||||
{!t.is_common && <Tag color="default" style={{ marginLeft: 6, fontSize: 11 }}>不常用</Tag>}
|
||||
</Checkbox>
|
||||
{/* per-task 最小执行间隔 */}
|
||||
{onTaskIntervalsChange && (
|
||||
<Space size={2} style={{ marginLeft: "auto", flexShrink: 0 }}>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
max={9999}
|
||||
placeholder="间隔"
|
||||
value={taskIntervals[t.code]?.value || null}
|
||||
onChange={(v) => handleIntervalChange(t.code, "value", v ?? 0)}
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
value={taskIntervals[t.code]?.unit ?? "minutes"}
|
||||
onChange={(v) => handleIntervalChange(t.code, "unit", v)}
|
||||
style={{ width: 72 }}
|
||||
options={[
|
||||
{ label: "分钟", value: "minutes" },
|
||||
{ label: "小时", value: "hours" },
|
||||
{ label: "天", value: "days" },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
145
apps/admin-web/src/components/ops/GitStatusSection.tsx
Normal file
145
apps/admin-web/src/components/ops/GitStatusSection.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Git 状态与配置区块
|
||||
*
|
||||
* 展示各环境 Git 分支/提交信息,支持 pull / 同步依赖 / 查看 .env 配置。
|
||||
* 从 OpsPanel 拆分,可独立使用(如 Dashboard 聚合页)。
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Tag,
|
||||
Button,
|
||||
Space,
|
||||
Descriptions,
|
||||
Modal,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Input,
|
||||
message,
|
||||
} from "antd";
|
||||
import {
|
||||
CloudDownloadOutlined,
|
||||
SyncOutlined,
|
||||
FileTextOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ServiceStatus, GitInfo } from "../../api/opsPanel";
|
||||
import { fetchEnvFile } from "../../api/opsPanel";
|
||||
|
||||
const { Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
export interface GitStatusSectionProps {
|
||||
gitInfos: GitInfo[];
|
||||
/** 用于查找 env 对应的 label */
|
||||
services: ServiceStatus[];
|
||||
actionLoading: Record<string, boolean>;
|
||||
onPull: (env: string) => void;
|
||||
onSyncDeps: (env: string) => void;
|
||||
}
|
||||
|
||||
const GitStatusSection: React.FC<GitStatusSectionProps> = ({
|
||||
gitInfos,
|
||||
services,
|
||||
actionLoading,
|
||||
onPull,
|
||||
onSyncDeps,
|
||||
}) => {
|
||||
const [envModalOpen, setEnvModalOpen] = useState(false);
|
||||
const [envModalContent, setEnvModalContent] = useState("");
|
||||
const [envModalTitle, setEnvModalTitle] = useState("");
|
||||
|
||||
const handleViewEnv = async (env: string, label: string) => {
|
||||
try {
|
||||
const r = await fetchEnvFile(env);
|
||||
setEnvModalTitle(`${label} .env 配置`);
|
||||
setEnvModalContent(r.content);
|
||||
setEnvModalOpen(true);
|
||||
} catch {
|
||||
message.error("读取配置文件失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card size="small" title="代码与配置" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16}>
|
||||
{gitInfos.map((git) => {
|
||||
const envCfg = services.find((s) => s.env === git.env);
|
||||
const label = envCfg?.label ?? git.env;
|
||||
return (
|
||||
<Col span={12} key={git.env}>
|
||||
<Card size="small" type="inner" title={label}>
|
||||
<Descriptions size="small" column={1} style={{ marginBottom: 12 }}>
|
||||
<Descriptions.Item label="分支">
|
||||
<Tag color="blue">{git.branch}</Tag>
|
||||
{git.has_local_changes && (
|
||||
<Tooltip title="工作区有未提交的变更">
|
||||
<Tag color="warning">有变更</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最新提交">
|
||||
<Text code style={{ fontSize: 12 }}>{git.last_commit_hash}</Text>
|
||||
<Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>
|
||||
{git.last_commit_message}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="提交时间">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{git.last_commit_time}</Text>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
loading={actionLoading[`pull-${git.env}`]}
|
||||
onClick={() => onPull(git.env)}
|
||||
>
|
||||
Git Pull
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<SyncOutlined />}
|
||||
loading={actionLoading[`sync-${git.env}`]}
|
||||
onClick={() => onSyncDeps(git.env)}
|
||||
>
|
||||
同步依赖
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => handleViewEnv(git.env, label)}
|
||||
>
|
||||
查看配置
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 配置查看弹窗 */}
|
||||
<Modal
|
||||
title={envModalTitle}
|
||||
open={envModalOpen}
|
||||
onCancel={() => setEnvModalOpen(false)}
|
||||
footer={null}
|
||||
width={700}
|
||||
>
|
||||
<TextArea
|
||||
value={envModalContent}
|
||||
readOnly
|
||||
autoSize={{ minRows: 10, maxRows: 30 }}
|
||||
style={{ fontFamily: "monospace", fontSize: 12 }}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitStatusSection;
|
||||
127
apps/admin-web/src/components/ops/ServiceStatusSection.tsx
Normal file
127
apps/admin-web/src/components/ops/ServiceStatusSection.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 服务状态区块
|
||||
*
|
||||
* 展示各环境服务运行状态,支持启动/停止/重启操作。
|
||||
* 从 OpsPanel 拆分,可独立使用(如 Dashboard 聚合页)。
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Tag,
|
||||
Button,
|
||||
Space,
|
||||
Descriptions,
|
||||
} from "antd";
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
ReloadOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ServiceStatus } from "../../api/opsPanel";
|
||||
|
||||
/** 秒数格式化为 "Xd Xh Xm" */
|
||||
function formatUptime(seconds: number | null): string {
|
||||
if (seconds == null) return "-";
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const parts: string[] = [];
|
||||
if (d > 0) parts.push(`${d}天`);
|
||||
if (h > 0) parts.push(`${h}时`);
|
||||
parts.push(`${m}分`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
export interface ServiceStatusSectionProps {
|
||||
services: ServiceStatus[];
|
||||
actionLoading: Record<string, boolean>;
|
||||
onStart: (env: string) => void;
|
||||
onStop: (env: string) => void;
|
||||
onRestart: (env: string) => void;
|
||||
}
|
||||
|
||||
const ServiceStatusSection: React.FC<ServiceStatusSectionProps> = ({
|
||||
services,
|
||||
actionLoading,
|
||||
onStart,
|
||||
onStop,
|
||||
onRestart,
|
||||
}) => (
|
||||
<Card size="small" title="服务状态" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16}>
|
||||
{services.map((svc) => (
|
||||
<Col span={12} key={svc.env}>
|
||||
<Card
|
||||
size="small"
|
||||
type="inner"
|
||||
title={
|
||||
<Space>
|
||||
{svc.running
|
||||
? <CheckCircleOutlined style={{ color: "#52c41a" }} />
|
||||
: <CloseCircleOutlined style={{ color: "#ff4d4f" }} />}
|
||||
{svc.label}
|
||||
<Tag color={svc.running ? "success" : "error"}>
|
||||
{svc.running ? "运行中" : "已停止"}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={<Tag>:{svc.port}</Tag>}
|
||||
>
|
||||
{svc.running && (
|
||||
<Descriptions size="small" column={3} style={{ marginBottom: 12 }}>
|
||||
<Descriptions.Item label="PID">{svc.pid}</Descriptions.Item>
|
||||
<Descriptions.Item label="运行时长">
|
||||
<ClockCircleOutlined style={{ marginRight: 4 }} />
|
||||
{formatUptime(svc.uptime_seconds)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="内存">{svc.memory_mb ?? "-"} MB</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
<Space>
|
||||
{!svc.running && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={actionLoading[`start-${svc.env}`]}
|
||||
onClick={() => onStart(svc.env)}
|
||||
>
|
||||
启动
|
||||
</Button>
|
||||
)}
|
||||
{svc.running && (
|
||||
<>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<PauseCircleOutlined />}
|
||||
loading={actionLoading[`stop-${svc.env}`]}
|
||||
onClick={() => onStop(svc.env)}
|
||||
>
|
||||
停止
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={actionLoading[`restart-${svc.env}`]}
|
||||
onClick={() => onRestart(svc.env)}
|
||||
>
|
||||
重启
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default ServiceStatusSection;
|
||||
65
apps/admin-web/src/components/ops/SystemResourceSection.tsx
Normal file
65
apps/admin-web/src/components/ops/SystemResourceSection.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 系统资源区块
|
||||
*
|
||||
* 展示服务器 CPU / 内存 / 磁盘使用情况。
|
||||
* 从 OpsPanel 拆分,可独立使用(如 Dashboard 聚合页)。
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Card, Row, Col, Statistic, Progress, Typography } from "antd";
|
||||
import type { SystemInfo } from "../../api/opsPanel";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export interface SystemResourceSectionProps {
|
||||
system: SystemInfo;
|
||||
}
|
||||
|
||||
const SystemResourceSection: React.FC<SystemResourceSectionProps> = ({ system }) => (
|
||||
<Card size="small" title="服务器资源" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={24}>
|
||||
<Col span={8}>
|
||||
<Statistic title="CPU 使用率" value={system.cpu_percent} suffix="%" />
|
||||
<Progress
|
||||
percent={system.cpu_percent}
|
||||
size="small"
|
||||
status={system.cpu_percent > 80 ? "exception" : "normal"}
|
||||
showInfo={false}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="内存"
|
||||
value={system.memory_used_gb}
|
||||
suffix={`/ ${system.memory_total_gb} GB`}
|
||||
precision={1}
|
||||
/>
|
||||
<Progress
|
||||
percent={system.memory_percent}
|
||||
size="small"
|
||||
status={system.memory_percent > 85 ? "exception" : "normal"}
|
||||
showInfo={false}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="磁盘"
|
||||
value={system.disk_used_gb}
|
||||
suffix={`/ ${system.disk_total_gb} GB`}
|
||||
precision={1}
|
||||
/>
|
||||
<Progress
|
||||
percent={system.disk_percent}
|
||||
size="small"
|
||||
status={system.disk_percent > 90 ? "exception" : "normal"}
|
||||
showInfo={false}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Text type="secondary" style={{ fontSize: 12, marginTop: 8, display: "block" }}>
|
||||
开机时间:{new Date(system.boot_time).toLocaleString()}
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default SystemResourceSection;
|
||||
14
apps/admin-web/src/components/ops/index.ts
Normal file
14
apps/admin-web/src/components/ops/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* OpsPanel 子组件导出
|
||||
*
|
||||
* 从 OpsPanel 拆分的三个独立区块,可被 Dashboard 等页面单独引用。
|
||||
*/
|
||||
|
||||
export { default as SystemResourceSection } from "./SystemResourceSection";
|
||||
export type { SystemResourceSectionProps } from "./SystemResourceSection";
|
||||
|
||||
export { default as ServiceStatusSection } from "./ServiceStatusSection";
|
||||
export type { ServiceStatusSectionProps } from "./ServiceStatusSection";
|
||||
|
||||
export { default as GitStatusSection } from "./GitStatusSection";
|
||||
export type { GitStatusSectionProps } from "./GitStatusSection";
|
||||
217
apps/admin-web/src/pages/AIDashboard.tsx
Normal file
217
apps/admin-web/src/pages/AIDashboard.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* AI 运行总览 Dashboard 页面。
|
||||
*
|
||||
* - 顶部:门店筛选 + 刷新
|
||||
* - 第一行:4 个统计卡片(今日调用、成功率、Token 消耗、平均延迟)
|
||||
* - 第二行:7 天趋势表格 + App 调用占比表格
|
||||
* - 第三行:Token 预算进度条 + App 健康状态
|
||||
* - 第四行:告警列表
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Card, Row, Col, Statistic, Table, Tag, Badge, Progress,
|
||||
Select, Button, message, Typography, Space,
|
||||
} from "antd";
|
||||
import { ReloadOutlined } from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
getDashboard,
|
||||
type DashboardResponse, type DailyTrend, type AppDistItem,
|
||||
type AlertItem, type AppHealthItem,
|
||||
} from "../api/adminAI";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const ALERT_STATUS_COLOR: Record<string, string> = {
|
||||
failed: "red", timeout: "orange", circuit_open: "volcano",
|
||||
};
|
||||
|
||||
const ALERT_MGMT_COLOR: Record<string, string> = {
|
||||
pending: "warning", acknowledged: "success", ignored: "default",
|
||||
};
|
||||
|
||||
const HEALTH_STATUS: Record<string, "success" | "error" | "warning" | "default"> = {
|
||||
success: "success", failed: "error", timeout: "warning", circuit_open: "error",
|
||||
};
|
||||
|
||||
function fmtTime(raw: string | null): string {
|
||||
if (!raw) return "—";
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
// ---- 表格列定义 ----
|
||||
|
||||
const trendColumns: ColumnsType<DailyTrend> = [
|
||||
{ title: "日期", dataIndex: "date", key: "date", width: 120 },
|
||||
{ title: "调用量", dataIndex: "calls", key: "calls", align: "right" },
|
||||
{
|
||||
title: "成功率", dataIndex: "success_rate", key: "success_rate", align: "right",
|
||||
render: (v: number) => `${(v * 100).toFixed(1)}%`,
|
||||
},
|
||||
];
|
||||
|
||||
const distColumns: ColumnsType<AppDistItem> = [
|
||||
{ title: "App 类型", dataIndex: "app_type", key: "app_type" },
|
||||
{ title: "调用次数", dataIndex: "count", key: "count", align: "right" },
|
||||
{
|
||||
title: "占比", dataIndex: "percentage", key: "percentage", align: "right",
|
||||
render: (v: number) => `${(v * 100).toFixed(1)}%`,
|
||||
},
|
||||
];
|
||||
|
||||
const alertColumns: ColumnsType<AlertItem> = [
|
||||
{ title: "ID", dataIndex: "id", key: "id", width: 80 },
|
||||
{ title: "App", dataIndex: "app_type", key: "app_type", width: 160 },
|
||||
{
|
||||
title: "状态", dataIndex: "status", key: "status", width: 110,
|
||||
render: (v: string) => <Tag color={ALERT_STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "告警状态", dataIndex: "alert_status", key: "alert_status", width: 110,
|
||||
render: (v: string | null) => v ? <Tag color={ALERT_MGMT_COLOR[v] ?? "default"}>{v}</Tag> : "—",
|
||||
},
|
||||
{
|
||||
title: "错误信息", dataIndex: "error_message", key: "error_message", ellipsis: true,
|
||||
render: (v: string | null) => v ?? "—",
|
||||
},
|
||||
{ title: "时间", dataIndex: "created_at", key: "created_at", width: 170, render: fmtTime },
|
||||
];
|
||||
|
||||
// ---- 页面组件 ----
|
||||
|
||||
const AIDashboard: React.FC = () => {
|
||||
const [data, setData] = useState<DashboardResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [siteId, setSiteId] = useState<number | undefined>(undefined);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getDashboard(siteId);
|
||||
setData(res);
|
||||
} catch {
|
||||
message.error("加载 Dashboard 失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [siteId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 顶部:门店筛选 + 刷新 */}
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Title level={4} style={{ margin: 0 }}>AI 运行总览</Title>
|
||||
<Select
|
||||
allowClear placeholder="门店筛选" style={{ width: 200 }}
|
||||
value={siteId} onChange={(v) => setSiteId(v)}
|
||||
options={[{ label: "默认门店", value: 2790685415443269 }]}
|
||||
/>
|
||||
</Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
</Row>
|
||||
|
||||
{/* 第一行:4 个统计卡片 */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="今日调用次数" value={data?.today_calls ?? 0} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="今日成功率" suffix="%"
|
||||
value={data ? (data.today_success_rate * 100).toFixed(1) : "0.0"}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="今日 Token 消耗" value={data?.today_tokens ?? 0} /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="平均延迟" suffix="ms"
|
||||
value={data ? data.today_avg_latency_ms.toFixed(0) : "0"}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 第二行:7 天趋势 + App 调用占比 */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={12}>
|
||||
<Card title="近 7 天趋势" size="small">
|
||||
<Table<DailyTrend>
|
||||
columns={trendColumns}
|
||||
dataSource={data?.trend_7d ?? []}
|
||||
rowKey="date" size="small" pagination={false}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card title="App 调用占比" size="small">
|
||||
<Table<AppDistItem>
|
||||
columns={distColumns}
|
||||
dataSource={data?.app_distribution ?? []}
|
||||
rowKey="app_type" size="small" pagination={false}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 第三行:Token 预算 + App 健康状态 */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={12}>
|
||||
<Card title="Token 预算" size="small">
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<span>日预算:{data?.budget.daily_used ?? 0} / {data?.budget.daily_limit ?? 0}</span>
|
||||
<Progress
|
||||
percent={data ? +(data.budget.daily_pct * 100).toFixed(1) : 0}
|
||||
status={data && data.budget.daily_pct > 0.9 ? "exception" : "active"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span>月预算:{data?.budget.monthly_used ?? 0} / {data?.budget.monthly_limit ?? 0}</span>
|
||||
<Progress
|
||||
percent={data ? +(data.budget.monthly_pct * 100).toFixed(1) : 0}
|
||||
status={data && data.budget.monthly_pct > 0.9 ? "exception" : "active"}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card title="App 健康状态" size="small">
|
||||
{(data?.app_health ?? []).map((item: AppHealthItem) => (
|
||||
<div key={item.app_type} style={{ display: "flex", justifyContent: "space-between", padding: "4px 0" }}>
|
||||
<span>{item.app_type}</span>
|
||||
<Space>
|
||||
<Badge status={HEALTH_STATUS[item.last_status ?? ""] ?? "default"} text={item.last_status ?? "无记录"} />
|
||||
<span style={{ fontSize: 12, color: "#999" }}>{fmtTime(item.last_call_at)}</span>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
{(data?.app_health ?? []).length === 0 && <span style={{ color: "#999" }}>暂无数据</span>}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 第四行:告警列表 */}
|
||||
<Card title="告警列表" size="small">
|
||||
<Table<AlertItem>
|
||||
columns={alertColumns}
|
||||
dataSource={data?.recent_alerts ?? []}
|
||||
rowKey="id" size="small" pagination={{ pageSize: 10 }}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIDashboard;
|
||||
329
apps/admin-web/src/pages/AIOperations.tsx
Normal file
329
apps/admin-web/src/pages/AIOperations.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* AI 手动操作页面。
|
||||
*
|
||||
* 4 个 Card 区域:
|
||||
* - Card 1:手动重跑(App + member_id + site_id → 执行)
|
||||
* - Card 2:缓存失效(app_type + member_id + site_id → 失效)
|
||||
* - Card 3:批量执行(app_types + member_ids + site_id → 预估 → 确认)
|
||||
* - Card 4:告警管理(告警列表 + 确认/忽略)
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Card, Row, Col, Select, Input, Button, Table, Tag, Space,
|
||||
Checkbox, Modal, Statistic, message, Typography,
|
||||
} from "antd";
|
||||
import { ReloadOutlined } from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
retryTriggerJob, invalidateCache, createBatchRun, confirmBatchRun,
|
||||
getAlerts, ackAlert, ignoreAlert,
|
||||
type AlertItem, type BatchRunEstimate,
|
||||
} from "../api/adminAI";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Title } = Typography;
|
||||
|
||||
const APP_TYPE_OPTIONS = [
|
||||
{ label: "App3 维客线索", value: "app3_clue" },
|
||||
{ label: "App4 关系分析", value: "app4_analysis" },
|
||||
{ label: "App5 话术参考", value: "app5_tactics" },
|
||||
{ label: "App6 备注分析", value: "app6_note_analysis" },
|
||||
{ label: "App7 客户分析", value: "app7_customer_analysis" },
|
||||
{ label: "App8 线索整理", value: "app8_clue_consolidated" },
|
||||
];
|
||||
|
||||
const ALERT_STATUS_COLOR: Record<string, string> = {
|
||||
failed: "red", timeout: "orange", circuit_open: "volcano",
|
||||
};
|
||||
|
||||
const ALERT_MGMT_COLOR: Record<string, string> = {
|
||||
pending: "warning", acknowledged: "success", ignored: "default",
|
||||
};
|
||||
|
||||
function fmtTime(raw: string | null): string {
|
||||
if (!raw) return "—";
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
const AIOperations: React.FC = () => {
|
||||
// ---- Card 1: 手动重跑 ----
|
||||
const [retryJobId, setRetryJobId] = useState<string>("");
|
||||
const [retryLoading, setRetryLoading] = useState(false);
|
||||
|
||||
const handleRetry = async () => {
|
||||
const id = Number(retryJobId);
|
||||
if (!id || Number.isNaN(id)) { message.warning("请输入有效的任务 ID"); return; }
|
||||
setRetryLoading(true);
|
||||
try {
|
||||
const res = await retryTriggerJob(id);
|
||||
message.success(`已创建重跑任务 #${res.trigger_job_id}`);
|
||||
setRetryJobId("");
|
||||
} catch {
|
||||
message.error("重跑失败");
|
||||
} finally {
|
||||
setRetryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Card 2: 缓存失效 ----
|
||||
const [cacheAppType, setCacheAppType] = useState<string | undefined>();
|
||||
const [cacheMemberId, setCacheMemberId] = useState<string>("");
|
||||
const [cacheSiteId, setCacheSiteId] = useState<number>(2790685415443269);
|
||||
const [cacheLoading, setCacheLoading] = useState(false);
|
||||
const [cacheResult, setCacheResult] = useState<number | null>(null);
|
||||
|
||||
const handleInvalidate = async () => {
|
||||
setCacheLoading(true);
|
||||
setCacheResult(null);
|
||||
try {
|
||||
const res = await invalidateCache({
|
||||
site_id: cacheSiteId,
|
||||
app_type: cacheAppType,
|
||||
member_id: cacheMemberId ? Number(cacheMemberId) : undefined,
|
||||
});
|
||||
setCacheResult(res.affected_count);
|
||||
message.success(`已失效 ${res.affected_count} 条缓存`);
|
||||
} catch {
|
||||
message.error("缓存失效操作失败");
|
||||
} finally {
|
||||
setCacheLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Card 3: 批量执行 ----
|
||||
const [batchAppTypes, setBatchAppTypes] = useState<string[]>([]);
|
||||
const [batchMemberIds, setBatchMemberIds] = useState<string>("");
|
||||
const [batchSiteId, setBatchSiteId] = useState<number>(2790685415443269);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [batchEstimate, setBatchEstimate] = useState<BatchRunEstimate | null>(null);
|
||||
const [confirmVisible, setConfirmVisible] = useState(false);
|
||||
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||
|
||||
const parseMemberIds = (text: string): number[] =>
|
||||
text.split(/[\n,;\s]+/).map(Number).filter((n) => !Number.isNaN(n) && n > 0);
|
||||
|
||||
const handleBatchEstimate = async () => {
|
||||
const memberIds = parseMemberIds(batchMemberIds);
|
||||
if (batchAppTypes.length === 0) { message.warning("请选择至少一个 App"); return; }
|
||||
if (memberIds.length === 0) { message.warning("请输入有效的会员 ID"); return; }
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res = await createBatchRun({ app_types: batchAppTypes, member_ids: memberIds, site_id: batchSiteId });
|
||||
setBatchEstimate(res);
|
||||
setConfirmVisible(true);
|
||||
} catch {
|
||||
message.error("预估失败");
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchConfirm = async () => {
|
||||
if (!batchEstimate) return;
|
||||
setConfirmLoading(true);
|
||||
try {
|
||||
await confirmBatchRun(batchEstimate.batch_id);
|
||||
message.success("批量执行已启动");
|
||||
setConfirmVisible(false);
|
||||
setBatchEstimate(null);
|
||||
setBatchAppTypes([]);
|
||||
setBatchMemberIds("");
|
||||
} catch {
|
||||
message.error("确认执行失败");
|
||||
} finally {
|
||||
setConfirmLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Card 4: 告警管理 ----
|
||||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||
const [alertTotal, setAlertTotal] = useState(0);
|
||||
const [alertLoading, setAlertLoading] = useState(false);
|
||||
const [alertPage, setAlertPage] = useState(1);
|
||||
|
||||
const loadAlerts = useCallback(async () => {
|
||||
setAlertLoading(true);
|
||||
try {
|
||||
const res = await getAlerts({ page: alertPage, page_size: 10 });
|
||||
setAlerts(res.items);
|
||||
setAlertTotal(res.total);
|
||||
} catch {
|
||||
message.error("加载告警列表失败");
|
||||
} finally {
|
||||
setAlertLoading(false);
|
||||
}
|
||||
}, [alertPage]);
|
||||
|
||||
useEffect(() => { loadAlerts(); }, [loadAlerts]);
|
||||
|
||||
const handleAck = async (id: number) => {
|
||||
try {
|
||||
await ackAlert(id);
|
||||
message.success("已确认告警");
|
||||
loadAlerts();
|
||||
} catch {
|
||||
message.error("确认失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleIgnore = async (id: number) => {
|
||||
try {
|
||||
await ignoreAlert(id);
|
||||
message.success("已忽略告警");
|
||||
loadAlerts();
|
||||
} catch {
|
||||
message.error("忽略失败");
|
||||
}
|
||||
};
|
||||
|
||||
const alertColumns: ColumnsType<AlertItem> = [
|
||||
{ title: "ID", dataIndex: "id", key: "id", width: 70 },
|
||||
{ title: "App", dataIndex: "app_type", key: "app_type", width: 150 },
|
||||
{
|
||||
title: "状态", dataIndex: "status", key: "status", width: 100,
|
||||
render: (v: string) => <Tag color={ALERT_STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "告警状态", dataIndex: "alert_status", key: "alert_status", width: 100,
|
||||
render: (v: string | null) => v ? <Tag color={ALERT_MGMT_COLOR[v] ?? "default"}>{v}</Tag> : "—",
|
||||
},
|
||||
{ title: "错误信息", dataIndex: "error_message", key: "error_message", ellipsis: true, render: (v) => v ?? "—" },
|
||||
{ title: "时间", dataIndex: "created_at", key: "created_at", width: 160, render: fmtTime },
|
||||
{
|
||||
title: "操作", key: "action", width: 140,
|
||||
render: (_: unknown, r: AlertItem) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => handleAck(r.id)} disabled={r.alert_status === "acknowledged"}>确认</Button>
|
||||
<Button size="small" onClick={() => handleIgnore(r.id)} disabled={r.alert_status === "ignored"}>忽略</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4} style={{ marginBottom: 16 }}>AI 手动操作</Title>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
{/* Card 1: 手动重跑 */}
|
||||
<Col span={12}>
|
||||
<Card title="手动重跑" size="small">
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<Input
|
||||
placeholder="调度任务 ID" value={retryJobId}
|
||||
onChange={(e) => setRetryJobId(e.target.value)}
|
||||
/>
|
||||
<Button type="primary" onClick={handleRetry} loading={retryLoading}>执行重跑</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Card 2: 缓存失效 */}
|
||||
<Col span={12}>
|
||||
<Card title="缓存失效" size="small">
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<Select
|
||||
allowClear placeholder="App 类型(可选)" style={{ width: "100%" }}
|
||||
value={cacheAppType} onChange={setCacheAppType}
|
||||
options={APP_TYPE_OPTIONS}
|
||||
/>
|
||||
<Input
|
||||
placeholder="会员 ID(可选)" value={cacheMemberId}
|
||||
onChange={(e) => setCacheMemberId(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
placeholder="门店" style={{ width: "100%" }}
|
||||
value={cacheSiteId} onChange={setCacheSiteId}
|
||||
options={[{ label: "默认门店", value: 2790685415443269 }]}
|
||||
/>
|
||||
<Space>
|
||||
<Button type="primary" danger onClick={handleInvalidate} loading={cacheLoading}>执行失效</Button>
|
||||
{cacheResult != null && <Statistic title="受影响记录" value={cacheResult} />}
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Card 3: 批量执行 */}
|
||||
<Card title="批量执行" size="small" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>选择 App</div>
|
||||
<Checkbox.Group
|
||||
options={APP_TYPE_OPTIONS}
|
||||
value={batchAppTypes}
|
||||
onChange={(v) => setBatchAppTypes(v as string[])}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>会员 ID(每行一个或逗号分隔)</div>
|
||||
<TextArea
|
||||
rows={6} value={batchMemberIds}
|
||||
onChange={(e) => setBatchMemberIds(e.target.value)}
|
||||
placeholder="例如: 12345 67890"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>门店</div>
|
||||
<Select
|
||||
style={{ width: "100%", marginBottom: 16 }}
|
||||
value={batchSiteId} onChange={setBatchSiteId}
|
||||
options={[{ label: "默认门店", value: 2790685415443269 }]}
|
||||
/>
|
||||
<Button type="primary" onClick={handleBatchEstimate} loading={batchLoading}>
|
||||
预估并执行
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 批量执行确认弹窗 */}
|
||||
<Modal
|
||||
title="确认批量执行"
|
||||
open={confirmVisible}
|
||||
onCancel={() => { setConfirmVisible(false); setBatchEstimate(null); }}
|
||||
onOk={handleBatchConfirm}
|
||||
confirmLoading={confirmLoading}
|
||||
okText="确认执行" cancelText="取消"
|
||||
>
|
||||
{batchEstimate && (
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Statistic title="预估调用次数" value={batchEstimate.estimated_calls} suffix="次" />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic title="预估 Token 消耗" value={batchEstimate.estimated_tokens} />
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
<p style={{ marginTop: 16, color: "#faad14" }}>
|
||||
确认后将在后台异步执行,请确保预算充足。
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
{/* Card 4: 告警管理 */}
|
||||
<Card
|
||||
title="告警管理" size="small"
|
||||
extra={<Button icon={<ReloadOutlined />} size="small" onClick={loadAlerts}>刷新</Button>}
|
||||
>
|
||||
<Table<AlertItem>
|
||||
columns={alertColumns}
|
||||
dataSource={alerts}
|
||||
rowKey="id" size="small"
|
||||
loading={alertLoading}
|
||||
pagination={{
|
||||
current: alertPage, pageSize: 10, total: alertTotal,
|
||||
onChange: (p) => setAlertPage(p),
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIOperations;
|
||||
229
apps/admin-web/src/pages/AIRunLogs.tsx
Normal file
229
apps/admin-web/src/pages/AIRunLogs.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* AI 调用明细页面。
|
||||
*
|
||||
* - 顶部筛选器:app_type / status / trigger_type / site_id / 日期范围
|
||||
* - 主体:分页表格(app_type、trigger_type、member_id、tokens、延迟、状态)
|
||||
* - 点击行:Drawer 展示完整 prompt / response / error_message
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Card, Table, Tag, Select, Button, DatePicker, Row, Space,
|
||||
Drawer, Descriptions, message, Typography,
|
||||
} from "antd";
|
||||
import { ReloadOutlined } from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
getRunLogs, getRunLogDetail,
|
||||
type RunLogItem, type RunLogDetailResponse, type RunLogQuery,
|
||||
} from "../api/adminAI";
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
const { Title } = Typography;
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
success: "green", failed: "red", timeout: "orange",
|
||||
circuit_open: "volcano", pending: "default", running: "processing",
|
||||
};
|
||||
|
||||
function fmtTime(raw: string | null): string {
|
||||
if (!raw) return "—";
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
const AIRunLogs: React.FC = () => {
|
||||
const [items, setItems] = useState<RunLogItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
|
||||
// 筛选状态
|
||||
const [appType, setAppType] = useState<string | undefined>();
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
const [triggerType, setTriggerType] = useState<string | undefined>();
|
||||
const [siteId, setSiteId] = useState<number | undefined>();
|
||||
const [dateRange, setDateRange] = useState<[string, string] | null>(null);
|
||||
|
||||
// Drawer 详情
|
||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||
const [detail, setDetail] = useState<RunLogDetailResponse | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: RunLogQuery = {
|
||||
page, page_size: pageSize,
|
||||
app_type: appType, status, trigger_type: triggerType,
|
||||
site_id: siteId,
|
||||
date_from: dateRange?.[0], date_to: dateRange?.[1],
|
||||
};
|
||||
const res = await getRunLogs(params);
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
} catch {
|
||||
message.error("加载调用记录失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, appType, status, triggerType, siteId, dateRange]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleRowClick = async (id: number) => {
|
||||
setDetailLoading(true);
|
||||
setDrawerVisible(true);
|
||||
try {
|
||||
const res = await getRunLogDetail(id);
|
||||
setDetail(res);
|
||||
} catch {
|
||||
message.error("加载详情失败");
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const APP_TYPE_OPTIONS = [
|
||||
"app1_chat", "app2_finance", "app3_clue", "app4_analysis",
|
||||
"app5_tactics", "app6_note_analysis", "app7_customer_analysis",
|
||||
"app8_clue_consolidated",
|
||||
].map((v) => ({ label: v, value: v }));
|
||||
|
||||
const columns: ColumnsType<RunLogItem> = [
|
||||
{ title: "ID", dataIndex: "id", key: "id", width: 70 },
|
||||
{ title: "App 类型", dataIndex: "app_type", key: "app_type", width: 160 },
|
||||
{ title: "触发方式", dataIndex: "trigger_type", key: "trigger_type", width: 110 },
|
||||
{ title: "会员 ID", dataIndex: "member_id", key: "member_id", width: 100, render: (v) => v ?? "—" },
|
||||
{ title: "Tokens", dataIndex: "tokens_used", key: "tokens_used", width: 90, align: "right" },
|
||||
{
|
||||
title: "延迟", dataIndex: "latency_ms", key: "latency_ms", width: 90, align: "right",
|
||||
render: (v: number | null) => v != null ? `${v}ms` : "—",
|
||||
},
|
||||
{
|
||||
title: "状态", dataIndex: "status", key: "status", width: 110,
|
||||
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
|
||||
},
|
||||
{ title: "时间", dataIndex: "created_at", key: "created_at", width: 170, render: fmtTime },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>AI 调用明细</Title>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
</Row>
|
||||
|
||||
{/* 筛选器行 */}
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Select
|
||||
allowClear placeholder="App 类型" style={{ width: 180 }}
|
||||
value={appType} onChange={(v) => { setAppType(v); setPage(1); }}
|
||||
options={APP_TYPE_OPTIONS}
|
||||
/>
|
||||
<Select
|
||||
allowClear placeholder="状态" style={{ width: 130 }}
|
||||
value={status} onChange={(v) => { setStatus(v); setPage(1); }}
|
||||
options={[
|
||||
{ label: "success", value: "success" },
|
||||
{ label: "failed", value: "failed" },
|
||||
{ label: "timeout", value: "timeout" },
|
||||
{ label: "circuit_open", value: "circuit_open" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
allowClear placeholder="触发方式" style={{ width: 130 }}
|
||||
value={triggerType} onChange={(v) => { setTriggerType(v); setPage(1); }}
|
||||
options={[
|
||||
{ label: "event", value: "event" },
|
||||
{ label: "scheduled", value: "scheduled" },
|
||||
{ label: "manual", value: "manual" },
|
||||
{ label: "backfill", value: "backfill" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
allowClear placeholder="门店" style={{ width: 180 }}
|
||||
value={siteId} onChange={(v) => { setSiteId(v); setPage(1); }}
|
||||
options={[{ label: "默认门店", value: 2790685415443269 }]}
|
||||
/>
|
||||
<RangePicker
|
||||
onChange={(_, dateStrings) => {
|
||||
setDateRange(dateStrings[0] ? [dateStrings[0], dateStrings[1]] : null);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* 主体表格 */}
|
||||
<Table<RunLogItem>
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1000 }}
|
||||
onRow={(record) => ({
|
||||
onClick: () => handleRowClick(record.id),
|
||||
style: { cursor: "pointer" },
|
||||
})}
|
||||
pagination={{
|
||||
current: page, pageSize, total,
|
||||
onChange: (p) => setPage(p),
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 详情 Drawer */}
|
||||
<Drawer
|
||||
title={`调用记录详情 #${detail?.id ?? ""}`}
|
||||
open={drawerVisible}
|
||||
onClose={() => { setDrawerVisible(false); setDetail(null); }}
|
||||
width={640}
|
||||
loading={detailLoading}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={2} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="App 类型">{detail.app_type}</Descriptions.Item>
|
||||
<Descriptions.Item label="触发方式">{detail.trigger_type}</Descriptions.Item>
|
||||
<Descriptions.Item label="会员 ID">{detail.member_id ?? "—"}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店 ID">{detail.site_id}</Descriptions.Item>
|
||||
<Descriptions.Item label="Tokens">{detail.tokens_used}</Descriptions.Item>
|
||||
<Descriptions.Item label="延迟">{detail.latency_ms != null ? `${detail.latency_ms}ms` : "—"}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STATUS_COLOR[detail.status] ?? "default"}>{detail.status}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Session ID">{detail.session_id ?? "—"}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间" span={2}>{fmtTime(detail.created_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="完成时间" span={2}>{fmtTime(detail.finished_at)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{detail.error_message && (
|
||||
<Card title="错误信息" size="small" style={{ marginBottom: 16 }}>
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap", color: "#cf1322" }}>
|
||||
{detail.error_message}
|
||||
</pre>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="Request Prompt" size="small" style={{ marginBottom: 16 }}>
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap", maxHeight: 300, overflow: "auto", background: "#f5f5f5", padding: 8, borderRadius: 4 }}>
|
||||
{detail.request_prompt ?? "(无)"}
|
||||
</pre>
|
||||
</Card>
|
||||
|
||||
<Card title="Response" size="small">
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap", maxHeight: 300, overflow: "auto", background: "#f5f5f5", padding: 8, borderRadius: 4 }}>
|
||||
{detail.response_text ?? "(无)"}
|
||||
</pre>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIRunLogs;
|
||||
247
apps/admin-web/src/pages/AITriggerJobs.tsx
Normal file
247
apps/admin-web/src/pages/AITriggerJobs.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* AI 调度状态页面。
|
||||
*
|
||||
* - 顶部筛选器:event_type / status / site_id / 日期范围
|
||||
* - 统计行:今日去重跳过数
|
||||
* - 主体:分页表格(事件类型、会员、状态、执行链、耗时、操作列)
|
||||
* - 操作列:查看详情 Modal、手动重跑 Popconfirm
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Card, Table, Tag, Select, Button, DatePicker, Row, Col, Space,
|
||||
Statistic, Modal, Popconfirm, Descriptions, message, Typography,
|
||||
} from "antd";
|
||||
import { ReloadOutlined } from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
getTriggerJobs, getTriggerJobDetail, retryTriggerJob,
|
||||
type TriggerJobItem, type TriggerJobDetailResponse, type TriggerJobQuery,
|
||||
} from "../api/adminAI";
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
const { Title } = Typography;
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
pending: "default", running: "processing", success: "success",
|
||||
failed: "error", skipped_duplicate: "warning", timeout: "orange",
|
||||
};
|
||||
|
||||
function fmtTime(raw: string | null): string {
|
||||
if (!raw) return "—";
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
function calcDuration(start: string | null, end: string | null): string {
|
||||
if (!start || !end) return "—";
|
||||
const ms = new Date(end).getTime() - new Date(start).getTime();
|
||||
if (Number.isNaN(ms) || ms < 0) return "—";
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
const AITriggerJobs: React.FC = () => {
|
||||
const [items, setItems] = useState<TriggerJobItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [skipped, setSkipped] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
|
||||
// 筛选状态
|
||||
const [eventType, setEventType] = useState<string | undefined>();
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
const [siteId, setSiteId] = useState<number | undefined>();
|
||||
const [dateRange, setDateRange] = useState<[string, string] | null>(null);
|
||||
|
||||
// 详情 Modal
|
||||
const [detailVisible, setDetailVisible] = useState(false);
|
||||
const [detail, setDetail] = useState<TriggerJobDetailResponse | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: TriggerJobQuery = {
|
||||
page, page_size: pageSize,
|
||||
event_type: eventType, status, site_id: siteId,
|
||||
date_from: dateRange?.[0], date_to: dateRange?.[1],
|
||||
};
|
||||
const res = await getTriggerJobs(params);
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
setSkipped(res.today_skipped_duplicates);
|
||||
} catch {
|
||||
message.error("加载调度任务失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, eventType, status, siteId, dateRange]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleViewDetail = async (id: number) => {
|
||||
setDetailLoading(true);
|
||||
setDetailVisible(true);
|
||||
try {
|
||||
const res = await getTriggerJobDetail(id);
|
||||
setDetail(res);
|
||||
} catch {
|
||||
message.error("加载详情失败");
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = async (id: number) => {
|
||||
try {
|
||||
const res = await retryTriggerJob(id);
|
||||
message.success(`已创建重跑任务 #${res.trigger_job_id}`);
|
||||
load();
|
||||
} catch {
|
||||
message.error("重跑失败");
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<TriggerJobItem> = [
|
||||
{ title: "ID", dataIndex: "id", key: "id", width: 70 },
|
||||
{ title: "事件类型", dataIndex: "event_type", key: "event_type", width: 140 },
|
||||
{ title: "会员 ID", dataIndex: "member_id", key: "member_id", width: 100, render: (v) => v ?? "—" },
|
||||
{
|
||||
title: "状态", dataIndex: "status", key: "status", width: 120,
|
||||
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
|
||||
},
|
||||
{ title: "执行链", dataIndex: "app_chain", key: "app_chain", ellipsis: true, render: (v) => v ?? "—" },
|
||||
{
|
||||
title: "强制执行", dataIndex: "is_forced", key: "is_forced", width: 80,
|
||||
render: (v: boolean) => v ? <Tag color="blue">是</Tag> : "否",
|
||||
},
|
||||
{
|
||||
title: "耗时", key: "duration", width: 90,
|
||||
render: (_: unknown, r: TriggerJobItem) => calcDuration(r.started_at, r.finished_at),
|
||||
},
|
||||
{ title: "创建时间", dataIndex: "created_at", key: "created_at", width: 170, render: fmtTime },
|
||||
{
|
||||
title: "操作", key: "action", width: 160, fixed: "right" as const,
|
||||
render: (_: unknown, r: TriggerJobItem) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => handleViewDetail(r.id)}>详情</Button>
|
||||
<Popconfirm title="确认手动重跑此任务?" onConfirm={() => handleRetry(r.id)}>
|
||||
<Button size="small" type="link" danger>重跑</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>AI 调度状态</Title>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
</Row>
|
||||
|
||||
{/* 筛选器行 */}
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Select
|
||||
allowClear placeholder="事件类型" style={{ width: 160 }}
|
||||
value={eventType} onChange={(v) => { setEventType(v); setPage(1); }}
|
||||
options={[
|
||||
{ label: "consumption", value: "consumption" },
|
||||
{ label: "note", value: "note" },
|
||||
{ label: "task_assign", value: "task_assign" },
|
||||
{ label: "coach_consumption", value: "coach_consumption" },
|
||||
{ label: "scheduled", value: "scheduled" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
allowClear placeholder="状态" style={{ width: 140 }}
|
||||
value={status} onChange={(v) => { setStatus(v); setPage(1); }}
|
||||
options={[
|
||||
{ label: "pending", value: "pending" },
|
||||
{ label: "running", value: "running" },
|
||||
{ label: "success", value: "success" },
|
||||
{ label: "failed", value: "failed" },
|
||||
{ label: "skipped_duplicate", value: "skipped_duplicate" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
allowClear placeholder="门店" style={{ width: 180 }}
|
||||
value={siteId} onChange={(v) => { setSiteId(v); setPage(1); }}
|
||||
options={[{ label: "默认门店", value: 2790685415443269 }]}
|
||||
/>
|
||||
<RangePicker
|
||||
onChange={(_, dateStrings) => {
|
||||
setDateRange(dateStrings[0] ? [dateStrings[0], dateStrings[1]] : null);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* 统计行 */}
|
||||
<Row style={{ marginBottom: 16 }}>
|
||||
<Col>
|
||||
<Statistic title="今日去重跳过数" value={skipped} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 主体表格 */}
|
||||
<Table<TriggerJobItem>
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page, pageSize, total,
|
||||
onChange: (p) => setPage(p),
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 详情 Modal */}
|
||||
<Modal
|
||||
title={`调度任务详情 #${detail?.id ?? ""}`}
|
||||
open={detailVisible}
|
||||
onCancel={() => { setDetailVisible(false); setDetail(null); }}
|
||||
footer={null} width={640}
|
||||
loading={detailLoading}
|
||||
>
|
||||
{detail && (
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="事件类型">{detail.event_type}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STATUS_COLOR[detail.status] ?? "default"}>{detail.status}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="会员 ID">{detail.member_id ?? "—"}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店 ID">{detail.site_id}</Descriptions.Item>
|
||||
<Descriptions.Item label="执行链">{detail.app_chain ?? "—"}</Descriptions.Item>
|
||||
<Descriptions.Item label="连接器">{detail.connector_type}</Descriptions.Item>
|
||||
<Descriptions.Item label="强制执行">{detail.is_forced ? "是" : "否"}</Descriptions.Item>
|
||||
<Descriptions.Item label="耗时">{calcDuration(detail.started_at, detail.finished_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间" span={2}>{fmtTime(detail.created_at)}</Descriptions.Item>
|
||||
{detail.error_message && (
|
||||
<Descriptions.Item label="错误信息" span={2}>
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap", maxHeight: 200, overflow: "auto" }}>
|
||||
{detail.error_message}
|
||||
</pre>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{detail.payload && (
|
||||
<Descriptions.Item label="Payload" span={2}>
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap", maxHeight: 200, overflow: "auto" }}>
|
||||
{JSON.stringify(detail.payload, null, 2)}
|
||||
</pre>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AITriggerJobs;
|
||||
380
apps/admin-web/src/pages/Dashboard.tsx
Normal file
380
apps/admin-web/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* 运行状态仪表盘(Dashboard)
|
||||
*
|
||||
* 登录后默认首页,聚合 4 个区块:
|
||||
* 1. OpsPanel 子组件(系统资源、服务状态、Git 状态)
|
||||
* 2. 数据库健康监控(DbHealthCard)
|
||||
* 3. AI 运行总览(复用 AIDashboard)
|
||||
* 4. AI 调度摘要(今日触发数、成功率、最近错误 + 跳转链接)
|
||||
*
|
||||
* 跳转链接:
|
||||
* - "ETL 状态详情" → /etl-tasks?tab=status
|
||||
* - "触发器详情" → /triggers?tab=all
|
||||
* - "AI 调度详情" → /triggers?tab=ai
|
||||
*
|
||||
* _Requirements: 2.1, 2.2, 2.6, 2.7, 2.8, 7.1, 7.2_
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Typography,
|
||||
Spin,
|
||||
message,
|
||||
Modal,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Tag,
|
||||
Button,
|
||||
Space,
|
||||
Divider,
|
||||
List,
|
||||
} from "antd";
|
||||
import {
|
||||
DashboardOutlined,
|
||||
ReloadOutlined,
|
||||
RightOutlined,
|
||||
CloseCircleOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { SystemInfo, ServiceStatus, GitInfo } from "../api/opsPanel";
|
||||
import {
|
||||
fetchSystemInfo,
|
||||
fetchServicesStatus,
|
||||
fetchGitInfo,
|
||||
startService,
|
||||
stopService,
|
||||
restartService,
|
||||
gitPull,
|
||||
syncDeps,
|
||||
} from "../api/opsPanel";
|
||||
import {
|
||||
SystemResourceSection,
|
||||
ServiceStatusSection,
|
||||
GitStatusSection,
|
||||
} from "../components/ops";
|
||||
import { fetchDbHealth } from "../api/dbHealth";
|
||||
import type { DbHealthItem } from "../api/dbHealth";
|
||||
import DbHealthCard from "../components/DbHealthCard";
|
||||
import AIDashboard from "./AIDashboard";
|
||||
import { getTriggerJobs, type TriggerJobItem } from "../api/adminAI";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/* 超时阈值(毫秒) */
|
||||
const DB_HEALTH_TIMEOUT = 10_000;
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ---- OpsPanel 数据 ----
|
||||
const [system, setSystem] = useState<SystemInfo | null>(null);
|
||||
const [services, setServices] = useState<ServiceStatus[]>([]);
|
||||
const [gitInfos, setGitInfos] = useState<GitInfo[]>([]);
|
||||
const [opsLoading, setOpsLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
|
||||
|
||||
// ---- DB 健康数据 ----
|
||||
const [dbItems, setDbItems] = useState<DbHealthItem[]>([]);
|
||||
const [dbLoading, setDbLoading] = useState(true);
|
||||
const [dbTimeout, setDbTimeout] = useState(false);
|
||||
|
||||
// ---- AI 调度摘要 ----
|
||||
const [triggerItems, setTriggerItems] = useState<TriggerJobItem[]>([]);
|
||||
const [triggerTotal, setTriggerTotal] = useState(0);
|
||||
const [triggerLoading, setTriggerLoading] = useState(true);
|
||||
|
||||
// ---- OpsPanel 数据加载(复用 OpsPanel.tsx 逻辑) ----
|
||||
|
||||
const loadOps = useCallback(async () => {
|
||||
try {
|
||||
const [sys, svc, git] = await Promise.all([
|
||||
fetchSystemInfo(),
|
||||
fetchServicesStatus(),
|
||||
fetchGitInfo(),
|
||||
]);
|
||||
setSystem(sys);
|
||||
setServices(svc);
|
||||
setGitInfos(git);
|
||||
} catch {
|
||||
message.error("加载运维数据失败");
|
||||
} finally {
|
||||
setOpsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ---- DB 健康加载 ----
|
||||
|
||||
const loadDbHealth = useCallback(async () => {
|
||||
setDbLoading(true);
|
||||
setDbTimeout(false);
|
||||
const timer = setTimeout(() => {
|
||||
setDbTimeout(true);
|
||||
setDbLoading(false);
|
||||
}, DB_HEALTH_TIMEOUT);
|
||||
try {
|
||||
const items = await fetchDbHealth();
|
||||
clearTimeout(timer);
|
||||
setDbItems(items);
|
||||
setDbTimeout(false);
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
message.error("加载数据库健康数据失败");
|
||||
} finally {
|
||||
setDbLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ---- AI 调度摘要加载 ----
|
||||
|
||||
const loadTriggerSummary = useCallback(async () => {
|
||||
setTriggerLoading(true);
|
||||
try {
|
||||
const res = await getTriggerJobs({ page: 1, page_size: 50 });
|
||||
setTriggerItems(res.items);
|
||||
setTriggerTotal(res.total);
|
||||
} catch {
|
||||
message.error("加载 AI 调度数据失败");
|
||||
} finally {
|
||||
setTriggerLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ---- 初始化 + 定时刷新 ----
|
||||
|
||||
useEffect(() => {
|
||||
loadOps();
|
||||
loadDbHealth();
|
||||
loadTriggerSummary();
|
||||
const timer = setInterval(loadOps, 15_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [loadOps, loadDbHealth, loadTriggerSummary]);
|
||||
|
||||
// ---- OpsPanel 操作处理(复用 OpsPanel.tsx 逻辑) ----
|
||||
|
||||
const withAction = async (key: string, fn: () => Promise<void>) => {
|
||||
setActionLoading((prev) => ({ ...prev, [key]: true }));
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
setActionLoading((prev) => ({ ...prev, [key]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = (env: string) =>
|
||||
withAction(`start-${env}`, async () => {
|
||||
const r = await startService(env);
|
||||
r.success ? message.success(r.message) : message.warning(r.message);
|
||||
await loadOps();
|
||||
});
|
||||
|
||||
const handleStop = (env: string) =>
|
||||
withAction(`stop-${env}`, async () => {
|
||||
const r = await stopService(env);
|
||||
r.success ? message.success(r.message) : message.warning(r.message);
|
||||
await loadOps();
|
||||
});
|
||||
|
||||
const handleRestart = (env: string) =>
|
||||
withAction(`restart-${env}`, async () => {
|
||||
const r = await restartService(env);
|
||||
r.success ? message.success(r.message) : message.warning(r.message);
|
||||
await loadOps();
|
||||
});
|
||||
|
||||
const handlePull = (env: string) =>
|
||||
withAction(`pull-${env}`, async () => {
|
||||
const r = await gitPull(env);
|
||||
if (r.success) {
|
||||
message.success("拉取成功");
|
||||
Modal.info({
|
||||
title: `Git Pull - ${env}`,
|
||||
content: (
|
||||
<pre style={{ maxHeight: 300, overflow: "auto", fontSize: 12 }}>
|
||||
{r.output}
|
||||
</pre>
|
||||
),
|
||||
width: 600,
|
||||
});
|
||||
} else {
|
||||
message.error("拉取失败");
|
||||
Modal.error({
|
||||
title: `Git Pull 失败 - ${env}`,
|
||||
content: (
|
||||
<pre style={{ maxHeight: 300, overflow: "auto", fontSize: 12 }}>
|
||||
{r.output}
|
||||
</pre>
|
||||
),
|
||||
width: 600,
|
||||
});
|
||||
}
|
||||
await loadOps();
|
||||
});
|
||||
|
||||
const handleSyncDeps = (env: string) =>
|
||||
withAction(`sync-${env}`, async () => {
|
||||
const r = await syncDeps(env);
|
||||
r.success ? message.success("依赖同步完成") : message.error(r.message);
|
||||
});
|
||||
|
||||
// ---- AI 调度摘要计算 ----
|
||||
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
const todayJobs = triggerItems.filter(
|
||||
(j) => j.created_at && j.created_at.startsWith(todayStr),
|
||||
);
|
||||
const todayCount = todayJobs.length;
|
||||
const todaySuccess = todayJobs.filter((j) => j.status === "success").length;
|
||||
const todaySuccessRate =
|
||||
todayCount > 0 ? ((todaySuccess / todayCount) * 100).toFixed(1) : "0.0";
|
||||
const recentErrors = triggerItems
|
||||
.filter((j) => j.status === "failed")
|
||||
.slice(0, 5);
|
||||
|
||||
// ---- 渲染 ----
|
||||
|
||||
if (opsLoading) {
|
||||
return (
|
||||
<Spin
|
||||
size="large"
|
||||
style={{ display: "flex", justifyContent: "center", marginTop: 120 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 页面标题 + 快捷跳转 */}
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<DashboardOutlined style={{ marginRight: 8 }} />
|
||||
运行状态
|
||||
</Title>
|
||||
<Space>
|
||||
<Button size="small" onClick={() => navigate("/etl-tasks?tab=status")}>
|
||||
ETL 状态详情 <RightOutlined />
|
||||
</Button>
|
||||
<Button size="small" onClick={() => navigate("/triggers?tab=all")}>
|
||||
触发器详情 <RightOutlined />
|
||||
</Button>
|
||||
<Button size="small" onClick={() => navigate("/triggers?tab=ai")}>
|
||||
AI 调度详情 <RightOutlined />
|
||||
</Button>
|
||||
</Space>
|
||||
</Row>
|
||||
|
||||
{/* 区块 1:OpsPanel 子组件 */}
|
||||
{system && <SystemResourceSection system={system} />}
|
||||
|
||||
<ServiceStatusSection
|
||||
services={services}
|
||||
actionLoading={actionLoading}
|
||||
onStart={handleStart}
|
||||
onStop={handleStop}
|
||||
onRestart={handleRestart}
|
||||
/>
|
||||
|
||||
<GitStatusSection
|
||||
gitInfos={gitInfos}
|
||||
services={services}
|
||||
actionLoading={actionLoading}
|
||||
onPull={handlePull}
|
||||
onSyncDeps={handleSyncDeps}
|
||||
/>
|
||||
|
||||
{/* 区块 2:数据库健康监控 */}
|
||||
<DbHealthCard
|
||||
items={dbItems}
|
||||
loading={dbLoading}
|
||||
timeout={dbTimeout}
|
||||
onRetry={loadDbHealth}
|
||||
/>
|
||||
|
||||
{/* 区块 3:AI 运行总览(复用 AIDashboard) */}
|
||||
<Divider orientation="left">AI 运行总览</Divider>
|
||||
<AIDashboard />
|
||||
|
||||
{/* 区块 4:AI 调度摘要 */}
|
||||
<Divider orientation="left">AI 调度摘要</Divider>
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<ThunderboltOutlined />
|
||||
<span>AI 调度摘要</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={loadTriggerSummary}
|
||||
loading={triggerLoading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
{/* 统计卡片行 */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={8}>
|
||||
<Statistic title="今日触发数" value={todayCount} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="今日成功率" value={todaySuccessRate} suffix="%" />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="总记录数"
|
||||
value={triggerTotal}
|
||||
valueStyle={{ fontSize: 16 }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 最近错误列表 */}
|
||||
<Card type="inner" size="small" title="最近错误" style={{ marginBottom: 12 }}>
|
||||
{recentErrors.length === 0 ? (
|
||||
<Text type="secondary">暂无失败记录</Text>
|
||||
) : (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={recentErrors}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<Space>
|
||||
<CloseCircleOutlined style={{ color: "#ff4d4f" }} />
|
||||
<Tag color="error">{item.status}</Tag>
|
||||
<Text>{item.event_type}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
ID: {item.id}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.created_at
|
||||
? new Date(item.created_at).toLocaleString("zh-CN")
|
||||
: "—"}
|
||||
</Text>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 跳转链接 */}
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => navigate("/triggers?tab=ai")}
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
查看 AI 调度详情 <RightOutlined />
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
760
apps/admin-web/src/pages/DevTrace.tsx
Normal file
760
apps/admin-web/src/pages/DevTrace.tsx
Normal file
@@ -0,0 +1,760 @@
|
||||
/**
|
||||
* 开发调试全链路日志页面。
|
||||
*
|
||||
* - 顶部:覆盖率状态栏 + 筛选栏
|
||||
* - 左侧:请求列表(Table,分页)
|
||||
* - 右侧:选中请求的 span 链路树
|
||||
* - 设置面板:Drawer(日志开关、保留天数、清理等)
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Table, Tag, Alert, Button, Select, Input, InputNumber,
|
||||
Checkbox, DatePicker, TimePicker, Space, Typography, Row, Col,
|
||||
Drawer, Switch, message, Spin, Tooltip, Divider, Progress,
|
||||
} from "antd";
|
||||
import {
|
||||
SettingOutlined, ReloadOutlined, SearchOutlined,
|
||||
DeleteOutlined, ScanOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
fetchDates, fetchRequests, fetchRequestDetail,
|
||||
fetchCoverage, triggerCoverageScan,
|
||||
fetchSettings, updateSettings, cleanupLogs,
|
||||
} from "../api/devTrace";
|
||||
import type {
|
||||
TraceRequest, TraceDetail, TraceSpan, TraceSettings,
|
||||
TraceFilter, TraceCoverage, SpanType, TraceType, CoverageCategory,
|
||||
} from "../types/devTrace";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// ---- 常量 ----
|
||||
|
||||
const SPAN_TYPE_OPTIONS: SpanType[] = [
|
||||
"HTTP_IN", "AUTH", "ROUTE", "SERVICE",
|
||||
"DB_QUERY", "DB_CONN", "DB_CONN_RELEASE",
|
||||
"HTTP_OUT", "ERROR", "DB_ERROR",
|
||||
"MIDDLEWARE", "MIDDLEWARE_ERROR",
|
||||
"SSE_START", "SSE_EVENT", "SSE_END",
|
||||
"AI_CALL", "AI_STREAM", "AI_ERROR",
|
||||
"WS_CONNECT", "WS_MESSAGE", "WS_DISCONNECT",
|
||||
"JOB_START", "JOB_END", "JOB_ERROR",
|
||||
];
|
||||
|
||||
const TRACE_TYPE_OPTIONS: { label: string; value: TraceType }[] = [
|
||||
{ label: "HTTP", value: "http" },
|
||||
{ label: "SSE", value: "sse" },
|
||||
{ label: "WebSocket", value: "ws" },
|
||||
{ label: "Job", value: "job" },
|
||||
];
|
||||
|
||||
const METHOD_OPTIONS = ["GET", "POST", "PUT", "DELETE", "PATCH"];
|
||||
|
||||
/** span_type → 颜色映射 */
|
||||
const SPAN_COLOR: Record<string, string> = {
|
||||
HTTP_IN: "#1890ff", HTTP_OUT: "#1890ff",
|
||||
AUTH: "#fa8c16",
|
||||
ROUTE: "#1890ff",
|
||||
SERVICE: "#52c41a",
|
||||
DB_QUERY: "#722ed1", DB_CONN: "#722ed1", DB_CONN_RELEASE: "#722ed1",
|
||||
DB_ERROR: "#f5222d",
|
||||
ERROR: "#f5222d",
|
||||
MIDDLEWARE: "#8c8c8c", MIDDLEWARE_ERROR: "#f5222d",
|
||||
SSE_START: "#13c2c2", SSE_EVENT: "#13c2c2", SSE_END: "#13c2c2",
|
||||
AI_CALL: "#2f54eb", AI_STREAM: "#2f54eb", AI_ERROR: "#f5222d",
|
||||
WS_CONNECT: "#faad14", WS_MESSAGE: "#faad14", WS_DISCONNECT: "#faad14",
|
||||
JOB_START: "#8c8c8c", JOB_END: "#8c8c8c", JOB_ERROR: "#f5222d",
|
||||
};
|
||||
|
||||
const ERROR_SPAN_TYPES = new Set(["ERROR", "DB_ERROR", "MIDDLEWARE_ERROR", "AI_ERROR", "JOB_ERROR"]);
|
||||
|
||||
// ---- 辅助函数 ----
|
||||
|
||||
function fmtTime(raw: string | null | undefined): string {
|
||||
if (!raw) return "—";
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
function fmtDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms.toFixed(0)}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function coveragePct(cat: CoverageCategory): number {
|
||||
return cat.total === 0 ? 100 : Math.round((cat.covered / cat.total) * 100);
|
||||
}
|
||||
|
||||
// ---- 覆盖率状态栏 ----
|
||||
|
||||
const CoverageBar: React.FC<{
|
||||
coverage: TraceCoverage | null;
|
||||
loading: boolean;
|
||||
onScan: () => void;
|
||||
}> = ({ coverage, loading, onScan }) => {
|
||||
if (!coverage) {
|
||||
return (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message={
|
||||
<Row align="middle" gutter={16}>
|
||||
<Col>
|
||||
<Text strong>Trace 覆盖率</Text>
|
||||
</Col>
|
||||
<Col>
|
||||
<Text type="secondary">暂无扫描数据</Text>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
size="small" icon={<ScanOutlined />}
|
||||
loading={loading} onClick={onScan}
|
||||
>
|
||||
扫描
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const dims: { label: string; cat: CoverageCategory }[] = [
|
||||
{ label: "路由", cat: coverage.routes },
|
||||
{ label: "Service", cat: coverage.services },
|
||||
{ label: "Job", cat: coverage.jobs },
|
||||
{ label: "SSE", cat: coverage.sse_endpoints },
|
||||
{ label: "WS", cat: coverage.ws_endpoints },
|
||||
];
|
||||
|
||||
const allUncovered = dims.flatMap((d) =>
|
||||
d.cat.uncovered.map((name) => `${d.label}: ${name}`),
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert
|
||||
type={allUncovered.length === 0 ? "success" : "warning"}
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message={
|
||||
<Row align="middle" gutter={16}>
|
||||
<Col>
|
||||
<Text strong>Trace 覆盖率</Text>
|
||||
</Col>
|
||||
{dims.map((d) => (
|
||||
<Col key={d.label}>
|
||||
<Space size={4}>
|
||||
<Text type="secondary">{d.label}</Text>
|
||||
<Progress
|
||||
type="circle" size={28}
|
||||
percent={coveragePct(d.cat)}
|
||||
format={(p) => `${p}%`}
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
))}
|
||||
<Col>
|
||||
<Button
|
||||
size="small" icon={<ScanOutlined />}
|
||||
loading={loading} onClick={onScan}
|
||||
>
|
||||
扫描
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
}
|
||||
description={
|
||||
allUncovered.length > 0 ? (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
未覆盖:{allUncovered.join("、")}
|
||||
</Text>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- Span 链路树 ----
|
||||
|
||||
const SpanTree: React.FC<{ detail: TraceDetail | null; loading: boolean }> = ({ detail, loading }) => {
|
||||
if (loading) return <Spin style={{ display: "block", marginTop: 40, textAlign: "center" }} />;
|
||||
if (!detail) {
|
||||
return <Text type="secondary" style={{ display: "block", textAlign: "center", marginTop: 40 }}>点击左侧请求查看链路详情</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: "0 8px" }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text strong>{detail.method} {detail.path}</Text>
|
||||
<Tag color={detail.error ? "red" : "green"} style={{ marginLeft: 8 }}>
|
||||
{detail.status_code ?? "—"}
|
||||
</Tag>
|
||||
<Text type="secondary" style={{ marginLeft: 8 }}>{fmtDuration(detail.total_duration_ms)}</Text>
|
||||
</div>
|
||||
<Divider style={{ margin: "8px 0" }} />
|
||||
{detail.spans.map((span, idx) => (
|
||||
<SpanRow key={idx} span={span} index={idx} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SpanRow: React.FC<{ span: TraceSpan; index: number }> = ({ span, index }) => {
|
||||
const isError = ERROR_SPAN_TYPES.has(span.span_type);
|
||||
const color = SPAN_COLOR[span.span_type] ?? "#8c8c8c";
|
||||
const isDbQuery = span.span_type === "DB_QUERY";
|
||||
|
||||
// 根据 span 类型决定缩进层级
|
||||
const indent = getSpanIndent(span.span_type);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginLeft: indent * 16,
|
||||
padding: "4px 8px",
|
||||
marginBottom: 2,
|
||||
borderLeft: `3px solid ${color}`,
|
||||
background: isError ? "#fff2f0" : index % 2 === 0 ? "#fafafa" : "#fff",
|
||||
borderRadius: 2,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<Row justify="space-between" align="top">
|
||||
<Col flex="auto">
|
||||
<Tag color={color} style={{ fontSize: 11, lineHeight: "18px" }}>
|
||||
{span.span_type}
|
||||
</Tag>
|
||||
<Text style={{ color: isError ? "#f5222d" : undefined }}>
|
||||
{span.description_zh || `${span.module}.${span.function}`}
|
||||
</Text>
|
||||
</Col>
|
||||
<Col flex="none">
|
||||
<Text type="secondary" style={{ fontSize: 12, whiteSpace: "nowrap" }}>
|
||||
{fmtDuration(span.duration_ms)}
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
{/* DB_QUERY / DB_ERROR:展示 SQL 详情 */}
|
||||
{(isDbQuery || span.span_type === "DB_ERROR") && !!span.extra?.sql && (
|
||||
<div style={{ margin: "4px 0 0", fontSize: 11 }}>
|
||||
<pre style={{
|
||||
margin: 0, padding: "4px 8px",
|
||||
background: span.span_type === "DB_ERROR" ? "#fff1f0" : "#f0f5ff",
|
||||
border: `1px solid ${span.span_type === "DB_ERROR" ? "#ffa39e" : "#adc6ff"}`,
|
||||
borderRadius: 2,
|
||||
whiteSpace: "pre-wrap", wordBreak: "break-all",
|
||||
maxHeight: 200, overflow: "auto",
|
||||
fontFamily: "'Cascadia Code', 'Fira Code', Consolas, monospace",
|
||||
}}>
|
||||
{String(span.extra.sql)}
|
||||
</pre>
|
||||
{/* 绑定参数 */}
|
||||
{span.extra.params != null && (
|
||||
<div style={{ marginTop: 2, color: "#8c8c8c" }}>
|
||||
<span style={{ color: "#722ed1" }}>参数:</span>
|
||||
{JSON.stringify(span.extra.params)}
|
||||
</div>
|
||||
)}
|
||||
{/* 行数 + 调用来源 */}
|
||||
<div style={{ marginTop: 2, color: "#8c8c8c", display: "flex", gap: 12 }}>
|
||||
{span.extra.row_count != null && (
|
||||
<span><span style={{ color: "#1890ff" }}>行数:</span>{String(span.extra.row_count)}</span>
|
||||
)}
|
||||
{span.extra.caller != null && (
|
||||
<span><span style={{ color: "#52c41a" }}>来源:</span>{String(span.extra.caller)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 通用 params 展示(非 DB_QUERY/DB_ERROR,且 params 非空) */}
|
||||
{!isDbQuery && span.span_type !== "DB_ERROR" && span.params && Object.keys(span.params).length > 0 && (
|
||||
<div style={{ marginTop: 4, fontSize: 11, color: "#8c8c8c" }}>
|
||||
<span style={{ color: "#722ed1" }}>参数:</span>
|
||||
<span style={{ fontFamily: "'Cascadia Code', 'Fira Code', Consolas, monospace" }}>
|
||||
{JSON.stringify(span.params, null, 0)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* ERROR:展示错误信息 */}
|
||||
{isError && span.result_summary && (
|
||||
<div style={{ marginTop: 4, color: "#f5222d", fontSize: 12 }}>
|
||||
{span.result_summary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** 根据 span_type 返回缩进层级(模拟层级关系) */
|
||||
function getSpanIndent(spanType: SpanType): number {
|
||||
switch (spanType) {
|
||||
case "HTTP_IN": case "HTTP_OUT": return 0;
|
||||
case "MIDDLEWARE": case "MIDDLEWARE_ERROR": return 1;
|
||||
case "AUTH": return 1;
|
||||
case "ROUTE": return 1;
|
||||
case "SERVICE": return 2;
|
||||
case "DB_QUERY": case "DB_CONN": case "DB_CONN_RELEASE": case "DB_ERROR": return 3;
|
||||
case "SSE_START": case "SSE_END": return 1;
|
||||
case "SSE_EVENT": case "AI_CALL": case "AI_STREAM": case "AI_ERROR": return 2;
|
||||
case "WS_CONNECT": case "WS_DISCONNECT": return 1;
|
||||
case "WS_MESSAGE": return 2;
|
||||
case "JOB_START": case "JOB_END": case "JOB_ERROR": return 0;
|
||||
case "ERROR": return 1;
|
||||
default: return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 设置面板(Task 20) ----
|
||||
|
||||
const SettingsDrawer: React.FC<{
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}> = ({ open, onClose }) => {
|
||||
const [settings, setSettings] = useState<TraceSettings | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [cleanRange, setCleanRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
|
||||
const [cleaning, setCleaning] = useState(false);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetchSettings();
|
||||
setSettings(res);
|
||||
} catch {
|
||||
message.error("加载设置失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) loadSettings();
|
||||
}, [open, loadSettings]);
|
||||
|
||||
const handleToggle = async (field: keyof TraceSettings, value: boolean) => {
|
||||
try {
|
||||
const res = await updateSettings({ [field]: value });
|
||||
setSettings(res);
|
||||
message.success("设置已更新");
|
||||
} catch {
|
||||
message.error("更新失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRetention = async () => {
|
||||
if (!settings) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await updateSettings({ retention_days: settings.retention_days });
|
||||
setSettings(res);
|
||||
message.success("保留天数已更新");
|
||||
} catch {
|
||||
message.error("更新失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCleanup = async () => {
|
||||
if (!cleanRange) { message.warning("请选择日期范围"); return; }
|
||||
setCleaning(true);
|
||||
try {
|
||||
const res = await cleanupLogs(
|
||||
cleanRange[0].format("YYYY-MM-DD"),
|
||||
cleanRange[1].format("YYYY-MM-DD"),
|
||||
);
|
||||
message.success(`已清理 ${res.deleted_files} 个文件(${res.deleted_dates.length} 天)`);
|
||||
} catch {
|
||||
message.error("清理失败");
|
||||
} finally {
|
||||
setCleaning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="Trace 设置" open={open} onClose={onClose}
|
||||
width={400} destroyOnClose
|
||||
>
|
||||
{loading ? <Spin /> : settings && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Text strong>日志开关</Text>
|
||||
<Switch
|
||||
checked={settings.enabled}
|
||||
onChange={(v) => handleToggle("enabled", v)}
|
||||
style={{ marginLeft: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Text strong>记录 SQL</Text>
|
||||
<Switch
|
||||
checked={settings.log_sql}
|
||||
onChange={(v) => handleToggle("log_sql", v)}
|
||||
style={{ marginLeft: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Text strong>记录参数</Text>
|
||||
<Switch
|
||||
checked={settings.log_params}
|
||||
onChange={(v) => handleToggle("log_params", v)}
|
||||
style={{ marginLeft: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Text strong>保留天数</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Space>
|
||||
<InputNumber
|
||||
min={1} max={365}
|
||||
value={settings.retention_days}
|
||||
onChange={(v) => v && setSettings({ ...settings, retention_days: v })}
|
||||
/>
|
||||
<Button type="primary" size="small" loading={saving} onClick={handleSaveRetention}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Text strong>日志目录</Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Text type="secondary" copyable style={{ fontSize: 12 }}>{settings.log_dir}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<Text strong>手动清理</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<DatePicker.RangePicker
|
||||
style={{ width: "100%" }}
|
||||
value={cleanRange}
|
||||
onChange={(v) => setCleanRange(v as [dayjs.Dayjs, dayjs.Dayjs] | null)}
|
||||
/>
|
||||
<Button
|
||||
danger icon={<DeleteOutlined />}
|
||||
loading={cleaning} onClick={handleCleanup}
|
||||
block
|
||||
>
|
||||
清理选定范围
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- 请求列表列定义 ----
|
||||
|
||||
const requestColumns: ColumnsType<TraceRequest> = [
|
||||
{
|
||||
title: "时间", dataIndex: "timestamp", key: "timestamp", width: 170,
|
||||
render: fmtTime,
|
||||
},
|
||||
{
|
||||
title: "类型", dataIndex: "trace_type", key: "trace_type", width: 70,
|
||||
render: (v: TraceType) => {
|
||||
const colors: Record<TraceType, string> = { http: "blue", sse: "cyan", ws: "gold", job: "default" };
|
||||
return <Tag color={colors[v]}>{v.toUpperCase()}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: "方法", dataIndex: "method", key: "method", width: 70 },
|
||||
{
|
||||
title: "路径", dataIndex: "path", key: "path", ellipsis: true,
|
||||
render: (v: string) => <Tooltip title={v}>{v}</Tooltip>,
|
||||
},
|
||||
{
|
||||
title: "状态", dataIndex: "status_code", key: "status_code", width: 60,
|
||||
render: (v: number | null) => {
|
||||
if (v == null) return "—";
|
||||
const color = v >= 400 ? "red" : v >= 300 ? "orange" : "green";
|
||||
return <Tag color={color}>{v}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "耗时", dataIndex: "total_duration_ms", key: "duration", width: 90,
|
||||
render: fmtDuration,
|
||||
sorter: (a, b) => a.total_duration_ms - b.total_duration_ms,
|
||||
},
|
||||
{
|
||||
title: "DB", dataIndex: "db_query_count", key: "db", width: 50,
|
||||
render: (v: number) => v > 0 ? <Text type="secondary">{v}</Text> : "—",
|
||||
},
|
||||
{
|
||||
title: "错误", dataIndex: "error", key: "error", width: 60,
|
||||
render: (v: string | null) => v ? <Tag color="red">有</Tag> : null,
|
||||
},
|
||||
];
|
||||
|
||||
// ---- 主页面组件 ----
|
||||
|
||||
const DevTrace: React.FC = () => {
|
||||
// 数据状态
|
||||
const [dates, setDates] = useState<string[]>([]);
|
||||
const [requests, setRequests] = useState<TraceRequest[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [detail, setDetail] = useState<TraceDetail | null>(null);
|
||||
const [coverage, setCoverage] = useState<TraceCoverage | null>(null);
|
||||
|
||||
// 加载状态
|
||||
const [listLoading, setListLoading] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [coverageLoading, setCoverageLoading] = useState(false);
|
||||
|
||||
// 筛选状态
|
||||
const [filter, setFilter] = useState<TraceFilter>({
|
||||
date: dayjs().format("YYYY-MM-DD"),
|
||||
page: 1,
|
||||
page_size: 30,
|
||||
});
|
||||
|
||||
// UI 状态
|
||||
const [selectedRowKey, setSelectedRowKey] = useState<string | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [hiddenIds, setHiddenIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 加载日期列表
|
||||
const loadDates = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetchDates();
|
||||
setDates(res.dates);
|
||||
} catch {
|
||||
// 静默降级
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 加载请求列表
|
||||
const loadRequests = useCallback(async () => {
|
||||
setListLoading(true);
|
||||
try {
|
||||
const res = await fetchRequests(filter);
|
||||
setRequests(res.items);
|
||||
setTotal(res.total);
|
||||
} catch {
|
||||
message.error("加载请求列表失败");
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
// 加载覆盖率
|
||||
const loadCoverage = useCallback(async () => {
|
||||
setCoverageLoading(true);
|
||||
try {
|
||||
const res = await fetchCoverage();
|
||||
setCoverage(res);
|
||||
} catch {
|
||||
message.warning("覆盖率数据加载失败,可点击扫描按钮重试");
|
||||
} finally {
|
||||
setCoverageLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 手动扫描覆盖率
|
||||
const handleScan = async () => {
|
||||
setCoverageLoading(true);
|
||||
try {
|
||||
const res = await triggerCoverageScan();
|
||||
setCoverage(res);
|
||||
message.success("覆盖率扫描完成");
|
||||
} catch {
|
||||
message.error("扫描失败");
|
||||
} finally {
|
||||
setCoverageLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 点击行查看详情
|
||||
const handleRowClick = async (record: TraceRequest) => {
|
||||
setSelectedRowKey(record.request_id);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const res = await fetchRequestDetail(record.request_id);
|
||||
setDetail(res);
|
||||
} catch {
|
||||
message.error("加载详情失败");
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化
|
||||
useEffect(() => { loadDates(); loadCoverage(); }, [loadDates, loadCoverage]);
|
||||
useEffect(() => { loadRequests(); }, [loadRequests]);
|
||||
|
||||
// 筛选变更辅助
|
||||
const updateFilter = (patch: Partial<TraceFilter>) => {
|
||||
setFilter((prev) => ({ ...prev, ...patch, page: 1 }));
|
||||
};
|
||||
|
||||
// 过滤掉被屏蔽的记录
|
||||
const visibleRequests = requests.filter((r) => !hiddenIds.has(r.request_id));
|
||||
|
||||
// 清空:把当前可见记录全部加入屏蔽集合(累积)
|
||||
const handleClearList = () => {
|
||||
setHiddenIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const r of requests) next.add(r.request_id);
|
||||
return next;
|
||||
});
|
||||
setDetail(null);
|
||||
setSelectedRowKey(null);
|
||||
};
|
||||
|
||||
// 取消清空:清空屏蔽集合
|
||||
const handleUnclearList = () => {
|
||||
setHiddenIds(new Set());
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 标题栏 */}
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>开发调试日志</Title>
|
||||
<Space>
|
||||
<Button icon={<DeleteOutlined />} onClick={handleClearList} disabled={visibleRequests.length === 0}>
|
||||
清空
|
||||
</Button>
|
||||
{hiddenIds.size > 0 && (
|
||||
<Button onClick={handleUnclearList}>
|
||||
取消清空({hiddenIds.size})
|
||||
</Button>
|
||||
)}
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { loadRequests(); loadCoverage(); }} loading={listLoading}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button icon={<SettingOutlined />} onClick={() => setSettingsOpen(true)}>
|
||||
设置
|
||||
</Button>
|
||||
</Space>
|
||||
</Row>
|
||||
|
||||
{/* 覆盖率状态栏 */}
|
||||
<CoverageBar coverage={coverage} loading={coverageLoading} onScan={handleScan} />
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div style={{ marginBottom: 12, background: "#fafafa", padding: "10px 12px", borderRadius: 4 }}>
|
||||
<Space wrap size={[8, 8]}>
|
||||
<Select
|
||||
placeholder="日期" style={{ width: 130 }}
|
||||
value={filter.date}
|
||||
onChange={(v) => updateFilter({ date: v })}
|
||||
options={dates.map((d) => ({ label: d, value: d }))}
|
||||
showSearch
|
||||
/>
|
||||
<TimePicker.RangePicker
|
||||
format="HH:mm"
|
||||
placeholder={["开始时间", "结束时间"]}
|
||||
onChange={(_, strs) => updateFilter({
|
||||
start_time: strs[0] || undefined,
|
||||
end_time: strs[1] || undefined,
|
||||
})}
|
||||
/>
|
||||
<Select
|
||||
placeholder="类型" style={{ width: 100 }} allowClear
|
||||
options={TRACE_TYPE_OPTIONS}
|
||||
onChange={(v) => updateFilter({ trace_type: v })}
|
||||
/>
|
||||
<Select
|
||||
placeholder="方法" style={{ width: 100 }} allowClear
|
||||
options={METHOD_OPTIONS.map((m) => ({ label: m, value: m }))}
|
||||
onChange={(v) => updateFilter({ method: v })}
|
||||
/>
|
||||
<Input
|
||||
placeholder="路径关键词" style={{ width: 160 }}
|
||||
prefix={<SearchOutlined />} allowClear
|
||||
onPressEnter={(e) => updateFilter({ path_contains: (e.target as HTMLInputElement).value || undefined })}
|
||||
onBlur={(e) => updateFilter({ path_contains: e.target.value || undefined })}
|
||||
/>
|
||||
<InputNumber
|
||||
placeholder="状态码" style={{ width: 90 }}
|
||||
min={100} max={599}
|
||||
onChange={(v) => updateFilter({ status_code: v ?? undefined })}
|
||||
/>
|
||||
<InputNumber
|
||||
placeholder="最小耗时(ms)" style={{ width: 130 }}
|
||||
min={0}
|
||||
onChange={(v) => updateFilter({ min_duration: v ?? undefined })}
|
||||
/>
|
||||
<Checkbox
|
||||
onChange={(e) => updateFilter({ has_error: e.target.checked || undefined })}
|
||||
>
|
||||
仅错误
|
||||
</Checkbox>
|
||||
<Select
|
||||
placeholder="Span 类型" style={{ width: 150 }} allowClear
|
||||
options={SPAN_TYPE_OPTIONS.map((s) => ({ label: s, value: s }))}
|
||||
onChange={(v) => updateFilter({ span_type: v })}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 左右分栏 */}
|
||||
<Row gutter={12} style={{ minHeight: 500 }}>
|
||||
{/* 左侧:请求列表 */}
|
||||
<Col span={14}>
|
||||
<Table<TraceRequest>
|
||||
columns={requestColumns}
|
||||
dataSource={visibleRequests}
|
||||
rowKey="request_id"
|
||||
size="small"
|
||||
loading={listLoading}
|
||||
pagination={{
|
||||
current: filter.page,
|
||||
pageSize: filter.page_size,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ["20", "30", "50", "100"],
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (page, pageSize) => setFilter((prev) => ({ ...prev, page, page_size: pageSize })),
|
||||
}}
|
||||
onRow={(record) => ({
|
||||
onClick: () => handleRowClick(record),
|
||||
style: {
|
||||
cursor: "pointer",
|
||||
background: record.request_id === selectedRowKey ? "#e6f7ff" : undefined,
|
||||
},
|
||||
})}
|
||||
scroll={{ y: 520 }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
{/* 右侧:Span 链路树 */}
|
||||
<Col span={10}>
|
||||
<div style={{
|
||||
border: "1px solid #f0f0f0", borderRadius: 4,
|
||||
padding: 12, minHeight: 520, maxHeight: 600,
|
||||
overflow: "auto", background: "#fff",
|
||||
}}>
|
||||
<SpanTree detail={detail} loading={detailLoading} />
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 设置面板 */}
|
||||
<SettingsDrawer open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DevTrace;
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type CursorInfo, type RecentRun,
|
||||
} from '../api/etlStatus';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Title } = Typography;
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
success: 'green', failed: 'red', running: 'blue', cancelled: 'orange',
|
||||
@@ -38,11 +38,8 @@ function formatDuration(ms: number | null): string {
|
||||
|
||||
const cursorColumns: ColumnsType<CursorInfo> = [
|
||||
{ title: '任务编码', dataIndex: 'task_code', key: 'task_code', render: (v: string) => <code>{v}</code> },
|
||||
{ title: '最后抓取时间', dataIndex: 'last_fetch_time', key: 'last_fetch_time', render: (v: string | null) => formatTime(v) },
|
||||
{
|
||||
title: '记录数', dataIndex: 'record_count', key: 'record_count', align: 'right',
|
||||
render: (v: number | null) => (v != null ? <Text strong>{v.toLocaleString()}</Text> : '—'),
|
||||
},
|
||||
{ title: '数据起始时间', dataIndex: 'last_start', key: 'last_start', render: (v: string | null) => formatTime(v) },
|
||||
{ title: '数据截止时间', dataIndex: 'last_end', key: 'last_end', render: (v: string | null) => formatTime(v) },
|
||||
];
|
||||
|
||||
const runColumns: ColumnsType<RecentRun> = [
|
||||
|
||||
121
apps/admin-web/src/pages/ETLTasks.tsx
Normal file
121
apps/admin-web/src/pages/ETLTasks.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* ETL 任务管理页面 — 合并 TaskConfig / QueueTab / ScheduleTab / ETLStatus 为 Tab 视图。
|
||||
*
|
||||
* - 5 个 Tab:config(发起)、queue(队列)、schedule(调度)、history(历史)、status(状态)
|
||||
* - Tab 切换通过 useSearchParams 同步 URL 查询参数 ?tab=config|queue|schedule|history|status
|
||||
* - destroyInactiveTabPane={false} 保持 Tab 状态不丢失
|
||||
*
|
||||
* CHANGE 2026-07-14 | Task 9.1:从占位页面替换为完整 Tab 视图实现
|
||||
* CHANGE 2026-03-25 | 将 TaskManager 内部子 Tab(队列/调度)提升到顶层,去掉历史 Tab
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Tabs, Typography } from 'antd';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
SettingOutlined,
|
||||
UnorderedListOutlined,
|
||||
ClockCircleOutlined,
|
||||
HistoryOutlined,
|
||||
DashboardOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import TaskConfig from './TaskConfig';
|
||||
import { QueueTab, HistoryTab } from './TaskManager';
|
||||
import ScheduleTab from '../components/ScheduleTab';
|
||||
import ETLStatus from './ETLStatus';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const VALID_TABS = ['config', 'queue', 'schedule', 'history', 'status'] as const;
|
||||
type TabKey = (typeof VALID_TABS)[number];
|
||||
const DEFAULT_TAB: TabKey = 'config';
|
||||
|
||||
function isValidTab(value: string | null): value is TabKey {
|
||||
return value != null && (VALID_TABS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
const ETLTasks: React.FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const activeTab: TabKey = useMemo(() => {
|
||||
const raw = searchParams.get('tab');
|
||||
return isValidTab(raw) ? raw : DEFAULT_TAB;
|
||||
}, [searchParams]);
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
setSearchParams({ tab: key }, { replace: true });
|
||||
};
|
||||
|
||||
const items = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'config' as TabKey,
|
||||
label: (
|
||||
<span>
|
||||
<SettingOutlined style={{ marginRight: 6 }} />
|
||||
发起
|
||||
</span>
|
||||
),
|
||||
children: <TaskConfig />,
|
||||
},
|
||||
{
|
||||
key: 'queue' as TabKey,
|
||||
label: (
|
||||
<span>
|
||||
<UnorderedListOutlined style={{ marginRight: 6 }} />
|
||||
队列
|
||||
</span>
|
||||
),
|
||||
children: <QueueTab />,
|
||||
},
|
||||
{
|
||||
key: 'schedule' as TabKey,
|
||||
label: (
|
||||
<span>
|
||||
<ClockCircleOutlined style={{ marginRight: 6 }} />
|
||||
调度
|
||||
</span>
|
||||
),
|
||||
children: <ScheduleTab />,
|
||||
},
|
||||
{
|
||||
key: 'history' as TabKey,
|
||||
label: (
|
||||
<span>
|
||||
<HistoryOutlined style={{ marginRight: 6 }} />
|
||||
历史
|
||||
</span>
|
||||
),
|
||||
children: <HistoryTab />,
|
||||
},
|
||||
{
|
||||
key: 'status' as TabKey,
|
||||
label: (
|
||||
<span>
|
||||
<DashboardOutlined style={{ marginRight: 6 }} />
|
||||
状态
|
||||
</span>
|
||||
),
|
||||
children: <ETLStatus />,
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4} style={{ marginBottom: 16 }}>
|
||||
<UnorderedListOutlined style={{ marginRight: 8 }} />
|
||||
ETL 任务管理
|
||||
</Title>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={handleTabChange}
|
||||
items={items}
|
||||
destroyInactiveTabPane={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ETLTasks;
|
||||
@@ -25,7 +25,7 @@ const Login: React.FC = () => {
|
||||
try {
|
||||
await login(values.username, values.password);
|
||||
message.success("登录成功");
|
||||
navigate("/", { replace: true });
|
||||
navigate("/dashboard", { replace: true });
|
||||
} catch (err: unknown) {
|
||||
const detail =
|
||||
(err as { response?: { data?: { detail?: string } } })?.response?.data
|
||||
|
||||
@@ -6,43 +6,16 @@
|
||||
* - 各环境服务状态 + 启停重启按钮
|
||||
* - 各环境 Git 状态 + pull / 同步依赖按钮
|
||||
* - 各环境 .env 配置查看(敏感值脱敏)
|
||||
*
|
||||
* CHANGE 2026-07-25 | admin-web-restructure 8.1
|
||||
* 拆分为 SystemResourceSection / ServiceStatusSection / GitStatusSection 三个子组件,
|
||||
* 本页面改为组合子组件,功能不变。
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Tag,
|
||||
Button,
|
||||
Space,
|
||||
Statistic,
|
||||
Progress,
|
||||
Modal,
|
||||
message,
|
||||
Descriptions,
|
||||
Spin,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Input,
|
||||
} from "antd";
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
ReloadOutlined,
|
||||
CloudDownloadOutlined,
|
||||
SyncOutlined,
|
||||
FileTextOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
DesktopOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type {
|
||||
SystemInfo,
|
||||
ServiceStatus,
|
||||
GitInfo,
|
||||
} from "../api/opsPanel";
|
||||
import { Modal, message, Spin, Typography } from "antd";
|
||||
import { DesktopOutlined } from "@ant-design/icons";
|
||||
import type { SystemInfo, ServiceStatus, GitInfo } from "../api/opsPanel";
|
||||
import {
|
||||
fetchSystemInfo,
|
||||
fetchServicesStatus,
|
||||
@@ -52,28 +25,14 @@ import {
|
||||
restartService,
|
||||
gitPull,
|
||||
syncDeps,
|
||||
fetchEnvFile,
|
||||
} from "../api/opsPanel";
|
||||
import {
|
||||
SystemResourceSection,
|
||||
ServiceStatusSection,
|
||||
GitStatusSection,
|
||||
} from "../components/ops";
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 工具函数 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 秒数格式化为 "Xd Xh Xm" */
|
||||
function formatUptime(seconds: number | null): string {
|
||||
if (seconds == null) return "-";
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const parts: string[] = [];
|
||||
if (d > 0) parts.push(`${d}天`);
|
||||
if (h > 0) parts.push(`${h}时`);
|
||||
parts.push(`${m}分`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
const { Title } = Typography;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 组件 */
|
||||
@@ -85,9 +44,6 @@ const OpsPanel: React.FC = () => {
|
||||
const [gitInfos, setGitInfos] = useState<GitInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
|
||||
const [envModalOpen, setEnvModalOpen] = useState(false);
|
||||
const [envModalContent, setEnvModalContent] = useState("");
|
||||
const [envModalTitle, setEnvModalTitle] = useState("");
|
||||
|
||||
// ---- 数据加载 ----
|
||||
|
||||
@@ -165,17 +121,6 @@ const OpsPanel: React.FC = () => {
|
||||
r.success ? message.success("依赖同步完成") : message.error(r.message);
|
||||
});
|
||||
|
||||
const handleViewEnv = async (env: string, label: string) => {
|
||||
try {
|
||||
const r = await fetchEnvFile(env);
|
||||
setEnvModalTitle(`${label} .env 配置`);
|
||||
setEnvModalContent(r.content);
|
||||
setEnvModalOpen(true);
|
||||
} catch {
|
||||
message.error("读取配置文件失败");
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 渲染 ----
|
||||
|
||||
if (loading) {
|
||||
@@ -189,175 +134,23 @@ const OpsPanel: React.FC = () => {
|
||||
运维控制面板
|
||||
</Title>
|
||||
|
||||
{/* ---- 系统资源 ---- */}
|
||||
{system && (
|
||||
<Card size="small" title="服务器资源" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={24}>
|
||||
<Col span={8}>
|
||||
<Statistic title="CPU 使用率" value={system.cpu_percent} suffix="%" />
|
||||
<Progress percent={system.cpu_percent} size="small" status={system.cpu_percent > 80 ? "exception" : "normal"} showInfo={false} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="内存" value={system.memory_used_gb} suffix={`/ ${system.memory_total_gb} GB`} precision={1} />
|
||||
<Progress percent={system.memory_percent} size="small" status={system.memory_percent > 85 ? "exception" : "normal"} showInfo={false} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="磁盘" value={system.disk_used_gb} suffix={`/ ${system.disk_total_gb} GB`} precision={1} />
|
||||
<Progress percent={system.disk_percent} size="small" status={system.disk_percent > 90 ? "exception" : "normal"} showInfo={false} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Text type="secondary" style={{ fontSize: 12, marginTop: 8, display: "block" }}>
|
||||
开机时间:{new Date(system.boot_time).toLocaleString()}
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
{system && <SystemResourceSection system={system} />}
|
||||
|
||||
{/* ---- 服务状态 ---- */}
|
||||
<Card size="small" title="服务状态" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16}>
|
||||
{services.map((svc) => (
|
||||
<Col span={12} key={svc.env}>
|
||||
<Card
|
||||
size="small"
|
||||
type="inner"
|
||||
title={
|
||||
<Space>
|
||||
{svc.running
|
||||
? <CheckCircleOutlined style={{ color: "#52c41a" }} />
|
||||
: <CloseCircleOutlined style={{ color: "#ff4d4f" }} />}
|
||||
{svc.label}
|
||||
<Tag color={svc.running ? "success" : "error"}>
|
||||
{svc.running ? "运行中" : "已停止"}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={<Tag>:{svc.port}</Tag>}
|
||||
>
|
||||
{svc.running && (
|
||||
<Descriptions size="small" column={3} style={{ marginBottom: 12 }}>
|
||||
<Descriptions.Item label="PID">{svc.pid}</Descriptions.Item>
|
||||
<Descriptions.Item label="运行时长">
|
||||
<ClockCircleOutlined style={{ marginRight: 4 }} />
|
||||
{formatUptime(svc.uptime_seconds)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="内存">{svc.memory_mb ?? "-"} MB</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
<Space>
|
||||
{!svc.running && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={actionLoading[`start-${svc.env}`]}
|
||||
onClick={() => handleStart(svc.env)}
|
||||
>
|
||||
启动
|
||||
</Button>
|
||||
)}
|
||||
{svc.running && (
|
||||
<>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<PauseCircleOutlined />}
|
||||
loading={actionLoading[`stop-${svc.env}`]}
|
||||
onClick={() => handleStop(svc.env)}
|
||||
>
|
||||
停止
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={actionLoading[`restart-${svc.env}`]}
|
||||
onClick={() => handleRestart(svc.env)}
|
||||
>
|
||||
重启
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
<ServiceStatusSection
|
||||
services={services}
|
||||
actionLoading={actionLoading}
|
||||
onStart={handleStart}
|
||||
onStop={handleStop}
|
||||
onRestart={handleRestart}
|
||||
/>
|
||||
|
||||
{/* ---- Git 状态 & 配置 ---- */}
|
||||
<Card size="small" title="代码与配置" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16}>
|
||||
{gitInfos.map((git) => {
|
||||
const envCfg = services.find((s) => s.env === git.env);
|
||||
const label = envCfg?.label ?? git.env;
|
||||
return (
|
||||
<Col span={12} key={git.env}>
|
||||
<Card size="small" type="inner" title={label}>
|
||||
<Descriptions size="small" column={1} style={{ marginBottom: 12 }}>
|
||||
<Descriptions.Item label="分支">
|
||||
<Tag color="blue">{git.branch}</Tag>
|
||||
{git.has_local_changes && (
|
||||
<Tooltip title="工作区有未提交的变更">
|
||||
<Tag color="warning">有变更</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最新提交">
|
||||
<Text code style={{ fontSize: 12 }}>{git.last_commit_hash}</Text>
|
||||
<Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>
|
||||
{git.last_commit_message}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="提交时间">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{git.last_commit_time}</Text>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
loading={actionLoading[`pull-${git.env}`]}
|
||||
onClick={() => handlePull(git.env)}
|
||||
>
|
||||
Git Pull
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<SyncOutlined />}
|
||||
loading={actionLoading[`sync-${git.env}`]}
|
||||
onClick={() => handleSyncDeps(git.env)}
|
||||
>
|
||||
同步依赖
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => handleViewEnv(git.env, label)}
|
||||
>
|
||||
查看配置
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* ---- 配置查看弹窗 ---- */}
|
||||
<Modal
|
||||
title={envModalTitle}
|
||||
open={envModalOpen}
|
||||
onCancel={() => setEnvModalOpen(false)}
|
||||
footer={null}
|
||||
width={700}
|
||||
>
|
||||
<TextArea
|
||||
value={envModalContent}
|
||||
readOnly
|
||||
autoSize={{ minRows: 10, maxRows: 30 }}
|
||||
style={{ fontFamily: "monospace", fontSize: 12 }}
|
||||
/>
|
||||
</Modal>
|
||||
<GitStatusSection
|
||||
gitInfos={gitInfos}
|
||||
services={services}
|
||||
actionLoading={actionLoading}
|
||||
onPull={handlePull}
|
||||
onSyncDeps={handleSyncDeps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
293
apps/admin-web/src/pages/PendingReview.tsx
Normal file
293
apps/admin-web/src/pages/PendingReview.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* P18 待审核任务页面。
|
||||
*
|
||||
* 展示 status='pending_review' 的任务,支持重新分配和关闭操作。
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Table, Card, Typography, Button, Space, InputNumber, Tag, Tooltip,
|
||||
Modal, Input, Drawer, message,
|
||||
} from "antd";
|
||||
import {
|
||||
ReloadOutlined, ExclamationCircleOutlined, AuditOutlined,
|
||||
SwapOutlined, CloseCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
fetchPendingReviews, reassignTask, closeTask,
|
||||
fetchMemberTransferHistory,
|
||||
type PendingReviewItem, type PendingReviewQuery, type TransferLogItem,
|
||||
} from "../api/taskEngine";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
function formatTime(raw: string | null): string {
|
||||
if (!raw) return "—";
|
||||
return dayjs(raw).format("YYYY-MM-DD HH:mm");
|
||||
}
|
||||
|
||||
const PendingReview: React.FC = () => {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isSuperAdmin = user?.roles?.includes("super_admin") ?? false;
|
||||
|
||||
const [items, setItems] = useState<PendingReviewItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState<PendingReviewQuery>({ page: 1, page_size: 20 });
|
||||
|
||||
// 重新分配弹窗
|
||||
const [reassignVisible, setReassignVisible] = useState(false);
|
||||
const [reassignTaskId, setReassignTaskId] = useState<number | null>(null);
|
||||
const [toAssistantId, setToAssistantId] = useState<number | null>(null);
|
||||
const [reassigning, setReassigning] = useState(false);
|
||||
|
||||
// 关闭弹窗
|
||||
const [closeVisible, setCloseVisible] = useState(false);
|
||||
const [closeTaskId, setCloseTaskId] = useState<number | null>(null);
|
||||
const [closeReason, setCloseReason] = useState("");
|
||||
const [closing, setClosing] = useState(false);
|
||||
|
||||
// 转移历史抽屉
|
||||
const [historyVisible, setHistoryVisible] = useState(false);
|
||||
const [historyMemberId, setHistoryMemberId] = useState<number | null>(null);
|
||||
const [historyItems, setHistoryItems] = useState<TransferLogItem[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchPendingReviews(query);
|
||||
setItems(data.items);
|
||||
setTotal(data.total);
|
||||
} catch {
|
||||
message.error("加载待审核任务失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleReassign = async () => {
|
||||
if (!reassignTaskId || !toAssistantId) return;
|
||||
setReassigning(true);
|
||||
try {
|
||||
await reassignTask(reassignTaskId, toAssistantId);
|
||||
message.success("重新分配成功");
|
||||
setReassignVisible(false);
|
||||
setToAssistantId(null);
|
||||
load();
|
||||
} catch {
|
||||
message.error("重新分配失败");
|
||||
} finally {
|
||||
setReassigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = async () => {
|
||||
if (!closeTaskId || !closeReason.trim()) return;
|
||||
setClosing(true);
|
||||
try {
|
||||
await closeTask(closeTaskId, closeReason.trim());
|
||||
message.success("任务已关闭");
|
||||
setCloseVisible(false);
|
||||
setCloseReason("");
|
||||
load();
|
||||
} catch {
|
||||
message.error("关闭任务失败");
|
||||
} finally {
|
||||
setClosing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showHistory = async (memberId: number) => {
|
||||
setHistoryMemberId(memberId);
|
||||
setHistoryVisible(true);
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const data = await fetchMemberTransferHistory(memberId);
|
||||
setHistoryItems(data);
|
||||
} catch {
|
||||
message.error("加载转移历史失败");
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<PendingReviewItem> = [
|
||||
{
|
||||
title: "创建时间", dataIndex: "created_at", key: "created_at", width: 160,
|
||||
render: (v: string) => formatTime(v),
|
||||
},
|
||||
{
|
||||
title: "门店", dataIndex: "site_name", key: "site_name", width: 120,
|
||||
render: (v: string, r) => v || `#${r.site_id}`,
|
||||
},
|
||||
{
|
||||
title: "客户", key: "member", width: 140,
|
||||
render: (_: unknown, r) => (
|
||||
<Tooltip title={`ID: ${r.member_id}`}>
|
||||
<a onClick={() => showHistory(r.member_id)}>
|
||||
{r.member_name || `会员#${r.member_id}`}
|
||||
</a>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "当前助教", key: "assistant", width: 120,
|
||||
render: (_: unknown, r) => r.assistant_name || `#${r.assistant_id}`,
|
||||
},
|
||||
{
|
||||
title: "任务类型", dataIndex: "task_type_label", key: "type", width: 120,
|
||||
render: (v: string) => <Tag color="blue">{v || "未知"}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "转移次数", dataIndex: "transfer_count", key: "tc", width: 90,
|
||||
render: (v: number) => (
|
||||
<Tag color={v >= 2 ? "red" : "default"} icon={v >= 2 ? <ExclamationCircleOutlined /> : undefined}>
|
||||
{v}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "优先级分", dataIndex: "priority_score", key: "score", width: 90,
|
||||
render: (v: number | null) => v != null ? v.toFixed(2) : "—",
|
||||
},
|
||||
];
|
||||
|
||||
// 超级管理员才显示操作列
|
||||
if (isSuperAdmin) {
|
||||
columns.push({
|
||||
title: "操作", key: "action", width: 180, fixed: "right",
|
||||
render: (_: unknown, r) => (
|
||||
<Space size={4}>
|
||||
<Button
|
||||
type="primary" size="small" icon={<SwapOutlined />}
|
||||
onClick={() => { setReassignTaskId(r.id); setReassignVisible(true); }}
|
||||
>
|
||||
分配
|
||||
</Button>
|
||||
<Button
|
||||
danger size="small" icon={<CloseCircleOutlined />}
|
||||
onClick={() => { setCloseTaskId(r.id); setCloseVisible(true); }}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<AuditOutlined style={{ marginRight: 8 }} />
|
||||
待审核任务
|
||||
</Title>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
</div>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<InputNumber
|
||||
placeholder="门店 ID"
|
||||
style={{ width: 140 }}
|
||||
onChange={(v) => setQuery((q) => ({ ...q, site_id: (v as number) ?? undefined, page: 1 }))}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small">
|
||||
<Table<PendingReviewItem>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
size="small"
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: query.page,
|
||||
pageSize: query.page_size,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (page, pageSize) => setQuery((q) => ({ ...q, page, page_size: pageSize })),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 重新分配弹窗 */}
|
||||
<Modal
|
||||
title="重新分配任务"
|
||||
open={reassignVisible}
|
||||
onOk={handleReassign}
|
||||
onCancel={() => { setReassignVisible(false); setToAssistantId(null); }}
|
||||
confirmLoading={reassigning}
|
||||
okButtonProps={{ disabled: !toAssistantId }}
|
||||
>
|
||||
<Text>请输入目标助教 ID:</Text>
|
||||
<InputNumber
|
||||
style={{ width: "100%", marginTop: 8 }}
|
||||
placeholder="目标助教 ID"
|
||||
value={toAssistantId}
|
||||
onChange={(v) => setToAssistantId(v)}
|
||||
/>
|
||||
<Text type="secondary" style={{ display: "block", marginTop: 8, fontSize: 12 }}>
|
||||
提示:POOL 为空时无法自动推荐候选助教。如需强制指定,操作将标记为 manual_override。
|
||||
</Text>
|
||||
</Modal>
|
||||
|
||||
{/* 关闭任务弹窗 */}
|
||||
<Modal
|
||||
title="关闭任务"
|
||||
open={closeVisible}
|
||||
onOk={handleClose}
|
||||
onCancel={() => { setCloseVisible(false); setCloseReason(""); }}
|
||||
confirmLoading={closing}
|
||||
okButtonProps={{ disabled: !closeReason.trim(), danger: true }}
|
||||
okText="确认关闭"
|
||||
>
|
||||
<Text>请填写关闭原因:</Text>
|
||||
<TextArea
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
showCount
|
||||
style={{ marginTop: 8 }}
|
||||
value={closeReason}
|
||||
onChange={(e) => setCloseReason(e.target.value)}
|
||||
placeholder="例如:客户已流失,无需继续跟进"
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* 转移历史抽屉 */}
|
||||
<Drawer
|
||||
title={`会员 #${historyMemberId} 转移历史`}
|
||||
open={historyVisible}
|
||||
onClose={() => setHistoryVisible(false)}
|
||||
width={600}
|
||||
>
|
||||
<Table<TransferLogItem>
|
||||
rowKey="id"
|
||||
dataSource={historyItems}
|
||||
loading={historyLoading}
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: "时间", dataIndex: "created_at", render: (v: string) => formatTime(v), width: 140 },
|
||||
{ title: "原助教", key: "from", render: (_: unknown, r: TransferLogItem) => r.from_assistant_name || `#${r.from_assistant_id}`, width: 100 },
|
||||
{ title: "新助教", key: "to", render: (_: unknown, r: TransferLogItem) => r.to_assistant_name || `#${r.to_assistant_id}`, width: 100 },
|
||||
{ title: "原因", dataIndex: "transfer_reason", width: 120 },
|
||||
{ title: "得分", dataIndex: "transfer_score", render: (v: number | null) => v != null ? v.toFixed(2) : "—", width: 80 },
|
||||
]}
|
||||
/>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PendingReview;
|
||||
@@ -47,7 +47,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import TaskSelector from "../components/TaskSelector";
|
||||
import { validateTaskConfig, fetchFlows } from "../api/tasks";
|
||||
import type { FlowDef, ProcessingModeDef } from "../api/tasks";
|
||||
import { submitToQueue, executeDirectly } from "../api/execution";
|
||||
import { submitToQueue, executeDirectly, cleanupOutput } from "../api/execution";
|
||||
import { createSchedule } from "../api/schedules";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import BusinessDayHint from "../components/BusinessDayHint";
|
||||
@@ -55,6 +55,7 @@ import type { RadioChangeEvent } from "antd";
|
||||
import type { Dayjs } from "dayjs";
|
||||
import dayjs from "dayjs";
|
||||
import type { TaskConfig as TaskConfigType, ScheduleConfig } from "../types";
|
||||
import type { MinRunIntervalItem } from "../types";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
@@ -228,6 +229,7 @@ const TaskConfig: React.FC = () => {
|
||||
/* ---------- 任务选择 ---------- */
|
||||
const [selectedTasks, setSelectedTasks] = useState<string[]>([]);
|
||||
const [selectedDwdTables, setSelectedDwdTables] = useState<string[]>([]);
|
||||
const [taskIntervals, setTaskIntervals] = useState<Record<string, MinRunIntervalItem>>({});
|
||||
|
||||
/* ---------- 高级选项 ---------- */
|
||||
const [dryRun, setDryRun] = useState(false);
|
||||
@@ -320,12 +322,26 @@ const TaskConfig: React.FC = () => {
|
||||
/* ---------- 事件处理 ---------- */
|
||||
const handleFlowChange = (e: RadioChangeEvent) => setFlow(e.target.value);
|
||||
|
||||
// CHANGE 2026-03-27 | 包含 ODS 层的 flow 执行前清理输出目录,每类任务只保留最近 10 个运行记录
|
||||
const tryCleanupOutput = async () => {
|
||||
if (!layers.includes("ODS")) return;
|
||||
try {
|
||||
const result = await cleanupOutput();
|
||||
if (result.dirs_deleted > 0) {
|
||||
message.info(`已清理 ${result.dirs_deleted} 个旧运行记录`);
|
||||
}
|
||||
} catch {
|
||||
message.warning("输出目录清理失败,不影响任务执行");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitToQueue = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await tryCleanupOutput();
|
||||
await submitToQueue(buildTaskConfig());
|
||||
message.success("已提交到执行队列");
|
||||
navigate("/task-manager");
|
||||
navigate("/etl-tasks?tab=queue");
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "提交失败";
|
||||
message.error(`提交到队列失败:${msg}`);
|
||||
@@ -334,12 +350,14 @@ const TaskConfig: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// CHANGE 2026-03-27 | 直接执行后跳转历史 tab 并自动打开任务详情
|
||||
const handleExecuteDirectly = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await executeDirectly(buildTaskConfig());
|
||||
await tryCleanupOutput();
|
||||
const { execution_id } = await executeDirectly(buildTaskConfig());
|
||||
message.success("任务已开始执行");
|
||||
navigate("/task-manager");
|
||||
navigate(`/etl-tasks?tab=history&openExecution=${execution_id}`);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "执行失败";
|
||||
message.error(`直接执行失败:${msg}`);
|
||||
@@ -399,6 +417,7 @@ const TaskConfig: React.FC = () => {
|
||||
task_config: taskConfig,
|
||||
schedule_config: scheduleConfig,
|
||||
run_immediately: !!values.run_immediately,
|
||||
min_run_intervals: Object.keys(taskIntervals).length > 0 ? taskIntervals : undefined,
|
||||
});
|
||||
message.success("调度任务已创建");
|
||||
setScheduleModalOpen(false);
|
||||
@@ -681,13 +700,15 @@ const TaskConfig: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
{/* ---- 任务选择(含 DWD 表过滤) ---- */}
|
||||
<Card size="small" title="任务选择" style={cardStyle}>
|
||||
<Card size="small" title={<Space size={8}>任务选择<Text type="secondary" style={{ fontSize: 11, fontWeight: 400 }}>右侧可设置每个任务的最小执行间隔</Text></Space>} style={cardStyle}>
|
||||
<TaskSelector
|
||||
layers={layers}
|
||||
selectedTasks={selectedTasks}
|
||||
onTasksChange={setSelectedTasks}
|
||||
selectedDwdTables={selectedDwdTables}
|
||||
onDwdTablesChange={setSelectedDwdTables}
|
||||
taskIntervals={taskIntervals}
|
||||
onTaskIntervalsChange={setTaskIntervals}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
|
||||
353
apps/admin-web/src/pages/TaskEngineConfig.tsx
Normal file
353
apps/admin-web/src/pages/TaskEngineConfig.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* P18 任务引擎参数管理页面。
|
||||
*
|
||||
* 展示 biz.cfg_task_generator_params 全局默认 + 门店覆盖参数。
|
||||
* 超级管理员可编辑/新增/删除;门店管理员只读。
|
||||
* 权重参数(w_rs/w_ms/w_ml)以卡片形式整体编辑,后端联合校验。
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Table, Card, Typography, Button, Space, Tag, InputNumber,
|
||||
Modal, Select, Popconfirm, Tooltip, message,
|
||||
} from "antd";
|
||||
import {
|
||||
ReloadOutlined, SettingOutlined, PlusOutlined,
|
||||
EditOutlined, DeleteOutlined, SaveOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
fetchConfigParams, updateConfigParam, createConfigParam, deleteConfigParam,
|
||||
type ConfigParam,
|
||||
} from "../api/taskEngine";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/** 参数中文描述映射 */
|
||||
const PARAM_LABELS: Record<string, string> = {
|
||||
high_priority_recall_threshold: "高优先召回阈值",
|
||||
priority_recall_threshold: "优先召回阈值",
|
||||
rs_min_for_relationship: "关系构建 RS 下限",
|
||||
rs_max_for_relationship: "关系构建 RS 上限",
|
||||
consecutive_recall_fail_cycles: "连续失败触发转移轮数",
|
||||
min_wbi_for_transfer: "触发转移最低 WBI",
|
||||
guard_assistant_coverage_ratio: "助教绑定率保护阈值",
|
||||
guard_new_assistant_days: "新助教入驻保护天数",
|
||||
transfer_score_w_rs: "转移排序 RS 权重",
|
||||
transfer_score_w_ms: "转移排序 MS 权重",
|
||||
transfer_score_w_ml: "转移排序 ML 权重",
|
||||
max_transfer_count: "单客户最大转移次数",
|
||||
follow_up_visit_retention_hours: "回访任务保留时长(h)",
|
||||
};
|
||||
|
||||
const WEIGHT_KEYS = ["transfer_score_w_rs", "transfer_score_w_ms", "transfer_score_w_ml"];
|
||||
|
||||
const TaskEngineConfig: React.FC = () => {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isSuperAdmin = user?.roles?.includes("super_admin") ?? false;
|
||||
|
||||
const [params, setParams] = useState<ConfigParam[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 行内编辑
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editValue, setEditValue] = useState<number>(0);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 新增弹窗
|
||||
const [addVisible, setAddVisible] = useState(false);
|
||||
const [addSiteId, setAddSiteId] = useState<number | null>(null);
|
||||
const [addKey, setAddKey] = useState<string>("");
|
||||
const [addValue, setAddValue] = useState<number>(0);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
// 权重卡片编辑
|
||||
const [weightVisible, setWeightVisible] = useState(false);
|
||||
const [weightSiteId, setWeightSiteId] = useState<number | null>(null);
|
||||
const [wRs, setWRs] = useState(0.5);
|
||||
const [wMs, setWMs] = useState(0.3);
|
||||
const [wMl, setWMl] = useState(0.2);
|
||||
const [weightSaving, setWeightSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchConfigParams();
|
||||
setParams(data.params);
|
||||
} catch {
|
||||
message.error("加载参数配置失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (editingId == null) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateConfigParam(editingId, editValue);
|
||||
message.success("参数已更新");
|
||||
setEditingId(null);
|
||||
load();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
||||
message.error(msg || "更新失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!addSiteId || !addKey) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
await createConfigParam(addSiteId, addKey, addValue);
|
||||
message.success("门店覆盖参数已添加");
|
||||
setAddVisible(false);
|
||||
setAddKey("");
|
||||
setAddValue(0);
|
||||
load();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
||||
message.error(msg || "添加失败");
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (paramId: number) => {
|
||||
try {
|
||||
await deleteConfigParam(paramId);
|
||||
message.success("门店覆盖已删除");
|
||||
load();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
||||
message.error(msg || "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
/** 打开权重卡片编辑弹窗 */
|
||||
const openWeightEditor = (siteId: number | null) => {
|
||||
const siteParams = params.filter(
|
||||
(p) => p.site_id === siteId && WEIGHT_KEYS.includes(p.param_key),
|
||||
);
|
||||
const findVal = (key: string) => siteParams.find((p) => p.param_key === key)?.param_value ?? 0;
|
||||
setWeightSiteId(siteId);
|
||||
setWRs(findVal("transfer_score_w_rs"));
|
||||
setWMs(findVal("transfer_score_w_ms"));
|
||||
setWMl(findVal("transfer_score_w_ml"));
|
||||
setWeightVisible(true);
|
||||
};
|
||||
|
||||
const handleWeightSave = async () => {
|
||||
const sum = wRs + wMs + wMl;
|
||||
if (Math.abs(sum - 1.0) > 0.001) {
|
||||
message.error(`权重之和必须为 1.0,当前为 ${sum.toFixed(4)}`);
|
||||
return;
|
||||
}
|
||||
setWeightSaving(true);
|
||||
try {
|
||||
// 逐个更新三个权重参数
|
||||
const weightParams = params.filter(
|
||||
(p) => p.site_id === weightSiteId && WEIGHT_KEYS.includes(p.param_key),
|
||||
);
|
||||
const valMap: Record<string, number> = {
|
||||
transfer_score_w_rs: wRs,
|
||||
transfer_score_w_ms: wMs,
|
||||
transfer_score_w_ml: wMl,
|
||||
};
|
||||
for (const wp of weightParams) {
|
||||
await updateConfigParam(wp.id, valMap[wp.param_key]);
|
||||
}
|
||||
message.success("权重配置已更新");
|
||||
setWeightVisible(false);
|
||||
load();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
||||
message.error(msg || "权重更新失败");
|
||||
} finally {
|
||||
setWeightSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ConfigParam> = [
|
||||
{
|
||||
title: "参数", dataIndex: "param_key", key: "param_key", width: 220,
|
||||
render: (v: string) => (
|
||||
<Tooltip title={v}>
|
||||
<Text strong>{PARAM_LABELS[v] || v}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>{v}</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "门店", key: "site", width: 120,
|
||||
render: (_: unknown, r) => r.site_id == null
|
||||
? <Tag color="blue">全局默认</Tag>
|
||||
: <span>{r.site_name || `#${r.site_id}`}</span>,
|
||||
},
|
||||
{
|
||||
title: "参数值", key: "value", width: 160,
|
||||
render: (_: unknown, r) => {
|
||||
if (editingId === r.id) {
|
||||
return (
|
||||
<Space>
|
||||
<InputNumber
|
||||
size="small"
|
||||
value={editValue}
|
||||
onChange={(v) => v != null && setEditValue(v)}
|
||||
step={WEIGHT_KEYS.includes(r.param_key) ? 0.01 : 1}
|
||||
/>
|
||||
<Button size="small" type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave} />
|
||||
<Button size="small" onClick={() => setEditingId(null)}>取消</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
return <Text>{r.param_value}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "说明", dataIndex: "description", key: "desc", width: 200,
|
||||
render: (v: string | null) => v || "—",
|
||||
},
|
||||
{
|
||||
title: "更新时间", dataIndex: "updated_at", key: "updated_at", width: 160,
|
||||
render: (v: string) => dayjs(v).format("YYYY-MM-DD HH:mm"),
|
||||
},
|
||||
];
|
||||
|
||||
if (isSuperAdmin) {
|
||||
columns.push({
|
||||
title: "操作", key: "action", width: 160, fixed: "right",
|
||||
render: (_: unknown, r) => {
|
||||
// 权重参数用卡片编辑
|
||||
if (WEIGHT_KEYS.includes(r.param_key)) {
|
||||
return (
|
||||
<Button
|
||||
size="small" icon={<EditOutlined />}
|
||||
onClick={() => openWeightEditor(r.site_id)}
|
||||
>
|
||||
权重编辑
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Space size={4}>
|
||||
<Button
|
||||
size="small" icon={<EditOutlined />}
|
||||
onClick={() => { setEditingId(r.id); setEditValue(r.param_value); }}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
{r.site_id != null && (
|
||||
<Popconfirm title="确认删除此门店覆盖?" onConfirm={() => handleDelete(r.id)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<SettingOutlined style={{ marginRight: 8 }} />
|
||||
任务引擎参数管理
|
||||
</Title>
|
||||
<Space>
|
||||
{isSuperAdmin && (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setAddVisible(true)}>
|
||||
新增门店覆盖
|
||||
</Button>
|
||||
)}
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card size="small">
|
||||
<Table<ConfigParam>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={params}
|
||||
loading={loading}
|
||||
size="small"
|
||||
scroll={{ x: 1000 }}
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 新增门店覆盖弹窗 */}
|
||||
<Modal
|
||||
title="新增门店覆盖参数"
|
||||
open={addVisible}
|
||||
onOk={handleAdd}
|
||||
onCancel={() => setAddVisible(false)}
|
||||
confirmLoading={adding}
|
||||
okButtonProps={{ disabled: !addSiteId || !addKey }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<div>
|
||||
<Text>门店 ID:</Text>
|
||||
<InputNumber style={{ width: "100%" }} value={addSiteId} onChange={(v) => setAddSiteId(v)} />
|
||||
</div>
|
||||
<div>
|
||||
<Text>参数名:</Text>
|
||||
<Select
|
||||
style={{ width: "100%" }}
|
||||
value={addKey || undefined}
|
||||
onChange={(v) => setAddKey(v)}
|
||||
placeholder="选择参数"
|
||||
options={Object.entries(PARAM_LABELS).map(([k, label]) => ({ value: k, label: `${label} (${k})` }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text>参数值:</Text>
|
||||
<InputNumber style={{ width: "100%" }} value={addValue} onChange={(v) => v != null && setAddValue(v)} />
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
{/* 权重卡片编辑弹窗 */}
|
||||
<Modal
|
||||
title={`权重配置${weightSiteId != null ? ` — 门店 #${weightSiteId}` : "(全局)"}`}
|
||||
open={weightVisible}
|
||||
onOk={handleWeightSave}
|
||||
onCancel={() => setWeightVisible(false)}
|
||||
confirmLoading={weightSaving}
|
||||
>
|
||||
<Text type="secondary" style={{ display: "block", marginBottom: 12 }}>
|
||||
三项权重之和必须等于 1.0(容差 0.001)
|
||||
</Text>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Text style={{ width: 120 }}>RS 权重 (w_rs):</Text>
|
||||
<InputNumber value={wRs} onChange={(v) => v != null && setWRs(v)} step={0.05} min={0} max={1} style={{ flex: 1 }} />
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Text style={{ width: 120 }}>MS 权重 (w_ms):</Text>
|
||||
<InputNumber value={wMs} onChange={(v) => v != null && setWMs(v)} step={0.05} min={0} max={1} style={{ flex: 1 }} />
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Text style={{ width: 120 }}>ML 权重 (w_ml):</Text>
|
||||
<InputNumber value={wMl} onChange={(v) => v != null && setWMl(v)} step={0.05} min={0} max={1} style={{ flex: 1 }} />
|
||||
</div>
|
||||
<div style={{ textAlign: "right", marginTop: 8 }}>
|
||||
<Text type={Math.abs(wRs + wMs + wMl - 1.0) > 0.001 ? "danger" : "success"}>
|
||||
当前合计:{(wRs + wMs + wMl).toFixed(4)}
|
||||
</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskEngineConfig;
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Tabs, Table, Tag, Button, Popconfirm, Space, message, Drawer,
|
||||
Typography, Descriptions, Empty, Spin,
|
||||
@@ -14,12 +15,12 @@ import {
|
||||
import {
|
||||
ReloadOutlined, DeleteOutlined, StopOutlined,
|
||||
UnorderedListOutlined, ClockCircleOutlined, HistoryOutlined,
|
||||
FileTextOutlined,
|
||||
FileTextOutlined, PlayCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { QueuedTask, ExecutionLog } from '../types';
|
||||
import {
|
||||
fetchQueue, fetchHistory, deleteFromQueue, cancelExecution,
|
||||
fetchQueue, fetchHistory, deleteFromQueue, cancelExecution, rerunExecution,
|
||||
} from '../api/execution';
|
||||
import { apiClient } from '../api/client';
|
||||
import LogStream from '../components/LogStream';
|
||||
@@ -37,6 +38,7 @@ const STATUS_COLOR: Record<string, string> = {
|
||||
success: 'success',
|
||||
failed: 'error',
|
||||
cancelled: 'warning',
|
||||
interrupted: 'volcano',
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -62,7 +64,7 @@ function fmtDuration(ms: number | null | undefined): string {
|
||||
/* 队列 Tab */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const QueueTab: React.FC = () => {
|
||||
export const QueueTab: React.FC = () => {
|
||||
const [data, setData] = useState<QueuedTask[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -236,7 +238,7 @@ const QueueTab: React.FC = () => {
|
||||
/* 历史 Tab */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const HistoryTab: React.FC = () => {
|
||||
export const HistoryTab: React.FC = () => {
|
||||
const [data, setData] = useState<ExecutionLog[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<ExecutionLog | null>(null);
|
||||
@@ -263,6 +265,16 @@ const HistoryTab: React.FC = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// CHANGE 2026-03-22 | 重新执行历史任务
|
||||
const handleRerun = useCallback(async (id: string) => {
|
||||
try {
|
||||
const { execution_id } = await rerunExecution(id);
|
||||
message.success(`已重新执行,新 ID: ${execution_id.slice(0, 8)}…`);
|
||||
load();
|
||||
} catch { message.error('重新执行失败'); }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setData(await fetchHistory()); }
|
||||
@@ -330,6 +342,23 @@ const HistoryTab: React.FC = () => {
|
||||
}
|
||||
}, [closeHistoryWs, load]);
|
||||
|
||||
// CHANGE 2026-03-27 | 支持 URL 参数 openExecution 自动打开任务详情
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const openExecutionHandled = useRef(false);
|
||||
useEffect(() => {
|
||||
const openId = searchParams.get('openExecution');
|
||||
if (!openId || openExecutionHandled.current || loading || data.length === 0) return;
|
||||
openExecutionHandled.current = true;
|
||||
const target = data.find((r) => r.id === openId);
|
||||
if (target) {
|
||||
handleRowClick(target);
|
||||
} else {
|
||||
handleRowClick({ id: openId, status: 'running' } as ExecutionLog);
|
||||
}
|
||||
searchParams.delete('openExecution');
|
||||
setSearchParams(searchParams, { replace: true });
|
||||
}, [data, loading, searchParams, setSearchParams, handleRowClick]);
|
||||
|
||||
const columns: ColumnsType<ExecutionLog> = [
|
||||
{
|
||||
title: '执行 ID', dataIndex: 'id', key: 'id', width: 120,
|
||||
@@ -366,19 +395,25 @@ const HistoryTab: React.FC = () => {
|
||||
) : '—',
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 80, align: 'center',
|
||||
render: (_: unknown, record: ExecutionLog) => {
|
||||
if (record.status === 'running') {
|
||||
return (
|
||||
title: '操作', key: 'action', width: 140, align: 'center',
|
||||
render: (_: unknown, record: ExecutionLog) => (
|
||||
<Space size={0}>
|
||||
{record.status === 'running' && (
|
||||
<Popconfirm title="确认终止该任务?" onConfirm={(e) => { e?.stopPropagation(); handleCancelHistory(record.id); }} onCancel={(e) => e?.stopPropagation()}>
|
||||
<Button type="link" danger icon={<StopOutlined />} size="small" onClick={(e) => e.stopPropagation()}>
|
||||
终止
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
)}
|
||||
{record.status !== 'running' && (
|
||||
<Popconfirm title="确认重新执行该任务?" onConfirm={(e) => { e?.stopPropagation(); handleRerun(record.id); }} onCancel={(e) => e?.stopPropagation()}>
|
||||
<Button type="link" icon={<PlayCircleOutlined />} size="small" onClick={(e) => e.stopPropagation()}>
|
||||
重新执行
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
1134
apps/admin-web/src/pages/TenantAdmins/index.tsx
Normal file
1134
apps/admin-web/src/pages/TenantAdmins/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
181
apps/admin-web/src/pages/TransferLog.tsx
Normal file
181
apps/admin-web/src/pages/TransferLog.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* P18 客户转移日志页面。
|
||||
*
|
||||
* 展示 biz.coach_task_transfer_log 分页列表,支持门店/时间/助教筛选。
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Table, Card, Typography, Button, Space, DatePicker, InputNumber,
|
||||
Tag, Tooltip, message,
|
||||
} from "antd";
|
||||
import {
|
||||
ReloadOutlined, SwapOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
fetchTransferLogs, type TransferLogItem, type TransferLogQuery,
|
||||
} from "../api/taskEngine";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
function formatTime(raw: string | null): string {
|
||||
if (!raw) return "—";
|
||||
return dayjs(raw).format("YYYY-MM-DD HH:mm");
|
||||
}
|
||||
|
||||
/** guard_checks JSON → 三项检查标签 */
|
||||
function renderGuardChecks(checks: Record<string, unknown> | null) {
|
||||
if (!checks) return <Text type="secondary">—</Text>;
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
{Object.entries(checks).map(([k, v]) => (
|
||||
<Tag
|
||||
key={k}
|
||||
color={v ? "success" : "error"}
|
||||
icon={v ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
|
||||
>
|
||||
{k}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
const REASON_LABELS: Record<string, string> = {
|
||||
consecutive_recall_fail: "连续召回失败",
|
||||
manual_reassign: "人工重新分配",
|
||||
ownership_change: "归属变更",
|
||||
};
|
||||
|
||||
const TransferLog: React.FC = () => {
|
||||
const [items, setItems] = useState<TransferLogItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState<TransferLogQuery>({ page: 1, page_size: 20 });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchTransferLogs(query);
|
||||
setItems(data.items);
|
||||
setTotal(data.total);
|
||||
} catch {
|
||||
message.error("加载转移日志失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const columns: ColumnsType<TransferLogItem> = [
|
||||
{
|
||||
title: "转移时间", dataIndex: "created_at", key: "created_at", width: 160,
|
||||
render: (v: string) => formatTime(v),
|
||||
},
|
||||
{
|
||||
title: "门店", dataIndex: "site_name", key: "site_name", width: 120,
|
||||
render: (v: string, r) => v || `#${r.site_id}`,
|
||||
},
|
||||
{
|
||||
title: "客户", key: "member", width: 140,
|
||||
render: (_: unknown, r) => (
|
||||
<Tooltip title={`ID: ${r.member_id}`}>
|
||||
{r.member_name || `会员#${r.member_id}`}
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "原助教", key: "from", width: 120,
|
||||
render: (_: unknown, r) => r.from_assistant_name || `#${r.from_assistant_id}`,
|
||||
},
|
||||
{
|
||||
title: "新助教", key: "to", width: 120,
|
||||
render: (_: unknown, r) => r.to_assistant_name || `#${r.to_assistant_id}`,
|
||||
},
|
||||
{
|
||||
title: "转移原因", dataIndex: "transfer_reason", key: "reason", width: 140,
|
||||
render: (v: string | null) => v ? (
|
||||
<Tag>{REASON_LABELS[v] || v}</Tag>
|
||||
) : "—",
|
||||
},
|
||||
{
|
||||
title: "转移得分", dataIndex: "transfer_score", key: "score", width: 90,
|
||||
render: (v: number | null) => v != null ? v.toFixed(2) : "—",
|
||||
},
|
||||
{
|
||||
title: "保护检查", dataIndex: "guard_checks", key: "guards", width: 200,
|
||||
render: (v: Record<string, unknown> | null) => renderGuardChecks(v),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<SwapOutlined style={{ marginRight: 8 }} />
|
||||
客户转移日志
|
||||
</Title>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
</div>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<RangePicker
|
||||
placeholder={["开始日期", "结束日期"]}
|
||||
onChange={(dates) => {
|
||||
setQuery((q) => ({
|
||||
...q,
|
||||
from_date: dates?.[0]?.format("YYYY-MM-DD"),
|
||||
to_date: dates?.[1]?.format("YYYY-MM-DD"),
|
||||
page: 1,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<InputNumber
|
||||
placeholder="助教 ID"
|
||||
style={{ width: 140 }}
|
||||
onChange={(v) => setQuery((q) => ({
|
||||
...q,
|
||||
assistant_id: (v as number) ?? undefined,
|
||||
page: 1,
|
||||
}))}
|
||||
/>
|
||||
<InputNumber
|
||||
placeholder="门店 ID"
|
||||
style={{ width: 140 }}
|
||||
onChange={(v) => setQuery((q) => ({
|
||||
...q,
|
||||
site_id: (v as number) ?? undefined,
|
||||
page: 1,
|
||||
}))}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small">
|
||||
<Table<TransferLogItem>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
size="small"
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: query.page,
|
||||
pageSize: query.page_size,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (page, pageSize) => setQuery((q) => ({ ...q, page, page_size: pageSize })),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransferLog;
|
||||
206
apps/admin-web/src/pages/TriggerJobs.tsx
Normal file
206
apps/admin-web/src/pages/TriggerJobs.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 定时任务管理页面。
|
||||
*
|
||||
* 展示 biz.trigger_jobs 表中所有定时任务,支持手动执行。
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { Table, Tag, Button, message, Modal, Typography, Card, Space, Popconfirm, Tooltip } from 'antd';
|
||||
import {
|
||||
ReloadOutlined,
|
||||
ClockCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { fetchTriggerJobs, runTriggerJob, clearAllTasks, type TriggerJob } from '../api/triggerJobs';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const TRIGGER_LABEL: Record<string, string> = {
|
||||
cron: '定时(Cron)',
|
||||
interval: '间隔',
|
||||
event: '事件触发',
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
enabled: 'green',
|
||||
disabled: 'default',
|
||||
};
|
||||
|
||||
function formatTime(raw: string | null): string {
|
||||
if (!raw) return '—';
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function formatTriggerConfig(job: TriggerJob): string {
|
||||
const cfg = job.trigger_config;
|
||||
if (!cfg) return '—';
|
||||
if (job.trigger_condition === 'cron') return cfg.cron_expression as string || '—';
|
||||
if (job.trigger_condition === 'interval') {
|
||||
const sec = cfg.interval_seconds as number;
|
||||
if (sec >= 3600) return `每 ${sec / 3600} 小时`;
|
||||
if (sec >= 60) return `每 ${sec / 60} 分钟`;
|
||||
return `每 ${sec} 秒`;
|
||||
}
|
||||
if (job.trigger_condition === 'event') return `事件: ${cfg.event_name || '—'}`;
|
||||
return JSON.stringify(cfg);
|
||||
}
|
||||
|
||||
const TriggerJobs: React.FC = () => {
|
||||
const [jobs, setJobs] = useState<TriggerJob[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [runningId, setRunningId] = useState<number | null>(null);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchTriggerJobs();
|
||||
setJobs(data);
|
||||
} catch {
|
||||
message.error('加载定时任务失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleRun = async (jobId: number) => {
|
||||
setRunningId(jobId);
|
||||
try {
|
||||
const result = await runTriggerJob(jobId);
|
||||
if (result.success) {
|
||||
message.success(result.message);
|
||||
} else {
|
||||
message.error(result.message);
|
||||
}
|
||||
await load();
|
||||
} catch {
|
||||
message.error('执行失败');
|
||||
} finally {
|
||||
setRunningId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAllTasks = async () => {
|
||||
setClearing(true);
|
||||
try {
|
||||
const result = await clearAllTasks();
|
||||
if (result.success) {
|
||||
Modal.success({
|
||||
title: '清空完成',
|
||||
content: result.message,
|
||||
});
|
||||
await load();
|
||||
} else {
|
||||
message.error(result.message);
|
||||
}
|
||||
} catch {
|
||||
message.error('清空任务失败');
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<TriggerJob> = [
|
||||
{
|
||||
title: '任务名称', dataIndex: 'job_name', key: 'job_name', width: 180,
|
||||
render: (name: string, record) => (
|
||||
<Tooltip title={record.description || name}>
|
||||
<Text strong>{record.description || name}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{name}</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '触发方式', dataIndex: 'trigger_condition', key: 'trigger_condition', width: 120,
|
||||
render: (v: string) => <Tag>{TRIGGER_LABEL[v] || v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '触发配置', key: 'trigger_config', width: 150,
|
||||
render: (_: unknown, record) => <code style={{ fontSize: 12 }}>{formatTriggerConfig(record)}</code>,
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 80,
|
||||
render: (v: string) => <Tag color={STATUS_COLOR[v] || 'default'}>{v === 'enabled' ? '启用' : '禁用'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '上次执行', dataIndex: 'last_run_at', key: 'last_run_at', width: 170,
|
||||
render: (v: string | null) => formatTime(v),
|
||||
},
|
||||
{
|
||||
title: '下次执行', dataIndex: 'next_run_at', key: 'next_run_at', width: 170,
|
||||
render: (v: string | null) => formatTime(v),
|
||||
},
|
||||
{
|
||||
title: '最近错误', dataIndex: 'last_error', key: 'last_error', width: 200,
|
||||
render: (v: string | null) => v
|
||||
? <Tooltip title={v}><Text type="danger" ellipsis style={{ maxWidth: 180 }}><ExclamationCircleOutlined /> {v}</Text></Tooltip>
|
||||
: <Text type="success"><CheckCircleOutlined /> 正常</Text>,
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 100, fixed: 'right',
|
||||
render: (_: unknown, record) => (
|
||||
<Popconfirm
|
||||
title={`确认手动执行「${record.description || record.job_name}」?`}
|
||||
onConfirm={() => handleRun(record.id)}
|
||||
okText="执行"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={runningId === record.id}
|
||||
disabled={record.status !== 'enabled'}
|
||||
>
|
||||
执行
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<ClockCircleOutlined style={{ marginRight: 8 }} />
|
||||
定时任务管理
|
||||
</Title>
|
||||
<Space>
|
||||
<Popconfirm
|
||||
title="确认清空所有助教任务?"
|
||||
description="将删除 coach_tasks 和 coach_task_history 中的全部数据,此操作不可撤销。"
|
||||
onConfirm={handleClearAllTasks}
|
||||
okText="确认清空"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button danger loading={clearing}>🧹 清空所有任务</Button>
|
||||
</Popconfirm>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card size="small">
|
||||
<Table<TriggerJob>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={jobs}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TriggerJobs;
|
||||
462
apps/admin-web/src/pages/TriggerManager.tsx
Normal file
462
apps/admin-web/src/pages/TriggerManager.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* 触发器统一管理页面 — 聚合 biz / ai / etl 三类触发器为 Tab 视图。
|
||||
*
|
||||
* - 4 个 Tab:all(全部,只读统一视图)、biz(业务)、ai(AI)、etl(ETL)
|
||||
* - Tab 切换通过 useSearchParams 同步 URL 查询参数 ?tab=all|biz|ai|etl
|
||||
* - destroyInactiveTabPane={false} 保持 Tab 状态不丢失
|
||||
* - "全部"Tab 调用 fetchUnifiedTriggers(),展示统一字段表格
|
||||
* - "业务"Tab 复用 TriggerJobs 组件 + 编辑 Modal
|
||||
* - "AI"Tab 复用 AIOperations + AITriggerJobs 组件
|
||||
* - "ETL"Tab 展示 scheduled_tasks 数据
|
||||
*
|
||||
* CHANGE 2026-07-15 | Task 10.1:创建 TriggerManager 页面
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Tabs, Typography, Table, Tag, message, Modal, Form, Input, InputNumber, Space,
|
||||
Button, Card,
|
||||
} from 'antd';
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
SettingOutlined,
|
||||
RobotOutlined,
|
||||
CloudServerOutlined,
|
||||
EditOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { fetchUnifiedTriggers, type UnifiedTriggerItem } from '../api/triggers';
|
||||
import {
|
||||
fetchTriggerJobs, updateTriggerConfig,
|
||||
type TriggerJob, type UpdateTriggerConfigReq,
|
||||
} from '../api/triggerJobs';
|
||||
import { fetchSchedules } from '../api/schedules';
|
||||
import type { ScheduledTask } from '../types';
|
||||
import AIOperations from './AIOperations';
|
||||
import AITriggerJobs from './AITriggerJobs';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
/* ───────── Tab 常量 ───────── */
|
||||
|
||||
const VALID_TABS = ['all', 'biz', 'ai', 'etl'] as const;
|
||||
type TabKey = (typeof VALID_TABS)[number];
|
||||
const DEFAULT_TAB: TabKey = 'all';
|
||||
|
||||
function isValidTab(value: string | null): value is TabKey {
|
||||
return value != null && (VALID_TABS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/* ───────── 工具函数 ───────── */
|
||||
|
||||
const SOURCE_COLOR: Record<string, string> = {
|
||||
biz: 'blue', ai: 'purple', etl: 'green',
|
||||
};
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
biz: '业务', ai: 'AI', etl: 'ETL',
|
||||
};
|
||||
|
||||
function formatTime(raw: string | null): string {
|
||||
if (!raw) return '—';
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
/* ───────── "全部"Tab:统一视图(只读) ───────── */
|
||||
|
||||
const AllTriggersTab: React.FC = () => {
|
||||
const [data, setData] = useState<UnifiedTriggerItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setData(await fetchUnifiedTriggers());
|
||||
} catch {
|
||||
message.error('加载统一触发器数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const columns: ColumnsType<UnifiedTriggerItem> = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name', width: 200 },
|
||||
{
|
||||
title: '类型', dataIndex: 'source', key: 'source', width: 80,
|
||||
render: (v: string) => (
|
||||
<Tag color={SOURCE_COLOR[v] ?? 'default'}>{SOURCE_LABEL[v] ?? v}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '触发条件', dataIndex: 'trigger_condition', key: 'trigger_condition', width: 120 },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 100,
|
||||
render: (v: string) => {
|
||||
const color = v === 'running' ? 'processing' : v === 'error' ? 'error'
|
||||
: v === 'disabled' ? 'default' : 'success';
|
||||
return <Tag color={color}>{v}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '上次执行', dataIndex: 'last_run_at', key: 'last_run_at', width: 170, render: formatTime },
|
||||
{ title: '下次执行', dataIndex: 'next_run_at', key: 'next_run_at', width: 170, render: formatTime },
|
||||
{
|
||||
title: '最近错误', dataIndex: 'last_error', key: 'last_error', ellipsis: true,
|
||||
render: (v: string | null) => v
|
||||
? <Typography.Text type="danger" ellipsis style={{ maxWidth: 200 }}>{v}</Typography.Text>
|
||||
: '—',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
extra={<Button icon={<ReloadOutlined />} size="small" onClick={load} loading={loading}>刷新</Button>}
|
||||
>
|
||||
<Table<UnifiedTriggerItem>
|
||||
rowKey={(r) => `${r.source}-${r.id}`}
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20, showTotal: (t) => `共 ${t} 条` }}
|
||||
size="small"
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/* ───────── "业务"Tab:TriggerJobs + 编辑 Modal ───────── */
|
||||
|
||||
const BizTriggersTab: React.FC = () => {
|
||||
const [jobs, setJobs] = useState<TriggerJob[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingJob, setEditingJob] = useState<TriggerJob | null>(null);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form] = Form.useForm<UpdateTriggerConfigReq>();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setJobs(await fetchTriggerJobs());
|
||||
} catch {
|
||||
message.error('加载业务触发器失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openEdit = (job: TriggerJob) => {
|
||||
setEditingJob(job);
|
||||
const cfg = job.trigger_config ?? {};
|
||||
form.setFieldsValue({
|
||||
cron_expression: (cfg.cron_expression as string) ?? undefined,
|
||||
interval_seconds: (cfg.interval_seconds as number) ?? undefined,
|
||||
});
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editingJob) return;
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
// 只发送有值的字段
|
||||
const body: UpdateTriggerConfigReq = {};
|
||||
if (values.cron_expression != null && values.cron_expression !== '') {
|
||||
body.cron_expression = values.cron_expression;
|
||||
}
|
||||
if (values.interval_seconds != null) {
|
||||
body.interval_seconds = values.interval_seconds;
|
||||
}
|
||||
if (!body.cron_expression && body.interval_seconds == null) {
|
||||
message.warning('请至少填写 cron 表达式或间隔秒数');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
await updateTriggerConfig(editingJob.id, body);
|
||||
message.success('触发器配置已更新');
|
||||
setEditModalOpen(false);
|
||||
setEditingJob(null);
|
||||
form.resetFields();
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
// 422 错误展示具体信息
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const resp = (err as { response?: { status?: number; data?: { detail?: string } } }).response;
|
||||
if (resp?.status === 422 && resp.data?.detail) {
|
||||
message.error(resp.data.detail);
|
||||
return;
|
||||
}
|
||||
}
|
||||
message.error('保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const TRIGGER_LABEL: Record<string, string> = {
|
||||
cron: '定时(Cron)', interval: '间隔', event: '事件触发',
|
||||
};
|
||||
|
||||
const formatTriggerConfig = (job: TriggerJob): string => {
|
||||
const cfg = job.trigger_config;
|
||||
if (!cfg) return '—';
|
||||
if (job.trigger_condition === 'cron') return (cfg.cron_expression as string) || '—';
|
||||
if (job.trigger_condition === 'interval') {
|
||||
const sec = cfg.interval_seconds as number;
|
||||
if (sec >= 3600) return `每 ${sec / 3600} 小时`;
|
||||
if (sec >= 60) return `每 ${sec / 60} 分钟`;
|
||||
return `每 ${sec} 秒`;
|
||||
}
|
||||
if (job.trigger_condition === 'event') return `事件: ${cfg.event_name || '—'}`;
|
||||
return JSON.stringify(cfg);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<TriggerJob> = [
|
||||
{
|
||||
title: '任务名称', dataIndex: 'job_name', key: 'job_name', width: 180,
|
||||
render: (name: string, record) => (
|
||||
<>
|
||||
<Typography.Text strong>{record.description || name}</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{name}</Typography.Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '触发方式', dataIndex: 'trigger_condition', key: 'trigger_condition', width: 120,
|
||||
render: (v: string) => <Tag>{TRIGGER_LABEL[v] || v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '触发配置', key: 'trigger_config', width: 150,
|
||||
render: (_: unknown, record) => <code style={{ fontSize: 12 }}>{formatTriggerConfig(record)}</code>,
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 80,
|
||||
render: (v: string) => (
|
||||
<Tag color={v === 'enabled' ? 'green' : 'default'}>{v === 'enabled' ? '启用' : '禁用'}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '上次执行', dataIndex: 'last_run_at', key: 'last_run_at', width: 170, render: formatTime },
|
||||
{ title: '下次执行', dataIndex: 'next_run_at', key: 'next_run_at', width: 170, render: formatTime },
|
||||
{
|
||||
title: '最近错误', dataIndex: 'last_error', key: 'last_error', width: 200,
|
||||
render: (v: string | null) => v
|
||||
? <Typography.Text type="danger" ellipsis style={{ maxWidth: 180 }}>{v}</Typography.Text>
|
||||
: <Typography.Text type="success">正常</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 80, fixed: 'right',
|
||||
render: (_: unknown, record) => (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(record)}
|
||||
disabled={record.status !== 'enabled'}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
size="small"
|
||||
extra={<Button icon={<ReloadOutlined />} size="small" onClick={load} loading={loading}>刷新</Button>}
|
||||
>
|
||||
<Table<TriggerJob>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={jobs}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={`编辑触发器配置 — ${editingJob?.description || editingJob?.job_name || ''}`}
|
||||
open={editModalOpen}
|
||||
onCancel={() => { setEditModalOpen(false); setEditingJob(null); form.resetFields(); }}
|
||||
onOk={handleSave}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="cron_expression"
|
||||
label="Cron 表达式(5 字段格式)"
|
||||
help="例如:0 */2 * * *(每 2 小时执行)"
|
||||
>
|
||||
<Input placeholder="分 时 日 月 周" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="interval_seconds"
|
||||
label="间隔秒数"
|
||||
help="最小值为 1"
|
||||
rules={[{ type: 'number', min: 1, message: 'interval_seconds 必须 >= 1' }]}
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} min={1} placeholder="秒" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/* ───────── "AI"Tab:AIOperations + AITriggerJobs ───────── */
|
||||
|
||||
const AITriggersTab: React.FC = () => (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<AIOperations />
|
||||
<AITriggerJobs />
|
||||
</Space>
|
||||
);
|
||||
|
||||
/* ───────── "ETL"Tab:scheduled_tasks 数据 ───────── */
|
||||
|
||||
const ETLTriggersTab: React.FC = () => {
|
||||
const [data, setData] = useState<ScheduledTask[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setData(await fetchSchedules());
|
||||
} catch {
|
||||
message.error('加载 ETL 调度任务失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const columns: ColumnsType<ScheduledTask> = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name', width: 200 },
|
||||
{
|
||||
title: '任务代码', dataIndex: 'task_codes', key: 'task_codes', width: 200,
|
||||
render: (v: string[]) => v?.join(', ') ?? '—',
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'enabled', key: 'enabled', width: 80,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '上次状态', dataIndex: 'last_status', key: 'last_status', width: 100,
|
||||
render: (v: string | null) => v
|
||||
? <Tag color={v === 'success' ? 'success' : v === 'failed' ? 'error' : 'default'}>{v}</Tag>
|
||||
: '—',
|
||||
},
|
||||
{ title: '上次执行', dataIndex: 'last_run_at', key: 'last_run_at', width: 170, render: formatTime },
|
||||
{ title: '下次执行', dataIndex: 'next_run_at', key: 'next_run_at', width: 170, render: formatTime },
|
||||
{ title: '执行次数', dataIndex: 'run_count', key: 'run_count', width: 90 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: formatTime },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
extra={<Button icon={<ReloadOutlined />} size="small" onClick={load} loading={loading}>刷新</Button>}
|
||||
>
|
||||
<Table<ScheduledTask>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20, showTotal: (t) => `共 ${t} 条` }}
|
||||
size="small"
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/* ───────── 主组件 ───────── */
|
||||
|
||||
const TriggerManager: React.FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const activeTab: TabKey = useMemo(() => {
|
||||
const raw = searchParams.get('tab');
|
||||
return isValidTab(raw) ? raw : DEFAULT_TAB;
|
||||
}, [searchParams]);
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
setSearchParams({ tab: key }, { replace: true });
|
||||
};
|
||||
|
||||
const items = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'all' as TabKey,
|
||||
label: (
|
||||
<span>
|
||||
<AppstoreOutlined style={{ marginRight: 6 }} />
|
||||
全部
|
||||
</span>
|
||||
),
|
||||
children: <AllTriggersTab />,
|
||||
},
|
||||
{
|
||||
key: 'biz' as TabKey,
|
||||
label: (
|
||||
<span>
|
||||
<SettingOutlined style={{ marginRight: 6 }} />
|
||||
业务
|
||||
</span>
|
||||
),
|
||||
children: <BizTriggersTab />,
|
||||
},
|
||||
{
|
||||
key: 'ai' as TabKey,
|
||||
label: (
|
||||
<span>
|
||||
<RobotOutlined style={{ marginRight: 6 }} />
|
||||
AI
|
||||
</span>
|
||||
),
|
||||
children: <AITriggersTab />,
|
||||
},
|
||||
{
|
||||
key: 'etl' as TabKey,
|
||||
label: (
|
||||
<span>
|
||||
<CloudServerOutlined style={{ marginRight: 6 }} />
|
||||
ETL
|
||||
</span>
|
||||
),
|
||||
children: <ETLTriggersTab />,
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4} style={{ marginBottom: 16 }}>
|
||||
<SettingOutlined style={{ marginRight: 8 }} />
|
||||
触发器管理
|
||||
</Title>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={handleTabChange}
|
||||
items={items}
|
||||
destroyInactiveTabPane={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TriggerManager;
|
||||
@@ -1,5 +1,9 @@
|
||||
/**
|
||||
* 日志查看器页面。
|
||||
* [ARCHIVED] 日志查看器页面。
|
||||
*
|
||||
* 已废弃:功能已合并到 ETLTasks 页面的"任务管理"Tab。
|
||||
* 归档日期:2026-03-25
|
||||
* 归档原因:admin-web-restructure spec,需求 8(LogViewer 废弃)
|
||||
*
|
||||
* - 输入执行 ID,通过 WebSocket 实时接收日志
|
||||
* - 支持加载历史日志
|
||||
@@ -13,9 +17,9 @@ import {
|
||||
FileTextOutlined, SearchOutlined, ClearOutlined,
|
||||
AppstoreOutlined, UnorderedListOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { apiClient } from "../api/client";
|
||||
import LogStream from "../components/LogStream";
|
||||
import TaskLogViewer from "../components/TaskLogViewer";
|
||||
import { apiClient } from "../../api/client";
|
||||
import LogStream from "../../components/LogStream";
|
||||
import TaskLogViewer from "../../components/TaskLogViewer";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface AuthUser {
|
||||
username: string;
|
||||
display_name: string;
|
||||
site_id: number;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
/** 后端 /api/auth/login 响应体 */
|
||||
@@ -64,6 +65,7 @@ function parseJwtPayload(token: string): AuthUser | null {
|
||||
username: payload.username as string,
|
||||
display_name: (payload.display_name as string) ?? "",
|
||||
site_id: payload.site_id as number,
|
||||
roles: (payload.roles as string[]) ?? [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
92
apps/admin-web/src/types/devTrace.ts
Normal file
92
apps/admin-web/src/types/devTrace.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 开发调试全链路日志 — TypeScript 类型定义。
|
||||
*
|
||||
* 与后端 trace 模块的 Pydantic 模型和 JSON Lines 输出结构对应。
|
||||
*/
|
||||
|
||||
// ---- Span 类型枚举 ----
|
||||
|
||||
export type SpanType =
|
||||
| "HTTP_IN" | "AUTH" | "ROUTE" | "SERVICE"
|
||||
| "DB_QUERY" | "DB_CONN" | "DB_CONN_RELEASE"
|
||||
| "HTTP_OUT" | "ERROR" | "DB_ERROR"
|
||||
| "MIDDLEWARE" | "MIDDLEWARE_ERROR"
|
||||
| "SSE_START" | "SSE_EVENT" | "SSE_END"
|
||||
| "AI_CALL" | "AI_STREAM" | "AI_ERROR"
|
||||
| "WS_CONNECT" | "WS_MESSAGE" | "WS_DISCONNECT"
|
||||
| "JOB_START" | "JOB_END" | "JOB_ERROR";
|
||||
|
||||
export type TraceType = "http" | "sse" | "ws" | "job";
|
||||
|
||||
// ---- 数据模型 ----
|
||||
|
||||
export interface TraceSpan {
|
||||
span_type: SpanType;
|
||||
module: string;
|
||||
function: string;
|
||||
description_zh: string;
|
||||
description_en: string;
|
||||
params: Record<string, unknown>;
|
||||
result_summary: string;
|
||||
duration_ms: number;
|
||||
timestamp: string;
|
||||
extra: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TraceRequest {
|
||||
request_id: string;
|
||||
trace_type: TraceType;
|
||||
timestamp: string;
|
||||
method: string;
|
||||
path: string;
|
||||
status_code: number | null;
|
||||
total_duration_ms: number;
|
||||
user_id: number | null;
|
||||
site_id: number | null;
|
||||
db_query_count: number;
|
||||
db_total_ms: number;
|
||||
error: string | null;
|
||||
span_count: number;
|
||||
}
|
||||
|
||||
export interface TraceDetail extends Omit<TraceRequest, "span_count"> {
|
||||
spans: TraceSpan[];
|
||||
}
|
||||
|
||||
export interface TraceSettings {
|
||||
enabled: boolean;
|
||||
log_dir: string;
|
||||
retention_days: number;
|
||||
log_sql: boolean;
|
||||
log_params: boolean;
|
||||
}
|
||||
|
||||
export interface TraceFilter {
|
||||
date: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
trace_type?: TraceType;
|
||||
method?: string;
|
||||
path_contains?: string;
|
||||
status_code?: number;
|
||||
min_duration?: number;
|
||||
has_error?: boolean;
|
||||
span_type?: SpanType;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export interface CoverageCategory {
|
||||
total: number;
|
||||
covered: number;
|
||||
uncovered: string[];
|
||||
}
|
||||
|
||||
export interface TraceCoverage {
|
||||
scan_time: string;
|
||||
routes: CoverageCategory;
|
||||
services: CoverageCategory;
|
||||
jobs: CoverageCategory;
|
||||
sse_endpoints: CoverageCategory;
|
||||
ws_endpoints: CoverageCategory;
|
||||
}
|
||||
@@ -125,6 +125,12 @@ export interface ExecutionLog {
|
||||
schedule_id: string | null;
|
||||
}
|
||||
|
||||
/** 单个任务的最小执行间隔 */
|
||||
export interface MinRunIntervalItem {
|
||||
value: number;
|
||||
unit: "minutes" | "hours" | "days";
|
||||
}
|
||||
|
||||
/** 调度任务 */
|
||||
export interface ScheduledTask {
|
||||
id: string;
|
||||
@@ -138,6 +144,10 @@ export interface ScheduledTask {
|
||||
next_run_at: string | null;
|
||||
run_count: number;
|
||||
last_status: string | null;
|
||||
min_run_interval_value: number;
|
||||
min_run_interval_unit: string;
|
||||
last_success_at: string | null;
|
||||
min_run_intervals: Record<string, MinRunIntervalItem>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/__tests__/flowlayers.test.ts","./src/__tests__/logfilter.test.ts","./src/__tests__/tasklogparser.test.ts","./src/api/businessday.ts","./src/api/client.ts","./src/api/dbviewer.ts","./src/api/envconfig.ts","./src/api/etlstatus.ts","./src/api/execution.ts","./src/api/opspanel.ts","./src/api/schedules.ts","./src/api/tasks.ts","./src/components/businessdayhint.tsx","./src/components/dwdtableselector.tsx","./src/components/errorboundary.tsx","./src/components/logstream.tsx","./src/components/schedulehistorydrawer.tsx","./src/components/scheduletab.tsx","./src/components/tasklogviewer.tsx","./src/components/taskselector.tsx","./src/pages/dbviewer.tsx","./src/pages/etlstatus.tsx","./src/pages/envconfig.tsx","./src/pages/logviewer.tsx","./src/pages/login.tsx","./src/pages/opspanel.tsx","./src/pages/taskconfig.tsx","./src/pages/taskmanager.tsx","./src/store/authstore.ts","./src/store/businessdaystore.ts","./src/types/index.ts","./src/utils/tasklogparser.ts"],"version":"5.8.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/__tests__/flowlayers.test.ts","./src/__tests__/logfilter.test.ts","./src/__tests__/tasklogparser.test.ts","./src/api/businessday.ts","./src/api/client.ts","./src/api/dbviewer.ts","./src/api/envconfig.ts","./src/api/etlstatus.ts","./src/api/execution.ts","./src/api/opspanel.ts","./src/api/registry.ts","./src/api/schedules.ts","./src/api/tasks.ts","./src/api/tenantadmins.ts","./src/components/businessdayhint.tsx","./src/components/dwdtableselector.tsx","./src/components/errorboundary.tsx","./src/components/logstream.tsx","./src/components/schedulehistorydrawer.tsx","./src/components/scheduletab.tsx","./src/components/tasklogviewer.tsx","./src/components/taskselector.tsx","./src/pages/dbviewer.tsx","./src/pages/etlstatus.tsx","./src/pages/envconfig.tsx","./src/pages/logviewer.tsx","./src/pages/login.tsx","./src/pages/opspanel.tsx","./src/pages/taskconfig.tsx","./src/pages/taskmanager.tsx","./src/pages/tenantadmins/index.tsx","./src/store/authstore.ts","./src/store/businessdaystore.ts","./src/types/index.ts","./src/utils/tasklogparser.ts"],"version":"5.8.3"}
|
||||
@@ -21,10 +21,28 @@ apps/backend/
|
||||
│ ├── auth/ # 认证模块
|
||||
│ │ ├── dependencies.py # FastAPI 依赖注入(CurrentUser)
|
||||
│ │ └── jwt.py # JWT 签发/验证/密码哈希
|
||||
│ ├── routers/ # 17 个路由模块(详见 API 参考)
|
||||
│ ├── routers/ # 18 个路由模块(详见 API 参考)
|
||||
│ ├── schemas/ # Pydantic 请求/响应模型
|
||||
│ ├── services/ # 业务逻辑层
|
||||
│ ├── middleware/ # 中间件(ResponseWrapper 全局响应包装)
|
||||
│ ├── ai/ # AI 模块(DashScope Application API + 8 个应用)
|
||||
│ │ ├── config.py # AIConfig — 环境变量加载(DASHSCOPE_*)
|
||||
│ │ ├── dashscope_client.py # DashScope Application API 统一封装
|
||||
│ │ ├── dispatcher.py # AIDispatcher — 事件调度 + 调用链编排
|
||||
│ │ ├── circuit_breaker.py # 熔断器(按 app_id 独立)
|
||||
│ │ ├── rate_limiter.py # 限流器(用户/门店维度)
|
||||
│ │ ├── budget_tracker.py # Token 预算追踪(日/月限额)
|
||||
│ │ ├── run_log_service.py # AI 运行日志 CRUD
|
||||
│ │ ├── exceptions.py # 异常层级(DashScopeError 基类)
|
||||
│ │ ├── cache_service.py # AI 缓存读写(biz.ai_cache + status 状态控制)
|
||||
│ │ ├── conversation_service.py # 对话管理(session_id 双轨)
|
||||
│ │ ├── schemas.py # AI 相关 Schema(SSEEvent 等)
|
||||
│ │ ├── apps/ # 8 个 AI 应用(app1_chat ~ app8_consolidation)
|
||||
│ │ ├── prompts/ # Prompt 模板(app2/app8 独立模板)
|
||||
│ │ └── data_fetchers/ # 共享数据获取层(NS2 新增)
|
||||
│ │ ├── member_data.py # 客户消费/会员卡/备注数据
|
||||
│ │ ├── assistant_data.py # 助教信息/服务记录
|
||||
│ │ └── page_context.py # 页面上下文文本化(10 种入口)
|
||||
│ └── ws/ # WebSocket(实时日志)
|
||||
├── tests/ # 后端测试
|
||||
├── pyproject.toml # 依赖声明
|
||||
@@ -71,6 +89,7 @@ ETL 只读连接自动设置 `default_transaction_read_only = on` 和 RLS `app.c
|
||||
|
||||
1. 管理后台认证(`/api/auth/*`):用户名 + 密码 → JWT
|
||||
2. 小程序认证(`/api/xcx-auth/*`):微信 code → openid → JWT
|
||||
3. 租户管理后台认证(`/api/tenant/auth/*`):用户名 + 密码 → JWT(`aud=tenant-admin`,与小程序完全隔离)
|
||||
|
||||
JWT 令牌分两种:
|
||||
- 完整令牌:已审批用户,包含 `user_id` + `site_id` + `roles`
|
||||
@@ -128,7 +147,20 @@ JWT 令牌分两种:
|
||||
| `/api/xcx-test` | `xcx_test.py` | MVP 全链路验证 | 无 |
|
||||
| `/api/wx-callback` | `wx_callback.py` | 微信消息推送回调 | 签名验证 |
|
||||
| `/api/retention-clue` | `member_retention_clue.py` | 维客线索 CRUD | JWT |
|
||||
| `/api/tenant/auth` | `tenant_auth.py` | 租户管理员登录/刷新令牌 | 无 |
|
||||
| `/api/tenant` | `tenant_users.py` | 租户用户审核/管理(申请列表/关联建议/审核/用户编辑/绑定) | 租户JWT |
|
||||
| `/api/tenant/excel` | `tenant_excel.py` | 租户 Excel 上传/校验/冲突/确认/记录/模板下载 | 租户JWT |
|
||||
| `/api/tenant` | `tenant_clues.py` | 租户维客线索管理(客户搜索/线索CRUD/隐藏显示) | 租户JWT |
|
||||
| `/api/tenant/site-admins` | `tenant_site_admins.py` | 店铺管理员 CRUD(列表/创建/编辑/删除/重置密码,仅 tenant_admin) | 租户JWT |
|
||||
| `/api/admin` | `admin_tenant_admins.py` | 管理端租户管理员 CRUD(列表/创建/编辑/删除/重置密码) | JWT+管理员 |
|
||||
| `/api/admin` | `admin_registry.py` | 注册体系管理(租户列表/店铺列表/简写ID/店铺同步) | JWT+管理员 |
|
||||
| `/api/admin/ai` | `admin_ai.py` | AI 监控后台(Dashboard/调度状态/调用明细/缓存/预算/批量/告警,13 端点) | JWT+管理员 |
|
||||
| `/api/admin/dev-trace` | `admin_dev_trace.py` | 开发调试全链路日志(日期/请求列表/详情/清理/设置/覆盖率,8 端点) | JWT+管理员 |
|
||||
| `/api/admin/task-engine` | `admin_task_engine.py` | P18 任务引擎运营看板(转移日志分页+历史、待审核任务分页+重新分配+关闭、参数管理 CRUD,9 端点) | JWT+管理员 |
|
||||
| `/api/xcx/chat` | `xcx_chat.py` | 小程序 CHAT 对话/消息/发送/SSE 流式 | JWT |
|
||||
| `/api/admin/db-health` | `admin_db_health.py` | 数据库健康监控(4 库连接池/大小/慢查询) | JWT |
|
||||
| `/api/admin/triggers` | `admin_triggers.py` | 触发器统一视图(biz/ai/etl 三源聚合) | JWT |
|
||||
| `/api/trigger-jobs` | `trigger_jobs.py` | 触发器任务管理(列表/详情/PATCH 配置编辑) | JWT |
|
||||
| `/api/ops` | `ops_panel.py` | 运维面板(服务启停/Git/系统信息) | 无 |
|
||||
| `/ws/logs` | `ws/logs.py` | WebSocket 实时日志推送 | — |
|
||||
| `/health` | `main.py` | 健康检查 | 无 |
|
||||
@@ -148,17 +180,57 @@ JWT 令牌分两种:
|
||||
| `task_queue.py` | 任务队列管理(入队/消费/重排) |
|
||||
| `task_registry.py` | ETL 任务/Flow/DWD 表静态注册表 |
|
||||
| `cli_builder.py` | ETL CLI 命令构建器 |
|
||||
| `task_generator.py` | 任务生成器(基于 WBI/NCI 指数) |
|
||||
| `task_generator.py` | 任务生成器(四级漏斗 + 保底 relationship_building,独立连接) |
|
||||
| `task_manager.py` | 任务管理(置顶/放弃/状态变更) |
|
||||
| `task_expiry.py` | 任务过期检查与处理 |
|
||||
| `task_manager.py` | 任务管理(CRUD + 列表扩展 + 详情) |
|
||||
| `performance_service.py` | 绩效概览 + 明细(ETL 直连查询) |
|
||||
| `note_service.py` | 备注服务(CRUD + 星星评分) |
|
||||
| `fdw_queries.py` | ETL 查询集中封装(直连 ETL 库 + 门店隔离 RLS) |
|
||||
| `fdw_queries.py` | ETL 查询集中封装(直连 ETL 库 + 门店隔离 RLS),含区域日粒度查询(`get_finance_overview_area`/`get_finance_revenue_area`)和缓存读写(`get_finance_board_cache`/`set_finance_board_cache`) |
|
||||
| `note_reclassifier.py` | 备注重分类(召回完成后回填) |
|
||||
| `recall_detector.py` | 召回完成检测(ETL 数据更新触发) |
|
||||
| `trigger_scheduler.py` | 触发器调度器(cron/interval/event) |
|
||||
| `chat_service.py` | CHAT 模块业务逻辑(对话管理/消息持久化/referenceCard) |
|
||||
| `ai/admin_service.py` | AI 监控后台聚合服务(Dashboard 统计/批量执行/告警管理) |
|
||||
| `ai/cleanup_service.py` | AI 数据清理服务(90 天保留 + 缓存上限 20000/App) |
|
||||
| `admin_task_engine.py` | P18 任务引擎运营看板路由(转移日志/待审核任务/参数管理,9 端点) |
|
||||
|
||||
## AI 模块(NS2 Prompt 细化)
|
||||
|
||||
8 个千问 AI 应用,通过百炼平台调用 Qwen3.5-Plus 模型。分三层架构:
|
||||
|
||||
```
|
||||
应用层(apps/app1_chat ~ app8_consolidation)
|
||||
↓ 调用
|
||||
数据获取层(data_fetchers/) ← NS2 新增
|
||||
↓ 查询
|
||||
基础设施层(database.py / cache_service.py / dashscope_client.py)
|
||||
```
|
||||
|
||||
### 数据获取层(`app/ai/data_fetchers/`)
|
||||
|
||||
NS2 新增的共享模块,封装 FDW 查询逻辑,供多个应用复用:
|
||||
|
||||
| 函数 | 数据来源 | 消费方 |
|
||||
|------|---------|--------|
|
||||
| `fetch_member_consumption_data()` | ETL FDW 视图(结算/商品/会员卡/到店) | App3/6/7 |
|
||||
| `fetch_member_notes()` | `biz.notes` | App4/6 |
|
||||
| `fetch_assistant_info()` | ETL FDW 视图(助教维度/月度汇总) | App4/5 |
|
||||
| `fetch_service_history()` | ETL FDW 视图(服务日志/亲密度) | App4/5 |
|
||||
| `build_page_text()` | 多数据源(按 contextType 路由) | App1 |
|
||||
|
||||
关键约束:
|
||||
- 金额口径使用 `items_sum`,禁止 `consume_money`
|
||||
- 所有 FDW 查询通过 `SET LOCAL app.current_site_id` 实现 RLS 隔离
|
||||
- 部分数据获取失败不阻断 Prompt 生成(错误降级)
|
||||
|
||||
### 页面上下文(App1)
|
||||
|
||||
App1 通用对话支持 10 种页面入口,通过 `contextType` 路由到对应的文本化函数:
|
||||
|
||||
`task-detail` / `customer-detail` / `coach-detail` / `task-list` / `customer-service-records` / `board-finance` / `board-customer` / `board-coach` / `performance` / `my-profile`
|
||||
|
||||
每种入口自动获取页面数据并格式化为结构化中文文本,注入 system prompt。
|
||||
|
||||
## 依赖
|
||||
|
||||
|
||||
@@ -12,15 +12,19 @@ import json
|
||||
import logging
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.dashscope_client import DashScopeClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.data_fetchers import build_page_text
|
||||
from app.ai.schemas import SSEEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app1_chat"
|
||||
|
||||
# system prompt 总字符数上限
|
||||
_MAX_SYSTEM_PROMPT_LEN = 4000
|
||||
|
||||
|
||||
async def chat_stream(
|
||||
*,
|
||||
@@ -32,7 +36,7 @@ async def chat_stream(
|
||||
source_page: str | None = None,
|
||||
page_context: dict | None = None,
|
||||
screen_content: str | None = None,
|
||||
bailian: BailianClient,
|
||||
client: DashScopeClient,
|
||||
conv_svc: ConversationService,
|
||||
) -> AsyncGenerator[SSEEvent, None]:
|
||||
"""流式对话入口,返回 SSEEvent 异步生成器。
|
||||
@@ -76,11 +80,12 @@ async def chat_stream(
|
||||
)
|
||||
|
||||
# 3. 构建消息列表(system prompt + user message)
|
||||
messages = _build_messages(
|
||||
messages = await _build_messages(
|
||||
message=message,
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
role=role,
|
||||
site_id=site_id,
|
||||
source_page=source_page,
|
||||
page_context=page_context,
|
||||
screen_content=screen_content,
|
||||
@@ -118,12 +123,13 @@ async def chat_stream(
|
||||
yield SSEEvent(type="error", message=str(e))
|
||||
|
||||
|
||||
def _build_messages(
|
||||
async def _build_messages(
|
||||
*,
|
||||
message: str,
|
||||
user_id: int | str,
|
||||
nickname: str,
|
||||
role: str,
|
||||
site_id: int,
|
||||
source_page: str | None,
|
||||
page_context: dict | None,
|
||||
screen_content: str | None,
|
||||
@@ -132,25 +138,38 @@ def _build_messages(
|
||||
|
||||
首条 system 消息注入页面上下文和用户信息。
|
||||
"""
|
||||
system_content = _build_system_prompt(
|
||||
system_content = await _build_system_prompt(
|
||||
user_id=user_id,
|
||||
nickname=nickname,
|
||||
role=role,
|
||||
site_id=site_id,
|
||||
source_page=source_page,
|
||||
page_context=page_context,
|
||||
screen_content=screen_content,
|
||||
)
|
||||
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
# system prompt 总字符数控制
|
||||
if len(content_str) > _MAX_SYSTEM_PROMPT_LEN:
|
||||
# 截断 page_context 中的 data_text
|
||||
pc = system_content.get("page_context", {})
|
||||
dt = pc.get("data_text", "")
|
||||
if dt and len(dt) > 500:
|
||||
pc["data_text"] = dt[:500] + "…(已截断)"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "system", "content": content_str},
|
||||
{"role": "user", "content": message},
|
||||
]
|
||||
|
||||
|
||||
def _build_system_prompt(
|
||||
async def _build_system_prompt(
|
||||
*,
|
||||
user_id: int | str,
|
||||
nickname: str,
|
||||
role: str,
|
||||
site_id: int,
|
||||
source_page: str | None,
|
||||
page_context: dict | None,
|
||||
screen_content: str | None,
|
||||
@@ -161,7 +180,12 @@ def _build_system_prompt(
|
||||
注入页面上下文供 AI 理解当前场景。
|
||||
"""
|
||||
prompt: dict = {
|
||||
"task": "你是台球门店的 AI 助手,根据用户的问题和当前页面上下文提供帮助。",
|
||||
"task": (
|
||||
"你是台球门店的 AI 助手,根据用户的问题和当前页面上下文提供帮助。"
|
||||
"当 page_context 中包含 memberNickname、contextId 或 data_text 时,"
|
||||
"你必须直接使用这些信息回答问题,不要再向用户索要已有的信息。"
|
||||
"例如用户在客户详情页提问时,直接基于该客户的数据回答,无需要求提供会员 ID。"
|
||||
),
|
||||
"biz_params": {
|
||||
"user_prompt_params": {
|
||||
"User_ID": str(user_id),
|
||||
@@ -172,10 +196,11 @@ def _build_system_prompt(
|
||||
}
|
||||
|
||||
# 注入页面上下文(首条消息)
|
||||
page_ctx = _build_page_context(
|
||||
page_ctx = await _build_page_context(
|
||||
source_page=source_page,
|
||||
page_context=page_context,
|
||||
screen_content=screen_content,
|
||||
site_id=site_id,
|
||||
)
|
||||
if page_ctx:
|
||||
prompt["page_context"] = page_ctx
|
||||
@@ -183,25 +208,52 @@ def _build_system_prompt(
|
||||
return prompt
|
||||
|
||||
|
||||
def _build_page_context(
|
||||
async def _build_page_context(
|
||||
*,
|
||||
source_page: str | None,
|
||||
page_context: dict | None,
|
||||
screen_content: str | None,
|
||||
site_id: int,
|
||||
) -> dict:
|
||||
"""构建页面上下文信息。
|
||||
|
||||
P5-A 阶段:直接透传前端传入的上下文字段。
|
||||
P5-B 阶段:各页面逐步实现文本化工具,丰富 screen_content。
|
||||
根据 source_page(contextType)调用 build_page_text 获取结构化文本,
|
||||
看板类页面从 page_context 提取筛选参数传入 filters。
|
||||
contextType 为空或未识别时返回空 dict(跳过注入)。
|
||||
"""
|
||||
# TODO: P5-B 各页面文本化工具细化
|
||||
ctx: dict = {}
|
||||
|
||||
if source_page:
|
||||
ctx["source_page"] = source_page
|
||||
|
||||
# 从 page_context 提取 contextId 和筛选参数
|
||||
context_id = None
|
||||
filters: dict = {}
|
||||
if page_context:
|
||||
context_id = page_context.get("contextId")
|
||||
# 看板类页面筛选参数透传
|
||||
for key in ("timeDimension", "areaFilter", "dimension", "typeFilter", "projectFilter"):
|
||||
if key in page_context:
|
||||
filters[key] = page_context[key]
|
||||
|
||||
# 调用 data_fetcher 获取页面数据文本
|
||||
try:
|
||||
data_text = await build_page_text(
|
||||
source_page=source_page,
|
||||
context_id=context_id,
|
||||
site_id=site_id,
|
||||
filters=filters if filters else None,
|
||||
)
|
||||
if data_text:
|
||||
ctx["data_text"] = data_text
|
||||
except Exception:
|
||||
logger.warning("页面上下文文本化失败: source_page=%s", source_page, exc_info=True)
|
||||
|
||||
if page_context:
|
||||
ctx["page_context"] = page_context
|
||||
if screen_content:
|
||||
ctx["screen_content"] = screen_content
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import logging
|
||||
import os
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.dashscope_client import DashScopeClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.prompts.app2_finance_prompt import build_prompt
|
||||
@@ -124,7 +124,7 @@ def compute_time_range(dimension: str, business_date: date) -> tuple[date, date]
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
bailian: BailianClient,
|
||||
client: DashScopeClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
|
||||
@@ -15,25 +15,42 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.dashscope_client import DashScopeClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.data_fetchers import fetch_member_consumption_data
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app3_clue"
|
||||
|
||||
# system message content 上限
|
||||
_MAX_SYSTEM_CONTENT_LEN = 8000
|
||||
|
||||
def build_prompt(
|
||||
|
||||
def _default_member_data() -> dict:
|
||||
"""数据获取失败时的默认空值。"""
|
||||
return {
|
||||
"member_nickname": "",
|
||||
"consumption_records": [],
|
||||
"member_cards": [],
|
||||
"card_balance_total": 0,
|
||||
"stored_value_balance_total": 0,
|
||||
"expected_visit_date": None,
|
||||
"days_since_last_visit": None,
|
||||
}
|
||||
|
||||
|
||||
async def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> list[dict]:
|
||||
"""构建 Prompt 消息列表。
|
||||
|
||||
P5-A 阶段:返回占位 Prompt,标注待细化字段。
|
||||
P5-B 阶段(P9-T1):补充 consumption_records 等完整数据。
|
||||
从 data_fetchers 获取真实消费数据,失败时降级为空值。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, member_id, nickname 等
|
||||
@@ -45,9 +62,28 @@ def build_prompt(
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
|
||||
# 获取消费数据(失败时降级)
|
||||
data_fetch_failed = False
|
||||
try:
|
||||
member_data = await fetch_member_consumption_data(site_id, member_id)
|
||||
except Exception:
|
||||
logger.warning("App3 消费数据获取失败,使用默认空值: site_id=%s member_id=%s", site_id, member_id, exc_info=True)
|
||||
member_data = _default_member_data()
|
||||
data_fetch_failed = True
|
||||
|
||||
# 构建 reference:App6 线索 + 最近 2 套 App8 历史(附 generated_at)
|
||||
reference = _build_reference(site_id, member_id, cache_svc)
|
||||
|
||||
member_nickname = member_data.get("member_nickname", "")
|
||||
consumption_records = member_data.get("consumption_records", [])
|
||||
|
||||
# 空数据标注
|
||||
if not consumption_records:
|
||||
if data_fetch_failed:
|
||||
consumption_records = "⚠ 消费数据获取失败,该客户暂无消费记录可供分析"
|
||||
else:
|
||||
consumption_records = "该客户暂无消费记录"
|
||||
|
||||
system_content = {
|
||||
"task": "分析客户消费数据,提取维客线索。",
|
||||
"app_id": APP_ID,
|
||||
@@ -67,14 +103,28 @@ def build_prompt(
|
||||
}
|
||||
]
|
||||
},
|
||||
# TODO: P9-T1 细化 - consumption_records 等客户消费数据
|
||||
"data": {
|
||||
"consumption_records": "待 P9-T1 补充",
|
||||
"member_info": "待 P9-T1 补充",
|
||||
"current_time": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
"member_nickname": member_nickname,
|
||||
"main_data": {
|
||||
"consumption_records": consumption_records,
|
||||
"member_cards": member_data.get("member_cards", []),
|
||||
"card_balance_total": member_data.get("card_balance_total", 0),
|
||||
"stored_value_balance_total": member_data.get("stored_value_balance_total", 0),
|
||||
"expected_visit_date": member_data.get("expected_visit_date"),
|
||||
"days_since_last_visit": member_data.get("days_since_last_visit"),
|
||||
},
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
# Token 预算控制:截断 consumption_records
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
records = system_content["main_data"].get("consumption_records")
|
||||
if isinstance(records, list) and len(records) > 5:
|
||||
system_content["main_data"]["consumption_records"] = records[:5]
|
||||
system_content["main_data"]["_truncated"] = f"消费记录已截断,原始共 {len(records)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
|
||||
user_content = (
|
||||
f"请分析会员 {member_id} 的消费数据,提取维客线索。"
|
||||
"每条线索包含 category、summary、detail、emoji 四个字段。"
|
||||
@@ -82,7 +132,7 @@ def build_prompt(
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "system", "content": content_str},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
@@ -134,7 +184,7 @@ def _build_reference(
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
bailian: BailianClient,
|
||||
client: DashScopeClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
@@ -162,7 +212,7 @@ async def run(
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = build_prompt(context, cache_svc)
|
||||
messages = await build_prompt(context, cache_svc)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
|
||||
@@ -11,27 +11,50 @@ app_id = "app4_analysis"
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.dashscope_client import DashScopeClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.data_fetchers import (
|
||||
fetch_assistant_info,
|
||||
fetch_member_consumption_data,
|
||||
fetch_member_notes,
|
||||
fetch_service_history,
|
||||
)
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app4_analysis"
|
||||
|
||||
# system message content 上限
|
||||
_MAX_SYSTEM_CONTENT_LEN = 8000
|
||||
|
||||
def build_prompt(
|
||||
|
||||
def _default_member_data() -> dict:
|
||||
"""数据获取失败时的默认空值。"""
|
||||
return {
|
||||
"member_nickname": "",
|
||||
"consumption_records": [],
|
||||
"member_cards": [],
|
||||
"card_balance_total": 0,
|
||||
"stored_value_balance_total": 0,
|
||||
"expected_visit_date": None,
|
||||
"days_since_last_visit": None,
|
||||
}
|
||||
|
||||
|
||||
async def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> list[dict]:
|
||||
"""构建 Prompt 消息列表。
|
||||
|
||||
P5-A 阶段:返回占位 Prompt,标注待细化字段。
|
||||
P5-B 阶段(P6-T4):补充 service_history、assistant_info 等完整数据。
|
||||
并发获取助教信息、服务历史、客户消费数据、备注,部分失败不阻断。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, assistant_id, member_id
|
||||
@@ -44,10 +67,50 @@ def build_prompt(
|
||||
assistant_id = context["assistant_id"]
|
||||
member_id = context["member_id"]
|
||||
|
||||
# 并发获取 4 类数据,部分失败不阻断
|
||||
results = await asyncio.gather(
|
||||
fetch_assistant_info(site_id, assistant_id),
|
||||
fetch_service_history(site_id, assistant_id, member_id),
|
||||
fetch_member_consumption_data(site_id, member_id),
|
||||
fetch_member_notes(site_id, member_id),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
# 降级处理
|
||||
fetch_errors: list[str] = []
|
||||
|
||||
if isinstance(results[0], Exception):
|
||||
logger.warning("App4 助教信息获取失败: %s", results[0])
|
||||
assistant_info = {}
|
||||
fetch_errors.append("助教信息获取失败")
|
||||
else:
|
||||
assistant_info = results[0]
|
||||
|
||||
if isinstance(results[1], Exception):
|
||||
logger.warning("App4 服务历史获取失败: %s", results[1])
|
||||
service_history: list = []
|
||||
fetch_errors.append("服务历史获取失败")
|
||||
else:
|
||||
service_history = results[1]
|
||||
|
||||
if isinstance(results[2], Exception):
|
||||
logger.warning("App4 消费数据获取失败: %s", results[2])
|
||||
member_data = _default_member_data()
|
||||
fetch_errors.append("消费数据获取失败")
|
||||
else:
|
||||
member_data = results[2]
|
||||
|
||||
if isinstance(results[3], Exception):
|
||||
logger.warning("App4 备注获取失败: %s", results[3])
|
||||
notes: list = []
|
||||
fetch_errors.append("备注获取失败")
|
||||
else:
|
||||
notes = results[3]
|
||||
|
||||
# 构建 reference:App8 最新 + 最近 2 套历史
|
||||
reference = _build_reference(site_id, member_id, cache_svc)
|
||||
|
||||
system_content = {
|
||||
system_content: dict = {
|
||||
"task": "分析助教与客户的关系,生成任务建议。",
|
||||
"app_id": APP_ID,
|
||||
"output_format": {
|
||||
@@ -55,14 +118,51 @@ def build_prompt(
|
||||
"action_suggestions": ["建议1", "建议2"],
|
||||
"one_line_summary": "一句话总结",
|
||||
},
|
||||
# TODO: P6-T4 细化 - service_history、assistant_info
|
||||
"data": {
|
||||
"service_history": "待 P6-T4 补充",
|
||||
"assistant_info": "待 P6-T4 补充",
|
||||
"current_time": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
"assistant_info": assistant_info if assistant_info else "⚠ 助教信息获取失败",
|
||||
"service_history": service_history if service_history else "暂无服务记录",
|
||||
"task_assignment_basis": {
|
||||
"consumption_records": member_data.get("consumption_records", []) or "该客户暂无消费记录",
|
||||
"member_cards": member_data.get("member_cards", []),
|
||||
"card_balance_total": member_data.get("card_balance_total", 0),
|
||||
"stored_value_balance_total": member_data.get("stored_value_balance_total", 0),
|
||||
"expected_visit_date": member_data.get("expected_visit_date"),
|
||||
"days_since_last_visit": member_data.get("days_since_last_visit"),
|
||||
},
|
||||
"customer_data": {
|
||||
"system_data": {
|
||||
"member_nickname": member_data.get("member_nickname", ""),
|
||||
},
|
||||
"notes": notes if notes else "暂无备注",
|
||||
},
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
if fetch_errors:
|
||||
system_content["_data_warnings"] = fetch_errors
|
||||
|
||||
# Token 预算控制
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
# 优先截断 service_history
|
||||
sh = system_content.get("service_history")
|
||||
if isinstance(sh, list) and len(sh) > 5:
|
||||
system_content["service_history"] = sh[:5]
|
||||
system_content["_truncated_service_history"] = f"服务记录已截断,原始共 {len(sh)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
records = system_content["task_assignment_basis"].get("consumption_records")
|
||||
if isinstance(records, list) and len(records) > 5:
|
||||
system_content["task_assignment_basis"]["consumption_records"] = records[:5]
|
||||
system_content["task_assignment_basis"]["_truncated"] = f"消费记录已截断,原始共 {len(records)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
n = system_content["customer_data"].get("notes")
|
||||
if isinstance(n, list) and len(n) > 10:
|
||||
system_content["customer_data"]["notes"] = n[:10]
|
||||
system_content["customer_data"]["_truncated_notes"] = f"备注已截断,原始共 {len(n)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
|
||||
# 缓存不存在时在 user prompt 中标注
|
||||
no_history_hint = ""
|
||||
if not reference:
|
||||
@@ -75,7 +175,7 @@ def build_prompt(
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "system", "content": content_str},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
@@ -127,7 +227,7 @@ def _build_reference(
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
bailian: BailianClient,
|
||||
client: DashScopeClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
@@ -149,7 +249,7 @@ async def run(
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = build_prompt(context, cache_svc)
|
||||
messages = await build_prompt(context, cache_svc)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
|
||||
@@ -10,27 +10,51 @@ app_id = "app5_tactics"
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.dashscope_client import DashScopeClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.data_fetchers import (
|
||||
fetch_assistant_info,
|
||||
fetch_member_consumption_data,
|
||||
fetch_member_notes,
|
||||
fetch_service_history,
|
||||
)
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app5_tactics"
|
||||
|
||||
# system message content 上限
|
||||
_MAX_SYSTEM_CONTENT_LEN = 8000
|
||||
|
||||
def build_prompt(
|
||||
|
||||
def _default_member_data() -> dict:
|
||||
"""数据获取失败时的默认空值。"""
|
||||
return {
|
||||
"member_nickname": "",
|
||||
"consumption_records": [],
|
||||
"member_cards": [],
|
||||
"card_balance_total": 0,
|
||||
"stored_value_balance_total": 0,
|
||||
"expected_visit_date": None,
|
||||
"days_since_last_visit": None,
|
||||
}
|
||||
|
||||
|
||||
async def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> list[dict]:
|
||||
"""构建 Prompt 消息列表。
|
||||
|
||||
P5-A 阶段:返回占位 Prompt,标注待细化字段。
|
||||
P5-B 阶段(P6-T4):补充 service_history、assistant_info(随 App4 同步)。
|
||||
复用 App4 的数据获取逻辑(并发获取助教信息、服务历史、消费数据、备注),
|
||||
额外从 context["app4_result"] 获取 task_suggestion。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, assistant_id, member_id, app4_result(dict)
|
||||
@@ -42,35 +66,117 @@ def build_prompt(
|
||||
site_id = context["site_id"]
|
||||
assistant_id = context["assistant_id"]
|
||||
member_id = context["member_id"]
|
||||
app4_result = context.get("app4_result", {})
|
||||
# App4 结果作为 task_suggestion,缺失时设为空对象
|
||||
task_suggestion = context.get("app4_result") or {}
|
||||
|
||||
# 并发获取 4 类数据,部分失败不阻断
|
||||
results = await asyncio.gather(
|
||||
fetch_assistant_info(site_id, assistant_id),
|
||||
fetch_service_history(site_id, assistant_id, member_id),
|
||||
fetch_member_consumption_data(site_id, member_id),
|
||||
fetch_member_notes(site_id, member_id),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
# 降级处理
|
||||
fetch_errors: list[str] = []
|
||||
|
||||
if isinstance(results[0], Exception):
|
||||
logger.warning("App5 助教信息获取失败: %s", results[0])
|
||||
assistant_info = {}
|
||||
fetch_errors.append("助教信息获取失败")
|
||||
else:
|
||||
assistant_info = results[0]
|
||||
|
||||
if isinstance(results[1], Exception):
|
||||
logger.warning("App5 服务历史获取失败: %s", results[1])
|
||||
service_history: list = []
|
||||
fetch_errors.append("服务历史获取失败")
|
||||
else:
|
||||
service_history = results[1]
|
||||
|
||||
if isinstance(results[2], Exception):
|
||||
logger.warning("App5 消费数据获取失败: %s", results[2])
|
||||
member_data = _default_member_data()
|
||||
fetch_errors.append("消费数据获取失败")
|
||||
else:
|
||||
member_data = results[2]
|
||||
|
||||
if isinstance(results[3], Exception):
|
||||
logger.warning("App5 备注获取失败: %s", results[3])
|
||||
notes: list = []
|
||||
fetch_errors.append("备注获取失败")
|
||||
else:
|
||||
notes = results[3]
|
||||
|
||||
# 构建 reference:最近 2 套 App8 历史
|
||||
reference = _build_reference(site_id, member_id, cache_svc)
|
||||
|
||||
system_content = {
|
||||
"task": "基于关系分析和任务建议,生成沟通话术参考。",
|
||||
system_content: dict = {
|
||||
"task": (
|
||||
"基于关系分析和任务建议,生成沟通话术参考。"
|
||||
"输出必须严格遵循 output_format 中定义的 JSON 结构,"
|
||||
"每条话术必须包含 scenario(场景描述)和 script(话术内容)两个字段,"
|
||||
"禁止使用 content 或其他字段名替代。"
|
||||
),
|
||||
"app_id": APP_ID,
|
||||
"task_suggestion": app4_result,
|
||||
"task_suggestion": task_suggestion,
|
||||
"output_format": {
|
||||
"tactics": [
|
||||
{"scenario": "场景描述", "script": "话术内容"}
|
||||
]
|
||||
},
|
||||
# TODO: P6-T4 细化 - service_history、assistant_info(随 App4 同步)
|
||||
"data": {
|
||||
"service_history": "待 P6-T4 补充",
|
||||
"assistant_info": "待 P6-T4 补充",
|
||||
"current_time": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
"assistant_info": assistant_info if assistant_info else "⚠ 助教信息获取失败",
|
||||
"service_history": service_history if service_history else "暂无服务记录",
|
||||
"task_assignment_basis": {
|
||||
"consumption_records": member_data.get("consumption_records", []) or "该客户暂无消费记录",
|
||||
"member_cards": member_data.get("member_cards", []),
|
||||
"card_balance_total": member_data.get("card_balance_total", 0),
|
||||
"stored_value_balance_total": member_data.get("stored_value_balance_total", 0),
|
||||
"expected_visit_date": member_data.get("expected_visit_date"),
|
||||
"days_since_last_visit": member_data.get("days_since_last_visit"),
|
||||
},
|
||||
"customer_data": {
|
||||
"system_data": {
|
||||
"member_nickname": member_data.get("member_nickname", ""),
|
||||
},
|
||||
"notes": notes if notes else "暂无备注",
|
||||
},
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
if fetch_errors:
|
||||
system_content["_data_warnings"] = fetch_errors
|
||||
|
||||
# Token 预算控制
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
sh = system_content.get("service_history")
|
||||
if isinstance(sh, list) and len(sh) > 5:
|
||||
system_content["service_history"] = sh[:5]
|
||||
system_content["_truncated_service_history"] = f"服务记录已截断,原始共 {len(sh)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
records = system_content["task_assignment_basis"].get("consumption_records")
|
||||
if isinstance(records, list) and len(records) > 5:
|
||||
system_content["task_assignment_basis"]["consumption_records"] = records[:5]
|
||||
system_content["task_assignment_basis"]["_truncated"] = f"消费记录已截断,原始共 {len(records)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
n = system_content["customer_data"].get("notes")
|
||||
if isinstance(n, list) and len(n) > 10:
|
||||
system_content["customer_data"]["notes"] = n[:10]
|
||||
system_content["customer_data"]["_truncated_notes"] = f"备注已截断,原始共 {len(n)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
|
||||
user_content = (
|
||||
f"请为助教 {assistant_id} 生成与会员 {member_id} 沟通的话术参考。"
|
||||
"返回 tactics 数组,每条包含 scenario 和 script 字段。"
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "system", "content": content_str},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
@@ -109,7 +215,7 @@ def _build_reference(
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
bailian: BailianClient,
|
||||
client: DashScopeClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
@@ -131,7 +237,7 @@ async def run(
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = build_prompt(context, cache_svc)
|
||||
messages = await build_prompt(context, cache_svc)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
|
||||
@@ -13,27 +13,45 @@ app_id = "app6_note"
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.dashscope_client import DashScopeClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.data_fetchers import fetch_member_consumption_data, fetch_member_notes
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app6_note"
|
||||
|
||||
# system message content 上限
|
||||
_MAX_SYSTEM_CONTENT_LEN = 8000
|
||||
|
||||
def build_prompt(
|
||||
|
||||
def _default_member_data() -> dict:
|
||||
"""数据获取失败时的默认空值。"""
|
||||
return {
|
||||
"member_nickname": "",
|
||||
"consumption_records": [],
|
||||
"member_cards": [],
|
||||
"card_balance_total": 0,
|
||||
"stored_value_balance_total": 0,
|
||||
"expected_visit_date": None,
|
||||
"days_since_last_visit": None,
|
||||
}
|
||||
|
||||
|
||||
async def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> list[dict]:
|
||||
"""构建 Prompt 消息列表。
|
||||
|
||||
P5-A 阶段:返回占位 Prompt,标注待细化字段。
|
||||
P5-B 阶段(P9-T1):补充 consumption_data 等完整数据。
|
||||
并发获取消费数据和备注,失败时降级为空值。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, member_id, note_content, noted_by_name
|
||||
@@ -46,11 +64,47 @@ def build_prompt(
|
||||
member_id = context["member_id"]
|
||||
note_content = context.get("note_content", "")
|
||||
noted_by_name = context.get("noted_by_name", "")
|
||||
noted_by_created_at = context.get("noted_by_created_at", "")
|
||||
|
||||
# 并发获取消费数据和备注
|
||||
results = await asyncio.gather(
|
||||
fetch_member_consumption_data(site_id, member_id),
|
||||
fetch_member_notes(site_id, member_id),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
fetch_errors: list[str] = []
|
||||
|
||||
if isinstance(results[0], Exception):
|
||||
logger.warning("App6 消费数据获取失败: %s", results[0])
|
||||
member_data = _default_member_data()
|
||||
fetch_errors.append("消费数据获取失败")
|
||||
else:
|
||||
member_data = results[0]
|
||||
|
||||
if isinstance(results[1], Exception):
|
||||
logger.warning("App6 备注获取失败: %s", results[1])
|
||||
all_notes: list = []
|
||||
fetch_errors.append("备注获取失败")
|
||||
else:
|
||||
all_notes = results[1]
|
||||
|
||||
# 构建 reference:App3 线索 + 最近 2 套 App8 历史
|
||||
reference = _build_reference(site_id, member_id, cache_svc)
|
||||
|
||||
system_content = {
|
||||
# 将消费数据和备注注入 reference
|
||||
reference["member_nickname"] = member_data.get("member_nickname", "")
|
||||
reference["consumption_data"] = {
|
||||
"consumption_records": member_data.get("consumption_records", []) or "该客户暂无消费记录",
|
||||
"member_cards": member_data.get("member_cards", []),
|
||||
"card_balance_total": member_data.get("card_balance_total", 0),
|
||||
"stored_value_balance_total": member_data.get("stored_value_balance_total", 0),
|
||||
"expected_visit_date": member_data.get("expected_visit_date"),
|
||||
"days_since_last_visit": member_data.get("days_since_last_visit"),
|
||||
}
|
||||
reference["all_notes"] = all_notes if all_notes else []
|
||||
|
||||
system_content: dict = {
|
||||
"task": "分析备注内容,提取维客线索并评分。",
|
||||
"app_id": APP_ID,
|
||||
"rules": {
|
||||
@@ -73,15 +127,33 @@ def build_prompt(
|
||||
}
|
||||
],
|
||||
},
|
||||
"note_content": note_content,
|
||||
"noted_by_name": noted_by_name,
|
||||
# TODO: P9-T1 细化 - consumption_data 等客户消费数据
|
||||
"data": {
|
||||
"consumption_data": "待 P9-T1 补充",
|
||||
"current_time": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
"current_note": {
|
||||
"content": note_content,
|
||||
"recorded_by": noted_by_name,
|
||||
"created_at": noted_by_created_at,
|
||||
},
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
if fetch_errors:
|
||||
system_content["_data_warnings"] = fetch_errors
|
||||
|
||||
# Token 预算控制
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
records = system_content["reference"].get("consumption_data", {}).get("consumption_records")
|
||||
if isinstance(records, list) and len(records) > 5:
|
||||
system_content["reference"]["consumption_data"]["consumption_records"] = records[:5]
|
||||
system_content["reference"]["consumption_data"]["_truncated"] = f"消费记录已截断,原始共 {len(records)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
n = system_content["reference"].get("all_notes")
|
||||
if isinstance(n, list) and len(n) > 10:
|
||||
system_content["reference"]["all_notes"] = n[:10]
|
||||
system_content["reference"]["_truncated_notes"] = f"备注已截断,原始共 {len(n)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
|
||||
user_content = (
|
||||
f"请分析以下备注内容,提取维客线索并评分。\n"
|
||||
f"备注提供人:{noted_by_name}\n"
|
||||
@@ -91,7 +163,7 @@ def build_prompt(
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "system", "content": content_str},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
@@ -143,7 +215,7 @@ def _build_reference(
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
bailian: BailianClient,
|
||||
client: DashScopeClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
@@ -164,7 +236,7 @@ async def run(
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = build_prompt(context, cache_svc)
|
||||
messages = await build_prompt(context, cache_svc)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
|
||||
@@ -13,27 +13,45 @@ app_id = "app7_customer"
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.dashscope_client import DashScopeClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.data_fetchers import fetch_member_consumption_data, fetch_member_notes
|
||||
from app.ai.schemas import CacheTypeEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ID = "app7_customer"
|
||||
|
||||
# system message content 上限
|
||||
_MAX_SYSTEM_CONTENT_LEN = 8000
|
||||
|
||||
def build_prompt(
|
||||
|
||||
def _default_member_data() -> dict:
|
||||
"""数据获取失败时的默认空值。"""
|
||||
return {
|
||||
"member_nickname": "",
|
||||
"consumption_records": [],
|
||||
"member_cards": [],
|
||||
"card_balance_total": 0,
|
||||
"stored_value_balance_total": 0,
|
||||
"expected_visit_date": None,
|
||||
"days_since_last_visit": None,
|
||||
}
|
||||
|
||||
|
||||
async def build_prompt(
|
||||
context: dict,
|
||||
cache_svc: AICacheService | None = None,
|
||||
) -> list[dict]:
|
||||
"""构建 Prompt 消息列表。
|
||||
|
||||
P5-A 阶段:返回占位 Prompt,标注待细化字段。
|
||||
P5-B 阶段(P9-T1):补充 objective_data 等完整数据。
|
||||
并发获取消费数据和备注,备注标注来源信息。
|
||||
|
||||
Args:
|
||||
context: 包含 site_id, member_id
|
||||
@@ -45,10 +63,46 @@ def build_prompt(
|
||||
site_id = context["site_id"]
|
||||
member_id = context["member_id"]
|
||||
|
||||
# 并发获取消费数据和备注
|
||||
results = await asyncio.gather(
|
||||
fetch_member_consumption_data(site_id, member_id),
|
||||
fetch_member_notes(site_id, member_id),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
fetch_errors: list[str] = []
|
||||
|
||||
if isinstance(results[0], Exception):
|
||||
logger.warning("App7 消费数据获取失败: %s", results[0])
|
||||
member_data = _default_member_data()
|
||||
fetch_errors.append("消费数据获取失败")
|
||||
else:
|
||||
member_data = results[0]
|
||||
|
||||
if isinstance(results[1], Exception):
|
||||
logger.warning("App7 备注获取失败: %s", results[1])
|
||||
notes_raw: list = []
|
||||
fetch_errors.append("备注获取失败")
|
||||
else:
|
||||
notes_raw = results[1]
|
||||
|
||||
# 备注标注来源信息
|
||||
if notes_raw:
|
||||
subjective_notes = []
|
||||
for note in notes_raw:
|
||||
recorded_by = note.get("recorded_by", "未知")
|
||||
annotated = dict(note)
|
||||
annotated["content"] = f"{note.get('content', '')}【来源:{recorded_by},请甄别信息真实性】"
|
||||
subjective_notes.append(annotated)
|
||||
else:
|
||||
subjective_notes = "该客户暂无主观备注信息"
|
||||
|
||||
member_nickname = member_data.get("member_nickname", "")
|
||||
|
||||
# 构建 reference:最新 + 最近 2 套 App8 历史
|
||||
reference = _build_reference(site_id, member_id, cache_svc)
|
||||
|
||||
system_content = {
|
||||
system_content: dict = {
|
||||
"task": "综合分析客户数据,生成运营策略建议。",
|
||||
"app_id": APP_ID,
|
||||
"rules": {
|
||||
@@ -62,13 +116,41 @@ def build_prompt(
|
||||
],
|
||||
"summary": "一句话总结",
|
||||
},
|
||||
# TODO: P9-T1 细化 - objective_data 等客户消费数据
|
||||
"data": {
|
||||
"objective_data": "待 P9-T1 补充",
|
||||
"current_time": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
"member_id": member_id,
|
||||
"member_nickname": member_nickname,
|
||||
"objective_data": {
|
||||
"consumption_records": member_data.get("consumption_records", []) or "该客户暂无消费记录",
|
||||
"member_cards": member_data.get("member_cards", []),
|
||||
"card_balance_total": member_data.get("card_balance_total", 0),
|
||||
"stored_value_balance_total": member_data.get("stored_value_balance_total", 0),
|
||||
"expected_visit_date": member_data.get("expected_visit_date"),
|
||||
"days_since_last_visit": member_data.get("days_since_last_visit"),
|
||||
},
|
||||
"subjective_data": {
|
||||
"notes": subjective_notes,
|
||||
},
|
||||
"reference": reference,
|
||||
}
|
||||
|
||||
if fetch_errors:
|
||||
system_content["_data_warnings"] = fetch_errors
|
||||
|
||||
# Token 预算控制
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
records = system_content["objective_data"].get("consumption_records")
|
||||
if isinstance(records, list) and len(records) > 5:
|
||||
system_content["objective_data"]["consumption_records"] = records[:5]
|
||||
system_content["objective_data"]["_truncated"] = f"消费记录已截断,原始共 {len(records)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
|
||||
n = system_content["subjective_data"].get("notes")
|
||||
if isinstance(n, list) and len(n) > 10:
|
||||
system_content["subjective_data"]["notes"] = n[:10]
|
||||
system_content["subjective_data"]["_truncated_notes"] = f"备注已截断,原始共 {len(n)} 条"
|
||||
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
|
||||
|
||||
user_content = (
|
||||
f"请综合分析会员 {member_id} 的客户数据,生成运营策略建议。"
|
||||
"返回 strategies 数组(每条含 title 和 content)和 summary 字段。"
|
||||
@@ -76,7 +158,7 @@ def build_prompt(
|
||||
)
|
||||
|
||||
return [
|
||||
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)},
|
||||
{"role": "system", "content": content_str},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
@@ -128,7 +210,7 @@ def _build_reference(
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
bailian: BailianClient,
|
||||
client: DashScopeClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
@@ -149,7 +231,7 @@ async def run(
|
||||
nickname = context.get("nickname", "")
|
||||
|
||||
# 1. 构建 Prompt
|
||||
messages = build_prompt(context, cache_svc)
|
||||
messages = await build_prompt(context, cache_svc)
|
||||
|
||||
# 2. 创建对话记录
|
||||
conversation_id = conv_svc.create_conversation(
|
||||
|
||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.dashscope_client import DashScopeClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.prompts.app8_consolidation_prompt import build_prompt
|
||||
@@ -120,7 +120,7 @@ def _determine_source(providers: str) -> str:
|
||||
|
||||
async def run(
|
||||
context: dict,
|
||||
bailian: BailianClient,
|
||||
client: DashScopeClient,
|
||||
cache_svc: AICacheService,
|
||||
conv_svc: ConversationService,
|
||||
) -> dict:
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
"""百炼 API 统一封装层。
|
||||
|
||||
使用 openai Python SDK(百炼兼容 OpenAI 协议),提供流式和非流式两种调用模式。
|
||||
所有 AI 应用通过此客户端统一调用阿里云通义千问。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
import openai
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 异常类 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class BailianApiError(Exception):
|
||||
"""百炼 API 调用失败(重试耗尽后)。"""
|
||||
|
||||
def __init__(self, message: str, status_code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class BailianJsonParseError(Exception):
|
||||
"""百炼 API 返回的 JSON 解析失败。"""
|
||||
|
||||
def __init__(self, message: str, raw_content: str = ""):
|
||||
super().__init__(message)
|
||||
self.raw_content = raw_content
|
||||
|
||||
|
||||
class BailianAuthError(BailianApiError):
|
||||
"""百炼 API Key 无效(HTTP 401)。"""
|
||||
|
||||
def __init__(self, message: str = "API Key 无效或已过期"):
|
||||
super().__init__(message, status_code=401)
|
||||
|
||||
|
||||
# ── 客户端 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class BailianClient:
|
||||
"""百炼 API 统一封装层。
|
||||
|
||||
使用 openai.AsyncOpenAI 客户端,base_url 指向百炼端点。
|
||||
提供流式(chat_stream)和非流式(chat_json)两种调用模式。
|
||||
"""
|
||||
|
||||
# 重试配置
|
||||
MAX_RETRIES = 3
|
||||
BASE_INTERVAL = 1 # 秒
|
||||
|
||||
def __init__(self, api_key: str, base_url: str, model: str):
|
||||
"""初始化百炼客户端。
|
||||
|
||||
Args:
|
||||
api_key: 百炼 API Key(环境变量 BAILIAN_API_KEY)
|
||||
base_url: 百炼 API 端点(环境变量 BAILIAN_BASE_URL)
|
||||
model: 模型标识,如 qwen-plus(环境变量 BAILIAN_MODEL)
|
||||
"""
|
||||
self.model = model
|
||||
self._client = openai.AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
messages: list[dict],
|
||||
*,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2000,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""流式调用,逐 chunk yield 文本。用于应用 1 SSE。
|
||||
|
||||
Args:
|
||||
messages: 消息列表
|
||||
temperature: 温度参数,默认 0.7
|
||||
max_tokens: 最大 token 数,默认 2000
|
||||
|
||||
Yields:
|
||||
文本 chunk
|
||||
"""
|
||||
messages = self._inject_current_time(messages)
|
||||
response = await self._call_with_retry(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
stream=True,
|
||||
)
|
||||
async for chunk in response:
|
||||
if chunk.choices and chunk.choices[0].delta.content:
|
||||
yield chunk.choices[0].delta.content
|
||||
|
||||
async def chat_json(
|
||||
self,
|
||||
messages: list[dict],
|
||||
*,
|
||||
temperature: float = 0.3,
|
||||
max_tokens: int = 4000,
|
||||
) -> tuple[dict, int]:
|
||||
"""非流式调用,返回解析后的 JSON dict 和 tokens_used。
|
||||
|
||||
用于应用 2-8 的结构化输出。使用 response_format={"type": "json_object"}
|
||||
确保返回合法 JSON。
|
||||
|
||||
Args:
|
||||
messages: 消息列表
|
||||
temperature: 温度参数,默认 0.3(结构化输出用低温度)
|
||||
max_tokens: 最大 token 数,默认 4000
|
||||
|
||||
Returns:
|
||||
(parsed_json_dict, tokens_used) 元组
|
||||
|
||||
Raises:
|
||||
BailianJsonParseError: 响应内容无法解析为 JSON
|
||||
BailianApiError: API 调用失败(重试耗尽后)
|
||||
"""
|
||||
messages = self._inject_current_time(messages)
|
||||
response = await self._call_with_retry(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
stream=False,
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
raw_content = response.choices[0].message.content or ""
|
||||
tokens_used = response.usage.total_tokens if response.usage else 0
|
||||
|
||||
try:
|
||||
parsed = json.loads(raw_content)
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
logger.error("百炼 API 返回非法 JSON: %s", raw_content[:500])
|
||||
raise BailianJsonParseError(
|
||||
f"JSON 解析失败: {e}",
|
||||
raw_content=raw_content,
|
||||
) from e
|
||||
|
||||
return parsed, tokens_used
|
||||
|
||||
def _inject_current_time(self, messages: list[dict]) -> list[dict]:
|
||||
"""纯函数:在首条消息的 content(JSON 字符串)中注入 current_time 字段。
|
||||
|
||||
- 深拷贝输入,不修改原始 messages
|
||||
- 首条消息 content 尝试解析为 JSON,注入 current_time
|
||||
- 如果首条消息 content 不是 JSON,则包装为 JSON
|
||||
- 其余消息不变
|
||||
- current_time 格式:ISO 8601 精确到秒,如 2026-03-08T14:30:00
|
||||
|
||||
Args:
|
||||
messages: 原始消息列表
|
||||
|
||||
Returns:
|
||||
注入 current_time 后的新消息列表
|
||||
"""
|
||||
if not messages:
|
||||
return []
|
||||
|
||||
result = copy.deepcopy(messages)
|
||||
first = result[0]
|
||||
content = first.get("content", "")
|
||||
now_str = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
if isinstance(parsed, dict):
|
||||
parsed["current_time"] = now_str
|
||||
else:
|
||||
# content 是合法 JSON 但不是 dict(如数组、字符串),包装为 dict
|
||||
parsed = {"original_content": parsed, "current_time": now_str}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# content 不是 JSON,包装为 dict
|
||||
parsed = {"content": content, "current_time": now_str}
|
||||
|
||||
first["content"] = json.dumps(parsed, ensure_ascii=False)
|
||||
return result
|
||||
|
||||
async def _call_with_retry(self, **kwargs: Any) -> Any:
|
||||
"""带指数退避的重试封装。
|
||||
|
||||
重试策略:
|
||||
- 最多重试 MAX_RETRIES 次(默认 3 次)
|
||||
- 间隔:BASE_INTERVAL × 2^(n-1),即 1s → 2s → 4s
|
||||
- HTTP 4xx:不重试,直接抛出(401 → BailianAuthError)
|
||||
- HTTP 5xx / 超时:重试
|
||||
|
||||
Args:
|
||||
**kwargs: 传递给 openai client 的参数
|
||||
|
||||
Returns:
|
||||
API 响应对象
|
||||
|
||||
Raises:
|
||||
BailianAuthError: API Key 无效(HTTP 401)
|
||||
BailianApiError: API 调用失败(重试耗尽后)
|
||||
"""
|
||||
is_stream = kwargs.get("stream", False)
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(self.MAX_RETRIES):
|
||||
try:
|
||||
if is_stream:
|
||||
# 流式调用:返回 async iterator
|
||||
return await self._client.chat.completions.create(**kwargs)
|
||||
else:
|
||||
return await self._client.chat.completions.create(**kwargs)
|
||||
|
||||
except openai.AuthenticationError as e:
|
||||
# 401:API Key 无效,不重试
|
||||
logger.error("百炼 API 认证失败: %s", e)
|
||||
raise BailianAuthError(str(e)) from e
|
||||
|
||||
except openai.BadRequestError as e:
|
||||
# 400:请求参数错误,不重试
|
||||
logger.error("百炼 API 请求参数错误: %s", e)
|
||||
raise BailianApiError(str(e), status_code=400) from e
|
||||
|
||||
except openai.RateLimitError as e:
|
||||
# 429:限流,不重试(属于 4xx)
|
||||
logger.error("百炼 API 限流: %s", e)
|
||||
raise BailianApiError(str(e), status_code=429) from e
|
||||
|
||||
except openai.PermissionDeniedError as e:
|
||||
# 403:权限不足,不重试
|
||||
logger.error("百炼 API 权限不足: %s", e)
|
||||
raise BailianApiError(str(e), status_code=403) from e
|
||||
|
||||
except openai.NotFoundError as e:
|
||||
# 404:资源不存在,不重试
|
||||
logger.error("百炼 API 资源不存在: %s", e)
|
||||
raise BailianApiError(str(e), status_code=404) from e
|
||||
|
||||
except openai.UnprocessableEntityError as e:
|
||||
# 422:不可处理,不重试
|
||||
logger.error("百炼 API 不可处理的请求: %s", e)
|
||||
raise BailianApiError(str(e), status_code=422) from e
|
||||
|
||||
except (openai.InternalServerError, openai.APIConnectionError, openai.APITimeoutError) as e:
|
||||
# 5xx / 超时 / 连接错误:重试
|
||||
last_error = e
|
||||
if attempt < self.MAX_RETRIES - 1:
|
||||
wait_time = self.BASE_INTERVAL * (2 ** attempt)
|
||||
logger.warning(
|
||||
"百炼 API 调用失败(第 %d/%d 次),%ds 后重试: %s",
|
||||
attempt + 1,
|
||||
self.MAX_RETRIES,
|
||||
wait_time,
|
||||
e,
|
||||
)
|
||||
await asyncio.sleep(wait_time)
|
||||
else:
|
||||
logger.error(
|
||||
"百炼 API 调用失败,已达最大重试次数 %d: %s",
|
||||
self.MAX_RETRIES,
|
||||
e,
|
||||
)
|
||||
|
||||
# 重试耗尽
|
||||
status_code = getattr(last_error, "status_code", None)
|
||||
raise BailianApiError(
|
||||
f"百炼 API 调用失败(重试 {self.MAX_RETRIES} 次后): {last_error}",
|
||||
status_code=status_code,
|
||||
) from last_error
|
||||
101
apps/backend/app/ai/budget_tracker.py
Normal file
101
apps/backend/app/ai/budget_tracker.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Token 预算追踪器 — 从 ai_run_logs 聚合日/月 token 消耗。
|
||||
|
||||
每次 AI 调用前检查预算,超限时拒绝请求。
|
||||
日预算默认 100,000 tokens,月预算默认 2,000,000 tokens。
|
||||
|
||||
聚合数据通过构造函数注入的 callable 获取(解耦 AIRunLogService),
|
||||
callable 签名:() -> int,分别返回当日/当月已消耗 token 数。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Protocol
|
||||
|
||||
|
||||
class UsageProvider(Protocol):
|
||||
"""Token 用量数据提供者协议。"""
|
||||
|
||||
def get_daily_usage(self) -> int:
|
||||
"""返回当日已消耗 token 数。"""
|
||||
...
|
||||
|
||||
def get_monthly_usage(self) -> int:
|
||||
"""返回当月已消耗 token 数。"""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class BudgetStatus:
|
||||
"""预算检查结果。"""
|
||||
|
||||
allowed: bool
|
||||
daily_used: int
|
||||
monthly_used: int
|
||||
reason: str | None = None # "daily_exceeded" / "monthly_exceeded" / None
|
||||
|
||||
|
||||
class BudgetTracker:
|
||||
"""Token 预算追踪器,从 ai_run_logs 聚合。
|
||||
|
||||
支持两种注入方式:
|
||||
1. 传入 UsageProvider 实例(如 AIRunLogService)
|
||||
2. 传入两个 callable:get_daily_usage / get_monthly_usage
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
daily_limit: int = 100_000,
|
||||
monthly_limit: int = 2_000_000,
|
||||
*,
|
||||
get_daily_usage: Callable[[], int] | None = None,
|
||||
get_monthly_usage: Callable[[], int] | None = None,
|
||||
usage_provider: UsageProvider | None = None,
|
||||
) -> None:
|
||||
self.daily_limit = daily_limit
|
||||
self.monthly_limit = monthly_limit
|
||||
|
||||
# 优先使用 usage_provider,其次使用独立 callable
|
||||
if usage_provider is not None:
|
||||
self._get_daily_usage = usage_provider.get_daily_usage
|
||||
self._get_monthly_usage = usage_provider.get_monthly_usage
|
||||
elif get_daily_usage is not None and get_monthly_usage is not None:
|
||||
self._get_daily_usage = get_daily_usage
|
||||
self._get_monthly_usage = get_monthly_usage
|
||||
else:
|
||||
raise ValueError(
|
||||
"必须提供 usage_provider 或同时提供 "
|
||||
"get_daily_usage 和 get_monthly_usage callable"
|
||||
)
|
||||
|
||||
def check_budget(self) -> BudgetStatus:
|
||||
"""检查当前预算状态。
|
||||
|
||||
先检查日预算,再检查月预算。
|
||||
任一超限即返回 allowed=False 并附带原因。
|
||||
"""
|
||||
daily_used = self._get_daily_usage()
|
||||
monthly_used = self._get_monthly_usage()
|
||||
|
||||
if daily_used >= self.daily_limit:
|
||||
return BudgetStatus(
|
||||
allowed=False,
|
||||
daily_used=daily_used,
|
||||
monthly_used=monthly_used,
|
||||
reason="daily_exceeded",
|
||||
)
|
||||
|
||||
if monthly_used >= self.monthly_limit:
|
||||
return BudgetStatus(
|
||||
allowed=False,
|
||||
daily_used=daily_used,
|
||||
monthly_used=monthly_used,
|
||||
reason="monthly_exceeded",
|
||||
)
|
||||
|
||||
return BudgetStatus(
|
||||
allowed=True,
|
||||
daily_used=daily_used,
|
||||
monthly_used=monthly_used,
|
||||
reason=None,
|
||||
)
|
||||
@@ -3,18 +3,38 @@ AI 缓存读写服务。
|
||||
|
||||
负责 biz.ai_cache 表的 CRUD 和保留策略管理。
|
||||
所有查询和写入操作强制 site_id 隔离。
|
||||
|
||||
P14 改造:
|
||||
- 新增 status 字段处理(valid/expired/invalidated/generating)
|
||||
- 查询仅返回 status='valid' 且未过期的记录
|
||||
- 按 App 类型设置过期时间
|
||||
- 每 App 保留最新 20,000 条
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.database import get_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 缓存过期策略:cache_type → 过期天数(0 表示当日 23:59:59)
|
||||
CACHE_EXPIRY_DAYS: dict[str, int] = {
|
||||
"app2_finance": 0, # 当日 23:59:59
|
||||
"app3_clue": 7,
|
||||
"app4_analysis": 7,
|
||||
"app5_tactics": 7,
|
||||
"app6_note_analysis": 30,
|
||||
"app7_customer_analysis": 7,
|
||||
"app8_clue_consolidated": 7,
|
||||
}
|
||||
|
||||
# 每 App 保留上限
|
||||
CACHE_MAX_PER_APP = 20_000
|
||||
|
||||
|
||||
class AICacheService:
|
||||
"""AI 缓存读写服务。"""
|
||||
@@ -25,9 +45,9 @@ class AICacheService:
|
||||
site_id: int,
|
||||
target_id: str,
|
||||
) -> dict | None:
|
||||
"""查询最新缓存记录。
|
||||
"""查询最新有效缓存记录。
|
||||
|
||||
按 (cache_type, site_id, target_id) 查询 created_at 最新的一条。
|
||||
仅返回 status='valid' 且未过期的记录。
|
||||
无记录时返回 None。
|
||||
"""
|
||||
conn = get_connection()
|
||||
@@ -37,9 +57,11 @@ class AICacheService:
|
||||
"""
|
||||
SELECT id, cache_type, site_id, target_id,
|
||||
result_json, score, triggered_by,
|
||||
created_at, expires_at
|
||||
created_at, expires_at, status
|
||||
FROM biz.ai_cache
|
||||
WHERE cache_type = %s AND site_id = %s AND target_id = %s
|
||||
AND (status = 'valid' OR status IS NULL)
|
||||
AND (expires_at IS NULL OR expires_at > now())
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
@@ -95,7 +117,15 @@ class AICacheService:
|
||||
score: int | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
) -> int:
|
||||
"""写入缓存记录,返回 id。写入后清理超限记录。"""
|
||||
"""写入缓存记录,返回 id。
|
||||
|
||||
自动设置 status='valid' 和按 App 类型计算 expires_at。
|
||||
写入后清理超限记录(每 App 保留 20,000 条)。
|
||||
"""
|
||||
# 自动计算过期时间(如果未显式指定)
|
||||
if expires_at is None:
|
||||
expires_at = self._calc_expires_at(cache_type)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
@@ -103,8 +133,8 @@ class AICacheService:
|
||||
"""
|
||||
INSERT INTO biz.ai_cache
|
||||
(cache_type, site_id, target_id, result_json,
|
||||
triggered_by, score, expires_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
triggered_by, score, expires_at, status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, 'valid')
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
@@ -126,7 +156,7 @@ class AICacheService:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 写入成功后清理超限记录(失败仅记录警告,不影响写入结果)
|
||||
# 写入成功后清理超限记录
|
||||
try:
|
||||
deleted = self._cleanup_excess(cache_type, site_id, target_id)
|
||||
if deleted > 0:
|
||||
@@ -143,12 +173,89 @@ class AICacheService:
|
||||
|
||||
return cache_id
|
||||
|
||||
def set_generating(
|
||||
self,
|
||||
cache_type: str,
|
||||
site_id: int,
|
||||
target_id: str,
|
||||
triggered_by: str | None = None,
|
||||
) -> int:
|
||||
"""写入 generating 状态占位记录,返回 id。完成后调用 finalize_cache 更新。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.ai_cache
|
||||
(cache_type, site_id, target_id, result_json, status, triggered_by)
|
||||
VALUES (%s, %s, %s, '{}', 'generating', %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(cache_type, site_id, target_id, triggered_by),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
return row[0]
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def finalize_cache(
|
||||
self,
|
||||
cache_id: int,
|
||||
result_json: dict,
|
||||
score: int | None = None,
|
||||
cache_type: str | None = None,
|
||||
) -> None:
|
||||
"""将 generating 记录更新为 valid,填充结果和过期时间。"""
|
||||
expires_at = self._calc_expires_at(cache_type) if cache_type else None
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.ai_cache
|
||||
SET result_json = %s, score = %s, status = 'valid', expires_at = %s
|
||||
WHERE id = %s AND status = 'generating'
|
||||
""",
|
||||
(
|
||||
json.dumps(result_json, ensure_ascii=False),
|
||||
score,
|
||||
expires_at,
|
||||
cache_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def _calc_expires_at(cache_type: str | None) -> datetime | None:
|
||||
"""根据 cache_type 计算过期时间。未知类型返回 None。"""
|
||||
if cache_type is None:
|
||||
return None
|
||||
days = CACHE_EXPIRY_DAYS.get(cache_type)
|
||||
if days is None:
|
||||
return None
|
||||
now = datetime.now(timezone.utc)
|
||||
if days == 0:
|
||||
# 当日 23:59:59(UTC+8)
|
||||
local_now = now + timedelta(hours=8)
|
||||
end_of_day = local_now.replace(hour=23, minute=59, second=59, microsecond=0)
|
||||
return end_of_day - timedelta(hours=8) # 转回 UTC
|
||||
return now + timedelta(days=days)
|
||||
|
||||
def _cleanup_excess(
|
||||
self,
|
||||
cache_type: str,
|
||||
site_id: int,
|
||||
target_id: str,
|
||||
max_count: int = 500,
|
||||
max_count: int = CACHE_MAX_PER_APP,
|
||||
) -> int:
|
||||
"""清理超限记录,保留最近 max_count 条,返回删除数量。"""
|
||||
conn = get_connection()
|
||||
|
||||
116
apps/backend/app/ai/circuit_breaker.py
Normal file
116
apps/backend/app/ai/circuit_breaker.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""熔断器 — 按 app_id 独立的断路保护。
|
||||
|
||||
状态机:CLOSED → OPEN(连续失败达阈值)→ HALF_OPEN(超时后探测)→ CLOSED/OPEN。
|
||||
内存实现,单实例部署,不依赖外部存储。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
class CircuitState(enum.Enum):
|
||||
"""熔断器状态。"""
|
||||
|
||||
CLOSED = "closed" # 正常放行
|
||||
OPEN = "open" # 熔断中,拒绝请求
|
||||
HALF_OPEN = "half_open" # 探测中,放行单个请求
|
||||
|
||||
|
||||
@dataclass
|
||||
class _BreakerState:
|
||||
"""单个 app_id 的熔断内部状态。"""
|
||||
|
||||
state: CircuitState = CircuitState.CLOSED
|
||||
failure_count: int = 0
|
||||
last_failure_time: float = 0.0
|
||||
last_state_change: float = field(default_factory=time.monotonic)
|
||||
|
||||
|
||||
class CircuitBreaker:
|
||||
"""按 app_id 独立的熔断器。
|
||||
|
||||
- check():检查当前状态,OPEN 且超时自动转 HALF_OPEN
|
||||
- record_success():HALF_OPEN→CLOSED;CLOSED 重置失败计数
|
||||
- record_failure():连续达阈值→OPEN;HALF_OPEN 失败→重新 OPEN
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
failure_threshold: int = 5,
|
||||
recovery_timeout: int = 60,
|
||||
) -> None:
|
||||
self._failure_threshold = failure_threshold
|
||||
self._recovery_timeout = recovery_timeout
|
||||
self._breakers: dict[str, _BreakerState] = {}
|
||||
|
||||
def _get_state(self, app_id: str) -> _BreakerState:
|
||||
"""获取或初始化指定 app_id 的状态。"""
|
||||
if app_id not in self._breakers:
|
||||
self._breakers[app_id] = _BreakerState()
|
||||
return self._breakers[app_id]
|
||||
|
||||
def check(self, app_id: str) -> CircuitState:
|
||||
"""检查当前熔断状态。
|
||||
|
||||
- CLOSED / HALF_OPEN:允许通过,返回对应状态
|
||||
- OPEN 且未超时:返回 OPEN(拒绝)
|
||||
- OPEN 且已超时:自动转 HALF_OPEN,返回 HALF_OPEN(允许探测)
|
||||
"""
|
||||
breaker = self._get_state(app_id)
|
||||
|
||||
if breaker.state == CircuitState.CLOSED:
|
||||
return CircuitState.CLOSED
|
||||
|
||||
if breaker.state == CircuitState.HALF_OPEN:
|
||||
return CircuitState.HALF_OPEN
|
||||
|
||||
# OPEN 状态:检查是否超过恢复超时
|
||||
elapsed = time.monotonic() - breaker.last_failure_time
|
||||
if elapsed >= self._recovery_timeout:
|
||||
# 超时,转为 HALF_OPEN 探测
|
||||
breaker.state = CircuitState.HALF_OPEN
|
||||
breaker.last_state_change = time.monotonic()
|
||||
return CircuitState.HALF_OPEN
|
||||
|
||||
return CircuitState.OPEN
|
||||
|
||||
def record_success(self, app_id: str) -> None:
|
||||
"""记录调用成功。
|
||||
|
||||
- HALF_OPEN→CLOSED(探测成功,恢复正常)
|
||||
- CLOSED 下重置失败计数
|
||||
"""
|
||||
breaker = self._get_state(app_id)
|
||||
|
||||
if breaker.state == CircuitState.HALF_OPEN:
|
||||
breaker.state = CircuitState.CLOSED
|
||||
breaker.failure_count = 0
|
||||
breaker.last_state_change = time.monotonic()
|
||||
elif breaker.state == CircuitState.CLOSED:
|
||||
# CLOSED 状态下成功重置失败计数
|
||||
breaker.failure_count = 0
|
||||
|
||||
def record_failure(self, app_id: str) -> None:
|
||||
"""记录调用失败。
|
||||
|
||||
- CLOSED:累加失败计数,达阈值→OPEN
|
||||
- HALF_OPEN:探测失败→重新 OPEN
|
||||
"""
|
||||
breaker = self._get_state(app_id)
|
||||
now = time.monotonic()
|
||||
|
||||
if breaker.state == CircuitState.HALF_OPEN:
|
||||
# 探测失败,重新熔断
|
||||
breaker.state = CircuitState.OPEN
|
||||
breaker.failure_count = self._failure_threshold
|
||||
breaker.last_failure_time = now
|
||||
breaker.last_state_change = now
|
||||
elif breaker.state == CircuitState.CLOSED:
|
||||
breaker.failure_count += 1
|
||||
breaker.last_failure_time = now
|
||||
if breaker.failure_count >= self._failure_threshold:
|
||||
breaker.state = CircuitState.OPEN
|
||||
breaker.last_state_change = now
|
||||
68
apps/backend/app/ai/config.py
Normal file
68
apps/backend/app/ai/config.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""AI 模块配置 — 从环境变量加载 DashScope 相关参数。
|
||||
|
||||
所有 DASHSCOPE_* 环境变量和 INTERNAL_API_TOKEN 统一在此管理,
|
||||
启动时通过 from_env() 校验必需变量,缺失立即报错。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AIConfig:
|
||||
"""AI 模块配置,从环境变量加载。不可变(frozen)。"""
|
||||
|
||||
api_key: str # DASHSCOPE_API_KEY
|
||||
workspace_id: str | None # DASHSCOPE_WORKSPACE_ID(可选)
|
||||
app_id_1_chat: str # DASHSCOPE_APP_ID_1_CHAT
|
||||
app_id_2_finance: str # DASHSCOPE_APP_ID_2_FINANCE
|
||||
app_id_3_clue: str # DASHSCOPE_APP_ID_3_CLUE
|
||||
app_id_4_analysis: str # DASHSCOPE_APP_ID_4_ANALYSIS
|
||||
app_id_5_tactics: str # DASHSCOPE_APP_ID_5_TACTICS
|
||||
app_id_6_note: str # DASHSCOPE_APP_ID_6_NOTE
|
||||
app_id_7_customer: str # DASHSCOPE_APP_ID_7_CUSTOMER
|
||||
app_id_8_consolidate: str # DASHSCOPE_APP_ID_8_CONSOLIDATE
|
||||
internal_api_token: str # INTERNAL_API_TOKEN
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> AIConfig:
|
||||
"""从环境变量加载配置。
|
||||
|
||||
必需变量缺失时立即抛出 ValueError,禁止静默回退空字符串。
|
||||
可选变量(DASHSCOPE_WORKSPACE_ID)缺失时为 None。
|
||||
"""
|
||||
required_mapping: dict[str, str] = {
|
||||
"DASHSCOPE_API_KEY": "api_key",
|
||||
"DASHSCOPE_APP_ID_1_CHAT": "app_id_1_chat",
|
||||
"DASHSCOPE_APP_ID_2_FINANCE": "app_id_2_finance",
|
||||
"DASHSCOPE_APP_ID_3_CLUE": "app_id_3_clue",
|
||||
"DASHSCOPE_APP_ID_4_ANALYSIS": "app_id_4_analysis",
|
||||
"DASHSCOPE_APP_ID_5_TACTICS": "app_id_5_tactics",
|
||||
"DASHSCOPE_APP_ID_6_NOTE": "app_id_6_note",
|
||||
"DASHSCOPE_APP_ID_7_CUSTOMER": "app_id_7_customer",
|
||||
"DASHSCOPE_APP_ID_8_CONSOLIDATE": "app_id_8_consolidate",
|
||||
"INTERNAL_API_TOKEN": "internal_api_token",
|
||||
}
|
||||
|
||||
# 收集所有缺失的必需变量,一次性报错
|
||||
missing: list[str] = []
|
||||
values: dict[str, str] = {}
|
||||
|
||||
for env_name, field_name in required_mapping.items():
|
||||
val = os.environ.get(env_name)
|
||||
if not val: # None 或空字符串均视为缺失
|
||||
missing.append(env_name)
|
||||
else:
|
||||
values[field_name] = val
|
||||
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"AI 配置缺失必需环境变量: {', '.join(missing)}"
|
||||
)
|
||||
|
||||
# 可选变量
|
||||
workspace_id = os.environ.get("DASHSCOPE_WORKSPACE_ID") or None
|
||||
|
||||
return cls(workspace_id=workspace_id, **values)
|
||||
@@ -27,6 +27,7 @@ class ConversationService:
|
||||
site_id: int,
|
||||
source_page: str | None = None,
|
||||
source_context: dict | None = None,
|
||||
title: str | None = None,
|
||||
) -> int:
|
||||
"""创建对话记录,返回 conversation_id。
|
||||
|
||||
@@ -38,8 +39,8 @@ class ConversationService:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.ai_conversations
|
||||
(user_id, nickname, app_id, site_id, source_page, source_context)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
(user_id, nickname, app_id, site_id, source_page, source_context, title)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
@@ -49,6 +50,7 @@ class ConversationService:
|
||||
site_id,
|
||||
source_page,
|
||||
json.dumps(source_context, ensure_ascii=False) if source_context else None,
|
||||
title,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
@@ -89,6 +91,22 @@ class ConversationService:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def update_title(self, conversation_id: int, title: str) -> None:
|
||||
"""更新对话标题。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE biz.ai_conversations SET title = %s WHERE id = %s",
|
||||
(title, conversation_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_conversations(
|
||||
self,
|
||||
user_id: int | str,
|
||||
@@ -104,7 +122,7 @@ class ConversationService:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, user_id, nickname, app_id, site_id,
|
||||
source_page, source_context, created_at
|
||||
source_page, source_context, title, created_at
|
||||
FROM biz.ai_conversations
|
||||
WHERE user_id = %s AND site_id = %s
|
||||
ORDER BY created_at DESC
|
||||
|
||||
318
apps/backend/app/ai/dashscope_client.py
Normal file
318
apps/backend/app/ai/dashscope_client.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""DashScope Application API 统一封装层。
|
||||
|
||||
使用 dashscope.Application.call() 调用百炼智能体应用,
|
||||
替代原 openai SDK 的通用模型 API。
|
||||
|
||||
- call_app_stream(): App1 流式调用,asyncio.Queue 桥接 async generator
|
||||
- call_app(): App2~8 单轮调用,asyncio.to_thread() 包装
|
||||
- _call_with_retry(): 指数退避重试(1s→2s→4s)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, AsyncGenerator, Callable
|
||||
|
||||
import dashscope
|
||||
from dashscope import Application
|
||||
|
||||
from app.ai.exceptions import (
|
||||
DashScopeApiError,
|
||||
DashScopeAuthError,
|
||||
DashScopeJsonParseError,
|
||||
DashScopeTimeoutError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DashScopeClient:
|
||||
"""DashScope Application API 统一封装层。
|
||||
|
||||
通过 app_id 调用百炼控制台配置的智能体应用,
|
||||
充分利用云端 System Prompt 和 MCP 工具。
|
||||
"""
|
||||
|
||||
MAX_RETRIES = 3
|
||||
BASE_INTERVAL = 1 # 秒
|
||||
|
||||
def __init__(self, api_key: str, workspace_id: str | None = None):
|
||||
"""初始化。dashscope 通过全局变量设置密钥。
|
||||
|
||||
Args:
|
||||
api_key: DashScope API Key
|
||||
workspace_id: 百炼工作空间 ID(可选)
|
||||
"""
|
||||
dashscope.api_key = api_key
|
||||
self._workspace_id = workspace_id
|
||||
|
||||
async def call_app_stream(
|
||||
self,
|
||||
app_id: str,
|
||||
prompt: str,
|
||||
session_id: str | None = None,
|
||||
biz_params: dict | None = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""App1 流式调用。
|
||||
|
||||
在线程中消费同步迭代器,通过 asyncio.Queue 桥接到 async generator。
|
||||
错误通过 queue 传递给调用方。
|
||||
|
||||
Args:
|
||||
app_id: 百炼应用 ID
|
||||
prompt: 用户输入
|
||||
session_id: 百炼 session_id(多轮对话)
|
||||
biz_params: 业务参数(如 user_prompt_params)
|
||||
|
||||
Yields:
|
||||
文本 chunk
|
||||
"""
|
||||
queue: asyncio.Queue[str | BaseException | None] = asyncio.Queue()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _consume_in_thread() -> None:
|
||||
"""在线程中消费同步迭代器,逐 chunk 放入 queue。"""
|
||||
try:
|
||||
call_kwargs: dict[str, Any] = {
|
||||
"app_id": app_id,
|
||||
"prompt": prompt,
|
||||
"stream": True,
|
||||
"incremental_output": True,
|
||||
}
|
||||
if session_id is not None:
|
||||
call_kwargs["session_id"] = session_id
|
||||
if biz_params is not None:
|
||||
call_kwargs["biz_params"] = biz_params
|
||||
if self._workspace_id is not None:
|
||||
call_kwargs["workspace"] = self._workspace_id
|
||||
|
||||
response = Application.call(**call_kwargs)
|
||||
for chunk in response:
|
||||
if chunk.status_code == 200:
|
||||
text = chunk.output.get("text", "")
|
||||
if text:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
queue.put(text), loop
|
||||
)
|
||||
else:
|
||||
# 非 200 状态码,构造异常传递给调用方
|
||||
status = chunk.status_code
|
||||
msg = getattr(chunk, "message", "") or f"状态码 {status}"
|
||||
if status == 401:
|
||||
err = DashScopeAuthError(msg)
|
||||
else:
|
||||
err = DashScopeApiError(msg, status_code=status)
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
queue.put(err), loop
|
||||
)
|
||||
return
|
||||
# 正常结束信号
|
||||
asyncio.run_coroutine_threadsafe(queue.put(None), loop)
|
||||
except Exception as exc:
|
||||
# 线程内未预期异常,传递给调用方
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
queue.put(exc), loop
|
||||
)
|
||||
|
||||
loop.run_in_executor(None, _consume_in_thread)
|
||||
|
||||
while True:
|
||||
item = await queue.get()
|
||||
if item is None:
|
||||
break
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
yield item
|
||||
|
||||
async def call_app(
|
||||
self,
|
||||
app_id: str,
|
||||
prompt: str,
|
||||
session_id: str | None = None,
|
||||
biz_params: dict | None = None,
|
||||
) -> tuple[dict, int, str | None]:
|
||||
"""App2~8 单轮调用。
|
||||
|
||||
通过 asyncio.to_thread() 包装同步 Application.call(),
|
||||
解析 response.output.text 获取 JSON 内容。
|
||||
非合法 JSON 触发重试(最多 3 次),不做本地修复。
|
||||
|
||||
Args:
|
||||
app_id: 百炼应用 ID
|
||||
prompt: 后端拼好的完整数据 JSON 字符串
|
||||
session_id: 百炼 session_id(可选)
|
||||
biz_params: 业务参数(可选)
|
||||
|
||||
Returns:
|
||||
(parsed_json, tokens_used, new_session_id) 元组
|
||||
|
||||
Raises:
|
||||
DashScopeApiError: API 调用失败(重试耗尽)
|
||||
DashScopeJsonParseError: JSON 解析失败(重试耗尽)
|
||||
"""
|
||||
call_kwargs: dict[str, Any] = {
|
||||
"app_id": app_id,
|
||||
"prompt": prompt,
|
||||
}
|
||||
if session_id is not None:
|
||||
call_kwargs["session_id"] = session_id
|
||||
if biz_params is not None:
|
||||
call_kwargs["biz_params"] = biz_params
|
||||
if self._workspace_id is not None:
|
||||
call_kwargs["workspace"] = self._workspace_id
|
||||
|
||||
# 非合法 JSON 纯重试,最多 MAX_RETRIES 次
|
||||
last_json_error: DashScopeJsonParseError | None = None
|
||||
for json_attempt in range(self.MAX_RETRIES):
|
||||
response = await self._call_with_retry(
|
||||
Application.call, **call_kwargs
|
||||
)
|
||||
|
||||
# 提取 output.text
|
||||
raw_text: str = ""
|
||||
if hasattr(response, "output"):
|
||||
output = response.output
|
||||
if isinstance(output, dict):
|
||||
raw_text = output.get("text", "")
|
||||
elif hasattr(output, "text"):
|
||||
raw_text = output.text or ""
|
||||
|
||||
# 提取 tokens_used
|
||||
tokens_used = 0
|
||||
if hasattr(response, "usage") and response.usage:
|
||||
usage = response.usage
|
||||
if isinstance(usage, dict):
|
||||
# input_tokens + output_tokens
|
||||
tokens_used = usage.get("input_tokens", 0) + usage.get(
|
||||
"output_tokens", 0
|
||||
)
|
||||
elif hasattr(usage, "total_tokens"):
|
||||
tokens_used = usage.total_tokens or 0
|
||||
|
||||
# 提取 new_session_id
|
||||
new_session_id: str | None = None
|
||||
if hasattr(response, "output") and isinstance(response.output, dict):
|
||||
new_session_id = response.output.get("session_id")
|
||||
|
||||
# 解析 JSON
|
||||
try:
|
||||
parsed = json.loads(raw_text)
|
||||
if isinstance(parsed, list):
|
||||
# CHANGE 2026-03-23 | Prompt: App2 LLM 返回 list 而非 dict
|
||||
# 百炼 LLM 有时直接返回 insights 数组而非包裹 dict,
|
||||
# 自动包装为 {"insights": list} 避免无意义重试
|
||||
logger.info(
|
||||
"LLM 返回 list(长度 %d),自动包装为 {\"insights\": [...]}",
|
||||
len(parsed),
|
||||
)
|
||||
parsed = {"insights": parsed}
|
||||
if not isinstance(parsed, dict):
|
||||
raise TypeError(f"期望 dict,实际 {type(parsed).__name__}")
|
||||
return parsed, tokens_used, new_session_id
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
last_json_error = DashScopeJsonParseError(
|
||||
f"JSON 解析失败(第 {json_attempt + 1}/{self.MAX_RETRIES} 次): {e}",
|
||||
raw_content=raw_text,
|
||||
)
|
||||
logger.warning(
|
||||
"Application API 返回非法 JSON(第 %d/%d 次): %s",
|
||||
json_attempt + 1,
|
||||
self.MAX_RETRIES,
|
||||
raw_text[:500],
|
||||
)
|
||||
# 非合法 JSON 纯重试,不做本地修复
|
||||
continue
|
||||
|
||||
# JSON 重试耗尽
|
||||
raise last_json_error # type: ignore[misc]
|
||||
|
||||
async def _call_with_retry(self, func: Callable, **kwargs: Any) -> Any:
|
||||
"""指数退避重试封装。
|
||||
|
||||
重试策略:
|
||||
- 最多重试 MAX_RETRIES 次(默认 3 次)
|
||||
- 间隔:BASE_INTERVAL × 2^(n-1),即 1s → 2s → 4s
|
||||
- HTTP 4xx → 不重试,立即抛出(401 → DashScopeAuthError)
|
||||
- HTTP 5xx / 超时 / 连接错误 → 重试
|
||||
|
||||
Args:
|
||||
func: 同步调用函数(如 Application.call)
|
||||
**kwargs: 传递给 func 的参数
|
||||
|
||||
Returns:
|
||||
API 响应对象(status_code == 200)
|
||||
|
||||
Raises:
|
||||
DashScopeAuthError: API Key 无效(HTTP 401)
|
||||
DashScopeTimeoutError: 调用超时(重试耗尽)
|
||||
DashScopeApiError: API 调用失败(重试耗尽)
|
||||
"""
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(self.MAX_RETRIES):
|
||||
try:
|
||||
response = await asyncio.to_thread(func, **kwargs)
|
||||
except Exception as exc:
|
||||
# 网络/连接/超时等底层异常 → 可重试
|
||||
last_error = exc
|
||||
if attempt < self.MAX_RETRIES - 1:
|
||||
wait_time = self.BASE_INTERVAL * (2**attempt)
|
||||
logger.warning(
|
||||
"DashScope API 底层异常(第 %d/%d 次),%ds 后重试: %s",
|
||||
attempt + 1,
|
||||
self.MAX_RETRIES,
|
||||
wait_time,
|
||||
exc,
|
||||
)
|
||||
await asyncio.sleep(wait_time)
|
||||
continue
|
||||
else:
|
||||
logger.error(
|
||||
"DashScope API 底层异常,已达最大重试次数 %d: %s",
|
||||
self.MAX_RETRIES,
|
||||
exc,
|
||||
)
|
||||
raise DashScopeApiError(
|
||||
f"DashScope API 调用失败(重试 {self.MAX_RETRIES} 次后): {exc}",
|
||||
) from exc
|
||||
|
||||
# Application.call() 返回 response 对象,通过 status_code 判断成功/失败
|
||||
status_code = getattr(response, "status_code", None)
|
||||
|
||||
if status_code == 200:
|
||||
return response
|
||||
|
||||
# 非 200:根据状态码分类处理
|
||||
message = getattr(response, "message", "") or f"状态码 {status_code}"
|
||||
|
||||
if status_code is not None and 400 <= status_code < 500:
|
||||
# 4xx:不重试,立即抛出
|
||||
if status_code == 401:
|
||||
raise DashScopeAuthError(message)
|
||||
raise DashScopeApiError(message, status_code=status_code)
|
||||
|
||||
# 5xx 或其他未知状态码 → 可重试
|
||||
last_error = DashScopeApiError(message, status_code=status_code)
|
||||
if attempt < self.MAX_RETRIES - 1:
|
||||
wait_time = self.BASE_INTERVAL * (2**attempt)
|
||||
logger.warning(
|
||||
"DashScope API 调用失败(第 %d/%d 次,状态码 %s),%ds 后重试: %s",
|
||||
attempt + 1,
|
||||
self.MAX_RETRIES,
|
||||
status_code,
|
||||
wait_time,
|
||||
message,
|
||||
)
|
||||
await asyncio.sleep(wait_time)
|
||||
else:
|
||||
logger.error(
|
||||
"DashScope API 调用失败,已达最大重试次数 %d(状态码 %s): %s",
|
||||
self.MAX_RETRIES,
|
||||
status_code,
|
||||
message,
|
||||
)
|
||||
|
||||
# 重试耗尽
|
||||
raise last_error # type: ignore[misc]
|
||||
29
apps/backend/app/ai/data_fetchers/__init__.py
Normal file
29
apps/backend/app/ai/data_fetchers/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""AI 数据获取层。
|
||||
|
||||
为 AI 应用提供共享的数据获取函数,封装 FDW 查询和业务库查询逻辑。
|
||||
所有 FDW 查询通过 get_etl_readonly_connection(site_id) 获取只读连接,
|
||||
自动设置 RLS 门店隔离。
|
||||
|
||||
模块:
|
||||
- member_data: 客户消费数据获取(应用 3/6/7 共用)
|
||||
- assistant_data: 助教数据获取(应用 4/5 共用)
|
||||
- page_context: 页面上下文文本化(应用 1 专用)
|
||||
"""
|
||||
|
||||
from app.ai.data_fetchers.member_data import (
|
||||
fetch_member_consumption_data,
|
||||
fetch_member_notes,
|
||||
)
|
||||
from app.ai.data_fetchers.assistant_data import (
|
||||
fetch_assistant_info,
|
||||
fetch_service_history,
|
||||
)
|
||||
from app.ai.data_fetchers.page_context import build_page_text
|
||||
|
||||
__all__ = [
|
||||
"fetch_member_consumption_data",
|
||||
"fetch_member_notes",
|
||||
"fetch_assistant_info",
|
||||
"fetch_service_history",
|
||||
"build_page_text",
|
||||
]
|
||||
253
apps/backend/app/ai/data_fetchers/assistant_data.py
Normal file
253
apps/backend/app/ai/data_fetchers/assistant_data.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""助教数据获取模块(应用 4/5 共用)。
|
||||
|
||||
从 ETL 库 app.v_* RLS 视图获取助教基本信息和助教-客户服务历史。
|
||||
使用 is_delete 字段排除废单(is_delete=0 为正常),禁止使用已废弃的 dwd_assistant_trash_event 表。
|
||||
"""
|
||||
# CHANGE 2026-03-23 | Prompt: FDW 迁移——fdw_etl.* → app.* 直连 ETL 库
|
||||
# intent: 将所有 fdw_etl.* 外部表引用改为 app.v_* RLS 视图(直连 ETL 库),列名同步修正
|
||||
# 连接方式不变(get_etl_readonly_connection),仅改 SQL 表名和列名
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from app.database import get_etl_readonly_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FDW_QUERY_TIMEOUT_SEC = 5
|
||||
|
||||
|
||||
async def fetch_assistant_info(
|
||||
site_id: int,
|
||||
assistant_id: int,
|
||||
) -> dict[str, Any]:
|
||||
"""获取助教基本信息。
|
||||
|
||||
返回:
|
||||
{
|
||||
"nickname": str,
|
||||
"level": str,
|
||||
"hire_date": str,
|
||||
"tenure_months": int,
|
||||
"monthly_customers": int,
|
||||
"performance_tier": str,
|
||||
}
|
||||
|
||||
Raises:
|
||||
ValueError: 助教不存在
|
||||
TimeoutError: FDW 查询超时
|
||||
ConnectionError: FDW 连接失败
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
partial(_fetch_assistant_info_sync, site_id, assistant_id),
|
||||
)
|
||||
|
||||
|
||||
def _fetch_assistant_info_sync(site_id: int, assistant_id: int) -> dict[str, Any]:
|
||||
"""同步实现。"""
|
||||
conn = None
|
||||
try:
|
||||
conn = get_etl_readonly_connection(site_id)
|
||||
# RLS 隔离 + 语句超时(get_etl_readonly_connection 的 SET LOCAL 在 commit 后失效,
|
||||
# 需在查询事务中重新设置)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||||
)
|
||||
cur.execute(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
)
|
||||
|
||||
# 基本信息
|
||||
# ⚠️ v_dim_assistant 列名: hire_date→entry_time
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT nickname, level, entry_time AS hire_date
|
||||
FROM app.v_dim_assistant
|
||||
WHERE assistant_id = %s AND scd2_is_current = 1
|
||||
LIMIT 1
|
||||
""",
|
||||
(assistant_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"assistant not found: assistant_id={assistant_id}")
|
||||
|
||||
nickname = row[0] or ""
|
||||
level = row[1] or ""
|
||||
hire_date = row[2]
|
||||
|
||||
# 计算工龄
|
||||
tenure_months = 0
|
||||
if hire_date and isinstance(hire_date, date):
|
||||
today = date.today()
|
||||
tenure_months = (today.year - hire_date.year) * 12 + (today.month - hire_date.month)
|
||||
|
||||
# 绩效数据
|
||||
# ⚠️ 列名映射: monthly_customers 不存在(用 0 占位),performance_tier→tier_name
|
||||
# ⚠️ salary_month 是 date 类型(YYYY-MM-01),按月降序取最新
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
0 AS monthly_customers,
|
||||
COALESCE(tier_name, '') AS performance_tier
|
||||
FROM app.v_dws_assistant_salary_calc
|
||||
WHERE assistant_id = %s
|
||||
ORDER BY salary_month DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(assistant_id,),
|
||||
)
|
||||
perf_row = cur.fetchone()
|
||||
monthly_customers = perf_row[0] if perf_row else 0
|
||||
performance_tier = perf_row[1] if perf_row else ""
|
||||
|
||||
conn.commit()
|
||||
return {
|
||||
"nickname": nickname,
|
||||
"level": level,
|
||||
"hire_date": hire_date.isoformat() if isinstance(hire_date, date) else "",
|
||||
"tenure_months": tenure_months,
|
||||
"monthly_customers": monthly_customers,
|
||||
"performance_tier": performance_tier,
|
||||
}
|
||||
|
||||
except (ValueError, TimeoutError, ConnectionError):
|
||||
raise
|
||||
except Exception as e:
|
||||
err_msg = str(e).lower()
|
||||
if "statement timeout" in err_msg or "timeout" in err_msg:
|
||||
raise TimeoutError(
|
||||
f"FDW 查询超时: assistant_id={assistant_id}"
|
||||
) from e
|
||||
if "connection" in err_msg or "connect" in err_msg:
|
||||
raise ConnectionError(
|
||||
f"FDW 连接失败: assistant_id={assistant_id}"
|
||||
) from e
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def fetch_service_history(
|
||||
site_id: int,
|
||||
assistant_id: int,
|
||||
member_id: int,
|
||||
months: int = 3,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""获取助教服务该客户的历史记录。
|
||||
|
||||
使用 is_delete 排除废单(WHERE is_delete = 0)。
|
||||
|
||||
返回:
|
||||
[
|
||||
{
|
||||
"service_date": str,
|
||||
"duration_minutes": int,
|
||||
"items_sum": float,
|
||||
"room_name": str,
|
||||
"is_pd": bool,
|
||||
},
|
||||
...
|
||||
]
|
||||
|
||||
Raises:
|
||||
TimeoutError: FDW 查询超时
|
||||
ConnectionError: FDW 连接失败
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
partial(_fetch_service_history_sync, site_id, assistant_id, member_id, months),
|
||||
)
|
||||
|
||||
|
||||
def _fetch_service_history_sync(
|
||||
site_id: int,
|
||||
assistant_id: int,
|
||||
member_id: int,
|
||||
months: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""同步实现。"""
|
||||
conn = None
|
||||
try:
|
||||
conn = get_etl_readonly_connection(site_id)
|
||||
# RLS 隔离 + 语句超时(get_etl_readonly_connection 的 SET LOCAL 在 commit 后失效,
|
||||
# 需在查询事务中重新设置)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||||
)
|
||||
cur.execute(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
)
|
||||
|
||||
# ⚠️ 列名映射: assistant_id→site_assistant_id, member_id→tenant_member_id,
|
||||
# is_trash=false→is_delete=0, service_date→create_time,
|
||||
# duration_minutes→real_use_seconds/60, items_sum→ledger_amount,
|
||||
# room_name→site_table_id, is_pd→(order_assistant_type=1)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
create_time AS service_date,
|
||||
COALESCE(real_use_seconds / 60, 0) AS duration_minutes,
|
||||
ledger_amount AS items_sum,
|
||||
site_table_id AS room_name,
|
||||
(order_assistant_type = 1) AS is_pd
|
||||
FROM app.v_dwd_assistant_service_log
|
||||
WHERE site_assistant_id = %s
|
||||
AND tenant_member_id = %s
|
||||
AND is_delete = 0
|
||||
AND create_time >= (CURRENT_DATE - INTERVAL '%s months')
|
||||
ORDER BY create_time DESC
|
||||
""",
|
||||
(assistant_id, member_id, months),
|
||||
)
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
rows = cur.fetchall()
|
||||
|
||||
conn.commit()
|
||||
|
||||
records = []
|
||||
for row in rows:
|
||||
record = {}
|
||||
for col, val in zip(columns, row):
|
||||
if isinstance(val, (date, datetime)):
|
||||
record[col] = val.isoformat()
|
||||
elif isinstance(val, Decimal):
|
||||
record[col] = float(val)
|
||||
elif isinstance(val, bool):
|
||||
record[col] = val
|
||||
else:
|
||||
record[col] = val
|
||||
records.append(record)
|
||||
|
||||
return records
|
||||
|
||||
except (TimeoutError, ConnectionError):
|
||||
raise
|
||||
except Exception as e:
|
||||
err_msg = str(e).lower()
|
||||
if "statement timeout" in err_msg or "timeout" in err_msg:
|
||||
raise TimeoutError(
|
||||
f"FDW 查询超时: assistant_id={assistant_id}, member_id={member_id}"
|
||||
) from e
|
||||
if "connection" in err_msg or "connect" in err_msg:
|
||||
raise ConnectionError(
|
||||
f"FDW 连接失败: assistant_id={assistant_id}, member_id={member_id}"
|
||||
) from e
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
402
apps/backend/app/ai/data_fetchers/member_data.py
Normal file
402
apps/backend/app/ai/data_fetchers/member_data.py
Normal file
@@ -0,0 +1,402 @@
|
||||
"""客户消费数据获取模块(应用 3/6/7 共用)。
|
||||
|
||||
从 ETL 库 app.v_* RLS 视图获取客户近 N 个月消费数据,从业务库获取备注。
|
||||
金额口径统一使用拆分字段(table_charge_money + assistant_pd/cx_money + goods_money),禁止 consume_money。
|
||||
会员信息通过 member_id JOIN v_dim_member (scd2_is_current=1) 获取。
|
||||
"""
|
||||
# CHANGE 2026-03-23 | Prompt: FDW 迁移——fdw_etl.* → app.* 直连 ETL 库
|
||||
# intent: 将所有 fdw_etl.* 外部表引用改为 app.v_* RLS 视图(直连 ETL 库),列名同步修正
|
||||
# 连接方式不变(get_etl_readonly_connection),仅改 SQL 表名和列名
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from app.database import get_connection, get_etl_readonly_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 消费记录最大返回数
|
||||
MAX_CONSUMPTION_RECORDS = 100
|
||||
# 备注最大返回数
|
||||
MAX_NOTES = 50
|
||||
# 备注单条最大字符数
|
||||
MAX_NOTE_LENGTH = 500
|
||||
# FDW 查询超时(秒)
|
||||
FDW_QUERY_TIMEOUT_SEC = 5
|
||||
|
||||
|
||||
async def fetch_member_consumption_data(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
months: int = 3,
|
||||
) -> dict[str, Any]:
|
||||
"""获取客户近 N 个月消费数据。
|
||||
|
||||
返回结构对应 NS2 设计文档中 main_data:
|
||||
- consumption_records: 消费记录列表(最多 100 条,settle_date DESC)
|
||||
- member_cards: 会员卡明细列表
|
||||
- card_balance_total: 储值卡余额合计
|
||||
- stored_value_balance_total: 储值余额合计
|
||||
- expected_visit_date: 预计到店日期
|
||||
- days_since_last_visit: 距上次到店天数
|
||||
- member_nickname: 会员昵称
|
||||
|
||||
Raises:
|
||||
TimeoutError: FDW 查询超时(>5s)
|
||||
ConnectionError: FDW 连接失败
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
partial(_fetch_member_consumption_data_sync, site_id, member_id, months),
|
||||
)
|
||||
|
||||
|
||||
def _fetch_member_consumption_data_sync(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
months: int,
|
||||
) -> dict[str, Any]:
|
||||
"""同步实现:在单个 FDW 连接上串行执行多个查询。"""
|
||||
conn = None
|
||||
try:
|
||||
conn = get_etl_readonly_connection(site_id)
|
||||
# RLS 隔离 + 语句超时(get_etl_readonly_connection 的 SET LOCAL 在 commit 后失效,
|
||||
# 需在查询事务中重新设置)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SET LOCAL app.current_site_id = %s", (str(site_id),)
|
||||
)
|
||||
cur.execute(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",), # 毫秒
|
||||
)
|
||||
|
||||
# 1. 会员昵称
|
||||
nickname = _query_member_nickname(conn, member_id)
|
||||
|
||||
# 2. 消费记录(台桌结账 + 商城订单)
|
||||
records, total_count = _query_consumption_records(conn, member_id, months)
|
||||
|
||||
# 3. 会员卡明细
|
||||
cards = _query_member_cards(conn, member_id)
|
||||
|
||||
# 4. 余额汇总
|
||||
balance_info = _query_balance_summary(conn, member_id)
|
||||
|
||||
# 5. 到店数据
|
||||
visit_info = _query_visit_info(conn, member_id)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"member_nickname": nickname,
|
||||
"consumption_records": records,
|
||||
"member_cards": cards,
|
||||
"card_balance_total": balance_info.get("card_balance_total", Decimal("0")),
|
||||
"stored_value_balance_total": balance_info.get(
|
||||
"stored_value_balance_total", Decimal("0")
|
||||
),
|
||||
"expected_visit_date": visit_info.get("expected_visit_date"),
|
||||
"days_since_last_visit": visit_info.get("days_since_last_visit"),
|
||||
}
|
||||
if total_count > MAX_CONSUMPTION_RECORDS:
|
||||
result["truncated"] = True
|
||||
result["total_count"] = total_count
|
||||
|
||||
conn.commit()
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# psycopg2 超时异常包含 "statement timeout"
|
||||
err_msg = str(e).lower()
|
||||
if "statement timeout" in err_msg or "timeout" in err_msg:
|
||||
raise TimeoutError(
|
||||
f"FDW 查询超时(>{FDW_QUERY_TIMEOUT_SEC}s): member_id={member_id}"
|
||||
) from e
|
||||
if "connection" in err_msg or "connect" in err_msg:
|
||||
raise ConnectionError(
|
||||
f"FDW 连接失败: member_id={member_id}, error={e}"
|
||||
) from e
|
||||
raise
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _query_member_nickname(conn: Any, member_id: int) -> str:
|
||||
"""从 app.v_dim_member 获取会员昵称(scd2_is_current=1)。"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT nickname
|
||||
FROM app.v_dim_member
|
||||
WHERE member_id = %s AND scd2_is_current = 1
|
||||
LIMIT 1
|
||||
""",
|
||||
(member_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return row[0] if row and row[0] else ""
|
||||
|
||||
|
||||
def _query_consumption_records(
|
||||
conn: Any, member_id: int, months: int
|
||||
) -> tuple[list[dict], int]:
|
||||
"""从 app.v_dwd_settlement_head + app.v_dwd_table_fee_log 获取消费记录。
|
||||
|
||||
仅包含正向交易(settle_type IN (1, 3))。
|
||||
⚠️ 费用拆分字段(table_charge_money, assistant_pd/cx_money)在 settlement_head 上。
|
||||
⚠️ table_fee_log 提供台桌时长(real_table_use_seconds)和桌台ID(site_table_id)。
|
||||
⚠️ 列名映射: settle_date→create_time, settle_id→order_settle_id, sale_amount→ledger_amount。
|
||||
返回 (records, total_count)。
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
# 先查总数
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM app.v_dwd_settlement_head sh
|
||||
WHERE sh.member_id = %s
|
||||
AND sh.settle_type IN (1, 3)
|
||||
AND sh.create_time >= (CURRENT_DATE - INTERVAL '%s months')
|
||||
""",
|
||||
(member_id, months),
|
||||
)
|
||||
total_count = cur.fetchone()[0]
|
||||
|
||||
# 查询消费记录(限制 100 条)
|
||||
# table_charge_money/assistant_pd_money/assistant_cx_money 直接从 settlement_head 取
|
||||
# 台桌信息从 table_fee_log 取(site_table_id, real_table_use_seconds)
|
||||
# 商品金额从 store_goods_sale 聚合
|
||||
# 助教姓名从 service_log JOIN dim_assistant 获取
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
sh.create_time AS settle_date,
|
||||
sh.settle_type,
|
||||
sh.table_charge_money + sh.assistant_pd_money + sh.assistant_cx_money
|
||||
+ COALESCE(sg.goods_money, 0) AS items_sum,
|
||||
COALESCE(sh.table_charge_money, 0) AS table_charge_money,
|
||||
COALESCE(sh.assistant_pd_money, 0) AS assistant_pd_money,
|
||||
COALESCE(sh.assistant_cx_money, 0) AS assistant_cx_money,
|
||||
COALESCE(sg.goods_money, 0) AS goods_money,
|
||||
tfl.site_table_id AS room_name,
|
||||
COALESCE(tfl.real_table_use_seconds / 60, 0) AS duration_minutes,
|
||||
coaches.assistant_names
|
||||
FROM app.v_dwd_settlement_head sh
|
||||
LEFT JOIN app.v_dwd_table_fee_log tfl
|
||||
ON sh.order_settle_id = tfl.order_settle_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT SUM(sgs.ledger_amount) AS goods_money
|
||||
FROM app.v_dwd_store_goods_sale sgs
|
||||
WHERE sgs.order_settle_id = sh.order_settle_id
|
||||
) sg ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT string_agg(DISTINCT COALESCE(da.nickname, da.real_name, ''), ', ')
|
||||
AS assistant_names
|
||||
FROM app.v_dwd_assistant_service_log sl
|
||||
LEFT JOIN app.v_dim_assistant da
|
||||
ON sl.site_assistant_id = da.assistant_id
|
||||
AND da.scd2_is_current = 1
|
||||
WHERE sl.order_settle_id = sh.order_settle_id
|
||||
AND sl.is_delete = 0
|
||||
) coaches ON true
|
||||
WHERE sh.member_id = %s
|
||||
AND sh.settle_type IN (1, 3)
|
||||
AND sh.create_time >= (CURRENT_DATE - INTERVAL '%s months')
|
||||
ORDER BY sh.create_time DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(member_id, months, MAX_CONSUMPTION_RECORDS),
|
||||
)
|
||||
columns = [desc[0] for desc in cur.description]
|
||||
rows = cur.fetchall()
|
||||
|
||||
records = []
|
||||
for row in rows:
|
||||
record = {}
|
||||
for col, val in zip(columns, row):
|
||||
if isinstance(val, (date, datetime)):
|
||||
record[col] = val.isoformat()
|
||||
elif isinstance(val, Decimal):
|
||||
record[col] = float(val)
|
||||
else:
|
||||
record[col] = val
|
||||
# assistant_names: 确保是列表
|
||||
names = record.get("assistant_names")
|
||||
if names and isinstance(names, str):
|
||||
record["assistant_names"] = [n.strip() for n in names.split(",") if n.strip()]
|
||||
elif not names:
|
||||
record["assistant_names"] = []
|
||||
records.append(record)
|
||||
|
||||
return records, total_count
|
||||
|
||||
|
||||
def _query_member_cards(conn: Any, member_id: int) -> list[dict]:
|
||||
"""从 app.v_dim_member_card_account 获取会员卡明细。
|
||||
⚠️ 列名映射: member_id→tenant_member_id, gift_balance 不存在(用 balance - principal_balance 近似)。
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT member_card_type_name AS card_type,
|
||||
COALESCE(balance, 0) AS balance,
|
||||
COALESCE(balance, 0) - COALESCE(principal_balance, 0) AS gift_balance
|
||||
FROM app.v_dim_member_card_account
|
||||
WHERE tenant_member_id = %s AND scd2_is_current = 1
|
||||
""",
|
||||
(member_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"card_type": row[0] or "",
|
||||
"balance": float(row[1]) if row[1] else 0.0,
|
||||
"gift_balance": float(row[2]) if row[2] else 0.0,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _query_balance_summary(conn: Any, member_id: int) -> dict:
|
||||
"""从 app.v_dws_member_consumption_summary 获取余额汇总。
|
||||
⚠️ 列名映射: recharge_card_amount→cash_card_balance, balance_amount→total_card_balance。
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(cash_card_balance, 0) AS card_balance_total,
|
||||
COALESCE(total_card_balance, 0) AS stored_value_balance_total
|
||||
FROM app.v_dws_member_consumption_summary
|
||||
WHERE member_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(member_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
return {
|
||||
"card_balance_total": Decimal("0"),
|
||||
"stored_value_balance_total": Decimal("0"),
|
||||
}
|
||||
return {
|
||||
"card_balance_total": row[0],
|
||||
"stored_value_balance_total": row[1],
|
||||
}
|
||||
|
||||
|
||||
def _query_visit_info(conn: Any, member_id: int) -> dict:
|
||||
"""从 app.v_dws_member_visit_detail 获取到店数据,推算预计到店日期。
|
||||
⚠️ 列名映射: last_visit_date→MAX(visit_date), avg_visit_interval_days 需从明细计算。
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
# 获取最近到店日期和平均到店间隔
|
||||
cur.execute(
|
||||
"""
|
||||
WITH visits AS (
|
||||
SELECT visit_date,
|
||||
LAG(visit_date) OVER (ORDER BY visit_date) AS prev_visit
|
||||
FROM app.v_dws_member_visit_detail
|
||||
WHERE member_id = %s
|
||||
)
|
||||
SELECT
|
||||
MAX(visit_date) AS last_visit_date,
|
||||
AVG(visit_date - prev_visit) AS avg_visit_interval_days
|
||||
FROM visits
|
||||
WHERE prev_visit IS NOT NULL
|
||||
""",
|
||||
(member_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row or not row[0]:
|
||||
return {"expected_visit_date": None, "days_since_last_visit": None}
|
||||
|
||||
last_visit = row[0]
|
||||
avg_interval = row[1]
|
||||
today = date.today()
|
||||
days_since = (today - last_visit).days if isinstance(last_visit, date) else None
|
||||
|
||||
expected = None
|
||||
if avg_interval and last_visit:
|
||||
from datetime import timedelta
|
||||
expected_date = last_visit + timedelta(days=int(avg_interval))
|
||||
expected = expected_date.isoformat()
|
||||
|
||||
return {
|
||||
"expected_visit_date": expected,
|
||||
"days_since_last_visit": days_since,
|
||||
}
|
||||
|
||||
|
||||
async def fetch_member_notes(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
limit: int = MAX_NOTES,
|
||||
) -> list[dict]:
|
||||
"""获取客户的全部备注(按 created_at DESC,最多 limit 条)。
|
||||
|
||||
从业务库 biz.notes 查询。
|
||||
单条备注内容截断 500 字符,超出附加"…(已截断)"。
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
partial(_fetch_member_notes_sync, site_id, member_id, limit),
|
||||
)
|
||||
|
||||
|
||||
def _fetch_member_notes_sync(
|
||||
site_id: int,
|
||||
member_id: int,
|
||||
limit: int,
|
||||
) -> list[dict]:
|
||||
"""同步实现:从业务库查询备注。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
n.content,
|
||||
u.nickname AS recorded_by,
|
||||
n.created_at
|
||||
FROM biz.notes n
|
||||
LEFT JOIN biz.coach_tasks ct ON ct.id = n.task_id
|
||||
LEFT JOIN public.users u ON u.id = n.user_id
|
||||
WHERE n.target_id = %s AND n.site_id = %s
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(member_id, site_id, limit),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
notes = []
|
||||
for row in rows:
|
||||
content = row[0] or ""
|
||||
recorded_by = row[1] or ""
|
||||
created_at = row[2]
|
||||
|
||||
# 截断处理
|
||||
if len(content) > MAX_NOTE_LENGTH:
|
||||
content = content[:MAX_NOTE_LENGTH] + "…(已截断)"
|
||||
|
||||
notes.append({
|
||||
"recorded_by": recorded_by,
|
||||
"content": content,
|
||||
"created_at": created_at.isoformat() if isinstance(created_at, (date, datetime)) else str(created_at) if created_at else "",
|
||||
})
|
||||
|
||||
return notes
|
||||
finally:
|
||||
conn.close()
|
||||
645
apps/backend/app/ai/data_fetchers/page_context.py
Normal file
645
apps/backend/app/ai/data_fetchers/page_context.py
Normal file
@@ -0,0 +1,645 @@
|
||||
"""页面上下文文本化模块(应用 1 专用)。
|
||||
|
||||
根据 contextType 从数据库获取对应页面数据,
|
||||
格式化为结构化中文文本(≤ 2000 字符),供 AI 理解当前场景。
|
||||
不传入 member_phone 等断档敏感字段。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from app.database import get_connection, get_etl_readonly_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_PAGE_CONTEXT_LENGTH = 2000
|
||||
FDW_QUERY_TIMEOUT_SEC = 5
|
||||
|
||||
# 支持的 10 种页面类型
|
||||
SUPPORTED_PAGE_TYPES = {
|
||||
"task-detail",
|
||||
"customer-detail",
|
||||
"coach-detail",
|
||||
"board-finance",
|
||||
"board-customer",
|
||||
"board-coach",
|
||||
"performance",
|
||||
"my-profile",
|
||||
"task-list",
|
||||
"customer-service-records",
|
||||
}
|
||||
|
||||
|
||||
async def build_page_text(
|
||||
source_page: str,
|
||||
context_id: int | str | None,
|
||||
site_id: int,
|
||||
filters: dict | None = None,
|
||||
) -> str:
|
||||
"""将页面数据转换为 AI 可读的结构化中文文本。
|
||||
|
||||
Args:
|
||||
source_page: 页面类型(contextType)
|
||||
context_id: 实体 ID(contextId)
|
||||
site_id: 门店 ID
|
||||
filters: 看板类页面的筛选参数
|
||||
|
||||
Returns:
|
||||
结构化中文文本(≤ 2000 字符),失败时返回降级文本
|
||||
"""
|
||||
if not source_page or source_page not in SUPPORTED_PAGE_TYPES:
|
||||
return ""
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
text = await loop.run_in_executor(
|
||||
None,
|
||||
partial(_build_page_text_sync, source_page, context_id, site_id, filters or {}),
|
||||
)
|
||||
# 截断保护
|
||||
if len(text) > MAX_PAGE_CONTEXT_LENGTH:
|
||||
text = text[:MAX_PAGE_CONTEXT_LENGTH - 20] + "\n…(上下文已截断)"
|
||||
return text
|
||||
except Exception:
|
||||
logger.exception("页面上下文获取失败: source_page=%s", source_page)
|
||||
return "页面上下文获取失败,请直接描述您的问题"
|
||||
|
||||
|
||||
def _build_page_text_sync(
|
||||
source_page: str,
|
||||
context_id: int | str | None,
|
||||
site_id: int,
|
||||
filters: dict,
|
||||
) -> str:
|
||||
"""同步路由到对应页面文本化函数。"""
|
||||
handlers = {
|
||||
"task-detail": _text_task_detail,
|
||||
"customer-detail": _text_customer_detail,
|
||||
"coach-detail": _text_coach_detail,
|
||||
"board-finance": _text_board_finance,
|
||||
"board-customer": _text_board_customer,
|
||||
"board-coach": _text_board_coach,
|
||||
"performance": _text_performance,
|
||||
"my-profile": _text_my_profile,
|
||||
"task-list": _text_task_list,
|
||||
"customer-service-records": _text_customer_service_records,
|
||||
}
|
||||
handler = handlers.get(source_page)
|
||||
if not handler:
|
||||
return ""
|
||||
return handler(context_id, site_id, filters)
|
||||
|
||||
|
||||
# ── 详情类页面 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _text_task_detail(
|
||||
context_id: int | str | None, site_id: int, filters: dict
|
||||
) -> str:
|
||||
"""任务详情页文本化。"""
|
||||
if not context_id:
|
||||
return ""
|
||||
task_id = int(context_id)
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 任务信息
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT ct.task_type, ct.status, ct.deadline,
|
||||
ct.member_id, ct.assistant_id,
|
||||
dm.nickname AS member_nickname,
|
||||
da.nickname AS assistant_nickname
|
||||
FROM biz.coach_tasks ct
|
||||
LEFT JOIN biz.coach_tasks_member_view dm
|
||||
ON dm.member_id = ct.member_id AND dm.site_id = ct.site_id
|
||||
LEFT JOIN biz.coach_tasks_assistant_view da
|
||||
ON da.assistant_id = ct.assistant_id AND da.site_id = ct.site_id
|
||||
WHERE ct.id = %s AND ct.site_id = %s
|
||||
""",
|
||||
(task_id, site_id),
|
||||
)
|
||||
task = cur.fetchone()
|
||||
if not task:
|
||||
return f"任务 {task_id} 不存在"
|
||||
|
||||
task_type, status, deadline, member_id, assistant_id, member_nick, asst_nick = task
|
||||
|
||||
# 最近备注(最多 3 条)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT content, created_at
|
||||
FROM biz.notes
|
||||
WHERE task_id = %s AND site_id = %s
|
||||
ORDER BY created_at DESC LIMIT 3
|
||||
""",
|
||||
(task_id, site_id),
|
||||
)
|
||||
notes = cur.fetchall()
|
||||
|
||||
# AI 缓存(最新分析)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT result_json, created_at
|
||||
FROM biz.ai_cache
|
||||
WHERE cache_type = 'app4_analysis'
|
||||
AND site_id = %s
|
||||
AND target_id = %s
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""",
|
||||
(site_id, f"{assistant_id}_{member_id}"),
|
||||
)
|
||||
ai_row = cur.fetchone()
|
||||
|
||||
lines = [
|
||||
"【任务详情】",
|
||||
f" 任务类型:{task_type or '未知'}",
|
||||
f" 状态:{status or '未知'}",
|
||||
f" 截止日期:{_fmt_date(deadline)}",
|
||||
f" 客户:{member_nick or f'ID:{member_id}'}",
|
||||
f" 助教:{asst_nick or f'ID:{assistant_id}'}",
|
||||
]
|
||||
if notes:
|
||||
lines.append("【最近备注】")
|
||||
for content, created_at in notes:
|
||||
short = (content or "")[:100]
|
||||
lines.append(f" {_fmt_date(created_at)} {short}")
|
||||
if ai_row:
|
||||
lines.append(f"【AI 分析】最近更新于 {_fmt_date(ai_row[1])}")
|
||||
|
||||
return "\n".join(lines)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _text_customer_detail(
|
||||
context_id: int | str | None, site_id: int, filters: dict
|
||||
) -> str:
|
||||
"""客户详情页文本化。"""
|
||||
if not context_id:
|
||||
return ""
|
||||
member_id = int(context_id)
|
||||
|
||||
# 复用 member_data 的同步查询(避免循环导入,直接查询)
|
||||
etl_conn = None
|
||||
biz_conn = None
|
||||
try:
|
||||
etl_conn = get_etl_readonly_connection(site_id)
|
||||
with etl_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
)
|
||||
# CHANGE 2026-03-23 | Prompt: FDW 迁移——fdw_etl.* → app.* 直连 ETL 库
|
||||
# 会员信息
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT nickname
|
||||
FROM app.v_dim_member
|
||||
WHERE member_id = %s AND scd2_is_current = 1
|
||||
""",
|
||||
(member_id,),
|
||||
)
|
||||
m_row = cur.fetchone()
|
||||
nickname = m_row[0] if m_row else f"ID:{member_id}"
|
||||
|
||||
# 最近 5 条消费
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT settle_date, items_sum, room_name
|
||||
FROM app.v_dwd_settlement_head
|
||||
WHERE member_id = %s AND settle_type IN (1, 3)
|
||||
ORDER BY settle_date DESC LIMIT 5
|
||||
""",
|
||||
(member_id,),
|
||||
)
|
||||
recent = cur.fetchall()
|
||||
|
||||
# 余额
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT balance_amount
|
||||
FROM app.v_dws_member_consumption_summary
|
||||
WHERE member_id = %s LIMIT 1
|
||||
""",
|
||||
(member_id,),
|
||||
)
|
||||
bal_row = cur.fetchone()
|
||||
etl_conn.commit()
|
||||
|
||||
# 维客线索
|
||||
biz_conn = get_connection()
|
||||
with biz_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT summary FROM member_retention_clue
|
||||
WHERE member_id = %s AND site_id = %s
|
||||
ORDER BY created_at DESC LIMIT 5
|
||||
""",
|
||||
(member_id, site_id),
|
||||
)
|
||||
clues = cur.fetchall()
|
||||
|
||||
lines = [
|
||||
"【客户详情】",
|
||||
f" 昵称:{nickname}",
|
||||
f" 储值余额:{_fmt_decimal(bal_row[0]) if bal_row else '未知'}",
|
||||
]
|
||||
if recent:
|
||||
lines.append("【近期消费】")
|
||||
for sd, amt, room in recent:
|
||||
lines.append(f" {_fmt_date(sd)} ¥{_fmt_decimal(amt)} {room or ''}")
|
||||
if clues:
|
||||
lines.append("【维客线索】")
|
||||
for (summary,) in clues:
|
||||
lines.append(f" {summary}")
|
||||
|
||||
return "\n".join(lines)
|
||||
finally:
|
||||
if etl_conn:
|
||||
etl_conn.close()
|
||||
if biz_conn:
|
||||
biz_conn.close()
|
||||
|
||||
|
||||
def _text_coach_detail(
|
||||
context_id: int | str | None, site_id: int, filters: dict
|
||||
) -> str:
|
||||
"""助教详情页文本化。"""
|
||||
if not context_id:
|
||||
return ""
|
||||
assistant_id = int(context_id)
|
||||
|
||||
etl_conn = None
|
||||
biz_conn = None
|
||||
try:
|
||||
etl_conn = get_etl_readonly_connection(site_id)
|
||||
with etl_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT nickname, level, hire_date
|
||||
FROM app.v_dim_assistant
|
||||
WHERE assistant_id = %s LIMIT 1
|
||||
""",
|
||||
(assistant_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
etl_conn.commit()
|
||||
|
||||
if not row:
|
||||
return f"助教 {assistant_id} 不存在"
|
||||
|
||||
nickname, level, hire_date = row
|
||||
|
||||
biz_conn = get_connection()
|
||||
with biz_conn.cursor() as cur:
|
||||
# 任务统计
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT status, COUNT(*)
|
||||
FROM biz.coach_tasks
|
||||
WHERE assistant_id = %s AND site_id = %s
|
||||
GROUP BY status
|
||||
""",
|
||||
(assistant_id, site_id),
|
||||
)
|
||||
task_stats = cur.fetchall()
|
||||
|
||||
lines = [
|
||||
"【助教详情】",
|
||||
f" 花名:{nickname or ''}",
|
||||
f" 级别:{level or ''}",
|
||||
f" 入职日期:{_fmt_date(hire_date)}",
|
||||
]
|
||||
if task_stats:
|
||||
lines.append("【任务统计】")
|
||||
for status, cnt in task_stats:
|
||||
lines.append(f" {status}: {cnt} 个")
|
||||
|
||||
return "\n".join(lines)
|
||||
finally:
|
||||
if etl_conn:
|
||||
etl_conn.close()
|
||||
if biz_conn:
|
||||
biz_conn.close()
|
||||
|
||||
|
||||
# ── 看板类页面 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _text_board_finance(
|
||||
context_id: int | str | None, site_id: int, filters: dict
|
||||
) -> str:
|
||||
"""财务看板文本化。"""
|
||||
time_dim = filters.get("timeDimension", "this_month")
|
||||
area = filters.get("areaFilter", "")
|
||||
|
||||
etl_conn = None
|
||||
try:
|
||||
etl_conn = get_etl_readonly_connection(site_id)
|
||||
with etl_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
)
|
||||
# 简化查询:获取汇总数据
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) AS settle_count,
|
||||
COALESCE(SUM(items_sum), 0) AS total_revenue,
|
||||
COALESCE(AVG(items_sum), 0) AS avg_revenue
|
||||
FROM app.v_dwd_settlement_head
|
||||
WHERE settle_type IN (1, 3)
|
||||
AND settle_date >= (CURRENT_DATE - INTERVAL '1 month')
|
||||
""",
|
||||
)
|
||||
row = cur.fetchone()
|
||||
etl_conn.commit()
|
||||
|
||||
lines = [
|
||||
"【财务看板】",
|
||||
f" 时间维度:{time_dim}",
|
||||
]
|
||||
if area:
|
||||
lines.append(f" 区域筛选:{area}")
|
||||
if row:
|
||||
lines.append(f" 结算笔数:{row[0]}")
|
||||
lines.append(f" 总营收:¥{_fmt_decimal(row[1])}")
|
||||
lines.append(f" 笔均:¥{_fmt_decimal(row[2])}")
|
||||
|
||||
return "\n".join(lines)
|
||||
finally:
|
||||
if etl_conn:
|
||||
etl_conn.close()
|
||||
|
||||
|
||||
def _text_board_customer(
|
||||
context_id: int | str | None, site_id: int, filters: dict
|
||||
) -> str:
|
||||
"""客户看板文本化。"""
|
||||
dimension = filters.get("dimension", "consumption")
|
||||
type_filter = filters.get("typeFilter", "")
|
||||
|
||||
etl_conn = None
|
||||
try:
|
||||
etl_conn = get_etl_readonly_connection(site_id)
|
||||
with etl_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
)
|
||||
# Top 10 客户
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
dm.nickname,
|
||||
COALESCE(SUM(sh.items_sum), 0) AS total_consumption
|
||||
FROM app.v_dwd_settlement_head sh
|
||||
JOIN app.v_dim_member dm
|
||||
ON dm.member_id = sh.member_id AND dm.scd2_is_current = 1
|
||||
WHERE sh.settle_type IN (1, 3)
|
||||
AND sh.member_id > 0
|
||||
AND sh.settle_date >= (CURRENT_DATE - INTERVAL '1 month')
|
||||
GROUP BY dm.nickname
|
||||
ORDER BY total_consumption DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
etl_conn.commit()
|
||||
|
||||
lines = [
|
||||
"【客户看板】",
|
||||
f" 排序维度:{dimension}",
|
||||
]
|
||||
if type_filter:
|
||||
lines.append(f" 类型筛选:{type_filter}")
|
||||
if rows:
|
||||
lines.append(" Top 10 客户:")
|
||||
for i, (nick, amt) in enumerate(rows, 1):
|
||||
lines.append(f" {i}. {nick or '未知'} ¥{_fmt_decimal(amt)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
finally:
|
||||
if etl_conn:
|
||||
etl_conn.close()
|
||||
|
||||
|
||||
def _text_board_coach(
|
||||
context_id: int | str | None, site_id: int, filters: dict
|
||||
) -> str:
|
||||
"""助教看板文本化。"""
|
||||
dimension = filters.get("dimension", "service_count")
|
||||
project = filters.get("projectFilter", "")
|
||||
time_dim = filters.get("timeDimension", "this_month")
|
||||
|
||||
etl_conn = None
|
||||
try:
|
||||
etl_conn = get_etl_readonly_connection(site_id)
|
||||
with etl_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
da.nickname,
|
||||
COUNT(*) AS service_count,
|
||||
COALESCE(SUM(sl.ledger_amount), 0) AS total_revenue
|
||||
FROM app.v_dwd_assistant_service_log sl
|
||||
JOIN app.v_dim_assistant da
|
||||
ON da.assistant_id = sl.site_assistant_id
|
||||
WHERE sl.is_delete = 0
|
||||
AND sl.create_time >= (CURRENT_DATE - INTERVAL '1 month')
|
||||
GROUP BY da.nickname
|
||||
ORDER BY service_count DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
etl_conn.commit()
|
||||
|
||||
lines = [
|
||||
"【助教看板】",
|
||||
f" 排序维度:{dimension}",
|
||||
f" 时间维度:{time_dim}",
|
||||
]
|
||||
if project:
|
||||
lines.append(f" 技能筛选:{project}")
|
||||
if rows:
|
||||
lines.append(" Top 10 助教:")
|
||||
for i, (nick, cnt, amt) in enumerate(rows, 1):
|
||||
lines.append(f" {i}. {nick or '未知'} 服务{cnt}次 ¥{_fmt_decimal(amt)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
finally:
|
||||
if etl_conn:
|
||||
etl_conn.close()
|
||||
|
||||
|
||||
# ── 其他页面 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _text_performance(
|
||||
context_id: int | str | None, site_id: int, filters: dict
|
||||
) -> str:
|
||||
"""绩效页面文本化。"""
|
||||
time_dim = filters.get("timeDimension", "this_month")
|
||||
|
||||
etl_conn = None
|
||||
try:
|
||||
etl_conn = get_etl_readonly_connection(site_id)
|
||||
with etl_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
da.nickname,
|
||||
sc.performance_tier,
|
||||
sc.monthly_customers
|
||||
FROM app.v_dws_assistant_salary_calc sc
|
||||
JOIN app.v_dim_assistant da
|
||||
ON da.assistant_id = sc.assistant_id
|
||||
ORDER BY sc.calc_month DESC, sc.monthly_customers DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
etl_conn.commit()
|
||||
|
||||
lines = [
|
||||
"【绩效数据】",
|
||||
f" 时间维度:{time_dim}",
|
||||
]
|
||||
if rows:
|
||||
for nick, tier, customers in rows:
|
||||
lines.append(f" {nick or '未知'} {tier or ''} 服务{customers or 0}人")
|
||||
|
||||
return "\n".join(lines)
|
||||
finally:
|
||||
if etl_conn:
|
||||
etl_conn.close()
|
||||
|
||||
|
||||
def _text_my_profile(
|
||||
context_id: int | str | None, site_id: int, filters: dict
|
||||
) -> str:
|
||||
"""个人信息页文本化。"""
|
||||
return "【个人信息】\n 当前为个人信息页面,可查询个人绩效和任务情况。"
|
||||
|
||||
|
||||
def _text_task_list(
|
||||
context_id: int | str | None, site_id: int, filters: dict
|
||||
) -> str:
|
||||
"""任务列表页文本化。"""
|
||||
if not context_id:
|
||||
# 无特定任务,返回概要
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT status, COUNT(*)
|
||||
FROM biz.coach_tasks
|
||||
WHERE site_id = %s
|
||||
GROUP BY status
|
||||
""",
|
||||
(site_id,),
|
||||
)
|
||||
stats = cur.fetchall()
|
||||
|
||||
lines = ["【任务列表】"]
|
||||
for status, cnt in stats:
|
||||
lines.append(f" {status}: {cnt} 个")
|
||||
return "\n".join(lines)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 有特定任务 ID,复用 task-detail
|
||||
return _text_task_detail(context_id, site_id, filters)
|
||||
|
||||
|
||||
def _text_customer_service_records(
|
||||
context_id: int | str | None, site_id: int, filters: dict
|
||||
) -> str:
|
||||
"""客户服务记录页文本化。"""
|
||||
if not context_id:
|
||||
return ""
|
||||
member_id = int(context_id)
|
||||
|
||||
etl_conn = None
|
||||
try:
|
||||
etl_conn = get_etl_readonly_connection(site_id)
|
||||
with etl_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SET LOCAL statement_timeout = %s",
|
||||
(f"{FDW_QUERY_TIMEOUT_SEC * 1000}",),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
create_time,
|
||||
real_use_seconds / 60 AS duration_minutes,
|
||||
ledger_amount,
|
||||
site_table_id
|
||||
FROM app.v_dwd_assistant_service_log
|
||||
WHERE tenant_member_id = %s AND is_delete = 0
|
||||
ORDER BY create_time DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
(member_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
etl_conn.commit()
|
||||
|
||||
lines = ["【服务记录】"]
|
||||
if not rows:
|
||||
lines.append(" 暂无服务记录")
|
||||
else:
|
||||
for sd, dur, amt, room in rows:
|
||||
lines.append(
|
||||
f" {_fmt_date(sd)} {dur or 0}分钟 ¥{_fmt_decimal(amt)} {room or ''}"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
finally:
|
||||
if etl_conn:
|
||||
etl_conn.close()
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _fmt_date(val: Any) -> str:
|
||||
"""格式化日期值。"""
|
||||
if isinstance(val, datetime):
|
||||
return val.strftime("%Y-%m-%d %H:%M")
|
||||
if isinstance(val, date):
|
||||
return val.isoformat()
|
||||
return str(val) if val else "未知"
|
||||
|
||||
|
||||
def _fmt_decimal(val: Any) -> str:
|
||||
"""格式化金额值。"""
|
||||
if val is None:
|
||||
return "0.00"
|
||||
if isinstance(val, Decimal):
|
||||
return f"{val:.2f}"
|
||||
if isinstance(val, float):
|
||||
return f"{val:.2f}"
|
||||
return str(val)
|
||||
File diff suppressed because it is too large
Load Diff
53
apps/backend/app/ai/exceptions.py
Normal file
53
apps/backend/app/ai/exceptions.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""AI 模块异常层级。
|
||||
|
||||
所有 DashScope 相关异常继承自 DashScopeError 基类,
|
||||
便于上层统一捕获和分类处理。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class DashScopeError(Exception):
|
||||
"""DashScope 异常基类。"""
|
||||
|
||||
|
||||
class DashScopeApiError(DashScopeError):
|
||||
"""Application API 调用失败(重试耗尽后)。"""
|
||||
|
||||
def __init__(self, message: str, status_code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class DashScopeAuthError(DashScopeApiError):
|
||||
"""API Key 无效(HTTP 401)。"""
|
||||
|
||||
def __init__(self, message: str = "API Key 无效或已过期"):
|
||||
super().__init__(message, status_code=401)
|
||||
|
||||
|
||||
class DashScopeTimeoutError(DashScopeApiError):
|
||||
"""调用超时。"""
|
||||
|
||||
def __init__(self, message: str = "DashScope API 调用超时"):
|
||||
super().__init__(message, status_code=None)
|
||||
|
||||
|
||||
class DashScopeJsonParseError(DashScopeError):
|
||||
"""响应 JSON 解析失败(重试耗尽后)。"""
|
||||
|
||||
def __init__(self, message: str, raw_content: str = ""):
|
||||
super().__init__(message)
|
||||
self.raw_content = raw_content
|
||||
|
||||
|
||||
class CircuitOpenError(DashScopeError):
|
||||
"""熔断器处于 OPEN 状态,拒绝请求。"""
|
||||
|
||||
|
||||
class RateLimitExceededError(DashScopeError):
|
||||
"""限流阈值超限。"""
|
||||
|
||||
|
||||
class BudgetExceededError(DashScopeError):
|
||||
"""Token 预算超限。"""
|
||||
73
apps/backend/app/ai/rate_limiter.py
Normal file
73
apps/backend/app/ai/rate_limiter.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""限流器 — 滑动窗口内存计数器。
|
||||
|
||||
App1 按 user_id 限流(每用户每分钟 10 次),
|
||||
App2~8 按 site_id 限流(每门店每小时 100 次)。
|
||||
内存实现,单实例部署,不依赖外部存储。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""滑动窗口内存限流器。
|
||||
|
||||
- check_user_rate():App1 每用户每分钟限流
|
||||
- check_store_rate():App2~8 每门店每小时限流
|
||||
|
||||
每个 key 维护一个时间戳 deque,检查时先清除过期条目,
|
||||
再判断窗口内请求数是否低于阈值。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._user_windows: dict[str, deque[float]] = {} # App1: user_id → 时间戳队列
|
||||
self._store_windows: dict[str, deque[float]] = {} # App2~8: site_id → 时间戳队列
|
||||
|
||||
def _check(
|
||||
self,
|
||||
windows: dict[str, deque[float]],
|
||||
key: str,
|
||||
limit: int,
|
||||
window_seconds: int,
|
||||
) -> bool:
|
||||
"""通用滑动窗口检查。返回 True 表示允许。"""
|
||||
now = time.monotonic()
|
||||
|
||||
if key not in windows:
|
||||
windows[key] = deque()
|
||||
|
||||
window = windows[key]
|
||||
|
||||
# 清除窗口外的过期时间戳
|
||||
cutoff = now - window_seconds
|
||||
while window and window[0] <= cutoff:
|
||||
window.popleft()
|
||||
|
||||
# 判断是否超限
|
||||
if len(window) >= limit:
|
||||
return False
|
||||
|
||||
# 未超限,记录本次请求时间戳
|
||||
window.append(now)
|
||||
return True
|
||||
|
||||
def check_user_rate(
|
||||
self,
|
||||
user_id: str,
|
||||
limit: int = 10,
|
||||
window_seconds: int = 60,
|
||||
) -> bool:
|
||||
"""App1 每用户每分钟限流。返回 True 表示允许。"""
|
||||
return self._check(self._user_windows, user_id, limit, window_seconds)
|
||||
|
||||
def check_store_rate(
|
||||
self,
|
||||
site_id: int,
|
||||
limit: int = 100,
|
||||
window_seconds: int = 3600,
|
||||
) -> bool:
|
||||
"""App2~8 每门店每小时限流。返回 True 表示允许。"""
|
||||
# site_id 为 int,转为 str 作为 dict key
|
||||
return self._check(self._store_windows, str(site_id), limit, window_seconds)
|
||||
207
apps/backend/app/ai/run_log_service.py
Normal file
207
apps/backend/app/ai/run_log_service.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""AI 运行日志服务 — biz.ai_run_logs 表的 CRUD 操作。
|
||||
|
||||
每次 Application API 调用前创建 pending 记录,调用过程中更新状态,
|
||||
调用结束后记录结果。同时提供日/月 token 聚合查询,实现 UsageProvider 协议
|
||||
以便注入 BudgetTracker。
|
||||
|
||||
request_prompt 写入前截断为前 2000 字符,避免大 prompt 占用过多存储。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable
|
||||
|
||||
import psycopg2.extensions
|
||||
|
||||
# prompt 最大存储长度
|
||||
_MAX_PROMPT_LENGTH = 2000
|
||||
|
||||
|
||||
def _truncate_prompt(prompt: str | None) -> str | None:
|
||||
"""截断 prompt 为前 2000 字符。None 原样返回。"""
|
||||
if prompt is None:
|
||||
return None
|
||||
return prompt[:_MAX_PROMPT_LENGTH]
|
||||
|
||||
|
||||
class AIRunLogService:
|
||||
"""AI 运行日志 CRUD,实现 UsageProvider 协议。
|
||||
|
||||
构造函数接受 get_conn callable,每次操作时获取数据库连接,
|
||||
避免长期持有连接导致超时或连接池耗尽。
|
||||
"""
|
||||
|
||||
def __init__(self, get_conn: Callable[[], psycopg2.extensions.connection]) -> None:
|
||||
self._get_conn = get_conn
|
||||
|
||||
# ── 创建 ──────────────────────────────────────────────
|
||||
|
||||
def create_log(
|
||||
self,
|
||||
site_id: int,
|
||||
app_type: str,
|
||||
trigger_type: str,
|
||||
*,
|
||||
member_id: int | None = None,
|
||||
request_prompt: str | None = None,
|
||||
session_id: str | None = None,
|
||||
) -> int:
|
||||
"""创建日志记录(status: pending),返回 log_id。
|
||||
|
||||
request_prompt 自动截断为前 2000 字符。
|
||||
"""
|
||||
truncated = _truncate_prompt(request_prompt)
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.ai_run_logs
|
||||
(site_id, app_type, trigger_type, member_id,
|
||||
request_prompt, session_id, status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, 'pending')
|
||||
RETURNING id
|
||||
""",
|
||||
(site_id, app_type, trigger_type, member_id,
|
||||
truncated, session_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
assert row is not None, "INSERT RETURNING 应返回 id"
|
||||
log_id: int = row[0]
|
||||
conn.commit()
|
||||
return log_id
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
# ── 状态转换 ──────────────────────────────────────────
|
||||
|
||||
def update_running(self, log_id: int) -> None:
|
||||
"""更新为 running。"""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.ai_run_logs
|
||||
SET status = 'running'
|
||||
WHERE id = %s
|
||||
""",
|
||||
(log_id,),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
def update_success(
|
||||
self,
|
||||
log_id: int,
|
||||
response_text: str,
|
||||
tokens_used: int,
|
||||
latency_ms: int,
|
||||
) -> None:
|
||||
"""更新为 success,记录响应、token 消耗和耗时。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.ai_run_logs
|
||||
SET status = 'success',
|
||||
response_text = %s,
|
||||
tokens_used = %s,
|
||||
latency_ms = %s,
|
||||
finished_at = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(response_text, tokens_used, latency_ms, now, log_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
def update_failed(
|
||||
self,
|
||||
log_id: int,
|
||||
error_message: str,
|
||||
latency_ms: int,
|
||||
) -> None:
|
||||
"""更新为 failed,记录错误信息和耗时。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.ai_run_logs
|
||||
SET status = 'failed',
|
||||
error_message = %s,
|
||||
latency_ms = %s,
|
||||
finished_at = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(error_message, latency_ms, now, log_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
def update_timeout(self, log_id: int, latency_ms: int) -> None:
|
||||
"""更新为 timeout。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.ai_run_logs
|
||||
SET status = 'timeout',
|
||||
latency_ms = %s,
|
||||
finished_at = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(latency_ms, now, log_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
# ── UsageProvider 协议实现 ────────────────────────────
|
||||
|
||||
def get_daily_usage(self) -> int:
|
||||
"""聚合今日 token 消耗(status='success',created_at 为今日)。"""
|
||||
conn = self._get_conn()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(SUM(tokens_used), 0)
|
||||
FROM biz.ai_run_logs
|
||||
WHERE status = 'success'
|
||||
AND created_at >= CURRENT_DATE
|
||||
AND created_at < CURRENT_DATE + INTERVAL '1 day'
|
||||
"""
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
def get_monthly_usage(self) -> int:
|
||||
"""聚合本月 token 消耗(status='success',created_at 为本月)。"""
|
||||
conn = self._get_conn()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COALESCE(SUM(tokens_used), 0)
|
||||
FROM biz.ai_run_logs
|
||||
WHERE status = 'success'
|
||||
AND created_at >= date_trunc('month', CURRENT_DATE)
|
||||
AND created_at < date_trunc('month', CURRENT_DATE) + INTERVAL '1 month'
|
||||
"""
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
109
apps/backend/app/ai/test_rate_limiter.py
Normal file
109
apps/backend/app/ai/test_rate_limiter.py
Normal file
@@ -0,0 +1,109 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""RateLimiter 单元测试。
|
||||
|
||||
被测代码:apps/backend/app/ai/rate_limiter.py
|
||||
纯内存测试,不涉及 DB/网络。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.ai.rate_limiter import RateLimiter
|
||||
|
||||
|
||||
class TestCheckUserRate:
|
||||
"""App1 每用户每分钟限流。"""
|
||||
|
||||
def test_allows_under_limit(self):
|
||||
rl = RateLimiter()
|
||||
for _ in range(10):
|
||||
assert rl.check_user_rate("u1", limit=10) is True
|
||||
|
||||
def test_rejects_at_limit(self):
|
||||
rl = RateLimiter()
|
||||
for _ in range(10):
|
||||
rl.check_user_rate("u1", limit=10)
|
||||
assert rl.check_user_rate("u1", limit=10) is False
|
||||
|
||||
def test_different_users_independent(self):
|
||||
rl = RateLimiter()
|
||||
for _ in range(10):
|
||||
rl.check_user_rate("u1", limit=10)
|
||||
# u1 已满,u2 不受影响
|
||||
assert rl.check_user_rate("u1", limit=10) is False
|
||||
assert rl.check_user_rate("u2", limit=10) is True
|
||||
|
||||
def test_window_expiry_allows_again(self):
|
||||
"""窗口过期后,历史请求不影响当前判断。"""
|
||||
rl = RateLimiter()
|
||||
base = time.monotonic()
|
||||
|
||||
with patch("app.ai.rate_limiter.time.monotonic", return_value=base):
|
||||
for _ in range(10):
|
||||
rl.check_user_rate("u1", limit=10, window_seconds=60)
|
||||
|
||||
# 61 秒后,窗口内无请求
|
||||
with patch("app.ai.rate_limiter.time.monotonic", return_value=base + 61):
|
||||
assert rl.check_user_rate("u1", limit=10, window_seconds=60) is True
|
||||
|
||||
|
||||
class TestCheckStoreRate:
|
||||
"""App2~8 每门店每小时限流。"""
|
||||
|
||||
def test_allows_under_limit(self):
|
||||
rl = RateLimiter()
|
||||
for _ in range(5):
|
||||
assert rl.check_store_rate(123, limit=5) is True
|
||||
|
||||
def test_rejects_at_limit(self):
|
||||
rl = RateLimiter()
|
||||
for _ in range(5):
|
||||
rl.check_store_rate(123, limit=5)
|
||||
assert rl.check_store_rate(123, limit=5) is False
|
||||
|
||||
def test_different_stores_independent(self):
|
||||
rl = RateLimiter()
|
||||
for _ in range(5):
|
||||
rl.check_store_rate(100, limit=5)
|
||||
assert rl.check_store_rate(100, limit=5) is False
|
||||
assert rl.check_store_rate(200, limit=5) is True
|
||||
|
||||
def test_site_id_int_works(self):
|
||||
"""site_id 为 int,内部转 str 存储。"""
|
||||
rl = RateLimiter()
|
||||
assert rl.check_store_rate(2790685415443269, limit=100) is True
|
||||
|
||||
def test_window_expiry_allows_again(self):
|
||||
rl = RateLimiter()
|
||||
base = time.monotonic()
|
||||
|
||||
with patch("app.ai.rate_limiter.time.monotonic", return_value=base):
|
||||
for _ in range(100):
|
||||
rl.check_store_rate(123, limit=100, window_seconds=3600)
|
||||
|
||||
# 3601 秒后
|
||||
with patch("app.ai.rate_limiter.time.monotonic", return_value=base + 3601):
|
||||
assert rl.check_store_rate(123, limit=100, window_seconds=3600) is True
|
||||
|
||||
|
||||
class TestRejectedRequestNotRecorded:
|
||||
"""被拒绝的请求不应记录时间戳(不占用窗口配额)。"""
|
||||
|
||||
def test_rejected_user_request_not_counted(self):
|
||||
rl = RateLimiter()
|
||||
for _ in range(3):
|
||||
rl.check_user_rate("u1", limit=3)
|
||||
# 连续拒绝不应增加窗口内计数
|
||||
rl.check_user_rate("u1", limit=3)
|
||||
rl.check_user_rate("u1", limit=3)
|
||||
assert len(rl._user_windows["u1"]) == 3
|
||||
|
||||
def test_rejected_store_request_not_counted(self):
|
||||
rl = RateLimiter()
|
||||
for _ in range(3):
|
||||
rl.check_store_rate(1, limit=3)
|
||||
rl.check_store_rate(1, limit=3)
|
||||
rl.check_store_rate(1, limit=3)
|
||||
assert len(rl._store_windows["1"]) == 3
|
||||
@@ -13,18 +13,92 @@ FastAPI 依赖注入:从 JWT 提取当前用户信息。
|
||||
... # 受限逻辑
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError
|
||||
|
||||
from app.auth.jwt import decode_access_token
|
||||
from app.trace.context import SpanType, TraceSpan, get_current_trace
|
||||
from app.trace.decorators import truncate_token
|
||||
|
||||
# Bearer token 提取器
|
||||
_bearer_scheme = HTTPBearer(auto_error=True)
|
||||
|
||||
|
||||
# ── 鉴权失败原因分类常量 ──
|
||||
AUTH_EXPIRED = "AUTH_EXPIRED"
|
||||
AUTH_INVALID = "AUTH_INVALID"
|
||||
AUTH_MALFORMED = "AUTH_MALFORMED"
|
||||
AUTH_LIMITED = "AUTH_LIMITED"
|
||||
AUTH_FORBIDDEN = "AUTH_FORBIDDEN"
|
||||
|
||||
|
||||
def _record_auth_span(
|
||||
*,
|
||||
token: str,
|
||||
success: bool,
|
||||
user_id: int | None = None,
|
||||
site_id: int | None = None,
|
||||
roles: list[str] | None = None,
|
||||
user_status: str = "",
|
||||
failure_reason: str = "",
|
||||
detail: str = "",
|
||||
duration_ms: float = 0.0,
|
||||
) -> None:
|
||||
"""向当前 TraceContext 添加 AUTH span(无 trace 时静默跳过)。"""
|
||||
ctx = get_current_trace()
|
||||
if ctx is None:
|
||||
return
|
||||
|
||||
token_prefix = truncate_token(token)
|
||||
|
||||
if success:
|
||||
desc_zh = f"JWT 鉴权通过:user_id={user_id}, site_id={site_id}, roles={roles}"
|
||||
desc_en = f"JWT auth passed: user_id={user_id}, site_id={site_id}, roles={roles}"
|
||||
result_summary = "approved"
|
||||
else:
|
||||
desc_zh = f"JWT 鉴权失败:{failure_reason} — {detail}"
|
||||
desc_en = f"JWT auth failed: {failure_reason} — {detail}"
|
||||
result_summary = failure_reason
|
||||
|
||||
extra: dict = {}
|
||||
if failure_reason:
|
||||
extra["failure_reason"] = failure_reason
|
||||
|
||||
ctx.add_span(TraceSpan(
|
||||
span_type=SpanType.AUTH,
|
||||
module="auth.dependencies",
|
||||
function="get_current_user",
|
||||
description_zh=desc_zh,
|
||||
description_en=desc_en,
|
||||
params={"token_prefix": token_prefix},
|
||||
result_summary=result_summary,
|
||||
duration_ms=duration_ms,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
extra=extra,
|
||||
))
|
||||
|
||||
# 鉴权成功时将 user_id / site_id 写入 TraceContext
|
||||
if success and user_id is not None:
|
||||
ctx.user_id = user_id
|
||||
if site_id is not None:
|
||||
ctx.site_id = site_id
|
||||
|
||||
|
||||
def _classify_jwt_error(exc: JWTError) -> str:
|
||||
"""根据 JWTError 消息分类失败原因。"""
|
||||
msg = str(exc).lower()
|
||||
if "expired" in msg or "exp" in msg:
|
||||
return AUTH_EXPIRED
|
||||
return AUTH_INVALID
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CurrentUser:
|
||||
"""从 JWT 解析出的当前用户上下文。"""
|
||||
@@ -45,9 +119,17 @@ async def get_current_user(
|
||||
要求完整令牌(非 limited),失败时抛出 401。
|
||||
"""
|
||||
token = credentials.credentials
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
payload = decode_access_token(token)
|
||||
except JWTError:
|
||||
except JWTError as exc:
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
reason = _classify_jwt_error(exc)
|
||||
_record_auth_span(
|
||||
token=token, success=False,
|
||||
failure_reason=reason, detail="无效的令牌",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的令牌",
|
||||
@@ -56,6 +138,12 @@ async def get_current_user(
|
||||
|
||||
# 受限令牌不允许通过此依赖
|
||||
if payload.get("limited"):
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
_record_auth_span(
|
||||
token=token, success=False,
|
||||
failure_reason=AUTH_LIMITED, detail="受限令牌无法访问此端点",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="受限令牌无法访问此端点",
|
||||
@@ -66,6 +154,12 @@ async def get_current_user(
|
||||
site_id = payload.get("site_id")
|
||||
|
||||
if user_id_raw is None or site_id is None:
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
_record_auth_span(
|
||||
token=token, success=False,
|
||||
failure_reason=AUTH_MALFORMED, detail="令牌缺少必要字段",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌缺少必要字段",
|
||||
@@ -75,6 +169,12 @@ async def get_current_user(
|
||||
try:
|
||||
user_id = int(user_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
_record_auth_span(
|
||||
token=token, success=False,
|
||||
failure_reason=AUTH_MALFORMED, detail="令牌中 user_id 格式无效",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌中 user_id 格式无效",
|
||||
@@ -82,6 +182,13 @@ async def get_current_user(
|
||||
)
|
||||
|
||||
roles = payload.get("roles", [])
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
|
||||
_record_auth_span(
|
||||
token=token, success=True,
|
||||
user_id=user_id, site_id=site_id, roles=roles,
|
||||
user_status="approved", duration_ms=elapsed,
|
||||
)
|
||||
|
||||
return CurrentUser(
|
||||
user_id=user_id,
|
||||
@@ -102,9 +209,17 @@ async def get_current_user_or_limited(
|
||||
- 完整令牌:正常返回 CurrentUser
|
||||
"""
|
||||
token = credentials.credentials
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
payload = decode_access_token(token)
|
||||
except JWTError:
|
||||
except JWTError as exc:
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
reason = _classify_jwt_error(exc)
|
||||
_record_auth_span(
|
||||
token=token, success=False,
|
||||
failure_reason=reason, detail="无效的令牌",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的令牌",
|
||||
@@ -113,6 +228,12 @@ async def get_current_user_or_limited(
|
||||
|
||||
user_id_raw = payload.get("sub")
|
||||
if user_id_raw is None:
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
_record_auth_span(
|
||||
token=token, success=False,
|
||||
failure_reason=AUTH_MALFORMED, detail="令牌缺少必要字段",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌缺少必要字段",
|
||||
@@ -122,6 +243,12 @@ async def get_current_user_or_limited(
|
||||
try:
|
||||
user_id = int(user_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
_record_auth_span(
|
||||
token=token, success=False,
|
||||
failure_reason=AUTH_MALFORMED, detail="令牌中 user_id 格式无效",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌中 user_id 格式无效",
|
||||
@@ -130,6 +257,12 @@ async def get_current_user_or_limited(
|
||||
|
||||
# 受限令牌:pending 用户
|
||||
if payload.get("limited"):
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
_record_auth_span(
|
||||
token=token, success=True,
|
||||
user_id=user_id, site_id=0, roles=[],
|
||||
user_status="pending", duration_ms=elapsed,
|
||||
)
|
||||
return CurrentUser(
|
||||
user_id=user_id,
|
||||
site_id=0,
|
||||
@@ -141,6 +274,12 @@ async def get_current_user_or_limited(
|
||||
# 完整令牌:要求 site_id
|
||||
site_id = payload.get("site_id")
|
||||
if site_id is None:
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
_record_auth_span(
|
||||
token=token, success=False,
|
||||
failure_reason=AUTH_MALFORMED, detail="令牌缺少必要字段",
|
||||
duration_ms=elapsed,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌缺少必要字段",
|
||||
@@ -148,6 +287,13 @@ async def get_current_user_or_limited(
|
||||
)
|
||||
|
||||
roles = payload.get("roles", [])
|
||||
elapsed = (time.perf_counter() - start) * 1000
|
||||
|
||||
_record_auth_span(
|
||||
token=token, success=True,
|
||||
user_id=user_id, site_id=site_id, roles=roles,
|
||||
user_status="approved", duration_ms=elapsed,
|
||||
)
|
||||
|
||||
return CurrentUser(
|
||||
user_id=user_id,
|
||||
|
||||
48
apps/backend/app/auth/internal_token.py
Normal file
48
apps/backend/app/auth/internal_token.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
通用 Internal-Token 认证依赖。
|
||||
|
||||
从环境变量 INTERNAL_API_TOKEN 读取期望 token,
|
||||
供 /api/internal/* 端点使用(不依赖 AIConfig)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import Header, HTTPException, status
|
||||
|
||||
|
||||
def verify_internal_token(authorization: str = Header(...)) -> str:
|
||||
"""校验 Internal-Token 认证。
|
||||
|
||||
Header 格式:Authorization: Internal-Token {token}
|
||||
"""
|
||||
prefix = "Internal-Token "
|
||||
if not authorization.startswith(prefix):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证格式,需要 Internal-Token",
|
||||
)
|
||||
|
||||
token = authorization[len(prefix):]
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token 不能为空",
|
||||
)
|
||||
|
||||
expected = os.environ.get("INTERNAL_API_TOKEN", "")
|
||||
if not expected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="INTERNAL_API_TOKEN 未配置",
|
||||
)
|
||||
|
||||
if token != expected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token 不匹配",
|
||||
)
|
||||
|
||||
return token
|
||||
208
apps/backend/app/auth/tenant_admins.py
Normal file
208
apps/backend/app/auth/tenant_admins.py
Normal file
@@ -0,0 +1,208 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
租户管理员认证依赖注入。
|
||||
|
||||
提供 require_tenant_admin() 依赖,验证 JWT aud=tenant-admin,
|
||||
与小程序端 get_current_user()(aud 隐含为 xcx)完全隔离。
|
||||
|
||||
用法:
|
||||
@router.get("/protected")
|
||||
async def endpoint(admin: CurrentTenantAdmin = Depends(require_tenant_admin)):
|
||||
print(admin.admin_id, admin.managed_site_ids)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# AI_CHANGELOG
|
||||
# - 2026-03-23 21:00:00 | Prompt: P20260323-210000(根治 tenant_admin managed_site_ids 限制)| Direct cause:JWT managed_site_ids 静态签发,新建店铺后所有端点受限 | Summary:新增 get_tenant_site_ids(tenant_id) 和 get_effective_site_ids(admin) 函数;改造 site_filter_clause 和 verify_site_access 支持 admin= keyword-only 参数(向后兼容旧签名)| Verify:tenant_admin 新建店铺后无需重新登录即可访问所有端点
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError
|
||||
|
||||
from app.auth.jwt import decode_access_token
|
||||
from app import config as _config
|
||||
from jose import jwt as _jose_jwt
|
||||
|
||||
# 复用与 dependencies.py 相同的 Bearer 提取器
|
||||
_bearer_scheme = HTTPBearer(auto_error=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CurrentTenantAdmin:
|
||||
"""从 JWT 解析出的租户管理员上下文。"""
|
||||
|
||||
admin_id: int
|
||||
tenant_id: int
|
||||
managed_site_ids: list[int] = field(default_factory=list)
|
||||
display_name: str | None = None
|
||||
admin_type: str = "tenant_admin" # tenant_admin / site_admin
|
||||
|
||||
|
||||
async def require_tenant_admin(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(_bearer_scheme),
|
||||
) -> CurrentTenantAdmin:
|
||||
"""
|
||||
FastAPI 依赖:验证 JWT aud=tenant-admin,提取管理员信息。
|
||||
|
||||
拒绝小程序 JWT(aud 不匹配)及任何无效/过期令牌。
|
||||
"""
|
||||
token = credentials.credentials
|
||||
try:
|
||||
# 直接解码并验证 aud=tenant-admin + type=access
|
||||
# 不能复用 decode_access_token(),因为它不传 audience 参数,
|
||||
# jose 遇到 aud claim 但无 audience 参数时会直接拒绝。
|
||||
payload = _jose_jwt.decode(
|
||||
token,
|
||||
_config.JWT_SECRET_KEY,
|
||||
algorithms=[_config.JWT_ALGORITHM],
|
||||
audience="tenant-admin",
|
||||
)
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的令牌",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# 验证 token type 为 access(与 decode_access_token 一致)
|
||||
if payload.get("type") != "access":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌类型不匹配",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# jose 在 aud claim 缺失时不会拒绝,需要显式检查
|
||||
if payload.get("aud") != "tenant-admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌类型不匹配",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# 提取必要字段
|
||||
sub = payload.get("sub")
|
||||
tenant_id = payload.get("tenant_id")
|
||||
managed_site_ids = payload.get("managed_site_ids")
|
||||
|
||||
if sub is None or tenant_id is None or managed_site_ids is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌缺少必要字段",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
admin_id = int(sub)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="令牌中 admin_id 格式无效",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return CurrentTenantAdmin(
|
||||
admin_id=admin_id,
|
||||
tenant_id=tenant_id,
|
||||
managed_site_ids=managed_site_ids,
|
||||
display_name=payload.get("display_name"),
|
||||
admin_type=payload.get("admin_type", "tenant_admin"),
|
||||
)
|
||||
|
||||
|
||||
# ── 数据隔离工具函数 ─────────────────────────────────────────
|
||||
|
||||
# [CHANGE P20260323-210000] intent: 根治 tenant_admin 的 managed_site_ids 限制,
|
||||
# tenant_admin 按 tenant_id 查 biz.sites 获取有效 site_ids,
|
||||
# site_admin 仍用 JWT 中的 managed_site_ids。
|
||||
# assumptions: biz.sites 数据量极小(几条),无需缓存
|
||||
# verify: tenant_admin 新建店铺后无需重新登录即可访问
|
||||
|
||||
|
||||
def get_tenant_site_ids(tenant_id: int) -> list[int]:
|
||||
"""查询租户下所有活跃店铺的 site_id 列表。
|
||||
|
||||
通过 biz.tenants.tenant_id(外部租户标识)→ biz.tenants.id(内部 PK)
|
||||
→ biz.sites.tenant_id 关联查询。
|
||||
"""
|
||||
from app.database import get_connection
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT s.site_id
|
||||
FROM biz.sites s
|
||||
JOIN biz.tenants t ON t.id = s.tenant_id
|
||||
WHERE t.tenant_id = %s AND t.is_active = true
|
||||
AND s.is_active = true
|
||||
""",
|
||||
(tenant_id,),
|
||||
)
|
||||
return [row[0] for row in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_effective_site_ids(admin: CurrentTenantAdmin) -> list[int]:
|
||||
"""获取管理员的有效 site_id 列表。
|
||||
|
||||
- tenant_admin:实时查 biz.sites(覆盖新建店铺)
|
||||
- site_admin:使用 JWT 中的 managed_site_ids(精确控制)
|
||||
"""
|
||||
if admin.admin_type == "tenant_admin":
|
||||
return get_tenant_site_ids(admin.tenant_id)
|
||||
return admin.managed_site_ids
|
||||
|
||||
|
||||
def site_filter_clause(
|
||||
managed_site_ids: list[int] | None = None,
|
||||
*,
|
||||
admin: CurrentTenantAdmin | None = None,
|
||||
) -> tuple[str, tuple]:
|
||||
"""生成 site_id IN (...) SQL 片段,用于数据隔离查询。
|
||||
|
||||
优先使用 admin 参数(自动区分 tenant_admin/site_admin),
|
||||
也兼容旧的 managed_site_ids 直传方式。
|
||||
|
||||
返回 (sql_fragment, params_tuple),可直接拼入 WHERE 子句。
|
||||
"""
|
||||
if admin is not None:
|
||||
site_ids = get_effective_site_ids(admin)
|
||||
elif managed_site_ids is not None:
|
||||
site_ids = managed_site_ids
|
||||
else:
|
||||
return "1 = 0", ()
|
||||
|
||||
if not site_ids:
|
||||
return "1 = 0", ()
|
||||
placeholders = ", ".join(["%s"] * len(site_ids))
|
||||
return f"site_id IN ({placeholders})", tuple(site_ids)
|
||||
|
||||
|
||||
def verify_site_access(
|
||||
site_id: int,
|
||||
managed_site_ids: list[int] | None = None,
|
||||
*,
|
||||
admin: CurrentTenantAdmin | None = None,
|
||||
) -> None:
|
||||
"""校验 site_id 是否在管辖范围内,不在则抛 403。
|
||||
|
||||
优先使用 admin 参数(自动区分 tenant_admin/site_admin),
|
||||
也兼容旧的 managed_site_ids 直传方式。
|
||||
"""
|
||||
if admin is not None:
|
||||
effective_ids = get_effective_site_ids(admin)
|
||||
elif managed_site_ids is not None:
|
||||
effective_ids = managed_site_ids
|
||||
else:
|
||||
effective_ids = []
|
||||
|
||||
if site_id not in effective_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权访问该门店数据",
|
||||
)
|
||||
@@ -111,7 +111,8 @@ APP_DB_NAME: str = get("APP_DB_NAME", "test_zqyy_app")
|
||||
JWT_SECRET_KEY: str = get("JWT_SECRET_KEY", "") # 生产环境必须设置
|
||||
JWT_ALGORITHM: str = get("JWT_ALGORITHM", "HS256")
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = int(get("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = int(get("JWT_REFRESH_TOKEN_EXPIRE_DAYS", "7"))
|
||||
# CHANGE 2026-03-27 | 权限改造 W1:refresh_token 有效期 7天→30天,配合滑动窗口续期
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = int(get("JWT_REFRESH_TOKEN_EXPIRE_DAYS", "30"))
|
||||
|
||||
# ---- ETL 数据库连接参数(可独立配置,缺省时复用 zqyy_app 的连接参数) ----
|
||||
ETL_DB_HOST: str = get("ETL_DB_HOST") or DB_HOST
|
||||
@@ -177,6 +178,10 @@ WX_SECRET: str = get("WX_SECRET", "")
|
||||
# 开发模式:WX_DEV_MODE=true 时启用 mock 登录端点,跳过微信 code2Session
|
||||
WX_DEV_MODE: bool = get("WX_DEV_MODE", "false").lower() in ("true", "1", "yes")
|
||||
|
||||
# ---- 用户头像存储 ----
|
||||
# chooseAvatar 上传后保存到此目录,文件名 {user_id}.jpg
|
||||
AVATAR_EXPORT_PATH: str = get("AVATAR_EXPORT_PATH", "")
|
||||
|
||||
# ---- 营业日分割点 ----
|
||||
BUSINESS_DAY_START_HOUR: int = int(get("BUSINESS_DAY_START_HOUR", "8"))
|
||||
|
||||
|
||||
@@ -8,8 +8,13 @@
|
||||
- get_connection():zqyy_app 读写连接(用户/队列/调度等业务数据)
|
||||
- get_etl_readonly_connection(site_id):etl_feiqiu 只读连接(数据库查看器),
|
||||
自动设置 RLS site_id 隔离
|
||||
|
||||
当 DEV_TRACE_ENABLED=true 且存在活跃 TraceContext 时,
|
||||
get_connection() 返回 TracedConnection 包装,自动记录 DB_CONN / DB_QUERY / DB_CONN_RELEASE span。
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extensions import connection as PgConnection
|
||||
|
||||
@@ -32,8 +37,19 @@ def get_connection() -> PgConnection:
|
||||
获取 zqyy_app 数据库连接。
|
||||
|
||||
调用方负责关闭连接(推荐配合 contextmanager 或 try/finally 使用)。
|
||||
当 trace 启用且有活跃 TraceContext 时,返回 TracedConnection 包装,
|
||||
自动记录 DB_CONN span(连接获取耗时)并拦截后续 SQL 执行。
|
||||
"""
|
||||
return psycopg2.connect(
|
||||
# CHANGE 2026-03-22 | task 8.2 | 集成 trace db_wrapper,仅 trace 启用时包装
|
||||
from app.trace.config import get_trace_config
|
||||
from app.trace.context import SpanType, TraceSpan, get_current_trace
|
||||
|
||||
config = get_trace_config()
|
||||
should_trace = config.enabled and get_current_trace() is not None
|
||||
|
||||
start = time.perf_counter() if should_trace else 0.0
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
user=DB_USER,
|
||||
@@ -41,6 +57,52 @@ def get_connection() -> PgConnection:
|
||||
dbname=APP_DB_NAME,
|
||||
)
|
||||
|
||||
if should_trace:
|
||||
from datetime import datetime
|
||||
from app.trace.db_wrapper import traced_connection
|
||||
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
ctx = get_current_trace()
|
||||
# ctx 不为 None(上面已检查)
|
||||
ctx.add_span(TraceSpan(
|
||||
span_type=SpanType.DB_CONN,
|
||||
module="app.database",
|
||||
function="get_connection",
|
||||
description_zh=f"获取数据库连接,耗时 {elapsed_ms:.1f}ms",
|
||||
description_en=f"Acquired database connection in {elapsed_ms:.1f}ms",
|
||||
params={},
|
||||
result_summary=f"{elapsed_ms:.1f}ms",
|
||||
duration_ms=elapsed_ms,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
))
|
||||
return traced_connection(conn)
|
||||
|
||||
return conn
|
||||
|
||||
|
||||
def get_etl_global_readonly_connection() -> PgConnection:
|
||||
"""
|
||||
获取 ETL 数据库的全局只读连接(不设 RLS)。
|
||||
|
||||
用于系统管理后台等不需要门店隔离的场景(如 ETL 状态监控)。
|
||||
"""
|
||||
conn = psycopg2.connect(
|
||||
host=ETL_DB_HOST,
|
||||
port=ETL_DB_PORT,
|
||||
user=ETL_DB_USER,
|
||||
password=ETL_DB_PASSWORD,
|
||||
dbname=ETL_DB_NAME,
|
||||
)
|
||||
try:
|
||||
conn.autocommit = False
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SET default_transaction_read_only = on")
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.close()
|
||||
raise
|
||||
return conn
|
||||
|
||||
|
||||
def get_etl_readonly_connection(site_id: int | str) -> PgConnection:
|
||||
"""
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.middleware.response_wrapper import (
|
||||
http_exception_handler,
|
||||
unhandled_exception_handler,
|
||||
)
|
||||
from app.trace.middleware import TraceMiddleware
|
||||
|
||||
from app import config
|
||||
# CHANGE 2026-02-19 | 新增 xcx_test 路由(MVP 验证)+ wx_callback 路由(微信消息推送)
|
||||
@@ -29,9 +30,16 @@ from app import config
|
||||
# CHANGE 2026-03-18 | 新增 xcx_customers 路由(CUST-1 客户详情、CUST-2 客户服务记录)
|
||||
# CHANGE 2026-03-19 | 新增 xcx_coaches 路由(COACH-1 助教详情)
|
||||
# CHANGE 2026-03-19 | 新增 xcx_board / xcx_config 路由(RNS1.3 三看板 + 技能类型配置)
|
||||
from app.routers import auth, execution, schedules, tasks, env_config, db_viewer, etl_status, xcx_test, wx_callback, member_retention_clue, ops_panel, xcx_auth, admin_applications, business_day, xcx_tasks, xcx_notes, xcx_chat, xcx_ai_cache, xcx_performance, xcx_customers, xcx_coaches, xcx_board, xcx_config
|
||||
# CHANGE 2026-03-22 | 新增 admin_registry 路由(NS4.1 注册体系:租户/店铺/简写ID 管理)
|
||||
# CHANGE 2026-03-23 | 新增 admin_ai 路由(P15 AI 监控后台:Dashboard/调度/调用/缓存/预算/批量/告警)
|
||||
# CHANGE 2026-03-24 | 新增 admin_dev_trace 路由(dev-trace-log: 开发调试日志管理 API)
|
||||
# CHANGE 2026-03-23 | 新增 trigger_jobs 路由(定时任务管理页面 API)
|
||||
# CHANGE 2026-03-24 | P18 任务引擎运营看板:新增 admin_task_engine 路由
|
||||
# CHANGE 2026-03-29 | DWS_TASK_ENGINE:新增 internal_events 路由(按 job_name 执行任务)
|
||||
from app.routers import auth, execution, schedules, tasks, env_config, db_viewer, etl_status, xcx_test, wx_callback, member_retention_clue, ops_panel, xcx_auth, xcx_avatar, admin_applications, business_day, xcx_tasks, xcx_notes, xcx_chat, xcx_ai_cache, xcx_performance, xcx_customers, xcx_coaches, xcx_board, xcx_config, tenant_auth, tenant_users, tenant_excel, tenant_clues, tenant_site_admins, admin_tenant_admins, admin_registry, internal_ai, admin_ai, admin_dev_trace, trigger_jobs, admin_task_engine, admin_db_health, admin_triggers, internal_events
|
||||
from app.services.scheduler import scheduler
|
||||
from app.services.task_queue import task_queue
|
||||
from app.services.task_executor import task_executor
|
||||
from app.ws.logs import ws_router
|
||||
|
||||
|
||||
@@ -56,40 +64,76 @@ async def lifespan(app: FastAPI):
|
||||
)
|
||||
print(_banner, flush=True)
|
||||
|
||||
# CHANGE 2026-03-22 | 启动时清理本机僵尸任务(上次非正常关闭遗留的 running 记录)
|
||||
task_executor.recover_stale()
|
||||
|
||||
# 启动
|
||||
task_queue.start()
|
||||
scheduler.start()
|
||||
|
||||
# CHANGE 2026-02-27 | 注册触发器 job handler(核心业务模块)
|
||||
# CHANGE 2026-03-24 | dev-trace-log: 用 trace_job 包装 job handler,追踪后台任务执行
|
||||
from app.services.trigger_scheduler import register_job
|
||||
from app.services import task_generator, task_expiry, recall_detector, note_reclassifier
|
||||
from app.trace.job_wrapper import trace_job
|
||||
|
||||
register_job("task_generator", lambda **_kw: task_generator.run())
|
||||
register_job("task_expiry_check", lambda **_kw: task_expiry.run())
|
||||
register_job("recall_completion_check", recall_detector.run)
|
||||
register_job("note_reclassify_backfill", note_reclassifier.run)
|
||||
register_job("task_generator", trace_job("task_generator")(lambda **_kw: task_generator.run()))
|
||||
register_job("task_expiry_check", trace_job("task_expiry_check")(lambda **_kw: task_expiry.run()))
|
||||
register_job("recall_completion_check", trace_job("recall_completion_check")(recall_detector.run))
|
||||
register_job("note_reclassify_backfill", trace_job("note_reclassify_backfill")(note_reclassifier.run))
|
||||
|
||||
# CHANGE 2026-03-23 | 启动时检查定时任务是否今天执行过,打印提示
|
||||
from app.services.trigger_scheduler import check_startup_jobs
|
||||
try:
|
||||
pending_jobs = check_startup_jobs()
|
||||
if pending_jobs:
|
||||
_lines = ["╔══ 定时任务提醒 ══════════════════════════════════════╗"]
|
||||
for j in pending_jobs:
|
||||
_lines.append(f"║ ⚠ {j['description']}({j['job_name']})— {j['last_run_at']}")
|
||||
_lines.append("║ → 请在管理后台「定时任务」页面手动执行")
|
||||
_lines.append("╚══════════════════════════════════════════════════════╝")
|
||||
print("\n".join(_lines), flush=True)
|
||||
else:
|
||||
print("✓ 所有定时任务今天已执行过", flush=True)
|
||||
except Exception:
|
||||
import logging as _log
|
||||
_log.getLogger(__name__).warning("启动检查定时任务失败", exc_info=True)
|
||||
|
||||
# CHANGE 2026-03-10 | 注册 AI 事件处理器(消费/备注/任务分配 → AI 调用链)
|
||||
# CHANGE 2026-03-22 | P14 迁移:BailianClient → DashScopeClient + AIConfig + 防护层
|
||||
try:
|
||||
import os
|
||||
_api_key = os.environ.get("BAILIAN_API_KEY", "")
|
||||
_base_url = os.environ.get("BAILIAN_BASE_URL", "")
|
||||
_model = os.environ.get("BAILIAN_MODEL", "qwen-plus")
|
||||
if _api_key and _base_url:
|
||||
from app.ai.bailian_client import BailianClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.dispatcher import AIDispatcher, register_ai_handlers
|
||||
from app.ai.config import AIConfig
|
||||
from app.ai.dashscope_client import DashScopeClient
|
||||
from app.ai.cache_service import AICacheService
|
||||
from app.ai.conversation_service import ConversationService
|
||||
from app.ai.circuit_breaker import CircuitBreaker
|
||||
from app.ai.rate_limiter import RateLimiter
|
||||
from app.ai.budget_tracker import BudgetTracker
|
||||
from app.ai.run_log_service import AIRunLogService
|
||||
from app.ai.dispatcher import AIDispatcher, register_ai_handlers
|
||||
from app.database import get_connection
|
||||
|
||||
_bailian = BailianClient(api_key=_api_key, base_url=_base_url, model=_model)
|
||||
_dispatcher = AIDispatcher(_bailian, AICacheService(), ConversationService())
|
||||
register_ai_handlers(_dispatcher)
|
||||
_ai_config = AIConfig.from_env()
|
||||
_client = DashScopeClient(api_key=_ai_config.api_key, workspace_id=_ai_config.workspace_id)
|
||||
_run_log_svc = AIRunLogService(get_conn=get_connection)
|
||||
_dispatcher = AIDispatcher(
|
||||
client=_client,
|
||||
cache_svc=AICacheService(),
|
||||
conv_svc=ConversationService(),
|
||||
circuit_breaker=CircuitBreaker(),
|
||||
rate_limiter=RateLimiter(),
|
||||
budget_tracker=BudgetTracker(usage_provider=_run_log_svc),
|
||||
run_log_svc=_run_log_svc,
|
||||
config=_ai_config,
|
||||
)
|
||||
register_ai_handlers(_dispatcher)
|
||||
except Exception:
|
||||
import logging as _log
|
||||
_log.getLogger(__name__).warning("AI 事件处理器注册失败,AI 功能不可用", exc_info=True)
|
||||
|
||||
yield
|
||||
# 关闭
|
||||
# CHANGE 2026-03-22 | 优雅关闭:先终止所有运行中的子进程(3s 超时),再停调度和队列
|
||||
await task_executor.shutdown(timeout=3.0)
|
||||
await scheduler.stop()
|
||||
await task_queue.stop()
|
||||
|
||||
@@ -117,6 +161,10 @@ app.add_middleware(
|
||||
# CHANGE 2026-03-16 | RNS1.0 T0-1: 全局响应包装 + 异常处理器
|
||||
app.add_middleware(ResponseWrapperMiddleware)
|
||||
|
||||
# ---- 全链路追踪中间件(最后添加 = 最先执行 = 最外层) ----
|
||||
# CHANGE 2026-03-24 | dev-trace-log: TraceMiddleware 包裹所有中间件,仅拦截 /api/xcx/ 路由
|
||||
app.add_middleware(TraceMiddleware)
|
||||
|
||||
# ---- 全局异常处理器 ----
|
||||
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
|
||||
app.add_exception_handler(Exception, unhandled_exception_handler)
|
||||
@@ -135,6 +183,7 @@ app.include_router(wx_callback.router)
|
||||
app.include_router(member_retention_clue.router)
|
||||
app.include_router(ops_panel.router)
|
||||
app.include_router(xcx_auth.router)
|
||||
app.include_router(xcx_avatar.router)
|
||||
app.include_router(admin_applications.router)
|
||||
app.include_router(business_day.router)
|
||||
app.include_router(xcx_tasks.router)
|
||||
@@ -146,6 +195,21 @@ app.include_router(xcx_customers.router)
|
||||
app.include_router(xcx_coaches.router)
|
||||
app.include_router(xcx_board.router)
|
||||
app.include_router(xcx_config.router)
|
||||
app.include_router(tenant_auth.router)
|
||||
app.include_router(tenant_users.router)
|
||||
app.include_router(tenant_excel.router)
|
||||
app.include_router(tenant_clues.router)
|
||||
app.include_router(tenant_site_admins.router)
|
||||
app.include_router(admin_tenant_admins.router)
|
||||
app.include_router(admin_registry.router)
|
||||
app.include_router(internal_ai.router)
|
||||
app.include_router(internal_events.router)
|
||||
app.include_router(admin_ai.router)
|
||||
app.include_router(admin_dev_trace.router)
|
||||
app.include_router(trigger_jobs.router)
|
||||
app.include_router(admin_task_engine.router)
|
||||
app.include_router(admin_db_health.router)
|
||||
app.include_router(admin_triggers.router)
|
||||
|
||||
|
||||
@app.get("/health", tags=["系统"])
|
||||
|
||||
@@ -180,6 +180,10 @@ def _update_content_length(
|
||||
|
||||
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
"""HTTPException → { code: <status_code>, message: <detail> }"""
|
||||
# 记录 ERROR span(trace 未激活时静默跳过)
|
||||
from app.trace.error_handler import record_http_exception
|
||||
record_http_exception(exc)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"code": exc.status_code, "message": exc.detail},
|
||||
@@ -189,6 +193,10 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONRe
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""未捕获异常 → { code: 500, message: "Internal Server Error" }
|
||||
完整堆栈写入服务端日志。"""
|
||||
# 记录 ERROR span(trace 未激活时静默跳过)
|
||||
from app.trace.error_handler import record_unhandled_exception
|
||||
record_unhandled_exception(exc)
|
||||
|
||||
logger.exception("未捕获异常: %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
|
||||
294
apps/backend/app/routers/admin_ai.py
Normal file
294
apps/backend/app/routers/admin_ai.py
Normal file
@@ -0,0 +1,294 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
管理端 — AI 监控后台路由。
|
||||
|
||||
端点清单(13 个,全部需要 JWT + admin 角色):
|
||||
- GET /api/admin/ai/dashboard — 总览统计
|
||||
- GET /api/admin/ai/trigger-jobs — 调度任务分页列表
|
||||
- GET /api/admin/ai/trigger-jobs/{job_id} — 调度任务详情
|
||||
- POST /api/admin/ai/trigger-jobs/{job_id}/retry — 手动重跑
|
||||
- GET /api/admin/ai/run-logs — 调用记录分页列表
|
||||
- GET /api/admin/ai/run-logs/{log_id} — 调用记录详情
|
||||
- POST /api/admin/ai/cache/invalidate — 缓存失效
|
||||
- GET /api/admin/ai/budget — Token 预算
|
||||
- POST /api/admin/ai/batch-run — 创建批量执行(返回预估)
|
||||
- POST /api/admin/ai/batch-run/confirm — 确认批量执行
|
||||
- GET /api/admin/ai/alerts — 告警列表
|
||||
- POST /api/admin/ai/alerts/{log_id}/ack — 确认告警
|
||||
- POST /api/admin/ai/alerts/{log_id}/ignore — 忽略告警
|
||||
|
||||
需求: A1.1, A2.1, A2.4, A3.1, A4.1, A4.3, A5.1, A6.1, A7.1, A7.3, A8.1, A8.2, A8.3, A9.1, A9.2, A9.3
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.auth.dependencies import CurrentUser
|
||||
from app.middleware.permission import require_permission
|
||||
from app.schemas.admin_ai import (
|
||||
AlertActionResponse,
|
||||
AlertListResponse,
|
||||
BatchRunConfirm,
|
||||
BatchRunConfirmResponse,
|
||||
BatchRunEstimate,
|
||||
BatchRunRequest,
|
||||
BudgetResponse,
|
||||
CacheInvalidateRequest,
|
||||
CacheInvalidateResponse,
|
||||
DashboardResponse,
|
||||
RetryResponse,
|
||||
RunLogDetailResponse,
|
||||
RunLogListResponse,
|
||||
TriggerJobDetailResponse,
|
||||
TriggerJobListResponse,
|
||||
)
|
||||
from app.services.ai.admin_service import AdminAIService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/admin/ai", tags=["admin-ai"])
|
||||
|
||||
|
||||
# ── 模块级服务实例 ────────────────────────────────────────
|
||||
_admin_svc = AdminAIService()
|
||||
|
||||
|
||||
# ── 权限依赖 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _require_admin():
|
||||
"""
|
||||
管理端依赖:要求 JWT status=approved 且角色包含 site_admin 或 tenant_admin。
|
||||
"""
|
||||
|
||||
async def _dependency(
|
||||
user: CurrentUser = Depends(require_permission()),
|
||||
) -> CurrentUser:
|
||||
admin_roles = {"site_admin", "tenant_admin"}
|
||||
if not admin_roles.intersection(user.roles):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="需要管理员权限(site_admin 或 tenant_admin)",
|
||||
)
|
||||
return user
|
||||
|
||||
return _dependency
|
||||
|
||||
|
||||
# ── Dashboard ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/dashboard", response_model=DashboardResponse)
|
||||
async def get_dashboard(
|
||||
site_id: Optional[int] = Query(None, description="门店 ID 筛选"),
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> DashboardResponse:
|
||||
"""总览统计(支持 site_id 筛选)。"""
|
||||
data = await _admin_svc.get_dashboard(site_id=site_id)
|
||||
return DashboardResponse(**data)
|
||||
|
||||
|
||||
# ── 调度任务 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/trigger-jobs", response_model=TriggerJobListResponse)
|
||||
async def list_trigger_jobs(
|
||||
event_type: Optional[str] = Query(None),
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
site_id: Optional[int] = Query(None),
|
||||
date_from: Optional[str] = Query(None, description="起始日期 YYYY-MM-DD"),
|
||||
date_to: Optional[str] = Query(None, description="截止日期 YYYY-MM-DD"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> TriggerJobListResponse:
|
||||
"""调度任务分页列表。"""
|
||||
filters: dict = {}
|
||||
if event_type is not None:
|
||||
filters["event_type"] = event_type
|
||||
if status_filter is not None:
|
||||
filters["status"] = status_filter
|
||||
if site_id is not None:
|
||||
filters["site_id"] = site_id
|
||||
if date_from is not None:
|
||||
filters["date_from"] = date_from
|
||||
if date_to is not None:
|
||||
filters["date_to"] = date_to
|
||||
|
||||
data = await _admin_svc.list_trigger_jobs(filters, page=page, page_size=page_size)
|
||||
return TriggerJobListResponse(**data)
|
||||
|
||||
|
||||
@router.get("/trigger-jobs/{job_id}", response_model=TriggerJobDetailResponse)
|
||||
async def get_trigger_job(
|
||||
job_id: int,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> TriggerJobDetailResponse:
|
||||
"""调度任务详情。"""
|
||||
data = await _admin_svc.get_trigger_job(job_id)
|
||||
if data is None:
|
||||
raise HTTPException(status_code=404, detail="调度任务不存在")
|
||||
return TriggerJobDetailResponse(**data)
|
||||
|
||||
|
||||
@router.post("/trigger-jobs/{job_id}/retry", response_model=RetryResponse)
|
||||
async def retry_trigger_job(
|
||||
job_id: int,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> RetryResponse:
|
||||
"""手动重跑:创建新 trigger_job(is_forced=true),异步执行。"""
|
||||
try:
|
||||
new_job_id = await _admin_svc.retry_trigger_job(job_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return RetryResponse(trigger_job_id=new_job_id, status="pending")
|
||||
|
||||
|
||||
# ── 调用记录 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/run-logs", response_model=RunLogListResponse)
|
||||
async def list_run_logs(
|
||||
app_type: Optional[str] = Query(None),
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
trigger_type: Optional[str] = Query(None),
|
||||
site_id: Optional[int] = Query(None),
|
||||
date_from: Optional[str] = Query(None, description="起始日期 YYYY-MM-DD"),
|
||||
date_to: Optional[str] = Query(None, description="截止日期 YYYY-MM-DD"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> RunLogListResponse:
|
||||
"""调用记录分页列表。"""
|
||||
filters: dict = {}
|
||||
if app_type is not None:
|
||||
filters["app_type"] = app_type
|
||||
if status_filter is not None:
|
||||
filters["status"] = status_filter
|
||||
if trigger_type is not None:
|
||||
filters["trigger_type"] = trigger_type
|
||||
if site_id is not None:
|
||||
filters["site_id"] = site_id
|
||||
if date_from is not None:
|
||||
filters["date_from"] = date_from
|
||||
if date_to is not None:
|
||||
filters["date_to"] = date_to
|
||||
|
||||
data = await _admin_svc.list_run_logs(filters, page=page, page_size=page_size)
|
||||
return RunLogListResponse(**data)
|
||||
|
||||
|
||||
@router.get("/run-logs/{log_id}", response_model=RunLogDetailResponse)
|
||||
async def get_run_log(
|
||||
log_id: int,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> RunLogDetailResponse:
|
||||
"""调用记录详情(含完整 prompt/response/error,不脱敏)。"""
|
||||
data = await _admin_svc.get_run_log(log_id)
|
||||
if data is None:
|
||||
raise HTTPException(status_code=404, detail="调用记录不存在")
|
||||
return RunLogDetailResponse(**data)
|
||||
|
||||
|
||||
# ── 缓存管理 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/cache/invalidate", response_model=CacheInvalidateResponse)
|
||||
async def invalidate_cache(
|
||||
body: CacheInvalidateRequest,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> CacheInvalidateResponse:
|
||||
"""批量缓存失效:将匹配条件的 ai_cache.status 设为 invalidated。"""
|
||||
affected = await _admin_svc.invalidate_cache(
|
||||
site_id=body.site_id,
|
||||
app_type=body.app_type,
|
||||
member_id=body.member_id,
|
||||
)
|
||||
return CacheInvalidateResponse(affected_count=affected)
|
||||
|
||||
|
||||
# ── Token 预算 ────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/budget", response_model=BudgetResponse)
|
||||
async def get_budget(
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> BudgetResponse:
|
||||
"""Token 预算使用情况:日/月已用量、上限、百分比。"""
|
||||
data = await _admin_svc.get_budget()
|
||||
return BudgetResponse(**data)
|
||||
|
||||
|
||||
# ── 批量执行 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/batch-run", response_model=BatchRunEstimate)
|
||||
async def create_batch_run(
|
||||
body: BatchRunRequest,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> BatchRunEstimate:
|
||||
"""创建批量执行请求,返回预估(不立即执行)。"""
|
||||
data = await _admin_svc.estimate_batch(
|
||||
app_types=body.app_types,
|
||||
member_ids=body.member_ids,
|
||||
site_id=body.site_id,
|
||||
)
|
||||
return BatchRunEstimate(**data)
|
||||
|
||||
|
||||
@router.post("/batch-run/confirm", response_model=BatchRunConfirmResponse)
|
||||
async def confirm_batch_run(
|
||||
body: BatchRunConfirm,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> BatchRunConfirmResponse:
|
||||
"""确认批量执行,后台异步执行。"""
|
||||
try:
|
||||
await _admin_svc.confirm_batch(batch_id=body.batch_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return BatchRunConfirmResponse(status="started")
|
||||
|
||||
|
||||
# ── 告警管理 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/alerts", response_model=AlertListResponse)
|
||||
async def list_alerts(
|
||||
alert_status: Optional[str] = Query(None, description="pending / acknowledged / ignored"),
|
||||
site_id: Optional[int] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> AlertListResponse:
|
||||
"""告警列表(ai_run_logs WHERE status IN ('failed','timeout','circuit_open'))。"""
|
||||
data = await _admin_svc.list_alerts(
|
||||
alert_status=alert_status,
|
||||
site_id=site_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return AlertListResponse(**data)
|
||||
|
||||
|
||||
@router.post("/alerts/{log_id}/ack", response_model=AlertActionResponse)
|
||||
async def ack_alert(
|
||||
log_id: int,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> AlertActionResponse:
|
||||
"""确认告警:alert_status → acknowledged。"""
|
||||
new_status = await _admin_svc.ack_alert(log_id)
|
||||
return AlertActionResponse(id=log_id, alert_status=new_status)
|
||||
|
||||
|
||||
@router.post("/alerts/{log_id}/ignore", response_model=AlertActionResponse)
|
||||
async def ignore_alert(
|
||||
log_id: int,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> AlertActionResponse:
|
||||
"""忽略告警:alert_status → ignored。"""
|
||||
new_status = await _admin_svc.ignore_alert(log_id)
|
||||
return AlertActionResponse(id=log_id, alert_status=new_status)
|
||||
164
apps/backend/app/routers/admin_db_health.py
Normal file
164
apps/backend/app/routers/admin_db_health.py
Normal file
@@ -0,0 +1,164 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""管理端 — 数据库健康监控 API
|
||||
|
||||
提供 1 个端点:
|
||||
- GET /api/admin/db-health — 返回 4 个数据库的健康状态
|
||||
|
||||
遍历 etl_feiqiu / test_etl_feiqiu / zqyy_app / test_zqyy_app,
|
||||
对每个库执行诊断 SQL(连接池、大小、慢查询)。
|
||||
连接失败时返回 disconnected 状态,不抛出 HTTP 错误。
|
||||
|
||||
需求: 6.1, 6.2, 6.3, 6.4
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import psycopg2
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
from app.config import (
|
||||
DB_HOST,
|
||||
DB_PASSWORD,
|
||||
DB_PORT,
|
||||
DB_USER,
|
||||
ETL_DB_HOST,
|
||||
ETL_DB_PASSWORD,
|
||||
ETL_DB_PORT,
|
||||
ETL_DB_USER,
|
||||
)
|
||||
from app.schemas.admin_db_health import DbHealthItem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/admin/db-health", tags=["系统管理"])
|
||||
|
||||
# 4 个数据库的连接参数:业务库正式/测试 + ETL 库正式/测试
|
||||
DB_CONFIGS: list[dict] = [
|
||||
{
|
||||
"db_name": "zqyy_app",
|
||||
"host": DB_HOST,
|
||||
"port": DB_PORT,
|
||||
"user": DB_USER,
|
||||
"password": DB_PASSWORD,
|
||||
"dbname": "zqyy_app",
|
||||
},
|
||||
{
|
||||
"db_name": "test_zqyy_app",
|
||||
"host": DB_HOST,
|
||||
"port": DB_PORT,
|
||||
"user": DB_USER,
|
||||
"password": DB_PASSWORD,
|
||||
"dbname": "test_zqyy_app",
|
||||
},
|
||||
{
|
||||
"db_name": "etl_feiqiu",
|
||||
"host": ETL_DB_HOST,
|
||||
"port": ETL_DB_PORT,
|
||||
"user": ETL_DB_USER,
|
||||
"password": ETL_DB_PASSWORD,
|
||||
"dbname": "etl_feiqiu",
|
||||
},
|
||||
{
|
||||
"db_name": "test_etl_feiqiu",
|
||||
"host": ETL_DB_HOST,
|
||||
"port": ETL_DB_PORT,
|
||||
"user": ETL_DB_USER,
|
||||
"password": ETL_DB_PASSWORD,
|
||||
"dbname": "test_etl_feiqiu",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# 诊断 SQL:连接池状态
|
||||
_SQL_CONNECTIONS = """
|
||||
SELECT
|
||||
count(*) FILTER (WHERE state = 'active') AS active_connections,
|
||||
count(*) FILTER (WHERE state = 'idle') AS idle_connections
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = current_database();
|
||||
"""
|
||||
|
||||
# 诊断 SQL:数据库大小(MB)
|
||||
_SQL_DB_SIZE = """
|
||||
SELECT pg_database_size(current_database()) / (1024.0 * 1024.0) AS db_size_mb;
|
||||
"""
|
||||
|
||||
# 诊断 SQL:慢查询(最近 1 小时内执行时间超过 1 秒)
|
||||
_SQL_SLOW_QUERIES = """
|
||||
SELECT count(*) AS slow_query_count
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = current_database()
|
||||
AND state = 'active'
|
||||
AND query_start < now() - interval '1 second'
|
||||
AND query_start > now() - interval '1 hour';
|
||||
"""
|
||||
|
||||
|
||||
def _check_single_db(cfg: dict) -> DbHealthItem:
|
||||
"""对单个数据库执行诊断,连接失败时返回 disconnected。"""
|
||||
db_name = cfg["db_name"]
|
||||
try:
|
||||
# CHANGE 2026-03-29 | Windows GBK 环境下 psycopg2/libpq 构建连接字符串时
|
||||
# 会读取系统用户名/计算机名,含中文时触发 UnicodeDecodeError(0xd6 是 GBK 首字节)。
|
||||
# 用显式 DSN 字符串连接,避免 libpq 自动拼接时混入系统 locale 信息。
|
||||
dsn = (
|
||||
f"host={cfg['host']} port={cfg['port']} "
|
||||
f"dbname={cfg['dbname']} user={cfg['user']} "
|
||||
f"password={cfg['password']} "
|
||||
f"connect_timeout=5 client_encoding=UTF8 "
|
||||
f"application_name=neozqyy_health"
|
||||
)
|
||||
os.environ.setdefault("PGCLIENTENCODING", "UTF8")
|
||||
conn = psycopg2.connect(dsn)
|
||||
except Exception:
|
||||
logger.warning("数据库 %s 连接失败", db_name, exc_info=True)
|
||||
return DbHealthItem(db_name=db_name, status="disconnected")
|
||||
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 连接池状态
|
||||
cur.execute(_SQL_CONNECTIONS)
|
||||
row = cur.fetchone()
|
||||
active_connections = row[0] if row else 0
|
||||
idle_connections = row[1] if row else 0
|
||||
|
||||
# 数据库大小
|
||||
cur.execute(_SQL_DB_SIZE)
|
||||
row = cur.fetchone()
|
||||
db_size_mb = round(float(row[0]), 2) if row else 0.0
|
||||
|
||||
# 慢查询
|
||||
cur.execute(_SQL_SLOW_QUERIES)
|
||||
row = cur.fetchone()
|
||||
slow_query_count = row[0] if row else 0
|
||||
|
||||
return DbHealthItem(
|
||||
db_name=db_name,
|
||||
status="connected",
|
||||
active_connections=active_connections,
|
||||
idle_connections=idle_connections,
|
||||
db_size_mb=db_size_mb,
|
||||
slow_query_count=slow_query_count,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("数据库 %s 诊断 SQL 执行失败", db_name, exc_info=True)
|
||||
return DbHealthItem(db_name=db_name, status="disconnected")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("", response_model=list[DbHealthItem])
|
||||
async def get_db_health(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[DbHealthItem]:
|
||||
"""返回 4 个数据库的健康状态。
|
||||
|
||||
遍历 DB_CONFIGS 中的 4 个库,逐一执行诊断 SQL。
|
||||
连接失败时返回 disconnected 状态,不抛出 HTTP 错误。
|
||||
即使所有库都连接失败,仍返回 HTTP 200。
|
||||
"""
|
||||
return [_check_single_db(cfg) for cfg in DB_CONFIGS]
|
||||
374
apps/backend/app/routers/admin_dev_trace.py
Normal file
374
apps/backend/app/routers/admin_dev_trace.py
Normal file
@@ -0,0 +1,374 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
管理端 — 开发调试全链路日志路由。
|
||||
|
||||
端点清单(8 个,全部需要 JWT + admin 角色):
|
||||
- GET /api/admin/dev-trace/dates — 有日志数据的日期列表
|
||||
- GET /api/admin/dev-trace/requests — 按条件分页查询请求列表
|
||||
- GET /api/admin/dev-trace/request/{id} — 指定 request_id 的完整 trace
|
||||
- POST /api/admin/dev-trace/cleanup — 按日期范围手动清理日志
|
||||
- GET /api/admin/dev-trace/settings — 当前设置
|
||||
- PUT /api/admin/dev-trace/settings — 更新运行时设置
|
||||
- GET /api/admin/dev-trace/coverage — 最近一次覆盖率扫描结果
|
||||
- POST /api/admin/dev-trace/coverage/scan — 手动触发覆盖率扫描
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
from app.trace.cleanup import cleanup_date_range
|
||||
from app.trace.config import get_trace_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/admin/dev-trace", tags=["开发调试日志"])
|
||||
|
||||
# 日期目录名格式
|
||||
_DATE_FORMAT = "%Y-%m-%d"
|
||||
|
||||
|
||||
# ── 权限依赖 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _require_admin():
|
||||
"""管理端依赖:仅要求 JWT 认证通过。
|
||||
|
||||
dev-trace 是开发调试工具,不涉及业务数据,无需检查业务角色
|
||||
(site_admin / tenant_admin)。只要是 admin-web 的已认证用户即可访问。
|
||||
"""
|
||||
|
||||
async def _dependency(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> CurrentUser:
|
||||
return user
|
||||
|
||||
return _dependency
|
||||
|
||||
|
||||
# ── Pydantic 请求/响应模型 ────────────────────────────────
|
||||
|
||||
|
||||
class CleanupRequest(BaseModel):
|
||||
"""手动清理请求体。"""
|
||||
start_date: str
|
||||
end_date: str
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
"""运行时设置更新请求体(所有字段可选)。"""
|
||||
enabled: Optional[bool] = None
|
||||
retention_days: Optional[int] = None
|
||||
log_sql: Optional[bool] = None
|
||||
log_params: Optional[bool] = None
|
||||
|
||||
|
||||
# ── 辅助函数 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_base_dir() -> Path:
|
||||
"""获取日志根目录 Path 对象。"""
|
||||
return Path(get_trace_config().log_dir)
|
||||
|
||||
|
||||
def _is_date_dir(name: str) -> bool:
|
||||
"""判断目录名是否为 YYYY-MM-DD 格式。"""
|
||||
try:
|
||||
datetime.strptime(name, _DATE_FORMAT)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _read_jsonl_file(filepath: Path) -> list[dict[str, Any]]:
|
||||
"""逐行读取 .jsonl 文件,跳过解析失败的行。"""
|
||||
records: list[dict[str, Any]] = []
|
||||
if not filepath.exists():
|
||||
return records
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
records.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return records
|
||||
|
||||
|
||||
def _match_filter(record: dict[str, Any], **filters: Any) -> bool:
|
||||
"""检查单条 trace 记录是否满足所有筛选条件。"""
|
||||
# trace_type
|
||||
if filters.get("trace_type") and record.get("trace_type") != filters["trace_type"]:
|
||||
return False
|
||||
|
||||
# method
|
||||
if filters.get("method") and record.get("method", "").upper() != filters["method"].upper():
|
||||
return False
|
||||
|
||||
# path_contains
|
||||
if filters.get("path_contains") and filters["path_contains"].lower() not in (record.get("path") or "").lower():
|
||||
return False
|
||||
|
||||
# status_code
|
||||
if filters.get("status_code") is not None and record.get("status_code") != filters["status_code"]:
|
||||
return False
|
||||
|
||||
# min_duration
|
||||
if filters.get("min_duration") is not None and (record.get("total_duration_ms") or 0) < filters["min_duration"]:
|
||||
return False
|
||||
|
||||
# has_error
|
||||
if filters.get("has_error") is not None:
|
||||
has_err = record.get("error") is not None
|
||||
if filters["has_error"] != has_err:
|
||||
return False
|
||||
|
||||
# span_type — 检查 spans 列表中是否包含指定类型
|
||||
if filters.get("span_type"):
|
||||
span_types = {s.get("span_type") for s in record.get("spans", [])}
|
||||
if filters["span_type"] not in span_types:
|
||||
return False
|
||||
|
||||
# start_time / end_time — 基于 record.timestamp
|
||||
ts_str = record.get("timestamp", "")
|
||||
if ts_str and (filters.get("start_time") or filters.get("end_time")):
|
||||
try:
|
||||
rec_dt = datetime.fromisoformat(ts_str)
|
||||
rec_time = rec_dt.time()
|
||||
if filters.get("start_time") and rec_time < filters["start_time"]:
|
||||
return False
|
||||
if filters.get("end_time") and rec_time > filters["end_time"]:
|
||||
return False
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ── 1. GET /dates — 有日志数据的日期列表 ─────────────────
|
||||
|
||||
|
||||
@router.get("/dates")
|
||||
async def list_dates(
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> dict[str, list[str]]:
|
||||
"""返回有日志数据的日期列表(降序排列)。"""
|
||||
base = _get_base_dir()
|
||||
if not base.exists():
|
||||
return {"dates": []}
|
||||
|
||||
dates = sorted(
|
||||
[d.name for d in base.iterdir() if d.is_dir() and _is_date_dir(d.name)],
|
||||
reverse=True,
|
||||
)
|
||||
return {"dates": dates}
|
||||
|
||||
|
||||
# ── 2. GET /requests — 按条件分页查询请求列表 ─────────────
|
||||
|
||||
|
||||
@router.get("/requests")
|
||||
async def list_requests(
|
||||
date: str = Query(..., description="日期,格式 YYYY-MM-DD"),
|
||||
start_time: Optional[str] = Query(None, description="起始时间 HH:MM:SS"),
|
||||
end_time: Optional[str] = Query(None, description="结束时间 HH:MM:SS"),
|
||||
trace_type: Optional[str] = Query(None, description="trace 类型:http/sse/ws/job"),
|
||||
method: Optional[str] = Query(None, description="HTTP 方法:GET/POST/PUT/DELETE"),
|
||||
path_contains: Optional[str] = Query(None, description="路径关键词"),
|
||||
status_code: Optional[int] = Query(None, description="HTTP 状态码"),
|
||||
min_duration: Optional[float] = Query(None, description="最小耗时(ms)"),
|
||||
has_error: Optional[bool] = Query(None, description="是否有错误"),
|
||||
span_type: Optional[str] = Query(None, description="包含的 span 类型"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(50, ge=1, le=200, description="每页条数"),
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> dict[str, Any]:
|
||||
"""按条件分页查询指定日期的请求列表。"""
|
||||
# 解析时间参数
|
||||
parsed_start = None
|
||||
parsed_end = None
|
||||
if start_time:
|
||||
try:
|
||||
parsed_start = datetime.strptime(start_time, "%H:%M:%S").time()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="start_time 格式无效,应为 HH:MM:SS")
|
||||
if end_time:
|
||||
try:
|
||||
parsed_end = datetime.strptime(end_time, "%H:%M:%S").time()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="end_time 格式无效,应为 HH:MM:SS")
|
||||
|
||||
# 读取指定日期目录下所有 .jsonl 文件
|
||||
date_dir = _get_base_dir() / date
|
||||
if not date_dir.exists() or not date_dir.is_dir():
|
||||
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||
|
||||
all_records: list[dict[str, Any]] = []
|
||||
for f in sorted(date_dir.glob("*.jsonl")):
|
||||
all_records.extend(_read_jsonl_file(f))
|
||||
|
||||
# 过滤
|
||||
filtered = [
|
||||
r for r in all_records
|
||||
if _match_filter(
|
||||
r,
|
||||
trace_type=trace_type,
|
||||
method=method,
|
||||
path_contains=path_contains,
|
||||
status_code=status_code,
|
||||
min_duration=min_duration,
|
||||
has_error=has_error,
|
||||
span_type=span_type,
|
||||
start_time=parsed_start,
|
||||
end_time=parsed_end,
|
||||
)
|
||||
]
|
||||
|
||||
# 按时间降序排列
|
||||
filtered.sort(key=lambda r: r.get("timestamp", ""), reverse=True)
|
||||
|
||||
total = len(filtered)
|
||||
start_idx = (page - 1) * page_size
|
||||
items = filtered[start_idx : start_idx + page_size]
|
||||
|
||||
# 列表项不返回完整 spans,只返回摘要字段
|
||||
summary_items = []
|
||||
for item in items:
|
||||
summary_items.append({
|
||||
"request_id": item.get("request_id"),
|
||||
"trace_type": item.get("trace_type"),
|
||||
"timestamp": item.get("timestamp"),
|
||||
"method": item.get("method"),
|
||||
"path": item.get("path"),
|
||||
"status_code": item.get("status_code"),
|
||||
"total_duration_ms": item.get("total_duration_ms"),
|
||||
"user_id": item.get("user_id"),
|
||||
"site_id": item.get("site_id"),
|
||||
"db_query_count": item.get("db_query_count"),
|
||||
"db_total_ms": item.get("db_total_ms"),
|
||||
"error": item.get("error"),
|
||||
"span_count": len(item.get("spans", [])),
|
||||
})
|
||||
|
||||
return {"items": summary_items, "total": total, "page": page, "page_size": page_size}
|
||||
|
||||
|
||||
# ── 3. GET /request/{request_id} — 完整 trace 记录 ──────
|
||||
|
||||
|
||||
@router.get("/request/{request_id}")
|
||||
async def get_request_detail(
|
||||
request_id: str,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> dict[str, Any]:
|
||||
"""返回指定 request_id 的完整 trace 记录(含所有 spans)。"""
|
||||
base = _get_base_dir()
|
||||
if not base.exists():
|
||||
raise HTTPException(status_code=404, detail="未找到该 request_id 的 trace 记录")
|
||||
|
||||
# 搜索所有日期目录下的 .jsonl 文件
|
||||
for date_dir in sorted(base.iterdir(), reverse=True):
|
||||
if not date_dir.is_dir() or not _is_date_dir(date_dir.name):
|
||||
continue
|
||||
for f in date_dir.glob("*.jsonl"):
|
||||
for record in _read_jsonl_file(f):
|
||||
if record.get("request_id") == request_id:
|
||||
return record
|
||||
|
||||
raise HTTPException(status_code=404, detail="未找到该 request_id 的 trace 记录")
|
||||
|
||||
|
||||
# ── 4. POST /cleanup — 按日期范围手动清理 ────────────────
|
||||
|
||||
|
||||
@router.post("/cleanup")
|
||||
async def cleanup_logs(
|
||||
body: CleanupRequest,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> dict[str, Any]:
|
||||
"""按日期范围手动清理日志目录。"""
|
||||
# 校验日期格式
|
||||
try:
|
||||
datetime.strptime(body.start_date, _DATE_FORMAT)
|
||||
datetime.strptime(body.end_date, _DATE_FORMAT)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="日期格式无效,应为 YYYY-MM-DD")
|
||||
|
||||
if body.start_date > body.end_date:
|
||||
raise HTTPException(status_code=400, detail="start_date 不能晚于 end_date")
|
||||
|
||||
result = cleanup_date_range(body.start_date, body.end_date)
|
||||
return {
|
||||
"deleted_dates": result["deleted_dirs"],
|
||||
"deleted_files": result["deleted_count"],
|
||||
}
|
||||
|
||||
|
||||
# ── 5. GET /settings — 当前设置 ──────────────────────────
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_settings(
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> dict[str, Any]:
|
||||
"""返回当前 trace 运行时设置。"""
|
||||
return get_trace_config().get_settings()
|
||||
|
||||
|
||||
# ── 6. PUT /settings — 更新运行时设置 ────────────────────
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
async def update_settings(
|
||||
body: SettingsUpdate,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> dict[str, Any]:
|
||||
"""更新 trace 运行时设置(不需重启,重启后回退到 .env 值)。"""
|
||||
cfg = get_trace_config()
|
||||
cfg.update_settings(
|
||||
enabled=body.enabled,
|
||||
retention_days=body.retention_days,
|
||||
log_sql=body.log_sql,
|
||||
log_params=body.log_params,
|
||||
)
|
||||
return cfg.get_settings()
|
||||
|
||||
|
||||
# ── 7. GET /coverage — 最近一次覆盖率扫描结果 ────────────
|
||||
|
||||
|
||||
@router.get("/coverage")
|
||||
async def get_coverage(
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> dict[str, Any]:
|
||||
"""返回最近一次覆盖率扫描结果(缓存)。"""
|
||||
from app.trace.coverage import get_cached_coverage, run_coverage_scan
|
||||
|
||||
result = get_cached_coverage()
|
||||
if result is None:
|
||||
# 首次访问时自动扫描一次
|
||||
result = run_coverage_scan()
|
||||
return result
|
||||
|
||||
|
||||
# ── 8. POST /coverage/scan — 手动触发覆盖率扫描 ──────────
|
||||
|
||||
|
||||
@router.post("/coverage/scan")
|
||||
async def trigger_coverage_scan(
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> dict[str, Any]:
|
||||
"""手动触发覆盖率扫描,返回最新结果。"""
|
||||
from app.trace.coverage import run_coverage_scan
|
||||
|
||||
return run_coverage_scan()
|
||||
673
apps/backend/app/routers/admin_registry.py
Normal file
673
apps/backend/app/routers/admin_registry.py
Normal file
@@ -0,0 +1,673 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
管理端路由 — 注册体系(连接器/租户/店铺/简写ID/店铺同步)。
|
||||
|
||||
端点清单:
|
||||
- GET /api/admin/tenants — 所有活跃租户列表
|
||||
- GET /api/admin/tenants/{tenant_id}/sites — 指定租户下所有活跃店铺
|
||||
- PUT /api/admin/sites/{site_id}/site-code — 设置/修改简写ID
|
||||
- GET /api/admin/sites/{site_id}/site-code-history — 简写ID 变更历史
|
||||
- POST /api/admin/sites/sync — 手动触发店铺同步
|
||||
- POST /api/admin/sites/sync/internal — 内部 API:ETL DWD 完成后触发同步(无认证,隐藏)
|
||||
|
||||
除 /sites/sync/internal 外,所有端点要求 JWT + site_admin 或 tenant_admin 角色。
|
||||
|
||||
需求: A2.1, A2.2, A2.4, A2.5, A3.1, A3.2, A3.3, A3.4, A5.1, A5.2, A5.3, A5.4
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
import psycopg2
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from psycopg2.extensions import connection as PgConnection
|
||||
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
from app.config import (
|
||||
ETL_DB_HOST,
|
||||
ETL_DB_NAME,
|
||||
ETL_DB_PASSWORD,
|
||||
ETL_DB_PORT,
|
||||
ETL_DB_USER,
|
||||
)
|
||||
from app.database import get_connection
|
||||
from app.schemas.admin_registry import (
|
||||
CreateSiteRequest,
|
||||
SiteCodeHistoryItem,
|
||||
SiteCodeResult,
|
||||
SiteItem,
|
||||
SiteSyncResult,
|
||||
TenantItem,
|
||||
UpdateSiteCodeRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin-registry"])
|
||||
|
||||
# 简写ID 格式:前 3 位字母/数字 + 后 3 位数字(共 6 位)
|
||||
_SITE_CODE_PATTERN = re.compile(r"^[A-Z0-9]{3}\d{3}$")
|
||||
|
||||
|
||||
# ── ETL 库直连(无 RLS,管理端同步专用) ─────────────────
|
||||
|
||||
|
||||
def _get_etl_admin_connection() -> PgConnection:
|
||||
"""获取 ETL 库只读连接(无 RLS 隔离),用于管理端跨站点同步。
|
||||
|
||||
与 database.get_etl_readonly_connection 不同:
|
||||
- 不设置 app.current_site_id(需要读取所有站点数据)
|
||||
- 仍设置 read_only 防止误写
|
||||
"""
|
||||
conn = psycopg2.connect(
|
||||
host=ETL_DB_HOST,
|
||||
port=ETL_DB_PORT,
|
||||
user=ETL_DB_USER,
|
||||
password=ETL_DB_PASSWORD,
|
||||
dbname=ETL_DB_NAME,
|
||||
)
|
||||
try:
|
||||
conn.autocommit = False
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SET default_transaction_read_only = on")
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.close()
|
||||
raise
|
||||
return conn
|
||||
|
||||
|
||||
# ── 店铺同步核心逻辑 ─────────────────────────────────────
|
||||
|
||||
|
||||
def sync_sites_from_etl() -> SiteSyncResult:
|
||||
"""从 ETL 库 dwd.dim_site 同步店铺到 biz.sites。
|
||||
|
||||
逻辑:
|
||||
1. 读取 dwd.dim_site(scd2_is_current=1)获取当前有效店铺
|
||||
2. 对比 biz.sites:
|
||||
- 新 site_id → INSERT(site_code 留空,tenant_id 通过 dim_site.tenant_id 映射 biz.tenants)
|
||||
- 已有 site_id 且 shop_name/site_label 变更 → UPDATE
|
||||
3. 不删除已有记录
|
||||
|
||||
需求: A5.1, A5.2
|
||||
"""
|
||||
# 1. 从 ETL 库读取当前有效店铺
|
||||
etl_conn = _get_etl_admin_connection()
|
||||
try:
|
||||
with etl_conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT site_id, tenant_id, shop_name, site_label
|
||||
FROM dwd.dim_site
|
||||
WHERE scd2_is_current = 1
|
||||
"""
|
||||
)
|
||||
etl_sites = cur.fetchall()
|
||||
finally:
|
||||
etl_conn.close()
|
||||
|
||||
if not etl_sites:
|
||||
return SiteSyncResult(inserted=0, updated=0)
|
||||
|
||||
# 2. 在 app 库中执行对比和写入
|
||||
app_conn = get_connection()
|
||||
inserted = 0
|
||||
updated = 0
|
||||
try:
|
||||
with app_conn.cursor() as cur:
|
||||
# 构建 tenant_id → biz.tenants.id 映射
|
||||
cur.execute("SELECT tenant_id, id FROM biz.tenants WHERE is_active = true")
|
||||
tenant_map: dict[int, int] = {row[0]: row[1] for row in cur.fetchall()}
|
||||
|
||||
# 获取 biz.sites 现有数据(site_id → (biz_id, site_name, site_label))
|
||||
cur.execute(
|
||||
"SELECT site_id, id, site_name, site_label FROM biz.sites"
|
||||
)
|
||||
existing: dict[int, tuple[int, str | None, str | None]] = {
|
||||
row[0]: (row[1], row[2], row[3]) for row in cur.fetchall()
|
||||
}
|
||||
|
||||
for etl_site_id, etl_tenant_id, etl_shop_name, etl_site_label in etl_sites:
|
||||
biz_tenant_id = tenant_map.get(etl_tenant_id)
|
||||
if biz_tenant_id is None:
|
||||
# 租户未注册,跳过
|
||||
logger.warning(
|
||||
"同步跳过: site_id=%s 的 tenant_id=%s 在 biz.tenants 中不存在",
|
||||
etl_site_id, etl_tenant_id,
|
||||
)
|
||||
continue
|
||||
|
||||
if etl_site_id not in existing:
|
||||
# 新增店铺:site_code 留空
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.sites (tenant_id, site_id, site_name, site_label)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(biz_tenant_id, etl_site_id, etl_shop_name, etl_site_label),
|
||||
)
|
||||
inserted += 1
|
||||
else:
|
||||
# 已有店铺:检查名称/标签是否变更
|
||||
_, cur_name, cur_label = existing[etl_site_id]
|
||||
if cur_name != etl_shop_name or cur_label != etl_site_label:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.sites
|
||||
SET site_name = %s, site_label = %s, updated_at = NOW()
|
||||
WHERE site_id = %s
|
||||
""",
|
||||
(etl_shop_name, etl_site_label, etl_site_id),
|
||||
)
|
||||
updated += 1
|
||||
|
||||
app_conn.commit()
|
||||
except Exception:
|
||||
app_conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
app_conn.close()
|
||||
|
||||
logger.info("店铺同步完成: 新增 %d, 更新 %d", inserted, updated)
|
||||
return SiteSyncResult(inserted=inserted, updated=updated)
|
||||
|
||||
|
||||
# ── 管理端权限依赖 ──────────────────────────────────────
|
||||
|
||||
|
||||
def _require_admin():
|
||||
"""管理端依赖:要求 JWT 中角色包含 site_admin 或 tenant_admin。"""
|
||||
|
||||
async def _dependency(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> CurrentUser:
|
||||
admin_roles = {"site_admin", "tenant_admin"}
|
||||
if not admin_roles.intersection(user.roles):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="需要管理员权限(site_admin 或 tenant_admin)",
|
||||
)
|
||||
return user
|
||||
|
||||
return _dependency
|
||||
|
||||
|
||||
# ── GET /api/admin/tenants ────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/tenants")
|
||||
async def list_tenants(
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> list[TenantItem]:
|
||||
"""
|
||||
所有活跃租户列表(含连接器名称)。
|
||||
|
||||
JOIN biz.connectors 获取 connector_name。
|
||||
需求 A2.1
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT t.id, t.tenant_id, t.tenant_name,
|
||||
c.display_name AS connector_name, t.is_active
|
||||
FROM biz.tenants t
|
||||
JOIN biz.connectors c ON c.id = t.connector_id
|
||||
WHERE t.is_active = true
|
||||
ORDER BY t.id
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return [
|
||||
TenantItem(
|
||||
id=r[0],
|
||||
tenant_id=r[1],
|
||||
tenant_name=r[2],
|
||||
connector_name=r[3],
|
||||
is_active=r[4],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ── GET /api/admin/tenants/{tenant_id}/sites ──────────────
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/sites")
|
||||
async def list_tenant_sites(
|
||||
tenant_id: int,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> list[SiteItem]:
|
||||
"""
|
||||
指定租户下所有活跃店铺。
|
||||
|
||||
tenant_id 参数支持两种格式:
|
||||
- 上游系统租户 ID(BIGINT,如 2790683160709957)
|
||||
- 内部主键(SERIAL,如 1, 2, 3...)
|
||||
自动判断:> 10000 视为上游 ID,否则视为内部 PK。
|
||||
|
||||
需求 A2.2
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# CHANGE 2026-03-22 | Prompt: 管辖门店下拉为空 | 兼容上游 tenant_id 和内部 PK
|
||||
# 自动判断:上游 ID 是 BIGINT(远大于内部 SERIAL),阈值 10000 足够区分
|
||||
if tenant_id > 10000:
|
||||
cur.execute(
|
||||
"SELECT id FROM biz.tenants WHERE tenant_id = %s AND is_active = true",
|
||||
(tenant_id,),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT id FROM biz.tenants WHERE id = %s AND is_active = true",
|
||||
(tenant_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="租户不存在",
|
||||
)
|
||||
internal_tenant_id = row[0]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, site_id, site_name, site_code, site_label, is_active
|
||||
FROM biz.sites
|
||||
WHERE tenant_id = %s AND is_active = true
|
||||
ORDER BY site_id
|
||||
""",
|
||||
(internal_tenant_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return [
|
||||
SiteItem(
|
||||
id=r[0],
|
||||
site_id=r[1],
|
||||
site_name=r[2],
|
||||
site_code=r[3],
|
||||
site_label=r[4],
|
||||
is_active=r[5],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ── PUT /api/admin/sites/{site_id}/site-code ──────────────
|
||||
|
||||
|
||||
@router.put("/sites/{site_id}/site-code")
|
||||
async def update_site_code(
|
||||
site_id: int,
|
||||
body: UpdateSiteCodeRequest,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> SiteCodeResult:
|
||||
"""
|
||||
设置/修改店铺简写ID,事务内执行历史记录管理。
|
||||
|
||||
校验规则:
|
||||
- 格式:6 位,前 3 位字母/数字 + 后 3 位数字,统一大写
|
||||
- 全局唯一:biz.sites.site_code + biz.site_code_history.site_code
|
||||
|
||||
事务步骤:
|
||||
a. 旧 code 在 site_code_history 中标记 is_current=false, retired_at=NOW()
|
||||
b. 新 code 插入 site_code_history(is_current=true)
|
||||
c. 更新 biz.sites.site_code
|
||||
d. 检查旧 code 是否有未审核申请引用,无引用则从 history 中删除旧记录
|
||||
|
||||
需求 A2.4, A3.1, A3.2, A3.3, A3.4
|
||||
"""
|
||||
new_code = body.new_code.strip().upper()
|
||||
|
||||
# ── 格式校验 ──
|
||||
if not _SITE_CODE_PATTERN.match(new_code):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="简写ID 格式错误,需 6 位(3+3 模式:前 3 位字母/数字 + 后 3 位数字)",
|
||||
)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# ── 校验店铺存在 ──
|
||||
cur.execute(
|
||||
"SELECT site_id, site_code FROM biz.sites WHERE id = %s",
|
||||
(site_id,),
|
||||
)
|
||||
site_row = cur.fetchone()
|
||||
if site_row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="店铺不存在",
|
||||
)
|
||||
db_site_id = site_row[0] # biz.sites.site_id(上游 ID)
|
||||
old_code = site_row[1]
|
||||
|
||||
# ── 全局唯一性校验(biz.sites + biz.site_code_history) ──
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM biz.sites
|
||||
WHERE site_code = %s AND id != %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(new_code, site_id),
|
||||
)
|
||||
if cur.fetchone():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"简写ID '{new_code}' 已被使用",
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM biz.site_code_history
|
||||
WHERE site_code = %s AND site_id != %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(new_code, db_site_id),
|
||||
)
|
||||
if cur.fetchone():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"简写ID '{new_code}' 已被使用",
|
||||
)
|
||||
|
||||
# ── 事务内执行变更 ──
|
||||
|
||||
# a. 旧 code 标记 retired
|
||||
history_cleaned = False
|
||||
if old_code:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.site_code_history
|
||||
SET is_current = false, retired_at = NOW()
|
||||
WHERE site_id = %s AND site_code = %s AND is_current = true
|
||||
""",
|
||||
(db_site_id, old_code),
|
||||
)
|
||||
|
||||
# b. 新 code 插入 history
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.site_code_history (site_id, site_code, is_current)
|
||||
VALUES (%s, %s, true)
|
||||
""",
|
||||
(db_site_id, new_code),
|
||||
)
|
||||
|
||||
# c. 更新 biz.sites.site_code
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.sites SET site_code = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(new_code, site_id),
|
||||
)
|
||||
|
||||
# d. 检查旧 code 是否有未审核申请引用,无引用则清理历史
|
||||
if old_code:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM auth.user_applications
|
||||
WHERE site_code = %s AND status = 'pending'
|
||||
LIMIT 1
|
||||
""",
|
||||
(old_code,),
|
||||
)
|
||||
has_pending = cur.fetchone() is not None
|
||||
|
||||
if not has_pending:
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM biz.site_code_history
|
||||
WHERE site_id = %s AND site_code = %s AND is_current = false
|
||||
""",
|
||||
(db_site_id, old_code),
|
||||
)
|
||||
history_cleaned = True
|
||||
|
||||
conn.commit()
|
||||
except HTTPException:
|
||||
conn.rollback()
|
||||
raise
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return SiteCodeResult(
|
||||
site_id=db_site_id,
|
||||
old_code=old_code,
|
||||
new_code=new_code,
|
||||
history_cleaned=history_cleaned,
|
||||
)
|
||||
|
||||
|
||||
# ── GET /api/admin/sites/{site_id}/site-code-history ──────
|
||||
|
||||
|
||||
@router.get("/sites/{site_id}/site-code-history")
|
||||
async def get_site_code_history(
|
||||
site_id: int,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> list[SiteCodeHistoryItem]:
|
||||
"""
|
||||
查看简写ID 变更历史。
|
||||
|
||||
需求 A2.5
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 校验店铺存在,获取上游 site_id
|
||||
cur.execute(
|
||||
"SELECT site_id FROM biz.sites WHERE id = %s",
|
||||
(site_id,),
|
||||
)
|
||||
site_row = cur.fetchone()
|
||||
if site_row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="店铺不存在",
|
||||
)
|
||||
db_site_id = site_row[0]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, site_code, is_current, created_at, retired_at
|
||||
FROM biz.site_code_history
|
||||
WHERE site_id = %s
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(db_site_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return [
|
||||
SiteCodeHistoryItem(
|
||||
id=r[0],
|
||||
site_code=r[1],
|
||||
is_current=r[2],
|
||||
created_at=r[3],
|
||||
retired_at=r[4],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ── POST /api/admin/sites/sync ────────────────────────────
|
||||
|
||||
|
||||
@router.post("/sites/sync")
|
||||
async def sync_sites(
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
) -> SiteSyncResult:
|
||||
"""
|
||||
手动触发店铺同步:从 ETL 库 dwd.dim_site 同步到 biz.sites。
|
||||
|
||||
返回同步结果(新增数/更新数)。
|
||||
|
||||
需求 A5.3
|
||||
"""
|
||||
return sync_sites_from_etl()
|
||||
|
||||
# ── POST /api/admin/sites/sync/internal ───────────────────
|
||||
|
||||
|
||||
@router.post("/sites/sync/internal", include_in_schema=False)
|
||||
async def sync_sites_internal() -> SiteSyncResult:
|
||||
"""内部 API:ETL DWD 完成后触发店铺同步。
|
||||
|
||||
不需要 JWT 认证(内部调用),通过 include_in_schema=False 隐藏。
|
||||
后续可添加 API key 或 IP 白名单认证。
|
||||
|
||||
需求 A5.4
|
||||
"""
|
||||
return sync_sites_from_etl()
|
||||
|
||||
|
||||
# ── POST /api/admin/sites(测试功能:手动创建店铺) ────────
|
||||
|
||||
|
||||
@router.post("/sites", status_code=status.HTTP_201_CREATED)
|
||||
async def create_site(
|
||||
body: CreateSiteRequest,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
):
|
||||
"""
|
||||
手动创建店铺(测试功能)。
|
||||
|
||||
向 biz.sites 插入一条记录,可选指定 site_code。
|
||||
site_id 和 site_code 需全局唯一,冲突返回 409。
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 校验 tenant_id 存在
|
||||
cur.execute(
|
||||
"SELECT id FROM biz.tenants WHERE tenant_id = %s AND is_active = true",
|
||||
(body.tenant_id,),
|
||||
)
|
||||
tenant_row = cur.fetchone()
|
||||
if tenant_row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="租户不存在",
|
||||
)
|
||||
internal_tenant_id = tenant_row[0]
|
||||
|
||||
# site_code 格式校验(如果提供)
|
||||
site_code = None
|
||||
if body.site_code:
|
||||
site_code = body.site_code.strip().upper()
|
||||
if not _SITE_CODE_PATTERN.match(site_code):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="简写ID 格式错误,需 6 位(3+3 格式)",
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.sites (tenant_id, site_id, site_name, site_code)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING id, site_id, site_name, site_code
|
||||
""",
|
||||
(internal_tenant_id, body.site_id, body.site_name, site_code),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
# 如果有 site_code,同步插入 history
|
||||
if site_code:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.site_code_history (site_id, site_code, is_current)
|
||||
VALUES (%s, %s, true)
|
||||
""",
|
||||
(body.site_id, site_code),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except HTTPException:
|
||||
conn.rollback()
|
||||
raise
|
||||
except psycopg2.errors.UniqueViolation as e:
|
||||
conn.rollback()
|
||||
detail = str(e)
|
||||
if "site_id" in detail:
|
||||
raise HTTPException(status_code=409, detail="site_id 已存在")
|
||||
if "site_code" in detail:
|
||||
raise HTTPException(status_code=409, detail="简写ID 已被占用")
|
||||
raise HTTPException(status_code=409, detail="唯一约束冲突")
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"id": row[0], "siteId": row[1], "siteName": row[2], "siteCode": row[3]}
|
||||
|
||||
|
||||
# ── DELETE /api/admin/sites/{site_id}(测试功能:删除店铺) ─
|
||||
|
||||
|
||||
@router.delete("/sites/{site_id}")
|
||||
async def delete_site(
|
||||
site_id: int,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
):
|
||||
"""
|
||||
删除店铺(测试功能,硬删除)。
|
||||
|
||||
同时清理 site_code_history 中的关联记录。
|
||||
site_id 参数为 biz.sites.id(内部主键)。
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 获取上游 site_id 用于清理 history
|
||||
cur.execute(
|
||||
"SELECT site_id FROM biz.sites WHERE id = %s",
|
||||
(site_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="店铺不存在",
|
||||
)
|
||||
upstream_site_id = row[0]
|
||||
|
||||
# 清理 site_code_history
|
||||
cur.execute(
|
||||
"DELETE FROM biz.site_code_history WHERE site_id = %s",
|
||||
(upstream_site_id,),
|
||||
)
|
||||
|
||||
# 删除店铺
|
||||
cur.execute("DELETE FROM biz.sites WHERE id = %s", (site_id,))
|
||||
|
||||
conn.commit()
|
||||
except HTTPException:
|
||||
conn.rollback()
|
||||
raise
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"id": site_id}
|
||||
640
apps/backend/app/routers/admin_task_engine.py
Normal file
640
apps/backend/app/routers/admin_task_engine.py
Normal file
@@ -0,0 +1,640 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""P18 任务引擎运营看板 API
|
||||
|
||||
提供转移日志查看、待审核任务管理、参数配置等端点。
|
||||
所有端点需要 JWT 认证;写操作仅限 super_admin。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
from app.database import get_connection
|
||||
from app.schemas.admin_task_engine import (
|
||||
CandidateAssistant,
|
||||
CandidateListResponse,
|
||||
CloseRequest,
|
||||
CloseResponse,
|
||||
ConfigParam,
|
||||
ConfigParamCreate,
|
||||
ConfigParamList,
|
||||
ConfigParamResponse,
|
||||
ConfigParamUpdate,
|
||||
PendingReviewItem,
|
||||
PendingReviewPage,
|
||||
ReassignRequest,
|
||||
ReassignResponse,
|
||||
TransferLogItem,
|
||||
TransferLogPage,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/admin/task-engine", tags=["任务引擎管理"])
|
||||
|
||||
# ---- 任务类型中文映射 ----
|
||||
TASK_TYPE_LABELS = {
|
||||
"high_priority_recall": "高优先召回",
|
||||
"priority_recall": "优先召回",
|
||||
"follow_up_visit": "客户回访",
|
||||
"relationship_building": "关系构建",
|
||||
}
|
||||
|
||||
|
||||
# ---- 权限辅助函数 ----
|
||||
|
||||
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_site_id(user: CurrentUser, query_site_id: int | None) -> int | None:
|
||||
"""读操作门店过滤:门店管理员强制按自身 site_id 过滤。"""
|
||||
if "super_admin" in user.roles:
|
||||
return query_site_id
|
||||
return user.site_id
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 1. 转移日志
|
||||
# =====================================================================
|
||||
|
||||
@router.get("/transfer-log", response_model=TransferLogPage)
|
||||
async def list_transfer_logs(
|
||||
site_id: int | None = Query(None, description="门店 ID"),
|
||||
from_date: date | None = Query(None, description="起始日期"),
|
||||
to_date: date | None = Query(None, description="截止日期"),
|
||||
assistant_id: int | None = Query(None, description="助教 ID"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> TransferLogPage:
|
||||
"""转移日志分页列表。"""
|
||||
effective_site_id = _filter_site_id(user, site_id)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
conditions = []
|
||||
params: list = []
|
||||
|
||||
if effective_site_id is not None:
|
||||
conditions.append("t.site_id = %s")
|
||||
params.append(effective_site_id)
|
||||
if from_date is not None:
|
||||
conditions.append("t.created_at >= %s")
|
||||
params.append(from_date)
|
||||
if to_date is not None:
|
||||
conditions.append("t.created_at < %s::date + interval '1 day'")
|
||||
params.append(to_date)
|
||||
if assistant_id is not None:
|
||||
conditions.append("(t.from_assistant_id = %s OR t.to_assistant_id = %s)")
|
||||
params.extend([assistant_id, assistant_id])
|
||||
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
|
||||
# 总数
|
||||
cur.execute(
|
||||
f"SELECT count(*) AS cnt FROM biz.coach_task_transfer_log t WHERE {where_clause}",
|
||||
params,
|
||||
)
|
||||
total = cur.fetchone()["cnt"]
|
||||
|
||||
# 分页数据
|
||||
offset = (page - 1) * page_size
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT t.*, s.site_name
|
||||
FROM biz.coach_task_transfer_log t
|
||||
LEFT JOIN biz.sites s ON s.site_id = t.site_id
|
||||
WHERE {where_clause}
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
params + [page_size, offset],
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
items = [TransferLogItem(**row) for row in rows]
|
||||
return TransferLogPage(items=items, total=total)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("查询转移日志失败")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"查询转移日志失败: {str(exc)[:200]}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/transfer-log/{member_id}/history", response_model=list[TransferLogItem])
|
||||
async def get_member_transfer_history(
|
||||
member_id: int,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[TransferLogItem]:
|
||||
"""某客户全部转移历史。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
# 门店管理员只能看自己门店的记录
|
||||
effective_site_id = _filter_site_id(user, None)
|
||||
if effective_site_id is not None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT t.*, s.site_name
|
||||
FROM biz.coach_task_transfer_log t
|
||||
LEFT JOIN biz.sites s ON s.site_id = t.site_id
|
||||
WHERE t.member_id = %s AND t.site_id = %s
|
||||
ORDER BY t.created_at DESC
|
||||
""",
|
||||
[member_id, effective_site_id],
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT t.*, s.site_name
|
||||
FROM biz.coach_task_transfer_log t
|
||||
LEFT JOIN biz.sites s ON s.site_id = t.site_id
|
||||
WHERE t.member_id = %s
|
||||
ORDER BY t.created_at DESC
|
||||
""",
|
||||
[member_id],
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
return [TransferLogItem(**row) for row in rows]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("查询客户转移历史失败: member_id=%s", member_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"查询客户转移历史失败: {str(exc)[:200]}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 2. 待审核任务
|
||||
# =====================================================================
|
||||
|
||||
@router.get("/pending-review", response_model=PendingReviewPage)
|
||||
async def list_pending_reviews(
|
||||
site_id: int | None = Query(None, description="门店 ID"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> PendingReviewPage:
|
||||
"""待审核任务列表。"""
|
||||
effective_site_id = _filter_site_id(user, site_id)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
conditions = ["ct.status = 'pending_review'"]
|
||||
params: list = []
|
||||
|
||||
if effective_site_id is not None:
|
||||
conditions.append("ct.site_id = %s")
|
||||
params.append(effective_site_id)
|
||||
|
||||
where_clause = " AND ".join(conditions)
|
||||
|
||||
# 总数
|
||||
cur.execute(
|
||||
f"SELECT count(*) AS cnt FROM biz.coach_tasks ct WHERE {where_clause}",
|
||||
params,
|
||||
)
|
||||
total = cur.fetchone()["cnt"]
|
||||
|
||||
# 分页数据
|
||||
offset = (page - 1) * page_size
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT ct.*, s.site_name
|
||||
FROM biz.coach_tasks ct
|
||||
LEFT JOIN biz.sites s ON s.site_id = ct.site_id
|
||||
WHERE {where_clause}
|
||||
ORDER BY ct.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
params + [page_size, offset],
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
row["task_type_label"] = TASK_TYPE_LABELS.get(row.get("task_type", ""), "")
|
||||
items.append(PendingReviewItem(**row))
|
||||
|
||||
return PendingReviewPage(items=items, total=total)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("查询待审核任务失败")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"查询待审核任务失败: {str(exc)[:200]}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/pending-review/{task_id}/reassign", response_model=ReassignResponse)
|
||||
async def reassign_task(
|
||||
task_id: int,
|
||||
body: ReassignRequest,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> ReassignResponse:
|
||||
"""重新分配待审核任务(仅超级管理员)。
|
||||
|
||||
逻辑:原任务 status → 'transferred',新建 active 任务,写 transfer_log。
|
||||
"""
|
||||
_require_super_admin(user)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
# 查询原任务
|
||||
cur.execute(
|
||||
"SELECT * FROM biz.coach_tasks WHERE id = %s FOR UPDATE",
|
||||
[task_id],
|
||||
)
|
||||
task = cur.fetchone()
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if task["status"] != "pending_review":
|
||||
raise HTTPException(status_code=400, detail="任务状态不是待审核,无法重新分配")
|
||||
|
||||
# 原任务标记为 transferred
|
||||
cur.execute(
|
||||
"UPDATE biz.coach_tasks SET status = 'transferred', updated_at = now() WHERE id = %s",
|
||||
[task_id],
|
||||
)
|
||||
|
||||
# 新建 active 任务
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.coach_tasks
|
||||
(site_id, member_id, assistant_id, task_type, priority_score, status, created_at, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, 'active', now(), now())
|
||||
RETURNING id
|
||||
""",
|
||||
[task["site_id"], task["member_id"], body.to_assistant_id,
|
||||
task["task_type"], task.get("priority_score")],
|
||||
)
|
||||
new_task_id = cur.fetchone()["id"]
|
||||
|
||||
# 写转移日志
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.coach_task_transfer_log
|
||||
(site_id, member_id, from_assistant_id, to_assistant_id,
|
||||
transfer_reason, transfer_score, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, now())
|
||||
""",
|
||||
[task["site_id"], task["member_id"], task["assistant_id"],
|
||||
body.to_assistant_id, "manual_reassign", task.get("priority_score")],
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return ReassignResponse(success=True, new_task_id=new_task_id)
|
||||
except HTTPException:
|
||||
conn.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
logger.exception("重新分配任务失败: task_id=%s", task_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"重新分配任务失败: {str(exc)[:200]}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/pending-review/{task_id}/close", response_model=CloseResponse)
|
||||
async def close_task(
|
||||
task_id: int,
|
||||
body: CloseRequest,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> CloseResponse:
|
||||
"""关闭待审核任务(仅超级管理员)。
|
||||
|
||||
逻辑:任务 status → 'inactive',记录 abandon_reason。
|
||||
"""
|
||||
_require_super_admin(user)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"SELECT id, status FROM biz.coach_tasks WHERE id = %s FOR UPDATE",
|
||||
[task_id],
|
||||
)
|
||||
task = cur.fetchone()
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if task["status"] != "pending_review":
|
||||
raise HTTPException(status_code=400, detail="任务状态不是待审核,无法关闭")
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.coach_tasks
|
||||
SET status = 'inactive', abandon_reason = %s, updated_at = now()
|
||||
WHERE id = %s
|
||||
""",
|
||||
[body.reason, task_id],
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return CloseResponse(success=True)
|
||||
except HTTPException:
|
||||
conn.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
logger.exception("关闭任务失败: task_id=%s", task_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"关闭任务失败: {str(exc)[:200]}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 3. 参数管理
|
||||
# =====================================================================
|
||||
|
||||
# 权重参数 key 列表(联合校验用)
|
||||
_WEIGHT_KEYS = {"w_rs", "w_ms", "w_ml"}
|
||||
|
||||
|
||||
@router.get("/config", response_model=ConfigParamList)
|
||||
async def list_config_params(
|
||||
site_id: int | None = Query(None, description="门店 ID(不传则返回全部)"),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> ConfigParamList:
|
||||
"""参数列表。"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
conditions: list[str] = []
|
||||
params: list = []
|
||||
|
||||
if site_id is not None:
|
||||
# 返回指定门店覆盖 + 全局默认
|
||||
conditions.append("(p.site_id = %s OR p.site_id IS NULL)")
|
||||
params.append(site_id)
|
||||
|
||||
where_clause = " AND ".join(conditions) if conditions else "1=1"
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT p.*, s.site_name
|
||||
FROM biz.cfg_task_generator_params p
|
||||
LEFT JOIN biz.sites s ON s.site_id = p.site_id
|
||||
WHERE {where_clause}
|
||||
ORDER BY p.site_id NULLS FIRST, p.param_key
|
||||
""",
|
||||
params,
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
return ConfigParamList(params=[ConfigParam(**row) for row in rows])
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("查询参数配置失败")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"查询参数配置失败: {str(exc)[:200]}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.put("/config/{param_id}", response_model=ConfigParamResponse)
|
||||
async def update_config_param(
|
||||
param_id: int,
|
||||
body: ConfigParamUpdate,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> ConfigParamResponse:
|
||||
"""更新参数值(仅超级管理员)。
|
||||
|
||||
权重参数(w_rs / w_ms / w_ml)更新后会校验三者之和是否为 1.0。
|
||||
"""
|
||||
_require_super_admin(user)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
# 查询当前参数
|
||||
cur.execute(
|
||||
"SELECT * FROM biz.cfg_task_generator_params WHERE id = %s FOR UPDATE",
|
||||
[param_id],
|
||||
)
|
||||
param = cur.fetchone()
|
||||
if param is None:
|
||||
raise HTTPException(status_code=404, detail="参数不存在")
|
||||
|
||||
# 更新
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE biz.cfg_task_generator_params
|
||||
SET param_value = %s, updated_at = now()
|
||||
WHERE id = %s
|
||||
""",
|
||||
[body.param_value, param_id],
|
||||
)
|
||||
|
||||
# 权重参数联合校验:w_rs + w_ms + w_ml = 1.0
|
||||
if param["param_key"] in _WEIGHT_KEYS:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT param_key, param_value
|
||||
FROM biz.cfg_task_generator_params
|
||||
WHERE site_id IS NOT DISTINCT FROM %s
|
||||
AND param_key = ANY(%s)
|
||||
""",
|
||||
[param["site_id"], list(_WEIGHT_KEYS)],
|
||||
)
|
||||
weight_rows = cur.fetchall()
|
||||
weight_sum = sum(r["param_value"] for r in weight_rows)
|
||||
if abs(weight_sum - 1.0) > 0.001:
|
||||
conn.rollback()
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"权重参数之和必须为 1.0,当前为 {weight_sum:.4f}",
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return ConfigParamResponse(success=True, id=param_id)
|
||||
except HTTPException:
|
||||
conn.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
logger.exception("更新参数失败: param_id=%s", param_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"更新参数失败: {str(exc)[:200]}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/config", response_model=ConfigParamResponse)
|
||||
async def create_config_param(
|
||||
body: ConfigParamCreate,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> ConfigParamResponse:
|
||||
"""新增门店覆盖参数(仅超级管理员)。"""
|
||||
_require_super_admin(user)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
# 检查是否已存在同 site_id + param_key 的记录
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM biz.cfg_task_generator_params
|
||||
WHERE site_id = %s AND param_key = %s
|
||||
""",
|
||||
[body.site_id, body.param_key],
|
||||
)
|
||||
if cur.fetchone() is not None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"门店 {body.site_id} 已存在参数 {body.param_key} 的覆盖配置",
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO biz.cfg_task_generator_params
|
||||
(site_id, param_key, param_value, updated_at)
|
||||
VALUES (%s, %s, %s, now())
|
||||
RETURNING id
|
||||
""",
|
||||
[body.site_id, body.param_key, body.param_value],
|
||||
)
|
||||
new_id = cur.fetchone()["id"]
|
||||
|
||||
conn.commit()
|
||||
return ConfigParamResponse(success=True, id=new_id)
|
||||
except HTTPException:
|
||||
conn.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
logger.exception("新增参数失败")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"新增参数失败: {str(exc)[:200]}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.delete("/clear-all-tasks")
|
||||
async def clear_all_tasks(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""【测试用】清空所有 coach_tasks 及关联数据(仅超级管理员)。
|
||||
|
||||
用于开发/测试阶段重置任务数据,让 task_generator 重新生成。
|
||||
按外键依赖顺序删除:transfer_log → notes → history → tasks。
|
||||
"""
|
||||
_require_super_admin(user)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 按外键依赖顺序:先删引用表,再删主表
|
||||
cur.execute("DELETE FROM biz.coach_task_transfer_log")
|
||||
transfer_count = cur.rowcount
|
||||
cur.execute("DELETE FROM biz.notes WHERE task_id IS NOT NULL")
|
||||
notes_count = cur.rowcount
|
||||
cur.execute("DELETE FROM biz.coach_task_history")
|
||||
history_count = cur.rowcount
|
||||
# coach_tasks 有自引用 FK,先清 parent_task_id 和 transferred_from
|
||||
cur.execute("UPDATE biz.coach_tasks SET parent_task_id = NULL, transferred_from = NULL")
|
||||
cur.execute("DELETE FROM biz.coach_tasks")
|
||||
task_count = cur.rowcount
|
||||
conn.commit()
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"已清空 {task_count} 条任务 + {history_count} 条历史 + {transfer_count} 条转移日志 + {notes_count} 条备注",
|
||||
"deleted_tasks": task_count,
|
||||
"deleted_history": history_count,
|
||||
"deleted_transfers": transfer_count,
|
||||
"deleted_notes": notes_count,
|
||||
}
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
logger.exception("清空任务失败")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"清空任务失败: {str(exc)[:200]}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.delete("/config/{param_id}", response_model=ConfigParamResponse)
|
||||
async def delete_config_param(
|
||||
param_id: int,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> ConfigParamResponse:
|
||||
"""删除门店覆盖参数(仅超级管理员)。
|
||||
|
||||
不允许删除 site_id IS NULL 的全局默认参数。
|
||||
"""
|
||||
_require_super_admin(user)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"SELECT id, site_id FROM biz.cfg_task_generator_params WHERE id = %s",
|
||||
[param_id],
|
||||
)
|
||||
param = cur.fetchone()
|
||||
if param is None:
|
||||
raise HTTPException(status_code=404, detail="参数不存在")
|
||||
if param["site_id"] is None:
|
||||
raise HTTPException(status_code=400, detail="不允许删除全局默认参数")
|
||||
|
||||
cur.execute(
|
||||
"DELETE FROM biz.cfg_task_generator_params WHERE id = %s",
|
||||
[param_id],
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return ConfigParamResponse(success=True, id=param_id)
|
||||
except HTTPException:
|
||||
conn.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
logger.exception("删除参数失败: param_id=%s", param_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"删除参数失败: {str(exc)[:200]}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
405
apps/backend/app/routers/admin_tenant_admins.py
Normal file
405
apps/backend/app/routers/admin_tenant_admins.py
Normal file
@@ -0,0 +1,405 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
管理端路由 — 租户管理员 CRUD。
|
||||
|
||||
端点清单:
|
||||
- GET /api/admin/tenant-admins — 管理员列表(分页 + 关键词搜索)
|
||||
- POST /api/admin/tenant-admins — 创建管理员
|
||||
- PATCH /api/admin/tenant-admins/{id} — 编辑管理员
|
||||
- DELETE /api/admin/tenant-admins/{id} — 软删除管理员
|
||||
- POST /api/admin/tenant-admins/{id}/reset-password — 重置密码
|
||||
|
||||
所有端点要求 JWT + site_admin 或 tenant_admin 角色。
|
||||
|
||||
需求: 14.1-14.7, A2.3, A2.6, A2.7, A2.8, A4.1
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from psycopg2 import errors as pg_errors
|
||||
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
from app.auth.jwt import hash_password
|
||||
from app.database import get_connection
|
||||
from app.schemas.admin_tenant_admins import (
|
||||
ResetPasswordRequest,
|
||||
TenantAdminCreateRequest,
|
||||
TenantAdminEditRequest,
|
||||
TenantAdminListItem,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["管理端租户管理员"])
|
||||
|
||||
|
||||
# ── 管理端权限依赖:要求 site_admin 或 tenant_admin 角色 ──
|
||||
|
||||
|
||||
def _require_admin():
|
||||
"""
|
||||
管理端依赖:要求 JWT 中角色包含 site_admin 或 tenant_admin。
|
||||
|
||||
直接从 JWT 校验角色,不查 auth.users 表(管理员在 admin_users 表,
|
||||
不在 auth.users 中,require_permission 会报"用户不存在")。
|
||||
"""
|
||||
|
||||
async def _dependency(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> CurrentUser:
|
||||
admin_roles = {"site_admin", "tenant_admin"}
|
||||
if not admin_roles.intersection(user.roles):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="需要管理员权限(site_admin 或 tenant_admin)",
|
||||
)
|
||||
return user
|
||||
|
||||
return _dependency
|
||||
|
||||
|
||||
# ── GET /api/admin/tenant-admins ──────────────────────────
|
||||
|
||||
|
||||
@router.get("/tenant-admins")
|
||||
async def list_tenant_admins(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
keyword: Optional[str] = Query(None, description="关键词搜索(用户名/显示名称)"),
|
||||
include_inactive: bool = Query(False, description="是否包含已禁用的管理员"),
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
):
|
||||
"""
|
||||
查询租户管理员列表,支持分页和关键词搜索。
|
||||
|
||||
默认只返回 is_active=true 的记录;include_inactive=true 时返回所有记录。
|
||||
JOIN biz.tenants 获取 tenant_name。
|
||||
|
||||
需求 14.1, A2.7, A2.6
|
||||
"""
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# 构建查询
|
||||
where_clauses: list[str] = []
|
||||
params: list = []
|
||||
|
||||
# CHANGE 2026-03-22 | Prompt: 删除与禁用分离 | 始终过滤已删除记录
|
||||
where_clauses.append("ta.deleted_at IS NULL")
|
||||
|
||||
# CHANGE 2026-03-23 | Prompt: 任务5.1 A2.7 | 默认过滤 is_active
|
||||
if not include_inactive:
|
||||
where_clauses.append("ta.is_active = true")
|
||||
|
||||
if keyword:
|
||||
where_clauses.append(
|
||||
"(ta.username ILIKE %s OR ta.display_name ILIKE %s)"
|
||||
)
|
||||
like_val = f"%{keyword}%"
|
||||
params.extend([like_val, like_val])
|
||||
|
||||
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
|
||||
|
||||
# 查询总数
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT COUNT(*)
|
||||
FROM auth.tenant_admins ta
|
||||
{where_sql}
|
||||
""",
|
||||
params,
|
||||
)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# CHANGE 2026-03-23 | Prompt: 任务5.1 A2.6 | JOIN biz.tenants 获取 tenant_name
|
||||
# CHANGE 2026-03-23 | Prompt: 角色体系隔离 | 加入 admin_type 列
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT ta.id, ta.username, ta.display_name, ta.tenant_id,
|
||||
ta.managed_site_ids,
|
||||
ta.is_active, ta.created_at, ta.last_login_at,
|
||||
bt.tenant_name, ta.admin_type
|
||||
FROM auth.tenant_admins ta
|
||||
LEFT JOIN biz.tenants bt ON bt.tenant_id = ta.tenant_id
|
||||
{where_sql}
|
||||
ORDER BY ta.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
params + [page_size, offset],
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
items = [
|
||||
TenantAdminListItem(
|
||||
id=r[0],
|
||||
username=r[1],
|
||||
display_name=r[2],
|
||||
tenant_id=r[3],
|
||||
managed_site_ids=list(r[4]) if r[4] else [],
|
||||
is_active=r[5],
|
||||
created_at=r[6].isoformat() if r[6] else None,
|
||||
last_login_at=r[7].isoformat() if r[7] else None,
|
||||
tenant_name=r[8],
|
||||
admin_type=r[9],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
# 返回分页格式(由 ResponseWrapperMiddleware 包装为 {code:0, data:...})
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
|
||||
|
||||
# ── POST /api/admin/tenant-admins ─────────────────────────
|
||||
|
||||
|
||||
@router.post("/tenant-admins", status_code=status.HTTP_201_CREATED)
|
||||
async def create_tenant_admin(
|
||||
body: TenantAdminCreateRequest,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
):
|
||||
"""
|
||||
创建租户管理员。
|
||||
|
||||
密码 bcrypt 哈希,username UNIQUE 冲突返回 409,记录 created_by。
|
||||
创建时校验 tenant_id 在 biz.tenants 中存在且 is_active=true。
|
||||
|
||||
需求 14.2, 14.3, A2.6
|
||||
"""
|
||||
password_hash = hash_password(body.password)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# CHANGE 2026-03-23 | Prompt: 任务5.1 A2.6 | 校验 tenant_id 存在性
|
||||
cur.execute(
|
||||
"SELECT id FROM biz.tenants WHERE tenant_id = %s AND is_active = true",
|
||||
(body.tenant_id,),
|
||||
)
|
||||
if cur.fetchone() is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="租户不存在",
|
||||
)
|
||||
|
||||
# CHANGE 2026-03-23 | Prompt: 登录用户名大小写不敏感 | 存储时统一小写
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth.tenant_admins
|
||||
(username, password_hash, display_name, tenant_id, managed_site_ids, created_by)
|
||||
VALUES (LOWER(%s), %s, %s, %s, %s, %s)
|
||||
RETURNING id, created_at
|
||||
""",
|
||||
(
|
||||
body.username,
|
||||
password_hash,
|
||||
body.display_name,
|
||||
body.tenant_id,
|
||||
body.managed_site_ids,
|
||||
user.user_id,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except pg_errors.UniqueViolation:
|
||||
conn.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="用户名已存在",
|
||||
)
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"id": row[0], "created_at": row[1].isoformat() if row[1] else None}
|
||||
|
||||
|
||||
# ── PATCH /api/admin/tenant-admins/{id} ───────────────────
|
||||
|
||||
|
||||
@router.patch("/tenant-admins/{admin_id}")
|
||||
async def edit_tenant_admin(
|
||||
admin_id: int,
|
||||
body: TenantAdminEditRequest,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
):
|
||||
"""
|
||||
编辑租户管理员信息(username / display_name / managed_site_ids / is_active)。
|
||||
|
||||
管理员 ID 不存在返回 404。
|
||||
修改 username 时校验全局唯一性(排除自身),冲突返回 409。
|
||||
|
||||
需求 14.4, 14.6, A2.8
|
||||
"""
|
||||
# 构建动态 SET 子句
|
||||
set_clauses: list[str] = []
|
||||
params: list = []
|
||||
|
||||
# CHANGE 2026-03-23 | Prompt: 任务5.1 A2.8 | 支持修改 username
|
||||
# CHANGE 2026-03-23 | Prompt: 登录用户名大小写不敏感 | 存储时统一小写
|
||||
if body.username is not None:
|
||||
set_clauses.append("username = LOWER(%s)")
|
||||
params.append(body.username)
|
||||
if body.display_name is not None:
|
||||
set_clauses.append("display_name = %s")
|
||||
params.append(body.display_name)
|
||||
if body.managed_site_ids is not None:
|
||||
set_clauses.append("managed_site_ids = %s")
|
||||
params.append(body.managed_site_ids)
|
||||
if body.is_active is not None:
|
||||
set_clauses.append("is_active = %s")
|
||||
params.append(body.is_active)
|
||||
|
||||
if not set_clauses:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="至少需要提供一个修改字段",
|
||||
)
|
||||
|
||||
params.append(admin_id)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# CHANGE 2026-03-23 | Prompt: 任务5.1 A2.8 | username 唯一性校验(排除自身)
|
||||
# CHANGE 2026-03-22 | Prompt: 删除与禁用分离 | 只在未删除记录中校验唯一性
|
||||
# CHANGE 2026-03-23 | Prompt: 登录用户名大小写不敏感 | LOWER() 比较 + 存储小写
|
||||
if body.username is not None:
|
||||
cur.execute(
|
||||
"SELECT id FROM auth.tenant_admins WHERE LOWER(username) = LOWER(%s) AND id != %s AND deleted_at IS NULL",
|
||||
(body.username, admin_id),
|
||||
)
|
||||
if cur.fetchone() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="用户名已存在",
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE auth.tenant_admins
|
||||
SET {', '.join(set_clauses)}
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
RETURNING id
|
||||
""",
|
||||
params,
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="租户管理员不存在",
|
||||
)
|
||||
conn.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"id": admin_id}
|
||||
|
||||
|
||||
# ── DELETE /api/admin/tenant-admins/{id} ──────────────────
|
||||
|
||||
|
||||
@router.delete("/tenant-admins/{admin_id}")
|
||||
async def delete_tenant_admin(
|
||||
admin_id: int,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
):
|
||||
"""
|
||||
软删除租户管理员(设置 deleted_at=NOW())。
|
||||
|
||||
无论 is_active 状态如何,均可删除。
|
||||
管理员不存在或已删除返回 404;重复删除幂等返回 404。
|
||||
|
||||
需求 A2.3
|
||||
"""
|
||||
# CHANGE 2026-03-22 | Prompt: 删除与禁用分离 | deleted_at 软删除,不再检查 is_active
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE auth.tenant_admins SET deleted_at = NOW() "
|
||||
"WHERE id = %s AND deleted_at IS NULL "
|
||||
"RETURNING id",
|
||||
(admin_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="管理员不存在",
|
||||
)
|
||||
conn.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"id": admin_id}
|
||||
|
||||
|
||||
# ── POST /api/admin/tenant-admins/{id}/reset-password ─────
|
||||
|
||||
|
||||
@router.post("/tenant-admins/{admin_id}/reset-password")
|
||||
async def reset_password(
|
||||
admin_id: int,
|
||||
body: ResetPasswordRequest,
|
||||
user: CurrentUser = Depends(_require_admin()),
|
||||
):
|
||||
"""
|
||||
重置租户管理员密码。
|
||||
|
||||
新密码 bcrypt 哈希后更新 password_hash。管理员 ID 不存在返回 404。
|
||||
需求 14.5
|
||||
"""
|
||||
new_hash = hash_password(body.new_password)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE auth.tenant_admins
|
||||
SET password_hash = %s
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
RETURNING id
|
||||
""",
|
||||
(new_hash, admin_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="租户管理员不存在",
|
||||
)
|
||||
conn.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {"id": admin_id}
|
||||
177
apps/backend/app/routers/admin_triggers.py
Normal file
177
apps/backend/app/routers/admin_triggers.py
Normal file
@@ -0,0 +1,177 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""管理端 — 触发器统一视图 API
|
||||
|
||||
提供 1 个端点:
|
||||
- GET /api/admin/triggers/unified — 聚合三张表的触发器数据
|
||||
|
||||
数据源:
|
||||
- biz.trigger_jobs(业务触发器)→ source="biz"
|
||||
- biz.ai_trigger_jobs(AI 事件链,最近 100 条)→ source="ai"
|
||||
- public.scheduled_tasks(ETL 调度)→ source="etl"
|
||||
|
||||
某数据源查询失败时记录日志,返回其他数据源数据。
|
||||
|
||||
需求: 4.1, 4.2, 4.3
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.auth.dependencies import CurrentUser, get_current_user
|
||||
from app.database import get_connection
|
||||
from app.schemas.admin_triggers import UnifiedTriggerItem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/admin/triggers", tags=["系统管理"])
|
||||
|
||||
|
||||
def _fetch_biz_triggers(conn) -> list[UnifiedTriggerItem]:
|
||||
"""查询 biz.trigger_jobs,映射 source='biz'。"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, job_name, trigger_condition, status,
|
||||
last_run_at, next_run_at, last_error
|
||||
FROM biz.trigger_jobs
|
||||
ORDER BY id
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [
|
||||
UnifiedTriggerItem(
|
||||
id=row[0],
|
||||
name=row[1],
|
||||
source="biz",
|
||||
trigger_condition=row[2] or "",
|
||||
status=row[3] or "",
|
||||
last_run_at=str(row[4]) if row[4] is not None else None,
|
||||
next_run_at=str(row[5]) if row[5] is not None else None,
|
||||
last_error=row[6],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _fetch_ai_triggers(conn) -> list[UnifiedTriggerItem]:
|
||||
"""查询 biz.ai_trigger_jobs(最近 100 条),映射 source='ai'。
|
||||
|
||||
字段映射(DDL 实际列 → UnifiedTriggerItem):
|
||||
- id → id
|
||||
- event_type → name(ai_trigger_jobs 无 job_name 列)
|
||||
- 'event' → trigger_condition(AI 触发器均为事件驱动)
|
||||
- status → status
|
||||
- started_at → last_run_at(ai_trigger_jobs 无 last_run_at 列)
|
||||
- None → next_run_at(事件驱动无预定下次执行时间)
|
||||
- error_message → last_error(ai_trigger_jobs 列名为 error_message)
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, event_type, status,
|
||||
started_at, error_message
|
||||
FROM biz.ai_trigger_jobs
|
||||
ORDER BY id DESC
|
||||
LIMIT 100
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [
|
||||
UnifiedTriggerItem(
|
||||
id=row[0],
|
||||
name=row[1] or "",
|
||||
source="ai",
|
||||
trigger_condition="event",
|
||||
status=row[2] or "",
|
||||
last_run_at=str(row[3]) if row[3] is not None else None,
|
||||
next_run_at=None,
|
||||
last_error=row[4],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _fetch_etl_triggers(conn) -> list[UnifiedTriggerItem]:
|
||||
"""查询 public.scheduled_tasks,映射 source='etl'。
|
||||
|
||||
字段映射(DDL 实际列 → UnifiedTriggerItem):
|
||||
- id → id(UUID,转为字符串后取 hashcode 作为 int 不合适,改用 row_number)
|
||||
- name → name
|
||||
- schedule_config->>'schedule_type' → trigger_condition
|
||||
- last_status / enabled → status(组合判断)
|
||||
- last_run_at → last_run_at
|
||||
- next_run_at → next_run_at
|
||||
- None → last_error(scheduled_tasks 无 last_error 列)
|
||||
|
||||
注意:scheduled_tasks.id 是 UUID 类型,UnifiedTriggerItem.id 是 int。
|
||||
使用 ROW_NUMBER() 生成临时整数 ID,加 100000 偏移避免与其他数据源冲突。
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT ROW_NUMBER() OVER (ORDER BY created_at) + 100000 AS row_id,
|
||||
name,
|
||||
schedule_config->>'schedule_type' AS schedule_type,
|
||||
CASE
|
||||
WHEN enabled = FALSE THEN 'disabled'
|
||||
WHEN last_status IS NOT NULL THEN last_status
|
||||
ELSE 'idle'
|
||||
END AS status,
|
||||
last_run_at,
|
||||
next_run_at
|
||||
FROM scheduled_tasks
|
||||
ORDER BY created_at
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [
|
||||
UnifiedTriggerItem(
|
||||
id=int(row[0]),
|
||||
name=row[1] or "",
|
||||
source="etl",
|
||||
trigger_condition=row[2] or "unknown",
|
||||
status=row[3] or "idle",
|
||||
last_run_at=str(row[4]) if row[4] is not None else None,
|
||||
next_run_at=str(row[5]) if row[5] is not None else None,
|
||||
last_error=None,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/unified", response_model=list[UnifiedTriggerItem])
|
||||
async def get_unified_triggers(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[UnifiedTriggerItem]:
|
||||
"""聚合三张表的触发器数据。
|
||||
|
||||
依次查询 biz.trigger_jobs、biz.ai_trigger_jobs、scheduled_tasks,
|
||||
某数据源查询失败时记录日志并跳过,返回其他数据源的数据。
|
||||
"""
|
||||
results: list[UnifiedTriggerItem] = []
|
||||
conn = get_connection()
|
||||
try:
|
||||
# 数据源 1:biz.trigger_jobs
|
||||
try:
|
||||
results.extend(_fetch_biz_triggers(conn))
|
||||
except Exception:
|
||||
logger.warning("查询 biz.trigger_jobs 失败", exc_info=True)
|
||||
|
||||
# 数据源 2:biz.ai_trigger_jobs
|
||||
try:
|
||||
results.extend(_fetch_ai_triggers(conn))
|
||||
except Exception:
|
||||
logger.warning("查询 biz.ai_trigger_jobs 失败", exc_info=True)
|
||||
|
||||
# 数据源 3:public.scheduled_tasks
|
||||
try:
|
||||
results.extend(_fetch_etl_triggers(conn))
|
||||
except Exception:
|
||||
logger.warning("查询 scheduled_tasks 失败", exc_info=True)
|
||||
|
||||
return results
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -37,7 +37,7 @@ async def login(body: LoginRequest):
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, password_hash, site_id, is_active "
|
||||
"SELECT id, password_hash, site_id, is_active, roles "
|
||||
"FROM admin_users WHERE username = %s",
|
||||
(body.username,),
|
||||
)
|
||||
@@ -51,7 +51,7 @@ async def login(body: LoginRequest):
|
||||
detail="用户名或密码错误",
|
||||
)
|
||||
|
||||
user_id, password_hash, site_id, is_active = row
|
||||
user_id, password_hash, site_id, is_active, roles = row
|
||||
|
||||
if not is_active:
|
||||
raise HTTPException(
|
||||
@@ -65,7 +65,7 @@ async def login(body: LoginRequest):
|
||||
detail="用户名或密码错误",
|
||||
)
|
||||
|
||||
tokens = create_token_pair(user_id, site_id)
|
||||
tokens = create_token_pair(user_id, site_id, roles=roles or [])
|
||||
return TokenResponse(**tokens)
|
||||
|
||||
|
||||
@@ -88,8 +88,22 @@ async def refresh(body: RefreshRequest):
|
||||
user_id = int(payload["sub"])
|
||||
site_id = payload["site_id"]
|
||||
|
||||
# CHANGE 2026-03-24 | Prompt: 修复 refresh 丢失 roles | 刷新前查询数据库获取最新 roles
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT roles FROM admin_users WHERE id = %s",
|
||||
(user_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
roles = row[0] if row else []
|
||||
|
||||
# 生成新的 access_token,refresh_token 原样返回
|
||||
new_access = create_access_token(user_id, site_id)
|
||||
new_access = create_access_token(user_id, site_id, roles=roles or [])
|
||||
return TokenResponse(
|
||||
access_token=new_access,
|
||||
refresh_token=body.refresh_token,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user