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:
Neo
2026-04-06 00:03:48 +08:00
parent 70324d8542
commit 6f8f12314f
515 changed files with 76604 additions and 7456 deletions

View File

@@ -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 监控后台 APIDashboard/调度/调用/缓存/预算/批量/告警)
│ │ ├── 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

View 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 }) => {
// 区块 1OpsPanel 子组件(系统资源信息)
// SystemResourceSection 会展示 CPU / 内存 / 磁盘等信息
await expect(page.locator('text=CPU').first()).toBeVisible();
// 区块 2数据库健康监控DbHealthCard
await expect(page.locator('text=数据库').first()).toBeVisible();
// 区块 3AI 运行总览
await expect(page.locator('text=AI 运行总览').first()).toBeVisible();
// 区块 4AI 调度摘要
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/);
});
});

View 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');
});
});

View File

@@ -0,0 +1,230 @@
/**
* E2E 测试公共辅助:注入 JWT 令牌 + 通用 API mock。
*
* 认证方式:向 localStorage 写入伪造的 access_token / refresh_token
* 与 authStore.hydrate() 逻辑一致,页面加载后自动识别为已登录状态。
*/
import { type Page } from '@playwright/test';
/* ------------------------------------------------------------------ */
/* 伪造 JWTpayload 可被 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: [] }),
}),
);
}

View 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/);
});
});

View 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');
});
});

View File

@@ -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",

View 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'] },
},
],
});

View File

@@ -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:

View File

@@ -2,8 +2,15 @@
* 主布局与路由配置。
*
* - Ant Design LayoutSider + Content + Footer状态栏
* - react-router-dom6 个功能页面路由 + 登录页路由
* - 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

View 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>,
);
// 区块 1OpsPanel 子组件 — 页面标题"运行状态"
expect(await screen.findByText("运行状态")).toBeInTheDocument();
// 区块 2数据库健康监控
expect(screen.getByText("数据库健康监控")).toBeInTheDocument();
// 区块 3AI 运行总览mocked AIDashboard
expect(screen.getByText("AI 运行总览")).toBeInTheDocument();
expect(screen.getByTestId("ai-dashboard-mock")).toBeInTheDocument();
// 区块 4AI 调度摘要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();
});
});

View 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 为 configRequirements 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("状态");
});
});

View File

@@ -8,7 +8,7 @@
*/
import { describe, it, expect } from "vitest";
import { filterLogLines } from "../pages/LogViewer";
import { filterLogLines } from "../pages/_archived/LogViewer";
describe("filterLogLines — 日志过滤正确性", () => {
/* ---- 1. 空关键词返回所有行 ---- */

View 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");
});
});

View 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 },
);
});
});

View File

@@ -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);
});

View 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`。
*
* 当前仅测试 ETLTasksTriggerManager 待后续任务创建后扩展)。
*/
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();
});
});
}
});

View 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();
});
});

View 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;
}

View File

@@ -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) {

View 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;
}

View 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;
}

View File

@@ -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;
}
/** 最近执行记录 */

View File

@@ -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;
}

View 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}`);
}

View File

@@ -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;
}

View 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;
}

View 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}`);
}

View 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;
}

View 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;
}

View 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;

View File

@@ -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>
</>
);
};

View File

@@ -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>

View 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;

View 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;

View 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;

View 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";

View 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;

View 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="例如:&#10;12345&#10;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;

View 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;

View 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;

View 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>
{/* 区块 1OpsPanel 子组件 */}
{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}
/>
{/* 区块 3AI 运行总览(复用 AIDashboard */}
<Divider orientation="left">AI </Divider>
<AIDashboard />
{/* 区块 4AI 调度摘要 */}
<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;

View 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;

View File

@@ -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> = [

View File

@@ -0,0 +1,121 @@
/**
* ETL 任务管理页面 — 合并 TaskConfig / QueueTab / ScheduleTab / ETLStatus 为 Tab 视图。
*
* - 5 个 Tabconfig发起、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;

View File

@@ -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

View File

@@ -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>
);
};

View 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;

View File

@@ -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>

View 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;

View File

@@ -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>
),
},
];

File diff suppressed because it is too large Load Diff

View 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;

View 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;

View File

@@ -0,0 +1,462 @@
/**
* 触发器统一管理页面 — 聚合 biz / ai / etl 三类触发器为 Tab 视图。
*
* - 4 个 Taball全部只读统一视图、biz业务、aiAI、etlETL
* - 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>
);
};
/* ───────── "业务"TabTriggerJobs + 编辑 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"TabAIOperations + AITriggerJobs ───────── */
const AITriggersTab: React.FC = () => (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<AIOperations />
<AITriggerJobs />
</Space>
);
/* ───────── "ETL"Tabscheduled_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;

View File

@@ -1,5 +1,9 @@
/**
*
* [ARCHIVED]
*
* ETLTasks "任务管理"Tab
* 2026-03-25
* admin-web-restructure spec 8LogViewer
*
* - 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;

View File

@@ -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;

View 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;
}

View File

@@ -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;
}

View File

@@ -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"}

View File

@@ -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 相关 SchemaSSEEvent 等)
│ │ ├── 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 任务引擎运营看板(转移日志分页+历史、待审核任务分页+重新分配+关闭、参数管理 CRUD9 端点) | 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。
## 依赖

View File

@@ -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_pagecontextType调用 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

View File

@@ -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:

View File

@@ -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
# 构建 referenceApp6 线索 + 最近 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(

View File

@@ -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]
# 构建 referenceApp8 最新 + 最近 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(

View File

@@ -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(

View File

@@ -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]
# 构建 referenceApp3 线索 + 最近 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(

View File

@@ -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(

View File

@@ -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:

View File

@@ -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]:
"""纯函数:在首条消息的 contentJSON 字符串)中注入 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:
# 401API 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

View 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. 传入两个 callableget_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,
)

View File

@@ -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:59UTC+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()

View 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→CLOSEDCLOSED 重置失败计数
- record_failure()连续达阈值→OPENHALF_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

View 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)

View File

@@ -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

View 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]

View 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",
]

View 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()

View 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和桌台IDsite_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()

View 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: 实体 IDcontextId
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

View 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 预算超限。"""

View 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)

View 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

View 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

View File

@@ -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,

View 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

View 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 causeJWT managed_site_ids 静态签发,新建店铺后所有端点受限 | Summary新增 get_tenant_site_ids(tenant_id) 和 get_effective_site_ids(admin) 函数;改造 site_filter_clause 和 verify_site_access 支持 admin= keyword-only 参数(向后兼容旧签名)| Verifytenant_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提取管理员信息。
拒绝小程序 JWTaud 不匹配)及任何无效/过期令牌。
"""
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="无权访问该门店数据",
)

View File

@@ -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 | 权限改造 W1refresh_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"))

View File

@@ -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:
"""

View File

@@ -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=["系统"])

View File

@@ -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 spantrace 未激活时静默跳过)
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 spantrace 未激活时静默跳过)
from app.trace.error_handler import record_unhandled_exception
record_unhandled_exception(exc)
logger.exception("未捕获异常: %s", exc)
return JSONResponse(
status_code=500,

View 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_jobis_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)

View 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 构建连接字符串时
# 会读取系统用户名/计算机名,含中文时触发 UnicodeDecodeError0xd6 是 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]

View 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()

View 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 — 内部 APIETL 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_sitescd2_is_current=1获取当前有效店铺
2. 对比 biz.sites
- 新 site_id → INSERTsite_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 参数支持两种格式:
- 上游系统租户 IDBIGINT如 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_historyis_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:
"""内部 APIETL 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}

View 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()

View 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}

View File

@@ -0,0 +1,177 @@
# -*- coding: utf-8 -*-
"""管理端 — 触发器统一视图 API
提供 1 个端点:
- GET /api/admin/triggers/unified — 聚合三张表的触发器数据
数据源:
- biz.trigger_jobs业务触发器→ source="biz"
- biz.ai_trigger_jobsAI 事件链,最近 100 条)→ source="ai"
- public.scheduled_tasksETL 调度)→ 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 → nameai_trigger_jobs 无 job_name 列)
- 'event' → trigger_conditionAI 触发器均为事件驱动)
- status → status
- started_at → last_run_atai_trigger_jobs 无 last_run_at 列)
- None → next_run_at事件驱动无预定下次执行时间
- error_message → last_errorai_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 → idUUID转为字符串后取 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_errorscheduled_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:
# 数据源 1biz.trigger_jobs
try:
results.extend(_fetch_biz_triggers(conn))
except Exception:
logger.warning("查询 biz.trigger_jobs 失败", exc_info=True)
# 数据源 2biz.ai_trigger_jobs
try:
results.extend(_fetch_ai_triggers(conn))
except Exception:
logger.warning("查询 biz.ai_trigger_jobs 失败", exc_info=True)
# 数据源 3public.scheduled_tasks
try:
results.extend(_fetch_etl_triggers(conn))
except Exception:
logger.warning("查询 scheduled_tasks 失败", exc_info=True)
return results
finally:
conn.close()

View File

@@ -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_tokenrefresh_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