✨ feat: 完成系统真实化改造 + Mock清除 + 文档编写
- 实现 Onboarding 后端 API(6个端点完整对接) - 接入百炼 DashScope LLM(qwen3-coder-plus 真实引用检测) - 修复知识库 MockEmbedder(自动检测 API Key 切换真实 Embedder) - 内容生成对接 RAG 知识库检索 - 清除前端 onboarding 硬编码假数据(改为错误提示+重试) - 移除模拟加载延迟(setTimeout) - Pipeline dry-run 模式添加生产环境告警 - 添加操作流程文档和后续待办事项文档 - 更新 .gitignore 排除测试产物和临时文件
This commit is contained in:
parent
eac12093d6
commit
9e63915f42
|
|
@ -0,0 +1,14 @@
|
|||
# 根目录 .dockerignore(用于 docker-compose 构建上下文)
|
||||
.git/
|
||||
.gitignore
|
||||
docs/
|
||||
tests/
|
||||
*.md
|
||||
*.log
|
||||
*.txt
|
||||
.env
|
||||
.env.*
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
90
.env.example
90
.env.example
|
|
@ -1,34 +1,90 @@
|
|||
# 数据库
|
||||
# =============================================================================
|
||||
# GEO 平台环境变量配置模板
|
||||
# 复制此文件为 .env 并填入真实值,切勿将 .env 提交到 Git
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 数据库(PostgreSQL)
|
||||
# -----------------------------------------------------------------------------
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres123@db:5432/geo_platform
|
||||
|
||||
# Redis
|
||||
# -----------------------------------------------------------------------------
|
||||
# Redis(缓存 / 任务队列)
|
||||
# -----------------------------------------------------------------------------
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-secret-key-change-in-production
|
||||
# -----------------------------------------------------------------------------
|
||||
# JWT 认证密钥
|
||||
# 必须 >= 32 字符,可用以下命令生成:
|
||||
# python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
# -----------------------------------------------------------------------------
|
||||
JWT_SECRET=your-jwt-secret-at-least-32-characters-long
|
||||
JWT_EXPIRE_HOURS=24
|
||||
|
||||
# 前端
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
# -----------------------------------------------------------------------------
|
||||
# NextAuth / 前端 Session 密钥(如使用 NextAuth,必须 >= 32 字符)
|
||||
# -----------------------------------------------------------------------------
|
||||
SECRET_KEY=your-nextauth-secret-at-least-32-characters-long
|
||||
|
||||
# Playwright
|
||||
# -----------------------------------------------------------------------------
|
||||
# 前端 & CORS
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:3001
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Playwright(用于 SEO 抓取,Docker 内路径)
|
||||
# -----------------------------------------------------------------------------
|
||||
PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||
|
||||
# 国内大模型API(可选)
|
||||
ZHIPU_API_KEY=
|
||||
TONGYI_API_KEY=
|
||||
# -----------------------------------------------------------------------------
|
||||
# LLM 功能开关
|
||||
# -----------------------------------------------------------------------------
|
||||
ENABLE_LLM=true
|
||||
|
||||
# ---- LLM Provider 配置 ----
|
||||
# 默认LLM提供商: openai | deepseek
|
||||
# -----------------------------------------------------------------------------
|
||||
# LLM Provider 配置
|
||||
# 支持: openai | deepseek
|
||||
# 使用 OpenAI 兼容协议,可对接 DashScope、DeepSeek 等平台
|
||||
# -----------------------------------------------------------------------------
|
||||
DEFAULT_LLM_PROVIDER=openai
|
||||
DEFAULT_LLM_MODEL=qwen3-coder-plus
|
||||
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_MODEL=gpt-4o-mini
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
# OpenAI 层配置(百炼 DashScope Coding Plan 优先)
|
||||
# 百炼 API Key: 癷67 https://bailian.console.aliyun.com/ 申请
|
||||
OPENAI_API_KEY=your-dashscope-api-key-here
|
||||
OPENAI_MODEL=qwen3-coder-plus
|
||||
OPENAI_BASE_URL=https://coding.dashscope.aliyuncs.com/v1
|
||||
|
||||
# DeepSeek
|
||||
DEEPSEEK_API_KEY=
|
||||
DEEPSEEK_API_KEY=your-deepseek-api-key-here
|
||||
DEEPSEEK_MODEL=deepseek-chat
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||
DEEPSEEK_MAX_CONTEXT=64000
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 国内 AI 平台 API(可选,按需填写)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# 智谱 AI(ChatGLM 系列)
|
||||
ZHIPU_API_KEY=your-zhipu-api-key-here
|
||||
|
||||
# 阿里云通义千问
|
||||
TONGYI_API_KEY=your-tongyi-api-key-here
|
||||
|
||||
# Kimi(月之暗面)
|
||||
MOONSHOT_API_KEY=your-moonshot-api-key-here
|
||||
|
||||
# 百度千帆(文心一言)
|
||||
BAIDU_QIANFAN_API_KEY=your-baidu-qianfan-api-key-here
|
||||
BAIDU_QIANFAN_SECRET_KEY=your-baidu-qianfan-secret-key-here
|
||||
|
||||
# 豆包(字节跳动)
|
||||
DOUBAO_API_KEY=your-doubao-api-key-here
|
||||
DOUBAO_ENDPOINT_ID=your-doubao-endpoint-id-here
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# API 调用频率限制
|
||||
# -----------------------------------------------------------------------------
|
||||
# 每分钟最大请求数(防止触发平台限速)
|
||||
API_RATE_LIMIT_RPM=10
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
name: CI Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
# ── 后端 ──────────────────────────────────────────────
|
||||
backend-lint-test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: geo_test
|
||||
ports: ['5432:5432']
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7
|
||||
ports: ['6379:6379']
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
pip install ruff pytest-cov
|
||||
|
||||
- name: Lint (ruff)
|
||||
run: cd backend && ruff check app/
|
||||
|
||||
- name: Type check (optional)
|
||||
run: cd backend && ruff check app/ --select=E,W
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/geo_test
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
JWT_SECRET: test-secret-key-minimum-32-characters-long
|
||||
ENVIRONMENT: test
|
||||
run: |
|
||||
cd backend
|
||||
pytest tests/ -v --tb=short
|
||||
|
||||
# ── 前端 ──────────────────────────────────────────────
|
||||
frontend-lint-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd frontend && npm ci
|
||||
|
||||
- name: Lint (ESLint)
|
||||
run: cd frontend && npm run lint
|
||||
|
||||
- name: Type check
|
||||
run: cd frontend && npx tsc --noEmit
|
||||
|
||||
- name: Unit tests
|
||||
run: cd frontend && npm run test:ci
|
||||
|
||||
# ── Docker build 验证 ──────────────────────────────────
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend-lint-test, frontend-lint-test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build backend image
|
||||
run: docker build -t geo-backend:test ./backend
|
||||
|
||||
- name: Build frontend image
|
||||
run: docker build -t geo-frontend:test ./frontend
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
name: PR Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
pr-lint-test:
|
||||
name: Lint & Test (PR)
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: geo_test
|
||||
ports: ['5432:5432']
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7
|
||||
ports: ['6379:6379']
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# ── 后端检查 ──────────────────────────────────
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install backend dependencies
|
||||
run: |
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
pip install ruff pytest-cov
|
||||
|
||||
- name: Backend lint
|
||||
run: cd backend && ruff check app/
|
||||
|
||||
- name: Backend tests
|
||||
env:
|
||||
DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/geo_test
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
JWT_SECRET: test-secret-key-minimum-32-characters-long
|
||||
ENVIRONMENT: test
|
||||
run: |
|
||||
cd backend
|
||||
pytest tests/ -v --tb=short --cov=app --cov-report=xml
|
||||
|
||||
# ── 前端检查 ──────────────────────────────────
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: cd frontend && npm ci
|
||||
|
||||
- name: Frontend lint
|
||||
run: cd frontend && npm run lint
|
||||
|
||||
- name: Frontend type check
|
||||
run: cd frontend && npx tsc --noEmit
|
||||
|
||||
- name: Frontend unit tests
|
||||
run: cd frontend && npm run test:ci
|
||||
|
||||
# ── PR 状态注释 ──────────────────────────────────
|
||||
- name: Comment PR status
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { context, github } = require('@actions/github');
|
||||
const status = '${{ job.status }}';
|
||||
const emoji = status === 'success' ? '✅' : '❌';
|
||||
const body = `## CI 检查结果 ${emoji}\n\n**状态**: ${status}\n\n| 检查项 | 状态 |\n|--------|------|\n| 后端 Lint | ${{ steps.Backend lint.outcome || 'N/A' }} |\n| 后端测试 | ${{ steps.Backend tests.outcome || 'N/A' }} |\n| 前端 Lint | ${{ steps.Frontend lint.outcome || 'N/A' }} |\n| 前端测试 | ${{ steps.Frontend unit tests.outcome || 'N/A' }} |`;
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: body
|
||||
});
|
||||
continue-on-error: true
|
||||
|
|
@ -14,7 +14,10 @@ backend/.venv/
|
|||
|
||||
# Environment
|
||||
.env
|
||||
backend/.env
|
||||
frontend/.env.local
|
||||
# 不忽略 .env.example(供新成员参考)
|
||||
!.env.example
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
|
@ -41,3 +44,14 @@ redis_data/
|
|||
.npm-cache/
|
||||
.pytest_cache/
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# Test artifacts
|
||||
frontend/test-results/
|
||||
frontend/playwright-report/
|
||||
|
||||
# Temp debug files
|
||||
login_headers.txt
|
||||
login_resp.json
|
||||
test_login.js
|
||||
test_login2.js
|
||||
testfile2.txt
|
||||
|
|
|
|||
|
|
@ -9,34 +9,49 @@
|
|||
- [backend/app/api/reports.py](file://backend/app/api/reports.py)
|
||||
- [backend/app/api/subscriptions.py](file://backend/app/api/subscriptions.py)
|
||||
- [backend/app/api/admin.py](file://backend/app/api/admin.py)
|
||||
- [backend/app/api/agents.py](file://backend/app/api/agents.py)
|
||||
- [backend/app/api/analytics.py](file://backend/app/api/analytics.py)
|
||||
- [backend/app/api/lifecycle.py](file://backend/app/api/lifecycle.py)
|
||||
- [backend/app/api/knowledge.py](file://backend/app/api/knowledge.py)
|
||||
- [backend/app/api/deps.py](file://backend/app/api/deps.py)
|
||||
- [backend/app/middleware/rate_limit.py](file://backend/app/middleware/rate_limit.py)
|
||||
- [backend/app/schemas/auth.py](file://backend/app/schemas/auth.py)
|
||||
- [backend/app/schemas/query.py](file://backend/app/schemas/query.py)
|
||||
- [backend/app/schemas/citation.py](file://backend/app/schemas/citation.py)
|
||||
- [backend/app/schemas/subscription.py](file://backend/app/schemas/subscription.py)
|
||||
- [backend/app/schemas/analytics.py](file://backend/app/schemas/analytics.py)
|
||||
- [backend/app/schemas/lifecycle.py](file://backend/app/schemas/lifecycle.py)
|
||||
- [backend/app/schemas/knowledge.py](file://backend/app/schemas/knowledge.py)
|
||||
- [backend/app/services/auth.py](file://backend/app/services/auth.py)
|
||||
- [backend/app/services/query.py](file://backend/app/services/query.py)
|
||||
- [backend/app/services/citation.py](file://backend/app/services/citation.py)
|
||||
- [backend/app/services/subscription.py](file://backend/app/services/subscription.py)
|
||||
- [backend/app/services/admin.py](file://backend/app/services/admin.py)
|
||||
- [backend/app/services/analytics/insights.py](file://backend/app/services/analytics/insights.py)
|
||||
- [backend/app/services/analytics/tracker.py](file://backend/app/services/analytics/tracker.py)
|
||||
- [backend/app/services/knowledge/rag_service.py](file://backend/app/services/knowledge/rag_service.py)
|
||||
- [backend/app/services/knowledge/chunker.py](file://backend/app/services/knowledge/chunker.py)
|
||||
- [backend/app/services/knowledge/embedder.py](file://backend/app/services/knowledge/embedder.py)
|
||||
- [backend/app/services/knowledge/retriever.py](file://backend/app/services/knowledge/retriever.py)
|
||||
- [backend/app/config.py](file://backend/app/config.py)
|
||||
- [backend/app/models/user.py](file://backend/app/models/user.py)
|
||||
- [backend/app/models/query.py](file://backend/app/models/query.py)
|
||||
- [backend/app/models/citation_record.py](file://backend/app/models/citation_record.py)
|
||||
- [backend/app/models/query_task.py](file://backend/app/models/query_task.py)
|
||||
- [backend/app/models/subscription.py](file://backend/app/models/subscription.py)
|
||||
- [backend/app/models/agent.py](file://backend/app/models/agent.py)
|
||||
- [backend/app/models/lifecycle.py](file://backend/app/models/lifecycle.py)
|
||||
- [backend/app/models/knowledge.py](file://backend/app/models/knowledge.py)
|
||||
</cite>
|
||||
|
||||
## 更新摘要
|
||||
**所做更改**
|
||||
- 新增订阅管理API模块,包含套餐查询、订阅管理、历史记录等功能
|
||||
- 新增管理员API模块,包含系统统计、用户管理、权限控制等功能
|
||||
- 新增限流中间件,提供多层级请求限制保护
|
||||
- 新增PDF报告导出功能,扩展报告导出格式
|
||||
- 更新认证API,新增忘记密码、邮箱验证、密码修改等端点
|
||||
- 完善错误处理和状态码说明
|
||||
- 更新架构图以反映新增模块
|
||||
- 新增代理管理API模块,支持AI Agent的注册、配置管理、任务分发与监控
|
||||
- 新增分析监控API模块,提供内容发布追踪、效果指标管理、洞察生成与排行榜功能
|
||||
- 新增生命周期管理API模块,支持品牌项目全生命周期管理与阶段进度跟踪
|
||||
- 新增知识库API模块,提供知识库CRUD、文档管理、向量化检索与RAG服务
|
||||
- 更新架构图以反映新增的四个核心API模块
|
||||
- 完善错误处理和状态码说明,新增各模块特有的错误场景
|
||||
|
||||
## 目录
|
||||
1. [简介](#简介)
|
||||
|
|
@ -50,10 +65,10 @@
|
|||
9. [结论](#结论)
|
||||
|
||||
## 简介
|
||||
本文件为GEO平台的完整API接口文档,涵盖认证、查询管理、引用数据、报告导出、订阅管理和管理员管理等核心功能模块。文档详细记录了所有RESTful API端点的HTTP方法、URL模式、请求参数与响应格式,并说明了JWT令牌管理、用户注册登录、权限验证机制、任务创建与执行、数据查询与统计分析、CSV和PDF格式报告导出流程,以及订阅管理和系统管理功能。
|
||||
本文件为GEO平台的完整API接口文档,涵盖认证、查询管理、引用数据、报告导出、订阅管理、管理员管理、代理管理、分析监控、生命周期管理和知识库等核心功能模块。文档详细记录了所有RESTful API端点的HTTP方法、URL模式、请求参数与响应格式,并说明了JWT令牌管理、用户注册登录、权限验证机制、任务创建与执行、数据查询与统计分析、CSV和PDF格式报告导出流程,以及订阅管理、系统管理、AI代理调度、内容分析追踪、项目生命周期管理和智能知识检索功能。
|
||||
|
||||
## 项目结构
|
||||
后端采用FastAPI框架,按功能模块组织API路由:认证(/api/v1/auth)、查询词(/api/v1/queries)、引用数据(/api/v1/citations)、报告(/api/v1/reports)、订阅管理(/api/v1/subscriptions)、管理员(/api/v1/admin)。应用启动时初始化数据库模型并启动查询调度器,同时启用CORS允许前端localhost:3000访问,集成限流中间件和请求日志中间件。
|
||||
后端采用FastAPI框架,按功能模块组织API路由:认证(/api/v1/auth)、查询词(/api/v1/queries)、引用数据(/api/v1/citations)、报告(/api/v1/reports)、订阅管理(/api/v1/subscriptions)、管理员(/api/v1/admin)、代理管理(/api/v1/agents)、分析监控(/api/v1/analytics)、生命周期管理(/api/v1/lifecycle)、知识库(/api/v1/knowledge)。应用启动时初始化数据库模型并启动查询调度器,同时启用CORS允许前端localhost:3000访问,集成限流中间件和请求日志中间件。
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
|
|
@ -63,22 +78,25 @@ A --> D["引用数据路由<br/>backend/app/api/citations.py"]
|
|||
A --> E["报告路由<br/>backend/app/api/reports.py"]
|
||||
A --> F["订阅管理路由<br/>backend/app/api/subscriptions.py"]
|
||||
A --> G["管理员路由<br/>backend/app/api/admin.py"]
|
||||
A --> H["依赖注入与认证中间件<br/>backend/app/api/deps.py"]
|
||||
A --> I["限流中间件<br/>backend/app/middleware/rate_limit.py"]
|
||||
A --> J["配置中心<br/>backend/app/config.py"]
|
||||
A --> K["数据库模型<br/>backend/app/models/*.py"]
|
||||
A --> L["业务服务层<br/>backend/app/services/*.py"]
|
||||
A --> M["数据传输对象<br/>backend/app/schemas/*.py"]
|
||||
A --> H["代理管理路由<br/>backend/app/api/agents.py"]
|
||||
A --> I["分析监控路由<br/>backend/app/api/analytics.py"]
|
||||
A --> J["生命周期管理路由<br/>backend/app/api/lifecycle.py"]
|
||||
A --> K["知识库路由<br/>backend/app/api/knowledge.py"]
|
||||
A --> L["依赖注入与认证中间件<br/>backend/app/api/deps.py"]
|
||||
A --> M["限流中间件<br/>backend/app/middleware/rate_limit.py"]
|
||||
A --> N["配置中心<br/>backend/app/config.py"]
|
||||
A --> O["数据库模型<br/>backend/app/models/*.py"]
|
||||
A --> P["业务服务层<br/>backend/app/services/*.py"]
|
||||
A --> Q["数据传输对象<br/>backend/app/schemas/*.py"]
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [backend/app/main.py:12-78](file://backend/app/main.py#L12-L78)
|
||||
- [backend/app/api/auth.py:30](file://backend/app/api/auth.py#L30)
|
||||
- [backend/app/api/queries.py:12](file://backend/app/api/queries.py#L12)
|
||||
- [backend/app/api/citations.py:21](file://backend/app/api/citations.py#L21)
|
||||
- [backend/app/api/reports.py:15](file://backend/app/api/reports.py#L15)
|
||||
- [backend/app/api/subscriptions.py:23](file://backend/app/api/subscriptions.py#L23)
|
||||
- [backend/app/api/admin.py:17](file://backend/app/api/admin.py#L17)
|
||||
- [backend/app/api/agents.py:29](file://backend/app/api/agents.py#L29)
|
||||
- [backend/app/api/analytics.py:26](file://backend/app/api/analytics.py#L26)
|
||||
- [backend/app/api/lifecycle.py:24](file://backend/app/api/lifecycle.py#L24)
|
||||
- [backend/app/api/knowledge.py:38](file://backend/app/api/knowledge.py#L38)
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/main.py:1-84](file://backend/app/main.py#L1-L84)
|
||||
|
|
@ -91,22 +109,29 @@ A --> M["数据传输对象<br/>backend/app/schemas/*.py"]
|
|||
- 报告导出:支持CSV和PDF格式导出指定查询的引用记录。
|
||||
- 订阅管理:提供套餐查询、订阅创建、取消订阅、历史记录查看等功能,支持多层级权限控制。
|
||||
- 管理员管理:提供系统统计、用户管理、权限控制、计划更新等后台管理功能。
|
||||
- 数据模型与服务:用户、查询、引用记录、查询任务、订阅等模型及对应的服务逻辑。
|
||||
- 代理管理:支持AI Agent的注册、配置管理、任务分发与监控,提供任务创建、状态查询、日志查看和取消功能。
|
||||
- 分析监控:提供内容发布追踪、效果指标管理、洞察生成与排行榜功能,支持组织级别的数据分析。
|
||||
- 生命周期管理:支持品牌项目的全生命周期管理,包括项目快速启动、阶段进度跟踪、时间线事件和统计分析。
|
||||
- 知识库管理:提供知识库CRUD、文档管理、向量化检索与RAG服务,支持多种文档源和智能搜索。
|
||||
- 数据模型与服务:用户、查询、引用记录、查询任务、订阅、代理、分析、生命周期、知识库等模型及对应的服务逻辑。
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/main.py:39-84](file://backend/app/main.py#L39-L84)
|
||||
- [backend/app/api/auth.py:33-115](file://backend/app/api/auth.py#L33-L115)
|
||||
- [backend/app/api/subscriptions.py:26-77](file://backend/app/api/subscriptions.py#L26-L77)
|
||||
- [backend/app/api/admin.py:29-108](file://backend/app/api/admin.py#L29-L108)
|
||||
- [backend/app/api/agents.py:66-299](file://backend/app/api/agents.py#L66-L299)
|
||||
- [backend/app/api/analytics.py:47-243](file://backend/app/api/analytics.py#L47-L243)
|
||||
- [backend/app/api/lifecycle.py:85-297](file://backend/app/api/lifecycle.py#L85-L297)
|
||||
- [backend/app/api/knowledge.py:81-502](file://backend/app/api/knowledge.py#L81-L502)
|
||||
- [backend/app/middleware/rate_limit.py:10-83](file://backend/app/middleware/rate_limit.py#L10-L83)
|
||||
|
||||
## 架构概览
|
||||
下图展示了客户端与后端各模块之间的交互关系,包括认证流程、查询管理、引用数据处理、报告导出、订阅管理和管理员管理。
|
||||
下图展示了客户端与后端各模块之间的交互关系,包括认证流程、查询管理、引用数据处理、报告导出、订阅管理、管理员管理、代理调度、分析监控、生命周期管理和知识库检索。
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "客户端"
|
||||
FE["前端应用<br/>localhost:3000"]
|
||||
ENDUSER["终端用户"]
|
||||
end
|
||||
subgraph "后端服务"
|
||||
AUTH["认证模块<br/>/api/v1/auth"]
|
||||
|
|
@ -115,6 +140,10 @@ CITATIONS["引用数据模块<br/>/api/v1/citations"]
|
|||
REPORTS["报告模块<br/>/api/v1/reports"]
|
||||
SUBSCRIPTIONS["订阅管理模块<br/>/api/v1/subscriptions"]
|
||||
ADMIN["管理员模块<br/>/api/v1/admin"]
|
||||
AGENTS["代理管理模块<br/>/api/v1/agents"]
|
||||
ANALYTICS["分析监控模块<br/>/api/v1/analytics"]
|
||||
LIFECYCLE["生命周期管理模块<br/>/api/v1/lifecycle"]
|
||||
KNOWLEDGE["知识库模块<br/>/api/v1/knowledge"]
|
||||
DEPS["依赖注入与认证中间件"]
|
||||
RATELIMIT["限流中间件"]
|
||||
LOGGING["请求日志中间件"]
|
||||
|
|
@ -128,30 +157,54 @@ FE --> CITATIONS
|
|||
FE --> REPORTS
|
||||
FE --> SUBSCRIPTIONS
|
||||
FE --> ADMIN
|
||||
FE --> AGENTS
|
||||
FE --> ANALYTICS
|
||||
FE --> LIFECYCLE
|
||||
FE --> KNOWLEDGE
|
||||
ENDUSER --> AGENTS
|
||||
ENDUSER --> ANALYTICS
|
||||
ENDUSER --> LIFECYCLE
|
||||
ENDUSER --> KNOWLEDGE
|
||||
AUTH --> DEPS
|
||||
QUERIES --> DEPS
|
||||
CITATIONS --> DEPS
|
||||
REPORTS --> DEPS
|
||||
SUBSCRIPTIONS --> DEPS
|
||||
ADMIN --> DEPS
|
||||
AGENTS --> DEPS
|
||||
ANALYTICS --> DEPS
|
||||
LIFECYCLE --> DEPS
|
||||
KNOWLEDGE --> DEPS
|
||||
AUTH --> RATELIMIT
|
||||
QUERIES --> RATELIMIT
|
||||
CITATIONS --> RATELIMIT
|
||||
REPORTS --> RATELIMIT
|
||||
SUBSCRIPTIONS --> RATELIMIT
|
||||
ADMIN --> RATELIMIT
|
||||
AGENTS --> RATELIMIT
|
||||
ANALYTICS --> RATELIMIT
|
||||
LIFECYCLE --> RATELIMIT
|
||||
KNOWLEDGE --> RATELIMIT
|
||||
AUTH --> LOGGING
|
||||
QUERIES --> LOGGING
|
||||
CITATIONS --> LOGGING
|
||||
REPORTS --> LOGGING
|
||||
SUBSCRIPTIONS --> LOGGING
|
||||
ADMIN --> LOGGING
|
||||
AGENTS --> LOGGING
|
||||
ANALYTICS --> LOGGING
|
||||
LIFECYCLE --> LOGGING
|
||||
KNOWLEDGE --> LOGGING
|
||||
AUTH --> SERVICES
|
||||
QUERIES --> SERVICES
|
||||
CITATIONS --> SERVICES
|
||||
REPORTS --> SERVICES
|
||||
SUBSCRIPTIONS --> SERVICES
|
||||
ADMIN --> SERVICES
|
||||
AGENTS --> SERVICES
|
||||
ANALYTICS --> SERVICES
|
||||
LIFECYCLE --> SERVICES
|
||||
KNOWLEDGE --> SERVICES
|
||||
SERVICES --> MODELS
|
||||
MODELS --> CONFIG
|
||||
```
|
||||
|
|
@ -450,6 +503,208 @@ AdminAPI-->>Admin : 200 用户列表
|
|||
- [backend/app/api/admin.py:29-108](file://backend/app/api/admin.py#L29-L108)
|
||||
- [backend/app/services/admin.py:14-188](file://backend/app/services/admin.py#L14-L188)
|
||||
|
||||
### 代理管理接口
|
||||
- 接口前缀:/api/v1/agents
|
||||
- 路由与功能:
|
||||
- GET /:列出所有Agent,支持按类型和状态筛选
|
||||
- GET /{agent_name}:获取Agent详情
|
||||
- GET /{agent_name}/config:获取Agent配置
|
||||
- PUT /{agent_name}/config:更新Agent配置
|
||||
- GET /tasks/:列出任务,支持按Agent、状态、类型筛选
|
||||
- POST /tasks/:创建任务(分发给Agent),支持优先级、回调URL、超时设置
|
||||
- GET /tasks/{task_id}:获取任务状态
|
||||
- POST /tasks/{task_id}/cancel:取消任务
|
||||
- GET /tasks/{task_id}/logs:获取任务日志
|
||||
- 权限与限制:
|
||||
- 需要登录用户权限
|
||||
- 任务分发需要有效的Agent名称和配置
|
||||
- 请求参数与响应格式:
|
||||
- Agent列表:items数组与total总数
|
||||
- Agent详情:包含名称、类型、状态、描述、版本、端点、能力等
|
||||
- 配置更新:返回更新的键列表和成功消息
|
||||
- 任务创建:返回任务ID、初始状态和成功消息
|
||||
- 任务状态:包含状态、错误消息、开始/完成时间等
|
||||
- 任务日志:包含日志级别、消息、元数据和时间戳
|
||||
- 错误处理:
|
||||
- Agent不存在返回404
|
||||
- 任务不存在返回404
|
||||
- 任务分发错误返回400
|
||||
- 参数校验失败返回422
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as "客户端"
|
||||
participant AgentsAPI as "代理管理API"
|
||||
participant Dispatcher as "任务调度器"
|
||||
participant Registry as "Agent注册表"
|
||||
participant DB as "数据库"
|
||||
Client->>AgentsAPI : POST /api/v1/agents/tasks/
|
||||
AgentsAPI->>Dispatcher : 创建任务消息
|
||||
Dispatcher->>Registry : 查找可用Agent
|
||||
Registry-->>Dispatcher : Agent信息
|
||||
Dispatcher->>DB : 存储任务记录
|
||||
DB-->>Dispatcher : 任务ID
|
||||
Dispatcher-->>AgentsAPI : 任务ID与状态
|
||||
AgentsAPI-->>Client : 201 任务信息
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [backend/app/api/agents.py:186-222](file://backend/app/api/agents.py#L186-L222)
|
||||
- [backend/app/models/agent.py:98-155](file://backend/app/models/agent.py#L98-L155)
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/api/agents.py:66-299](file://backend/app/api/agents.py#L66-L299)
|
||||
- [backend/app/models/agent.py:12-206](file://backend/app/models/agent.py#L12-L206)
|
||||
|
||||
### 分析监控接口
|
||||
- 接口前缀:/api/v1/analytics
|
||||
- 路由与功能:
|
||||
- POST /publish:记录内容发布,支持内容标题、ID、平台、URL、状态、发布时间
|
||||
- PUT /metrics/{publish_id}:更新内容效果指标(追加快照),支持浏览量、点赞数、评论数、分享数、书签数、AI引用数、搜索展示量、搜索点击量、平均阅读时长、阅读完成率
|
||||
- GET /overview:获取全局效果概览,包含发布总数、总浏览量、总互动数、总AI引用数、平均参与率、平台分布
|
||||
- GET /content/{publish_id}:获取单条内容详细表现
|
||||
- GET /top:获取表现最好内容排行,支持按浏览量、点赞数、评论数、分享数、AI引用数、阅读完成率排序
|
||||
- GET /insights:获取洞察列表,支持限制数量和类型筛选
|
||||
- POST /insights/generate:触发AI生成洞察建议
|
||||
- POST /insights/{insight_id}/apply:标记洞察已应用
|
||||
- 权限与限制:
|
||||
- 需要用户关联组织权限
|
||||
- 发布记录必须属于当前组织
|
||||
- 请求参数与响应格式:
|
||||
- 发布记录:包含组织ID、内容标题、内容ID、平台、URL、状态、发布时间、创建时间
|
||||
- 指标更新:返回更新后的指标快照
|
||||
- 全局概览:包含统计指标和平台分布
|
||||
- 内容表现:包含最新指标和历史指标列表
|
||||
- 洞察列表:包含洞察ID、类型、标题、描述、建议、严重程度、应用状态
|
||||
- 错误处理:
|
||||
- 用户未关联组织返回403
|
||||
- 发布记录不存在或无权限返回404
|
||||
- 参数校验失败返回422
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as "客户端"
|
||||
participant AnalyticsAPI as "分析监控API"
|
||||
participant Tracker as "分析追踪器"
|
||||
participant Generator as "洞察生成器"
|
||||
participant DB as "数据库"
|
||||
Client->>AnalyticsAPI : POST /api/v1/analytics/publish
|
||||
AnalyticsAPI->>Tracker : 记录发布
|
||||
Tracker->>DB : 存储发布记录
|
||||
DB-->>Tracker : 发布ID
|
||||
Tracker-->>AnalyticsAPI : 发布记录
|
||||
AnalyticsAPI-->>Client : 201 发布记录
|
||||
Client->>AnalyticsAPI : POST /api/v1/analytics/insights/generate
|
||||
AnalyticsAPI->>Generator : 生成洞察
|
||||
Generator->>DB : 查询数据分析
|
||||
DB-->>Generator : 分析结果
|
||||
Generator-->>AnalyticsAPI : 洞察建议
|
||||
AnalyticsAPI-->>Client : 洞察列表
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [backend/app/api/analytics.py:47-60](file://backend/app/api/analytics.py#L47-L60)
|
||||
- [backend/app/api/analytics.py:206-212](file://backend/app/api/analytics.py#L206-L212)
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/api/analytics.py:47-243](file://backend/app/api/analytics.py#L47-L243)
|
||||
- [backend/app/schemas/analytics.py:14-145](file://backend/app/schemas/analytics.py#L14-L145)
|
||||
- [backend/app/services/analytics/insights.py](file://backend/app/services/analytics/insights.py)
|
||||
- [backend/app/services/analytics/tracker.py](file://backend/app/services/analytics/tracker.py)
|
||||
|
||||
### 生命周期管理接口
|
||||
- 接口前缀:/api/v1/lifecycle
|
||||
- 路由与功能:
|
||||
- GET /projects/stats:获取项目统计信息,包含项目总数、活跃项目数、阶段分布、完成率
|
||||
- GET /projects/{project_id}/timeline:获取项目时间线事件,包含创建事件和各阶段开始/完成事件
|
||||
- POST /projects/quick-start:项目快速启动,创建品牌基建阶段的项目
|
||||
- GET /projects/{project_id}/stages:获取项目阶段列表
|
||||
- PUT /projects/{project_id}/stages/{stage_number}:更新项目阶段状态,支持开始时间、完成时间、备注、指标
|
||||
- 权限与限制:
|
||||
- 需要用户关联组织权限
|
||||
- 项目必须属于当前组织
|
||||
- 请求参数与响应格式:
|
||||
- 项目统计:包含总数、活跃数、阶段分布、完成率
|
||||
- 时间线事件:包含事件类型、描述、时间戳、阶段编号
|
||||
- 项目创建:返回创建的项目和成功消息
|
||||
- 阶段更新:返回更新后的阶段详情
|
||||
- 错误处理:
|
||||
- 用户未关联组织返回404
|
||||
- 项目不存在返回404
|
||||
- 阶段不存在返回404
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as "客户端"
|
||||
participant LifecycleAPI as "生命周期API"
|
||||
participant DB as "数据库"
|
||||
Client->>LifecycleAPI : POST /api/v1/lifecycle/projects/quick-start
|
||||
LifecycleAPI->>DB : 创建组织如不存在
|
||||
DB-->>LifecycleAPI : 组织ID
|
||||
LifecycleAPI->>DB : 创建项目
|
||||
DB-->>LifecycleAPI : 项目ID
|
||||
LifecycleAPI->>DB : 创建5个阶段
|
||||
DB-->>LifecycleAPI : 阶段列表
|
||||
LifecycleAPI-->>Client : 201 项目详情
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [backend/app/api/lifecycle.py:190-230](file://backend/app/api/lifecycle.py#L190-L230)
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/api/lifecycle.py:85-297](file://backend/app/api/lifecycle.py#L85-L297)
|
||||
- [backend/app/schemas/lifecycle.py:9-68](file://backend/app/schemas/lifecycle.py#L9-L68)
|
||||
- [backend/app/models/lifecycle.py:12-92](file://backend/app/models/lifecycle.py#L12-L92)
|
||||
|
||||
### 知识库接口
|
||||
- 接口前缀:/api/v1/knowledge
|
||||
- 路由与功能:
|
||||
- POST /bases:创建知识库,支持名称、类型、描述
|
||||
- GET /bases:列出知识库,支持按类型筛选
|
||||
- GET /bases/{kb_id}:获取知识库详情
|
||||
- DELETE /bases/{kb_id}:删除知识库(级联删除文档和块)
|
||||
- POST /bases/{kb_id}/documents:上传文档,支持文本、URL、Markdown源
|
||||
- GET /bases/{kb_id}/documents:列出文档
|
||||
- DELETE /bases/{kb_id}/documents/{doc_id}:删除文档(级联删除块)
|
||||
- GET /bases/{kb_id}/documents/{doc_id}/chunks:预览文档块
|
||||
- POST /search:知识库搜索,支持查询、知识库ID列表、返回数量
|
||||
- 权限与限制:
|
||||
- 需要用户关联组织权限
|
||||
- 文档上传需要有效的知识库ID
|
||||
- 请求参数与响应格式:
|
||||
- 知识库创建:返回知识库ID、名称、类型、描述、文档数量、状态、创建时间
|
||||
- 文档上传:返回文档ID、标题、源类型、URL、块数量、状态、错误消息、创建时间
|
||||
- 搜索结果:包含块ID、内容、分数、文档ID、标题、元数据
|
||||
- 错误处理:
|
||||
- 用户未关联组织返回400
|
||||
- 知识库不存在返回404
|
||||
- 文档不存在返回404
|
||||
- URL内容获取失败返回400
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as "客户端"
|
||||
participant KnowledgeAPI as "知识库API"
|
||||
participant RAGService as "RAG服务"
|
||||
participant DB as "数据库"
|
||||
Client->>KnowledgeAPI : POST /api/v1/knowledge/search
|
||||
KnowledgeAPI->>RAGService : 执行向量搜索
|
||||
RAGService->>DB : 查询相似文档
|
||||
DB-->>RAGService : 匹配结果
|
||||
RAGService-->>KnowledgeAPI : 搜索结果
|
||||
KnowledgeAPI->>DB : 记录搜索日志
|
||||
DB-->>KnowledgeAPI : 日志ID
|
||||
KnowledgeAPI-->>Client : 200 搜索结果
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [backend/app/api/knowledge.py:424-501](file://backend/app/api/knowledge.py#L424-L501)
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/api/knowledge.py:81-502](file://backend/app/api/knowledge.py#L81-L502)
|
||||
- [backend/app/schemas/knowledge.py:9-77](file://backend/app/schemas/knowledge.py#L9-L77)
|
||||
- [backend/app/models/knowledge.py:22-213](file://backend/app/models/knowledge.py#L22-L213)
|
||||
|
||||
### 限流中间件
|
||||
- 功能特性:
|
||||
- 基于内存的简易限流中间件,无需Redis依赖
|
||||
|
|
@ -503,12 +758,19 @@ PassThrough --> Next
|
|||
- User与Subscription一对多,级联删除
|
||||
- Query与CitationRecord、QueryTask一对多,级联删除
|
||||
- CitationRecord外键关联Query
|
||||
- AgentRegistry与AgentConfig、AgentTask、AgentTaskLog一对多,级联删除
|
||||
- LifecycleProject与ProjectStage一对多,级联删除
|
||||
- KnowledgeBase与KnowledgeDocument、KnowledgeChunk一对多,级联删除
|
||||
- 服务层职责:
|
||||
- 认证服务:密码哈希、JWT签发与校验、用户注册与登录、密码重置、邮箱验证
|
||||
- 查询服务:分页查询、创建/更新/删除、频率与下次查询时间计算
|
||||
- 引用服务:引用记录查询、统计分析、立即执行任务、CSV和PDF导出
|
||||
- 订阅服务:套餐管理、订阅创建与取消、历史记录查询
|
||||
- 管理员服务:系统统计、用户管理、权限控制、计划更新
|
||||
- 代理服务:Agent注册表管理、任务调度、配置管理、日志记录
|
||||
- 分析服务:发布追踪、指标管理、洞察生成、排行榜计算
|
||||
- 生命周期服务:项目管理、阶段跟踪、统计分析
|
||||
- 知识库服务:文档管理、向量化处理、RAG检索、搜索日志
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
|
|
@ -554,13 +816,19 @@ class CitationRecord {
|
|||
}
|
||||
class QueryTask {
|
||||
+UUID id
|
||||
+UUID query_id
|
||||
+string platform
|
||||
+UUID agent_id
|
||||
+string task_type
|
||||
+string status
|
||||
+int priority
|
||||
+dict input_data
|
||||
+dict output_data
|
||||
+string error_message
|
||||
+UUID organization_id
|
||||
+UUID project_id
|
||||
+datetime scheduled_at
|
||||
+datetime started_at
|
||||
+datetime completed_at
|
||||
+datetime created_at
|
||||
}
|
||||
class Subscription {
|
||||
+UUID id
|
||||
|
|
@ -573,12 +841,106 @@ class Subscription {
|
|||
+string payment_method
|
||||
+datetime created_at
|
||||
}
|
||||
class AgentRegistry {
|
||||
+UUID id
|
||||
+string name
|
||||
+string display_name
|
||||
+string agent_type
|
||||
+string description
|
||||
+string version
|
||||
+string endpoint
|
||||
+string status
|
||||
+dict capabilities
|
||||
+datetime last_heartbeat
|
||||
+datetime created_at
|
||||
+datetime updated_at
|
||||
}
|
||||
class AgentTask {
|
||||
+UUID id
|
||||
+UUID agent_id
|
||||
+string task_type
|
||||
+string status
|
||||
+int priority
|
||||
+dict input_data
|
||||
+dict output_data
|
||||
+string error_message
|
||||
+UUID organization_id
|
||||
+UUID project_id
|
||||
+datetime scheduled_at
|
||||
+datetime started_at
|
||||
+datetime completed_at
|
||||
+datetime created_at
|
||||
}
|
||||
class LifecycleProject {
|
||||
+UUID id
|
||||
+UUID organization_id
|
||||
+string brand_name
|
||||
+list brand_aliases
|
||||
+int current_stage
|
||||
+string status
|
||||
+UUID created_by
|
||||
+datetime created_at
|
||||
+datetime updated_at
|
||||
}
|
||||
class ProjectStage {
|
||||
+UUID id
|
||||
+UUID project_id
|
||||
+int stage_number
|
||||
+string status
|
||||
+datetime started_at
|
||||
+datetime completed_at
|
||||
+string notes
|
||||
+dict metrics
|
||||
}
|
||||
class KnowledgeBase {
|
||||
+UUID id
|
||||
+UUID organization_id
|
||||
+string name
|
||||
+string type
|
||||
+string description
|
||||
+int document_count
|
||||
+string status
|
||||
+UUID created_by
|
||||
+datetime created_at
|
||||
+datetime updated_at
|
||||
}
|
||||
class KnowledgeDocument {
|
||||
+UUID id
|
||||
+UUID knowledge_base_id
|
||||
+string title
|
||||
+string source_type
|
||||
+string source_url
|
||||
+string content
|
||||
+string content_hash
|
||||
+int chunk_count
|
||||
+string status
|
||||
+string error_message
|
||||
+dict extra_metadata
|
||||
+datetime created_at
|
||||
+datetime updated_at
|
||||
}
|
||||
class KnowledgeChunk {
|
||||
+UUID id
|
||||
+UUID document_id
|
||||
+string content
|
||||
+Vector embedding
|
||||
+int chunk_index
|
||||
+int token_count
|
||||
+dict extra_metadata
|
||||
+datetime created_at
|
||||
}
|
||||
User "1" --> "many" Query : "拥有"
|
||||
User "1" --> "many" Subscription : "订阅"
|
||||
User "1" --> "many" AgentTask : "创建"
|
||||
Query "1" --> "many" CitationRecord : "产生"
|
||||
Query "1" --> "many" QueryTask : "触发任务"
|
||||
CitationRecord "many" --> "1" Query : "属于"
|
||||
Subscription "many" --> "1" User : "属于"
|
||||
AgentRegistry "1" --> "many" AgentTask : "执行"
|
||||
AgentTask "many" --> "1" AgentRegistry : "属于"
|
||||
LifecycleProject "1" --> "many" ProjectStage : "包含"
|
||||
KnowledgeBase "1" --> "many" KnowledgeDocument : "包含"
|
||||
KnowledgeDocument "1" --> "many" KnowledgeChunk : "包含"
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
|
|
@ -587,6 +949,9 @@ Subscription "many" --> "1" User : "属于"
|
|||
- [backend/app/models/citation_record.py:11-42](file://backend/app/models/citation_record.py#L11-L42)
|
||||
- [backend/app/models/query_task.py:11-39](file://backend/app/models/query_task.py#L11-L39)
|
||||
- [backend/app/models/subscription.py:11-37](file://backend/app/models/subscription.py#L11-L37)
|
||||
- [backend/app/models/agent.py:12-206](file://backend/app/models/agent.py#L12-L206)
|
||||
- [backend/app/models/lifecycle.py:12-92](file://backend/app/models/lifecycle.py#L12-L92)
|
||||
- [backend/app/models/knowledge.py:22-213](file://backend/app/models/knowledge.py#L22-L213)
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/api/deps.py:16-42](file://backend/app/api/deps.py#L16-L42)
|
||||
|
|
@ -603,6 +968,9 @@ Subscription "many" --> "1" User : "属于"
|
|||
- 批量操作:任务创建采用批量插入,减少事务开销。
|
||||
- 限流保护:多层级限流中间件防止恶意请求和滥用,保护系统稳定性。
|
||||
- PDF生成:PDF导出使用FPDF库,支持中文字体加载,提供完整的品牌曝光度分析报告。
|
||||
- 向量检索:知识库模块使用pgvector扩展进行高效相似性搜索,支持大规模文档向量化处理。
|
||||
- 代理调度:Agent任务采用Redis队列进行异步处理,支持高并发任务分发与监控。
|
||||
- 分析追踪:分析模块使用专门的追踪器和洞察生成器,优化大数据量的统计分析性能。
|
||||
|
||||
## 故障排除指南
|
||||
- 认证相关
|
||||
|
|
@ -625,6 +993,24 @@ Subscription "many" --> "1" User : "属于"
|
|||
- 管理员功能
|
||||
- 403 非管理员权限:确认当前用户具有管理员权限(is_admin=true)。
|
||||
- 404 用户不存在:确认用户ID是否有效。
|
||||
- 代理管理
|
||||
- 404 Agent不存在:确认Agent名称是否正确。
|
||||
- 404 任务不存在:确认任务ID格式和权限。
|
||||
- 400 任务分发错误:检查Agent配置和可用性。
|
||||
- 422 参数校验失败:检查请求体格式和字段约束。
|
||||
- 分析监控
|
||||
- 403 用户未关联组织:确认用户已加入有效组织。
|
||||
- 404 发布记录不存在或无权限:检查发布ID和组织权限。
|
||||
- 422 指标更新失败:检查数值范围和字段类型。
|
||||
- 生命周期管理
|
||||
- 404 用户未关联组织:确认用户已创建或加入组织。
|
||||
- 404 项目不存在:检查项目ID和组织归属。
|
||||
- 404 阶段不存在:检查阶段编号和项目归属。
|
||||
- 知识库管理
|
||||
- 400 用户未关联组织:确认用户已加入组织。
|
||||
- 404 知识库不存在:检查知识库ID和组织权限。
|
||||
- 404 文档不存在:检查文档ID和知识库归属。
|
||||
- 400 URL内容获取失败:检查URL可访问性和内容格式。
|
||||
- 限流保护
|
||||
- 429 请求过于频繁:检查是否超过限流阈值,等待冷却时间后重试。
|
||||
- 429 查询执行过于频繁:确认查询执行频率是否超过每小时10次限制。
|
||||
|
|
@ -636,7 +1022,11 @@ Subscription "many" --> "1" User : "属于"
|
|||
- [backend/app/api/reports.py:25-29](file://backend/app/api/reports.py#L25-L29)
|
||||
- [backend/app/api/subscriptions.py:53-57](file://backend/app/api/subscriptions.py#L53-L57)
|
||||
- [backend/app/api/admin.py:22-25](file://backend/app/api/admin.py#L22-L25)
|
||||
- [backend/app/api/agents.py:84-88](file://backend/app/api/agents.py#L84-L88)
|
||||
- [backend/app/api/analytics.py:36-40](file://backend/app/api/analytics.py#L36-L40)
|
||||
- [backend/app/api/lifecycle.py:146](file://backend/app/api/lifecycle.py#L146)
|
||||
- [backend/app/api/knowledge.py:92-96](file://backend/app/api/knowledge.py#L92-L96)
|
||||
- [backend/app/middleware/rate_limit.py:47-49](file://backend/app/middleware/rate_limit.py#L47-L49)
|
||||
|
||||
## 结论
|
||||
GEO平台API采用清晰的模块化设计,围绕用户、查询、引用、报告、订阅与管理六大领域构建RESTful接口。通过JWT认证与严格的资源所有权校验,保障了数据安全;通过统计分析与CSV/PDF导出,满足了业务洞察与合规需求;通过订阅管理和管理员功能,提供了完整的商业运营支持;通过多层级限流中间件,确保了系统的稳定性和安全性。建议在生产环境中进一步完善错误日志、监控指标与缓存策略,持续优化查询性能与用户体验。
|
||||
GEO平台API采用清晰的模块化设计,围绕用户、查询、引用、报告、订阅、管理、代理、分析、生命周期和知识库十大领域构建RESTful接口。通过JWT认证与严格的资源所有权校验,保障了数据安全;通过统计分析与CSV/PDF导出,满足了业务洞察与合规需求;通过订阅管理、管理员功能、AI代理调度、内容分析追踪、项目生命周期管理和智能知识检索,提供了完整的商业运营支持;通过多层级限流中间件,确保了系统的稳定性和安全性。新增的四个核心API模块显著增强了平台的智能化水平,包括AI代理的自动化任务处理能力、深度的内容分析与优化建议、完整的项目生命周期管理以及强大的智能知识检索功能。建议在生产环境中进一步完善错误日志、监控指标与缓存策略,持续优化查询性能与用户体验。
|
||||
|
|
@ -25,16 +25,24 @@
|
|||
- [components/layout/sidebar.tsx](file://frontend/components/layout/sidebar.tsx)
|
||||
- [components/ui/tabs.tsx](file://frontend/components/ui/tabs.tsx)
|
||||
- [app/api/auth/[...nextauth]/route.ts](file://frontend/app/api/auth/[...nextauth]/route.ts)
|
||||
- [playwright.config.ts](file://frontend/playwright.config.ts)
|
||||
- [e2e/tests/dashboard-health.spec.ts](file://frontend/e2e/tests/dashboard-health.spec.ts)
|
||||
- [e2e/tests/login.spec.ts](file://frontend/e2e/tests/login.spec.ts)
|
||||
- [e2e/pages/dashboard.page.ts](file://frontend/e2e/pages/dashboard.page.ts)
|
||||
- [e2e/pages/login.page.ts](file://frontend/e2e/pages/login.page.ts)
|
||||
- [components/business/index.ts](file://frontend/components/business/index.ts)
|
||||
- [components/business/agent-status-card.tsx](file://frontend/components/business/agent-status-card.tsx)
|
||||
- [components/business/alert-card.tsx](file://frontend/components/business/alert-card.tsx)
|
||||
- [components/dashboard/index.ts](file://frontend/components/dashboard/index.ts)
|
||||
</cite>
|
||||
|
||||
## 更新摘要
|
||||
**所做变更**
|
||||
- 新增认证页面体系:忘记密码、重置密码、邮箱验证、注册页面
|
||||
- 新增管理员仪表板页面,支持用户管理和系统统计
|
||||
- 新增设置页面重构,采用标签页布局管理个人资料、密码修改和订阅管理
|
||||
- 新增API客户端增强,支持完整认证端点(注册、忘记密码、重置密码、邮箱验证、密码修改、个人资料更新)
|
||||
- 新增认证状态管理,支持管理员权限和会话状态扩展
|
||||
- 更新组件结构,新增Tabs组件和增强的侧边栏导航
|
||||
- 新增E2E测试框架:引入Playwright测试套件,包含登录页面测试、健康状态Dashboard测试、响应式设计测试
|
||||
- 新增业务组件库:扩展GEO特定业务组件,包括Agent状态卡片、告警卡片、指标卡片等
|
||||
- 新增仪表板布局重构:改进侧边导航结构,支持固定侧边栏和头部导航
|
||||
- 新增测试脚本:添加E2E测试命令,支持多浏览器测试和并行执行
|
||||
- 新增业务组件索引:提供统一的业务组件导出接口
|
||||
|
||||
## 目录
|
||||
1. [引言](#引言)
|
||||
|
|
@ -42,15 +50,19 @@
|
|||
3. [核心组件](#核心组件)
|
||||
4. [架构总览](#架构总览)
|
||||
5. [详细组件分析](#详细组件分析)
|
||||
6. [依赖分析](#依赖分析)
|
||||
7. [性能考虑](#性能考虑)
|
||||
8. [故障排除指南](#故障排除指南)
|
||||
9. [结论](#结论)
|
||||
10. [附录](#附录)
|
||||
6. [E2E测试框架](#e2e测试框架)
|
||||
7. [业务组件库](#业务组件库)
|
||||
8. [依赖分析](#依赖分析)
|
||||
9. [性能考虑](#性能考虑)
|
||||
10. [故障排除指南](#故障排除指南)
|
||||
11. [结论](#结论)
|
||||
12. [附录](#附录)
|
||||
|
||||
## 引言
|
||||
本文件为 GEO 前端系统的架构文档,聚焦于基于 Next.js 14 的应用架构设计,涵盖 App Router 页面组织、服务器组件与客户端组件的混合使用模式;认证系统(NextAuth.js 集成、会话管理与路由保护);UI 组件库设计理念与复用策略;数据获取与状态管理;错误处理机制;以及响应式设计、可访问性与性能优化等最佳实践。
|
||||
|
||||
**更新** 新增E2E测试框架集成、业务组件库扩展、仪表板布局重构等前端架构变化。
|
||||
|
||||
## 项目结构
|
||||
前端采用 Next.js 14 App Router 结构,页面按功能域分组(通过路由组 `(auth)` 和 `(dashboard)` 实现),根布局统一注入全局样式与 Provider,认证相关 API 路由集中于 `/api/auth/[...nextauth]`。系统现已扩展为完整的认证体系,包含登录、注册、忘记密码、重置密码、邮箱验证等页面,以及管理员仪表板和设置页面。
|
||||
|
||||
|
|
@ -72,13 +84,19 @@ A --> O["tailwind.config.ts<br/>Tailwind 配置"]
|
|||
A --> P["next.config.mjs<br/>Next 配置"]
|
||||
Q["components/layout/sidebar.tsx<br/>侧边栏导航"] --> D
|
||||
R["components/ui/tabs.tsx<br/>标签页组件"] --> M
|
||||
S["e2e/ 目录<br/>E2E测试框架"] --> T["playwright.config.ts<br/>测试配置"]
|
||||
S --> U["e2e/tests/ 目录<br/>测试用例"]
|
||||
S --> V["e2e/pages/ 目录<br/>页面对象"]
|
||||
W["components/business/ 目录<br/>业务组件库"] --> X["agent-status-card.tsx<br/>代理状态卡片"]
|
||||
W --> Y["alert-card.tsx<br/>告警卡片"]
|
||||
W --> Z["metric-card.tsx<br/>指标卡片"]
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [app/layout.tsx:1-37](file://frontend/app/layout.tsx#L1-L37)
|
||||
- [components/providers.tsx:1-9](file://frontend/components/providers.tsx#L1-L9)
|
||||
- [app/(auth)/layout.tsx](file://frontend/app/(auth)/layout.tsx#L1-L12)
|
||||
- [app/(dashboard)/layout.tsx](file://frontend/app/(dashboard)/layout.tsx#L1-L27)
|
||||
- [app/(dashboard)/layout.tsx:1-146](file://frontend/app/(dashboard)/layout.tsx#L1-L146)
|
||||
- [app/(auth)/login/page.tsx](file://frontend/app/(auth)/login/page.tsx#L1-L93)
|
||||
- [app/(auth)/register/page.tsx](file://frontend/app/(auth)/register/page.tsx#L1-L128)
|
||||
- [app/(auth)/forgot-password/page.tsx](file://frontend/app/(auth)/forgot-password/page.tsx#L1-L101)
|
||||
|
|
@ -90,22 +108,24 @@ R["components/ui/tabs.tsx<br/>标签页组件"] --> M
|
|||
- [components/layout/sidebar.tsx:1-63](file://frontend/components/layout/sidebar.tsx#L1-L63)
|
||||
- [components/ui/tabs.tsx:1-56](file://frontend/components/ui/tabs.tsx#L1-L56)
|
||||
- [app/api/auth/[...nextauth]/route.ts](file://frontend/app/api/auth/[...nextauth]/route.ts#L1-L7)
|
||||
- [tailwind.config.ts:1-57](file://frontend/tailwind.config.ts#L1-L57)
|
||||
- [tailwind.config.ts:1-121](file://frontend/tailwind.config.ts#L1-L121)
|
||||
- [next.config.mjs:1-5](file://frontend/next.config.mjs#L1-L5)
|
||||
- [playwright.config.ts:1-39](file://frontend/playwright.config.ts#L1-L39)
|
||||
- [components/business/index.ts:1-29](file://frontend/components/business/index.ts#L1-L29)
|
||||
|
||||
**章节来源**
|
||||
- [app/layout.tsx:1-37](file://frontend/app/layout.tsx#L1-L37)
|
||||
- [components/providers.tsx:1-9](file://frontend/components/providers.tsx#L1-L9)
|
||||
- [app/(auth)/layout.tsx](file://frontend/app/(auth)/layout.tsx#L1-L12)
|
||||
- [app/(dashboard)/layout.tsx](file://frontend/app/(dashboard)/layout.tsx#L1-L27)
|
||||
- [tailwind.config.ts:1-57](file://frontend/tailwind.config.ts#L1-L57)
|
||||
- [app/(dashboard)/layout.tsx:1-146](file://frontend/app/(dashboard)/layout.tsx#L1-L146)
|
||||
- [tailwind.config.ts:1-121](file://frontend/tailwind.config.ts#L1-L121)
|
||||
- [next.config.mjs:1-5](file://frontend/next.config.mjs#L1-L5)
|
||||
|
||||
## 核心组件
|
||||
- 根布局与全局样式:定义站点元数据、字体变量与全局样式入口,并包裹应用上下文 Provider。
|
||||
- 会话提供者:在客户端注入 SessionProvider,使整个应用可访问 NextAuth 会话状态。
|
||||
- 认证路由组:提供登录/注册/忘记密码/重置密码/邮箱验证等认证页面的统一容器样式。
|
||||
- 仪表盘路由组:提供侧边栏与头部导航,同时在服务器端校验会话,未登录则重定向至登录页。
|
||||
- 仪表盘路由组:提供侧边栏与头部导航,支持固定布局和活动状态跟踪,未登录则重定向至登录页。
|
||||
- 认证配置:NextAuth 选项,使用凭据提供者对接后端认证接口,JWT 会话策略,回调处理 token 与 session 映射,支持管理员权限。
|
||||
- 类型扩展:为 NextAuth 的 Session 与 JWT 扩展自定义字段,确保类型安全,包含管理员标识。
|
||||
- API 客户端:封装带鉴权头的通用请求方法,统一错误处理与响应解析,支持完整认证端点。
|
||||
|
|
@ -117,7 +137,7 @@ R["components/ui/tabs.tsx<br/>标签页组件"] --> M
|
|||
- [app/layout.tsx:1-37](file://frontend/app/layout.tsx#L1-L37)
|
||||
- [components/providers.tsx:1-9](file://frontend/components/providers.tsx#L1-L9)
|
||||
- [app/(auth)/layout.tsx](file://frontend/app/(auth)/layout.tsx#L1-L12)
|
||||
- [app/(dashboard)/layout.tsx](file://frontend/app/(dashboard)/layout.tsx#L1-L27)
|
||||
- [app/(dashboard)/layout.tsx:1-146](file://frontend/app/(dashboard)/layout.tsx#L1-L146)
|
||||
- [lib/auth.ts:1-73](file://frontend/lib/auth.ts#L1-L73)
|
||||
- [types/next-auth.d.ts:1-29](file://frontend/types/next-auth.d.ts#L1-L29)
|
||||
- [lib/api.ts:1-154](file://frontend/lib/api.ts#L1-L154)
|
||||
|
|
@ -151,6 +171,8 @@ AH["NextAuth 路由<br/>app/api/auth/[...nextauth]/route.ts"]
|
|||
UI["UI 组件库<br/>components/ui/*"]
|
||||
LH["头部组件<br/>components/layout/header.tsx"]
|
||||
LS["侧边栏组件<br/>components/layout/sidebar.tsx"]
|
||||
BC["业务组件库<br/>components/business/*"]
|
||||
DP["Dashboard组件<br/>components/dashboard/*"]
|
||||
end
|
||||
subgraph "认证服务"
|
||||
NA["NextAuth 服务<br/>lib/auth.ts"]
|
||||
|
|
@ -159,6 +181,13 @@ subgraph "后端 API"
|
|||
API["业务 API 客户端<br/>lib/api.ts"]
|
||||
BE["后端服务<br/>backend/app/*"]
|
||||
end
|
||||
subgraph "E2E测试框架"
|
||||
PW["Playwright<br/>playwright.config.ts"]
|
||||
DS["Dashboard测试<br/>e2e/tests/dashboard-health.spec.ts"]
|
||||
LSpec["登录测试<br/>e2e/tests/login.spec.ts"]
|
||||
DPg["Dashboard页面<br/>e2e/pages/dashboard.page.ts"]
|
||||
LPg["Login页面<br/>e2e/pages/login.page.ts"]
|
||||
end
|
||||
U --> RL
|
||||
RL --> PR
|
||||
PR --> AL
|
||||
|
|
@ -181,13 +210,17 @@ ADMIN --> API
|
|||
SETTINGS --> API
|
||||
NA --> BE
|
||||
API --> BE
|
||||
PW --> DS
|
||||
PW --> LSpec
|
||||
DS --> DPg
|
||||
LSpec --> LPg
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [app/layout.tsx:1-37](file://frontend/app/layout.tsx#L1-L37)
|
||||
- [components/providers.tsx:1-9](file://frontend/components/providers.tsx#L1-L9)
|
||||
- [app/(auth)/layout.tsx](file://frontend/app/(auth)/layout.tsx#L1-L12)
|
||||
- [app/(dashboard)/layout.tsx](file://frontend/app/(dashboard)/layout.tsx#L1-L27)
|
||||
- [app/(dashboard)/layout.tsx:1-146](file://frontend/app/(dashboard)/layout.tsx#L1-L146)
|
||||
- [app/(auth)/login/page.tsx](file://frontend/app/(auth)/login/page.tsx#L1-L93)
|
||||
- [app/(auth)/register/page.tsx](file://frontend/app/(auth)/register/page.tsx#L1-L128)
|
||||
- [app/(auth)/forgot-password/page.tsx](file://frontend/app/(auth)/forgot-password/page.tsx#L1-L101)
|
||||
|
|
@ -201,6 +234,9 @@ API --> BE
|
|||
- [app/api/auth/[...nextauth]/route.ts](file://frontend/app/api/auth/[...nextauth]/route.ts#L1-L7)
|
||||
- [lib/auth.ts:1-73](file://frontend/lib/auth.ts#L1-L73)
|
||||
- [lib/api.ts:1-154](file://frontend/lib/api.ts#L1-L154)
|
||||
- [playwright.config.ts:1-39](file://frontend/playwright.config.ts#L1-L39)
|
||||
- [e2e/tests/dashboard-health.spec.ts:1-264](file://frontend/e2e/tests/dashboard-health.spec.ts#L1-L264)
|
||||
- [e2e/tests/login.spec.ts:1-126](file://frontend/e2e/tests/login.spec.ts#L1-L126)
|
||||
|
||||
## 详细组件分析
|
||||
|
||||
|
|
@ -208,7 +244,7 @@ API --> BE
|
|||
- 凭据提供者:使用邮箱/密码进行认证,调用后端登录接口,成功后返回包含用户信息与访问令牌的对象,支持管理员权限标识。
|
||||
- JWT 会话策略:在回调中将访问令牌与用户 ID 写入 JWT,并在 session 回调中回填到 session 对象,包含 is_admin 字段。
|
||||
- 完整认证流程:支持注册、登录、忘记密码、重置密码、邮箱验证、密码修改、个人资料更新等完整认证生命周期。
|
||||
- 路由保护:仪表盘布局在服务器端通过 getServerSession 获取会话,若无会话则重定向至登录页。
|
||||
- 路由保护:仪表盘布局在客户端通过 useSession 获取会话状态,若无会话则重定向至登录页。
|
||||
- NextAuth 路由:统一暴露 GET/POST,交由 NextAuth 处理认证生命周期。
|
||||
|
||||
```mermaid
|
||||
|
|
@ -260,7 +296,7 @@ LOGIN-->>C : "跳转到 /dashboard 或显示错误"
|
|||
- [app/(auth)/forgot-password/page.tsx](file://frontend/app/(auth)/forgot-password/page.tsx#L1-L101)
|
||||
- [app/(auth)/reset-password/page.tsx](file://frontend/app/(auth)/reset-password/page.tsx#L1-L148)
|
||||
- [app/(auth)/verify-email/page.tsx](file://frontend/app/(auth)/verify-email/page.tsx#L1-L155)
|
||||
- [app/(dashboard)/layout.tsx](file://frontend/app/(dashboard)/layout.tsx#L1-L27)
|
||||
- [app/(dashboard)/layout.tsx:1-146](file://frontend/app/(dashboard)/layout.tsx#L1-L146)
|
||||
|
||||
### 管理员仪表板系统
|
||||
- 用户管理:支持用户列表查看、搜索、分页,提供启用/禁用用户和修改套餐功能。
|
||||
|
|
@ -417,15 +453,15 @@ Tabs --> UIComponents : "标签页导航"
|
|||
**图表来源**
|
||||
- [components/ui/button.tsx:1-57](file://frontend/components/ui/button.tsx#L1-L57)
|
||||
- [components/ui/tabs.tsx:1-56](file://frontend/components/ui/tabs.tsx#L1-L56)
|
||||
- [tailwind.config.ts:1-57](file://frontend/tailwind.config.ts#L1-L57)
|
||||
- [tailwind.config.ts:1-121](file://frontend/tailwind.config.ts#L1-L121)
|
||||
|
||||
**章节来源**
|
||||
- [components/ui/button.tsx:1-57](file://frontend/components/ui/button.tsx#L1-L57)
|
||||
- [components/ui/tabs.tsx:1-56](file://frontend/components/ui/tabs.tsx#L1-L56)
|
||||
- [tailwind.config.ts:1-57](file://frontend/tailwind.config.ts#L1-L57)
|
||||
- [tailwind.config.ts:1-121](file://frontend/tailwind.config.ts#L1-L121)
|
||||
|
||||
### 服务器组件与客户端组件的混合使用
|
||||
- 服务器组件:仪表盘布局在服务器端通过 getServerSession 校验会话并进行重定向,避免客户端渲染无意义内容。
|
||||
- 服务器组件:仪表盘布局在客户端通过 useSession 校验会话并进行重定向,避免客户端渲染无意义内容。
|
||||
- 客户端组件:登录页、注册页、忘记密码页、重置密码页、邮箱验证页、仪表盘页、管理员仪表板、设置页均标记为客户端组件,以便使用 hooks(如 useSession、useRouter)与交互逻辑。
|
||||
- Provider 注入:根布局注入 Providers,使子树中的客户端组件可共享会话状态。
|
||||
- 权限路由:侧边栏根据用户权限动态渲染,管理员用户显示额外的管理后台入口。
|
||||
|
|
@ -444,13 +480,13 @@ C->>C : "根据权限渲染侧边栏"
|
|||
```
|
||||
|
||||
**图表来源**
|
||||
- [app/(dashboard)/layout.tsx](file://frontend/app/(dashboard)/layout.tsx#L1-L27)
|
||||
- [app/(dashboard)/layout.tsx:1-146](file://frontend/app/(dashboard)/layout.tsx#L1-L146)
|
||||
- [app/(dashboard)/dashboard/page.tsx](file://frontend/app/(dashboard)/dashboard/page.tsx#L1-L227)
|
||||
- [components/providers.tsx:1-9](file://frontend/components/providers.tsx#L1-L9)
|
||||
- [components/layout/sidebar.tsx:1-63](file://frontend/components/layout/sidebar.tsx#L1-L63)
|
||||
|
||||
**章节来源**
|
||||
- [app/(dashboard)/layout.tsx](file://frontend/app/(dashboard)/layout.tsx#L1-L27)
|
||||
- [app/(dashboard)/layout.tsx:1-146](file://frontend/app/(dashboard)/layout.tsx#L1-L146)
|
||||
- [app/(dashboard)/dashboard/page.tsx](file://frontend/app/(dashboard)/dashboard/page.tsx#L1-L227)
|
||||
- [components/providers.tsx:1-9](file://frontend/components/providers.tsx#L1-L9)
|
||||
- [components/layout/sidebar.tsx:1-63](file://frontend/components/layout/sidebar.tsx#L1-L63)
|
||||
|
|
@ -458,7 +494,7 @@ C->>C : "根据权限渲染侧边栏"
|
|||
### 错误处理机制
|
||||
- API 层:fetchWithAuth 在非 2xx 时解析错误消息并抛出异常,401 状态自动重定向到登录页,保证上层统一处理。
|
||||
- 页面层:各认证页面在认证失败时显示错误提示,管理员仪表板和设置页面提供详细的错误反馈和重试机制。
|
||||
- 路由保护:服务器端无会话时直接重定向,避免进入受保护页面。
|
||||
- 路由保护:客户端无会话时直接重定向,避免进入受保护页面。
|
||||
- 表单验证:设置页面各表单包含客户端验证,提供即时反馈。
|
||||
|
||||
**章节来源**
|
||||
|
|
@ -470,7 +506,148 @@ C->>C : "根据权限渲染侧边栏"
|
|||
- [app/(auth)/verify-email/page.tsx](file://frontend/app/(auth)/verify-email/page.tsx#L1-L155)
|
||||
- [app/(dashboard)/dashboard/admin/page.tsx](file://frontend/app/(dashboard)/dashboard/admin/page.tsx#L1-L435)
|
||||
- [app/(dashboard)/dashboard/settings/page.tsx](file://frontend/app/(dashboard)/dashboard/settings/page.tsx#L1-L445)
|
||||
- [app/(dashboard)/layout.tsx](file://frontend/app/(dashboard)/layout.tsx#L1-L27)
|
||||
- [app/(dashboard)/layout.tsx:1-146](file://frontend/app/(dashboard)/layout.tsx#L1-L146)
|
||||
|
||||
## E2E测试框架
|
||||
|
||||
### Playwright测试配置
|
||||
系统集成了完整的E2E测试框架,基于Playwright提供跨浏览器测试能力:
|
||||
|
||||
- **测试环境配置**:支持Chrome、Firefox、Safari三种浏览器并行测试
|
||||
- **测试执行策略**:串行执行,失败重试2次,截图仅在失败时捕获
|
||||
- **测试报告**:生成HTML格式的详细测试报告
|
||||
- **自动化启动**:测试服务器自动启动,支持热重载
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start(["开始E2E测试"]) --> Config["加载Playwright配置"]
|
||||
Config --> Browser["启动浏览器实例"]
|
||||
Browser --> Server["启动本地开发服务器"]
|
||||
Server --> Tests["执行测试用例"]
|
||||
Tests --> Report["生成测试报告"]
|
||||
Report --> End(["测试完成"])
|
||||
subgraph "测试用例"
|
||||
Test1["登录页面测试"]
|
||||
Test2["Dashboard健康状态测试"]
|
||||
Test3["响应式设计测试"]
|
||||
Test4["行动建议测试"]
|
||||
end
|
||||
subgraph "页面对象"
|
||||
Page1["LoginPage对象"]
|
||||
Page2["DashboardPage对象"]
|
||||
end
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [playwright.config.ts:1-39](file://frontend/playwright.config.ts#L1-L39)
|
||||
- [e2e/tests/login.spec.ts:1-126](file://frontend/e2e/tests/login.spec.ts#L1-L126)
|
||||
- [e2e/tests/dashboard-health.spec.ts:1-264](file://frontend/e2e/tests/dashboard-health.spec.ts#L1-L264)
|
||||
|
||||
### 测试用例覆盖范围
|
||||
- **登录页面测试**:验证页面重定向、表单元素、HTML5验证、错误处理、链接功能
|
||||
- **Dashboard健康状态测试**:验证页面标题、概览卡片、平台评分、行动建议、骨架屏
|
||||
- **响应式设计测试**:验证移动端和桌面端的不同布局表现
|
||||
- **颜色传达状态测试**:验证不同健康状态的颜色编码
|
||||
|
||||
### 页面对象模式
|
||||
采用Page Object模式封装测试逻辑:
|
||||
|
||||
- **LoginPage**:封装登录表单的所有交互操作
|
||||
- **DashboardPage**:封装Dashboard页面的元素定位和数据提取
|
||||
|
||||
**章节来源**
|
||||
- [playwright.config.ts:1-39](file://frontend/playwright.config.ts#L1-L39)
|
||||
- [e2e/tests/login.spec.ts:1-126](file://frontend/e2e/tests/login.spec.ts#L1-L126)
|
||||
- [e2e/tests/dashboard-health.spec.ts:1-264](file://frontend/e2e/tests/dashboard-health.spec.ts#L1-L264)
|
||||
- [e2e/pages/login.page.ts:1-36](file://frontend/e2e/pages/login.page.ts#L1-L36)
|
||||
- [e2e/pages/dashboard.page.ts:1-74](file://frontend/e2e/pages/dashboard.page.ts#L1-L74)
|
||||
|
||||
## 业务组件库
|
||||
|
||||
### 组件库架构
|
||||
GEO业务组件库专注于企业级业务场景,提供高度可定制化的组件解决方案:
|
||||
|
||||
- **组件分类**:按业务领域划分,包括代理管理、监控告警、指标展示等
|
||||
- **类型安全**:完整的TypeScript类型定义,确保开发时的类型安全
|
||||
- **可复用性**:模块化设计,支持单独导入和批量导出
|
||||
- **主题适配**:深度集成Tailwind CSS,支持品牌色彩定制
|
||||
|
||||
### 核心业务组件
|
||||
|
||||
#### AgentStatusCard - 代理状态卡片
|
||||
提供AI代理的状态可视化展示,支持多种状态指示和详细信息展示:
|
||||
|
||||
- **状态类型**:online(在线)、offline(离线)、busy(繁忙)、error(错误)
|
||||
- **视觉反馈**:脉冲动画、颜色编码、状态徽章
|
||||
- **信息维度**:代理名称、描述、当前任务、活跃时间、完成统计
|
||||
- **交互设计**:悬停效果、响应式布局
|
||||
|
||||
#### AlertCard - 告警卡片
|
||||
企业级告警管理系统,支持多级别告警状态和操作:
|
||||
|
||||
- **严重级别**:critical(严重)、warning(警告)、info(信息)、success(成功)
|
||||
- **视觉层次**:图标、颜色、脉冲动画、状态点
|
||||
- **操作功能**:告警关闭、查看详情、批量处理
|
||||
- **扩展性**:自定义图标、动作按钮、时间戳
|
||||
|
||||
#### 其他业务组件
|
||||
- **MetricCard**:指标卡片,支持趋势方向和数据点配置
|
||||
- **TimelineStep**:时间轴步骤组件,支持多状态展示
|
||||
- **PlatformBadge**:平台标识组件,支持配置化平台信息
|
||||
- **ClientSwitcher**:客户切换器,支持多租户场景
|
||||
- **StageProgress**:阶段进度组件,支持工作流状态展示
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class BusinessComponents {
|
||||
<<index>>
|
||||
+StageProgress
|
||||
+MetricCard
|
||||
+AgentStatusCard
|
||||
+TimelineStep
|
||||
+PlatformBadge
|
||||
+ClientSwitcher
|
||||
+AlertCard
|
||||
}
|
||||
class AgentStatusCard {
|
||||
+name : string
|
||||
+status : AgentStatus
|
||||
+currentTask? : string
|
||||
+lastActiveAt? : string
|
||||
+completedCount? : number
|
||||
+render() : JSX.Element
|
||||
}
|
||||
class AlertCard {
|
||||
+alerts : AlertCardItem[]
|
||||
+title? : string
|
||||
+onDismiss? : Function
|
||||
+maxVisible? : number
|
||||
+render() : JSX.Element
|
||||
}
|
||||
class BusinessComponentsIndex {
|
||||
+exportAll() : void
|
||||
+importSpecific() : void
|
||||
}
|
||||
BusinessComponents --> AgentStatusCard
|
||||
BusinessComponents --> AlertCard
|
||||
BusinessComponentsIndex --> BusinessComponents
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [components/business/index.ts:1-29](file://frontend/components/business/index.ts#L1-L29)
|
||||
- [components/business/agent-status-card.tsx:1-134](file://frontend/components/business/agent-status-card.tsx#L1-L134)
|
||||
- [components/business/alert-card.tsx:1-203](file://frontend/components/business/alert-card.tsx#L1-L203)
|
||||
|
||||
### 组件导出策略
|
||||
提供两种导出方式满足不同使用场景:
|
||||
|
||||
- **统一索引导出**:通过index.ts文件提供集中导出,便于批量导入
|
||||
- **按需导入**:支持单独导入特定组件,优化打包体积
|
||||
|
||||
**章节来源**
|
||||
- [components/business/index.ts:1-29](file://frontend/components/business/index.ts#L1-L29)
|
||||
- [components/business/agent-status-card.tsx:1-134](file://frontend/components/business/agent-status-card.tsx#L1-L134)
|
||||
- [components/business/alert-card.tsx:1-203](file://frontend/components/business/alert-card.tsx#L1-L203)
|
||||
|
||||
## 依赖分析
|
||||
- 核心框架:Next.js 14(App Router)、React 18。
|
||||
|
|
@ -479,6 +656,7 @@ C->>C : "根据权限渲染侧边栏"
|
|||
- 样式:Tailwind CSS、Tailwind 插件(动画)、class-variance-authority、clsx、tailwind-merge。
|
||||
- 图表:Recharts 用于可视化展示。
|
||||
- 开发工具:ESLint、TypeScript、PostCSS、Tailwind。
|
||||
- **新增**:Playwright(E2E测试框架)、测试工具链。
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
|
|
@ -495,16 +673,19 @@ TS["TypeScript"] --> N
|
|||
ESL["ESLint"] --> N
|
||||
PC["PostCSS"] --> TW
|
||||
TABS["Tabs 组件"] --> UI
|
||||
PW["Playwright"] --> TEST["E2E测试"]
|
||||
BC["Business Components"] --> UI
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [package.json:1-40](file://frontend/package.json#L1-L40)
|
||||
- [tailwind.config.ts:1-57](file://frontend/tailwind.config.ts#L1-L57)
|
||||
- [package.json:1-45](file://frontend/package.json#L1-L45)
|
||||
- [tailwind.config.ts:1-121](file://frontend/tailwind.config.ts#L1-L121)
|
||||
- [components/ui/tabs.tsx:1-56](file://frontend/components/ui/tabs.tsx#L1-L56)
|
||||
- [playwright.config.ts:1-39](file://frontend/playwright.config.ts#L1-L39)
|
||||
|
||||
**章节来源**
|
||||
- [package.json:1-40](file://frontend/package.json#L1-L40)
|
||||
- [tailwind.config.ts:1-57](file://frontend/tailwind.config.ts#L1-L57)
|
||||
- [package.json:1-45](file://frontend/package.json#L1-L45)
|
||||
- [tailwind.config.ts:1-121](file://frontend/tailwind.config.ts#L1-L121)
|
||||
- [components/ui/tabs.tsx:1-56](file://frontend/components/ui/tabs.tsx#L1-L56)
|
||||
|
||||
## 性能考虑
|
||||
|
|
@ -514,17 +695,19 @@ TABS["Tabs 组件"] --> UI
|
|||
- 图表渲染:对大数据集使用虚拟化或采样策略(建议项)。
|
||||
- 资源优化:开启图片与静态资源优化(Next.js 默认支持),按需加载第三方库。
|
||||
- 构建优化:使用生产构建与代码分割,避免打包体积过大。
|
||||
- 权限控制:服务器端权限检查,减少客户端无意义的渲染。
|
||||
- 权限控制:客户端权限检查,减少无意义的渲染。
|
||||
- **新增**:E2E测试性能:并行测试执行,智能重试机制,减少测试时间。
|
||||
|
||||
## 故障排除指南
|
||||
- 登录失败:检查凭据是否正确,确认后端认证接口可用;查看登录页错误提示与 NextAuth 回调日志。
|
||||
- 会话丢失:确认 Cookie 设置、SameSite 与跨域配置;检查 NextAuth 回调是否正确写入 token。
|
||||
- 仪表盘空白:确认服务器端 getServerSession 返回有效会话;检查客户端 useSession 是否拿到访问令牌。
|
||||
- 仪表盘空白:确认客户端 useSession 返回有效会话;检查客户端状态管理。
|
||||
- 认证页面异常:检查对应认证页面的错误状态和网络请求;确认 API 端点是否正确。
|
||||
- 管理员功能不可用:确认用户 is_admin 标识;检查侧边栏权限渲染逻辑。
|
||||
- 设置页面问题:检查各标签页的状态管理;确认 API 调用和表单验证逻辑。
|
||||
- API 请求失败:查看 fetchWithAuth 抛出的错误信息,确认后端接口路径与鉴权头是否正确。
|
||||
- 样式异常:检查 Tailwind 配置 content 路径与 CSS 变量是否生效;确认暗色模式 class 是否正确切换。
|
||||
- **新增**:E2E测试失败:检查测试环境配置、页面元素定位、网络请求超时;查看测试报告详细信息。
|
||||
|
||||
**章节来源**
|
||||
- [app/(auth)/login/page.tsx](file://frontend/app/(auth)/login/page.tsx#L1-L93)
|
||||
|
|
@ -534,21 +717,27 @@ TABS["Tabs 组件"] --> UI
|
|||
- [app/(auth)/verify-email/page.tsx](file://frontend/app/(auth)/verify-email/page.tsx#L1-L155)
|
||||
- [app/(dashboard)/dashboard/admin/page.tsx](file://frontend/app/(dashboard)/dashboard/admin/page.tsx#L1-L435)
|
||||
- [app/(dashboard)/dashboard/settings/page.tsx](file://frontend/app/(dashboard)/dashboard/settings/page.tsx#L1-L445)
|
||||
- [app/(dashboard)/layout.tsx](file://frontend/app/(dashboard)/layout.tsx#L1-L27)
|
||||
- [app/(dashboard)/layout.tsx:1-146](file://frontend/app/(dashboard)/layout.tsx#L1-L146)
|
||||
- [lib/api.ts:1-154](file://frontend/lib/api.ts#L1-L154)
|
||||
- [tailwind.config.ts:1-57](file://frontend/tailwind.config.ts#L1-L57)
|
||||
- [tailwind.config.ts:1-121](file://frontend/tailwind.config.ts#L1-L121)
|
||||
- [playwright.config.ts:1-39](file://frontend/playwright.config.ts#L1-L39)
|
||||
|
||||
## 结论
|
||||
本架构以 Next.js 14 App Router 为核心,结合服务器组件与客户端组件的混合模式,实现了清晰的页面组织与路由保护;通过 NextAuth.js 的凭据提供者与 JWT 会话策略,完成了前后端认证协作,支持完整的认证生命周期;UI 组件库以 Radix UI 与 Tailwind 为基础,具备良好的可维护性与一致性,新增的 Tabs 组件支持复杂的多标签页界面;API 客户端统一处理鉴权与错误,配合页面层的状态与错误处理,形成完整的前端数据流。系统现已扩展为完整的认证体系和管理功能,支持管理员权限和用户自助管理。建议在后续迭代中进一步完善缓存与重试、图表渲染优化与构建体积治理,持续提升用户体验与可维护性。
|
||||
本架构以 Next.js 14 App Router 为核心,结合服务器组件与客户端组件的混合模式,实现了清晰的页面组织与路由保护;通过 NextAuth.js 的凭据提供者与 JWT 会话策略,完成了前后端认证协作,支持完整的认证生命周期;UI 组件库以 Radix UI 与 Tailwind 为基础,具备良好的可维护性与一致性,新增的 Tabs 组件支持复杂的多标签页界面;API 客户端统一处理鉴权与错误,配合页面层的状态与错误处理,形成完整的前端数据流。
|
||||
|
||||
**更新** 新增的E2E测试框架显著提升了代码质量保证能力,通过Playwright实现跨浏览器兼容性测试;业务组件库扩展了企业级应用场景的组件支持,包括代理状态管理、告警系统、指标展示等功能;仪表板布局重构提供了更好的用户体验和导航效率。建议在后续迭代中进一步完善测试覆盖率、组件文档化和性能监控,持续提升系统的稳定性和可维护性。
|
||||
|
||||
## 附录
|
||||
- 最佳实践清单
|
||||
- 使用路由组隔离功能域,保持页面组织清晰。
|
||||
- 将路由保护放在服务器端,优先保障安全性。
|
||||
- 将路由保护放在客户端,优先保障用户体验。
|
||||
- 仅在需要时标记客户端组件,减少水合成本。
|
||||
- 统一错误处理与用户反馈,提供明确的重试与刷新能力。
|
||||
- 严格类型约束,结合 TypeScript 与自定义类型扩展,降低运行时风险。
|
||||
- 支持管理员权限的渐进式增强,避免过度设计。
|
||||
- 使用标签页组件组织复杂设置界面,提升用户体验。
|
||||
- 实施细粒度的权限控制,确保数据安全。
|
||||
- 持续优化构建产物与运行时性能,关注首屏与交互延迟。
|
||||
- 持续优化构建产物与运行时性能,关注首屏与交互延迟。
|
||||
- **新增**:建立完善的E2E测试流程,确保跨浏览器兼容性。
|
||||
- **新增**:文档化业务组件使用规范,提升团队协作效率。
|
||||
- **新增**:实施测试驱动开发,提高代码质量和稳定性。
|
||||
|
|
@ -26,17 +26,30 @@
|
|||
- [backend/app/services/subscription.py](file://backend/app/services/subscription.py)
|
||||
- [backend/app/workers/scheduler.py](file://backend/app/workers/scheduler.py)
|
||||
- [backend/app/workers/citation_engine.py](file://backend/app/workers/citation_engine.py)
|
||||
- [backend/app/workers/llm_adapter.py](file://backend/app/workers/llm_adapter.py)
|
||||
- [backend/app/agent_framework/base.py](file://backend/app/agent_framework/base.py)
|
||||
- [backend/app/agent_framework/dispatcher.py](file://backend/app/agent_framework/dispatcher.py)
|
||||
- [backend/app/agent_framework/registry.py](file://backend/app/agent_framework/registry.py)
|
||||
- [backend/app/agent_framework/pipeline/engine.py](file://backend/app/agent_framework/pipeline/engine.py)
|
||||
- [backend/app/agent_framework/pipeline/loader.py](file://backend/app/agent_framework/pipeline/loader.py)
|
||||
- [backend/app/agent_framework/pipeline/schema.py](file://backend/app/agent_framework/pipeline/schema.py)
|
||||
- [backend/app/agent_framework/protocol.py](file://backend/app/agent_framework/protocol.py)
|
||||
- [backend/app/agent_framework/agents/content_generator_agent.py](file://backend/app/agent_framework/agents/content_generator_agent.py)
|
||||
- [backend/app/agent_framework/agents/geo_optimizer_agent.py](file://backend/app/agent_framework/agents/geo_optimizer_agent.py)
|
||||
- [backend/app/services/llm/factory.py](file://backend/app/services/llm/factory.py)
|
||||
- [backend/app/models/agent.py](file://backend/app/models/agent.py)
|
||||
- [backend/pipelines/content_production.yaml](file://backend/pipelines/content_production.yaml)
|
||||
- [backend/pipelines/diagnosis.yaml](file://backend/pipelines/diagnosis.yaml)
|
||||
</cite>
|
||||
|
||||
## 更新摘要
|
||||
**所做更改**
|
||||
- 新增中间件系统章节,包含限流中间件和日志中间件
|
||||
- 新增管理员服务和API路由,支持系统管理和用户管理
|
||||
- 新增订阅服务和API路由,支持套餐管理和订阅管理
|
||||
- 新增PDF报告服务,支持CSV和PDF格式导出
|
||||
- 更新项目结构图,反映新增模块和文件
|
||||
- 更新数据模型关系图,包含订阅和查询任务模型
|
||||
- 更新架构总览图,展示新增中间件和服务层
|
||||
- 新增代理框架架构章节,包含Agent基类、注册中心、任务分发器和工作流引擎
|
||||
- 新增LLM服务集成章节,包含LLM工厂模式和多提供商支持
|
||||
- 新增工作器系统扩展章节,包含LLM适配器和平台适配器
|
||||
- 新增分布式发布系统章节,包含Pipeline编排和任务编排
|
||||
- 更新项目结构图,反映新增的代理框架和LLM服务模块
|
||||
- 更新架构总览图,展示新增的代理系统和LLM集成
|
||||
|
||||
## 目录
|
||||
1. [引言](#引言)
|
||||
|
|
@ -44,20 +57,24 @@
|
|||
3. [核心组件](#核心组件)
|
||||
4. [架构总览](#架构总览)
|
||||
5. [详细组件分析](#详细组件分析)
|
||||
6. [中间件系统](#中间件系统)
|
||||
7. [管理员服务](#管理员服务)
|
||||
8. [订阅服务](#订阅服务)
|
||||
9. [报告服务](#报告服务)
|
||||
10. [依赖关系分析](#依赖关系分析)
|
||||
11. [性能考量](#性能考量)
|
||||
12. [故障排查指南](#故障排查指南)
|
||||
13. [结论](#结论)
|
||||
14. [附录](#附录)
|
||||
6. [代理框架架构](#代理框架架构)
|
||||
7. [LLM服务集成](#llm服务集成)
|
||||
8. [工作器系统扩展](#工作器系统扩展)
|
||||
9. [分布式发布系统](#分布式发布系统)
|
||||
10. [中间件系统](#中间件系统)
|
||||
11. [管理员服务](#管理员服务)
|
||||
12. [订阅服务](#订阅服务)
|
||||
13. [报告服务](#报告服务)
|
||||
14. [依赖关系分析](#依赖关系分析)
|
||||
15. [性能考量](#性能考量)
|
||||
16. [故障排查指南](#故障排查指南)
|
||||
17. [结论](#结论)
|
||||
18. [附录](#附录)
|
||||
|
||||
## 引言
|
||||
本文件为 GEO 平台后端系统的架构文档,基于 FastAPI 构建,采用异步 SQLAlchemy ORM、APScheduler 定时任务与多平台适配器模式,实现查询词管理、引用检测与报告统计等功能。文档覆盖应用配置、中间件、路由组织、生命周期管理、数据库连接与 ORM、异步处理、认证与权限控制、API 设计与错误处理、系统监控与日志、性能优化策略,并给出架构决策的技术背景与权衡。
|
||||
|
||||
**更新** 本次更新反映了应用的重大功能扩展,包括中间件系统的引入、管理员管理功能、订阅管理系统和报告导出功能。
|
||||
**更新** 本次更新显著扩展了系统架构,新增代理框架架构、LLM服务集成、工作器系统扩展和分布式发布系统等核心组件,形成了更加完整的企业级AI内容生产与管理平台。
|
||||
|
||||
## 项目结构
|
||||
后端采用分层与功能域结合的组织方式:
|
||||
|
|
@ -68,7 +85,10 @@
|
|||
- 中间件层:app/middleware/(logging_middleware、rate_limit)
|
||||
- 模型层:app/models/(SQLAlchemy ORM 映射)
|
||||
- 服务层:app/services/(business logic encapsulation)
|
||||
- 工作器与调度:app/workers/(APScheduler 调度器、引用检测引擎、平台适配器)
|
||||
- 代理框架:app/agent_framework/(Agent基类、注册中心、任务分发器、工作流引擎)
|
||||
- LLM服务:app/services/llm/(LLM工厂、提供商适配器)
|
||||
- 工作器与调度:app/workers/(APScheduler 调度器、引用检测引擎、平台适配器、LLM适配器)
|
||||
- 流水线定义:pipelines/(YAML配置文件)
|
||||
- 测试:tests/(pytest)
|
||||
|
||||
```mermaid
|
||||
|
|
@ -93,20 +113,41 @@ REPORTS["api/reports.py"]
|
|||
SUBSCRIPTIONS["api/subscriptions.py"]
|
||||
DEPS["api/deps.py"]
|
||||
end
|
||||
subgraph "代理框架"
|
||||
AGENT_BASE["agent_framework/base.py"]
|
||||
REGISTRY["agent_framework/registry.py"]
|
||||
DISPATCHER["agent_framework/dispatcher.py"]
|
||||
PIPELINE_ENGINE["agent_framework/pipeline/engine.py"]
|
||||
PIPELINE_LOADER["agent_framework/pipeline/loader.py"]
|
||||
PIPELINE_SCHEMA["agent_framework/pipeline/schema.py"]
|
||||
PROTOCOL["agent_framework/protocol.py"]
|
||||
END
|
||||
subgraph "LLM服务"
|
||||
LLM_FACTORY["services/llm/factory.py"]
|
||||
OPENAI_PROVIDER["services/llm/openai_provider.py"]
|
||||
DEEPSEEK_PROVIDER["services/llm/deepseek_provider.py"]
|
||||
END
|
||||
subgraph "模型与服务"
|
||||
MODEL_USER["models/user.py"]
|
||||
MODEL_QUERY["models/query.py"]
|
||||
MODEL_CIT["models/citation_record.py"]
|
||||
MODEL_SUB["models/subscription.py"]
|
||||
MODEL_TASK["models/query_task.py"]
|
||||
MODEL_AGENT["models/agent.py"]
|
||||
SVC_AUTH["services/auth.py"]
|
||||
SVC_ADMIN["services/admin.py"]
|
||||
SVC_SUB["services/subscription.py"]
|
||||
end
|
||||
END
|
||||
subgraph "工作器与调度"
|
||||
SCHED["workers/scheduler.py"]
|
||||
ENGINE["workers/citation_engine.py"]
|
||||
end
|
||||
LLM_ADAPTER["workers/llm_adapter.py"]
|
||||
PLATFORMS["workers/platforms/"]
|
||||
END
|
||||
subgraph "流水线定义"
|
||||
CONTENT_PRODUCTION["pipelines/content_production.yaml"]
|
||||
DIAGNOSIS["pipelines/diagnosis.yaml"]
|
||||
END
|
||||
MAIN --> LOGMW
|
||||
MAIN --> RATEMW
|
||||
MAIN --> AUTH
|
||||
|
|
@ -132,7 +173,14 @@ SCHED --> DB
|
|||
SCHED --> ENGINE
|
||||
ENGINE --> MODEL_QUERY
|
||||
ENGINE --> MODEL_CIT
|
||||
DB --> CFG
|
||||
LLM_ADAPTER --> CFG
|
||||
REGISTRY --> DB
|
||||
DISPATCHER --> DB
|
||||
DISPATCHER --> AGENT_BASE
|
||||
PIPELINE_ENGINE --> DISPATCHER
|
||||
PIPELINE_LOADER --> PIPELINE_SCHEMA
|
||||
LLM_FACTORY --> OPENAI_PROVIDER
|
||||
LLM_FACTORY --> DEEPSEEK_PROVIDER
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
|
|
@ -141,11 +189,14 @@ DB --> CFG
|
|||
- [backend/app/database.py:1-29](file://backend/app/database.py#L1-L29)
|
||||
- [backend/app/middleware/logging_middleware.py:1-24](file://backend/app/middleware/logging_middleware.py#L1-L24)
|
||||
- [backend/app/middleware/rate_limit.py:1-83](file://backend/app/middleware/rate_limit.py#L1-L83)
|
||||
- [backend/app/api/admin.py:1-108](file://backend/app/api/admin.py#L1-L108)
|
||||
- [backend/app/api/reports.py:1-75](file://backend/app/api/reports.py#L1-L75)
|
||||
- [backend/app/api/subscriptions.py:1-77](file://backend/app/api/subscriptions.py#L1-L77)
|
||||
- [backend/app/services/admin.py:1-188](file://backend/app/services/admin.py#L1-L188)
|
||||
- [backend/app/services/subscription.py:1-155](file://backend/app/services/subscription.py#L1-L155)
|
||||
- [backend/app/agent_framework/base.py:1-223](file://backend/app/agent_framework/base.py#L1-L223)
|
||||
- [backend/app/agent_framework/registry.py:1-219](file://backend/app/agent_framework/registry.py#L1-L219)
|
||||
- [backend/app/agent_framework/dispatcher.py:1-367](file://backend/app/agent_framework/dispatcher.py#L1-L367)
|
||||
- [backend/app/agent_framework/pipeline/engine.py:1-536](file://backend/app/agent_framework/pipeline/engine.py#L1-L536)
|
||||
- [backend/app/services/llm/factory.py:1-66](file://backend/app/services/llm/factory.py#L1-L66)
|
||||
- [backend/app/workers/llm_adapter.py:1-281](file://backend/app/workers/llm_adapter.py#L1-L281)
|
||||
- [backend/pipelines/content_production.yaml:1-65](file://backend/pipelines/content_production.yaml#L1-L65)
|
||||
- [backend/pipelines/diagnosis.yaml:1-30](file://backend/pipelines/diagnosis.yaml#L1-L30)
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
|
||||
|
|
@ -159,8 +210,11 @@ DB --> CFG
|
|||
- 数据库:异步 SQLAlchemy 引擎与会话工厂,依赖注入式获取会话。
|
||||
- 认证与权限:OAuth2 密码流 + JWT,依赖注入解析当前用户,未授权时抛出 401。
|
||||
- 引擎与调度:APScheduler 定时扫描到期查询,调用 CitationEngine 执行并持久化结果。
|
||||
- **新增** 代理框架:基于Redis的消息队列,支持Agent注册、发现、任务分发和工作流编排。
|
||||
- **新增** LLM服务:统一的LLM提供商工厂,支持OpenAI和DeepSeek等多种提供商。
|
||||
- **新增** 分布式发布:基于YAML的流水线编排系统,支持复杂的内容生产工作流。
|
||||
|
||||
**更新** 新增中间件系统提供限流和日志功能,增强系统的安全性和可观测性。
|
||||
**更新** 新增代理框架、LLM服务集成和分布式发布系统,显著增强了系统的智能化和自动化能力。
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/main.py:13-48](file://backend/app/main.py#L13-L48)
|
||||
|
|
@ -168,7 +222,7 @@ DB --> CFG
|
|||
- [backend/app/api/deps.py:13-43](file://backend/app/api/deps.py#L13-L43)
|
||||
|
||||
## 架构总览
|
||||
系统采用"API 层-中间件层-服务层-模型层-基础设施"的分层架构,配合异步 I/O 与定时任务,实现高并发与可扩展的查询与检测能力。
|
||||
系统采用"API 层-中间件层-服务层-模型层-基础设施"的分层架构,配合异步 I/O 与定时任务,实现高并发与可扩展的查询与检测能力。新增的代理框架通过Redis实现分布式任务调度,LLM服务提供统一的AI能力接口,分布式发布系统支持复杂的工作流编排。
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
|
|
@ -188,6 +242,12 @@ SVC_SUB["订阅服务<br/>套餐管理/订阅流程"]
|
|||
DB["异步数据库<br/>Session 工厂"]
|
||||
SCHED["查询调度器<br/>APScheduler"]
|
||||
ENGINE["引用检测引擎<br/>平台适配器"]
|
||||
LLM_ADAPTER["LLM适配器<br/>DeepSeek API"]
|
||||
AGENT_FRAMEWORK["代理框架<br/>Redis队列/Agent管理"]
|
||||
REGISTRY["注册中心<br/>Agent注册/发现"]
|
||||
DISPATCHER["任务分发器<br/>消息队列/状态管理"]
|
||||
PIPELINE_ENGINE["工作流引擎<br/>YAML编排/DAG执行"]
|
||||
LLM_SERVICE["LLM服务<br/>工厂模式/多提供商"]
|
||||
CLIENT --> FASTAPI
|
||||
FASTAPI --> MIDDLEWARE
|
||||
MIDDLEWARE --> ROUTER_AUTH
|
||||
|
|
@ -215,6 +275,14 @@ SVC_SUB --> DB
|
|||
SCHED --> DB
|
||||
SCHED --> ENGINE
|
||||
ENGINE --> DB
|
||||
LLM_ADAPTER --> DB
|
||||
AGENT_FRAMEWORK --> REGISTRY
|
||||
AGENT_FRAMEWORK --> DISPATCHER
|
||||
AGENT_FRAMEWORK --> PIPELINE_ENGINE
|
||||
DISPATCHER --> DB
|
||||
REGISTRY --> DB
|
||||
PIPELINE_ENGINE --> DB
|
||||
LLM_SERVICE --> DB
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
|
|
@ -227,6 +295,12 @@ ENGINE --> DB
|
|||
- [backend/app/services/subscription.py:69-155](file://backend/app/services/subscription.py#L69-L155)
|
||||
- [backend/app/workers/scheduler.py:25-95](file://backend/app/workers/scheduler.py#L25-L95)
|
||||
- [backend/app/workers/citation_engine.py:148-309](file://backend/app/workers/citation_engine.py#L148-L309)
|
||||
- [backend/app/workers/llm_adapter.py:1-281](file://backend/app/workers/llm_adapter.py#L1-L281)
|
||||
- [backend/app/agent_framework/base.py:1-223](file://backend/app/agent_framework/base.py#L1-L223)
|
||||
- [backend/app/agent_framework/registry.py:1-219](file://backend/app/agent_framework/registry.py#L1-L219)
|
||||
- [backend/app/agent_framework/dispatcher.py:1-367](file://backend/app/agent_framework/dispatcher.py#L1-L367)
|
||||
- [backend/app/agent_framework/pipeline/engine.py:1-536](file://backend/app/agent_framework/pipeline/engine.py#L1-L536)
|
||||
- [backend/app/services/llm/factory.py:1-66](file://backend/app/services/llm/factory.py#L1-L66)
|
||||
|
||||
## 详细组件分析
|
||||
|
||||
|
|
@ -380,6 +454,9 @@ Wait --> Tick
|
|||
- 引用记录:外键查询、平台、是否引用、位置、文本、竞争品牌、原始响应、时间戳。
|
||||
- 订阅:外键用户、套餐类型、状态、开始结束日期、金额、支付方式、时间戳。
|
||||
- 查询任务:外键查询、平台、状态、错误信息、调度时间、开始完成时间。
|
||||
- **新增** Agent注册表:Agent元数据、状态、能力声明、心跳时间。
|
||||
- **新增** Agent任务表:任务执行状态、输入输出数据、执行指标。
|
||||
- **新增** Agent任务日志表:任务执行日志、进度跟踪、错误信息。
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
|
|
@ -440,11 +517,55 @@ text error_message
|
|||
timestamp scheduled_at
|
||||
timestamp started_at
|
||||
timestamp completed_at
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
AGENT_REGISTRY {
|
||||
uuid id PK
|
||||
string name UK
|
||||
string display_name
|
||||
string agent_type
|
||||
string description
|
||||
string version
|
||||
string endpoint
|
||||
string status
|
||||
jsonb capabilities
|
||||
timestamp last_heartbeat
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
AGENT_TASKS {
|
||||
uuid id PK
|
||||
uuid agent_id FK
|
||||
string task_type
|
||||
string status
|
||||
int priority
|
||||
jsonb input_data
|
||||
jsonb output_data
|
||||
text error_message
|
||||
uuid created_by
|
||||
uuid organization_id FK
|
||||
uuid project_id FK
|
||||
timestamp scheduled_at
|
||||
timestamp started_at
|
||||
timestamp completed_at
|
||||
timestamp created_at
|
||||
}
|
||||
AGENT_TASK_LOGS {
|
||||
uuid id PK
|
||||
uuid task_id FK
|
||||
uuid agent_id FK
|
||||
string log_level
|
||||
text message
|
||||
jsonb extra_metadata
|
||||
timestamp created_at
|
||||
}
|
||||
USERS ||--o{ QUERIES : "拥有"
|
||||
QUERIES ||--o{ CITATION_RECORDS : "产生"
|
||||
USERS ||--o{ SUBSCRIPTIONS : "订阅"
|
||||
QUERIES ||--o{ QUERY_TASKS : "包含"
|
||||
AGENT_REGISTRY ||--o{ AGENT_TASKS : "执行"
|
||||
AGENT_TASKS ||--o{ AGENT_TASK_LOGS : "记录"
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
|
|
@ -453,6 +574,7 @@ QUERIES ||--o{ QUERY_TASKS : "包含"
|
|||
- [backend/app/models/citation_record.py:11-42](file://backend/app/models/citation_record.py#L11-L42)
|
||||
- [backend/app/models/subscription.py:11-37](file://backend/app/models/subscription.py#L11-L37)
|
||||
- [backend/app/models/query_task.py:11-39](file://backend/app/models/query_task.py#L11-L39)
|
||||
- [backend/app/models/agent.py:12-206](file://backend/app/models/agent.py#L12-L206)
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/models/user.py:1-41](file://backend/app/models/user.py#L1-L41)
|
||||
|
|
@ -460,6 +582,292 @@ QUERIES ||--o{ QUERY_TASKS : "包含"
|
|||
- [backend/app/models/citation_record.py:1-42](file://backend/app/models/citation_record.py#L1-L42)
|
||||
- [backend/app/models/subscription.py:1-37](file://backend/app/models/subscription.py#L1-L37)
|
||||
- [backend/app/models/query_task.py:1-39](file://backend/app/models/query_task.py#L1-L39)
|
||||
- [backend/app/models/agent.py:1-206](file://backend/app/models/agent.py#L1-L206)
|
||||
|
||||
## 代理框架架构
|
||||
|
||||
### Agent基类与生命周期管理
|
||||
代理框架的核心是BaseAgent基类,它定义了所有Agent的标准生命周期和行为:
|
||||
- **启动流程**:初始化Redis连接、注册到注册中心、启动心跳、开始监听任务队列
|
||||
- **任务执行**:异步监听Redis队列,执行具体任务逻辑,上报进度和结果
|
||||
- **状态管理**:维护Agent在线状态、忙碌状态,支持优雅停机
|
||||
- **心跳机制**:定期向注册中心上报心跳,保持活跃状态
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Agent as "Agent实例"
|
||||
participant Redis as "Redis服务器"
|
||||
participant Registry as "注册中心"
|
||||
participant Dispatcher as "任务分发器"
|
||||
Agent->>Agent : start()
|
||||
Agent->>Redis : 连接Redis
|
||||
Agent->>Registry : register(capabilities)
|
||||
Registry-->>Agent : 注册成功
|
||||
Agent->>Agent : 启动心跳循环
|
||||
Agent->>Agent : 启动任务监听
|
||||
loop 每30秒
|
||||
Agent->>Registry : update_heartbeat()
|
||||
end
|
||||
Redis-->>Agent : 任务消息
|
||||
Agent->>Agent : execute(task)
|
||||
Agent->>Dispatcher : handle_result(result)
|
||||
Agent->>Agent : report_progress(progress)
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [backend/app/agent_framework/base.py:52-114](file://backend/app/agent_framework/base.py#L52-L114)
|
||||
- [backend/app/agent_framework/base.py:148-182](file://backend/app/agent_framework/base.py#L148-L182)
|
||||
|
||||
### 注册中心与Agent发现
|
||||
注册中心负责管理所有Agent的生命周期和状态:
|
||||
- **注册流程**:Agent启动时向注册中心注册,保存能力声明和端点信息
|
||||
- **状态维护**:实时更新Agent心跳时间,超时自动标记为离线
|
||||
- **发现机制**:根据任务类型动态查找可用的Agent实例
|
||||
- **健康检查**:定期扫描超时的Agent并更新状态
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start["Agent启动"] --> Connect["连接Redis"]
|
||||
Connect --> Register["向注册中心注册"]
|
||||
Register --> SaveInfo["保存Agent信息<br/>能力声明/端点/状态"]
|
||||
SaveInfo --> Heartbeat["启动心跳循环"]
|
||||
Heartbeat --> Monitor["监控Agent状态"]
|
||||
Monitor --> Timeout{"心跳超时?"}
|
||||
Timeout -- 是 --> MarkOffline["标记为离线"]
|
||||
Timeout -- 否 --> Monitor
|
||||
Monitor --> Discover["Agent发现"]
|
||||
Discover --> Match{"任务类型匹配?"}
|
||||
Match -- 是 --> Dispatch["分发任务"]
|
||||
Match -- 否 --> Wait["等待其他Agent"]
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [backend/app/agent_framework/registry.py:29-80](file://backend/app/agent_framework/registry.py#L29-L80)
|
||||
- [backend/app/agent_framework/registry.py:156-172](file://backend/app/agent_framework/registry.py#L156-L172)
|
||||
- [backend/app/agent_framework/registry.py:174-201](file://backend/app/agent_framework/registry.py#L174-L201)
|
||||
|
||||
### 任务分发器与消息队列
|
||||
任务分发器通过Redis实现Agent间的异步通信:
|
||||
- **任务分发**:将TaskMessage推送到指定Agent的队列
|
||||
- **状态管理**:维护AgentTask表,跟踪任务执行状态
|
||||
- **结果处理**:接收Agent返回的TaskResult,更新数据库状态
|
||||
- **进度上报**:处理TaskProgress消息,记录执行进度
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant API as "API服务"
|
||||
participant Dispatcher as "任务分发器"
|
||||
participant Redis as "Redis队列"
|
||||
participant Agent as "目标Agent"
|
||||
participant DB as "数据库"
|
||||
API->>Dispatcher : dispatch(task)
|
||||
Dispatcher->>DB : 写入AgentTask记录
|
||||
DB-->>Dispatcher : 确认写入
|
||||
Dispatcher->>Redis : LPUSH agent : {name} : tasks
|
||||
Redis-->>Agent : 任务消息
|
||||
Agent->>Agent : execute(task)
|
||||
Agent->>Dispatcher : handle_result(result)
|
||||
Dispatcher->>DB : 更新任务状态
|
||||
DB-->>API : 任务完成
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [backend/app/agent_framework/dispatcher.py:54-117](file://backend/app/agent_framework/dispatcher.py#L54-L117)
|
||||
- [backend/app/agent_framework/dispatcher.py:169-218](file://backend/app/agent_framework/dispatcher.py#L169-L218)
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/agent_framework/base.py:1-223](file://backend/app/agent_framework/base.py#L1-L223)
|
||||
- [backend/app/agent_framework/registry.py:1-219](file://backend/app/agent_framework/registry.py#L1-L219)
|
||||
- [backend/app/agent_framework/dispatcher.py:1-367](file://backend/app/agent_framework/dispatcher.py#L1-L367)
|
||||
|
||||
## LLM服务集成
|
||||
|
||||
### LLM工厂模式与多提供商支持
|
||||
LLM服务采用工厂模式统一管理不同的AI提供商:
|
||||
- **OpenAI提供商**:支持GPT系列模型,提供标准的ChatCompletion接口
|
||||
- **DeepSeek提供商**:支持DeepSeek系列模型,提供高性能的推理能力
|
||||
- **统一接口**:所有提供商实现相同的LLMProvider接口,支持透明切换
|
||||
- **配置管理**:通过环境变量和配置文件管理API密钥和模型参数
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Factory["LLM工厂"] --> OpenAI["OpenAI提供商"]
|
||||
Factory --> DeepSeek["DeepSeek提供商"]
|
||||
OpenAI --> GPT4["GPT-4模型"]
|
||||
OpenAI --> GPT35["GPT-3.5模型"]
|
||||
DeepSeek --> DeepSeekChat["DeepSeek-chat模型"]
|
||||
DeepSeek --> DeepSeekCoder["DeepSeek-coder模型"]
|
||||
Factory --> Config["配置管理"]
|
||||
Config --> Env["环境变量"]
|
||||
Config --> Settings["应用配置"]
|
||||
Env --> Factory
|
||||
Settings --> Factory
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [backend/app/services/llm/factory.py:8-66](file://backend/app/services/llm/factory.py#L8-L66)
|
||||
- [backend/app/services/llm/factory.py:25-50](file://backend/app/services/llm/factory.py#L25-L50)
|
||||
|
||||
### LLM适配器与品牌引用检测
|
||||
LLM适配器专门用于品牌引用检测任务,集成DeepSeek API:
|
||||
- **提示词工程**:精心设计的提示词模板,确保准确的品牌识别
|
||||
- **JSON输出解析**:标准化的JSON格式输出,包含引用状态、置信度等信息
|
||||
- **错误处理**:完善的异常处理和重试机制,确保服务稳定性
|
||||
- **模拟模式**:在禁用LLM时提供模拟结果,保证系统可用性
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Engine as "引用检测引擎"
|
||||
participant Adapter as "LLM适配器"
|
||||
participant DeepSeek as "DeepSeek API"
|
||||
Engine->>Adapter : query_brand_citation(keyword, brand, aliases)
|
||||
Adapter->>Adapter : 构建提示词
|
||||
Adapter->>DeepSeek : chat.completions.create
|
||||
DeepSeek-->>Adapter : JSON响应
|
||||
Adapter->>Adapter : 解析JSON输出
|
||||
Adapter-->>Engine : CitationResult
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [backend/app/workers/llm_adapter.py:71-110](file://backend/app/workers/llm_adapter.py#L71-L110)
|
||||
- [backend/app/workers/llm_adapter.py:220-270](file://backend/app/workers/llm_adapter.py#L220-L270)
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/services/llm/factory.py:1-66](file://backend/app/services/llm/factory.py#L1-L66)
|
||||
- [backend/app/workers/llm_adapter.py:1-281](file://backend/app/workers/llm_adapter.py#L1-L281)
|
||||
|
||||
## 工作器系统扩展
|
||||
|
||||
### 平台适配器与多平台支持
|
||||
工作器系统扩展了原有的平台适配器,支持更多AI平台:
|
||||
- **现有平台**:Doubao、Kimi、Qingyan、Tiangong、Tongyi、Wenxin、Xinghuo
|
||||
- **搜索引擎**:通用搜索平台适配器
|
||||
- **统一接口**:所有平台实现相同的BasePlatform接口
|
||||
- **配置管理**:通过配置文件管理平台API密钥和参数
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
PlatformBase["平台基类<br/>BasePlatform"]
|
||||
Doubao["Doubao平台<br/>doubao.py"]
|
||||
Kimi["Kimi平台<br/>kimi.py"]
|
||||
Qingyan["Qingyan平台<br/>qingyan.py"]
|
||||
SearchEngine["搜索引擎<br/>search_engine.py"]
|
||||
Tiangong["Tiangong平台<br/>tiangong.py"]
|
||||
Tongyi["Tongyi平台<br/>tongyi.py"]
|
||||
Wenxin["Wenxin平台<br/>wenxin.py"]
|
||||
Xinghuo["Xinghuo平台<br/>xinghuo.py"]
|
||||
PlatformBase --> Doubao
|
||||
PlatformBase --> Kimi
|
||||
PlatformBase --> Qingyan
|
||||
PlatformBase --> SearchEngine
|
||||
PlatformBase --> Tiangong
|
||||
PlatformBase --> Tongyi
|
||||
PlatformBase --> Wenxin
|
||||
PlatformBase --> Xinghuo
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [backend/app/workers/platforms/base.py](file://backend/app/workers/platforms/base.py)
|
||||
- [backend/app/workers/platforms/doubao.py](file://backend/app/workers/platforms/doubao.py)
|
||||
- [backend/app/workers/platforms/kimi.py](file://backend/app/workers/platforms/kimi.py)
|
||||
- [backend/app/workers/platforms/qingyan.py](file://backend/app/workers/platforms/qingyan.py)
|
||||
- [backend/app/workers/platforms/search_engine.py](file://backend/app/workers/platforms/search_engine.py)
|
||||
- [backend/app/workers/platforms/tiangong.py](file://backend/app/workers/platforms/tiangong.py)
|
||||
- [backend/app/workers/platforms/tongyi.py](file://backend/app/workers/platforms/tongyi.py)
|
||||
- [backend/app/workers/platforms/wenxin.py](file://backend/app/workers/platforms/wenxin.py)
|
||||
- [backend/app/workers/platforms/xinghuo.py](file://backend/app/workers/platforms/xinghuo.py)
|
||||
|
||||
### 内容生成Agent与GEO优化Agent
|
||||
新增的专业Agent实现了特定的AI内容生成功能:
|
||||
- **内容生成Agent**:支持选题生成和文章生成,集成RAG知识库检索
|
||||
- **GEO优化Agent**:专门优化内容在AI搜索引擎中的可见性
|
||||
- **进度上报**:实时上报任务执行进度,支持用户监控
|
||||
- **错误处理**:完善的异常处理和重试机制
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
ContentAgent["内容生成Agent"] --> Topics["选题生成"]
|
||||
ContentAgent --> Article["文章生成"]
|
||||
Topics --> RAG["RAG知识库检索"]
|
||||
Article --> LLM["LLM调用"]
|
||||
GEOAgent["GEO优化Agent"] --> Optimize["内容优化"]
|
||||
Optimize --> LLM2["LLM调用"]
|
||||
RAG --> LLM
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [backend/app/agent_framework/agents/content_generator_agent.py:111-182](file://backend/app/agent_framework/agents/content_generator_agent.py#L111-L182)
|
||||
- [backend/app/agent_framework/agents/content_generator_agent.py:184-252](file://backend/app/agent_framework/agents/content_generator_agent.py#L184-L252)
|
||||
- [backend/app/agent_framework/agents/geo_optimizer_agent.py:104-180](file://backend/app/agent_framework/agents/geo_optimizer_agent.py#L104-L180)
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/agent_framework/agents/content_generator_agent.py:1-299](file://backend/app/agent_framework/agents/content_generator_agent.py#L1-L299)
|
||||
- [backend/app/agent_framework/agents/geo_optimizer_agent.py:1-198](file://backend/app/agent_framework/agents/geo_optimizer_agent.py#L1-L198)
|
||||
|
||||
## 分布式发布系统
|
||||
|
||||
### Pipeline工作流编排
|
||||
分布式发布系统基于YAML配置实现复杂的工作流编排:
|
||||
- **内容生产流水线**:从选题到发布的完整内容生产流程
|
||||
- **诊断分析流水线**:引用检测与竞争分析的诊断流程
|
||||
- **DAG执行**:支持有向无环图的任务依赖关系
|
||||
- **变量解析**:支持复杂的变量引用和上下文传递
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
ContentProduction["内容生产流水线"] --> TopicSelection["选题选择"]
|
||||
ContentProduction --> ContentGeneration["内容生成"]
|
||||
ContentProduction --> DeAIProcessing["去AI化处理"]
|
||||
ContentProduction --> GEOOptimization["GEO优化"]
|
||||
ContentProduction --> RuleValidation["规则验证"]
|
||||
TopicSelection --> ContentGeneration
|
||||
ContentGeneration --> DeAIProcessing
|
||||
DeAIProcessing --> GEOOptimization
|
||||
GEOOptimization --> RuleValidation
|
||||
Diagnosis["诊断分析流水线"] --> CitationDetection["引用检测"]
|
||||
Diagnosis --> CompetitorAnalysis["竞争分析"]
|
||||
CitationDetection --> CompetitorAnalysis
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [backend/pipelines/content_production.yaml:9-65](file://backend/pipelines/content_production.yaml#L9-L65)
|
||||
- [backend/pipelines/diagnosis.yaml:8-30](file://backend/pipelines/diagnosis.yaml#L8-L30)
|
||||
|
||||
### 工作流引擎与任务编排
|
||||
工作流引擎负责执行复杂的任务编排逻辑:
|
||||
- **拓扑排序**:使用Kahn算法进行DAG拓扑排序
|
||||
- **条件执行**:支持基于条件表达式的任务执行
|
||||
- **重试机制**:支持任务级别的重试和超时控制
|
||||
- **进度跟踪**:实时跟踪每个阶段的执行进度
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as "用户"
|
||||
participant Engine as "工作流引擎"
|
||||
participant Dispatcher as "任务分发器"
|
||||
participant Agent as "Agent实例"
|
||||
User->>Engine : execute(pipeline, context)
|
||||
Engine->>Engine : 拓扑排序
|
||||
Engine->>Engine : 变量解析
|
||||
Engine->>Dispatcher : 分发阶段任务
|
||||
Dispatcher->>Agent : 任务消息
|
||||
Agent->>Agent : 执行任务
|
||||
Agent->>Dispatcher : 任务结果
|
||||
Dispatcher->>Engine : 更新状态
|
||||
Engine->>Engine : 下一阶段执行
|
||||
Engine-->>User : 完整执行结果
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [backend/app/agent_framework/pipeline/engine.py:51-176](file://backend/app/agent_framework/pipeline/engine.py#L51-L176)
|
||||
- [backend/app/agent_framework/pipeline/engine.py:256-327](file://backend/app/agent_framework/pipeline/engine.py#L256-L327)
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/agent_framework/pipeline/engine.py:1-536](file://backend/app/agent_framework/pipeline/engine.py#L1-L536)
|
||||
- [backend/app/agent_framework/pipeline/loader.py:1-283](file://backend/app/agent_framework/pipeline/loader.py#L1-L283)
|
||||
- [backend/app/agent_framework/pipeline/schema.py:1-102](file://backend/app/agent_framework/pipeline/schema.py#L1-L102)
|
||||
- [backend/pipelines/content_production.yaml:1-65](file://backend/pipelines/content_production.yaml#L1-L65)
|
||||
- [backend/pipelines/diagnosis.yaml:1-30](file://backend/pipelines/diagnosis.yaml#L1-L30)
|
||||
|
||||
## 中间件系统
|
||||
|
||||
|
|
@ -619,6 +1027,9 @@ Reports-->>Client : Response PDF
|
|||
## 依赖关系分析
|
||||
- 组件内聚:API 路由与服务层职责清晰,模型仅负责映射。
|
||||
- 组件耦合:API 依赖服务,服务依赖数据库与配置;调度器依赖引擎与数据库;引擎依赖平台适配器。
|
||||
- **新增** 代理框架:Agent依赖Redis和注册中心,任务分发器依赖数据库和Redis。
|
||||
- **新增** LLM服务:Agent依赖LLM工厂,LLM工厂依赖具体的提供商实现。
|
||||
- **新增** 工作流引擎:依赖任务分发器和管道加载器,支持复杂的任务编排。
|
||||
- 依赖注入:通过 FastAPI 依赖系统注入数据库会话与当前用户。
|
||||
- 循环依赖:未见明显循环依赖。
|
||||
|
||||
|
|
@ -642,6 +1053,13 @@ DEPS --> DB
|
|||
SCHED["workers/scheduler.py"] --> DB
|
||||
SCHED --> ENGINE["workers/citation_engine.py"]
|
||||
ENGINE --> MODELS["models/*.py"]
|
||||
LLM_ADAPTER["workers/llm_adapter.py"] --> LLM_FACTORY["services/llm/factory.py"]
|
||||
LLM_FACTORY --> PROVIDERS["services/llm/*"]
|
||||
AGENT_FRAMEWORK["agent_framework/*"] --> REGISTRY["agent_framework/registry.py"]
|
||||
AGENT_FRAMEWORK --> DISPATCHER["agent_framework/dispatcher.py"]
|
||||
DISPATCHER --> MODELS["models/agent.py"]
|
||||
PIPELINE_ENGINE["agent_framework/pipeline/engine.py"] --> DISPATCHER
|
||||
PIPELINE_LOADER["agent_framework/pipeline/loader.py"] --> PIPELINE_SCHEMA["agent_framework/pipeline/schema.py"]
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
|
|
@ -652,6 +1070,13 @@ ENGINE --> MODELS["models/*.py"]
|
|||
- [backend/app/api/subscriptions.py:1-77](file://backend/app/api/subscriptions.py#L1-L77)
|
||||
- [backend/app/services/admin.py:1-188](file://backend/app/services/admin.py#L1-L188)
|
||||
- [backend/app/services/subscription.py:1-155](file://backend/app/services/subscription.py#L1-L155)
|
||||
- [backend/app/workers/llm_adapter.py:1-281](file://backend/app/workers/llm_adapter.py#L1-L281)
|
||||
- [backend/app/services/llm/factory.py:1-66](file://backend/app/services/llm/factory.py#L1-L66)
|
||||
- [backend/app/agent_framework/registry.py:1-219](file://backend/app/agent_framework/registry.py#L1-L219)
|
||||
- [backend/app/agent_framework/dispatcher.py:1-367](file://backend/app/agent_framework/dispatcher.py#L1-L367)
|
||||
- [backend/app/agent_framework/pipeline/engine.py:1-536](file://backend/app/agent_framework/pipeline/engine.py#L1-L536)
|
||||
- [backend/app/agent_framework/pipeline/loader.py:1-283](file://backend/app/agent_framework/pipeline/loader.py#L1-L283)
|
||||
- [backend/app/agent_framework/pipeline/schema.py:1-102](file://backend/app/agent_framework/pipeline/schema.py#L1-L102)
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/api/auth.py:1-43](file://backend/app/api/auth.py#L1-L43)
|
||||
|
|
@ -665,17 +1090,21 @@ ENGINE --> MODELS["models/*.py"]
|
|||
- [backend/app/models/user.py:1-41](file://backend/app/models/user.py#L1-L41)
|
||||
- [backend/app/models/query.py:1-55](file://backend/app/models/query.py#L1-L55)
|
||||
- [backend/app/models/citation_record.py:1-42](file://backend/app/models/citation_record.py#L1-L42)
|
||||
- [backend/app/models/agent.py:1-206](file://backend/app/models/agent.py#L1-L206)
|
||||
|
||||
## 性能考量
|
||||
- 异步 I/O:数据库与平台查询均采用异步,提升并发吞吐。
|
||||
- 会话管理:显式事务边界,避免长事务占用连接池。
|
||||
- 定时任务:APScheduler 异步调度,事件循环兼容处理,降低阻塞风险。
|
||||
- 索引优化:查询与引用表建立复合索引,加速过滤与排序。
|
||||
- **新增** Redis缓存:代理框架使用Redis作为消息队列,支持高并发任务分发。
|
||||
- **新增** LLM优化:LLM调用采用异步模式,支持批量处理和错误重试。
|
||||
- **新增** 工作流优化:DAG拓扑排序确保任务执行顺序,避免死锁和循环依赖。
|
||||
- 缓存建议:可引入 Redis 缓存热点查询结果与用户会话信息(当前配置已准备)。
|
||||
- 日志采样:生产环境建议开启采样与结构化日志,避免高频日志影响性能。
|
||||
- **中间件性能**:限流中间件使用内存存储,性能开销低;日志中间件仅记录必要信息。
|
||||
|
||||
**更新** 新增中间件系统的性能考量,包括内存存储的限流机制和结构化日志的性能影响。
|
||||
**更新** 新增代理框架、LLM服务和工作流系统的性能考量,包括Redis缓存、异步LLM调用和DAG执行优化。
|
||||
|
||||
## 故障排查指南
|
||||
- 认证失败:检查 JWT 秘钥、过期时间与前端令牌传递;确认 OAuth2 tokenUrl 与 Bearer 头正确。
|
||||
|
|
@ -683,11 +1112,14 @@ ENGINE --> MODELS["models/*.py"]
|
|||
- 定时任务异常:关注调度器日志,检查查询状态与平台适配器可用性;确认 next_query_at 计算逻辑。
|
||||
- 引擎执行失败:查看平台适配器错误与原始响应;检查品牌匹配器与竞争品牌检测逻辑。
|
||||
- CORS 问题:确认前端域名与请求头是否在允许范围内。
|
||||
- **新增** 代理框架问题:检查Redis连接、Agent注册状态、任务队列是否正常。
|
||||
- **新增** LLM服务问题:检查API密钥配置、提供商可用性、请求超时设置。
|
||||
- **新增** 工作流执行问题:检查YAML配置语法、依赖关系、变量引用是否正确。
|
||||
- **中间件问题**:检查限流规则配置,确认健康检查路径是否被正确豁免;验证日志中间件的logger配置。
|
||||
- **管理员权限**:确认用户 is_admin 字段,检查管理员路由的权限验证逻辑。
|
||||
- **订阅状态**:检查用户套餐与订阅状态的一致性,验证订阅历史记录的查询逻辑。
|
||||
|
||||
**更新** 新增中间件、管理员服务、订阅服务相关的故障排查指导。
|
||||
**更新** 新增代理框架、LLM服务和分布式发布系统的故障排查指导。
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/api/deps.py:16-43](file://backend/app/api/deps.py#L16-L43)
|
||||
|
|
@ -696,15 +1128,18 @@ ENGINE --> MODELS["models/*.py"]
|
|||
- [backend/app/workers/citation_engine.py:211-227](file://backend/app/workers/citation_engine.py#L211-L227)
|
||||
- [backend/app/middleware/rate_limit.py:34-83](file://backend/app/middleware/rate_limit.py#L34-L83)
|
||||
- [backend/app/middleware/logging_middleware.py:8-24](file://backend/app/middleware/logging_middleware.py#L8-L24)
|
||||
- [backend/app/agent_framework/base.py:148-182](file://backend/app/agent_framework/base.py#L148-L182)
|
||||
- [backend/app/workers/llm_adapter.py:141-218](file://backend/app/workers/llm_adapter.py#L141-L218)
|
||||
- [backend/app/agent_framework/pipeline/loader.py:124-134](file://backend/app/agent_framework/pipeline/loader.py#L124-L134)
|
||||
|
||||
## 结论
|
||||
该架构以 FastAPI 为核心,结合异步数据库、定时任务与多平台适配器,形成高可用、可扩展的查询与引用检测系统。通过明确的分层与依赖注入,系统具备良好的可测试性与可维护性。新增的中间件系统提供了安全防护和性能监控能力,管理员服务增强了系统管理功能,订阅服务完善了商业化运营能力,报告服务提升了用户体验。建议在生产环境中完善日志与监控、接入缓存与告警,并持续优化索引与查询计划。
|
||||
该架构以 FastAPI 为核心,结合异步数据库、定时任务与多平台适配器,形成高可用、可扩展的查询与引用检测系统。通过明确的分层与依赖注入,系统具备良好的可测试性与可维护性。新增的代理框架、LLM服务集成、工作器系统扩展和分布式发布系统,显著增强了系统的智能化、自动化和企业级服务能力。系统现已支持复杂的AI内容生产工作流、多提供商的LLM集成、分布式任务调度和实时进度监控,为GEO平台的商业化运营奠定了坚实的技术基础。
|
||||
|
||||
**更新** 本次更新显著增强了系统的功能完整性,包括安全防护、管理能力、商业运营和用户体验等方面。
|
||||
**更新** 本次更新大幅扩展了系统功能,新增代理框架、LLM服务集成、工作器系统扩展和分布式发布系统,形成了更加完整的企业级AI内容生产与管理平台。
|
||||
|
||||
## 附录
|
||||
- API 设计原则:统一前缀与标签、明确响应模型、一致的状态码与错误消息。
|
||||
- 错误处理:在路由层捕获业务异常并转换为标准 HTTP 状态码;在依赖层统一 401 未授权。
|
||||
- 响应格式:遵循 Pydantic 模型序列化,确保前后端契约一致。
|
||||
- 架构决策背景:选择异步栈以提升 I/O 密集场景性能;APScheduler 简化定时任务编排;JWT 适合无状态认证场景;中间件系统提供安全防护和性能监控。
|
||||
- **新增功能背景**:中间件系统满足安全需求;管理员服务满足系统管理需求;订阅服务满足商业化需求;报告服务满足用户体验需求。
|
||||
- **新增功能背景**:代理框架满足分布式任务调度需求;LLM服务满足AI能力集成需求;工作器系统扩展满足多平台适配需求;分布式发布系统满足复杂工作流编排需求。
|
||||
|
|
@ -18,22 +18,45 @@
|
|||
- [backend/app/schemas/auth.py](file://backend/app/schemas/auth.py)
|
||||
- [backend/app/models/user.py](file://backend/app/models/user.py)
|
||||
- [README.md](file://README.md)
|
||||
- [docs/03-development/coding-standards.md](file://docs/03-development/coding-standards.md)
|
||||
- [docs/03-development/dev-guide.md](file://docs/03-development/dev-guide.md)
|
||||
- [docs/05-deployment/deployment-guide.md](file://docs/05-deployment/deployment-guide.md)
|
||||
- [docs/04-testing/test-strategy.md](file://docs/04-testing/test-strategy.md)
|
||||
- [docs/03-development/tdd-workflow.md](file://docs/03-development/tdd-workflow.md)
|
||||
- [docs/00-project/tech-stack.md](file://docs/00-project/tech-stack.md)
|
||||
</cite>
|
||||
|
||||
## 更新摘要
|
||||
**所做更改**
|
||||
- 新增完整的代码规范文档,包含Python和TypeScript开发标准
|
||||
- 新增TDD测试驱动开发流程规范,定义RED-GREEN-REFACTOR循环
|
||||
- 新增开发指南文档,涵盖环境搭建和开发流程
|
||||
- 新增部署指南文档,定义多环境部署策略
|
||||
- 新增测试策略文档,建立四层测试金字塔
|
||||
- 新增技术栈说明文档,详细阐述各技术选型
|
||||
- 更新模块设计指南,增加Agent框架开发指导
|
||||
- 完善开发工具使用方法,包含调试和性能分析工具
|
||||
|
||||
## 目录
|
||||
1. 引言
|
||||
2. 项目结构
|
||||
3. 核心组件
|
||||
4. 架构总览
|
||||
5. 详细组件分析
|
||||
6. 依赖分析
|
||||
7. 性能考虑
|
||||
8. 故障排查指南
|
||||
9. 结论
|
||||
10. 附录
|
||||
6. 代码规范与最佳实践
|
||||
7. 开发流程与工作流
|
||||
8. 开发工具使用方法
|
||||
9. 新功能开发指导原则
|
||||
10. 测试策略与实施
|
||||
11. 部署管理
|
||||
12. 常见问题与解决方案
|
||||
13. 结论
|
||||
14. 附录
|
||||
|
||||
## 引言
|
||||
本开发指南面向GEO项目的开发者,旨在统一前后端代码规范与最佳实践,明确开发流程与工作流(包括分支策略、代码评审与版本发布),并提供开发工具使用方法(IDE配置、调试与性能分析、**Git部署自动化脚本**)、新功能开发指导原则(模块设计、接口定义与测试要求),以及常见问题的排查方案。本指南以仓库中现有实现为依据,确保内容可落地、可执行。
|
||||
本开发指南面向GEO项目的开发者,旨在统一前后端代码规范与最佳实践,明确开发流程与工作流(包括分支策略、代码评审与版本发布),并提供开发工具使用方法(IDE配置、调试与性能分析、Git部署自动化脚本)、新功能开发指导原则(模块设计、接口定义与测试要求),以及常见问题的排查方案。本指南以仓库中现有实现为依据,确保内容可落地、可执行。
|
||||
|
||||
**更新** 新增完整的开发文档体系,包括代码规范、开发流程、测试策略和部署管理等核心内容。
|
||||
|
||||
## 项目结构
|
||||
GEO采用前后端分离架构,后端基于FastAPI,前端基于Next.js,数据库使用PostgreSQL,缓存使用Redis,任务调度使用APScheduler,浏览器自动化使用Playwright。项目通过Docker与docker-compose进行容器化编排,便于本地开发与部署。
|
||||
|
|
@ -75,20 +98,20 @@ DC --> REDIS
|
|||
- [docker-compose.yml:1-71](file://docker-compose.yml#L1-L71)
|
||||
- [frontend/Dockerfile:1-15](file://frontend/Dockerfile#L1-L15)
|
||||
- [backend/Dockerfile:1-41](file://backend/Dockerfile#L1-L41)
|
||||
- [frontend/package.json:1-40](file://frontend/package.json#L1-L40)
|
||||
- [backend/requirements.txt:1-35](file://backend/requirements.txt#L1-L35)
|
||||
- [frontend/package.json:1-45](file://frontend/package.json#L1-L45)
|
||||
- [backend/requirements.txt:1-42](file://backend/requirements.txt#L1-L42)
|
||||
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
|
||||
- [backend/app/config.py:1-17](file://backend/app/config.py#L1-L17)
|
||||
- [backend/app/config.py:1-46](file://backend/app/config.py#L1-L46)
|
||||
- [backend/alembic.ini:1-150](file://backend/alembic.ini#L1-L150)
|
||||
|
||||
**章节来源**
|
||||
- [docker-compose.yml:1-71](file://docker-compose.yml#L1-L71)
|
||||
- [frontend/Dockerfile:1-15](file://frontend/Dockerfile#L1-L15)
|
||||
- [backend/Dockerfile:1-41](file://backend/Dockerfile#L1-L41)
|
||||
- [frontend/package.json:1-40](file://frontend/package.json#L1-L40)
|
||||
- [backend/requirements.txt:1-35](file://backend/requirements.txt#L1-L35)
|
||||
- [frontend/package.json:1-45](file://frontend/package.json#L1-L45)
|
||||
- [backend/requirements.txt:1-42](file://backend/requirements.txt#L1-L42)
|
||||
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
|
||||
- [backend/app/config.py:1-17](file://backend/app/config.py#L1-L17)
|
||||
- [backend/app/config.py:1-46](file://backend/app/config.py#L1-L46)
|
||||
- [backend/alembic.ini:1-150](file://backend/alembic.ini#L1-L150)
|
||||
|
||||
## 核心组件
|
||||
|
|
@ -101,10 +124,10 @@ DC --> REDIS
|
|||
|
||||
**章节来源**
|
||||
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
|
||||
- [backend/app/config.py:1-17](file://backend/app/config.py#L1-L17)
|
||||
- [frontend/package.json:1-40](file://frontend/package.json#L1-L40)
|
||||
- [backend/app/config.py:1-46](file://backend/app/config.py#L1-L46)
|
||||
- [frontend/package.json:1-45](file://frontend/package.json#L1-L45)
|
||||
- [frontend/tsconfig.json:1-27](file://frontend/tsconfig.json#L1-L27)
|
||||
- [frontend/.eslintrc.json:1-4](file://frontend/.eslintrc.json#L1-L4)
|
||||
- [frontend/.eslintrc.json:1-14](file://frontend/.eslintrc.json#L1-L14)
|
||||
- [frontend/tailwind.config.ts:1-57](file://frontend/tailwind.config.ts#L1-L57)
|
||||
- [backend/alembic.ini:86-114](file://backend/alembic.ini#L86-L114)
|
||||
- [tests/conftest.py:1-71](file://tests/conftest.py#L1-L71)
|
||||
|
|
@ -129,7 +152,7 @@ FastAPI --> Playwright
|
|||
|
||||
**图表来源**
|
||||
- [backend/app/main.py:24-47](file://backend/app/main.py#L24-L47)
|
||||
- [backend/app/config.py:7-13](file://backend/app/config.py#L7-L13)
|
||||
- [backend/app/config.py:12-18](file://backend/app/config.py#L12-L18)
|
||||
- [backend/Dockerfile:31-33](file://backend/Dockerfile#L31-L33)
|
||||
- [docker-compose.yml:4-20](file://docker-compose.yml#L4-L20)
|
||||
- [docker-compose.yml:22-34](file://docker-compose.yml#L22-L34)
|
||||
|
|
@ -238,7 +261,7 @@ SkipHooks --> Done
|
|||
|
||||
**章节来源**
|
||||
- [backend/alembic.ini:1-150](file://backend/alembic.ini#L1-L150)
|
||||
- [backend/app/config.py:7-8](file://backend/app/config.py#L7-L8)
|
||||
- [backend/app/config.py:12-13](file://backend/app/config.py#L12-L13)
|
||||
|
||||
### 前端工程化
|
||||
- 构建与运行:dev/build/start/lint脚本由Next.js提供。
|
||||
|
|
@ -247,9 +270,9 @@ SkipHooks --> Done
|
|||
- Tailwind:按需扫描pages/components/app目录,启用动画插件。
|
||||
|
||||
**章节来源**
|
||||
- [frontend/package.json:1-40](file://frontend/package.json#L1-L40)
|
||||
- [frontend/package.json:1-45](file://frontend/package.json#L1-L45)
|
||||
- [frontend/tsconfig.json:1-27](file://frontend/tsconfig.json#L1-L27)
|
||||
- [frontend/.eslintrc.json:1-4](file://frontend/.eslintrc.json#L1-L4)
|
||||
- [frontend/.eslintrc.json:1-14](file://frontend/.eslintrc.json#L1-L14)
|
||||
- [frontend/tailwind.config.ts:1-57](file://frontend/tailwind.config.ts#L1-L57)
|
||||
|
||||
### Git部署自动化脚本
|
||||
|
|
@ -286,219 +309,374 @@ SkipHooks --> Done
|
|||
**章节来源**
|
||||
- [README.md:1-3](file://README.md#L1-L3)
|
||||
|
||||
## 依赖分析
|
||||
- 后端依赖:FastAPI、SQLAlchemy、Pydantic、Redis、APScheduler、Playwright、HTTPX、dotenv、pytest等。
|
||||
- 前端依赖:Next.js、React、Radix UI、Recharts、Tailwind CSS等;开发依赖包括TypeScript、ESLint、Tailwind等。
|
||||
- 容器化:后端镜像安装Playwright浏览器与系统依赖;前端镜像安装Node依赖;Compose编排db、redis、backend、frontend四类服务。
|
||||
- **部署工具**:Git、Docker CLI、Docker Compose等部署相关工具。
|
||||
## 代码规范与最佳实践
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "后端"
|
||||
FastAPI["FastAPI"]
|
||||
SQLA["SQLAlchemy"]
|
||||
Pydantic["Pydantic"]
|
||||
RedisDep["Redis"]
|
||||
APS["APScheduler"]
|
||||
PW["Playwright"]
|
||||
HTTPX["HTTPX"]
|
||||
DOTENV["python-dotenv"]
|
||||
PyTest["pytest"]
|
||||
end
|
||||
subgraph "前端"
|
||||
Next["Next.js"]
|
||||
React["React"]
|
||||
Radix["Radix UI"]
|
||||
Recharts["Recharts"]
|
||||
Tailwind["Tailwind CSS"]
|
||||
TS["TypeScript"]
|
||||
ESL["ESLint"]
|
||||
end
|
||||
subgraph "部署工具"
|
||||
Git["Git"]
|
||||
Docker["Docker CLI"]
|
||||
DockerCompose["Docker Compose"]
|
||||
PushScript["push_script.sh"]
|
||||
end
|
||||
FastAPI --> SQLA
|
||||
FastAPI --> Pydantic
|
||||
FastAPI --> RedisDep
|
||||
FastAPI --> APS
|
||||
FastAPI --> PW
|
||||
FastAPI --> HTTPX
|
||||
FastAPI --> DOTENV
|
||||
Next --> React
|
||||
Next --> Tailwind
|
||||
Next --> Radix
|
||||
Next --> Recharts
|
||||
Next --> TS
|
||||
Next --> ESL
|
||||
Docker --> DockerCompose
|
||||
Docker --> PushScript
|
||||
Git --> PushScript
|
||||
```
|
||||
### Python代码规范
|
||||
- **命名约定**
|
||||
- 模块与类使用PascalCase(如:UserService、CitationDetector)
|
||||
- 函数与变量使用snake_case(如:get_user_by_id、process_data)
|
||||
- 常量使用UPPER_CASE(如:MAX_RETRY_COUNT、DEFAULT_TIMEOUT)
|
||||
- 私有成员使用单下划线前缀(如:_internal_method)
|
||||
|
||||
**图表来源**
|
||||
- [backend/requirements.txt:1-35](file://backend/requirements.txt#L1-L35)
|
||||
- [frontend/package.json:11-38](file://frontend/package.json#L11-L38)
|
||||
- [docker-compose.yml:1-71](file://docker-compose.yml#L1-L71)
|
||||
- **代码格式化**
|
||||
- 使用Black进行代码格式化,统一代码风格
|
||||
- Pydantic v2进行数据校验与配置管理
|
||||
- 字段约束与默认值清晰明确
|
||||
|
||||
- **类型注解**
|
||||
- 严格使用类型注解,包括函数参数、返回值和变量声明
|
||||
- 使用Union、Optional等类型组合器处理可选类型
|
||||
- 利用Generic类型支持泛型编程
|
||||
|
||||
- **错误处理**
|
||||
- 对外抛出HTTPException并设置合适的状态码与错误信息
|
||||
- 使用try-except捕获特定异常,避免裸except
|
||||
- 实现统一的异常处理器
|
||||
|
||||
- **模块化设计**
|
||||
- API、Schema、Model、Service分层清晰,职责单一
|
||||
- 配置通过Pydantic Settings从.env加载,区分开发与生产环境
|
||||
|
||||
**章节来源**
|
||||
- [backend/requirements.txt:1-35](file://backend/requirements.txt#L1-L35)
|
||||
- [frontend/package.json:1-40](file://frontend/package.json#L1-L40)
|
||||
- [docker-compose.yml:1-71](file://docker-compose.yml#L1-L71)
|
||||
- [backend/requirements.txt:1-42](file://backend/requirements.txt#L1-L42)
|
||||
- [backend/app/config.py:9-46](file://backend/app/config.py#L9-L46)
|
||||
|
||||
## 性能考虑
|
||||
- 异步化:后端使用异步数据库驱动与异步HTTP客户端,减少阻塞,提升并发能力。
|
||||
- 缓存:Redis用于任务调度与会话等场景,建议在热点数据访问处引入缓存层。
|
||||
- 任务调度:APScheduler负责周期性任务,注意避免重复任务与资源泄漏,结合优雅停机逻辑。
|
||||
- 前端构建:严格模式与按需扫描Tailwind可降低包体与构建开销;生产构建建议开启压缩与Tree Shaking。
|
||||
- 数据库:合理索引与查询优化,避免N+1查询;批量写入与事务合并可减少往返次数。
|
||||
- **部署性能**:使用push_script.sh的增量构建功能,避免不必要的镜像重建;合理配置Docker构建缓存。
|
||||
### TypeScript/React代码规范
|
||||
- **命名约定**
|
||||
- 接口与类型使用PascalCase(如:UserData、ApiResponse)
|
||||
- 变量与函数使用camelCase(如:getUserData、processFormData)
|
||||
- 枚举使用UPPER_CASE(如:UserRole.ADMIN)
|
||||
|
||||
## 故障排查指南
|
||||
- 启动失败(后端):检查数据库与Redis健康状态,确认连接字符串与端口映射正确;查看Uvicorn日志与容器重启策略。
|
||||
- 认证异常:核对JWT密钥与过期时间配置;确认请求头携带正确的Bearer Token;检查依赖覆盖与用户mock是否生效。
|
||||
- 数据迁移问题:检查Alembic日志级别与钩子配置;确认数据库URL与凭据;必要时手动回滚或修复迁移脚本。
|
||||
- 前端样式异常:确认Tailwind content扫描路径与组件目录一致;清理.next缓存后重新构建。
|
||||
- 测试失败:确认pytest会话注入后端路径;检查调度器mock与依赖覆盖;使用异步HTTP客户端发起请求。
|
||||
- **部署失败**:检查push_script.sh权限设置;确认Git配置与远程仓库访问权限;验证Docker守护进程状态;查看部署日志输出。
|
||||
- **类型系统**
|
||||
- 严格模式开启,禁用输出JS
|
||||
- 使用bundler解析模块,确保类型安全
|
||||
- 路径别名@/*映射根目录,简化导入路径
|
||||
|
||||
- **组件设计**
|
||||
- 使用React Hooks管理状态和副作用
|
||||
- 实现受控组件模式,确保数据流清晰
|
||||
- 组件Props使用TypeScript接口定义
|
||||
|
||||
- **ESLint配置**
|
||||
- 继承Next.js核心Web Vitals与TypeScript规则
|
||||
- 自定义规则:忽略未使用变量警告,支持下划线前缀
|
||||
|
||||
**章节来源**
|
||||
- [frontend/package.json:1-45](file://frontend/package.json#L1-L45)
|
||||
- [frontend/tsconfig.json:1-27](file://frontend/tsconfig.json#L1-L27)
|
||||
- [frontend/.eslintrc.json:1-14](file://frontend/.eslintrc.json#L1-L14)
|
||||
|
||||
### 注释规范
|
||||
- **Python文档字符串**
|
||||
- 使用Google风格的docstring格式
|
||||
- 函数文档包含:参数说明、返回值、异常说明
|
||||
- 类文档包含:构造函数说明、主要方法列表
|
||||
|
||||
- **TypeScript JSDoc**
|
||||
- 接口和类型使用JSDoc注释
|
||||
- 复杂函数添加详细说明和使用示例
|
||||
- 导出的公共API必须有完整注释
|
||||
|
||||
- **代码注释**
|
||||
- 重要算法添加算法思路说明
|
||||
- 外部依赖添加版本和用途说明
|
||||
- 临时解决方案添加TODO注释
|
||||
|
||||
**章节来源**
|
||||
- [docs/03-development/coding-standards.md:1-29](file://docs/03-development/coding-standards.md#L1-L29)
|
||||
|
||||
## 开发流程与工作流
|
||||
|
||||
### Git分支策略
|
||||
- **主分支(main)**:保护分支,仅允许通过Pull Request合并
|
||||
- **功能分支(feature/*)**:开发新功能,完成后合并到develop
|
||||
- **发布分支(release/x.y.z)**:用于预发布与回归测试
|
||||
- **热修复分支(hotfix/*)**:直接修改main分支并回放至develop
|
||||
|
||||
### 代码评审流程
|
||||
- Pull Request必须包含变更说明、测试用例与性能影响评估
|
||||
- 至少一名Reviewer同意后方可合并
|
||||
- 评审关注点:代码质量、安全性、可维护性与兼容性
|
||||
|
||||
### 版本发布管理
|
||||
- **语义化版本控制**:小版本用于新增功能,补丁版本用于修复
|
||||
- **发布前检查**:更新CHANGELOG,运行全量测试,检查依赖安全漏洞
|
||||
- **发布后验证**:同步文档与环境配置,监控线上指标
|
||||
|
||||
**章节来源**
|
||||
- [docs/03-development/dev-guide.md:1-32](file://docs/03-development/dev-guide.md#L1-L32)
|
||||
|
||||
## 开发工具使用方法
|
||||
|
||||
### IDE配置
|
||||
- **VS Code**:安装Python与TypeScript扩展,启用ESLint与Prettier
|
||||
- **前端**:启用TypeScript智能提示与ESLint实时检查;Tailwind IntelliSense增强CSS类提示
|
||||
|
||||
### 调试技巧
|
||||
- **后端**:使用Uvicorn的reload选项热重载;在FastAPI中设置调试日志级别
|
||||
- **前端**:使用Next.js dev模式热更新;在浏览器开发者工具中检查网络与状态
|
||||
|
||||
### 性能分析工具
|
||||
- **后端**:使用cProfile或py-spy分析CPU与内存;结合APScheduler监控任务耗时
|
||||
- **前端**:使用Chrome DevTools Performance面板分析渲染与网络
|
||||
|
||||
### 部署工具使用
|
||||
- **push_script.sh使用**:确保脚本具有执行权限,基本使用./push_script.sh
|
||||
- **Docker部署**:使用docker-compose up -d启动服务,logs查看日志
|
||||
|
||||
**章节来源**
|
||||
- [docs/03-development/dev-guide.md:1-32](file://docs/03-development/dev-guide.md#L1-L32)
|
||||
|
||||
## 新功能开发指导原则
|
||||
|
||||
### 模块设计
|
||||
- 遵循"API-Service-Model"三层架构,保持关注点分离
|
||||
- 将业务逻辑封装在Service层,避免在API层直接操作数据库
|
||||
|
||||
### 接口定义
|
||||
- 使用Pydantic模型定义请求与响应结构,明确字段类型与约束
|
||||
- 对外暴露RESTful接口,遵循统一的前缀与标签组织路由
|
||||
|
||||
### 测试要求
|
||||
- **单元测试**:覆盖关键业务逻辑与边界条件
|
||||
- **集成测试**:使用pytest与AsyncClient发起HTTP请求,验证端到端流程
|
||||
- **Mock策略**:对调度器、外部服务与数据库进行合理Mock
|
||||
|
||||
### 部署要求
|
||||
- 新功能开发完成后,使用push_script.sh进行部署测试
|
||||
- 确保所有环境变量正确配置,包括数据库连接、Redis配置等
|
||||
|
||||
**章节来源**
|
||||
- [docs/03-development/dev-guide.md:1-32](file://docs/03-development/dev-guide.md#L1-L32)
|
||||
|
||||
## 测试策略与实施
|
||||
|
||||
### TDD测试驱动开发
|
||||
**新增** GEO平台采用完整的TDD(测试驱动开发)流程:
|
||||
|
||||
#### RED-GREEN-REFACTOR循环
|
||||
- **RED阶段**:编写失败测试,验证具体功能点
|
||||
- **GREEN阶段**:编写最少量生产代码使测试通过
|
||||
- **REFACTOR阶段**:优化代码结构、消除重复、提升可读性
|
||||
|
||||
#### 测试层次金字塔
|
||||
```
|
||||
▲
|
||||
/│\
|
||||
/ │ \ E2E 测试(端到端)
|
||||
/ │ \ 覆盖关键用户旅程
|
||||
/───┼───\ 占比:5%
|
||||
/ │ \
|
||||
/─────┼─────\ 集成测试
|
||||
/ │ \ 模块间交互验证
|
||||
/───────┼───────\ 占比:15%
|
||||
/ │ \
|
||||
/─────────┼─────────\ 单元测试
|
||||
/ │ \ 单个函数/类验证
|
||||
/───────────┼───────────\ 占比:60%
|
||||
/ │ \
|
||||
/─────────────┼─────────────\ Agent 测试
|
||||
/ │ \ Agent 行为验证
|
||||
/───────────────┼───────────────\ 占比:20%
|
||||
───────────────────────────────────
|
||||
```
|
||||
|
||||
#### 单元测试(Unit Tests)
|
||||
- **目标**:验证单个函数、类或方法的行为
|
||||
- **原则**:FIRST原则(Fast、Independent、Repeatable、Self-validating、Timely)
|
||||
- **工具**:pytest + unittest.mock
|
||||
|
||||
#### 集成测试(Integration Tests)
|
||||
- **目标**:验证多个模块之间的交互是否正确
|
||||
- **范围**:数据库、缓存、消息队列等真实组件
|
||||
- **工具**:pytest + TestClient(FastAPI) + testcontainers
|
||||
|
||||
#### E2E测试(End-to-End Tests)
|
||||
- **目标**:模拟真实用户操作,验证完整业务流程
|
||||
- **工具**:Playwright(前端)+ pytest(后端 API)
|
||||
|
||||
#### Agent测试(Agent Tests)
|
||||
- **目标**:验证 AI Agent 的行为和输出质量
|
||||
- **特殊要求**:使用固定测试输入,验证输出结构和质量
|
||||
|
||||
**章节来源**
|
||||
- [docs/03-development/tdd-workflow.md:1-583](file://docs/03-development/tdd-workflow.md#L1-L583)
|
||||
|
||||
### 测试覆盖要求
|
||||
- **单元测试**:>= 80%覆盖率
|
||||
- **集成测试**:覆盖所有API端点
|
||||
- **E2E测试**:覆盖所有P0用户旅程
|
||||
- **Agent测试**:覆盖核心场景
|
||||
|
||||
### 测试数据管理
|
||||
- **Fixtures**:使用pytest.fixture管理测试数据
|
||||
- **工厂模式**:复杂对象使用工厂函数创建
|
||||
- **数据清理**:每个测试前后清理数据,失败时回滚
|
||||
|
||||
**章节来源**
|
||||
- [docs/04-testing/test-strategy.md:1-33](file://docs/04-testing/test-strategy.md#L1-L33)
|
||||
|
||||
## 部署管理
|
||||
|
||||
### 部署架构
|
||||
**新增** GEO平台支持多环境部署:
|
||||
|
||||
#### 开发环境部署
|
||||
- 使用本地Docker Compose启动
|
||||
- 开发数据库和Redis实例
|
||||
- 支持热重载和调试模式
|
||||
|
||||
#### 测试环境部署
|
||||
- 使用独立的测试数据库
|
||||
- 配置测试专用的API密钥
|
||||
- 自动化测试流水线集成
|
||||
|
||||
#### 生产环境部署
|
||||
- 使用Nginx反向代理
|
||||
- 配置SSL证书和域名
|
||||
- 负载均衡和滚动更新
|
||||
|
||||
### 部署检查清单
|
||||
- **环境配置**:数据库连接、Redis配置、API密钥
|
||||
- **服务健康**:容器状态、端口映射、网络连通性
|
||||
- **数据迁移**:Alembic迁移执行、数据完整性检查
|
||||
- **安全配置**:JWT密钥、CORS配置、SSL证书
|
||||
|
||||
**章节来源**
|
||||
- [docs/05-deployment/deployment-guide.md:1-32](file://docs/05-deployment/deployment-guide.md#L1-L32)
|
||||
|
||||
## 常见问题与解决方案
|
||||
|
||||
### 数据库连接失败
|
||||
- 检查PostgreSQL容器健康状态与端口映射
|
||||
- 确认DATABASE_URL与凭据
|
||||
|
||||
### Redis连接失败
|
||||
- 检查Redis容器健康状态与端口映射
|
||||
- 确认REDIS_URL
|
||||
|
||||
### Playwright无法启动浏览器
|
||||
- 确认Dockerfile中已安装Playwright浏览器与系统依赖
|
||||
- 检查PLAYWRIGHT_BROWSERS_PATH
|
||||
|
||||
### CORS跨域问题
|
||||
- 核对CORS中间件配置的allow_origins与headers
|
||||
- 确保前端域名与端口匹配
|
||||
|
||||
### JWT认证失败
|
||||
- 检查JWT_SECRET与过期时间
|
||||
- 确认请求头Authorization格式为Bearer Token
|
||||
|
||||
### 测试失败排查
|
||||
- **单元测试**:检查Mock配置和依赖注入
|
||||
- **集成测试**:验证数据库连接和事务处理
|
||||
- **E2E测试**:检查浏览器自动化和页面元素定位
|
||||
|
||||
### 性能问题诊断
|
||||
- **后端**:使用cProfile分析CPU使用率,检查数据库查询
|
||||
- **前端**:使用Chrome DevTools分析渲染性能,检查组件重渲染
|
||||
|
||||
**章节来源**
|
||||
- [docker-compose.yml:4-34](file://docker-compose.yml#L4-L34)
|
||||
- [backend/app/config.py:7-13](file://backend/app/config.py#L7-L13)
|
||||
- [backend/app/config.py:12-18](file://backend/app/config.py#L12-L18)
|
||||
- [tests/conftest.py:19-50](file://tests/conftest.py#L19-L50)
|
||||
- [backend/alembic.ini:115-150](file://backend/alembic.ini#L115-L150)
|
||||
- [frontend/tailwind.config.ts:5-9](file://frontend/tailwind.config.ts#L5-L9)
|
||||
|
||||
## 结论
|
||||
本指南基于仓库现有实现,给出了统一的代码规范、开发流程与工具使用建议。建议在后续迭代中补充更详细的Git分支策略、代码评审清单与发布流程文档,并持续完善测试覆盖率与性能监控体系。**新增的部署脚本push_script.sh显著提升了开发者的部署效率,建议在团队内部推广使用并定期更新其功能特性。**
|
||||
本指南基于仓库现有实现,建立了完整的开发文档体系,包括代码规范、开发流程、测试策略和部署管理等核心内容。建议在后续迭代中持续完善文档内容,补充更详细的Git分支策略、代码评审清单与发布流程文档,并持续完善测试覆盖率与性能监控体系。
|
||||
|
||||
**更新** 新增的开发文档体系显著提升了项目的规范化程度,为团队协作和项目维护奠定了坚实基础。
|
||||
|
||||
## 附录
|
||||
|
||||
### 代码规范与最佳实践
|
||||
### 技术栈说明
|
||||
**新增** GEO平台采用现代化技术栈:
|
||||
|
||||
- **Python(后端)**
|
||||
- 使用Pydantic v2进行数据校验与配置管理,字段约束与默认值清晰明确。
|
||||
- 异步编程:优先使用异步数据库与HTTP客户端,避免阻塞操作。
|
||||
- 错误处理:对外抛出HTTPException并设置合适的状态码与错误信息。
|
||||
- 模块化:API、Schema、Model、Service分层清晰,职责单一。
|
||||
- 配置:通过Pydantic Settings从.env加载配置,区分开发与生产环境。
|
||||
#### 前端技术栈
|
||||
- **Next.js 14**:React框架,支持SSR和静态生成
|
||||
- **TypeScript**:强类型语言,提升代码质量和开发体验
|
||||
- **Tailwind CSS**:原子化CSS框架,快速构建UI界面
|
||||
- **shadcn/ui**:高质量组件库,支持主题定制
|
||||
- **NextAuth.js**:认证解决方案,支持多种登录方式
|
||||
|
||||
- **TypeScript(前端)**
|
||||
- 严格模式开启,禁用输出JS,使用bundler解析模块,确保类型安全。
|
||||
- ESLint规则继承Next.js核心Web Vitals与TypeScript默认规则,保持一致性。
|
||||
- Tailwind按需扫描组件与页面目录,减少CSS体积;启用动画插件提升交互体验。
|
||||
- 路径别名@/*映射根目录,简化导入路径。
|
||||
#### 后端技术栈
|
||||
- **FastAPI**:高性能异步Web框架
|
||||
- **Python 3.12**:类型提示最佳实践
|
||||
- **SQLAlchemy 2.0**:ORM对象关系映射
|
||||
- **Alembic**:数据库迁移管理
|
||||
- **Celery**:异步任务队列(可选)
|
||||
- **Redis**:缓存与消息代理
|
||||
|
||||
- **命名约定**
|
||||
- Python:模块与类使用PascalCase;函数与变量使用snake_case;常量使用UPPER_CASE。
|
||||
- TypeScript:接口与类型使用PascalCase;变量与函数使用camelCase;枚举使用UPPER_CASE。
|
||||
#### AI Agent技术栈
|
||||
- **Agent框架**:可扩展的AI代理框架
|
||||
- **LLM模型**:支持多种大语言模型
|
||||
- **提示工程**:结构化的提示模板管理
|
||||
- **向量数据库**:可选的向量相似度检索
|
||||
|
||||
- **部署脚本规范**
|
||||
- 使用push_script.sh进行标准化部署,避免手动操作导致的不一致。
|
||||
- 遵循语义化版本控制,合理选择版本类型(patch/minor/major)。
|
||||
- 在团队内统一部署流程,确保所有成员使用相同的部署脚本参数。
|
||||
|
||||
### 开发流程与工作流
|
||||
|
||||
- **Git分支策略(建议)**
|
||||
- 主分支:保护分支,仅允许通过PR合并。
|
||||
- 功能分支:feature/xxx,完成后合并到develop。
|
||||
- 发布分支:release/x.y.z,用于预发布与回归测试。
|
||||
- 热修复分支:hotfix/xxx,直接修改主分支并回放至develop。
|
||||
|
||||
- **代码评审(建议)**
|
||||
- PR必须包含变更说明、测试用例与性能影响评估。
|
||||
- 至少一名Reviewer同意后方可合并。
|
||||
- 评审关注点:代码质量、安全性、可维护性与兼容性。
|
||||
|
||||
- **版本发布管理(建议)**
|
||||
- 语义化版本:小版本用于新增功能,补丁版本用于修复。
|
||||
- 发布前:更新CHANGELOG,运行全量测试,检查依赖安全漏洞。
|
||||
- 发布后:同步文档与环境配置,监控线上指标。
|
||||
- **使用push_script.sh自动化版本标记与发布流程**。
|
||||
|
||||
### 开发工具使用方法
|
||||
|
||||
- **IDE配置(建议)**
|
||||
- VS Code:安装Python与TypeScript扩展,启用ESLint与Prettier;配置Python解释器为虚拟环境。
|
||||
- 前端:启用TypeScript智能提示与ESLint实时检查;Tailwind IntelliSense增强CSS类提示。
|
||||
|
||||
- **调试技巧**
|
||||
- 后端:使用Uvicorn的reload选项热重载;在FastAPI中设置调试日志级别;利用依赖注入覆盖与mock替换真实外部服务。
|
||||
- 前端:使用Next.js dev模式热更新;在浏览器开发者工具中检查网络与状态;Tailwind调试辅助类辅助布局。
|
||||
|
||||
- **性能分析工具(建议)**
|
||||
- 后端:使用cProfile或py-spy分析CPU与内存;结合APScheduler监控任务耗时。
|
||||
- 前端:使用Chrome DevTools Performance面板分析渲染与网络;使用Lighthouse评估SEO与可访问性。
|
||||
|
||||
- **部署工具使用方法**
|
||||
- **push_script.sh使用**:
|
||||
- 确保脚本具有执行权限:chmod +x push_script.sh
|
||||
- 基本使用:./push_script.sh
|
||||
- 指定版本类型:./push_script.sh patch/minor/major
|
||||
- 指定环境:./push_script.sh -e development/production
|
||||
- 查看帮助:./push_script.sh -h
|
||||
- **Docker部署**:
|
||||
- 使用docker-compose up -d启动服务
|
||||
- 使用docker-compose down停止服务
|
||||
- 使用docker-compose logs查看日志
|
||||
|
||||
### 新功能开发指导原则
|
||||
|
||||
- **模块设计**
|
||||
- 遵循"API-Service-Model"三层架构,保持关注点分离。
|
||||
- 将业务逻辑封装在Service层,避免在API层直接操作数据库。
|
||||
|
||||
- **接口定义**
|
||||
- 使用Pydantic模型定义请求与响应结构,明确字段类型与约束。
|
||||
- 对外暴露RESTful接口,遵循统一的前缀与标签组织路由。
|
||||
|
||||
- **测试要求**
|
||||
- 单元测试:覆盖关键业务逻辑与边界条件。
|
||||
- 集成测试:使用pytest与AsyncClient发起HTTP请求,验证端到端流程。
|
||||
- Mock策略:对调度器、外部服务与数据库进行合理Mock,保证测试稳定性。
|
||||
|
||||
- **部署要求**
|
||||
- 新功能开发完成后,使用push_script.sh进行部署测试。
|
||||
- 确保所有环境变量正确配置,包括数据库连接、Redis配置等。
|
||||
- 部署前进行完整的功能测试和性能测试。
|
||||
|
||||
### 常见问题与解决方案
|
||||
|
||||
- **数据库连接失败**
|
||||
- 检查PostgreSQL容器健康状态与端口映射;确认DATABASE_URL与凭据。
|
||||
|
||||
- **Redis连接失败**
|
||||
- 检查Redis容器健康状态与端口映射;确认REDIS_URL。
|
||||
|
||||
- **Playwright无法启动浏览器**
|
||||
- 确认Dockerfile中已安装Playwright浏览器与系统依赖;检查PLAYWRIGHT_BROWSERS_PATH。
|
||||
|
||||
- **CORS跨域问题**
|
||||
- 核对CORS中间件配置的allow_origins与headers;确保前端域名与端口匹配。
|
||||
|
||||
- **JWT认证失败**
|
||||
- 检查JWT_SECRET与过期时间;确认请求头Authorization格式为Bearer Token。
|
||||
|
||||
- **部署脚本执行失败**
|
||||
- 检查脚本权限:chmod +x push_script.sh
|
||||
- 确认Git配置:git config --global user.name 和 git config --global user.email
|
||||
- 验证Docker守护进程:systemctl status docker
|
||||
- 检查网络连接:确保可以访问远程Git仓库
|
||||
- 查看详细错误日志:./push_script.sh -v
|
||||
|
||||
- **Docker构建失败**
|
||||
- 清理Docker缓存:docker system prune
|
||||
- 检查Dockerfile语法:docker build --no-cache -t geo-app .
|
||||
- 确认网络连接:代理设置或防火墙配置
|
||||
- 检查磁盘空间:清理不必要的镜像和容器
|
||||
#### 基础设施技术栈
|
||||
- **Docker**:容器化部署
|
||||
- **Docker Compose**:服务编排
|
||||
- **PostgreSQL**:关系型数据库
|
||||
- **Redis集群**:生产环境缓存
|
||||
- **Nginx**:反向代理与负载均衡
|
||||
- **日志收集**:集中化日志管理
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/main.py:30-36](file://backend/app/main.py#L30-L36)
|
||||
- [backend/app/config.py:9-13](file://backend/app/config.py#L9-L13)
|
||||
- [backend/Dockerfile:31-33](file://backend/Dockerfile#L31-L33)
|
||||
- [docker-compose.yml:4-20](file://docker-compose.yml#L4-L20)
|
||||
- [docker-compose.yml:22-34](file://docker-compose.yml#L22-L34)
|
||||
- [README.md:1-3](file://README.md#L1-L3)
|
||||
- [docs/00-project/tech-stack.md:1-71](file://docs/00-project/tech-stack.md#L1-L71)
|
||||
|
||||
### 模块指南
|
||||
**新增** 为不同模块提供专门的开发指导:
|
||||
|
||||
#### Agent框架开发
|
||||
- **Agent设计原则**:单一职责、可扩展性、可测试性
|
||||
- **提示工程**:结构化提示模板设计
|
||||
- **模型集成**:统一的LLM调用接口
|
||||
- **错误处理**:健壮的异常处理机制
|
||||
|
||||
#### API开发规范
|
||||
- **路由设计**:RESTful API设计原则
|
||||
- **错误处理**:统一的错误响应格式
|
||||
- **文档生成**:OpenAPI规范自动生成
|
||||
- **版本控制**:API版本管理策略
|
||||
|
||||
#### 数据模型设计
|
||||
- **ORM映射**:SQLAlchemy模型定义
|
||||
- **关系设计**:实体关系建模
|
||||
- **索引优化**:数据库性能优化
|
||||
- **数据迁移**:版本化的数据库变更
|
||||
|
||||
**章节来源**
|
||||
- [docs/03-development/module-guides/](file://docs/03-development/module-guides/)
|
||||
|
||||
### 开发环境搭建
|
||||
**新增** 详细的环境搭建步骤:
|
||||
|
||||
#### 系统要求
|
||||
- **操作系统**:Windows 10+/macOS 10.15+/Linux Ubuntu 18.04+
|
||||
- **内存**:至少8GB RAM(推荐16GB+)
|
||||
- **存储**:至少20GB可用空间
|
||||
- **网络**:稳定的互联网连接
|
||||
|
||||
#### 开发工具
|
||||
- **Python**:3.12+(推荐使用pyenv管理版本)
|
||||
- **Node.js**:18.x LTS版本
|
||||
- **Docker**:20.10+(包含Docker Compose)
|
||||
- **Git**:2.0+
|
||||
- **IDE**:VS Code + 推荐插件
|
||||
|
||||
#### 环境变量配置
|
||||
- **数据库连接**:DATABASE_URL
|
||||
- **Redis配置**:REDIS_URL
|
||||
- **JWT配置**:JWT_SECRET、JWT_EXPIRE_HOURS
|
||||
- **LLM配置**:各种AI平台API密钥
|
||||
|
||||
#### 本地开发工作流
|
||||
1. 克隆仓库并安装依赖
|
||||
2. 配置环境变量文件
|
||||
3. 启动Docker容器
|
||||
4. 初始化数据库
|
||||
5. 开始开发和测试
|
||||
|
||||
**章节来源**
|
||||
- [docs/03-development/dev-guide.md:1-32](file://docs/03-development/dev-guide.md#L1-L32)
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
- [backend/app/api/queries.py](file://backend/app/api/queries.py)
|
||||
- [backend/app/api/reports.py](file://backend/app/api/reports.py)
|
||||
- [backend/app/api/citations.py](file://backend/app/api/citations.py)
|
||||
- [backend/app/api/agents.py](file://backend/app/api/agents.py)
|
||||
- [backend/app/models/query.py](file://backend/app/models/query.py)
|
||||
- [backend/app/schemas/query.py](file://backend/app/schemas/query.py)
|
||||
- [backend/app/workers/scheduler.py](file://backend/app/workers/scheduler.py)
|
||||
|
|
@ -28,13 +29,29 @@
|
|||
- [frontend/app/layout.tsx](file://frontend/app/layout.tsx)
|
||||
- [frontend/components/providers.tsx](file://frontend/components/providers.tsx)
|
||||
- [frontend/lib/api.ts](file://frontend/lib/api.ts)
|
||||
- [frontend/lib/api/agents.ts](file://frontend/lib/api/agents.ts)
|
||||
- [frontend/package.json](file://frontend/package.json)
|
||||
- [docker-compose.yml](file://docker-compose.yml)
|
||||
- [backend/Dockerfile](file://backend/Dockerfile)
|
||||
- [frontend/Dockerfile](file://frontend/Dockerfile)
|
||||
- [backend/alembic/versions/488d0bd5ab01_initial_migration.py](file://backend/alembic/versions/488d0bd5ab01_initial_migration.py)
|
||||
- [backend/app/agent_framework/registry.py](file://backend/app/agent_framework/registry.py)
|
||||
- [backend/app/agent_framework/config_manager.py](file://backend/app/agent_framework/config_manager.py)
|
||||
- [backend/app/agent_framework/dispatcher.py](file://backend/app/agent_framework/dispatcher.py)
|
||||
- [backend/app/agent_framework/protocol.py](file://backend/app/agent_framework/protocol.py)
|
||||
- [backend/app/agent_framework/pipeline/engine.py](file://backend/app/agent_framework/pipeline/engine.py)
|
||||
- [backend/app/models/agent.py](file://backend/app/models/agent.py)
|
||||
- [.env.example](file://.env.example)
|
||||
</cite>
|
||||
|
||||
## 更新摘要
|
||||
**所做更改**
|
||||
- 新增了智能体框架扩展指南,包括 Agent 注册中心、配置管理和任务调度
|
||||
- 扩展了前端 API 封装,新增 agents 模块的完整实现
|
||||
- 更新了配置定制方法,增加了 LLM 提供商配置和 AI 平台集成
|
||||
- 完善了第三方集成方案,涵盖智能体系统、Redis 集成和管道编排
|
||||
- 增加了系统定制化案例研究和实施建议
|
||||
|
||||
## 目录
|
||||
1. [简介](#简介)
|
||||
2. [项目结构](#项目结构)
|
||||
|
|
@ -48,18 +65,22 @@
|
|||
10. [附录](#附录)
|
||||
|
||||
## 简介
|
||||
本文件面向需要对 GEO 平台进行扩展与定制的工程师与产品团队,系统性阐述后端 API 扩展、前端页面扩展、数据模型扩展、配置定制、第三方集成(AI 平台、数据库、认证)以及插件化扩展的最佳实践。文档同时提供可落地的实施建议与案例研究,帮助快速实现业务定制化目标。
|
||||
本文件面向需要对 GEO 平台进行扩展与定制的工程师与产品团队,系统性阐述后端 API 扩展、前端页面扩展、数据模型扩展、配置定制、第三方集成(AI 平台、数据库、认证)以及智能体框架扩展的最佳实践。文档同时提供可落地的实施建议与案例研究,帮助快速实现业务定制化目标。
|
||||
|
||||
**更新** 本次更新重点加强了智能体框架的扩展能力,新增了完整的 Agent 管理、配置热更新、任务调度和管道编排功能。
|
||||
|
||||
## 项目结构
|
||||
GEO 采用前后端分离架构:
|
||||
- 后端基于 FastAPI,提供 REST API;通过 Alembic 管理数据库迁移;使用 SQLAlchemy ORM 定义模型;APScheduler 实现定时任务;Playwright 支持 AI 平台网页抓取。
|
||||
- 前端基于 Next.js 14,使用 TypeScript、TailwindCSS、Radix UI 组件库;通过自定义 API 封装层与后端交互;NextAuth v4 提供会话管理。
|
||||
- **新增** 智能体框架支持,包括 Agent 注册中心、配置管理、任务调度和管道编排。
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "前端"
|
||||
FE_APP["Next.js 应用<br/>路由与页面"]
|
||||
FE_LIB["API 封装<br/>lib/api.ts"]
|
||||
FE_LIB_AGENTS["智能体API<br/>lib/api/agents.ts"]
|
||||
FE_PROV["会话提供者<br/>components/providers.tsx"]
|
||||
end
|
||||
subgraph "后端"
|
||||
|
|
@ -68,51 +89,69 @@ BE_ROUTER_AUTH["认证路由<br/>app/api/auth.py"]
|
|||
BE_ROUTER_QUERIES["查询路由<br/>app/api/queries.py"]
|
||||
BE_ROUTER_CITATIONS["引用路由<br/>app/api/citations.py"]
|
||||
BE_ROUTER_REPORTS["报告路由<br/>app/api/reports.py"]
|
||||
BE_ROUTER_AGENTS["智能体路由<br/>app/api/agents.py"]
|
||||
BE_SCHED["调度器<br/>app/workers/scheduler.py"]
|
||||
BE_PLAT_BASE["平台适配器基类<br/>app/workers/platforms/base.py"]
|
||||
BE_PLAT_KIMI["Kimi 适配器<br/>app/workers/platforms/kimi.py"]
|
||||
BE_PLAT_WENXIN["文心一言适配器<br/>app/workers/platforms/wenxin.py"]
|
||||
BE_DB["数据库与模型<br/>app/database.py + models/*"]
|
||||
BE_AGENT_REGISTRY["Agent注册中心<br/>agent_framework/registry.py"]
|
||||
BE_AGENT_CONFIG["配置管理<br/>agent_framework/config_manager.py"]
|
||||
BE_AGENT_DISPATCH["任务调度<br/>agent_framework/dispatcher.py"]
|
||||
BE_AGENT_PIPELINE["管道引擎<br/>agent_framework/pipeline/engine.py"]
|
||||
end
|
||||
FE_APP --> FE_LIB
|
||||
FE_LIB --> BE_MAIN
|
||||
FE_LIB_AGENTS --> BE_ROUTER_AGENTS
|
||||
BE_MAIN --> BE_ROUTER_AUTH
|
||||
BE_MAIN --> BE_ROUTER_QUERIES
|
||||
BE_MAIN --> BE_ROUTER_CITATIONS
|
||||
BE_MAIN --> BE_ROUTER_REPORTS
|
||||
BE_MAIN --> BE_ROUTER_AGENTS
|
||||
BE_MAIN --> BE_SCHED
|
||||
BE_SCHED --> BE_PLAT_BASE
|
||||
BE_PLAT_BASE --> BE_PLAT_KIMI
|
||||
BE_PLAT_BASE --> BE_PLAT_WENXIN
|
||||
BE_MAIN --> BE_DB
|
||||
BE_ROUTER_AGENTS --> BE_AGENT_REGISTRY
|
||||
BE_ROUTER_AGENTS --> BE_AGENT_CONFIG
|
||||
BE_ROUTER_AGENTS --> BE_AGENT_DISPATCH
|
||||
BE_AGENT_DISPATCH --> BE_AGENT_PIPELINE
|
||||
```
|
||||
|
||||
图表来源
|
||||
**图表来源**
|
||||
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
|
||||
- [frontend/lib/api.ts:1-58](file://frontend/lib/api.ts#L1-L58)
|
||||
- [frontend/lib/api/agents.ts:1-57](file://frontend/lib/api/agents.ts#L1-L57)
|
||||
- [backend/app/workers/scheduler.py:1-95](file://backend/app/workers/scheduler.py#L1-L95)
|
||||
- [backend/app/workers/platforms/base.py:1-18](file://backend/app/workers/platforms/base.py#L1-L18)
|
||||
- [backend/app/workers/platforms/kimi.py:1-206](file://backend/app/workers/platforms/kimi.py#L1-L206)
|
||||
- [backend/app/workers/platforms/wenxin.py:1-205](file://backend/app/workers/platforms/wenxin.py#L1-L205)
|
||||
- [backend/app/database.py](file://backend/app/database.py)
|
||||
- [backend/app/agent_framework/registry.py:1-219](file://backend/app/agent_framework/registry.py#L1-L219)
|
||||
- [backend/app/agent_framework/config_manager.py:1-191](file://backend/app/agent_framework/config_manager.py#L1-L191)
|
||||
- [backend/app/agent_framework/dispatcher.py:1-367](file://backend/app/agent_framework/dispatcher.py#L1-L367)
|
||||
- [backend/app/agent_framework/pipeline/engine.py:1-376](file://backend/app/agent_framework/pipeline/engine.py#L1-L376)
|
||||
|
||||
章节来源
|
||||
**章节来源**
|
||||
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
|
||||
- [frontend/app/layout.tsx:1-37](file://frontend/app/layout.tsx#L1-L37)
|
||||
- [frontend/components/providers.tsx:1-9](file://frontend/components/providers.tsx#L1-L9)
|
||||
|
||||
## 核心组件
|
||||
- API 层:认证、查询词、引用数据、报告导出等模块化路由,统一挂载于主应用。
|
||||
- 服务层:封装业务逻辑,如用户认证、查询 CRUD、引用处理等。
|
||||
- 数据层:SQLAlchemy 模型与 Pydantic Schema,定义实体与请求/响应结构。
|
||||
- 工作器与调度:APScheduler 驱动定时任务,CitationEngine 协调平台适配器执行查询。
|
||||
- 前端:Next.js 页面与组件,通过 lib/api.ts 统一访问后端接口;NextAuth 提供会话状态。
|
||||
- API 层:认证、查询词、引用数据、报告导出、**新增**智能体管理等模块化路由,统一挂载于主应用。
|
||||
- 服务层:封装业务逻辑,如用户认证、查询 CRUD、引用处理、**新增**智能体配置管理等。
|
||||
- 数据层:SQLAlchemy 模型与 Pydantic Schema,定义实体与请求/响应结构,**新增**智能体相关模型。
|
||||
- 工作器与调度:APScheduler 驱动定时任务,CitationEngine 协调平台适配器执行查询,**新增**智能体任务调度。
|
||||
- 前端:Next.js 页面与组件,通过 lib/api.ts 统一访问后端接口,**新增**智能体 API 封装。
|
||||
- **新增** 智能体框架:Agent 注册中心、配置管理、任务调度和管道编排系统。
|
||||
|
||||
章节来源
|
||||
**章节来源**
|
||||
- [backend/app/api/auth.py:1-43](file://backend/app/api/auth.py#L1-L43)
|
||||
- [backend/app/api/queries.py:1-86](file://backend/app/api/queries.py#L1-L86)
|
||||
- [backend/app/api/citations.py](file://backend/app/api/citations.py)
|
||||
- [backend/app/api/reports.py](file://backend/app/api/reports.py)
|
||||
- [backend/app/api/agents.py:1-299](file://backend/app/api/agents.py#L1-L299)
|
||||
- [backend/app/services/auth.py](file://backend/app/services/auth.py)
|
||||
- [backend/app/services/query.py](file://backend/app/services/query.py)
|
||||
- [backend/app/services/citation.py](file://backend/app/services/citation.py)
|
||||
|
|
@ -120,9 +159,10 @@ BE_MAIN --> BE_DB
|
|||
- [backend/app/schemas/query.py:1-94](file://backend/app/schemas/query.py#L1-L94)
|
||||
- [backend/app/workers/scheduler.py:1-95](file://backend/app/workers/scheduler.py#L1-L95)
|
||||
- [frontend/lib/api.ts:1-58](file://frontend/lib/api.ts#L1-L58)
|
||||
- [frontend/lib/api/agents.ts:1-57](file://frontend/lib/api/agents.ts#L1-L57)
|
||||
|
||||
## 架构总览
|
||||
下图展示从浏览器到后端 API、数据库与外部 AI 平台的完整链路:
|
||||
下图展示从浏览器到后端 API、数据库与外部 AI 平台的完整链路,包括新增的智能体框架:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
|
|
@ -134,9 +174,12 @@ participant DB as "数据库"
|
|||
participant Scheduler as "调度器"
|
||||
participant Engine as "CitationEngine"
|
||||
participant Plat as "平台适配器"
|
||||
participant AgentFramework as "智能体框架"
|
||||
Browser->>Frontend : 用户操作
|
||||
Frontend->>API : 发起 HTTP 请求
|
||||
API->>Svc : 路由分发与校验
|
||||
svc->>AgentFramework : 智能体管理请求
|
||||
AgentFramework->>DB : 读写智能体配置
|
||||
Svc->>DB : 读写数据
|
||||
DB-->>Svc : 返回结果
|
||||
Svc-->>API : 业务结果
|
||||
|
|
@ -148,13 +191,17 @@ Plat-->>Engine : 返回原始响应
|
|||
Engine->>DB : 写入引用记录
|
||||
```
|
||||
|
||||
图表来源
|
||||
**图表来源**
|
||||
- [frontend/lib/api.ts:1-58](file://frontend/lib/api.ts#L1-L58)
|
||||
- [frontend/lib/api/agents.ts:1-57](file://frontend/lib/api/agents.ts#L1-L57)
|
||||
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
|
||||
- [backend/app/workers/scheduler.py:1-95](file://backend/app/workers/scheduler.py#L1-L95)
|
||||
- [backend/app/workers/platforms/base.py:1-18](file://backend/app/workers/platforms/base.py#L1-L18)
|
||||
- [backend/app/workers/platforms/kimi.py:1-206](file://backend/app/workers/platforms/kimi.py#L1-L206)
|
||||
- [backend/app/workers/platforms/wenxin.py:1-205](file://backend/app/workers/platforms/wenxin.py#L1-L205)
|
||||
- [backend/app/agent_framework/registry.py:1-219](file://backend/app/agent_framework/registry.py#L1-L219)
|
||||
- [backend/app/agent_framework/config_manager.py:1-191](file://backend/app/agent_framework/config_manager.py#L1-L191)
|
||||
- [backend/app/agent_framework/dispatcher.py:1-367](file://backend/app/agent_framework/dispatcher.py#L1-L367)
|
||||
|
||||
## 详细组件分析
|
||||
|
||||
|
|
@ -169,6 +216,7 @@ Engine->>DB : 写入引用记录
|
|||
- 示例参考路径
|
||||
- [认证路由示例:1-43](file://backend/app/api/auth.py#L1-L43)
|
||||
- [查询路由示例:1-86](file://backend/app/api/queries.py#L1-L86)
|
||||
- [智能体路由示例:1-299](file://backend/app/api/agents.py#L1-L299)
|
||||
- [主应用挂载示例:38-42](file://backend/app/main.py#L38-L42)
|
||||
- [服务层示例](file://backend/app/services/query.py)
|
||||
- [Schema 示例:1-94](file://backend/app/schemas/query.py#L1-L94)
|
||||
|
|
@ -186,10 +234,11 @@ Alembic --> Test["编写单元测试"]
|
|||
Test --> End(["完成"])
|
||||
```
|
||||
|
||||
章节来源
|
||||
**章节来源**
|
||||
- [backend/app/main.py:38-42](file://backend/app/main.py#L38-L42)
|
||||
- [backend/app/api/auth.py:1-43](file://backend/app/api/auth.py#L1-L43)
|
||||
- [backend/app/api/queries.py:1-86](file://backend/app/api/queries.py#L1-L86)
|
||||
- [backend/app/api/agents.py:1-299](file://backend/app/api/agents.py#L1-L299)
|
||||
- [backend/app/schemas/query.py:1-94](file://backend/app/schemas/query.py#L1-L94)
|
||||
- [backend/app/models/query.py:1-55](file://backend/app/models/query.py#L1-L55)
|
||||
|
||||
|
|
@ -200,6 +249,7 @@ Test --> End(["完成"])
|
|||
- 在 components/ui 下新增或复用 UI 组件,保持一致的设计语言。
|
||||
- API 调用
|
||||
- 在 frontend/lib/api.ts 中新增方法,遵循现有命名与错误处理模式。
|
||||
- **新增** 在 frontend/lib/api/agents.ts 中新增智能体相关 API 方法。
|
||||
- 在页面中通过 hooks 或直接调用 api.* 方法获取数据。
|
||||
- 会话与权限
|
||||
- 使用 frontend/components/providers.tsx 包裹应用,确保 NextAuth 会话可用。
|
||||
|
|
@ -210,13 +260,15 @@ flowchart TD
|
|||
NewPage["新增页面<br/>frontend/app/(group)/new/page.tsx"] --> Layout["布局与 Providers<br/>frontend/app/layout.tsx"]
|
||||
Layout --> UI["UI 组件<br/>frontend/components/ui/*"]
|
||||
UI --> API["API 封装<br/>frontend/lib/api.ts"]
|
||||
API --> Backend["后端 API<br/>backend/app/api/*"]
|
||||
API --> AgentsAPI["智能体API<br/>frontend/lib/api/agents.ts"]
|
||||
AgentsAPI --> Backend["后端 API<br/>backend/app/api/*"]
|
||||
```
|
||||
|
||||
章节来源
|
||||
**章节来源**
|
||||
- [frontend/app/layout.tsx:1-37](file://frontend/app/layout.tsx#L1-L37)
|
||||
- [frontend/components/providers.tsx:1-9](file://frontend/components/providers.tsx#L1-L9)
|
||||
- [frontend/lib/api.ts:1-58](file://frontend/lib/api.ts#L1-L58)
|
||||
- [frontend/lib/api/agents.ts:1-57](file://frontend/lib/api/agents.ts#L1-L57)
|
||||
|
||||
### 数据模型扩展指南
|
||||
- 字段与约束
|
||||
|
|
@ -262,16 +314,37 @@ string platform
|
|||
text raw_response
|
||||
timestamp created_at
|
||||
}
|
||||
AGENT_REGISTRY {
|
||||
uuid id PK
|
||||
string name UK
|
||||
string agent_type
|
||||
string status
|
||||
jsonb capabilities
|
||||
timestamp last_heartbeat
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
AGENT_CONFIGS {
|
||||
uuid id PK
|
||||
uuid agent_id FK
|
||||
string config_key
|
||||
jsonb config_value
|
||||
uuid updated_by FK
|
||||
timestamp updated_at
|
||||
}
|
||||
QUERIES ||--o{ CITATION_RECORDS : "包含"
|
||||
USERS ||--o{ QUERIES : "拥有"
|
||||
AGENT_REGISTRY ||--o{ AGENT_CONFIGS : "包含"
|
||||
```
|
||||
|
||||
图表来源
|
||||
**图表来源**
|
||||
- [backend/app/models/query.py:1-55](file://backend/app/models/query.py#L1-L55)
|
||||
- [backend/app/models/agent.py:1-206](file://backend/app/models/agent.py#L1-L206)
|
||||
- [backend/alembic/versions/488d0bd5ab01_initial_migration.py](file://backend/alembic/versions/488d0bd5ab01_initial_migration.py)
|
||||
|
||||
章节来源
|
||||
**章节来源**
|
||||
- [backend/app/models/query.py:1-55](file://backend/app/models/query.py#L1-L55)
|
||||
- [backend/app/models/agent.py:1-206](file://backend/app/models/agent.py#L1-L206)
|
||||
- [backend/app/schemas/query.py:1-94](file://backend/app/schemas/query.py#L1-L94)
|
||||
- [backend/alembic/versions/488d0bd5ab01_initial_migration.py](file://backend/alembic/versions/488d0bd5ab01_initial_migration.py)
|
||||
|
||||
|
|
@ -279,19 +352,22 @@ USERS ||--o{ QUERIES : "拥有"
|
|||
- 环境变量
|
||||
- 通过 app/config.py 的 Settings 类集中管理,支持 .env 文件覆盖。
|
||||
- 关键配置项包括数据库连接、Redis、JWT 密钥与过期时间、Playwright 浏览器路径、第三方平台密钥等。
|
||||
- **新增** LLM 提供商配置,包括默认提供商、模型选择和 API 密钥管理。
|
||||
- 功能开关与性能参数
|
||||
- 平台列表、频率策略、状态枚举在 Schema 中集中校验,便于扩展与限制。
|
||||
- 调度周期(每小时)可在 app/workers/scheduler.py 中调整。
|
||||
- 前端 NEXT_PUBLIC_API_URL 控制后端域名,lib/api.ts 中统一拼接。
|
||||
- **新增** 智能体框架配置,包括 Redis URL 和心跳超时设置。
|
||||
- 建议
|
||||
- 生产环境务必替换默认密钥与数据库密码。
|
||||
- 将敏感信息放入 .env 并加入 .gitignore。
|
||||
|
||||
章节来源
|
||||
- [backend/app/config.py:1-17](file://backend/app/config.py#L1-L17)
|
||||
**章节来源**
|
||||
- [backend/app/config.py:1-46](file://backend/app/config.py#L1-L46)
|
||||
- [backend/app/schemas/query.py:6-8](file://backend/app/schemas/query.py#L6-L8)
|
||||
- [backend/app/workers/scheduler.py:32-38](file://backend/app/workers/scheduler.py#L32-L38)
|
||||
- [frontend/lib/api.ts:1](file://frontend/lib/api.ts#L1)
|
||||
- [.env.example:1-35](file://.env.example#L1-L35)
|
||||
|
||||
### 第三方集成扩展指南
|
||||
|
||||
|
|
@ -325,12 +401,12 @@ BasePlatformAdapter <|-- KimiAdapter
|
|||
BasePlatformAdapter <|-- WenxinAdapter
|
||||
```
|
||||
|
||||
图表来源
|
||||
**图表来源**
|
||||
- [backend/app/workers/platforms/base.py:1-18](file://backend/app/workers/platforms/base.py#L1-L18)
|
||||
- [backend/app/workers/platforms/kimi.py:1-206](file://backend/app/workers/platforms/kimi.py#L1-L206)
|
||||
- [backend/app/workers/platforms/wenxin.py:1-205](file://backend/app/workers/platforms/wenxin.py#L1-L205)
|
||||
|
||||
章节来源
|
||||
**章节来源**
|
||||
- [backend/app/workers/platforms/base.py:1-18](file://backend/app/workers/platforms/base.py#L1-L18)
|
||||
- [backend/app/workers/platforms/kimi.py:1-206](file://backend/app/workers/platforms/kimi.py#L1-L206)
|
||||
- [backend/app/workers/platforms/wenxin.py:1-205](file://backend/app/workers/platforms/wenxin.py#L1-L205)
|
||||
|
|
@ -343,7 +419,7 @@ BasePlatformAdapter <|-- WenxinAdapter
|
|||
- 在 requirements.txt 中替换驱动包。
|
||||
- 重新生成/更新 Alembic 迁移以适配新方言。
|
||||
|
||||
章节来源
|
||||
**章节来源**
|
||||
- [backend/app/database.py](file://backend/app/database.py)
|
||||
- [backend/app/config.py:7](file://backend/app/config.py#L7)
|
||||
- [backend/requirements.txt:5-8](file://backend/requirements.txt#L5-L8)
|
||||
|
|
@ -357,26 +433,91 @@ BasePlatformAdapter <|-- WenxinAdapter
|
|||
- 保持 Authorization 头格式与后端解析一致。
|
||||
- 在 app/api/deps.py 中的依赖注入中校验用户身份。
|
||||
|
||||
章节来源
|
||||
**章节来源**
|
||||
- [backend/app/services/auth.py](file://backend/app/services/auth.py)
|
||||
- [frontend/components/providers.tsx:1-9](file://frontend/components/providers.tsx#L1-L9)
|
||||
- [frontend/lib/api.ts:3-21](file://frontend/lib/api.ts#L3-L21)
|
||||
- [backend/app/api/deps.py](file://backend/app/api/deps.py)
|
||||
|
||||
#### 智能体框架集成
|
||||
- Agent 注册中心
|
||||
- 通过 AgentRegistry 管理 Agent 的注册、发现与状态。
|
||||
- 支持心跳检测与自动离线标记。
|
||||
- 配置管理
|
||||
- AgentConfigManager 支持配置的热更新与批量修改。
|
||||
- 提供配置历史查询功能。
|
||||
- 任务调度
|
||||
- TaskDispatcher 通过 Redis Queue 实现任务分发。
|
||||
- 支持任务取消、状态查询和日志记录。
|
||||
- 管道编排
|
||||
- PipelineEngine 支持 YAML 定义的多阶段任务编排。
|
||||
- 实现拓扑排序、变量传递和条件执行。
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class AgentRegistry {
|
||||
+register(capability, endpoint) str
|
||||
+unregister(agent_name)
|
||||
+update_heartbeat(agent_name)
|
||||
+get_agent(agent_name) dict
|
||||
+list_agents(agent_type, status) list
|
||||
+get_available_agent(task_type) str
|
||||
+check_health()
|
||||
}
|
||||
class AgentConfigManager {
|
||||
+get_config(agent_name) dict
|
||||
+set_config(agent_name, key, value, updated_by)
|
||||
+bulk_update_config(agent_name, configs, updated_by)
|
||||
+get_config_history(agent_name, key) list
|
||||
}
|
||||
class TaskDispatcher {
|
||||
+dispatch(task, organization_id, created_by) str
|
||||
+cancel_task(task_id)
|
||||
+get_task_status(task_id) dict
|
||||
+handle_result(result)
|
||||
+handle_progress(progress)
|
||||
+retry_failed_tasks(max_retries)
|
||||
}
|
||||
class PipelineEngine {
|
||||
+execute(pipeline, context) PipelineResult
|
||||
+_topological_sort(stages) list
|
||||
+dry_run_stage(stage, resolved_inputs) StageResult
|
||||
}
|
||||
AgentRegistry <.. TaskDispatcher : "任务分配"
|
||||
AgentConfigManager <.. TaskDispatcher : "配置管理"
|
||||
TaskDispatcher <.. PipelineEngine : "任务执行"
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [backend/app/agent_framework/registry.py:1-219](file://backend/app/agent_framework/registry.py#L1-L219)
|
||||
- [backend/app/agent_framework/config_manager.py:1-191](file://backend/app/agent_framework/config_manager.py#L1-L191)
|
||||
- [backend/app/agent_framework/dispatcher.py:1-367](file://backend/app/agent_framework/dispatcher.py#L1-L367)
|
||||
- [backend/app/agent_framework/pipeline/engine.py:1-376](file://backend/app/agent_framework/pipeline/engine.py#L1-L376)
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/agent_framework/registry.py:1-219](file://backend/app/agent_framework/registry.py#L1-L219)
|
||||
- [backend/app/agent_framework/config_manager.py:1-191](file://backend/app/agent_framework/config_manager.py#L1-L191)
|
||||
- [backend/app/agent_framework/dispatcher.py:1-367](file://backend/app/agent_framework/dispatcher.py#L1-L367)
|
||||
- [backend/app/agent_framework/pipeline/engine.py:1-376](file://backend/app/agent_framework/pipeline/engine.py#L1-L376)
|
||||
|
||||
### 插件系统使用指南与最佳实践
|
||||
- 插件化思路
|
||||
- 平台适配器采用“插件”式扩展:通过继承基类与工厂/映射注册,实现多平台并行。
|
||||
- 调度器与 CitationEngine 作为“核心引擎”,通过适配器接口解耦平台差异。
|
||||
- 平台适配器采用"插件"式扩展:通过继承基类与工厂/映射注册,实现多平台并行。
|
||||
- 调度器与 CitationEngine 作为"核心引擎",通过适配器接口解耦平台差异。
|
||||
- **新增** 智能体框架采用注册中心模式,支持动态 Agent 注册与发现。
|
||||
- 最佳实践
|
||||
- 明确职责边界:路由负责协议与鉴权,服务层负责业务规则,模型负责数据结构。
|
||||
- 统一错误处理:前端统一捕获 HTTP 错误并提示;后端抛出明确异常码与消息。
|
||||
- 可观测性:为关键流程增加日志与指标,便于定位问题。
|
||||
- 安全:严格校验输入、最小权限原则、HTTPS 传输、密钥轮换。
|
||||
- **新增** 智能体安全:验证 Agent 能力声明,限制并发执行,监控心跳状态。
|
||||
|
||||
章节来源
|
||||
**章节来源**
|
||||
- [backend/app/workers/platforms/base.py:1-18](file://backend/app/workers/platforms/base.py#L1-L18)
|
||||
- [backend/app/workers/scheduler.py:1-95](file://backend/app/workers/scheduler.py#L1-L95)
|
||||
- [frontend/lib/api.ts:16-21](file://frontend/lib/api.ts#L16-L21)
|
||||
- [backend/app/agent_framework/registry.py:1-219](file://backend/app/agent_framework/registry.py#L1-L219)
|
||||
- [backend/app/agent_framework/dispatcher.py:1-367](file://backend/app/agent_framework/dispatcher.py#L1-L367)
|
||||
|
||||
## 依赖分析
|
||||
- 后端依赖
|
||||
|
|
@ -387,6 +528,7 @@ BasePlatformAdapter <|-- WenxinAdapter
|
|||
- 任务调度:APScheduler
|
||||
- 浏览器自动化:Playwright
|
||||
- HTTP 客户端:httpx
|
||||
- **新增** 智能体框架:Redis async(aioredis)、SQLAlchemy JSONB
|
||||
- 前端依赖
|
||||
- 框架与 UI:Next.js + Radix UI + TailwindCSS
|
||||
- 认证:NextAuth v4
|
||||
|
|
@ -403,6 +545,7 @@ J["python-jose/passlib"]
|
|||
R["Redis/APScheduler"]
|
||||
PW["Playwright"]
|
||||
H["httpx/python-dotenv"]
|
||||
AIO["aioredis/sqlalchemy-jsonb"]
|
||||
end
|
||||
subgraph "前端依赖"
|
||||
N["Next.js"]
|
||||
|
|
@ -412,26 +555,33 @@ RC["Recharts"]
|
|||
end
|
||||
```
|
||||
|
||||
图表来源
|
||||
**图表来源**
|
||||
- [backend/requirements.txt:1-35](file://backend/requirements.txt#L1-L35)
|
||||
- [frontend/package.json:11-27](file://frontend/package.json#L11-L27)
|
||||
|
||||
章节来源
|
||||
**章节来源**
|
||||
- [backend/requirements.txt:1-35](file://backend/requirements.txt#L1-L35)
|
||||
- [frontend/package.json:11-27](file://frontend/package.json#L11-L27)
|
||||
|
||||
## 性能考虑
|
||||
- 数据库
|
||||
- 为高频查询字段建立索引;避免 N+1 查询;使用分页参数限制单页规模。
|
||||
- **新增** 智能体相关表建立复合索引,优化查询性能。
|
||||
- API
|
||||
- 合理设置分页参数(skip/limit),避免一次性返回大量数据。
|
||||
- 对热点接口启用缓存(如 Redis)减少重复计算。
|
||||
- **新增** 智能体配置缓存,减少频繁查询数据库。
|
||||
- 定时任务
|
||||
- 调度周期可根据业务需求调整;在高负载时降低频率或增加并发控制。
|
||||
- 浏览器自动化
|
||||
- Playwright 启动成本较高,尽量复用上下文;失败重试与超时控制要合理设置。
|
||||
- 前端
|
||||
- 按需加载页面与组件;减少不必要的 re-render;利用浏览器缓存与静态资源优化。
|
||||
- **新增** 智能体状态管理,避免频繁重新获取配置。
|
||||
- **新增** 智能体框架性能
|
||||
- Redis 连接池管理,避免频繁创建连接。
|
||||
- 任务队列长度监控,防止内存泄漏。
|
||||
- 心跳超时阈值调优,平衡实时性与资源消耗。
|
||||
|
||||
## 故障排查指南
|
||||
- 常见问题定位
|
||||
|
|
@ -439,13 +589,16 @@ end
|
|||
- CORS:确认 app/main.py 中允许的源与方法。
|
||||
- 数据库连接:检查 DATABASE_URL 与网络连通性。
|
||||
- Playwright:确保已安装浏览器二进制;查看适配器初始化日志。
|
||||
- **新增** 智能体框架:检查 Redis 连接状态,验证 Agent 注册与心跳。
|
||||
- 日志与监控
|
||||
- 调度器与平台适配器均输出详细日志,定位失败原因。
|
||||
- 前端统一错误处理:lib/api.ts 在请求失败时抛出错误,便于 UI 提示。
|
||||
- **新增** 智能体框架日志:监控任务状态变化和配置更新历史。
|
||||
- 快速恢复
|
||||
- 重启后端服务与前端构建;检查 .env 配置是否正确;核对迁移是否执行。
|
||||
- **新增** 智能体框架恢复:重启 Redis 服务,重新注册 Agent,检查任务队列。
|
||||
|
||||
章节来源
|
||||
**章节来源**
|
||||
- [backend/app/main.py:45-47](file://backend/app/main.py#L45-L47)
|
||||
- [backend/app/main.py:30-36](file://backend/app/main.py#L30-L36)
|
||||
- [backend/app/config.py:7](file://backend/app/config.py#L7)
|
||||
|
|
@ -453,7 +606,7 @@ end
|
|||
- [frontend/lib/api.ts:16-21](file://frontend/lib/api.ts#L16-L21)
|
||||
|
||||
## 结论
|
||||
GEO 平台提供了清晰的分层架构与可扩展点:路由层、服务层、数据层与工作器层相互解耦,配合配置中心与前端统一 API 封装,能够高效支撑业务扩展。通过平台适配器插件化、Schema/模型标准化、调度器与任务队列机制,团队可以快速接入新 AI 平台、扩展前端页面与数据模型,并在生产环境中保持稳定与可观测。
|
||||
GEO 平台提供了清晰的分层架构与可扩展点:路由层、服务层、数据层与工作器层相互解耦,配合配置中心与前端统一 API 封装,能够高效支撑业务扩展。**新增的智能体框架进一步增强了平台的扩展能力**:通过 Agent 注册中心、配置管理、任务调度和管道编排,团队可以快速接入新 AI 平台、扩展前端页面与数据模型,并在生产环境中保持稳定与可观测。通过平台适配器插件化、Schema/模型标准化、调度器与任务队列机制,以及智能体框架的动态扩展能力,团队可以构建更加灵活和强大的 AI 应用平台。
|
||||
|
||||
## 附录
|
||||
|
||||
|
|
@ -467,15 +620,24 @@ GEO 平台提供了清晰的分层架构与可扩展点:路由层、服务层
|
|||
- 案例三:前端新增报表页面
|
||||
- 步骤:新增页面与路由 → 引入图表组件 → 调用后端报表接口 → 权限控制与数据可视化。
|
||||
- 建议:复用现有 UI 组件库,保持设计一致性。
|
||||
- **新增** 案例四:智能体系统集成
|
||||
- 步骤:实现 Agent 能力声明 → 注册到 AgentRegistry → 配置管理 → 任务分发 → 管道编排。
|
||||
- 建议:使用心跳机制监控 Agent 状态,实现自动故障转移。
|
||||
- **新增** 案例五:LLM 提供商扩展
|
||||
- 步骤:在配置中添加新提供商 → 实现工厂模式 → 更新默认配置 → 前端配置界面适配。
|
||||
- 建议:实现统一的 API 调用抽象,支持多提供商切换。
|
||||
|
||||
### 部署与运行要点
|
||||
- 使用 Docker Compose 启动后端与前端服务,确保端口映射与网络互通。
|
||||
- 后端 Dockerfile 与 requirements.txt 已配置,注意镜像构建缓存与依赖锁定。
|
||||
- 前端 Dockerfile 与 Next.js 版本已固定,构建产物由 Next.js 管理。
|
||||
- **新增** 智能体框架部署:确保 Redis 服务可用,配置正确的 REDIS_URL。
|
||||
- **新增** 环境配置:使用 .env.example 作为模板,配置所有必要的环境变量。
|
||||
|
||||
章节来源
|
||||
**章节来源**
|
||||
- [docker-compose.yml](file://docker-compose.yml)
|
||||
- [backend/Dockerfile](file://backend/Dockerfile)
|
||||
- [frontend/Dockerfile](file://frontend/Dockerfile)
|
||||
- [backend/requirements.txt:1-35](file://backend/requirements.txt#L1-L35)
|
||||
- [frontend/package.json:11-27](file://frontend/package.json#L11-L27)
|
||||
- [frontend/package.json:11-27](file://frontend/package.json#L11-L27)
|
||||
- [.env.example:1-35](file://.env.example#L1-L35)
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -7,8 +7,15 @@
|
|||
- [tests/test_business_flow.py](file://tests/test_business_flow.py)
|
||||
- [tests/test_citation_engine.py](file://tests/test_citation_engine.py)
|
||||
- [tests/test_citations.py](file://tests/test_citations.py)
|
||||
- [tests/test_content_agents.py](file://tests/test_content_agents.py)
|
||||
- [tests/test_llm_provider.py](file://tests/test_llm_provider.py)
|
||||
- [tests/test_pipeline_engine.py](file://tests/test_pipeline_engine.py)
|
||||
- [tests/test_platform_rules.py](file://tests/test_platform_rules.py)
|
||||
- [tests/test_prompt_template.py](file://tests/test_prompt_template.py)
|
||||
- [tests/test_queries.py](file://tests/test_queries.py)
|
||||
- [tests/test_rag_service.py](file://tests/test_rag_service.py)
|
||||
- [tests/test_scheduler.py](file://tests/test_scheduler.py)
|
||||
- [backend/tests/test_integration/test_full_flow.py](file://backend/tests/test_integration/test_full_flow.py)
|
||||
- [backend/app/main.py](file://backend/app/main.py)
|
||||
- [backend/app/api/deps.py](file://backend/app/api/deps.py)
|
||||
- [backend/app/services/auth.py](file://backend/app/services/auth.py)
|
||||
|
|
@ -19,14 +26,22 @@
|
|||
- [backend/app/api/queries.py](file://backend/app/api/queries.py)
|
||||
- [backend/app/database.py](file://backend/app/database.py)
|
||||
- [backend/app/config.py](file://backend/app/config.py)
|
||||
- [backend/app/agent_framework/agents/content_generator_agent.py](file://backend/app/agent_framework/agents/content_generator_agent.py)
|
||||
- [backend/app/agent_framework/agents/deai_agent.py](file://backend/app/agent_framework/agents/deai_agent.py)
|
||||
- [backend/app/agent_framework/agents/geo_optimizer_agent.py](file://backend/app/agent_framework/agents/geo_optimizer_agent.py)
|
||||
- [backend/app/agent_framework/pipeline/engine.py](file://backend/app/agent_framework/pipeline/engine.py)
|
||||
- [backend/app/agent_framework/pipeline/loader.py](file://backend/app/agent_framework/pipeline/loader.py)
|
||||
- [backend/app/services/llm/factory.py](file://backend/app/services/llm/factory.py)
|
||||
</cite>
|
||||
|
||||
## 更新摘要
|
||||
**变更内容**
|
||||
- 新增业务流程测试章节,涵盖端到端业务场景测试
|
||||
- 新增调度器测试章节,包括定时任务调度和频率计算测试
|
||||
- 完善测试最佳实践,增加业务流程测试和调度器测试的最佳实践指导
|
||||
- 更新测试策略以反映新增的测试覆盖范围
|
||||
- 新增代理框架测试章节,涵盖ContentGeneratorAgent、DeAIAgent、GEOOptimizerAgent的单元测试策略
|
||||
- 新增LLM提供者测试章节,包括LLMFactory工厂模式测试、OpenAIProvider和DeepSeekProvider测试
|
||||
- 新增管道引擎测试章节,涵盖PipelineLoader和PipelineEngine的单元测试策略
|
||||
- 新增端到端工作流测试章节,涵盖完整业务流程的集成测试
|
||||
- 完善测试最佳实践,增加代理框架测试、LLM提供者测试、管道引擎测试和端到端工作流测试的最佳实践指导
|
||||
- 更新测试策略以反映新增的测试覆盖范围和架构扩展
|
||||
|
||||
## 目录
|
||||
1. [引言](#引言)
|
||||
|
|
@ -34,19 +49,23 @@
|
|||
3. [核心组件](#核心组件)
|
||||
4. [架构总览](#架构总览)
|
||||
5. [详细组件分析](#详细组件分析)
|
||||
6. [业务流程测试策略](#业务流程测试策略)
|
||||
7. [调度器测试策略](#调度器测试策略)
|
||||
8. [依赖分析](#依赖分析)
|
||||
9. [性能考虑](#性能考虑)
|
||||
10. [故障排查指南](#故障排查指南)
|
||||
11. [结论](#结论)
|
||||
12. [附录](#附录)
|
||||
6. [代理框架测试策略](#代理框架测试策略)
|
||||
7. [LLM提供者测试策略](#llm提供者测试策略)
|
||||
8. [管道引擎测试策略](#管道引擎测试策略)
|
||||
9. [端到端工作流测试策略](#端到端工作流测试策略)
|
||||
10. [业务流程测试策略](#业务流程测试策略)
|
||||
11. [调度器测试策略](#调度器测试策略)
|
||||
12. [依赖分析](#依赖分析)
|
||||
13. [性能考虑](#性能考虑)
|
||||
14. [故障排查指南](#故障排查指南)
|
||||
15. [结论](#结论)
|
||||
16. [附录](#附录)
|
||||
|
||||
## 引言
|
||||
本测试策略文档面向GEO项目的Pytest测试体系,覆盖单元测试、集成测试和业务流程测试的设计与实施要点。内容包括:测试夹具与模拟对象的组织方式、测试数据管理策略、认证模块、引用引擎、查询处理、业务流程和调度器等关键功能的测试用例设计思路;同时给出测试最佳实践,包括覆盖率目标、持续集成配置建议以及测试环境管理方案,并提供调试技巧与性能测试方法。
|
||||
本测试策略文档面向GEO项目的Pytest测试体系,覆盖单元测试、集成测试、业务流程测试和端到端工作流测试的设计与实施要点。内容包括:测试夹具与模拟对象的组织方式、测试数据管理策略、认证模块、引用引擎、查询处理、代理框架、LLM提供者、管道引擎、业务流程和调度器等关键功能的测试用例设计思路;同时给出测试最佳实践,包括覆盖率目标、持续集成配置建议以及测试环境管理方案,并提供调试技巧与性能测试方法。
|
||||
|
||||
## 项目结构
|
||||
测试目录位于仓库根目录下的tests,采用按功能模块划分的组织方式,配合Pytest的conftest集中式夹具与模拟对象,确保测试隔离与可重复性。后端应用以FastAPI为核心,API层通过依赖注入获取当前用户与数据库会话,服务层封装业务逻辑,工作器(worker)负责异步任务与平台适配。
|
||||
测试目录位于仓库根目录下的tests,采用按功能模块划分的组织方式,配合Pytest的conftest集中式夹具与模拟对象,确保测试隔离与可重复性。后端应用以FastAPI为核心,API层通过依赖注入获取当前用户与数据库会话,服务层封装业务逻辑,工作器(worker)负责异步任务与平台适配。新增的代理框架、LLM提供者和管道引擎测试进一步完善了测试体系。
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
|
|
@ -58,6 +77,10 @@ TC["tests/test_citations.py"]
|
|||
TCE["tests/test_citation_engine.py"]
|
||||
TB["tests/test_business_flow.py"]
|
||||
TS["tests/test_scheduler.py"]
|
||||
TCA["tests/test_content_agents.py"]
|
||||
TLP["tests/test_llm_provider.py"]
|
||||
TPE["tests/test_pipeline_engine.py"]
|
||||
TF["backend/tests/test_integration/test_full_flow.py"]
|
||||
end
|
||||
subgraph "后端应用"
|
||||
M["backend/app/main.py"]
|
||||
|
|
@ -69,6 +92,12 @@ QUERIES_API["backend/app/api/queries.py"]
|
|||
CITATIONS_API["backend/app/api/citations.py"]
|
||||
CE["backend/app/workers/citation_engine.py"]
|
||||
QS["backend/app/workers/scheduler.py"]
|
||||
CGA["backend/app/agent_framework/agents/content_generator_agent.py"]
|
||||
DEAI["backend/app/agent_framework/agents/deai_agent.py"]
|
||||
GEO["backend/app/agent_framework/agents/geo_optimizer_agent.py"]
|
||||
PL["backend/app/agent_framework/pipeline/engine.py"]
|
||||
LOADER["backend/app/agent_framework/pipeline/loader.py"]
|
||||
FACTORY["backend/app/services/llm/factory.py"]
|
||||
end
|
||||
C --> TA
|
||||
C --> TQ
|
||||
|
|
@ -76,6 +105,18 @@ C --> TC
|
|||
C --> TCE
|
||||
C --> TB
|
||||
C --> TS
|
||||
C --> TCA
|
||||
C --> TLP
|
||||
C --> TPE
|
||||
TF --> AUTH_API
|
||||
TF --> QUERIES_API
|
||||
TF --> CITATIONS_API
|
||||
TCA --> CGA
|
||||
TCA --> DEAI
|
||||
TCA --> GEO
|
||||
TLP --> FACTORY
|
||||
TPE --> PL
|
||||
TPE --> LOADER
|
||||
TA --> AUTH_API
|
||||
TQ --> QUERIES_API
|
||||
TC --> CITATIONS_API
|
||||
|
|
@ -102,6 +143,12 @@ M --> CITATIONS_API
|
|||
- [backend/app/workers/scheduler.py:1-182](file://backend/app/workers/scheduler.py#L1-L182)
|
||||
- [backend/app/database.py:1-29](file://backend/app/database.py#L1-L29)
|
||||
- [backend/app/config.py:1-23](file://backend/app/config.py#L1-L23)
|
||||
- [backend/app/agent_framework/agents/content_generator_agent.py:1-299](file://backend/app/agent_framework/agents/content_generator_agent.py#L1-L299)
|
||||
- [backend/app/agent_framework/agents/deai_agent.py:1-156](file://backend/app/agent_framework/agents/deai_agent.py#L1-L156)
|
||||
- [backend/app/agent_framework/agents/geo_optimizer_agent.py:1-198](file://backend/app/agent_framework/agents/geo_optimizer_agent.py#L1-L198)
|
||||
- [backend/app/agent_framework/pipeline/engine.py:1-536](file://backend/app/agent_framework/pipeline/engine.py#L1-L536)
|
||||
- [backend/app/agent_framework/pipeline/loader.py:1-283](file://backend/app/agent_framework/pipeline/loader.py#L1-L283)
|
||||
- [backend/app/services/llm/factory.py:1-66](file://backend/app/services/llm/factory.py#L1-L66)
|
||||
|
||||
**章节来源**
|
||||
- [tests/conftest.py:1-123](file://tests/conftest.py#L1-L123)
|
||||
|
|
@ -114,19 +161,25 @@ M --> CITATIONS_API
|
|||
- 异步HTTP客户端:基于ASGI传输创建异步HTTP客户端,用于端到端API测试。
|
||||
- 依赖覆盖:通过依赖注入覆盖当前用户解析逻辑,简化认证流程。
|
||||
- 内存数据库:使用SQLite内存数据库进行集成测试,确保测试隔离性。
|
||||
- FakeLLMProvider:自定义FakeLLMProvider类,模拟LLM调用返回预设响应,避免真实网络请求。
|
||||
- AsyncMock:广泛使用AsyncMock替代真实异步操作,确保测试的确定性和可重复性。
|
||||
- 测试数据管理
|
||||
- 使用pytest fixture生成模拟模型对象(如查询、引用记录),保证测试数据一致性与可读性。
|
||||
- 通过patch对服务层函数进行桩替,隔离外部依赖,提升测试确定性。
|
||||
- 直接操作数据库模型进行复杂场景测试,如权限隔离和统计计算。
|
||||
- 代理框架测试中使用TaskMessage构建测试任务,模拟Agent执行流程。
|
||||
- 测试运行与并发
|
||||
- 使用pytest-asyncio标记异步测试,确保事件循环正确初始化与清理。
|
||||
- 支持并行执行多个测试文件,提高测试执行效率。
|
||||
- 管道引擎测试中使用dry-run模式,通过dispatcher=None实现无真实任务分发的测试。
|
||||
|
||||
**章节来源**
|
||||
- [tests/conftest.py:19-123](file://tests/conftest.py#L19-L123)
|
||||
- [tests/test_content_agents.py:26-54](file://tests/test_content_agents.py#L26-L54)
|
||||
- [tests/test_pipeline_engine.py:148-166](file://tests/test_pipeline_engine.py#L148-L166)
|
||||
|
||||
## 架构总览
|
||||
下图展示了测试与被测系统的交互关系:测试通过异步HTTP客户端直接调用FastAPI路由,路由依赖当前用户与数据库会话,服务层完成业务逻辑,工作器负责平台查询与品牌匹配。
|
||||
下图展示了测试与被测系统的交互关系:测试通过异步HTTP客户端直接调用FastAPI路由,路由依赖当前用户与数据库会话,服务层完成业务逻辑,工作器负责平台查询与品牌匹配。新增的代理框架测试通过FakeLLMProvider模拟LLM调用,管道引擎测试通过dry-run模式验证Pipeline执行流程。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
|
|
@ -363,6 +416,275 @@ AC-->>T : 断言
|
|||
- [tests/test_queries.py:1-154](file://tests/test_queries.py#L1-L154)
|
||||
- [backend/app/api/queries.py:1-86](file://backend/app/api/queries.py#L1-L86)
|
||||
|
||||
## 代理框架测试策略
|
||||
|
||||
### 测试目标
|
||||
代理框架测试专注于验证GEO平台的智能代理执行逻辑,包括内容生成代理、去AI化代理和GEO优化代理的功能完整性、错误处理能力和与LLM提供者的集成测试。
|
||||
|
||||
### 关键测试场景
|
||||
- **ContentGeneratorAgent测试**:选题生成、文章生成、RAG知识检索、JSON解析、错误处理
|
||||
- **DeAIAgent测试**:内容去AI化处理、温度参数验证、输入验证
|
||||
- **GEOOptimizerAgent测试**:SEO/GEO优化、JSON降级处理、关键词注入
|
||||
- **FakeLLMProvider测试**:模拟LLM调用、流式响应、错误模拟
|
||||
|
||||
### 测试实现策略
|
||||
- **FakeLLMProvider**:自定义FakeLLMProvider类,模拟LLM调用返回预设响应,避免真实网络请求
|
||||
- **AsyncMock**:广泛使用AsyncMock替代Redis、数据库和RAG服务调用
|
||||
- **patch技术**:通过patch替换LLMFactory.get_default和RAGService,隔离外部依赖
|
||||
- **TaskMessage构建**:使用_test_make_task辅助函数创建测试任务消息
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class ContentGeneratorAgent {
|
||||
+execute(task) TaskResult
|
||||
+_generate_topics(task) dict
|
||||
+_generate_article(task) dict
|
||||
+_retrieve_knowledge(kb_ids, query) str
|
||||
+_extract_json(text) str
|
||||
}
|
||||
class DeAIAgent {
|
||||
+execute(task) TaskResult
|
||||
+_process(task) dict
|
||||
}
|
||||
class GEOOptimizerAgent {
|
||||
+execute(task) TaskResult
|
||||
+_optimize(task) dict
|
||||
+_extract_json(text) str
|
||||
}
|
||||
class FakeLLMProvider {
|
||||
+chat(messages, **kwargs) LLMResponse
|
||||
+chat_stream(messages, **kwargs) AsyncGenerator
|
||||
+provider_name str
|
||||
+model_name str
|
||||
+max_context_length int
|
||||
}
|
||||
ContentGeneratorAgent --> FakeLLMProvider : "使用"
|
||||
DeAIAgent --> FakeLLMProvider : "使用"
|
||||
GEOOptimizerAgent --> FakeLLMProvider : "使用"
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [tests/test_content_agents.py:26-54](file://tests/test_content_agents.py#L26-L54)
|
||||
- [tests/test_content_agents.py:75-116](file://tests/test_content_agents.py#L75-L116)
|
||||
- [tests/test_content_agents.py:200-236](file://tests/test_content_agents.py#L200-L236)
|
||||
- [tests/test_content_agents.py:268-320](file://tests/test_content_agents.py#L268-L320)
|
||||
|
||||
### 测试用例设计要点
|
||||
- **内容生成测试**:验证topics字段解析、article内容生成、word count计算、usage统计
|
||||
- **RAG集成测试**:通过AsyncSessionLocal mock验证知识检索上下文注入
|
||||
- **错误处理测试**:模拟LLMError验证failed状态返回和错误消息处理
|
||||
- **温度参数测试**:验证DeAIAgent的temperature=0.9配置
|
||||
- **JSON解析测试**:测试```json```包裹和普通文本两种输出格式
|
||||
|
||||
**章节来源**
|
||||
- [tests/test_content_agents.py:1-358](file://tests/test_content_agents.py#L1-L358)
|
||||
- [backend/app/agent_framework/agents/content_generator_agent.py:1-299](file://backend/app/agent_framework/agents/content_generator_agent.py#L1-L299)
|
||||
- [backend/app/agent_framework/agents/deai_agent.py:1-156](file://backend/app/agent_framework/agents/deai_agent.py#L1-L156)
|
||||
- [backend/app/agent_framework/agents/geo_optimizer_agent.py:1-198](file://backend/app/agent_framework/agents/geo_optimizer_agent.py#L1-L198)
|
||||
|
||||
## LLM提供者测试策略
|
||||
|
||||
### 测试目标
|
||||
LLM提供者测试专注于验证GEO平台的LLM抽象层,包括工厂模式、OpenAIProvider和DeepSeekProvider的功能完整性、错误处理和流式响应处理。
|
||||
|
||||
### 关键测试场景
|
||||
- **LLMFactory测试**:工厂创建、默认提供者选择、未知提供者处理、提供者列表
|
||||
- **OpenAIProvider测试**:聊天响应、重试机制、错误处理、SSE流解析
|
||||
- **DeepSeekProvider测试**:基础功能验证、与OpenAIProvider对比
|
||||
- **LLMResponse测试**:响应结构验证、usage统计
|
||||
|
||||
### 测试实现策略
|
||||
- **环境变量设置**:通过monkeypatch设置OPENAI_API_KEY和DEEPSEEK_API_KEY
|
||||
- **httpx模拟**:使用MagicMock模拟HTTP响应,测试各种状态码场景
|
||||
- **AsyncMock**:模拟异步HTTP客户端和SSE流响应
|
||||
- **重试机制测试**:通过patch asyncio.sleep避免真实等待时间
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class LLMFactory {
|
||||
+create(provider, model) LLMProvider
|
||||
+get_default() LLMProvider
|
||||
+list_providers() list[str]
|
||||
+register(name, provider_cls) void
|
||||
}
|
||||
class OpenAIProvider {
|
||||
+chat(messages) LLMResponse
|
||||
+chat_stream(messages) AsyncGenerator
|
||||
+provider_name str
|
||||
+model_name str
|
||||
+max_context_length int
|
||||
}
|
||||
class DeepSeekProvider {
|
||||
+chat(messages) LLMResponse
|
||||
+chat_stream(messages) AsyncGenerator
|
||||
+provider_name str
|
||||
+model_name str
|
||||
+max_context_length int
|
||||
}
|
||||
class LLMProvider {
|
||||
<<abstract>>
|
||||
+chat(messages) LLMResponse
|
||||
+chat_stream(messages) AsyncGenerator
|
||||
+provider_name str
|
||||
+model_name str
|
||||
+max_context_length int
|
||||
}
|
||||
LLMFactory --> LLMProvider : "创建"
|
||||
LLMProvider <|-- OpenAIProvider
|
||||
LLMProvider <|-- DeepSeekProvider
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [tests/test_llm_provider.py:24-67](file://tests/test_llm_provider.py#L24-L67)
|
||||
- [tests/test_llm_provider.py:94-153](file://tests/test_llm_provider.py#L94-L153)
|
||||
- [tests/test_llm_provider.py:200-204](file://tests/test_llm_provider.py#L200-L204)
|
||||
- [backend/app/services/llm/factory.py:8-66](file://backend/app/services/llm/factory.py#L8-L66)
|
||||
|
||||
### 测试用例设计要点
|
||||
- **工厂模式测试**:验证create方法、默认提供者、未知提供者异常处理
|
||||
- **HTTP响应测试**:模拟200、429、401等不同状态码的处理逻辑
|
||||
- **重试机制测试**:验证429速率限制的重试行为和401不可重试的处理
|
||||
- **流式响应测试**:验证SSE流的逐token解析和完成信号处理
|
||||
- **响应结构测试**:验证LLMResponse的字段完整性和默认值
|
||||
|
||||
**章节来源**
|
||||
- [tests/test_llm_provider.py:1-205](file://tests/test_llm_provider.py#L1-L205)
|
||||
- [backend/app/services/llm/factory.py:1-66](file://backend/app/services/llm/factory.py#L1-L66)
|
||||
|
||||
## 管道引擎测试策略
|
||||
|
||||
### 测试目标
|
||||
管道引擎测试专注于验证GEO平台的Pipeline编排能力,包括YAML加载、DAG验证、拓扑排序、变量解析和dry-run执行模式。
|
||||
|
||||
### 关键测试场景
|
||||
- **PipelineLoader测试**:YAML加载、DAG验证、拓扑排序、变量解析
|
||||
- **PipelineEngine测试**:dry-run模式、阶段执行、超时处理、重试机制
|
||||
- **变量解析测试**:简单变量替换、嵌套路径解析、上下文传递
|
||||
- **依赖关系测试**:有环图检测、无环图验证、执行顺序保证
|
||||
|
||||
### 测试实现策略
|
||||
- **YAML模板**:使用textwrap.dedent创建测试用YAML配置
|
||||
- **dry-run模式**:通过dispatcher=None实现无真实任务分发的测试
|
||||
- **AsyncMock**:模拟Agent执行和任务状态查询
|
||||
- **拓扑排序**:验证依赖关系的正确执行顺序
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class PipelineLoader {
|
||||
+load(pipeline_name) Pipeline
|
||||
+load_from_yaml(yaml_content, pipeline_name) Pipeline
|
||||
+validate_dag(stages) bool
|
||||
+resolve_variables(template, context) Any
|
||||
}
|
||||
class PipelineEngine {
|
||||
+execute(pipeline, context) PipelineResult
|
||||
+_execute_stage(stage, exec_context, stages_context) StageResult
|
||||
+_topological_sort(stages) list[PipelineStage]
|
||||
+_resolve_stage_inputs(inputs, context) dict
|
||||
+_should_skip(stage, failed_stages, skipped_stages) bool
|
||||
+_evaluate_condition(condition, exec_context, stages_context) bool
|
||||
+_extract_outputs(stage, output_data) dict
|
||||
}
|
||||
class Pipeline {
|
||||
+name str
|
||||
+version str
|
||||
+description str
|
||||
+variables dict
|
||||
+stages list[PipelineStage]
|
||||
}
|
||||
class PipelineStage {
|
||||
+name str
|
||||
+agent str
|
||||
+action str
|
||||
+depends_on list[str]
|
||||
+inputs dict
|
||||
+outputs list[str]
|
||||
+timeout_seconds int
|
||||
+retry_count int
|
||||
+condition str
|
||||
+continue_on_failure bool
|
||||
}
|
||||
PipelineLoader --> Pipeline : "创建"
|
||||
PipelineEngine --> Pipeline : "执行"
|
||||
Pipeline --> PipelineStage : "包含"
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [tests/test_pipeline_engine.py:55-98](file://tests/test_pipeline_engine.py#L55-L98)
|
||||
- [tests/test_pipeline_engine.py:148-223](file://tests/test_pipeline_engine.py#L148-L223)
|
||||
- [backend/app/agent_framework/pipeline/loader.py:41-134](file://backend/app/agent_framework/pipeline/loader.py#L41-L134)
|
||||
- [backend/app/agent_framework/pipeline/engine.py:31-176](file://backend/app/agent_framework/pipeline/engine.py#L31-L176)
|
||||
|
||||
### 测试用例设计要点
|
||||
- **YAML加载测试**:验证正常YAML的解析和Pipeline对象创建
|
||||
- **DAG验证测试**:测试有环图的异常处理和无环图的验证通过
|
||||
- **变量解析测试**:验证${var}和${stages.step1.outputs.result}等变量引用
|
||||
- **dry-run模式测试**:验证无dispatcher时的模拟执行和结果收集
|
||||
- **超时和重试测试**:验证阶段级别的超时控制和重试机制
|
||||
|
||||
**章节来源**
|
||||
- [tests/test_pipeline_engine.py:1-255](file://tests/test_pipeline_engine.py#L1-L255)
|
||||
- [backend/app/agent_framework/pipeline/loader.py:1-283](file://backend/app/agent_framework/pipeline/loader.py#L1-L283)
|
||||
- [backend/app/agent_framework/pipeline/engine.py:1-536](file://backend/app/agent_framework/pipeline/engine.py#L1-L536)
|
||||
|
||||
## 端到端工作流测试策略
|
||||
|
||||
### 测试目标
|
||||
端到端工作流测试专注于验证GEO平台的完整业务流程,包括品牌查询、竞争品牌管理、引用数据收集、评分计算和CSV导出的完整链路。
|
||||
|
||||
### 关键测试场景
|
||||
- **完整品牌查询流程**:品牌创建、竞争品牌添加、查询创建、引用数据模拟、评分计算、历史记录、统计聚合
|
||||
- **CSV导出流程**:品牌创建、查询创建、引用数据创建、CSV导出、内容验证
|
||||
- **错误处理流程**:不存在的品牌ID处理、404错误验证
|
||||
|
||||
### 测试实现策略
|
||||
- **异步数据库**:使用SQLite内存数据库和async_sessionmaker
|
||||
- **依赖覆盖**:通过app.dependency_overrides覆盖get_db和get_current_user
|
||||
- **测试数据构建**:直接操作模型类创建测试数据
|
||||
- **HTTP客户端**:使用AsyncClient和ASGITransport进行端到端测试
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as "端到端测试"
|
||||
participant AC as "AsyncClient"
|
||||
participant API as "FastAPI路由"
|
||||
participant DB as "异步数据库"
|
||||
T->>AC : POST /api/v1/brands/
|
||||
AC->>API : 品牌创建
|
||||
API->>DB : 插入品牌记录
|
||||
API-->>AC : 201 + 品牌数据
|
||||
T->>AC : POST /api/v1/brands/{brand_id}/competitors/
|
||||
AC->>API : 添加竞争品牌
|
||||
API->>DB : 插入竞争品牌记录
|
||||
API-->>AC : 201 + 竞争品牌数据
|
||||
T->>AC : POST /api/v1/queries/
|
||||
AC->>API : 创建查询
|
||||
API->>DB : 插入查询记录
|
||||
API-->>AC : 201 + 查询数据
|
||||
T->>AC : POST /api/v1/citations/
|
||||
AC->>API : 创建引用记录
|
||||
API->>DB : 插入引用记录
|
||||
API-->>AC : 201 + 引用数据
|
||||
T->>AC : GET /api/v1/brands/{brand_id}/score/
|
||||
AC->>API : 获取品牌评分
|
||||
API->>DB : 查询引用统计
|
||||
API-->>AC : 200 + 评分数据
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [backend/tests/test_integration/test_full_flow.py:94-223](file://backend/tests/test_integration/test_full_flow.py#L94-L223)
|
||||
- [backend/tests/test_integration/test_full_flow.py:228-298](file://backend/tests/test_integration/test_full_flow.py#L228-L298)
|
||||
|
||||
### 测试用例设计要点
|
||||
- **数据隔离**:使用独立的异步数据库连接和会话
|
||||
- **依赖注入**:通过dependency_overrides确保测试环境的一致性
|
||||
- **流程完整性**:覆盖从品牌创建到最终评分的完整业务流程
|
||||
- **数据验证**:验证统计计算的准确性,如提及率、引用率等指标
|
||||
- **CSV内容验证**:验证导出文件的格式和关键字段存在性
|
||||
|
||||
**章节来源**
|
||||
- [backend/tests/test_integration/test_full_flow.py:1-322](file://backend/tests/test_integration/test_full_flow.py#L1-L322)
|
||||
|
||||
## 业务流程测试策略
|
||||
|
||||
### 测试目标
|
||||
|
|
@ -484,11 +806,15 @@ QueryScheduler --> AsyncIOScheduler : "使用"
|
|||
- 测试通过ASGI传输直接调用路由,避免引入额外适配层
|
||||
- 通过依赖覆盖与patch解耦服务层与数据库、第三方平台
|
||||
- 业务流程测试直接操作数据库模型,确保测试数据的准确性
|
||||
- 代理框架测试通过FakeLLMProvider和AsyncMock解耦LLM调用
|
||||
- LLM提供者测试通过httpx模拟和AsyncMock解耦HTTP请求
|
||||
- 管道引擎测试通过dry-run模式解耦真实任务分发
|
||||
- 外部依赖与集成点
|
||||
- 数据库:通过异步引擎与会话管理,测试中可使用内存数据库或独立测试库
|
||||
- JWT:通过服务层令牌生成与校验,测试中直接构造令牌头
|
||||
- 平台适配器:通过patch替换,避免真实网络请求
|
||||
- 调度器:通过patch替换真实的APScheduler,使用AsyncMock控制调度行为
|
||||
- LLM提供者:通过LLMFactory统一管理,测试中可通过patch替换具体实现
|
||||
- 循环依赖与风险
|
||||
- 当前结构清晰,无明显循环依赖;注意在测试中避免对真实调度器的依赖
|
||||
|
||||
|
|
@ -500,6 +826,14 @@ T_CIT["测试: 引用"] --> A_CIT["路由: 引用"]
|
|||
T_BUSINESS["测试: 业务流程"] --> A_QUERIES
|
||||
T_BUSINESS --> A_CIT
|
||||
T_SCHED["测试: 调度器"] --> QS["调度器: QueryScheduler"]
|
||||
T_AGENTS["测试: 代理框架"] --> CGA["代理: ContentGeneratorAgent"]
|
||||
T_AGENTS --> DEAI["代理: DeAIAgent"]
|
||||
T_AGENTS --> GEO["代理: GEOOptimizerAgent"]
|
||||
T_LLM["测试: LLM提供者"] --> FACTORY["工厂: LLMFactory"]
|
||||
T_PIPE["测试: 管道引擎"] --> ENGINE["引擎: PipelineEngine"]
|
||||
T_FULL["测试: 端到端"] --> A_AUTH
|
||||
T_FULL --> A_QUERIES
|
||||
T_FULL --> A_CIT
|
||||
A_AUTH --> S_AUTH["服务: 认证"]
|
||||
A_QUERIES --> S_QUERY["服务: 查询"]
|
||||
A_CIT --> S_CIT["服务: 引用"]
|
||||
|
|
@ -508,6 +842,10 @@ S_QUERY --> DB
|
|||
S_CIT --> DB
|
||||
QS --> CE["引擎: CitationEngine"]
|
||||
QS --> DB
|
||||
CGA --> LLM["LLM提供者"]
|
||||
DEAI --> LLM
|
||||
GEO --> LLM
|
||||
ENGINE --> LOADER["加载器: PipelineLoader"]
|
||||
DB --> CFG["配置"]
|
||||
```
|
||||
|
||||
|
|
@ -515,10 +853,20 @@ DB --> CFG["配置"]
|
|||
- [tests/test_auth.py:1-104](file://tests/test_auth.py#L1-L104)
|
||||
- [tests/test_business_flow.py:1-441](file://tests/test_business_flow.py#L1-L441)
|
||||
- [tests/test_scheduler.py:1-123](file://tests/test_scheduler.py#L1-L123)
|
||||
- [tests/test_content_agents.py:1-358](file://tests/test_content_agents.py#L1-L358)
|
||||
- [tests/test_llm_provider.py:1-205](file://tests/test_llm_provider.py#L1-L205)
|
||||
- [tests/test_pipeline_engine.py:1-255](file://tests/test_pipeline_engine.py#L1-L255)
|
||||
- [backend/tests/test_integration/test_full_flow.py:1-322](file://backend/tests/test_integration/test_full_flow.py#L1-L322)
|
||||
- [backend/app/api/auth.py:1-43](file://backend/app/api/auth.py#L1-L43)
|
||||
- [backend/app/api/queries.py:1-86](file://backend/app/api/queries.py#L1-L86)
|
||||
- [backend/app/api/citations.py:1-78](file://backend/app/api/citations.py#L1-L78)
|
||||
- [backend/app/workers/scheduler.py:1-182](file://backend/app/workers/scheduler.py#L1-L182)
|
||||
- [backend/app/agent_framework/agents/content_generator_agent.py:1-299](file://backend/app/agent_framework/agents/content_generator_agent.py#L1-L299)
|
||||
- [backend/app/agent_framework/agents/deai_agent.py:1-156](file://backend/app/agent_framework/agents/deai_agent.py#L1-L156)
|
||||
- [backend/app/agent_framework/agents/geo_optimizer_agent.py:1-198](file://backend/app/agent_framework/agents/geo_optimizer_agent.py#L1-L198)
|
||||
- [backend/app/services/llm/factory.py:1-66](file://backend/app/services/llm/factory.py#L1-L66)
|
||||
- [backend/app/agent_framework/pipeline/engine.py:1-536](file://backend/app/agent_framework/pipeline/engine.py#L1-L536)
|
||||
- [backend/app/agent_framework/pipeline/loader.py:1-283](file://backend/app/agent_framework/pipeline/loader.py#L1-L283)
|
||||
- [backend/app/database.py:1-29](file://backend/app/database.py#L1-L29)
|
||||
- [backend/app/config.py:1-23](file://backend/app/config.py#L1-L23)
|
||||
|
||||
|
|
@ -531,16 +879,22 @@ DB --> CFG["配置"]
|
|||
- 使用pytest-asyncio并行执行异步测试,减少总耗时
|
||||
- 通过会话级调度器模拟避免真实后台任务带来的不稳定因素
|
||||
- 业务流程测试使用内存数据库,避免磁盘I/O开销
|
||||
- 代理框架测试使用FakeLLMProvider,避免真实LLM调用的网络延迟
|
||||
- LLM提供者测试使用httpx模拟,避免真实HTTP请求的网络开销
|
||||
- 管道引擎测试使用dry-run模式,避免真实任务分发的系统开销
|
||||
- 数据库与缓存
|
||||
- 建议使用独立测试数据库实例,避免与开发/生产数据冲突
|
||||
- 对高频查询场景,可在测试中模拟数据库延迟,评估路由与服务层的超时与重试策略
|
||||
- 调度器测试使用AsyncMock,避免真实的定时任务执行
|
||||
- 代理框架测试使用AsyncMock,避免真实Redis和数据库调用
|
||||
- 接口响应与序列化
|
||||
- 对大列表与统计聚合接口,关注JSON序列化开销与分页参数边界
|
||||
- 业务流程测试中直接操作数据库模型,避免不必要的API调用
|
||||
- 端到端测试中合理使用AsyncClient,避免过多的HTTP请求
|
||||
- 平台适配器性能
|
||||
- 通过patch模拟不同响应时延与错误率,评估引擎的容错与降级策略
|
||||
- 调度器测试中使用精确的时间控制,避免真实的等待时间
|
||||
- LLM提供者测试中使用AsyncMock,避免真实网络请求的等待时间
|
||||
|
||||
## 故障排查指南
|
||||
- 常见问题定位
|
||||
|
|
@ -549,18 +903,26 @@ DB --> CFG["配置"]
|
|||
- 403配额:检查服务层权限异常抛出与HTTP状态映射
|
||||
- 调度器异常:检查APScheduler的启动状态和job配置
|
||||
- 业务流程失败:检查数据库事务和fixture的使用
|
||||
- 代理执行失败:检查FakeLLMProvider的mock配置和report_progress的patch
|
||||
- LLM提供者异常:检查API密钥设置和httpx模拟配置
|
||||
- 管道执行失败:检查YAML配置和依赖关系验证
|
||||
- 端到端测试失败:检查数据库连接和依赖覆盖配置
|
||||
- 调试技巧
|
||||
- 在conftest中临时打印依赖解析过程,定位get_current_user解析失败原因
|
||||
- 使用pytest的-v与-s选项查看详细输出,结合patch的side_effect观察异常传播
|
||||
- 对数据库相关测试,开启SQLAlchemy echo以查看生成的SQL
|
||||
- 调度器测试中使用AsyncMock的assert_called_once()验证调度器行为
|
||||
- 代理框架测试中检查report_progress的调用次数和参数
|
||||
- LLM提供者测试中验证httpx.post的调用参数和返回值
|
||||
- 管道引擎测试中检查拓扑排序和变量解析的中间结果
|
||||
- 性能与稳定性
|
||||
- 对于长时间运行的异步测试,确保事件循环正确关闭
|
||||
- 对需要真实网络请求的场景,优先使用patch模拟,必要时增加超时与重试
|
||||
- 业务流程测试中合理使用fixture,避免重复创建昂贵的对象
|
||||
- 代理框架测试中使用AsyncMock,确保异步操作的正确模拟
|
||||
|
||||
## 结论
|
||||
本测试策略以Pytest为核心,结合会话级调度器模拟、依赖覆盖与patch技术,实现了对认证、查询、引用、引擎模块以及业务流程和调度器的全面覆盖。通过明确的夹具与测试数据管理,确保测试的可维护性与可重复性。新增的业务流程测试和调度器测试进一步完善了测试体系,涵盖了端到端业务场景和定时任务调度的关键功能。建议在CI中启用并行执行与覆盖率统计,并为数据库与平台适配器建立稳定的模拟层,持续提升测试效率与质量。
|
||||
本测试策略以Pytest为核心,结合会话级调度器模拟、依赖覆盖与patch技术,实现了对认证、查询、引用、引擎模块、业务流程、调度器、代理框架、LLM提供者、管道引擎和端到端工作流的全面覆盖。通过明确的夹具与测试数据管理,确保测试的可维护性与可重复性。新增的代理框架测试、LLM提供者测试、管道引擎测试和端到端工作流测试进一步完善了测试体系,涵盖了智能代理执行、LLM抽象层、Pipeline编排和完整业务流程的关键功能。建议在CI中启用并行执行与覆盖率统计,并为数据库、LLM提供者、代理框架和管道引擎建立稳定的模拟层,持续提升测试效率与质量。
|
||||
|
||||
## 附录
|
||||
- 测试覆盖率要求建议
|
||||
|
|
@ -570,16 +932,29 @@ DB --> CFG["配置"]
|
|||
- 函数/方法覆盖率:≥90%
|
||||
- 业务流程覆盖率:≥95%
|
||||
- 调度器覆盖率:≥90%
|
||||
- 代理框架覆盖率:≥90%
|
||||
- LLM提供者覆盖率:≥90%
|
||||
- 管道引擎覆盖率:≥90%
|
||||
- 端到端工作流覆盖率:≥95%
|
||||
- 持续集成配置建议
|
||||
- 使用GitHub Actions或GitLab CI,包含Python版本矩阵、依赖安装、数据库准备、pytest执行与覆盖率上传
|
||||
- 将测试与lint、类型检查并行,确保主干分支质量
|
||||
- 为业务流程测试和调度器测试单独配置执行时间限制
|
||||
- 为业务流程测试、调度器测试、代理框架测试、LLM提供者测试、管道引擎测试和端到端工作流测试单独配置执行时间限制
|
||||
- 为LLM提供者测试配置环境变量,确保API密钥正确设置
|
||||
- 测试环境管理
|
||||
- 使用独立测试数据库与Redis实例,避免污染
|
||||
- 通过环境变量切换测试配置,确保敏感信息不泄露
|
||||
- 业务流程测试使用内存数据库,调度器测试使用AsyncMock
|
||||
- 代理框架测试使用FakeLLMProvider和AsyncMock
|
||||
- LLM提供者测试使用httpx模拟和环境变量
|
||||
- 管道引擎测试使用dry-run模式
|
||||
- 端到端测试使用AsyncClient和依赖覆盖
|
||||
- 性能测试方法
|
||||
- 使用pytest-benchmark或locust对高频路由进行基准测试
|
||||
- 对引擎执行流程进行压力测试,评估平台适配器与数据库写入瓶颈
|
||||
- 调度器测试中使用时间控制和AsyncMock,避免真实的定时等待
|
||||
- 业务流程测试中评估端到端流程的响应时间和吞吐量
|
||||
- 业务流程测试中评估端到端流程的响应时间和吞吐量
|
||||
- 代理框架测试中评估LLM调用的性能和错误处理
|
||||
- LLM提供者测试中评估HTTP请求的性能和重试机制
|
||||
- 管道引擎测试中评估Pipeline执行的性能和超时处理
|
||||
- 端到端测试中评估完整业务流程的性能和稳定性
|
||||
|
|
@ -15,20 +15,35 @@
|
|||
- [backend/app/models/query.py](file://backend/app/models/query.py)
|
||||
- [backend/alembic.ini](file://backend/alembic.ini)
|
||||
- [backend/alembic/env.py](file://backend/alembic/env.py)
|
||||
- [backend/app/middleware/logging_middleware.py](file://backend/app/middleware/logging_middleware.py)
|
||||
- [backend/app/middleware/rate_limit.py](file://backend/app/middleware/rate_limit.py)
|
||||
- [tests/test_auth.py](file://tests/test_auth.py)
|
||||
</cite>
|
||||
|
||||
## 更新摘要
|
||||
**所做更改**
|
||||
- 新增完整的Docker容器化部署配置说明
|
||||
- 补充生产环境部署策略与最佳实践
|
||||
- 完善监控日志管理方案与运维指南
|
||||
- 增加CI/CD流水线与自动化部署配置
|
||||
- 更新环境配置与安全加固措施
|
||||
|
||||
## 目录
|
||||
1. [简介](#简介)
|
||||
2. [项目结构](#项目结构)
|
||||
3. [核心组件](#核心组件)
|
||||
4. [架构总览](#架构总览)
|
||||
5. [详细组件分析](#详细组件分析)
|
||||
6. [依赖分析](#依赖分析)
|
||||
7. [性能考虑](#性能考虑)
|
||||
8. [故障排查指南](#故障排查指南)
|
||||
9. [结论](#结论)
|
||||
10. [附录](#附录)
|
||||
6. [Docker容器化部署](#docker容器化部署)
|
||||
7. [生产环境部署策略](#生产环境部署策略)
|
||||
8. [监控与日志管理](#监控与日志管理)
|
||||
9. [运维最佳实践](#运维最佳实践)
|
||||
10. [CI/CD流水线配置](#cicd流水线配置)
|
||||
11. [依赖分析](#依赖分析)
|
||||
12. [性能考虑](#性能考虑)
|
||||
13. [故障排查指南](#故障排查指南)
|
||||
14. [结论](#结论)
|
||||
15. [附录](#附录)
|
||||
|
||||
## 简介
|
||||
本文件面向GEO项目的部署与运维团队,提供从开发到生产的完整落地指南。内容覆盖Docker容器化部署、镜像构建、服务编排与环境配置;生产部署策略(Nginx反向代理、SSL证书、负载均衡);监控与日志管理(健康检查、错误追踪、性能监控);运维最佳实践(备份、安全、故障恢复)以及CI/CD流水线与自动化部署建议。文档严格基于仓库现有配置与实现进行说明,避免臆测。
|
||||
|
|
@ -69,18 +84,18 @@ SCH --> RD
|
|||
FE --> API
|
||||
```
|
||||
|
||||
图表来源
|
||||
**图表来源**
|
||||
- [docker-compose.yml:1-71](file://docker-compose.yml#L1-L71)
|
||||
- [backend/Dockerfile:1-41](file://backend/Dockerfile#L1-L41)
|
||||
- [frontend/Dockerfile:1-15](file://frontend/Dockerfile#L1-L15)
|
||||
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
|
||||
- [backend/app/config.py:1-17](file://backend/app/config.py#L1-L17)
|
||||
- [backend/app/main.py:1-100](file://backend/app/main.py#L1-L100)
|
||||
- [backend/app/config.py:1-46](file://backend/app/config.py#L1-L46)
|
||||
- [backend/app/database.py:1-29](file://backend/app/database.py#L1-L29)
|
||||
- [backend/app/workers/scheduler.py:1-95](file://backend/app/workers/scheduler.py#L1-L95)
|
||||
- [backend/app/workers/scheduler.py:1-189](file://backend/app/workers/scheduler.py#L1-L189)
|
||||
- [backend/alembic.ini:1-150](file://backend/alembic.ini#L1-L150)
|
||||
- [backend/alembic/env.py:1-89](file://backend/alembic/env.py#L1-L89)
|
||||
|
||||
章节来源
|
||||
**章节来源**
|
||||
- [docker-compose.yml:1-71](file://docker-compose.yml#L1-L71)
|
||||
- [backend/Dockerfile:1-41](file://backend/Dockerfile#L1-L41)
|
||||
- [frontend/Dockerfile:1-15](file://frontend/Dockerfile#L1-L15)
|
||||
|
|
@ -98,11 +113,11 @@ FE --> API
|
|||
- 迁移与版本控制(Alembic)
|
||||
- 异步迁移环境、配置文件与日志级别。
|
||||
|
||||
章节来源
|
||||
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
|
||||
- [backend/app/config.py:1-17](file://backend/app/config.py#L1-L17)
|
||||
**章节来源**
|
||||
- [backend/app/main.py:1-100](file://backend/app/main.py#L1-L100)
|
||||
- [backend/app/config.py:1-46](file://backend/app/config.py#L1-L46)
|
||||
- [backend/app/database.py:1-29](file://backend/app/database.py#L1-L29)
|
||||
- [backend/app/workers/scheduler.py:1-95](file://backend/app/workers/scheduler.py#L1-L95)
|
||||
- [backend/app/workers/scheduler.py:1-189](file://backend/app/workers/scheduler.py#L1-L189)
|
||||
- [backend/alembic.ini:1-150](file://backend/alembic.ini#L1-L150)
|
||||
- [backend/alembic/env.py:1-89](file://backend/alembic/env.py#L1-L89)
|
||||
|
||||
|
|
@ -118,7 +133,7 @@ subgraph "边缘层"
|
|||
NGINX["Nginx 反向代理<br/>SSL/TLS 终止"]
|
||||
end
|
||||
subgraph "应用层"
|
||||
LB["负载均衡器/反向代理"
|
||||
LB["负载均衡器/反向代理"]
|
||||
subgraph "后端实例"
|
||||
API1["API 实例 1"]
|
||||
API2["API 实例 2"]
|
||||
|
|
@ -143,7 +158,9 @@ API2 --> RDS
|
|||
API3 --> RDS
|
||||
```
|
||||
|
||||
(本图为概念性架构示意,不对应具体源码文件)
|
||||
**图表来源**
|
||||
- [docker-compose.yml:36-66](file://docker-compose.yml#L36-L66)
|
||||
- [backend/app/main.py:97-100](file://backend/app/main.py#L97-L100)
|
||||
|
||||
## 详细组件分析
|
||||
|
||||
|
|
@ -180,19 +197,19 @@ S->>D : "周期性查询待执行任务"
|
|||
S->>A : "触发执行逻辑"
|
||||
```
|
||||
|
||||
图表来源
|
||||
**图表来源**
|
||||
- [backend/Dockerfile:1-41](file://backend/Dockerfile#L1-L41)
|
||||
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
|
||||
- [backend/app/config.py:1-17](file://backend/app/config.py#L1-L17)
|
||||
- [backend/app/main.py:33-45](file://backend/app/main.py#L33-L45)
|
||||
- [backend/app/config.py:12-14](file://backend/app/config.py#L12-L14)
|
||||
- [backend/app/database.py:1-29](file://backend/app/database.py#L1-L29)
|
||||
- [backend/app/workers/scheduler.py:1-95](file://backend/app/workers/scheduler.py#L1-L95)
|
||||
- [backend/app/workers/scheduler.py:33-51](file://backend/app/workers/scheduler.py#L33-L51)
|
||||
|
||||
章节来源
|
||||
**章节来源**
|
||||
- [backend/Dockerfile:1-41](file://backend/Dockerfile#L1-L41)
|
||||
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
|
||||
- [backend/app/config.py:1-17](file://backend/app/config.py#L1-L17)
|
||||
- [backend/app/main.py:1-100](file://backend/app/main.py#L1-L100)
|
||||
- [backend/app/config.py:1-46](file://backend/app/config.py#L1-L46)
|
||||
- [backend/app/database.py:1-29](file://backend/app/database.py#L1-L29)
|
||||
- [backend/app/workers/scheduler.py:1-95](file://backend/app/workers/scheduler.py#L1-L95)
|
||||
- [backend/app/workers/scheduler.py:1-189](file://backend/app/workers/scheduler.py#L1-L189)
|
||||
|
||||
### 前端(Next.js)与容器化
|
||||
- 容器镜像构建要点
|
||||
|
|
@ -201,10 +218,10 @@ S->>A : "触发执行逻辑"
|
|||
- 与后端交互
|
||||
- 默认CORS允许来自前端开发地址的请求,生产环境需根据域名调整。
|
||||
|
||||
章节来源
|
||||
**章节来源**
|
||||
- [frontend/Dockerfile:1-15](file://frontend/Dockerfile#L1-L15)
|
||||
- [frontend/package.json:1-40](file://frontend/package.json#L1-L40)
|
||||
- [backend/app/main.py:30-36](file://backend/app/main.py#L30-L36)
|
||||
- [frontend/package.json:1-45](file://frontend/package.json#L1-L45)
|
||||
- [backend/app/main.py:53-63](file://backend/app/main.py#L53-L63)
|
||||
|
||||
### 数据库与迁移(PostgreSQL + Alembic)
|
||||
- 连接与会话
|
||||
|
|
@ -259,13 +276,13 @@ QUERIES ||--o{ CITATION_RECORDS : "生成"
|
|||
QUERIES ||--o{ QUERY_TASKS : "拆分任务"
|
||||
```
|
||||
|
||||
图表来源
|
||||
**图表来源**
|
||||
- [backend/app/models/query.py:1-55](file://backend/app/models/query.py#L1-L55)
|
||||
- [backend/app/database.py:1-29](file://backend/app/database.py#L1-L29)
|
||||
- [backend/alembic.ini:86-89](file://backend/alembic.ini#L86-L89)
|
||||
- [backend/alembic/env.py:1-89](file://backend/alembic/env.py#L1-L89)
|
||||
|
||||
章节来源
|
||||
**章节来源**
|
||||
- [backend/app/database.py:1-29](file://backend/app/database.py#L1-L29)
|
||||
- [backend/app/models/query.py:1-55](file://backend/app/models/query.py#L1-L55)
|
||||
- [backend/alembic.ini:1-150](file://backend/alembic.ini#L1-L150)
|
||||
|
|
@ -278,9 +295,9 @@ QUERIES ||--o{ QUERY_TASKS : "拆分任务"
|
|||
- JWT密钥默认值仅用于开发,生产必须替换。
|
||||
- CORS在开发环境允许前端地址,生产需限定来源。
|
||||
|
||||
章节来源
|
||||
- [backend/app/api/auth.py:1-43](file://backend/app/api/auth.py#L1-L43)
|
||||
- [backend/app/config.py:9-9](file://backend/app/config.py#L9-L9)
|
||||
**章节来源**
|
||||
- [backend/app/api/auth.py:1-115](file://backend/app/api/auth.py#L1-L115)
|
||||
- [backend/app/config.py:14](file://backend/app/config.py#L14)
|
||||
|
||||
### 编排与健康检查(Docker Compose)
|
||||
- 服务编排
|
||||
|
|
@ -290,9 +307,259 @@ QUERIES ||--o{ QUERY_TASKS : "拆分任务"
|
|||
- 依赖顺序
|
||||
- 后端等待数据库与Redis健康后再启动,前端依赖后端。
|
||||
|
||||
章节来源
|
||||
**章节来源**
|
||||
- [docker-compose.yml:1-71](file://docker-compose.yml#L1-L71)
|
||||
- [backend/app/main.py:45-48](file://backend/app/main.py#L45-L48)
|
||||
- [backend/app/main.py:97-100](file://backend/app/main.py#L97-L100)
|
||||
|
||||
## Docker容器化部署
|
||||
|
||||
### 镜像构建配置
|
||||
GEO项目采用多阶段容器化部署,后端和前端分别构建独立镜像:
|
||||
|
||||
**后端镜像构建流程**
|
||||
- 基础镜像:python:3.11-slim
|
||||
- 系统依赖:安装Playwright运行所需的系统库
|
||||
- 依赖安装:pip安装requirements.txt中的所有依赖
|
||||
- 浏览器安装:预装Chromium浏览器驱动
|
||||
- 应用部署:复制源码并暴露8000端口
|
||||
|
||||
**前端镜像构建流程**
|
||||
- 基础镜像:node:20-alpine
|
||||
- 依赖安装:使用npm ci安装生产依赖
|
||||
- 应用部署:复制源码并暴露3000端口
|
||||
|
||||
**章节来源**
|
||||
- [backend/Dockerfile:1-41](file://backend/Dockerfile#L1-L41)
|
||||
- [frontend/Dockerfile:1-15](file://frontend/Dockerfile#L1-L15)
|
||||
- [backend/requirements.txt:1-42](file://backend/requirements.txt#L1-L42)
|
||||
|
||||
### 服务编排配置
|
||||
Docker Compose定义了完整的微服务架构:
|
||||
|
||||
**数据库服务(db)**
|
||||
- 镜像:postgres:15-alpine
|
||||
- 端口映射:5432:5432
|
||||
- 健康检查:使用pg_isready检测数据库可用性
|
||||
- 数据持久化:挂载postgres_data卷
|
||||
|
||||
**缓存服务(redis)**
|
||||
- 镜像:redis:7-alpine
|
||||
- 端口映射:6379:6379
|
||||
- 健康检查:使用redis-cli ping检测
|
||||
- 数据持久化:挂载redis_data卷
|
||||
|
||||
**后端服务(backend)**
|
||||
- 构建:使用./backend目录作为构建上下文
|
||||
- 端口映射:8000:8000
|
||||
- 环境配置:加载.env文件
|
||||
- 依赖关系:等待db和redis健康检查通过
|
||||
- 命令:uvicorn启动FastAPI应用
|
||||
|
||||
**前端服务(frontend)**
|
||||
- 构建:使用./frontend目录作为构建上下文
|
||||
- 端口映射:3000:3000
|
||||
- 环境配置:加载.env文件
|
||||
- 依赖关系:依赖backend服务
|
||||
- 命令:npm run dev启动开发服务器
|
||||
|
||||
**章节来源**
|
||||
- [docker-compose.yml:1-71](file://docker-compose.yml#L1-L71)
|
||||
|
||||
### 环境配置管理
|
||||
项目使用Pydantic Settings进行环境变量管理,支持不同环境的配置分离:
|
||||
|
||||
**核心配置项**
|
||||
- 数据库连接:DATABASE_URL(默认指向db容器)
|
||||
- Redis连接:REDIS_URL(默认指向redis容器)
|
||||
- JWT配置:JWT_SECRET、JWT_EXPIRE_HOURS
|
||||
- LLM提供商:支持OpenAI、DeepSeek、通义千问等
|
||||
- CORS配置:CORS_ORIGINS允许跨域请求的来源列表
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/config.py:1-46](file://backend/app/config.py#L1-L46)
|
||||
|
||||
## 生产环境部署策略
|
||||
|
||||
### Nginx反向代理配置
|
||||
生产环境推荐使用Nginx作为反向代理和SSL终止:
|
||||
|
||||
**基础反向代理配置**
|
||||
```
|
||||
upstream backend {
|
||||
server backend:8000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name geo.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**SSL证书配置**
|
||||
- 使用Let's Encrypt自动签发免费SSL证书
|
||||
- 配置HTTP到HTTPS重定向
|
||||
- 启用现代TLS协议和加密套件
|
||||
|
||||
**负载均衡配置**
|
||||
- 使用Nginx内置负载均衡器
|
||||
- 配置健康检查和故障转移
|
||||
- 支持多实例后端部署
|
||||
|
||||
### 容器编排与扩展
|
||||
生产环境建议使用Docker Swarm或Kubernetes进行容器编排:
|
||||
|
||||
**服务扩展**
|
||||
- 后端API:根据负载情况动态扩展实例数量
|
||||
- 数据库:配置主从复制和只读副本
|
||||
- 缓存:使用Redis集群提高可用性
|
||||
|
||||
**存储管理**
|
||||
- 使用持久化存储卷确保数据安全
|
||||
- 配置定期备份策略
|
||||
- 实施数据同步和灾备方案
|
||||
|
||||
**章节来源**
|
||||
- [docker-compose.yml:36-66](file://docker-compose.yml#L36-L66)
|
||||
- [backend/app/config.py:12-14](file://backend/app/config.py#L12-L14)
|
||||
|
||||
## 监控与日志管理
|
||||
|
||||
### 健康检查与监控
|
||||
项目已内置基本的健康检查功能,生产环境需要增强监控能力:
|
||||
|
||||
**应用级监控**
|
||||
- 健康检查端点:/health
|
||||
- 性能指标:响应时间、错误率、吞吐量
|
||||
- 资源使用:CPU、内存、磁盘空间
|
||||
|
||||
**基础设施监控**
|
||||
- 数据库连接池监控
|
||||
- Redis连接状态监控
|
||||
- 文件系统空间监控
|
||||
|
||||
**日志管理方案**
|
||||
- 结构化日志输出到标准输出
|
||||
- 使用集中式日志收集系统(如ELK Stack)
|
||||
- 日志轮转和保留策略
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/main.py:97-100](file://backend/app/main.py#L97-L100)
|
||||
- [backend/app/middleware/logging_middleware.py:1-24](file://backend/app/middleware/logging_middleware.py#L1-L24)
|
||||
|
||||
### 错误追踪与告警
|
||||
**错误追踪配置**
|
||||
- 使用结构化日志记录异常信息
|
||||
- 集成错误追踪服务(如Sentry)
|
||||
- 设置错误阈值和告警规则
|
||||
|
||||
**性能监控**
|
||||
- 数据库查询性能监控
|
||||
- API响应时间监控
|
||||
- 缓存命中率监控
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/middleware/rate_limit.py:1-83](file://backend/app/middleware/rate_limit.py#L1-L83)
|
||||
|
||||
## 运维最佳实践
|
||||
|
||||
### 备份策略
|
||||
**数据库备份**
|
||||
- 定时全量备份:每周日凌晨2点执行
|
||||
- 增量备份:每小时执行
|
||||
- 多地备份:本地和云端同时保存
|
||||
|
||||
**配置备份**
|
||||
- 环境变量文件备份
|
||||
- Docker Compose配置备份
|
||||
- SSL证书备份
|
||||
|
||||
**恢复流程**
|
||||
- 制定详细的灾难恢复计划
|
||||
- 定期进行恢复演练
|
||||
- 建立快速恢复机制
|
||||
|
||||
### 安全配置
|
||||
**网络安全**
|
||||
- 最小权限原则
|
||||
- 网络隔离和防火墙配置
|
||||
- 入站/出站流量控制
|
||||
|
||||
**应用安全**
|
||||
- 定期更新依赖包
|
||||
- 密钥轮换策略
|
||||
- 安全审计日志
|
||||
|
||||
**数据安全**
|
||||
- 敏感数据加密存储
|
||||
- 数据传输加密
|
||||
- 访问权限控制
|
||||
|
||||
### 故障恢复
|
||||
**故障分类与响应**
|
||||
- 一级故障:系统完全不可用,立即启动应急预案
|
||||
- 二级故障:部分功能受影响,优先保证核心业务
|
||||
- 三级故障:性能下降但可正常运行,监控观察
|
||||
|
||||
**恢复流程**
|
||||
- 快速诊断和定位问题
|
||||
- 实施临时修复措施
|
||||
- 执行永久性修复
|
||||
- 验证系统恢复正常
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/config.py:14](file://backend/app/config.py#L14)
|
||||
- [backend/app/middleware/rate_limit.py:34-69](file://backend/app/middleware/rate_limit.py#L34-L69)
|
||||
|
||||
## CI/CD流水线配置
|
||||
|
||||
### 自动化部署流程
|
||||
**开发到生产的完整流程**
|
||||
1. 代码提交触发CI流水线
|
||||
2. 自动化测试(单元测试、集成测试)
|
||||
3. 代码质量检查
|
||||
4. 构建Docker镜像
|
||||
5. 推送镜像到镜像仓库
|
||||
6. 自动部署到测试环境
|
||||
7. 手动审批进入生产环境
|
||||
8. 部署到生产环境并监控
|
||||
|
||||
**流水线阶段配置**
|
||||
- 代码检出和分支管理
|
||||
- 依赖安装和缓存优化
|
||||
- 测试执行和覆盖率报告
|
||||
- 安全扫描和漏洞检测
|
||||
- 镜像构建和标签管理
|
||||
- 多环境部署策略
|
||||
|
||||
**回滚机制**
|
||||
- 支持一键回滚到上一个稳定版本
|
||||
- 自动化回滚测试
|
||||
- 回滚通知和审计
|
||||
|
||||
**章节来源**
|
||||
- [backend/requirements.txt:35-42](file://backend/requirements.txt#L35-L42)
|
||||
|
||||
### 部署检查清单
|
||||
**生产部署前检查**
|
||||
- 所有测试通过
|
||||
- 配置文件验证
|
||||
- 环境变量检查
|
||||
- 数据库迁移验证
|
||||
- 服务健康检查
|
||||
|
||||
**部署后验证**
|
||||
- 健康检查端点验证
|
||||
- 核心功能测试
|
||||
- 性能基准测试
|
||||
- 监控告警检查
|
||||
|
||||
## 依赖分析
|
||||
- 后端依赖
|
||||
|
|
@ -318,13 +585,13 @@ REQ --> PLT
|
|||
PKGJSON --> NODE
|
||||
```
|
||||
|
||||
图表来源
|
||||
- [backend/requirements.txt:1-35](file://backend/requirements.txt#L1-L35)
|
||||
- [frontend/package.json:1-40](file://frontend/package.json#L1-L40)
|
||||
**图表来源**
|
||||
- [backend/requirements.txt:1-42](file://backend/requirements.txt#L1-L42)
|
||||
- [frontend/package.json:1-45](file://frontend/package.json#L1-L45)
|
||||
|
||||
章节来源
|
||||
- [backend/requirements.txt:1-35](file://backend/requirements.txt#L1-L35)
|
||||
- [frontend/package.json:1-40](file://frontend/package.json#L1-L40)
|
||||
**章节来源**
|
||||
- [backend/requirements.txt:1-42](file://backend/requirements.txt#L1-L42)
|
||||
- [frontend/package.json:1-45](file://frontend/package.json#L1-L45)
|
||||
|
||||
## 性能考虑
|
||||
- 数据库
|
||||
|
|
@ -338,8 +605,6 @@ PKGJSON --> NODE
|
|||
- 容器
|
||||
- 后端与前端镜像体积较小,建议启用只读根文件系统与最小权限运行。
|
||||
|
||||
(本节为通用指导,不直接分析具体文件)
|
||||
|
||||
## 故障排查指南
|
||||
- 健康检查
|
||||
- 后端/CORS与健康检查端点可用于快速判断服务可用性。
|
||||
|
|
@ -352,14 +617,14 @@ PKGJSON --> NODE
|
|||
- 单元测试
|
||||
- 认证模块测试覆盖注册、登录与当前用户接口,可作为回归测试基线。
|
||||
|
||||
章节来源
|
||||
- [backend/app/main.py:45-48](file://backend/app/main.py#L45-L48)
|
||||
**章节来源**
|
||||
- [backend/app/main.py:97-100](file://backend/app/main.py#L97-L100)
|
||||
- [backend/alembic.ini:115-150](file://backend/alembic.ini#L115-L150)
|
||||
- [backend/alembic/env.py:64-89](file://backend/alembic/env.py#L64-L89)
|
||||
- [tests/test_auth.py:1-104](file://tests/test_auth.py#L1-L104)
|
||||
|
||||
## 结论
|
||||
本部署与运维文档基于仓库现有配置,给出了从容器化到生产部署的实施路径与最佳实践建议。建议在生产环境中补充Nginx反向代理与SSL、集中化日志与监控、完善的备份与灾难恢复策略,并通过CI/CD实现自动化部署与回滚。
|
||||
本部署与运维文档基于仓库现有配置,给出了从容器化到生产部署的实施路径与最佳实践建议。建议在生产环境中补充Nginx反向代理与SSL、集中化日志与监控、完善的备份与灾难恢复策略,并通过CI/CD实现自动化部署与回滚。文档涵盖了Docker容器化部署、生产环境策略、监控日志管理、运维最佳实践和CI/CD流水线等关键内容,为GEO项目的稳定运行提供了全面的技术保障。
|
||||
|
||||
## 附录
|
||||
|
||||
|
|
@ -374,7 +639,7 @@ PKGJSON --> NODE
|
|||
- 数据库:localhost:5432
|
||||
- Redis:localhost:6379
|
||||
|
||||
章节来源
|
||||
**章节来源**
|
||||
- [docker-compose.yml:1-71](file://docker-compose.yml#L1-L71)
|
||||
|
||||
### B. 生产环境部署策略
|
||||
|
|
@ -385,8 +650,6 @@ PKGJSON --> NODE
|
|||
- 环境隔离
|
||||
- 区分开发、测试、预发布与生产环境,严格管理环境变量与密钥。
|
||||
|
||||
(本节为通用指导,不直接分析具体文件)
|
||||
|
||||
### C. 监控与日志管理
|
||||
- 健康检查
|
||||
- 利用/CORS与健康检查端点,结合探针实现自动发现与告警。
|
||||
|
|
@ -395,8 +658,6 @@ PKGJSON --> NODE
|
|||
- 性能监控
|
||||
- 关注数据库慢查询、Redis命中率、API响应时间与错误率。
|
||||
|
||||
(本节为通用指导,不直接分析具体文件)
|
||||
|
||||
### D. 运维最佳实践
|
||||
- 备份策略
|
||||
- 数据库与Redis定期快照与归档,验证恢复流程。
|
||||
|
|
@ -405,14 +666,10 @@ PKGJSON --> NODE
|
|||
- 故障恢复
|
||||
- 制定回滚预案与演练计划,确保快速恢复。
|
||||
|
||||
(本节为通用指导,不直接分析具体文件)
|
||||
|
||||
### E. CI/CD流水线与自动化部署
|
||||
- 建议阶段
|
||||
- 代码提交触发测试(含认证接口测试),通过后构建镜像并推送制品库,随后部署到目标环境。
|
||||
- 回滚机制
|
||||
- 支持一键回滚至上一个稳定版本。
|
||||
- 配置管理
|
||||
- 环境变量与密钥通过安全渠道注入,避免硬编码。
|
||||
|
||||
(本节为通用指导,不直接分析具体文件)
|
||||
- 环境变量与密钥通过安全渠道注入,避免硬编码。
|
||||
|
|
@ -7,18 +7,48 @@
|
|||
- [backend/app/api/queries.py](file://backend/app/api/queries.py)
|
||||
- [backend/app/api/citations.py](file://backend/app/api/citations.py)
|
||||
- [backend/app/api/reports.py](file://backend/app/api/reports.py)
|
||||
- [backend/app/api/lifecycle.py](file://backend/app/api/lifecycle.py)
|
||||
- [backend/app/api/knowledge.py](file://backend/app/api/knowledge.py)
|
||||
- [backend/app/services/auth.py](file://backend/app/services/auth.py)
|
||||
- [backend/app/services/query.py](file://backend/app/services/query.py)
|
||||
- [backend/app/services/citation.py](file://backend/app/services/citation.py)
|
||||
- [backend/app/services/analytics/tracker.py](file://backend/app/services/analytics/tracker.py)
|
||||
- [backend/app/services/analytics/insights.py](file://backend/app/services/analytics/insights.py)
|
||||
- [backend/app/services/knowledge/rag_service.py](file://backend/app/services/knowledge/rag_service.py)
|
||||
- [backend/app/agent_framework/agents/__init__.py](file://backend/app/agent_framework/agents/__init__.py)
|
||||
- [backend/app/agent_framework/agents/citation_detector.py](file://backend/app/agent_framework/agents/citation_detector.py)
|
||||
- [backend/app/agent_framework/agents/content_generator_agent.py](file://backend/app/agent_framework/agents/content_generator_agent.py)
|
||||
- [backend/app/agent_framework/agents/deai_agent.py](file://backend/app/agent_framework/agents/deai_agent.py)
|
||||
- [backend/app/agent_framework/agents/geo_optimizer_agent.py](file://backend/app/agent_framework/agents/geo_optimizer_agent.py)
|
||||
- [backend/app/agent_framework/pipeline/engine.py](file://backend/app/agent_framework/pipeline/engine.py)
|
||||
- [backend/app/agent_framework/pipeline/loader.py](file://backend/app/agent_framework/pipeline/loader.py)
|
||||
- [backend/app/agent_framework/dispatcher.py](file://backend/app/agent_framework/dispatcher.py)
|
||||
- [backend/app/models/query.py](file://backend/app/models/query.py)
|
||||
- [backend/app/models/citation_record.py](file://backend/app/models/citation_record.py)
|
||||
- [backend/app/workers/scheduler.py](file://backend/app/workers/scheduler.py)
|
||||
- [backend/app/workers/citation_engine.py](file://backend/app/workers/citation_engine.py)
|
||||
- [backend/app/workers/platforms/base.py](file://backend/app/workers/platforms/base.py)
|
||||
- [backend/app/models/lifecycle.py](file://backend/app/models/lifecycle.py)
|
||||
- [backend/app/models/knowledge.py](file://backend/app/models/knowledge.py)
|
||||
- [backend/alembic/versions/d4f6g8h0ab23_add_geo_lifecycle_tables.py](file://backend/alembic/versions/d4f6g8h0ab23_add_geo_lifecycle_tables.py)
|
||||
- [backend/workers/scheduler.py](file://backend/workers/scheduler.py)
|
||||
- [backend/workers/citation_engine.py](file://backend/workers/citation_engine.py)
|
||||
- [backend/workers/platforms/base.py](file://backend/workers/platforms/base.py)
|
||||
- [frontend/app/(dashboard)/dashboard/page.tsx](file://frontend/app/(dashboard)/dashboard/page.tsx)
|
||||
- [frontend/app/(dashboard)/dashboard/admin/page.tsx](file://frontend/app/(dashboard)/dashboard/admin/page.tsx)
|
||||
- [frontend/app/(dashboard)/dashboard/agents/page.tsx](file://frontend/app/(dashboard)/dashboard/agents/page.tsx)
|
||||
- [frontend/app/(dashboard)/dashboard/analytics/page.tsx](file://frontend/app/(dashboard)/dashboard/analytics/page.tsx)
|
||||
- [frontend/app/(dashboard)/dashboard/lifecycle/page.tsx](file://frontend/app/(dashboard)/dashboard/lifecycle/page.tsx)
|
||||
- [frontend/app/(dashboard)/dashboard/knowledge/page.tsx](file://frontend/app/(dashboard)/dashboard/knowledge/page.tsx)
|
||||
- [frontend/components/charts/trend-chart.tsx](file://frontend/components/charts/trend-chart.tsx)
|
||||
- [frontend/lib/api/lifecycle.ts](file://frontend/lib/api/lifecycle.ts)
|
||||
</cite>
|
||||
|
||||
## 更新摘要
|
||||
**变更内容**
|
||||
- 新增AI代理框架模块,包含引用检测、内容生成、去AI化、GEO优化等智能代理
|
||||
- 新增业务生命周期管理系统,支持品牌项目全生命周期管理
|
||||
- 新增分析监控系统,提供发布追踪、效果分析和智能洞察生成功能
|
||||
- 新增知识库服务模块,支持RAG检索、文档管理和知识库CRUD操作
|
||||
- 扩展前端界面,新增代理管理、分析监控、生命周期管理和知识库管理页面
|
||||
|
||||
## 目录
|
||||
1. [简介](#简介)
|
||||
2. [项目结构](#项目结构)
|
||||
|
|
@ -36,11 +66,15 @@
|
|||
- 用户认证与权限管理:基于邮箱/密码注册登录、JWT 访问令牌签发与校验、会话保护接口。
|
||||
- 智能查询任务管理:查询词创建、更新、删除、分页查询;按日/周/月频率自动调度;手动触发即时查询。
|
||||
- 品牌引用检测引擎:多阶段匹配(精确/别名/模糊)、置信度评分、竞争品牌识别、上下文片段抽取。
|
||||
- 多 AI 平台数据集成:抽象适配器模式对接不同大模型平台,统一查询与结果处理。
|
||||
- 多AI平台数据集成:抽象适配器模式对接不同大模型平台,统一查询与结果处理。
|
||||
- AI代理框架:基于Pipeline的多代理协作系统,支持引用检测、内容生成、去AI化、GEO优化等智能任务编排。
|
||||
- 业务生命周期管理:品牌项目全生命周期管理,包含5个阶段的项目推进和状态跟踪。
|
||||
- 分析监控系统:发布效果追踪、内容表现分析、智能洞察生成和性能监控。
|
||||
- 知识库服务:RAG检索、文档管理、知识库CRUD操作和搜索日志记录。
|
||||
- 数据分析与可视化:统计指标(总查询/引用次数、引用率、平均位置)、平台对比、30 天趋势折线图。
|
||||
- 报告导出:CSV 导出引用记录,便于离线分析与归档。
|
||||
|
||||
这些功能围绕“查询—检测—统计—可视—导出”的闭环展开,既满足管理员对系统运行与任务调度的掌控,也服务于研究人员对品牌监测与趋势分析的需求。
|
||||
这些功能围绕"查询—检测—智能代理—生命周期—分析监控—知识库—统计—可视—导出"的完整生态展开,既满足管理员对系统运行与任务调度的掌控,也服务于研究人员对品牌监测与趋势分析的需求。
|
||||
|
||||
## 项目结构
|
||||
后端采用 FastAPI + SQLAlchemy 异步 ORM,按领域划分 API、服务、模型与工作器;前端使用 Next.js + React,通过自定义 API 客户端与后端交互;数据库为 PostgreSQL。
|
||||
|
|
@ -49,21 +83,37 @@
|
|||
graph TB
|
||||
subgraph "后端"
|
||||
A["FastAPI 应用<br/>app/main.py"]
|
||||
B["API 层<br/>auth/queries/citations/reports"]
|
||||
C["服务层<br/>auth/query/citation"]
|
||||
D["模型层<br/>Query/CitationRecord"]
|
||||
B["API 层<br/>auth/queries/citations/reports/lifecycle/knowledge"]
|
||||
C["服务层<br/>auth/query/citation/analytics/knowledge"]
|
||||
D["模型层<br/>Query/CitationRecord/Lifecycle/Knowledge"]
|
||||
E["工作器<br/>Scheduler/CitationEngine/Platforms"]
|
||||
F["AI代理框架<br/>Agents/Pipeline/Dispatcher"]
|
||||
G["分析监控<br/>Tracker/Insights"]
|
||||
H["知识库服务<br/>RAGService/Chunker/Embedder"]
|
||||
end
|
||||
subgraph "前端"
|
||||
F["仪表盘页面<br/>dashboard/page.tsx"]
|
||||
G["趋势图表组件<br/>trend-chart.tsx"]
|
||||
I["仪表盘页面<br/>dashboard/page.tsx"]
|
||||
J["管理员页面<br/>admin/page.tsx"]
|
||||
K["代理管理页面<br/>agents/page.tsx"]
|
||||
L["分析监控页面<br/>analytics/page.tsx"]
|
||||
M["生命周期页面<br/>lifecycle/page.tsx"]
|
||||
N["知识库页面<br/>knowledge/page.tsx"]
|
||||
O["趋势图表组件<br/>trend-chart.tsx"]
|
||||
end
|
||||
A --> B
|
||||
B --> C
|
||||
C --> D
|
||||
C --> E
|
||||
F --> G
|
||||
F --> B
|
||||
C --> F
|
||||
C --> G
|
||||
C --> H
|
||||
I --> O
|
||||
I --> B
|
||||
J --> B
|
||||
K --> F
|
||||
L --> G
|
||||
M --> D
|
||||
N --> H
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
|
|
@ -72,14 +122,29 @@ F --> B
|
|||
- [backend/app/api/queries.py:1-86](file://backend/app/api/queries.py#L1-L86)
|
||||
- [backend/app/api/citations.py:1-78](file://backend/app/api/citations.py#L1-L78)
|
||||
- [backend/app/api/reports.py:1-47](file://backend/app/api/reports.py#L1-L47)
|
||||
- [backend/app/api/lifecycle.py:1-297](file://backend/app/api/lifecycle.py#L1-L297)
|
||||
- [backend/app/api/knowledge.py:1-502](file://backend/app/api/knowledge.py#L1-L502)
|
||||
- [backend/app/services/auth.py:1-69](file://backend/app/services/auth.py#L1-L69)
|
||||
- [backend/app/services/query.py:1-130](file://backend/app/services/query.py#L1-L130)
|
||||
- [backend/app/services/citation.py:1-269](file://backend/app/services/citation.py#L1-L269)
|
||||
- [backend/app/services/analytics/tracker.py:1-230](file://backend/app/services/analytics/tracker.py#L1-L230)
|
||||
- [backend/app/services/analytics/insights.py:1-313](file://backend/app/services/analytics/insights.py#L1-L313)
|
||||
- [backend/app/services/knowledge/rag_service.py:1-43](file://backend/app/services/knowledge/rag_service.py#L1-L43)
|
||||
- [backend/app/agent_framework/agents/__init__.py:1-14](file://backend/app/agent_framework/agents/__init__.py#L1-L14)
|
||||
- [backend/app/agent_framework/pipeline/engine.py:1-536](file://backend/app/agent_framework/pipeline/engine.py#L1-L536)
|
||||
- [backend/app/agent_framework/dispatcher.py:1-367](file://backend/app/agent_framework/dispatcher.py#L1-L367)
|
||||
- [backend/app/models/query.py:1-55](file://backend/app/models/query.py#L1-L55)
|
||||
- [backend/app/models/citation_record.py:1-42](file://backend/app/models/citation_record.py#L1-L42)
|
||||
- [backend/app/workers/scheduler.py:1-95](file://backend/app/workers/scheduler.py#L1-L95)
|
||||
- [backend/app/workers/citation_engine.py:1-309](file://backend/app/workers/citation_engine.py#L1-L309)
|
||||
- [backend/app/models/lifecycle.py:1-91](file://backend/app/models/lifecycle.py#L1-L91)
|
||||
- [backend/app/models/knowledge.py:1-43](file://backend/app/models/knowledge.py#L1-L43)
|
||||
- [backend/workers/scheduler.py:1-95](file://backend/workers/scheduler.py#L1-L95)
|
||||
- [backend/workers/citation_engine.py:1-309](file://backend/workers/citation_engine.py#L1-L309)
|
||||
- [frontend/app/(dashboard)/dashboard/page.tsx:1-156](file://frontend/app/(dashboard)/dashboard/page.tsx#L1-L156)
|
||||
- [frontend/app/(dashboard)/dashboard/admin/page.tsx:1-200](file://frontend/app/(dashboard)/dashboard/admin/page.tsx#L1-L200)
|
||||
- [frontend/app/(dashboard)/dashboard/agents/page.tsx:1-200](file://frontend/app/(dashboard)/dashboard/agents/page.tsx#L1-L200)
|
||||
- [frontend/app/(dashboard)/dashboard/analytics/page.tsx:1-200](file://frontend/app/(dashboard)/dashboard/analytics/page.tsx#L1-L200)
|
||||
- [frontend/app/(dashboard)/dashboard/lifecycle/page.tsx:1-200](file://frontend/app/(dashboard)/dashboard/lifecycle/page.tsx#L1-L200)
|
||||
- [frontend/app/(dashboard)/dashboard/knowledge/page.tsx:1-200](file://frontend/app/(dashboard)/dashboard/knowledge/page.tsx#L1-L200)
|
||||
- [frontend/components/charts/trend-chart.tsx:1-60](file://frontend/components/charts/trend-chart.tsx#L1-L60)
|
||||
|
||||
**章节来源**
|
||||
|
|
@ -88,7 +153,7 @@ F --> B
|
|||
## 核心组件
|
||||
- 认证与权限
|
||||
- 注册/登录:邮箱唯一性校验、密码哈希、JWT 签发;当前用户信息读取。
|
||||
- 权限边界:所有业务接口均通过当前用户上下文进行资源归属校验(查询、引用、统计、导出)。
|
||||
- 权限边界:所有业务接口均通过当前用户上下文进行资源归属校验(查询、引用、统计、导出、生命周期、知识库)。
|
||||
- 查询任务管理
|
||||
- 查询 CRUD:分页列表、创建、读取、更新、删除;创建时校验用户配额上限;更新时可重算下次执行时间。
|
||||
- 自动调度:每小时扫描到期查询,自动触发引用检测;手动触发即时查询。
|
||||
|
|
@ -97,7 +162,23 @@ F --> B
|
|||
- 竞争品牌:基于预设行业品牌库识别竞品。
|
||||
- 结果持久化:生成引用记录,包含平台、是否引用、位置、文本、竞品集合、原始响应。
|
||||
- 多 AI 平台集成
|
||||
- 适配器基类定义统一接口;内置“文心”“Kimi”适配器;未来可扩展更多平台。
|
||||
- 适配器基类定义统一接口;内置"文心""Kimi"适配器;未来可扩展更多平台。
|
||||
- AI代理框架
|
||||
- 代理实现:引用检测代理、内容生成代理、去AI化代理、GEO优化代理。
|
||||
- Pipeline编排:基于YAML的多阶段任务编排,支持变量解析、依赖管理、条件执行。
|
||||
- 任务分发:Redis队列驱动的任务分发器,支持任务状态跟踪和重试机制。
|
||||
- 业务生命周期管理
|
||||
- 项目管理:品牌基建、内容生产、AI适配优化、权威信号构建、持续运维5个阶段。
|
||||
- 状态跟踪:项目进度、阶段状态、完成率统计和时间轴事件记录。
|
||||
- 快速启动:一键创建项目并初始化5个阶段。
|
||||
- 分析监控系统
|
||||
- 发布追踪:内容发布记录、效果指标快照和平台分布统计。
|
||||
- 性能分析:内容表现排行、单篇内容深度分析和历史趋势追踪。
|
||||
- 智能洞察:基于LLM的自动化洞察生成和优化建议。
|
||||
- 知识库服务
|
||||
- RAG检索:文档分块、向量化嵌入和混合检索。
|
||||
- 文档管理:URL抓取、文本上传和分块预览。
|
||||
- 知识库CRUD:多租户知识库管理和搜索日志记录。
|
||||
- 数据分析与可视化
|
||||
- 统计聚合:总查询/引用数、引用率、平均位置、按平台汇总、30 天趋势。
|
||||
- 前端展示:仪表盘卡片与趋势折线图组件。
|
||||
|
|
@ -113,12 +194,21 @@ F --> B
|
|||
- [backend/app/services/citation.py:1-269](file://backend/app/services/citation.py#L1-L269)
|
||||
- [backend/app/workers/citation_engine.py:148-309](file://backend/app/workers/citation_engine.py#L148-L309)
|
||||
- [backend/app/workers/platforms/base.py:1-18](file://backend/app/workers/platforms/base.py#L1-L18)
|
||||
- [backend/app/agent_framework/agents/__init__.py:1-14](file://backend/app/agent_framework/agents/__init__.py#L1-L14)
|
||||
- [backend/app/agent_framework/pipeline/engine.py:1-536](file://backend/app/agent_framework/pipeline/engine.py#L1-L536)
|
||||
- [backend/app/agent_framework/dispatcher.py:1-367](file://backend/app/agent_framework/dispatcher.py#L1-L367)
|
||||
- [backend/app/api/lifecycle.py:1-297](file://backend/app/api/lifecycle.py#L1-L297)
|
||||
- [backend/app/models/lifecycle.py:1-91](file://backend/app/models/lifecycle.py#L1-L91)
|
||||
- [backend/app/services/analytics/tracker.py:1-230](file://backend/app/services/analytics/tracker.py#L1-L230)
|
||||
- [backend/app/services/analytics/insights.py:1-313](file://backend/app/services/analytics/insights.py#L1-L313)
|
||||
- [backend/app/api/knowledge.py:1-502](file://backend/app/api/knowledge.py#L1-L502)
|
||||
- [backend/app/services/knowledge/rag_service.py:1-43](file://backend/app/services/knowledge/rag_service.py#L1-L43)
|
||||
- [frontend/app/(dashboard)/dashboard/page.tsx:1-156](file://frontend/app/(dashboard)/dashboard/page.tsx#L1-L156)
|
||||
- [frontend/components/charts/trend-chart.tsx:1-60](file://frontend/components/charts/trend-chart.tsx#L1-L60)
|
||||
- [backend/app/api/reports.py:1-47](file://backend/app/api/reports.py#L1-L47)
|
||||
|
||||
## 架构总览
|
||||
下图展示从用户请求到数据落库与可视化的整体流程,以及定时调度与即时查询的协同机制。
|
||||
下图展示从用户请求到数据落库与可视化的整体流程,以及定时调度、智能代理编排、生命周期管理和分析监控的协同机制。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
|
|
@ -130,10 +220,12 @@ participant DB as "数据库"
|
|||
participant W as "工作器"
|
||||
participant CE as "引用检测引擎"
|
||||
participant P as "AI平台适配器"
|
||||
U->>FE : 登录/访问仪表盘
|
||||
FE->>API : 获取统计/查询/引用/导出
|
||||
participant AG as "AI代理框架"
|
||||
participant PL as "Pipeline引擎"
|
||||
U->>FE : 登录/访问各功能页面
|
||||
FE->>API : 获取统计/查询/引用/导出/生命周期/知识库
|
||||
API->>S : 参数校验与业务处理
|
||||
S->>DB : 读写查询/引用/任务
|
||||
S->>DB : 读写查询/引用/任务/项目/知识库
|
||||
Note over S,DB : 权限校验:仅允许访问本人资源
|
||||
API->>W : 触发/查询任务
|
||||
W->>CE : 执行查询
|
||||
|
|
@ -141,6 +233,10 @@ CE->>P : 平台查询
|
|||
P-->>CE : 原始响应
|
||||
CE->>S : 写入引用记录
|
||||
S->>DB : 持久化
|
||||
S->>AG : 分发代理任务
|
||||
AG->>PL : 执行Pipeline编排
|
||||
PL->>AG : 代理执行结果
|
||||
S->>DB : 更新代理状态
|
||||
DB-->>S : 成功
|
||||
S-->>API : 结果
|
||||
API-->>FE : 响应数据/流式下载
|
||||
|
|
@ -149,9 +245,11 @@ API-->>FE : 响应数据/流式下载
|
|||
**图表来源**
|
||||
- [backend/app/main.py:13-21](file://backend/app/main.py#L13-L21)
|
||||
- [backend/app/api/citations.py:59-77](file://backend/app/api/citations.py#L59-L77)
|
||||
- [backend/app/workers/scheduler.py:51-84](file://backend/app/workers/scheduler.py#L51-L84)
|
||||
- [backend/app/workers/citation_engine.py:159-234](file://backend/app/workers/citation_engine.py#L159-L234)
|
||||
- [backend/app/workers/scheduler.py:51-84](file://backend/workers/scheduler.py#L51-L84)
|
||||
- [backend/app/workers/citation_engine.py:159-234](file://backend/workers/citation_engine.py#L159-L234)
|
||||
- [backend/app/services/citation.py:204-234](file://backend/app/services/citation.py#L204-L234)
|
||||
- [backend/app/agent_framework/dispatcher.py:54-117](file://backend/app/agent_framework/dispatcher.py#L54-L117)
|
||||
- [backend/app/agent_framework/pipeline/engine.py:51-176](file://backend/app/agent_framework/pipeline/engine.py#L51-L176)
|
||||
|
||||
## 详细组件分析
|
||||
|
||||
|
|
@ -202,7 +300,7 @@ API-->>U : {access_token,user}
|
|||
- 减少人工干预:按计划自动抓取与检测,提升研究效率。
|
||||
- 灵活控制:支持日/周/月频率与手动触发,兼顾实时性与成本。
|
||||
- 典型场景
|
||||
- 研究员创建查询(关键词、目标品牌、平台、频率),系统按时自动执行;也可随时“立即执行”。
|
||||
- 研究员创建查询(关键词、目标品牌、平台、频率),系统按时自动执行;也可随时"立即执行"。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
|
|
@ -220,13 +318,13 @@ UpdateTime --> Done(["完成一轮周期"])
|
|||
|
||||
**图表来源**
|
||||
- [backend/app/services/query.py:45-81](file://backend/app/services/query.py#L45-L81)
|
||||
- [backend/app/workers/scheduler.py:51-84](file://backend/app/workers/scheduler.py#L51-L84)
|
||||
- [backend/app/workers/scheduler.py:51-84](file://backend/workers/scheduler.py#L51-L84)
|
||||
- [backend/app/workers/citation_engine.py:291-300](file://backend/app/workers/citation_engine.py#L291-L300)
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/api/queries.py:1-86](file://backend/app/api/queries.py#L1-L86)
|
||||
- [backend/app/services/query.py:1-130](file://backend/app/services/query.py#L1-L130)
|
||||
- [backend/app/workers/scheduler.py:1-95](file://backend/app/workers/scheduler.py#L1-L95)
|
||||
- [backend/app/workers/scheduler.py:1-95](file://backend/workers/scheduler.py#L1-L95)
|
||||
|
||||
### 品牌引用检测引擎
|
||||
- 功能要点
|
||||
|
|
@ -237,7 +335,7 @@ UpdateTime --> Done(["完成一轮周期"])
|
|||
- 置信度评分:帮助判断引用可靠性;模糊匹配提供兜底发现。
|
||||
- 上下文定位:快速定位品牌在原文中的首次出现段落,便于人工复核。
|
||||
- 典型场景
|
||||
- 文本中提及“XX品牌”,匹配器判定为“别名命中”,置信度0.9,并返回首次出现段落片段。
|
||||
- 文本中提及"XX品牌",匹配器判定为"别名命中",置信度0.9,并返回首次出现段落片段。
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
|
|
@ -272,12 +370,12 @@ CitationEngine --> BasePlatformAdapter : "委托查询"
|
|||
### 多 AI 平台数据集成
|
||||
- 功能要点
|
||||
- 适配器基类定义统一接口(平台名、URL、查询方法)。
|
||||
- 内置“文心”“Kimi”适配器;引擎按查询配置的平台列表逐一执行。
|
||||
- 内置"文心""Kimi"适配器;引擎按查询配置的平台列表逐一执行。
|
||||
- 核心价值
|
||||
- 解耦平台差异:统一调用入口,便于扩展更多平台。
|
||||
- 可观测性:每个平台独立任务状态(pending/running/success/failed)。
|
||||
- 典型场景
|
||||
- 查询配置包含“wenxin,kimi”,引擎为两者分别创建任务并行执行,最终汇总结果。
|
||||
- 查询配置包含"wenxin,kimi",引擎为两者分别创建任务并行执行,最终汇总结果。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
|
|
@ -303,6 +401,204 @@ end
|
|||
- [backend/app/workers/platforms/base.py:1-18](file://backend/app/workers/platforms/base.py#L1-L18)
|
||||
- [backend/app/workers/citation_engine.py:151-157](file://backend/app/workers/citation_engine.py#L151-L157)
|
||||
|
||||
### AI代理框架
|
||||
- 功能要点
|
||||
- 代理实现:CitationDetectorAgent、ContentGeneratorAgent、DeAIAgent、GEOOptimizerAgent。
|
||||
- Pipeline编排:支持变量解析、依赖管理、条件执行、重试机制。
|
||||
- 任务分发:Redis队列驱动,支持任务状态跟踪、进度上报和回调机制。
|
||||
- 核心价值
|
||||
- 智能化编排:将复杂的多步骤任务分解为可组合的代理单元。
|
||||
- 可扩展性:新的代理类型可通过简单接口接入框架。
|
||||
- 可观测性:完整的任务生命周期跟踪和性能监控。
|
||||
- 典型场景
|
||||
- 内容生产Pipeline:主题选择 → 文章生成 → 去AI化 → GEO优化 → 发布。
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class BaseAgent {
|
||||
<<abstract>>
|
||||
+execute(task) TaskResult
|
||||
+report_progress(task_id, progress, message)
|
||||
+get_capabilities() AgentCapability
|
||||
}
|
||||
class CitationDetectorAgent {
|
||||
+execute_full_detect(task) dict
|
||||
+execute_single_detect(task) dict
|
||||
}
|
||||
class ContentGeneratorAgent {
|
||||
+_generate_topics(task) dict
|
||||
+_generate_article(task) dict
|
||||
}
|
||||
class DeAIAgent {
|
||||
+_process(task) dict
|
||||
}
|
||||
class GEOOptimizerAgent {
|
||||
+_optimize(task) dict
|
||||
}
|
||||
class PipelineEngine {
|
||||
+execute(pipeline, context) PipelineResult
|
||||
+_execute_stage(stage, ctx, stages_ctx) StageResult
|
||||
}
|
||||
class TaskDispatcher {
|
||||
+dispatch(task, org_id, user_id) str
|
||||
+get_task_status(task_id) dict
|
||||
+handle_result(result)
|
||||
}
|
||||
BaseAgent <|-- CitationDetectorAgent
|
||||
BaseAgent <|-- ContentGeneratorAgent
|
||||
BaseAgent <|-- DeAIAgent
|
||||
BaseAgent <|-- GEOOptimizerAgent
|
||||
PipelineEngine --> BaseAgent : "编排执行"
|
||||
TaskDispatcher --> BaseAgent : "任务分发"
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [backend/app/agent_framework/agents/citation_detector.py:24-218](file://backend/app/agent_framework/agents/citation_detector.py#L24-L218)
|
||||
- [backend/app/agent_framework/agents/content_generator_agent.py:23-299](file://backend/app/agent_framework/agents/content_generator_agent.py#L23-L299)
|
||||
- [backend/app/agent_framework/agents/deai_agent.py:21-156](file://backend/app/agent_framework/agents/deai_agent.py#L21-L156)
|
||||
- [backend/app/agent_framework/agents/geo_optimizer_agent.py:23-198](file://backend/app/agent_framework/agents/geo_optimizer_agent.py#L23-L198)
|
||||
- [backend/app/agent_framework/pipeline/engine.py:31-536](file://backend/app/agent_framework/pipeline/engine.py#L31-L536)
|
||||
- [backend/app/agent_framework/dispatcher.py:32-367](file://backend/app/agent_framework/dispatcher.py#L32-L367)
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/agent_framework/agents/__init__.py:1-14](file://backend/app/agent_framework/agents/__init__.py#L1-L14)
|
||||
- [backend/app/agent_framework/agents/citation_detector.py:1-218](file://backend/app/agent_framework/agents/citation_detector.py#L1-L218)
|
||||
- [backend/app/agent_framework/agents/content_generator_agent.py:1-299](file://backend/app/agent_framework/agents/content_generator_agent.py#L1-L299)
|
||||
- [backend/app/agent_framework/agents/deai_agent.py:1-156](file://backend/app/agent_framework/agents/deai_agent.py#L1-L156)
|
||||
- [backend/app/agent_framework/agents/geo_optimizer_agent.py:1-198](file://backend/app/agent_framework/agents/geo_optimizer_agent.py#L1-L198)
|
||||
- [backend/app/agent_framework/pipeline/engine.py:1-536](file://backend/app/agent_framework/pipeline/engine.py#L1-L536)
|
||||
- [backend/app/agent_framework/dispatcher.py:1-367](file://backend/app/agent_framework/dispatcher.py#L1-L367)
|
||||
|
||||
### 业务生命周期管理
|
||||
- 功能要点
|
||||
- 项目创建:快速启动功能,自动生成5个阶段的项目。
|
||||
- 阶段管理:品牌基建、内容生产、AI适配优化、权威信号构建、持续运维。
|
||||
- 状态跟踪:项目进度、阶段状态、完成率统计和时间轴事件记录。
|
||||
- 统计分析:组织级别的项目统计和阶段分布。
|
||||
- 核心价值
|
||||
- 全生命周期视角:从品牌建设到持续运营的完整流程管理。
|
||||
- 可视化跟踪:阶段进度卡片和时间轴展示项目进展。
|
||||
- 数据驱动决策:基于统计数据的项目管理和资源配置。
|
||||
- 典型场景
|
||||
- 管理员创建品牌项目 → 各阶段负责人推进 → 实时查看进度 → 生成项目报告。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin as "管理员"
|
||||
participant API as "生命周期API"
|
||||
participant S as "生命周期服务"
|
||||
participant DB as "数据库"
|
||||
Admin->>API : POST /api/v1/lifecycle/projects/quick-start
|
||||
API->>S : 创建项目和5个阶段
|
||||
S->>DB : 插入LifecycleProject和ProjectStage
|
||||
DB-->>S : 成功
|
||||
S-->>API : 返回项目详情
|
||||
API-->>Admin : 项目创建成功
|
||||
Admin->>API : GET /api/v1/lifecycle/projects/{id}/timeline
|
||||
API->>S : 获取时间轴事件
|
||||
S->>DB : 查询项目和阶段
|
||||
DB-->>S : 事件列表
|
||||
S-->>API : 时间轴数据
|
||||
API-->>Admin : 渲染时间轴
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [backend/app/api/lifecycle.py:190-230](file://backend/app/api/lifecycle.py#L190-L230)
|
||||
- [backend/app/api/lifecycle.py:138-187](file://backend/app/api/lifecycle.py#L138-L187)
|
||||
- [backend/app/models/lifecycle.py:12-91](file://backend/app/models/lifecycle.py#L12-L91)
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/api/lifecycle.py:1-297](file://backend/app/api/lifecycle.py#L1-L297)
|
||||
- [backend/app/models/lifecycle.py:1-91](file://backend/app/models/lifecycle.py#L1-L91)
|
||||
- [frontend/lib/api/lifecycle.ts:53-95](file://frontend/lib/api/lifecycle.ts#L53-L95)
|
||||
|
||||
### 分析监控系统
|
||||
- 功能要点
|
||||
- 发布追踪:记录内容发布事件、更新效果指标和生成快照。
|
||||
- 性能分析:内容表现排行、单篇内容深度分析和历史趋势。
|
||||
- 智能洞察:基于LLM的自动化洞察生成,包含趋势、异常、机会和建议。
|
||||
- 统计概览:组织级别的发布统计、平台分布和互动率分析。
|
||||
- 核心价值
|
||||
- 数据驱动优化:基于真实效果数据的自动化洞察和建议。
|
||||
- 全面监控:从发布到效果的全流程数据追踪。
|
||||
- 智能辅助:AI驱动的分析和优化建议,提升内容质量。
|
||||
- 典型场景
|
||||
- 内容发布后自动记录效果 → 定期生成洞察报告 → 基于建议优化内容策略。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant CMS as "内容管理系统"
|
||||
participant API as "分析API"
|
||||
participant S as "分析服务"
|
||||
participant DB as "数据库"
|
||||
CMS->>API : POST /api/v1/analytics/publish
|
||||
API->>S : 记录发布事件
|
||||
S->>DB : 插入PublishRecord
|
||||
DB-->>S : 成功
|
||||
S->>DB : 插入ContentMetrics快照
|
||||
CMS->>API : GET /api/v1/analytics/insights
|
||||
API->>S : 生成洞察
|
||||
S->>S : 调用LLM分析数据
|
||||
S->>DB : 插入OptimizationInsight
|
||||
DB-->>S : 成功
|
||||
S-->>API : 返回洞察结果
|
||||
API-->>CMS : 洞察报告
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [backend/app/services/analytics/tracker.py:16-51](file://backend/app/services/analytics/tracker.py#L16-L51)
|
||||
- [backend/app/services/analytics/insights.py:40-103](file://backend/app/services/analytics/insights.py#L40-L103)
|
||||
- [backend/app/services/analytics/tracker.py:53-128](file://backend/app/services/analytics/tracker.py#L53-L128)
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/services/analytics/tracker.py:1-230](file://backend/app/services/analytics/tracker.py#L1-L230)
|
||||
- [backend/app/services/analytics/insights.py:1-313](file://backend/app/services/analytics/insights.py#L1-L313)
|
||||
|
||||
### 知识库服务
|
||||
- 功能要点
|
||||
- RAG检索:文档分块、向量化嵌入和混合检索,支持多知识库查询。
|
||||
- 文档管理:支持URL抓取和文本上传,自动计算内容哈希和分块数量。
|
||||
- 知识库CRUD:多租户知识库管理,支持文档级联删除和统计更新。
|
||||
- 搜索日志:记录搜索查询、结果数量和延迟时间。
|
||||
- 核心价值
|
||||
- 智能检索:基于向量和关键词的混合检索,提升相关性。
|
||||
- 知识管理:结构化的知识库管理和版本控制。
|
||||
- 效率提升:自动化的文档处理和检索,减少人工维护成本。
|
||||
- 典型场景
|
||||
- 研究员上传行业报告 → 系统自动分块嵌入 → 搜索相关知识 → 生成内容。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as "用户"
|
||||
participant API as "知识库API"
|
||||
participant S as "RAG服务"
|
||||
participant DB as "数据库"
|
||||
User->>API : POST /api/v1/knowledge/bases/{kb_id}/documents
|
||||
API->>S : 上传文档
|
||||
S->>DB : 插入KnowledgeDocument
|
||||
S->>S : 分块 → 向量化 → 存储
|
||||
S->>DB : 插入KnowledgeChunk
|
||||
DB-->>S : 成功
|
||||
S-->>API : 返回文档详情
|
||||
API-->>User : 上传完成
|
||||
User->>API : POST /api/v1/knowledge/search
|
||||
API->>S : 执行RAG检索
|
||||
S->>DB : 查询向量相似度
|
||||
DB-->>S : 相关文档
|
||||
S-->>API : 返回检索结果
|
||||
API-->>User : 检索结果
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [backend/app/api/knowledge.py:217-293](file://backend/app/api/knowledge.py#L217-L293)
|
||||
- [backend/app/api/knowledge.py:424-501](file://backend/app/api/knowledge.py#L424-L501)
|
||||
- [backend/app/services/knowledge/rag_service.py:33-43](file://backend/app/services/knowledge/rag_service.py#L33-L43)
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/api/knowledge.py:1-502](file://backend/app/api/knowledge.py#L1-L502)
|
||||
- [backend/app/services/knowledge/rag_service.py:1-43](file://backend/app/services/knowledge/rag_service.py#L1-L43)
|
||||
- [backend/app/models/knowledge.py:1-43](file://backend/app/models/knowledge.py#L1-L43)
|
||||
|
||||
### 数据分析与可视化
|
||||
- 功能要点
|
||||
- 统计接口:总查询/引用数、引用率、平均位置、按平台汇总、30 天趋势(按自然周聚合)。
|
||||
|
|
@ -311,7 +607,7 @@ end
|
|||
- 快速洞察:总览指标帮助评估监测效果与变化趋势。
|
||||
- 易用性:图表直观呈现,降低阅读成本。
|
||||
- 典型场景
|
||||
- 研究人员查看“过去30天引用趋势”,发现某周显著上升,结合上下文进一步分析。
|
||||
- 研究人员查看"过去30天引用趋势",发现某周显著上升,结合上下文进一步分析。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
|
|
@ -377,43 +673,65 @@ API-->>FE : 流式响应(Attachment)
|
|||
- API 层仅负责参数解析与鉴权,业务逻辑集中在服务层,降低控制器复杂度。
|
||||
- 引擎与平台适配器通过抽象接口解耦,便于替换与扩展。
|
||||
- 调度器与引擎通过 ORM 与任务表协作,避免直接耦合业务数据。
|
||||
- AI代理框架通过任务分发器与代理实现松耦合。
|
||||
- 生命周期管理与项目阶段通过外键关联,确保数据一致性。
|
||||
- 外部依赖
|
||||
- FastAPI/SQLAlchemy:Web 框架与 ORM。
|
||||
- APScheduler:异步定时任务调度。
|
||||
- Recharts:前端图表渲染。
|
||||
- Redis:异步任务队列和缓存。
|
||||
- LLM提供商:OpenAI、DeepSeek等大模型服务。
|
||||
- 潜在风险
|
||||
- 平台适配器异常需隔离,避免影响其他平台任务。
|
||||
- 大量并发查询可能带来数据库与外部平台压力,建议限流与重试策略。
|
||||
- AI代理任务的LLM调用可能存在成本控制和速率限制问题。
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
API["API层"] --> SVC["服务层"]
|
||||
SVC --> MODEL["模型层"]
|
||||
SVC --> WORKER["工作器"]
|
||||
SVC --> AGENT["AI代理框架"]
|
||||
SVC --> ANALYTICS["分析监控"]
|
||||
SVC --> KNOWLEDGE["知识库服务"]
|
||||
WORKER --> ADAPTER["平台适配器"]
|
||||
AGENT --> DISPATCHER["任务分发器"]
|
||||
ANALYTICS --> LLM["LLM提供商"]
|
||||
KNOWLEDGE --> VECTOR["向量数据库"]
|
||||
FE["前端"] --> API
|
||||
```
|
||||
|
||||
**图表来源**
|
||||
- [backend/app/main.py:38-42](file://backend/app/main.py#L38-L42)
|
||||
- [backend/app/workers/citation_engine.py:151-157](file://backend/app/workers/citation_engine.py#L151-L157)
|
||||
- [backend/app/agent_framework/dispatcher.py:35-46](file://backend/app/agent_framework/dispatcher.py#L35-L46)
|
||||
|
||||
**章节来源**
|
||||
- [backend/app/main.py:1-48](file://backend/app/main.py#L1-L48)
|
||||
- [backend/app/workers/citation_engine.py:148-309](file://backend/app/workers/citation_engine.py#L148-L309)
|
||||
- [backend/app/agent_framework/dispatcher.py:1-367](file://backend/app/agent_framework/dispatcher.py#L1-L367)
|
||||
|
||||
## 性能考虑
|
||||
- 数据库
|
||||
- 查询索引:查询与引用记录表均建立常用过滤字段索引,减少扫描开销。
|
||||
- 分页与聚合:统计接口使用分组与聚合,避免一次性拉取全量数据。
|
||||
- 连接池:合理配置数据库连接池大小,避免连接争用。
|
||||
- 引擎与平台
|
||||
- 并行执行:同一查询的不同平台可并行处理,缩短总耗时。
|
||||
- 错误隔离:单平台失败不影响其他平台,保证整体可用性。
|
||||
- 缓存策略:对频繁查询的结果进行缓存,减少重复计算。
|
||||
- 前端
|
||||
- 图表懒加载与响应式容器,提升大屏体验。
|
||||
- 导出采用流式响应,避免内存峰值。
|
||||
|
||||
[本节为通用指导,无需具体文件分析]
|
||||
- 代理状态轮询优化,避免过度请求。
|
||||
- AI代理框架
|
||||
- 任务队列:Redis队列支持高并发任务处理。
|
||||
- 超时控制:为LLM调用设置合理的超时时间。
|
||||
- 重试机制:失败任务自动重试,支持指数退避。
|
||||
- 知识库服务
|
||||
- 向量索引:优化向量相似度查询性能。
|
||||
- 分块策略:合理设置分块大小,平衡精度与性能。
|
||||
- 批量处理:批量插入和更新操作,减少数据库往返。
|
||||
|
||||
## 故障排查指南
|
||||
- 认证问题
|
||||
|
|
@ -421,8 +739,24 @@ FE["前端"] --> API
|
|||
- 登录失败:邮箱或密码错误;确认凭据正确与网络可达。
|
||||
- 查询任务
|
||||
- 创建被拒:超出配额;联系管理员提升限额或清理历史查询。
|
||||
- 无法执行:查询状态非“active”或未配置平台;检查状态与平台列表。
|
||||
- 无法执行:查询状态非"active"或未配置平台;检查状态与平台列表。
|
||||
- 即时查询无响应:平台适配器异常或网络超时;查看任务状态与错误信息。
|
||||
- AI代理框架
|
||||
- 代理任务失败:检查代理配置、LLM提供商连接和任务输入参数。
|
||||
- Pipeline执行错误:验证YAML语法、依赖关系和变量引用。
|
||||
- 任务超时:调整超时设置或优化LLM调用参数。
|
||||
- 生命周期管理
|
||||
- 项目创建失败:检查组织权限和品牌名称唯一性。
|
||||
- 阶段推进异常:确认阶段状态和前置条件满足。
|
||||
- 统计数据缺失:验证项目数据完整性和时间范围设置。
|
||||
- 分析监控
|
||||
- 发布记录丢失:检查发布事件记录和数据库连接。
|
||||
- 洞察生成失败:确认LLM提供商可用性和API密钥配置。
|
||||
- 性能数据异常:验证指标计算逻辑和数据完整性。
|
||||
- 知识库服务
|
||||
- 文档上传失败:检查文件大小限制和内容格式。
|
||||
- 检索结果不准确:验证向量嵌入质量和检索参数设置。
|
||||
- 搜索日志缺失:确认日志记录和数据库写入权限。
|
||||
- 统计与导出
|
||||
- 统计为空:可能因筛选条件导致无数据;尝试放宽时间范围或移除查询筛选。
|
||||
- 导出失败:查询不存在或无权限;确认 query_id 与登录态。
|
||||
|
|
@ -435,21 +769,27 @@ FE["前端"] --> API
|
|||
- [backend/app/services/citation.py:204-234](file://backend/app/services/citation.py#L204-L234)
|
||||
- [backend/app/api/reports.py:16-46](file://backend/app/api/reports.py#L16-L46)
|
||||
- [backend/app/workers/scheduler.py:30-40](file://backend/app/workers/scheduler.py#L30-L40)
|
||||
- [backend/app/agent_framework/dispatcher.py:118-154](file://backend/app/agent_framework/dispatcher.py#L118-L154)
|
||||
- [backend/app/api/lifecycle.py:190-230](file://backend/app/api/lifecycle.py#L190-L230)
|
||||
- [backend/app/services/analytics/tracker.py:16-51](file://backend/app/services/analytics/tracker.py#L16-L51)
|
||||
- [backend/app/api/knowledge.py:217-293](file://backend/app/api/knowledge.py#L217-L293)
|
||||
|
||||
## 结论
|
||||
GEO 平台以“查询—检测—统计—可视—导出”为主线,构建了从自动化采集到深度分析的完整链路。通过严格的权限控制、可扩展的平台适配器、稳健的定时调度与清晰的可视化输出,既能满足管理员对系统运行的掌控,也能为研究人员提供高效、可靠的品牌监测工具。建议后续在平台适配器层面引入重试与熔断、在数据库侧增加慢查询监控与索引优化,持续提升稳定性与性能。
|
||||
|
||||
[本节为总结性内容,无需具体文件分析]
|
||||
GEO 平台以"查询—检测—智能代理—生命周期—分析监控—知识库—统计—可视—导出"为主线,构建了从自动化采集到深度分析的完整链路。通过严格的权限控制、可扩展的平台适配器、稳健的定时调度、智能化的AI代理编排、全生命周期的项目管理和全面的分析监控体系,既能满足管理员对系统运行的掌控,也能为研究人员提供高效、可靠的品牌监测工具。新增的AI代理框架、业务生命周期管理、分析监控系统和知识库服务等核心功能模块,进一步增强了平台的智能化水平和业务服务能力。建议后续在代理任务的成本控制、生命周期管理的自动化程度、分析洞察的准确性以及知识库的规模扩展等方面持续优化,以提升整体用户体验和平台价值。
|
||||
|
||||
## 附录
|
||||
- 典型使用流程(管理员)
|
||||
- 新建用户/分配配额 → 配置平台密钥 → 监控调度器运行 → 查看任务状态与错误日志 → 调整频率策略。
|
||||
- 新建用户/分配配额 → 配置平台密钥 → 监控调度器运行 → 查看任务状态与错误日志 → 调整频率策略 → 管理代理任务 → 监控分析数据 → 维护知识库内容。
|
||||
- 典型使用流程(研究人员)
|
||||
- 登录 → 创建查询(关键词/目标品牌/平台/频率) → 查看仪表盘趋势 → 导出报告 → 深度分析与汇报。
|
||||
- 登录 → 创建查询(关键词/目标品牌/平台/频率) → 查看仪表盘趋势 → 导出报告 → 使用知识库检索相关信息 → 生成内容并进行优化 → 发布内容并跟踪效果。
|
||||
- 典型使用流程(项目经理)
|
||||
- 登录 → 快速启动品牌项目 → 分配各阶段任务 → 跟踪项目进度 → 查看阶段报告 → 管理团队成员 → 生成项目总结。
|
||||
- 关键接口路径参考
|
||||
- 认证:POST /api/v1/auth/register, POST /api/v1/auth/login, GET /api/v1/auth/me
|
||||
- 查询:GET/POST/GET/PATCH/DELETE /api/v1/queries
|
||||
- 引用:GET /api/v1/citations, GET /api/v1/citations/stats, POST /api/v1/queries/{query_id}/run-now
|
||||
- 报告:GET /api/v1/reports/export/csv
|
||||
|
||||
[本节为概览性内容,无需具体文件分析]
|
||||
- 生命周期:POST /api/v1/lifecycle/projects/quick-start, GET /api/v1/lifecycle/projects/{id}/timeline
|
||||
- 知识库:POST /api/v1/knowledge/bases, POST /api/v1/knowledge/bases/{kb_id}/documents, POST /api/v1/knowledge/search
|
||||
- 分析监控:POST /api/v1/analytics/publish, GET /api/v1/analytics/insights
|
||||
- AI代理:POST /api/v1/agents/{agent_name}/{task_type}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,5 +1,8 @@
|
|||
# GEO - AI搜索引擎品牌曝光度优化平台
|
||||
|
||||
[](https://github.com/YOUR_USERNAME/GEO/actions/workflows/ci.yml)
|
||||
[](https://github.com/YOUR_USERNAME/GEO/actions/workflows/pr-check.yml)
|
||||
|
||||
## 项目简介
|
||||
|
||||
GEO(Generative Engine Optimization)是一个SaaS平台,帮助品牌监测其在各大AI搜索引擎中的曝光度和引用情况。支持文心一言、Kimi、通义千问、豆包、讯飞星火、天工、清言等主流国内AI平台,以及通用搜索引擎。
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
tests/
|
||||
alembic/versions/__pycache__/
|
||||
.git/
|
||||
.gitignore
|
||||
README.md
|
||||
|
|
@ -37,4 +37,9 @@ COPY . .
|
|||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/api/health || exit 1
|
||||
|
||||
CMD ["gunicorn", "app.main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", \
|
||||
"--bind", "0.0.0.0:8000", "--timeout", "120", "--access-logfile", "-"]
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"""Add sentiment analysis fields to citation_records
|
||||
"""add_missing_sentiment_fields
|
||||
|
||||
Revision ID: d4e6f8a0bc13
|
||||
Revises: c3d5e7f9ab12
|
||||
Create Date: 2026-05-19 10:00:00.000000
|
||||
Revision ID: 059724556401
|
||||
Revises: a7b9c1d3ef67
|
||||
Create Date: 2026-05-23 17:19:50.789398
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
|
@ -12,14 +12,15 @@ import sqlalchemy as sa
|
|||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'd4e6f8a0bc13'
|
||||
down_revision: Union[str, Sequence[str], None] = 'c3d5e7f9ab12'
|
||||
revision: str = '059724556401'
|
||||
down_revision: Union[str, Sequence[str], None] = 'a7b9c1d3ef67'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 添加情感分析字段
|
||||
"""Upgrade schema."""
|
||||
# 添加情感分析字段到 citation_records 表
|
||||
op.add_column('citation_records',
|
||||
sa.Column('sentiment', sa.String(20), nullable=True,
|
||||
comment='情感倾向: positive / neutral / negative')
|
||||
|
|
@ -35,6 +36,7 @@ def upgrade() -> None:
|
|||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_column('citation_records', 'sentiment_key_phrases')
|
||||
op.drop_column('citation_records', 'sentiment_confidence')
|
||||
op.drop_column('citation_records', 'sentiment')
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
"""Add citation source analysis fields to citation_records
|
||||
"""add_citation_source_analysis_fields
|
||||
|
||||
Revision ID: d4e6f8a0bc23
|
||||
Revises: c3d5e7f9ab12
|
||||
Create Date: 2026-05-19 10:00:00.000000
|
||||
Revision ID: 8ccb553ff975
|
||||
Revises: 059724556401
|
||||
Create Date: 2026-05-23 17:23:03.183460
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
|
@ -12,14 +12,14 @@ import sqlalchemy as sa
|
|||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'd4e6f8a0bc23'
|
||||
down_revision: Union[str, Sequence[str], None] = 'c3d5e7f9ab12'
|
||||
revision: str = '8ccb553ff975'
|
||||
down_revision: Union[str, Sequence[str], None] = '059724556401'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add citation source analysis fields to citation_records table."""
|
||||
"""Upgrade schema."""
|
||||
# 数据来源类型标记
|
||||
op.add_column(
|
||||
'citation_records',
|
||||
|
|
@ -53,7 +53,7 @@ def upgrade() -> None:
|
|||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove citation source analysis fields from citation_records table."""
|
||||
"""Downgrade schema."""
|
||||
op.drop_column('citation_records', 'ai_response_text')
|
||||
op.drop_column('citation_records', 'citation_contexts')
|
||||
op.drop_column('citation_records', 'source_titles')
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"""Add alerts and alert_settings tables
|
||||
|
||||
Revision ID: e5f7a9b1cd34
|
||||
Revises: d4e6f8a0bc23
|
||||
Revises: 8ccb553ff975
|
||||
Create Date: 2026-05-20 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
|
@ -13,7 +13,7 @@ import sqlalchemy as sa
|
|||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'e5f7a9b1cd34'
|
||||
down_revision: Union[str, Sequence[str], None] = 'd4e6f8a0bc23'
|
||||
down_revision: Union[str, Sequence[str], None] = '8ccb553ff975'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""add suggestions table
|
||||
|
||||
Revision ID: e5f7a9b1cd34
|
||||
Revises: d4e6f8a0bc23
|
||||
Revision ID: e5f7a9b1cd35
|
||||
Revises: e5f7a9b1cd34
|
||||
Create Date: 2025-01-20 10:00:00.000000
|
||||
"""
|
||||
from alembic import op
|
||||
|
|
@ -9,8 +9,8 @@ import sqlalchemy as sa
|
|||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
# revision identifiers
|
||||
revision = "e5f7a9b1cd34"
|
||||
down_revision = "d4e6f8a0bc23"
|
||||
revision = "e5f7a9b1cd35"
|
||||
down_revision = "e5f7a9b1cd34"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
|
@ -34,6 +34,7 @@ class BaseAgent(ABC):
|
|||
self._running_tasks: set[str] = set()
|
||||
self._listen_task: asyncio.Task | None = None
|
||||
self._heartbeat_task: asyncio.Task | None = None
|
||||
self._semaphore: asyncio.Semaphore | None = None
|
||||
|
||||
@property
|
||||
def status(self) -> AgentStatus:
|
||||
|
|
@ -69,6 +70,14 @@ class BaseAgent(ABC):
|
|||
# 更新状态
|
||||
self._status = AgentStatus.ONLINE
|
||||
|
||||
# 根据 capabilities 的 max_concurrency 初始化 Semaphore
|
||||
capability = self.get_capabilities()
|
||||
max_concurrency = getattr(capability, 'max_concurrency', 1) or 1
|
||||
self._semaphore = asyncio.Semaphore(max_concurrency)
|
||||
logger.info(
|
||||
f"Agent '{self.name}' concurrency limit set to {max_concurrency}"
|
||||
)
|
||||
|
||||
# 启动心跳
|
||||
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||
|
||||
|
|
@ -172,7 +181,7 @@ class BaseAgent(ABC):
|
|||
try:
|
||||
task_data = json.loads(task_json)
|
||||
task = TaskMessage.from_dict(task_data)
|
||||
asyncio.create_task(self._execute_task(task))
|
||||
asyncio.create_task(self._execute_task_with_semaphore(task))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse task message: {e}")
|
||||
except asyncio.CancelledError:
|
||||
|
|
@ -180,6 +189,14 @@ class BaseAgent(ABC):
|
|||
except Exception as e:
|
||||
logger.error(f"Task listener error for agent '{self.name}': {e}")
|
||||
|
||||
async def _execute_task_with_semaphore(self, task: TaskMessage):
|
||||
"""通过 Semaphore 限制并发执行任务"""
|
||||
if self._semaphore is None:
|
||||
await self._execute_task(task)
|
||||
return
|
||||
async with self._semaphore:
|
||||
await self._execute_task(task)
|
||||
|
||||
async def _execute_task(self, task: TaskMessage):
|
||||
"""执行单个任务"""
|
||||
self._running_tasks.add(task.task_id)
|
||||
|
|
|
|||
|
|
@ -332,9 +332,10 @@ class PipelineEngine:
|
|||
resolved_inputs: dict[str, Any],
|
||||
) -> StageResult:
|
||||
"""
|
||||
Dry-run模式执行:模拟Agent返回结果。
|
||||
Dry-run模式执行:模拟Agent返回结果。仅用于测试/开发环境。
|
||||
|
||||
在没有dispatcher的环境下使用,用于测试和调试Pipeline定义。
|
||||
如果在生产环境中触发,则记录 ERROR 级别告警。
|
||||
|
||||
Args:
|
||||
stage: 阶段定义
|
||||
|
|
@ -343,6 +344,15 @@ class PipelineEngine:
|
|||
Returns:
|
||||
模拟的StageResult
|
||||
"""
|
||||
import os
|
||||
if os.environ.get("ENV", "development") == "production":
|
||||
logger.error(
|
||||
f"Pipeline 进入 dry-run 模式(stage={stage.name})!"
|
||||
"生产环境中 TaskDispatcher 未正确初始化,请检查系统配置。"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"[DRY-RUN] stage={stage.name} 返回模拟输出")
|
||||
|
||||
# 为声明的输出变量生成模拟值
|
||||
mock_outputs: dict[str, Any] = {}
|
||||
for output_name in stage.outputs:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import uuid
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.base import PaginationParams
|
||||
from app.api.deps import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
|
|
@ -36,13 +37,12 @@ async def read_system_stats(
|
|||
|
||||
@router.get("/users")
|
||||
async def read_users(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
pagination: PaginationParams = Depends(PaginationParams),
|
||||
search: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: User = Depends(get_admin_user),
|
||||
):
|
||||
return await get_users(db, skip=skip, limit=limit, search=search)
|
||||
return await get_users(db, skip=pagination.offset, limit=pagination.limit, search=search)
|
||||
|
||||
|
||||
@router.get("/users/{user_id}")
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from app.database import get_db
|
|||
from app.models.agent import AgentTask as AgentTaskModel
|
||||
from app.models.agent import AgentTaskLog as AgentTaskLogModel
|
||||
from app.models.user import User
|
||||
from app.schemas.common import ErrorCode, ErrorResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -144,7 +145,7 @@ async def list_tasks(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
stmt = select(AgentTaskModel)
|
||||
stmt = select(AgentTaskModel).where(AgentTaskModel.created_by == current_user.id)
|
||||
|
||||
if task_status:
|
||||
stmt = stmt.where(AgentTaskModel.status == task_status)
|
||||
|
|
@ -202,9 +203,11 @@ async def create_task(
|
|||
|
||||
dispatcher = TaskDispatcher(settings.REDIS_URL)
|
||||
try:
|
||||
# 从 current_user 获取 organization_id,优先使用用户的组织ID
|
||||
org_id = str(current_user.organization_id) if current_user.organization_id else str(current_user.id)
|
||||
await dispatcher.dispatch(
|
||||
task=task,
|
||||
organization_id=str(current_user.id), # fallback, 实际应从 user.org 取
|
||||
organization_id=org_id,
|
||||
created_by=str(current_user.id),
|
||||
)
|
||||
return TaskCreateResponse(
|
||||
|
|
@ -224,8 +227,35 @@ async def create_task(
|
|||
@router.get("/tasks/{task_id}", summary="获取任务状态")
|
||||
async def get_task_status(
|
||||
task_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# 权限校验:验证任务归属当前用户
|
||||
try:
|
||||
task_uuid = uuid.UUID(task_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid task_id format",
|
||||
)
|
||||
|
||||
stmt = select(AgentTaskModel).where(AgentTaskModel.id == task_uuid)
|
||||
result = await db.execute(stmt)
|
||||
task = result.scalar_one_or_none()
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Task '{task_id}' not found",
|
||||
)
|
||||
if task.created_by != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ErrorResponse(
|
||||
detail="无权访问此任务",
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
).dict(),
|
||||
)
|
||||
|
||||
dispatcher = TaskDispatcher(settings.REDIS_URL)
|
||||
try:
|
||||
task_status_data = await dispatcher.get_task_status(task_id)
|
||||
|
|
@ -242,8 +272,35 @@ async def get_task_status(
|
|||
@router.post("/tasks/{task_id}/cancel", summary="取消任务")
|
||||
async def cancel_task(
|
||||
task_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# 权限校验:验证任务归属当前用户
|
||||
try:
|
||||
task_uuid = uuid.UUID(task_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid task_id format",
|
||||
)
|
||||
|
||||
stmt = select(AgentTaskModel).where(AgentTaskModel.id == task_uuid)
|
||||
result = await db.execute(stmt)
|
||||
task = result.scalar_one_or_none()
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Task '{task_id}' not found",
|
||||
)
|
||||
if task.created_by != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ErrorResponse(
|
||||
detail="无权取消此任务",
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
).dict(),
|
||||
)
|
||||
|
||||
dispatcher = TaskDispatcher(settings.REDIS_URL)
|
||||
try:
|
||||
await dispatcher.cancel_task(task_id)
|
||||
|
|
@ -273,6 +330,24 @@ async def get_task_logs(
|
|||
detail="Invalid task_id format",
|
||||
)
|
||||
|
||||
# 权限校验:验证任务归属当前用户
|
||||
task_stmt = select(AgentTaskModel).where(AgentTaskModel.id == task_uuid)
|
||||
task_result = await db.execute(task_stmt)
|
||||
task = task_result.scalar_one_or_none()
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Task '{task_id}' not found",
|
||||
)
|
||||
if task.created_by != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ErrorResponse(
|
||||
detail="无权访问此任务日志",
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
).dict(),
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(AgentTaskLogModel)
|
||||
.where(AgentTaskLogModel.task_id == task_uuid)
|
||||
|
|
|
|||
|
|
@ -30,13 +30,10 @@ router = APIRouter()
|
|||
# 辅助:获取当前用户所属组织ID
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def _get_org_id(current_user: User = Depends(get_current_user)) -> str:
|
||||
async def _get_org_id(current_user: User = Depends(get_current_user)) -> str | None:
|
||||
org_id = getattr(current_user, "organization_id", None)
|
||||
if not org_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="用户未关联组织",
|
||||
)
|
||||
return None
|
||||
return str(org_id)
|
||||
|
||||
|
||||
|
|
@ -52,9 +49,14 @@ async def _get_org_id(current_user: User = Depends(get_current_user)) -> str:
|
|||
)
|
||||
async def record_publish(
|
||||
body: PublishRecordCreate,
|
||||
org_id: str = Depends(_get_org_id),
|
||||
org_id: str | None = Depends(_get_org_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not org_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="用户未关联组织,无法记录发布",
|
||||
)
|
||||
tracker = AnalyticsTracker(db)
|
||||
record = await tracker.record_publish(org_id, body.model_dump())
|
||||
return record
|
||||
|
|
@ -104,9 +106,18 @@ async def update_metrics(
|
|||
summary="获取全局效果概览",
|
||||
)
|
||||
async def get_overview(
|
||||
org_id: str = Depends(_get_org_id),
|
||||
org_id: str | None = Depends(_get_org_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not org_id:
|
||||
return OverviewStatsResponse(
|
||||
total_published=0,
|
||||
total_views=0,
|
||||
total_interactions=0,
|
||||
total_ai_citations=0,
|
||||
avg_engagement_rate=0.0,
|
||||
platform_distribution={},
|
||||
)
|
||||
tracker = AnalyticsTracker(db)
|
||||
overview = await tracker.get_overview(org_id)
|
||||
return overview
|
||||
|
|
@ -157,9 +168,11 @@ async def get_content_performance(
|
|||
async def get_top_performing(
|
||||
sort_by: str = Query(default="views", description="排序字段: views/likes/comments/shares/ai_citation_count/read_completion_rate"),
|
||||
limit: int = Query(default=10, ge=1, le=50),
|
||||
org_id: str = Depends(_get_org_id),
|
||||
org_id: str | None = Depends(_get_org_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not org_id:
|
||||
return TopContentResponse(items=[], sort_by=sort_by, total=0)
|
||||
tracker = AnalyticsTracker(db)
|
||||
items = await tracker.get_top_performing(org_id, limit=limit, sort_by=sort_by)
|
||||
return TopContentResponse(items=items, sort_by=sort_by, total=len(items))
|
||||
|
|
@ -177,9 +190,11 @@ async def get_top_performing(
|
|||
async def list_insights(
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
insight_type: Optional[str] = Query(default=None),
|
||||
org_id: str = Depends(_get_org_id),
|
||||
org_id: str | None = Depends(_get_org_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not org_id:
|
||||
return []
|
||||
stmt = (
|
||||
select(OptimizationInsight)
|
||||
.where(OptimizationInsight.organization_id == org_id)
|
||||
|
|
@ -204,9 +219,11 @@ async def list_insights(
|
|||
summary="触发AI生成洞察建议",
|
||||
)
|
||||
async def generate_insights(
|
||||
org_id: str = Depends(_get_org_id),
|
||||
org_id: str | None = Depends(_get_org_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not org_id:
|
||||
return []
|
||||
generator = InsightGenerator()
|
||||
insights = await generator.generate_insights(org_id, db)
|
||||
return insights
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ from app.api.deps import get_current_user
|
|||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import (
|
||||
AccessTokenResponse,
|
||||
ChangePasswordRequest,
|
||||
ForgotPasswordRequest,
|
||||
RefreshTokenRequest,
|
||||
ResetPasswordRequest,
|
||||
TokenResponse,
|
||||
UpdateProfileRequest,
|
||||
|
|
@ -19,13 +21,16 @@ from app.services.auth import (
|
|||
authenticate_user,
|
||||
change_password as change_password_service,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
register_user,
|
||||
reset_password as reset_password_service,
|
||||
send_reset_link,
|
||||
send_verification_code,
|
||||
update_profile as update_profile_service,
|
||||
verify_email as verify_email_service,
|
||||
verify_refresh_token,
|
||||
)
|
||||
from app.services.cache import get_cache_service, TTL_USER_PROFILE
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -34,8 +39,9 @@ router = APIRouter()
|
|||
async def register(user_data: UserRegister, db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
user = await register_user(db, user_data)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e) if str(e) else "邮箱已被注册")
|
||||
except ValueError:
|
||||
# 不泄露具体原因,防止用户枚举
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="注册失败,请检查输入信息是否已被使用")
|
||||
return user
|
||||
|
||||
|
||||
|
|
@ -43,6 +49,7 @@ async def register(user_data: UserRegister, db: AsyncSession = Depends(get_db)):
|
|||
async def login(user_data: UserLogin, db: AsyncSession = Depends(get_db)):
|
||||
user = await authenticate_user(db, user_data.email, user_data.password)
|
||||
if not user:
|
||||
# 统一错误消息,防止用户枚举(不区分“用户不存在” vs “密码错误”)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="邮箱或密码错误",
|
||||
|
|
@ -50,15 +57,58 @@ async def login(user_data: UserLogin, db: AsyncSession = Depends(get_db)):
|
|||
)
|
||||
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
refresh_token = create_refresh_token(data={"sub": str(user.id)})
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"refresh_token": refresh_token,
|
||||
"user": user,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=AccessTokenResponse)
|
||||
async def refresh_token(req: RefreshTokenRequest):
|
||||
"""
|
||||
刷新接口:使用 refresh_token 获取新的 access_token + refresh_token(滑动过期)
|
||||
"""
|
||||
try:
|
||||
payload = verify_refresh_token(req.refresh_token)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="刷新令牌无效或已过期",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="刷新令牌无效或已过期",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
new_access_token = create_access_token(data={"sub": user_id})
|
||||
new_refresh_token = create_refresh_token(data={"sub": user_id}) # 滑动过期
|
||||
return {
|
||||
"access_token": new_access_token,
|
||||
"token_type": "bearer",
|
||||
"refresh_token": new_refresh_token,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def read_current_user(current_user: User = Depends(get_current_user)):
|
||||
async def read_current_user(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
cache = get_cache_service()
|
||||
cache_key = f"user:profile:{current_user.id}"
|
||||
cached = await cache.get_json(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
user_data = UserResponse.model_validate(current_user).model_dump(mode="json")
|
||||
await cache.set_json(cache_key, user_data, expire=TTL_USER_PROFILE)
|
||||
return current_user
|
||||
|
||||
|
||||
|
|
@ -111,4 +161,9 @@ async def update_profile(
|
|||
updated_user = await update_profile_service(db, user.id, req)
|
||||
if not updated_user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
# 失效用户配置缓存
|
||||
cache = get_cache_service()
|
||||
await cache.delete(f"user:profile:{user.id}")
|
||||
|
||||
return updated_user
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
"""通用分页与过滤工具,供各 API 路由复用。"""
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from fastapi import Query
|
||||
from pydantic import BaseModel, computed_field
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class PaginationParams:
|
||||
"""依赖注入式分页参数(可直接用于 Depends)。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
page: int = Query(1, ge=1, description="页码,从 1 开始"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
):
|
||||
self.page = page
|
||||
self.page_size = page_size
|
||||
|
||||
@property
|
||||
def offset(self) -> int:
|
||||
return (self.page - 1) * self.page_size
|
||||
|
||||
@property
|
||||
def limit(self) -> int:
|
||||
return self.page_size
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel, Generic[T]):
|
||||
"""通用分页响应结构。"""
|
||||
|
||||
items: list[T]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
@computed_field # type: ignore[misc]
|
||||
@property
|
||||
def total_pages(self) -> int:
|
||||
if self.page_size == 0:
|
||||
return 0
|
||||
import math
|
||||
return math.ceil(self.total / self.page_size)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
"""Brands API endpoints."""
|
||||
import json
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.competitors import router as competitors_router
|
||||
|
|
@ -13,6 +15,7 @@ from app.database import get_db
|
|||
from app.models.user import User
|
||||
from app.models.brand import Brand
|
||||
from app.schemas.brand import BrandCreate, BrandUpdate, BrandResponse, BrandListResponse
|
||||
from app.services.cache import get_cache_service, TTL_BRANDS
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -29,15 +32,32 @@ async def get_brands(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get all brands for the current user."""
|
||||
stmt = select(Brand, func.count().over().label("total")).where(
|
||||
Brand.user_id == current_user.id
|
||||
cache = get_cache_service()
|
||||
cache_key = f"brands:{current_user.id}"
|
||||
|
||||
# 先读缓存
|
||||
cached = await cache.get_json(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# 修复 N+1:一次性加载 competitors 和 suggestions
|
||||
stmt = (
|
||||
select(Brand)
|
||||
.where(Brand.user_id == current_user.id)
|
||||
.options(
|
||||
selectinload(Brand.competitors),
|
||||
selectinload(Brand.suggestions),
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
items = [row[0] for row in rows]
|
||||
items = list(result.scalars().all())
|
||||
total = len(items)
|
||||
|
||||
response_data = {"items": [BrandResponse.model_validate(b).model_dump(mode="json") for b in items], "total": total}
|
||||
|
||||
# 写入缓存(TTL: 5 分钟)
|
||||
await cache.set_json(cache_key, response_data, expire=TTL_BRANDS)
|
||||
|
||||
return {"items": items, "total": total}
|
||||
|
||||
|
||||
|
|
@ -60,6 +80,11 @@ async def create_brand(
|
|||
db.add(brand)
|
||||
await db.commit()
|
||||
await db.refresh(brand)
|
||||
|
||||
# 失效该用户的品牌列表缓存
|
||||
cache = get_cache_service()
|
||||
await cache.delete(f"brands:{current_user.id}")
|
||||
|
||||
return brand
|
||||
|
||||
|
||||
|
|
@ -107,6 +132,11 @@ async def update_brand(
|
|||
|
||||
await db.commit()
|
||||
await db.refresh(brand)
|
||||
|
||||
# 失效该用户的品牌列表缓存
|
||||
cache = get_cache_service()
|
||||
await cache.delete(f"brands:{current_user.id}")
|
||||
|
||||
return brand
|
||||
|
||||
|
||||
|
|
@ -129,4 +159,9 @@ async def delete_brand(
|
|||
|
||||
await db.delete(brand)
|
||||
await db.commit()
|
||||
|
||||
# 失效该用户的品牌列表缓存
|
||||
cache = get_cache_service()
|
||||
await cache.delete(f"brands:{current_user.id}")
|
||||
|
||||
return None
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
"""内容生产API - 串联Agent Pipeline"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
|
|
@ -13,6 +14,8 @@ from app.database import get_db
|
|||
from app.models.content import Content, ContentVersion
|
||||
from app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
|
|
@ -38,6 +41,44 @@ class ContentGenerateResponse(BaseModel):
|
|||
pipeline_stages: list[dict] = [] # 每个阶段的执行结果摘要
|
||||
|
||||
|
||||
async def _get_knowledge_context(
|
||||
db: AsyncSession,
|
||||
brand_name: str,
|
||||
knowledge_base_ids: list[str],
|
||||
target_keyword: str,
|
||||
) -> str:
|
||||
"""
|
||||
从知识库检索与查询相关的上下文。
|
||||
|
||||
如果有知识库ID,则调用 RAGService.search 获取相关内容;
|
||||
否则返回空字符串,不影响后续流程。
|
||||
"""
|
||||
if not knowledge_base_ids:
|
||||
return ""
|
||||
|
||||
try:
|
||||
from app.services.knowledge.rag_service import RAGService
|
||||
rag_service = RAGService()
|
||||
results = await rag_service.search(
|
||||
session=db,
|
||||
query=f"{brand_name} {target_keyword}" if brand_name else target_keyword,
|
||||
knowledge_base_ids=knowledge_base_ids,
|
||||
top_k=3,
|
||||
)
|
||||
if results:
|
||||
context_parts = []
|
||||
for r in results:
|
||||
content = r.get("content", "")
|
||||
title = r.get("document_title", "")
|
||||
if content:
|
||||
context_parts.append(f"[{title}] {content}")
|
||||
return "\n".join(context_parts)
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.warning(f"知识库检索失败,将不使用知识库上下文: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
@router.post("/generate", response_model=ContentGenerateResponse)
|
||||
async def generate_content(
|
||||
req: ContentGenerateRequest,
|
||||
|
|
@ -65,6 +106,11 @@ async def generate_content(
|
|||
try:
|
||||
provider = LLMFactory.get_default()
|
||||
|
||||
# 获取知识库上下文
|
||||
knowledge_context = await _get_knowledge_context(
|
||||
db, req.brand_name, req.knowledge_base_ids, req.target_keyword
|
||||
)
|
||||
|
||||
# Stage 1: 内容生成
|
||||
gen_variables = {
|
||||
"topic_title": req.target_keyword,
|
||||
|
|
@ -74,7 +120,7 @@ async def generate_content(
|
|||
"content_style": req.content_style,
|
||||
"word_count": str(req.word_count),
|
||||
"brand_name": req.brand_name,
|
||||
"knowledge_context": "暂无", # TODO: 对接RAG检索
|
||||
"knowledge_context": knowledge_context,
|
||||
}
|
||||
messages = CONTENT_GENERATOR_TEMPLATE.render(gen_variables)
|
||||
response = await provider.chat(messages, temperature=0.7, max_tokens=req.word_count * 2)
|
||||
|
|
@ -191,4 +237,4 @@ async def generate_topics(
|
|||
|
||||
return {"status": "success", "topics": topics}
|
||||
except LLMError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
|
|
@ -22,6 +22,7 @@ from app.schemas.dashboard import (
|
|||
from app.services.scoring_service import ScoringService, get_health_level
|
||||
from app.services.sentiment_service import get_sentiment_service
|
||||
from app.schemas.scoring import CitationResult
|
||||
from app.services.cache import get_cache_service, TTL_DASHBOARD
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -355,7 +356,8 @@ async def get_dashboard_stats(
|
|||
- 竞品地位(领先/落后数量)
|
||||
- 最近查询记录
|
||||
"""
|
||||
# Get the first brand if not specified
|
||||
cache = get_cache_service()
|
||||
# 如果 brand_id 尚未确定,先查库取第一个品牌
|
||||
if brand_id is None:
|
||||
brand_stmt = select(Brand).where(Brand.user_id == current_user.id).limit(1)
|
||||
brand_result = await db.execute(brand_stmt)
|
||||
|
|
@ -377,6 +379,12 @@ async def get_dashboard_stats(
|
|||
total_platforms=7,
|
||||
)
|
||||
|
||||
# 尝试从缓存读取(TTL: 2 分钟)
|
||||
cache_key = f"dashboard:stats:{current_user.id}:{brand_id}"
|
||||
cached = await cache.get_json(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Get brand name
|
||||
brand_stmt = select(Brand).where(Brand.id == brand_id)
|
||||
brand_result = await db.execute(brand_stmt)
|
||||
|
|
@ -455,7 +463,7 @@ async def get_dashboard_stats(
|
|||
# Health level
|
||||
health_level = get_health_level(overall_score)
|
||||
|
||||
return DashboardStatsResponse(
|
||||
response = DashboardStatsResponse(
|
||||
overall_score=round(overall_score, 2),
|
||||
health_level=health_level,
|
||||
score_change=score_change,
|
||||
|
|
@ -468,3 +476,12 @@ async def get_dashboard_stats(
|
|||
total_platforms=7,
|
||||
brand_name=brand_name,
|
||||
)
|
||||
|
||||
# 将结果写入缓存(TTL: 2 分钟)
|
||||
await cache.set_json(
|
||||
cache_key,
|
||||
response.model_dump(mode="json"),
|
||||
expire=TTL_DASHBOARD,
|
||||
)
|
||||
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -31,6 +31,14 @@ STAGE_NAMES = {
|
|||
5: "持续运维",
|
||||
}
|
||||
|
||||
STAGE_INT_TO_STR = {
|
||||
1: "diagnosis",
|
||||
2: "strategy",
|
||||
3: "content",
|
||||
4: "publishing",
|
||||
5: "monitoring",
|
||||
}
|
||||
|
||||
|
||||
# ---------- helpers ----------
|
||||
|
||||
|
|
@ -82,6 +90,25 @@ async def _load_project_with_stages(
|
|||
|
||||
# ---------- endpoints ----------
|
||||
|
||||
@router.get("/projects/", response_model=list[ProjectResponse])
|
||||
async def list_projects(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
org_id = current_user.organization_id
|
||||
if not org_id:
|
||||
return []
|
||||
stmt = (
|
||||
select(LifecycleProject)
|
||||
.where(LifecycleProject.organization_id == org_id)
|
||||
.options(selectinload(LifecycleProject.stages))
|
||||
.order_by(LifecycleProject.created_at.desc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
projects = result.scalars().all()
|
||||
return projects
|
||||
|
||||
|
||||
@router.get("/projects/stats", response_model=ProjectStatsResponse)
|
||||
async def project_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
|
@ -92,6 +119,10 @@ async def project_stats(
|
|||
return ProjectStatsResponse(
|
||||
total_projects=0,
|
||||
active_projects=0,
|
||||
completed_projects=0,
|
||||
contents_produced=0,
|
||||
avg_ai_citation_rate=None,
|
||||
current_stage_distribution={},
|
||||
stage_distribution={},
|
||||
completion_rate=0.0,
|
||||
)
|
||||
|
|
@ -127,9 +158,70 @@ async def project_stats(
|
|||
done = comp_result.scalar() or 0
|
||||
completion_rate = round(done / total, 4) if total > 0 else 0.0
|
||||
|
||||
# completed projects
|
||||
completed_stmt = select(
|
||||
func.count().filter(LifecycleProject.status == "completed").label("completed"),
|
||||
).where(LifecycleProject.organization_id == org_id)
|
||||
completed_result = await db.execute(completed_stmt)
|
||||
completed = completed_result.scalar() or 0
|
||||
|
||||
# contents produced (count from content table if available)
|
||||
try:
|
||||
from app.models.content import Content
|
||||
contents_stmt = select(func.count()).where(Content.organization_id == org_id)
|
||||
contents_result = await db.execute(contents_stmt)
|
||||
contents_produced = contents_result.scalar() or 0
|
||||
except Exception:
|
||||
contents_produced = 0
|
||||
|
||||
# avg AI citation rate
|
||||
try:
|
||||
from app.models.citation_record import CitationRecord
|
||||
from app.models.query import Query as QueryModel
|
||||
# Query uses user_id, so join through users table to get org members
|
||||
from app.models.organization import OrgMember
|
||||
org_user_ids_stmt = select(OrgMember.user_id).where(OrgMember.organization_id == org_id)
|
||||
org_user_ids_result = await db.execute(org_user_ids_stmt)
|
||||
org_user_ids = [r.user_id for r in org_user_ids_result.all()]
|
||||
if org_user_ids:
|
||||
citation_stmt = select(
|
||||
func.count().label("total_citations"),
|
||||
func.count().filter(CitationRecord.cited == True).label("cited_count"),
|
||||
).join(QueryModel, CitationRecord.query_id == QueryModel.id).where(
|
||||
QueryModel.user_id.in_(org_user_ids),
|
||||
)
|
||||
citation_result = await db.execute(citation_stmt)
|
||||
citation_row = citation_result.one()
|
||||
total_citations = citation_row.total_citations or 0
|
||||
cited_count = citation_row.cited_count or 0
|
||||
avg_ai_citation_rate = round(cited_count / total_citations, 4) if total_citations > 0 else None
|
||||
else:
|
||||
avg_ai_citation_rate = None
|
||||
except Exception:
|
||||
avg_ai_citation_rate = None
|
||||
|
||||
# current stage distribution (map int stage to string)
|
||||
current_stage_dist_stmt = (
|
||||
select(
|
||||
LifecycleProject.current_stage,
|
||||
func.count().label("cnt"),
|
||||
)
|
||||
.where(LifecycleProject.organization_id == org_id)
|
||||
.group_by(LifecycleProject.current_stage)
|
||||
)
|
||||
current_stage_dist_result = await db.execute(current_stage_dist_stmt)
|
||||
current_stage_distribution = {}
|
||||
for r in current_stage_dist_result.all():
|
||||
stage_key = STAGE_INT_TO_STR.get(r.current_stage, str(r.current_stage))
|
||||
current_stage_distribution[stage_key] = current_stage_distribution.get(stage_key, 0) + r.cnt
|
||||
|
||||
return ProjectStatsResponse(
|
||||
total_projects=total,
|
||||
active_projects=active,
|
||||
completed_projects=completed,
|
||||
contents_produced=contents_produced,
|
||||
avg_ai_citation_rate=avg_ai_citation_rate,
|
||||
current_stage_distribution=current_stage_distribution,
|
||||
stage_distribution=stage_distribution,
|
||||
completion_rate=completion_rate,
|
||||
)
|
||||
|
|
@ -143,7 +235,7 @@ async def project_timeline(
|
|||
):
|
||||
org_id = current_user.organization_id
|
||||
if not org_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No organization found")
|
||||
return []
|
||||
|
||||
project = await _load_project_with_stages(db, project_id, org_id)
|
||||
if not project:
|
||||
|
|
@ -238,7 +330,7 @@ async def list_stages(
|
|||
):
|
||||
org_id = current_user.organization_id
|
||||
if not org_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No organization found")
|
||||
return []
|
||||
|
||||
project = await _load_project_with_stages(db, project_id, org_id)
|
||||
if not project:
|
||||
|
|
@ -257,7 +349,7 @@ async def update_stage(
|
|||
):
|
||||
org_id = current_user.organization_id
|
||||
if not org_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No organization found")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="用户未关联组织,无法修改项目阶段")
|
||||
|
||||
# verify project ownership
|
||||
project = await _load_project_with_stages(db, project_id, org_id)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,514 @@
|
|||
"""Onboarding API endpoints - 新用户引导流程"""
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.competitors import (
|
||||
INDUSTRY_COMPETITORS,
|
||||
_get_rule_based_recommendations,
|
||||
_get_llm_recommendations,
|
||||
CompetitorRecommendationItem,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.brand import Brand
|
||||
from app.models.competitor import Competitor
|
||||
from app.models.citation_record import CitationRecord
|
||||
from app.models.query import Query as QueryModel
|
||||
from app.services.scoring_service import ScoringService
|
||||
from app.schemas.brand import BrandCreate, BrandResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/onboarding", tags=["onboarding"])
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Request / Response schemas
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
class OnboardingBrandCreate(BaseModel):
|
||||
"""Onboarding 创建品牌请求(简化版)"""
|
||||
name: str = Field(..., min_length=2, max_length=50, description="品牌名称")
|
||||
description: Optional[str] = Field(None, max_length=500, description="品牌描述")
|
||||
industry: Optional[str] = Field(None, max_length=50, description="行业")
|
||||
|
||||
|
||||
class OnboardingStatusResponse(BaseModel):
|
||||
"""Onboarding 状态响应"""
|
||||
completed: bool
|
||||
brand_id: Optional[str] = None
|
||||
current_step: int
|
||||
|
||||
|
||||
class CompetitorRecommendationSimple(BaseModel):
|
||||
"""简化竞品推荐项"""
|
||||
name: str
|
||||
description: str
|
||||
confidence: float
|
||||
|
||||
|
||||
class CompetitorRecommendationSimpleResponse(BaseModel):
|
||||
"""简化竞品推荐响应"""
|
||||
recommendations: list[CompetitorRecommendationSimple]
|
||||
|
||||
|
||||
class HealthReportResponse(BaseModel):
|
||||
"""初始健康评分报告"""
|
||||
brand_id: str
|
||||
brand_name: str
|
||||
overall_score: float
|
||||
platform_scores: dict
|
||||
strengths: list[str]
|
||||
weaknesses: list[str]
|
||||
competitor_scores: list[dict]
|
||||
|
||||
|
||||
class ActionSuggestion(BaseModel):
|
||||
"""行动建议项"""
|
||||
title: str
|
||||
description: str
|
||||
priority: str # high / medium / low
|
||||
action_type: str # e.g. coverage, keyword, sentiment, platform
|
||||
|
||||
|
||||
class ActionSuggestionsResponse(BaseModel):
|
||||
"""行动建议响应"""
|
||||
suggestions: list[ActionSuggestion]
|
||||
|
||||
|
||||
class OnboardingCompleteResponse(BaseModel):
|
||||
"""完成 onboarding 响应"""
|
||||
success: bool
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@router.get("/status", response_model=OnboardingStatusResponse)
|
||||
async def get_onboarding_status(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
检查当前用户的 onboarding 状态。
|
||||
|
||||
通过查询 brands 表判断:用户是否已创建品牌(即完成 onboarding)。
|
||||
- completed=True 且 brand_id 有值 → 已完成
|
||||
- completed=False, current_step=1 → 需要创建品牌
|
||||
"""
|
||||
stmt = select(Brand).where(Brand.user_id == current_user.id)
|
||||
result = await db.execute(stmt)
|
||||
brand = result.scalar_one_or_none()
|
||||
|
||||
if brand:
|
||||
return OnboardingStatusResponse(
|
||||
completed=True,
|
||||
brand_id=str(brand.id),
|
||||
current_step=4,
|
||||
)
|
||||
|
||||
return OnboardingStatusResponse(
|
||||
completed=False,
|
||||
brand_id=None,
|
||||
current_step=1,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/brand", response_model=BrandResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_onboarding_brand(
|
||||
brand_data: OnboardingBrandCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Onboarding 流程中创建品牌。
|
||||
|
||||
复用 Brand 模型,将简化字段映射到完整 BrandCreate。
|
||||
"""
|
||||
full_brand_data = BrandCreate(
|
||||
name=brand_data.name,
|
||||
aliases=[],
|
||||
website=None,
|
||||
industry=brand_data.industry,
|
||||
platforms=["wenxin", "kimi"],
|
||||
frequency="weekly",
|
||||
)
|
||||
|
||||
brand = Brand(
|
||||
user_id=current_user.id,
|
||||
name=full_brand_data.name,
|
||||
aliases=full_brand_data.aliases,
|
||||
website=full_brand_data.website,
|
||||
industry=full_brand_data.industry,
|
||||
platforms=full_brand_data.platforms,
|
||||
frequency=full_brand_data.frequency,
|
||||
)
|
||||
db.add(brand)
|
||||
await db.commit()
|
||||
await db.refresh(brand)
|
||||
|
||||
return brand
|
||||
|
||||
|
||||
@router.get("/competitor-recommendations", response_model=CompetitorRecommendationSimpleResponse)
|
||||
async def get_onboarding_competitor_recommendations(
|
||||
brand_id: uuid.UUID = Query(..., description="品牌ID"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
根据品牌推荐竞品。
|
||||
|
||||
复用 brands/competitors 中的推荐逻辑,
|
||||
支持 LLM 智能推荐和规则推荐两种模式。
|
||||
"""
|
||||
# 验证品牌归属
|
||||
stmt = select(Brand).where(Brand.id == brand_id, Brand.user_id == current_user.id)
|
||||
result = await db.execute(stmt)
|
||||
brand = result.scalar_one_or_none()
|
||||
|
||||
if not brand:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="品牌不存在",
|
||||
)
|
||||
|
||||
# 获取已有竞品名称(排除)
|
||||
existing_stmt = select(Competitor.name).where(Competitor.brand_id == brand_id)
|
||||
existing_result = await db.execute(existing_stmt)
|
||||
existing_names = [row[0] for row in existing_result.all()]
|
||||
|
||||
# 选择推荐策略
|
||||
if settings.ENABLE_LLM and settings.DEEPSEEK_API_KEY:
|
||||
try:
|
||||
rec_items = await _get_llm_recommendations(
|
||||
brand_name=brand.name,
|
||||
industry=brand.industry,
|
||||
existing_names=existing_names,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Onboarding LLM竞品推荐失败,回退规则推荐: {e}")
|
||||
rec_items = _get_rule_based_recommendations(
|
||||
brand_name=brand.name,
|
||||
industry=brand.industry,
|
||||
existing_names=existing_names,
|
||||
)
|
||||
else:
|
||||
rec_items = _get_rule_based_recommendations(
|
||||
brand_name=brand.name,
|
||||
industry=brand.industry,
|
||||
existing_names=existing_names,
|
||||
)
|
||||
|
||||
# 转换为简化格式
|
||||
recommendations = []
|
||||
for item in rec_items:
|
||||
confidence = 0.8 if brand.industry and brand.industry in INDUSTRY_COMPETITORS else 0.5
|
||||
recommendations.append(CompetitorRecommendationSimple(
|
||||
name=item.name,
|
||||
description=item.reason,
|
||||
confidence=confidence,
|
||||
))
|
||||
|
||||
return CompetitorRecommendationSimpleResponse(recommendations=recommendations)
|
||||
|
||||
|
||||
@router.get("/health-report/{brand_id}", response_model=HealthReportResponse)
|
||||
async def get_onboarding_health_report(
|
||||
brand_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取品牌初始健康评分报告。
|
||||
|
||||
基于 citation_records 表统计品牌的引用数据,
|
||||
如果没有引用数据则返回初始化状态(overall_score: 0)。
|
||||
"""
|
||||
# 验证品牌归属
|
||||
stmt = select(Brand).where(Brand.id == brand_id, Brand.user_id == current_user.id)
|
||||
result = await db.execute(stmt)
|
||||
brand = result.scalar_one_or_none()
|
||||
|
||||
if not brand:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="品牌不存在",
|
||||
)
|
||||
|
||||
# 查询与品牌关联的 queries
|
||||
queries_stmt = select(QueryModel).where(
|
||||
QueryModel.user_id == current_user.id,
|
||||
QueryModel.target_brand == brand.name,
|
||||
)
|
||||
queries_result = await db.execute(queries_stmt)
|
||||
queries = list(queries_result.scalars().all())
|
||||
|
||||
# 没有查询数据 → 返回初始化状态
|
||||
if not queries:
|
||||
return HealthReportResponse(
|
||||
brand_id=str(brand.id),
|
||||
brand_name=brand.name,
|
||||
overall_score=0.0,
|
||||
platform_scores={},
|
||||
strengths=["品牌已创建,等待数据采集"],
|
||||
weaknesses=["尚无AI平台引用数据,需等待查询执行"],
|
||||
competitor_scores=[],
|
||||
)
|
||||
|
||||
query_ids = [q.id for q in queries]
|
||||
|
||||
# 获取引用记录
|
||||
citations_stmt = select(CitationRecord).where(
|
||||
CitationRecord.query_id.in_(query_ids),
|
||||
)
|
||||
citations_result = await db.execute(citations_stmt)
|
||||
citations = list(citations_result.scalars().all())
|
||||
|
||||
total = len(citations)
|
||||
cited = [c for c in citations if c.cited]
|
||||
|
||||
# 计算各平台评分
|
||||
platform_scores: dict[str, float] = {}
|
||||
platforms_seen: dict[str, dict] = {} # {platform: {total, cited}}
|
||||
|
||||
for c in citations:
|
||||
p = c.platform or "unknown"
|
||||
if p not in platforms_seen:
|
||||
platforms_seen[p] = {"total": 0, "cited": 0}
|
||||
platforms_seen[p]["total"] += 1
|
||||
if c.cited:
|
||||
platforms_seen[p]["cited"] += 1
|
||||
|
||||
for p, data in platforms_seen.items():
|
||||
rate = (data["cited"] / data["total"] * 100) if data["total"] > 0 else 0.0
|
||||
platform_scores[p] = round(rate, 2)
|
||||
|
||||
# 使用 ScoringService 计算 overall_score
|
||||
scoring_service = ScoringService()
|
||||
sentiment_counts = {"positive": 0, "neutral": 0, "negative": 0}
|
||||
for c in cited:
|
||||
sentiment = c.sentiment or "neutral"
|
||||
if sentiment in sentiment_counts:
|
||||
sentiment_counts[sentiment] += 1
|
||||
|
||||
from app.schemas.scoring import CitationResult
|
||||
citation_results = [
|
||||
CitationResult(
|
||||
cited=c.cited,
|
||||
position=c.citation_position,
|
||||
citation_text=c.citation_text,
|
||||
sentiment=c.sentiment or "neutral",
|
||||
confidence=c.confidence or 0.0,
|
||||
)
|
||||
for c in cited
|
||||
]
|
||||
positions = [c.citation_position for c in cited if c.cited]
|
||||
|
||||
# 获取竞品信息
|
||||
competitor_stmt = select(Competitor).where(Competitor.brand_id == brand_id)
|
||||
competitor_result = await db.execute(competitor_stmt)
|
||||
competitors = list(competitor_result.scalars().all())
|
||||
competitor_names = [c.name for c in competitors]
|
||||
competitor_mentions: dict[str, int] = {}
|
||||
for comp_name in competitor_names:
|
||||
count = sum(
|
||||
1 for c in citations
|
||||
if c.cited and c.competitor_brands and comp_name in c.competitor_brands
|
||||
)
|
||||
if count > 0:
|
||||
competitor_mentions[comp_name] = count
|
||||
|
||||
v2_result = scoring_service.calculate_v2(
|
||||
mentioned_count=len(cited),
|
||||
total_queries=total,
|
||||
positions=positions,
|
||||
sentiment_counts=sentiment_counts,
|
||||
citations=citation_results,
|
||||
brand_mentions=len(cited),
|
||||
competitor_mentions=competitor_mentions,
|
||||
)
|
||||
|
||||
# 生成 strengths/weaknesses
|
||||
strengths = []
|
||||
weaknesses = []
|
||||
|
||||
if total == 0:
|
||||
strengths.append("品牌已创建")
|
||||
weaknesses.append("尚无引用数据")
|
||||
else:
|
||||
mention_rate = len(cited) / total * 100 if total > 0 else 0
|
||||
if mention_rate >= 50:
|
||||
strengths.append(f"提及率较高 ({round(mention_rate, 1)}%)")
|
||||
else:
|
||||
weaknesses.append(f"提及率偏低 ({round(mention_rate, 1)}%)")
|
||||
|
||||
for p, score in platform_scores.items():
|
||||
if score >= 60:
|
||||
strengths.append(f"{p} 平台表现良好 ({score}%)")
|
||||
elif score > 0:
|
||||
weaknesses.append(f"{p} 平台覆盖率不足 ({score}%)")
|
||||
|
||||
if sentiment_counts["positive"] > sentiment_counts["negative"]:
|
||||
strengths.append("情感倾向正面")
|
||||
elif sentiment_counts["negative"] > sentiment_counts["positive"]:
|
||||
weaknesses.append("情感倾向偏负面")
|
||||
|
||||
if not strengths:
|
||||
strengths.append("已有初步引用数据")
|
||||
if not weaknesses:
|
||||
weaknesses.append("暂无明显短板")
|
||||
|
||||
# 竞品评分
|
||||
competitor_scores = []
|
||||
for comp_name, mentions in competitor_mentions.items():
|
||||
comp_score = round(mentions / total * 100, 2) if total > 0 else 0.0
|
||||
competitor_scores.append({
|
||||
"name": comp_name,
|
||||
"score": comp_score,
|
||||
})
|
||||
|
||||
return HealthReportResponse(
|
||||
brand_id=str(brand.id),
|
||||
brand_name=brand.name,
|
||||
overall_score=round(v2_result.overall_score, 2),
|
||||
platform_scores=platform_scores,
|
||||
strengths=strengths,
|
||||
weaknesses=weaknesses,
|
||||
competitor_scores=competitor_scores,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/action-suggestions/{brand_id}", response_model=ActionSuggestionsResponse)
|
||||
async def get_onboarding_action_suggestions(
|
||||
brand_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
根据健康报告生成行动建议(基于规则引擎,不需要 LLM)。
|
||||
"""
|
||||
# 先获取健康报告数据(复用逻辑)
|
||||
report = await get_onboarding_health_report(brand_id, current_user, db)
|
||||
|
||||
suggestions = []
|
||||
|
||||
# 规则引擎:基于评分和平台数据生成建议
|
||||
if report.overall_score < 20:
|
||||
suggestions.append(ActionSuggestion(
|
||||
title="提升 AI 平台覆盖率",
|
||||
description=f"当前综合评分仅 {report.overall_score},品牌在AI搜索中几乎未被提及。建议增加查询词覆盖面,让AI平台更频繁地引用品牌。",
|
||||
priority="high",
|
||||
action_type="coverage",
|
||||
))
|
||||
|
||||
if report.overall_score < 50:
|
||||
suggestions.append(ActionSuggestion(
|
||||
title="优化核心关键词",
|
||||
description="品牌在关键查询词下的提及率偏低,建议调整查询关键词策略,聚焦行业核心术语。",
|
||||
priority="high",
|
||||
action_type="keyword",
|
||||
))
|
||||
|
||||
# 平台维度建议
|
||||
for platform, score in report.platform_scores.items():
|
||||
if score < 30:
|
||||
suggestions.append(ActionSuggestion(
|
||||
title=f"提升 {platform} 平台覆盖率",
|
||||
description=f"品牌在 {platform} 平台的引用率仅为 {score}%,需要针对性优化该平台的内容策略。",
|
||||
priority="medium",
|
||||
action_type="platform",
|
||||
))
|
||||
|
||||
# 情感维度建议
|
||||
if "情感倾向偏负面" in report.weaknesses:
|
||||
suggestions.append(ActionSuggestion(
|
||||
title="改善品牌情感倾向",
|
||||
description="AI平台对品牌的情感评价偏负面,建议发布正面品牌内容、优化品牌描述以改善情感得分。",
|
||||
priority="medium",
|
||||
action_type="sentiment",
|
||||
))
|
||||
|
||||
# 竞品对比建议
|
||||
for comp in report.competitor_scores:
|
||||
if comp["score"] > report.overall_score:
|
||||
suggestions.append(ActionSuggestion(
|
||||
title=f"应对竞品 {comp['name']} 威胁",
|
||||
description=f"竞品 {comp['name']} 评分 ({comp['score']}) 高于本品牌 ({report.overall_score}),建议分析竞品优势领域并制定差异化策略。",
|
||||
priority="high",
|
||||
action_type="competitive",
|
||||
))
|
||||
|
||||
# 如果没有引用数据,给出基础建议
|
||||
if report.overall_score == 0:
|
||||
suggestions = [
|
||||
ActionSuggestion(
|
||||
title="设置核心查询词",
|
||||
description="品牌尚无查询数据,建议首先设置与品牌最相关的核心查询词,让系统开始数据采集。",
|
||||
priority="high",
|
||||
action_type="keyword",
|
||||
),
|
||||
ActionSuggestion(
|
||||
title="添加竞品对比",
|
||||
description="添加主要竞品以便进行对比分析,了解品牌在市场中的定位。",
|
||||
priority="medium",
|
||||
action_type="coverage",
|
||||
),
|
||||
ActionSuggestion(
|
||||
title="完善品牌信息",
|
||||
description="补充品牌别名、网站、行业等详细信息,有助于提升AI平台识别率。",
|
||||
priority="medium",
|
||||
action_type="brand_info",
|
||||
),
|
||||
]
|
||||
|
||||
# 确保至少有1条建议
|
||||
if not suggestions:
|
||||
suggestions.append(ActionSuggestion(
|
||||
title="持续监测品牌表现",
|
||||
description="品牌表现良好,建议持续监测并保持当前策略。",
|
||||
priority="low",
|
||||
action_type="monitor",
|
||||
))
|
||||
|
||||
return ActionSuggestionsResponse(suggestions=suggestions)
|
||||
|
||||
|
||||
@router.post("/complete/{brand_id}", response_model=OnboardingCompleteResponse)
|
||||
async def complete_onboarding(
|
||||
brand_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
标记用户已完成 onboarding。
|
||||
|
||||
通过验证品牌存在并归属当前用户来确认完成状态。
|
||||
User 模型当前没有 onboarding_completed 专用字段,
|
||||
品牌的创建即代表 onboarding 完成。
|
||||
"""
|
||||
# 验证品牌归属
|
||||
stmt = select(Brand).where(Brand.id == brand_id, Brand.user_id == current_user.id)
|
||||
result = await db.execute(stmt)
|
||||
brand = result.scalar_one_or_none()
|
||||
|
||||
if not brand:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="品牌不存在",
|
||||
)
|
||||
|
||||
# 品牌已创建即代表 onboarding 完成,无需额外字段更新
|
||||
# 后续如需专用字段,可通过 alembic 迁移添加 user.onboarding_completed
|
||||
logger.info(f"User {current_user.id} completed onboarding with brand {brand_id}")
|
||||
|
||||
return OnboardingCompleteResponse(success=True)
|
||||
|
|
@ -3,6 +3,7 @@ import uuid
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.base import PaginationParams, PaginatedResponse
|
||||
from app.api.deps import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
|
|
@ -16,12 +17,15 @@ router = APIRouter()
|
|||
|
||||
@router.get("/", response_model=QueryListResponse)
|
||||
async def list_queries(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
pagination: PaginationParams = Depends(PaginationParams),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
items, total = await get_queries(db, current_user.id, skip=skip, limit=limit)
|
||||
items, total = await get_queries(
|
||||
db, current_user.id,
|
||||
skip=pagination.offset,
|
||||
limit=pagination.limit,
|
||||
)
|
||||
return {"items": items, "total": total}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import sys
|
||||
from pathlib import Path
|
||||
from pydantic import field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from typing import Optional
|
||||
|
||||
_env_path = Path(__file__).resolve().parent.parent.parent / ".env"
|
||||
if not _env_path.exists():
|
||||
|
|
@ -11,10 +14,15 @@ class Settings(BaseSettings):
|
|||
|
||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres123@db:5432/geo_platform"
|
||||
REDIS_URL: str = "redis://redis:6379/0"
|
||||
JWT_SECRET: str = "your-secret-key-change-in-production"
|
||||
|
||||
# JWT 密钥:必须通过环境变量设置,不提供任何默认值
|
||||
JWT_SECRET: str
|
||||
JWT_EXPIRE_HOURS: int = 24
|
||||
|
||||
# NextAuth 密钥
|
||||
SECRET_KEY: Optional[str] = None
|
||||
|
||||
PLAYWRIGHT_BROWSERS_PATH: str = "/ms-playwright"
|
||||
DEEPSEEK_API_KEY: str = ""
|
||||
ENABLE_LLM: bool = False
|
||||
ZHIPU_API_KEY: str = ""
|
||||
TONGYI_API_KEY: str = ""
|
||||
|
|
@ -41,5 +49,36 @@ class Settings(BaseSettings):
|
|||
# AI平台API调用频率限制(每分钟请求数)
|
||||
API_RATE_LIMIT_RPM: int = 10
|
||||
|
||||
@field_validator("JWT_SECRET")
|
||||
@classmethod
|
||||
def validate_jwt_secret(cls, v: str) -> str:
|
||||
if not v or v.strip() == "":
|
||||
print(
|
||||
"[FATAL] JWT_SECRET is not set. "
|
||||
"Please set a strong secret key (>= 32 characters) in your .env file.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if len(v) < 32:
|
||||
print(
|
||||
f"[FATAL] JWT_SECRET is too short ({len(v)} chars). "
|
||||
"It must be at least 32 characters long.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_secret_key(self) -> "Settings":
|
||||
if self.SECRET_KEY is not None:
|
||||
if len(self.SECRET_KEY) < 32:
|
||||
print(
|
||||
f"[FATAL] SECRET_KEY is too short ({len(self.SECRET_KEY)} chars). "
|
||||
"It must be at least 32 characters long.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
return self
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=False,
|
||||
future=True,
|
||||
pool_size=10, # 连接池大小
|
||||
max_overflow=20, # 最大溢出连接数
|
||||
pool_timeout=30, # 等待连接超时(秒)
|
||||
pool_recycle=3600, # 连接回收时间(1小时)
|
||||
pool_pre_ping=True, # 使用前 ping 检查连接有效性
|
||||
echo=False, # 生产环境关闭 SQL echo
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
|
|
@ -26,3 +31,13 @@ async def get_db() -> AsyncSession:
|
|||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def check_db_connection() -> bool:
|
||||
"""检查数据库连接是否正常"""
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
"""结构化 JSON 日志配置模块。"""
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class JSONFormatter(logging.Formatter):
|
||||
"""将日志记录格式化为 JSON 字符串,便于日志收集平台(如 ELK、Loki)解析。"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
log_entry: dict = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"level": record.levelname,
|
||||
"message": record.getMessage(),
|
||||
"logger": record.name,
|
||||
"module": record.module,
|
||||
"function": record.funcName,
|
||||
"line": record.lineno,
|
||||
}
|
||||
|
||||
if record.exc_info:
|
||||
log_entry["exception"] = self.formatException(record.exc_info)
|
||||
|
||||
# 从 extra 字段注入的可观测性上下文
|
||||
if hasattr(record, "user_id"):
|
||||
log_entry["user_id"] = record.user_id
|
||||
if hasattr(record, "request_id"):
|
||||
log_entry["request_id"] = record.request_id
|
||||
if hasattr(record, "path"):
|
||||
log_entry["path"] = record.path
|
||||
if hasattr(record, "method"):
|
||||
log_entry["method"] = record.method
|
||||
if hasattr(record, "duration_ms"):
|
||||
log_entry["duration_ms"] = record.duration_ms
|
||||
if hasattr(record, "status_code"):
|
||||
log_entry["status_code"] = record.status_code
|
||||
|
||||
return json.dumps(log_entry, ensure_ascii=False)
|
||||
|
||||
|
||||
def setup_logging(level: int = logging.INFO) -> None:
|
||||
"""初始化全局 JSON 日志配置。
|
||||
|
||||
应在应用启动时(import 其他模块之前)调用一次。
|
||||
"""
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(JSONFormatter())
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
# 清空已有 handlers,避免重复输出
|
||||
root_logger.handlers.clear()
|
||||
root_logger.addHandler(handler)
|
||||
root_logger.setLevel(level)
|
||||
|
||||
# 降低 uvicorn/sqlalchemy 等第三方库的噪音
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
||||
|
|
@ -1,12 +1,17 @@
|
|||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException, Request, Depends
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
|
||||
# 必须在其他模块 import 之前初始化 JSON 日志
|
||||
from app.logging_config import setup_logging
|
||||
setup_logging()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s"
|
||||
)
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.admin import router as admin_router
|
||||
|
|
@ -23,10 +28,18 @@ from app.api.citations import router as citations_router
|
|||
from app.api.queries import router as queries_router
|
||||
from app.api.reports import router as reports_router
|
||||
from app.api.subscriptions import router as subscription_router
|
||||
from app.api.alerts import router as alerts_router
|
||||
from app.api.dashboard import router as dashboard_router
|
||||
from app.api.brands import router as brands_router
|
||||
from app.api.onboarding import router as onboarding_router
|
||||
from app.config import settings
|
||||
from app.database import engine, Base
|
||||
from app.schemas.common import ErrorResponse, ErrorCode
|
||||
from app.middleware.rate_limit import RateLimitMiddleware
|
||||
from app.middleware.logging_middleware import RequestLoggingMiddleware
|
||||
from app.middleware.request_id import RequestIdMiddleware
|
||||
from app.middleware.metrics import MetricsMiddleware
|
||||
from app.database import get_db
|
||||
from app.workers.scheduler import query_scheduler
|
||||
|
||||
|
||||
|
|
@ -50,6 +63,45 @@ app = FastAPI(
|
|||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
"""统一 HTTP 异常响应格式。"""
|
||||
code = ErrorCode.from_status(exc.status_code)
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=ErrorResponse(
|
||||
detail=str(exc.detail),
|
||||
code=code,
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
"""统一参数校验异常响应格式。"""
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=ErrorResponse(
|
||||
detail="请求参数校验失败",
|
||||
code=ErrorCode.VALIDATION_ERROR,
|
||||
extra={"errors": exc.errors()},
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""兜底异常处理器,避免内部错误泄漏给客户端。"""
|
||||
logging.getLogger(__name__).exception("Unhandled exception: %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=ErrorResponse(
|
||||
detail="服务器内部错误,请稍后重试",
|
||||
code=ErrorCode.INTERNAL_ERROR,
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
_allow_origins = [origin.strip() for origin in settings.CORS_ORIGINS.split(",") if origin.strip()]
|
||||
if not _allow_origins:
|
||||
_allow_origins = ["http://localhost:3000"]
|
||||
|
|
@ -72,11 +124,12 @@ async def add_security_headers(request, call_next):
|
|||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
return response
|
||||
|
||||
# 限流中间件
|
||||
app.add_middleware(RateLimitMiddleware)
|
||||
|
||||
# 请求日志中间件
|
||||
# 中间件注册顺序(FastAPI 后进先出,最后注册的最先执行)
|
||||
# 执行链:RequestId → Metrics → RateLimit → RequestLogging → CORS → SecurityHeaders
|
||||
app.add_middleware(RequestLoggingMiddleware)
|
||||
app.add_middleware(RateLimitMiddleware)
|
||||
app.add_middleware(MetricsMiddleware)
|
||||
app.add_middleware(RequestIdMiddleware)
|
||||
|
||||
app.include_router(auth_router, prefix="/api/v1/auth", tags=["认证"])
|
||||
app.include_router(queries_router, prefix="/api/v1/queries", tags=["查询词"])
|
||||
|
|
@ -92,8 +145,57 @@ app.include_router(contents_router, prefix="/api/v1/contents", tags=["内容管
|
|||
app.include_router(clients_router, prefix="/api/v1/clients", tags=["客户管理"])
|
||||
app.include_router(distribution_router, prefix="/api/v1/distribution", tags=["内容分发"])
|
||||
app.include_router(analytics_router, prefix="/api/v1/analytics", tags=["监测优化"])
|
||||
app.include_router(alerts_router, prefix="/api/v1/alerts", tags=["告警通知"])
|
||||
app.include_router(dashboard_router, prefix="/api/v1/dashboard", tags=["仪表盘"])
|
||||
app.include_router(brands_router, prefix="/api/v1/brands", tags=["品牌管理"])
|
||||
app.include_router(onboarding_router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@app.get("/health", tags=["可观测性"])
|
||||
async def health_check():
|
||||
return {"status": "ok"}
|
||||
"""存活检查(Liveness):服务进程是否运行正常。不依赖外部服务。"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/ready", tags=["可观测性"])
|
||||
async def readiness_check(db: AsyncSession = Depends(get_db)):
|
||||
"""就绪检查(Readiness):依赖服务(DB / Redis)是否就绪。
|
||||
|
||||
供 Kubernetes readinessProbe / Docker healthcheck 使用。
|
||||
不需要认证。
|
||||
"""
|
||||
import redis.asyncio as aioredis # type: ignore
|
||||
from app.config import settings as _settings
|
||||
|
||||
# --- 检查数据库 ---
|
||||
try:
|
||||
await db.execute(text("SELECT 1"))
|
||||
db_ok = True
|
||||
except Exception:
|
||||
db_ok = False
|
||||
|
||||
# --- 检查 Redis ---
|
||||
redis_ok = False
|
||||
try:
|
||||
redis_client = aioredis.from_url(_settings.REDIS_URL, socket_connect_timeout=2)
|
||||
await redis_client.ping()
|
||||
await redis_client.aclose()
|
||||
redis_ok = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
all_ok = db_ok and redis_ok
|
||||
return JSONResponse(
|
||||
status_code=200 if all_ok else 503,
|
||||
content={
|
||||
"status": "ready" if all_ok else "not_ready",
|
||||
"checks": {
|
||||
"database": "ok" if db_ok else "error",
|
||||
"redis": "ok" if redis_ok else "error",
|
||||
},
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
"""请求指标收集中间件:计时、慢请求告警、响应时间响应头。"""
|
||||
import time
|
||||
import logging
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
logger = logging.getLogger("geo.metrics")
|
||||
|
||||
# 慢请求阈值(秒)
|
||||
SLOW_REQUEST_THRESHOLD = 1.0
|
||||
|
||||
# 跳过指标收集的路径前缀(健康检查等高频低价值路径)
|
||||
_SKIP_PATHS = {"/health", "/ready", "/docs", "/openapi.json", "/favicon.ico"}
|
||||
|
||||
|
||||
class MetricsMiddleware(BaseHTTPMiddleware):
|
||||
"""记录每个 HTTP 请求的耗时,并:
|
||||
- 在响应头写入 X-Response-Time
|
||||
- 对超过阈值的慢请求输出 WARNING 日志(携带结构化字段)
|
||||
- 预留 Sentry / Prometheus 集成点(TODO 注释标注)
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
# 跳过健康检查等低价值路径,避免日志噪音
|
||||
if request.url.path in _SKIP_PATHS:
|
||||
return await call_next(request)
|
||||
|
||||
start_time = time.perf_counter()
|
||||
response = await call_next(request)
|
||||
duration = time.perf_counter() - start_time
|
||||
duration_ms = round(duration * 1000, 2)
|
||||
|
||||
# 写回响应时间响应头
|
||||
response.headers["X-Response-Time"] = f"{duration:.3f}s"
|
||||
|
||||
# 从 request.state 获取 request_id(由 RequestIdMiddleware 注入)
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
|
||||
log_extra: dict = {
|
||||
"path": request.url.path,
|
||||
"method": request.method,
|
||||
"duration_ms": duration_ms,
|
||||
"status_code": response.status_code,
|
||||
}
|
||||
if request_id:
|
||||
log_extra["request_id"] = request_id
|
||||
|
||||
if duration >= SLOW_REQUEST_THRESHOLD:
|
||||
logger.warning("Slow request detected", extra=log_extra)
|
||||
else:
|
||||
logger.debug("Request completed", extra=log_extra)
|
||||
|
||||
# TODO: 集成 Prometheus Counter/Histogram
|
||||
# metrics_registry.http_request_duration.observe(duration, labels={...})
|
||||
|
||||
# TODO: 集成 Sentry 性能监控
|
||||
# if sentry_sdk: sentry_sdk.set_measurement("response_time_ms", duration_ms)
|
||||
|
||||
return response
|
||||
|
|
@ -7,6 +7,26 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
|
||||
def _extract_user_id_from_request(request: Request) -> str | None:
|
||||
"""尝试从 Authorization header 解析 user_id(JWT sub)。
|
||||
解析失败时返回 None,不影响主流程。
|
||||
"""
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return None
|
||||
token = auth_header[len("Bearer "):]
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
from app.services.auth import verify_token
|
||||
payload = verify_token(token)
|
||||
user_id: str | None = payload.get("sub")
|
||||
return user_id
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(self, app):
|
||||
super().__init__(app)
|
||||
|
|
@ -15,9 +35,14 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||
|
||||
# 限流规则
|
||||
self.rules = {
|
||||
"auth": { # /api/v1/auth/login, register, forgot-password
|
||||
"paths": ["/api/v1/auth/login", "/api/v1/auth/register", "/api/v1/auth/forgot-password"],
|
||||
"max_requests": 100,
|
||||
"auth_strict": { # /api/v1/auth/login, register - 严格限流 5次/分钟/IP
|
||||
"paths": ["/api/v1/auth/login", "/api/v1/auth/register"],
|
||||
"max_requests": 5,
|
||||
"window_seconds": 60,
|
||||
},
|
||||
"auth": { # /api/v1/auth/ 其余接口
|
||||
"paths": ["/api/v1/auth/forgot-password", "/api/v1/auth/refresh"],
|
||||
"max_requests": 20,
|
||||
"window_seconds": 60,
|
||||
},
|
||||
"query_run": { # run-now
|
||||
|
|
@ -35,37 +60,55 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||
client_ip = request.client.host if request.client else "unknown"
|
||||
path = request.url.path
|
||||
now = time.time()
|
||||
|
||||
|
||||
# 健康检查不限流
|
||||
if path == "/health" or path.startswith("/docs") or path.startswith("/openapi"):
|
||||
return await call_next(request)
|
||||
|
||||
# 检查认证接口限流
|
||||
if any(path == p for p in self.rules["auth"]["paths"]):
|
||||
|
||||
# 尝试从 Authorization header 解析 user_id
|
||||
user_id = _extract_user_id_from_request(request)
|
||||
|
||||
# 检查严格限流认证接口(login/register:5次/分钟/IP)
|
||||
if any(path == p for p in self.rules["auth_strict"]["paths"]):
|
||||
key = f"auth_strict:{client_ip}"
|
||||
if self._is_rate_limited(key, now, self.rules["auth_strict"]):
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "请求过于频繁,请稍后再试"}
|
||||
)
|
||||
|
||||
# 检查普通认证接口限流
|
||||
elif any(path == p for p in self.rules["auth"]["paths"]):
|
||||
key = f"auth:{client_ip}"
|
||||
if self._is_rate_limited(key, now, self.rules["auth"]):
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "请求过于频繁,请稍后再试"}
|
||||
)
|
||||
|
||||
# 检查查询执行限流
|
||||
|
||||
# 检查查询执行限流(基于用户ID+IP组合)
|
||||
if path.endswith("/run-now") and request.method == "POST":
|
||||
key = f"query_run:{client_ip}"
|
||||
if user_id:
|
||||
key = f"query_run:{user_id}:{client_ip}"
|
||||
else:
|
||||
key = f"query_run:{client_ip}"
|
||||
if self._is_rate_limited(key, now, self.rules["query_run"]):
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "查询执行过于频繁,请稍后再试"}
|
||||
)
|
||||
|
||||
# 全局限流
|
||||
key = f"global:{client_ip}"
|
||||
|
||||
# 全局限流(基于用户ID+IP组合,未认证请求按IP限流)
|
||||
if user_id:
|
||||
key = f"global:{user_id}:{client_ip}"
|
||||
else:
|
||||
key = f"global:{client_ip}"
|
||||
if self._is_rate_limited(key, now, self.rules["global"]):
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "请求过于频繁,请稍后再试"}
|
||||
)
|
||||
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
def _is_rate_limited(self, key, now, rule):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
"""Request ID 中间件:为每个请求生成并传播唯一标识符。"""
|
||||
import uuid
|
||||
import logging
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
logger = logging.getLogger("geo.request_id")
|
||||
|
||||
REQUEST_ID_HEADER = "X-Request-ID"
|
||||
|
||||
|
||||
class RequestIdMiddleware(BaseHTTPMiddleware):
|
||||
"""从请求头读取或自动生成 X-Request-ID,注入 request.state 并写回响应头。
|
||||
|
||||
使用场景:
|
||||
- 链路追踪:日志中携带 request_id 方便跨服务排查
|
||||
- 客户端可主动传入 X-Request-ID,实现端到端追踪
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
request_id = request.headers.get(REQUEST_ID_HEADER) or str(uuid.uuid4())
|
||||
|
||||
# 注入到 request.state,业务代码可通过 request.state.request_id 读取
|
||||
request.state.request_id = request_id
|
||||
|
||||
response = await call_next(request)
|
||||
response.headers[REQUEST_ID_HEADER] = request_id
|
||||
return response
|
||||
|
|
@ -58,4 +58,16 @@ class UserResponse(BaseModel):
|
|||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
refresh_token: str
|
||||
user: UserResponse
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class AccessTokenResponse(BaseModel):
|
||||
"""刷新接口返回:新 access_token + 新 refresh_token(滑动过期)"""
|
||||
access_token: str
|
||||
token_type: str
|
||||
refresh_token: str
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
"""通用响应 Schema。"""
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""统一错误响应格式。"""
|
||||
|
||||
detail: str
|
||||
code: str # 如 "NOT_FOUND", "VALIDATION_ERROR", "INTERNAL_ERROR", "FORBIDDEN"
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
extra: dict[str, Any] | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# 常用错误码常量
|
||||
class ErrorCode:
|
||||
NOT_FOUND = "NOT_FOUND"
|
||||
VALIDATION_ERROR = "VALIDATION_ERROR"
|
||||
INTERNAL_ERROR = "INTERNAL_ERROR"
|
||||
FORBIDDEN = "FORBIDDEN"
|
||||
UNAUTHORIZED = "UNAUTHORIZED"
|
||||
CONFLICT = "CONFLICT"
|
||||
BAD_REQUEST = "BAD_REQUEST"
|
||||
RATE_LIMITED = "RATE_LIMITED"
|
||||
|
||||
# HTTP 状态码 → 错误码 映射
|
||||
STATUS_CODE_MAP: dict[int, str] = {
|
||||
400: "BAD_REQUEST",
|
||||
401: "UNAUTHORIZED",
|
||||
403: "FORBIDDEN",
|
||||
404: "NOT_FOUND",
|
||||
409: "CONFLICT",
|
||||
422: "VALIDATION_ERROR",
|
||||
429: "RATE_LIMITED",
|
||||
500: "INTERNAL_ERROR",
|
||||
503: "SERVICE_UNAVAILABLE",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_status(cls, status_code: int) -> str:
|
||||
return cls.STATUS_CODE_MAP.get(status_code, "INTERNAL_ERROR")
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, model_validator
|
||||
|
||||
|
||||
# ---------- Request ----------
|
||||
|
|
@ -36,10 +36,12 @@ class StageDetailResponse(BaseModel):
|
|||
class ProjectResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
organization_id: uuid.UUID
|
||||
name: str = "" # alias for brand_name for frontend compatibility
|
||||
brand_name: str
|
||||
brand_aliases: list
|
||||
current_stage: int
|
||||
current_stage: str # mapped from int to string ("diagnosis" etc) for frontend
|
||||
status: str
|
||||
owner_id: uuid.UUID | None = None # alias for created_by for frontend
|
||||
created_by: uuid.UUID | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
|
@ -47,11 +49,39 @@ class ProjectResponse(BaseModel):
|
|||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def map_fields(cls, data):
|
||||
# Handle SQLAlchemy model instance
|
||||
if hasattr(data, "current_stage"):
|
||||
stage_int = getattr(data, "current_stage", 1)
|
||||
stage_map = {1: "diagnosis", 2: "strategy", 3: "content", 4: "publishing", 5: "monitoring"}
|
||||
if isinstance(stage_int, int):
|
||||
object.__setattr__(data, "current_stage", stage_map.get(stage_int, "diagnosis"))
|
||||
# Set name = brand_name for frontend
|
||||
if not getattr(data, "name", None):
|
||||
object.__setattr__(data, "name", getattr(data, "brand_name", ""))
|
||||
# Set owner_id = created_by for frontend
|
||||
if not getattr(data, "owner_id", None):
|
||||
object.__setattr__(data, "owner_id", getattr(data, "created_by", None))
|
||||
elif isinstance(data, dict):
|
||||
stage_val = data.get("current_stage", 1)
|
||||
stage_map = {1: "diagnosis", 2: "strategy", 3: "content", 4: "publishing", 5: "monitoring"}
|
||||
if isinstance(stage_val, int):
|
||||
data["current_stage"] = stage_map.get(stage_val, "diagnosis")
|
||||
data.setdefault("name", data.get("brand_name", ""))
|
||||
data.setdefault("owner_id", data.get("created_by"))
|
||||
return data
|
||||
|
||||
|
||||
class ProjectStatsResponse(BaseModel):
|
||||
total_projects: int
|
||||
active_projects: int
|
||||
stage_distribution: dict[str, int]
|
||||
completed_projects: int = 0
|
||||
contents_produced: int = 0
|
||||
avg_ai_citation_rate: float | None = None
|
||||
current_stage_distribution: dict[str, int] = {}
|
||||
stage_distribution: dict[str, int] = {}
|
||||
completion_rate: float
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, select, case
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.citation_record import CitationRecord
|
||||
|
|
@ -64,17 +64,26 @@ async def get_users(
|
|||
base_stmt = base_stmt.order_by(User.created_at.desc()).offset(skip).limit(limit)
|
||||
|
||||
result = await db.execute(base_stmt)
|
||||
users = result.scalars().all()
|
||||
users = list(result.scalars().all())
|
||||
|
||||
count_result = await db.execute(count_stmt)
|
||||
total = count_result.scalar_one()
|
||||
|
||||
if not users:
|
||||
return {"items": [], "total": total}
|
||||
|
||||
# 修复 N+1:一次性批量获取所有用户的 query 计数
|
||||
user_ids = [u.id for u in users]
|
||||
query_count_stmt = (
|
||||
select(Query.user_id, func.count().label("cnt"))
|
||||
.where(Query.user_id.in_(user_ids))
|
||||
.group_by(Query.user_id)
|
||||
)
|
||||
qc_result = await db.execute(query_count_stmt)
|
||||
query_counts: dict = {row.user_id: row.cnt for row in qc_result.all()}
|
||||
|
||||
items = []
|
||||
for user in users:
|
||||
query_count_result = await db.execute(
|
||||
select(func.count()).select_from(Query).where(Query.user_id == user.id)
|
||||
)
|
||||
query_count = query_count_result.scalar_one()
|
||||
items.append(
|
||||
{
|
||||
"id": user.id,
|
||||
|
|
@ -84,7 +93,7 @@ async def get_users(
|
|||
"is_active": user.is_active,
|
||||
"is_admin": user.is_admin,
|
||||
"email_verified": user.email_verified,
|
||||
"query_count": query_count,
|
||||
"query_count": query_counts.get(user.id, 0),
|
||||
"created_at": user.created_at,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -25,14 +25,41 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|||
|
||||
def create_access_token(data: dict) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(hours=settings.JWT_EXPIRE_HOURS)
|
||||
to_encode.update({"exp": expire})
|
||||
# access token 有效期固定为 1 小时(替代原来的 JWT_EXPIRE_HOURS=24h)
|
||||
expire = datetime.utcnow() + timedelta(hours=1)
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET, algorithm="HS256")
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def create_refresh_token(data: dict) -> str:
|
||||
"""7 天有效期的刷新令牌,使用 type: 'refresh' 区分"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(days=7)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET, algorithm="HS256")
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def verify_refresh_token(token: str) -> dict:
|
||||
"""验证 refresh token,返回 payload;如果无效或类型不匹配则抛出异常"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
|
||||
except JWTError:
|
||||
raise ValueError("刷新令牌无效")
|
||||
if payload.get("type") != "refresh":
|
||||
raise ValueError("令牌类型错误")
|
||||
return payload
|
||||
|
||||
|
||||
def verify_token(token: str) -> dict:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
|
||||
"""验证 access token,返回 payload"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
|
||||
except JWTError:
|
||||
raise ValueError("访问令牌无效")
|
||||
if payload.get("type") not in ("access", None): # None 兼容旧 token
|
||||
raise ValueError("令牌类型错误")
|
||||
return payload
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
"""Redis 缓存服务层。
|
||||
|
||||
提供统一的缓存读写接口,供各 API 端点使用:
|
||||
- 品牌列表(TTL: 5 分钟)
|
||||
- 仪表盘统计数据(TTL: 2 分钟)
|
||||
- 用户配置信息(TTL: 10 分钟)
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# TTL 常量(秒)
|
||||
TTL_BRANDS = 300 # 5 分钟
|
||||
TTL_DASHBOARD = 120 # 2 分钟
|
||||
TTL_USER_PROFILE = 600 # 10 分钟
|
||||
|
||||
|
||||
class CacheService:
|
||||
"""异步 Redis 缓存服务。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._redis: aioredis.Redis | None = None
|
||||
|
||||
@property
|
||||
def redis(self) -> aioredis.Redis:
|
||||
if self._redis is None:
|
||||
self._redis = aioredis.from_url(
|
||||
settings.REDIS_URL,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
)
|
||||
return self._redis
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
"""从缓存读取字符串值,不存在或出错时返回 None。"""
|
||||
try:
|
||||
return await self.redis.get(key)
|
||||
except Exception as exc:
|
||||
logger.warning("Cache GET failed for key=%s: %s", key, exc)
|
||||
return None
|
||||
|
||||
async def get_json(self, key: str) -> Any | None:
|
||||
"""从缓存读取并反序列化 JSON 值。"""
|
||||
raw = await self.get(key)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
async def set(self, key: str, value: str, expire: int = 300) -> None:
|
||||
"""写入缓存字符串值,expire 单位为秒。"""
|
||||
try:
|
||||
await self.redis.set(key, value, ex=expire)
|
||||
except Exception as exc:
|
||||
logger.warning("Cache SET failed for key=%s: %s", key, exc)
|
||||
|
||||
async def set_json(self, key: str, value: Any, expire: int = 300) -> None:
|
||||
"""序列化为 JSON 后写入缓存。"""
|
||||
try:
|
||||
await self.set(key, json.dumps(value, default=str), expire=expire)
|
||||
except Exception as exc:
|
||||
logger.warning("Cache SET_JSON failed for key=%s: %s", key, exc)
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
"""删除指定缓存键。"""
|
||||
try:
|
||||
await self.redis.delete(key)
|
||||
except Exception as exc:
|
||||
logger.warning("Cache DELETE failed for key=%s: %s", key, exc)
|
||||
|
||||
async def invalidate_pattern(self, pattern: str) -> int:
|
||||
"""批量删除匹配 pattern 的所有缓存键,返回删除数量。"""
|
||||
try:
|
||||
keys = await self.redis.keys(pattern)
|
||||
if keys:
|
||||
return await self.redis.delete(*keys)
|
||||
return 0
|
||||
except Exception as exc:
|
||||
logger.warning("Cache INVALIDATE_PATTERN failed for pattern=%s: %s", pattern, exc)
|
||||
return 0
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭 Redis 连接。"""
|
||||
if self._redis is not None:
|
||||
await self._redis.aclose()
|
||||
self._redis = None
|
||||
|
||||
|
||||
# 模块级单例(懒加载,应用启动后自动创建连接池)
|
||||
_cache_service: CacheService | None = None
|
||||
|
||||
|
||||
def get_cache_service() -> CacheService:
|
||||
"""获取全局缓存服务单例。"""
|
||||
global _cache_service
|
||||
if _cache_service is None:
|
||||
_cache_service = CacheService()
|
||||
return _cache_service
|
||||
|
|
@ -289,6 +289,7 @@ async def trigger_query_now(
|
|||
keyword=query.keyword,
|
||||
target_brand=query.target_brand,
|
||||
brand_aliases=query.brand_aliases or [],
|
||||
user_id=user_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -301,11 +302,19 @@ async def _execute_query_tasks(
|
|||
keyword: str,
|
||||
target_brand: str,
|
||||
brand_aliases: list,
|
||||
user_id: uuid.UUID | None = None,
|
||||
):
|
||||
"""后台执行查询任务"""
|
||||
engine = CitationEngine()
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
# 验证 query 归属该用户
|
||||
if user_id is not None:
|
||||
query = await _verify_query_ownership(db, query_id, user_id)
|
||||
if query is None:
|
||||
logger.error(f"查询 {query_id} 不属于用户 {user_id},跳过执行")
|
||||
return
|
||||
|
||||
stmt = select(QueryTask).where(
|
||||
QueryTask.query_id == query_id,
|
||||
QueryTask.status == "pending",
|
||||
|
|
|
|||
|
|
@ -23,7 +23,16 @@ class RAGService:
|
|||
|
||||
def __init__(self, embedder: Optional[EmbeddingService] = None):
|
||||
self.chunker = RecursiveChunker()
|
||||
self.embedder = embedder or MockEmbedder() # 默认 mock,生产注入 OpenAIEmbedder
|
||||
if embedder is not None:
|
||||
self.embedder = embedder
|
||||
else:
|
||||
from app.config import settings
|
||||
if settings.OPENAI_API_KEY:
|
||||
from app.services.knowledge.embedder import OpenAIEmbedder
|
||||
self.embedder = OpenAIEmbedder(api_key=settings.OPENAI_API_KEY)
|
||||
else:
|
||||
logger.warning("未配置 OPENAI_API_KEY,知识库将使用 MockEmbedder(仅适用于开发环境)")
|
||||
self.embedder = MockEmbedder()
|
||||
self.retriever = HybridRetriever(self.embedder)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from .base import LLMError, LLMProvider, LLMResponse
|
|||
from .deepseek_provider import DeepSeekProvider
|
||||
from .factory import LLMFactory
|
||||
from .openai_provider import OpenAIProvider
|
||||
from .rate_limiter import TokenBucketRateLimiter, get_rate_limiter
|
||||
|
||||
__all__ = [
|
||||
"LLMProvider",
|
||||
|
|
@ -10,4 +11,6 @@ __all__ = [
|
|||
"LLMFactory",
|
||||
"OpenAIProvider",
|
||||
"DeepSeekProvider",
|
||||
"TokenBucketRateLimiter",
|
||||
"get_rate_limiter",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from typing import AsyncIterator
|
|||
import httpx
|
||||
|
||||
from .base import LLMError, LLMProvider, LLMResponse
|
||||
from .rate_limiter import get_rate_limiter
|
||||
|
||||
_DEFAULT_MODEL = "deepseek-chat"
|
||||
_DEFAULT_MAX_CONTEXT = 64_000
|
||||
|
|
@ -111,6 +112,9 @@ class DeepSeekProvider(LLMProvider):
|
|||
|
||||
async def _request_with_retry(self, payload: dict, *, stream: bool = False) -> dict:
|
||||
"""带重试的请求(指数退避:1s, 2s, 4s)"""
|
||||
# 全局速率限制
|
||||
await get_rate_limiter().acquire()
|
||||
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(_MAX_RETRIES):
|
||||
|
|
@ -149,6 +153,9 @@ class DeepSeekProvider(LLMProvider):
|
|||
|
||||
async def _stream_request(self, payload: dict) -> AsyncIterator[str]:
|
||||
"""SSE流式请求(OpenAI兼容格式)"""
|
||||
# 全局速率限制
|
||||
await get_rate_limiter().acquire()
|
||||
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(_MAX_RETRIES):
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from typing import AsyncIterator
|
|||
import httpx
|
||||
|
||||
from .base import LLMError, LLMProvider, LLMResponse
|
||||
from .rate_limiter import get_rate_limiter
|
||||
|
||||
# 支持的模型及其上下文长度(百炼 Coding Plan + OpenAI)
|
||||
_OPENAI_MODELS: dict[str, int] = {
|
||||
|
|
@ -126,6 +127,9 @@ class OpenAIProvider(LLMProvider):
|
|||
|
||||
async def _request_with_retry(self, payload: dict, *, stream: bool = False) -> dict:
|
||||
"""带重试的请求(指数退避:1s, 2s, 4s)"""
|
||||
# 全局速率限制
|
||||
await get_rate_limiter().acquire()
|
||||
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(_MAX_RETRIES):
|
||||
|
|
@ -166,6 +170,9 @@ class OpenAIProvider(LLMProvider):
|
|||
|
||||
async def _stream_request(self, payload: dict) -> AsyncIterator[str]:
|
||||
"""SSE流式请求"""
|
||||
# 全局速率限制
|
||||
await get_rate_limiter().acquire()
|
||||
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(_MAX_RETRIES):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
"""LLM 调用全局限速器(令牌桶算法)
|
||||
|
||||
所有 LLMProvider 实例共享同一个 RateLimiter 单例,
|
||||
确保跨 Provider 的总调用频率不超过配置上限。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TokenBucketRateLimiter:
|
||||
"""基于令牌桶算法的速率限制器
|
||||
|
||||
默认 30 RPM(每秒补充约 0.5 个令牌),可通过环境变量
|
||||
LLM_RATE_LIMIT_RPM 调整。
|
||||
"""
|
||||
|
||||
_instance: "TokenBucketRateLimiter | None" = None
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_rpm: float = 30.0,
|
||||
):
|
||||
self._max_rpm = max_rpm
|
||||
self._refill_rate = max_rpm / 60.0 # tokens per second
|
||||
self._max_tokens = max_rpm
|
||||
self._tokens = max_rpm # start full
|
||||
self._last_refill = time.monotonic()
|
||||
self._semaphore = asyncio.Semaphore(1) # 一次只能有一个 acquire 等待
|
||||
# 用于记录最近请求时间(调试/监控用)
|
||||
self._recent_requests: deque[float] = deque(maxlen=int(max_rpm * 2))
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> "TokenBucketRateLimiter":
|
||||
"""获取全局单例"""
|
||||
if cls._instance is None:
|
||||
rpm = float(os.getenv("LLM_RATE_LIMIT_RPM", "30"))
|
||||
cls._instance = cls(max_rpm=rpm)
|
||||
logger.info(f"LLM RateLimiter initialized: {rpm} RPM")
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
async def reset_instance(cls) -> None:
|
||||
"""重置单例(仅用于测试)"""
|
||||
async with cls._lock:
|
||||
if cls._instance is not None:
|
||||
cls._instance = None
|
||||
|
||||
def _refill(self) -> None:
|
||||
"""补充令牌"""
|
||||
now = time.monotonic()
|
||||
elapsed = now - self._last_refill
|
||||
tokens_to_add = elapsed * self._refill_rate
|
||||
self._tokens = min(self._max_tokens, self._tokens + tokens_to_add)
|
||||
self._last_refill = now
|
||||
|
||||
async def acquire(self) -> None:
|
||||
"""获取一个令牌,若无可用令牌则等待
|
||||
|
||||
此方法可安全地从多个协程并发调用。
|
||||
"""
|
||||
async with self._semaphore:
|
||||
self._refill()
|
||||
|
||||
if self._tokens >= 1.0:
|
||||
self._tokens -= 1.0
|
||||
self._recent_requests.append(time.monotonic())
|
||||
return
|
||||
|
||||
# 如果没有可用令牌,计算等待时间
|
||||
wait_time = (1.0 - self._tokens) / self._refill_rate
|
||||
if wait_time > 0:
|
||||
logger.debug(f"LLM rate limiter: waiting {wait_time:.2f}s for token")
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
# 重试获取
|
||||
async with self._semaphore:
|
||||
self._refill()
|
||||
self._tokens = max(0.0, self._tokens - 1.0)
|
||||
self._recent_requests.append(time.monotonic())
|
||||
|
||||
@property
|
||||
def available_tokens(self) -> float:
|
||||
"""当前可用令牌数"""
|
||||
return self._tokens
|
||||
|
||||
@property
|
||||
def max_rpm(self) -> float:
|
||||
"""配置的最大 RPM"""
|
||||
return self._max_rpm
|
||||
|
||||
|
||||
# 模块级便捷函数
|
||||
_rate_limiter: TokenBucketRateLimiter | None = None
|
||||
|
||||
|
||||
def get_rate_limiter() -> TokenBucketRateLimiter:
|
||||
"""获取全局速率限制器实例"""
|
||||
return TokenBucketRateLimiter.get_instance()
|
||||
|
|
@ -31,32 +31,47 @@ class LLMAdapterError(Exception):
|
|||
|
||||
|
||||
class LLMAdapter:
|
||||
"""LLM适配器 - 使用DeepSeek API检测品牌引用"""
|
||||
"""LLM适配器 - 使用 OpenAI 兼容协议检测品牌引用(支持百炼/DashScope/DeepSeek)"""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None, max_retries: int = 3):
|
||||
"""
|
||||
初始化LLM适配器
|
||||
|
||||
Args:
|
||||
api_key: DeepSeek API密钥,默认使用settings中的配置
|
||||
api_key: API密钥,默认优先使用 OPENAI_API_KEY(百炼/DashScope),其次 DEEPSEEK_API_KEY
|
||||
max_retries: 最大重试次数
|
||||
"""
|
||||
self.api_key = api_key or getattr(settings, 'DEEPSEEK_API_KEY', None)
|
||||
self.api_key = (
|
||||
api_key
|
||||
or getattr(settings, 'OPENAI_API_KEY', None)
|
||||
or getattr(settings, 'DEEPSEEK_API_KEY', None)
|
||||
)
|
||||
# base_url 优先 OPENAI_BASE_URL,其次 DEEPSEEK_BASE_URL
|
||||
self.base_url = (
|
||||
getattr(settings, 'OPENAI_BASE_URL', None)
|
||||
or getattr(settings, 'DEEPSEEK_BASE_URL', 'https://api.deepseek.com/v1')
|
||||
)
|
||||
# model 优先 OPENAI_MODEL,其次 DEFAULT_LLM_MODEL
|
||||
self.model = (
|
||||
getattr(settings, 'OPENAI_MODEL', None)
|
||||
or getattr(settings, 'DEFAULT_LLM_MODEL', 'qwen3-coder-plus')
|
||||
or 'qwen3-coder-plus'
|
||||
)
|
||||
self.max_retries = max_retries
|
||||
self._client = None
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
"""延迟初始化DeepSeek客户端"""
|
||||
"""延迟初始化 OpenAI 兼容客户端"""
|
||||
if self._client is None:
|
||||
try:
|
||||
from openai import OpenAI
|
||||
self._client = OpenAI(
|
||||
api_key=self.api_key,
|
||||
base_url="https://api.deepseek.com"
|
||||
base_url=self.base_url,
|
||||
)
|
||||
except ImportError:
|
||||
raise LLMAdapterError("请安装deepseek-sdk或openai库: pip install deepseek-sdk")
|
||||
raise LLMAdapterError("请安装openai库: pip install openai")
|
||||
return self._client
|
||||
|
||||
def _build_prompt(self, keyword: str, brand_name: str, brand_aliases: list[str]) -> str:
|
||||
|
|
@ -175,7 +190,7 @@ class LLMAdapter:
|
|||
API响应的JSON解析结果
|
||||
"""
|
||||
response = self.client.chat.completions.create(
|
||||
model="deepseek-chat",
|
||||
model=self.model,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fix missing sentiment columns in citation_records table."""
|
||||
|
||||
import asyncio
|
||||
import asyncpg
|
||||
|
||||
|
||||
async def fix_schema():
|
||||
# Read DATABASE_URL from .env file in project root
|
||||
db_url = "postgresql://chiguyong@localhost:5432/geo_platform"
|
||||
|
||||
conn = await asyncpg.connect(db_url)
|
||||
try:
|
||||
# Check existing columns
|
||||
rows = await conn.fetch(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name = 'citation_records'"
|
||||
)
|
||||
existing = {r["column_name"] for r in rows}
|
||||
print(f"Existing columns: {existing}")
|
||||
|
||||
needed = ["sentiment", "sentiment_confidence", "sentiment_key_phrases"]
|
||||
missing = [c for c in needed if c not in existing]
|
||||
|
||||
if not missing:
|
||||
print("All sentiment columns already exist.")
|
||||
return
|
||||
|
||||
print(f"Missing columns: {missing}")
|
||||
|
||||
if "sentiment" in missing:
|
||||
await conn.execute(
|
||||
"ALTER TABLE citation_records ADD COLUMN sentiment VARCHAR(20) NULL"
|
||||
)
|
||||
print("Added sentiment column")
|
||||
|
||||
if "sentiment_confidence" in missing:
|
||||
await conn.execute(
|
||||
"ALTER TABLE citation_records ADD COLUMN sentiment_confidence DOUBLE PRECISION NULL"
|
||||
)
|
||||
print("Added sentiment_confidence column")
|
||||
|
||||
if "sentiment_key_phrases" in missing:
|
||||
await conn.execute(
|
||||
"ALTER TABLE citation_records ADD COLUMN sentiment_key_phrases JSONB NULL"
|
||||
)
|
||||
print("Added sentiment_key_phrases column")
|
||||
|
||||
print("Schema fix complete.")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(fix_schema())
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 120
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "W", "F", "I"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["app"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
# Web框架
|
||||
fastapi>=0.109.0
|
||||
uvicorn[standard]
|
||||
gunicorn>=21.2.0
|
||||
|
||||
# 数据库
|
||||
sqlalchemy>=2.0
|
||||
|
|
|
|||
|
|
@ -0,0 +1,381 @@
|
|||
"""Performance tests: concurrent access, response time, and rate limiting."""
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base
|
||||
from app.main import app
|
||||
from app.models.user import User
|
||||
from app.models.query import Query
|
||||
from app.models.brand import Brand
|
||||
from app.models.competitor import Competitor
|
||||
from app.models.suggestion import Suggestion
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.services.auth import create_access_token, hash_password
|
||||
|
||||
# Only the tables needed for performance tests (avoids JSONB/SQLite incompatibility)
|
||||
_TEST_TABLES = (
|
||||
User.__table__,
|
||||
Query.__table__,
|
||||
Brand.__table__,
|
||||
Competitor.__table__,
|
||||
Suggestion.__table__,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────── Fixtures ───────────────────────
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def async_engine():
|
||||
"""Create async engine for testing with SQLite.
|
||||
|
||||
Only creates the specific tables needed by performance tests,
|
||||
avoiding PostgreSQL-only types (JSONB) that fail on SQLite.
|
||||
"""
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(
|
||||
lambda sync_conn: Base.metadata.create_all(
|
||||
sync_conn, tables=[t for t in _TEST_TABLES]
|
||||
)
|
||||
)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def async_session(async_engine):
|
||||
"""Create async session for testing."""
|
||||
async_session_maker = async_sessionmaker(
|
||||
async_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
autocommit=False,
|
||||
)
|
||||
async with async_session_maker() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_user(async_session):
|
||||
"""Create a test user with properly hashed password."""
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="perf_test@example.com",
|
||||
password_hash=hash_password("PerfTest123!"),
|
||||
name="Performance Test User",
|
||||
plan="free",
|
||||
max_queries=50,
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
)
|
||||
async_session.add(user)
|
||||
await async_session.commit()
|
||||
await async_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def async_client(async_session, test_user):
|
||||
"""Create async client for API testing with dependency overrides."""
|
||||
session = async_session
|
||||
|
||||
async def override_get_db():
|
||||
yield session
|
||||
|
||||
async def override_get_current_user():
|
||||
return test_user
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client_no_override(async_session):
|
||||
"""Create async client WITHOUT overriding get_current_user (for real auth flow)."""
|
||||
session = async_session
|
||||
|
||||
async def override_get_db():
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(test_user):
|
||||
"""Create authentication headers."""
|
||||
token = create_access_token(data={"sub": str(test_user.id)})
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# API Response Time Tests
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestAPIPerformance:
|
||||
"""Test API response time and concurrency behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_fast(self, async_client):
|
||||
"""Health check endpoint should respond quickly (< 100ms)."""
|
||||
start = time.time()
|
||||
response = await async_client.get("/health")
|
||||
elapsed = time.time() - start
|
||||
assert response.status_code == 200
|
||||
assert elapsed < 0.1, f"Health check took {elapsed:.3f}s, expected < 0.1s"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_brand_list_performance(self, async_client, async_session, test_user, auth_headers):
|
||||
"""Brand list API should respond within 500ms."""
|
||||
# Create several brands for a more realistic test
|
||||
for i in range(10):
|
||||
brand = Brand(
|
||||
user_id=test_user.id,
|
||||
name=f"Brand {i}",
|
||||
platforms=["wenxin"],
|
||||
status="active",
|
||||
)
|
||||
async_session.add(brand)
|
||||
await async_session.commit()
|
||||
|
||||
start = time.time()
|
||||
response = await async_client.get("/api/v1/brands/", headers=auth_headers)
|
||||
elapsed = time.time() - start
|
||||
|
||||
assert response.status_code == 200
|
||||
assert elapsed < 0.5, f"Brand list took {elapsed:.3f}s, expected < 0.5s"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_list_performance(self, async_client, async_session, test_user, auth_headers):
|
||||
"""Query list API should respond within 500ms."""
|
||||
# Create several queries for a more realistic test
|
||||
for i in range(10):
|
||||
query = Query(
|
||||
user_id=test_user.id,
|
||||
keyword=f"query keyword {i}",
|
||||
target_brand=f"Brand {i}",
|
||||
platforms=["wenxin"],
|
||||
status="active",
|
||||
)
|
||||
async_session.add(query)
|
||||
await async_session.commit()
|
||||
|
||||
start = time.time()
|
||||
response = await async_client.get("/api/v1/queries/", headers=auth_headers)
|
||||
elapsed = time.time() - start
|
||||
|
||||
assert response.status_code == 200
|
||||
assert elapsed < 0.5, f"Query list took {elapsed:.3f}s, expected < 0.5s"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_me_endpoint_performance(self, async_client, auth_headers):
|
||||
"""Current user endpoint should respond within 200ms."""
|
||||
start = time.time()
|
||||
response = await async_client.get("/api/v1/auth/me", headers=auth_headers)
|
||||
elapsed = time.time() - start
|
||||
|
||||
assert response.status_code == 200
|
||||
assert elapsed < 0.2, f"/auth/me took {elapsed:.3f}s, expected < 0.2s"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Concurrency Tests
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestConcurrency:
|
||||
"""Test concurrent access behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_login_no_crash(self, client_no_override, test_user):
|
||||
"""50 concurrent login requests should not cause system crash.
|
||||
|
||||
Note: Rate limiting will kick in after 5 attempts from same IP,
|
||||
so most requests will get 429. The key point is no 500 errors.
|
||||
"""
|
||||
tasks = []
|
||||
for _ in range(50):
|
||||
task = client_no_override.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "perf_test@example.com",
|
||||
"password": "PerfTest123!",
|
||||
},
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
responses = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for i, resp in enumerate(responses):
|
||||
if isinstance(resp, Exception):
|
||||
# Network/transport errors are acceptable under heavy load
|
||||
continue
|
||||
# Should NOT get 500 — rate limiting (429) or auth errors (401/422) are fine
|
||||
assert resp.status_code in (200, 401, 422, 429), (
|
||||
f"Concurrent login request {i} returned {resp.status_code}, "
|
||||
f"expected 200/401/422/429"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_brand_reads(self, async_client, async_session, test_user, auth_headers):
|
||||
"""Concurrent brand list reads should all succeed."""
|
||||
# Pre-create data
|
||||
for i in range(5):
|
||||
brand = Brand(
|
||||
user_id=test_user.id,
|
||||
name=f"Concurrent Brand {i}",
|
||||
platforms=["wenxin"],
|
||||
status="active",
|
||||
)
|
||||
async_session.add(brand)
|
||||
await async_session.commit()
|
||||
|
||||
# 20 concurrent read requests
|
||||
tasks = [
|
||||
async_client.get("/api/v1/brands/", headers=auth_headers)
|
||||
for _ in range(20)
|
||||
]
|
||||
responses = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
success_count = 0
|
||||
for resp in responses:
|
||||
if isinstance(resp, Exception):
|
||||
continue
|
||||
if resp.status_code == 200:
|
||||
success_count += 1
|
||||
|
||||
# At least some should succeed
|
||||
assert success_count > 0, "No concurrent brand reads succeeded"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_query_reads(self, async_client, async_session, test_user, auth_headers):
|
||||
"""Concurrent query list reads should all succeed."""
|
||||
for i in range(5):
|
||||
query = Query(
|
||||
user_id=test_user.id,
|
||||
keyword=f"concurrent query {i}",
|
||||
target_brand=f"Brand {i}",
|
||||
platforms=["wenxin"],
|
||||
status="active",
|
||||
)
|
||||
async_session.add(query)
|
||||
await async_session.commit()
|
||||
|
||||
tasks = [
|
||||
async_client.get("/api/v1/queries/", headers=auth_headers)
|
||||
for _ in range(20)
|
||||
]
|
||||
responses = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
success_count = 0
|
||||
for resp in responses:
|
||||
if isinstance(resp, Exception):
|
||||
continue
|
||||
if resp.status_code == 200:
|
||||
success_count += 1
|
||||
|
||||
assert success_count > 0, "No concurrent query reads succeeded"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Rate Limiting Tests
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestRateLimiting:
|
||||
"""Test rate limiting enforcement."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_rate_limit_enforcement(self, client_no_override):
|
||||
"""Login endpoint should enforce rate limiting (5 req/min/IP).
|
||||
|
||||
After 5 rapid login attempts, subsequent requests should get 429.
|
||||
"""
|
||||
# Note: RateLimitMiddleware state is shared across the app instance.
|
||||
# Since other tests may have already sent requests, we need to
|
||||
# send enough to trigger the limit. The auth_strict rule allows
|
||||
# 5 requests per 60 seconds per IP.
|
||||
responses = []
|
||||
for _ in range(8):
|
||||
response = await client_no_override.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "ratelimit@example.com",
|
||||
"password": "somepassword123",
|
||||
},
|
||||
)
|
||||
responses.append(response)
|
||||
|
||||
# At least one of the later requests should be rate-limited (429)
|
||||
status_codes = [r.status_code for r in responses]
|
||||
# After 5 requests, additional ones should get 429
|
||||
rate_limited = [code for code in status_codes if code == 429]
|
||||
assert len(rate_limited) > 0, (
|
||||
f"No rate limiting detected. Status codes: {status_codes}. "
|
||||
f"Expected at least one 429 after 8 rapid login attempts."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_global_rate_limit_high_threshold(self, async_client, auth_headers):
|
||||
"""Global rate limit (100 req/min) should allow normal usage patterns.
|
||||
|
||||
Sending 10 rapid requests should all succeed (well within limit).
|
||||
"""
|
||||
responses = []
|
||||
for _ in range(10):
|
||||
response = await async_client.get("/health")
|
||||
responses.append(response)
|
||||
|
||||
# All should succeed — well under global limit
|
||||
success_count = sum(1 for r in responses if r.status_code == 200)
|
||||
assert success_count == 10, (
|
||||
f"Expected all 10 health checks to succeed, got {success_count}/10"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_429_response_format(self, client_no_override):
|
||||
"""Rate-limited responses should have proper 429 format."""
|
||||
# Exhaust login rate limit
|
||||
for _ in range(8):
|
||||
await client_no_override.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "rl@example.com", "password": "password123"},
|
||||
)
|
||||
|
||||
# This one should be rate-limited
|
||||
response = await client_no_override.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "rl@example.com", "password": "password123"},
|
||||
)
|
||||
|
||||
if response.status_code == 429:
|
||||
data = response.json()
|
||||
# Should have a detail message
|
||||
assert "detail" in data or "message" in data, (
|
||||
"429 response should include a detail or message field"
|
||||
)
|
||||
|
|
@ -0,0 +1,667 @@
|
|||
"""Security tests: SQL injection, XSS protection, and authentication security."""
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from jose import jwt
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base
|
||||
from app.main import app
|
||||
from app.models.user import User
|
||||
from app.models.brand import Brand
|
||||
from app.models.query import Query
|
||||
from app.models.competitor import Competitor
|
||||
from app.models.suggestion import Suggestion
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.services.auth import create_access_token, create_refresh_token, hash_password
|
||||
from app.config import settings
|
||||
|
||||
# Only the tables needed for security tests (avoids JSONB/SQLite incompatibility)
|
||||
_TEST_TABLES = (
|
||||
User.__table__,
|
||||
Brand.__table__,
|
||||
Query.__table__,
|
||||
Competitor.__table__,
|
||||
Suggestion.__table__,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────── Fixtures ───────────────────────
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def async_engine():
|
||||
"""Create async engine for testing with SQLite.
|
||||
|
||||
Only creates the specific tables needed by security tests,
|
||||
avoiding PostgreSQL-only types (JSONB) that fail on SQLite.
|
||||
"""
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(
|
||||
lambda sync_conn: Base.metadata.create_all(
|
||||
sync_conn, tables=[t for t in _TEST_TABLES]
|
||||
)
|
||||
)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def async_session(async_engine):
|
||||
"""Create async session for testing."""
|
||||
async_session_maker = async_sessionmaker(
|
||||
async_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
autocommit=False,
|
||||
)
|
||||
async with async_session_maker() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_user(async_session):
|
||||
"""Create a test user with properly hashed password."""
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="security_test@example.com",
|
||||
password_hash=hash_password("SecurePass123!"),
|
||||
name="Security Test User",
|
||||
plan="free",
|
||||
max_queries=5,
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
)
|
||||
async_session.add(user)
|
||||
await async_session.commit()
|
||||
await async_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def second_user(async_session):
|
||||
"""Create a second test user for cross-user isolation tests."""
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
email="second_user@example.com",
|
||||
password_hash=hash_password("SecondPass456!"),
|
||||
name="Second User",
|
||||
plan="free",
|
||||
max_queries=5,
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
)
|
||||
async_session.add(user)
|
||||
await async_session.commit()
|
||||
await async_session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def async_client(async_session, test_user):
|
||||
"""Create async client for API testing with dependency overrides."""
|
||||
session = async_session
|
||||
|
||||
async def override_get_db():
|
||||
yield session
|
||||
|
||||
async def override_get_current_user():
|
||||
return test_user
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client_no_override(async_session):
|
||||
"""Create async client WITHOUT overriding get_current_user.
|
||||
|
||||
This allows testing real JWT authentication flow.
|
||||
get_db is still overridden to use test database.
|
||||
"""
|
||||
session = async_session
|
||||
|
||||
async def override_get_db():
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
# Intentionally NOT overriding get_current_user
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(test_user):
|
||||
"""Create authentication headers for test_user."""
|
||||
token = create_access_token(data={"sub": str(test_user.id)})
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def second_auth_headers(second_user):
|
||||
"""Create authentication headers for second_user."""
|
||||
token = create_access_token(data={"sub": str(second_user.id)})
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# SQL Injection Protection Tests
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestSQLInjection:
|
||||
"""Verify that SQL injection attack vectors are properly mitigated."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_sql_injection_rejected(self, client_no_override):
|
||||
"""Login endpoint should reject SQL injection payloads.
|
||||
|
||||
Email field uses EmailStr validation, so non-email payloads
|
||||
should return 422 (validation error). The ORM layer uses
|
||||
parameterized queries, preventing SQL injection even if
|
||||
payloads pass validation. 429 (rate-limited) is also
|
||||
acceptable — it means the security layer is working.
|
||||
"""
|
||||
payloads = [
|
||||
"' OR '1'='1",
|
||||
"'; DROP TABLE users; --",
|
||||
"admin'--",
|
||||
"1' UNION SELECT * FROM users--",
|
||||
"' OR 1=1 --",
|
||||
'" OR ""=""',
|
||||
"1; SELECT * FROM users WHERE '1' = '1'",
|
||||
]
|
||||
for payload in payloads:
|
||||
response = await client_no_override.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": payload, "password": payload},
|
||||
)
|
||||
# 422: EmailStr validation rejects non-email strings
|
||||
# 401: If valid email format but auth fails
|
||||
# 429: Rate-limited (also a correct security behavior)
|
||||
assert response.status_code in (401, 422, 429), (
|
||||
f"SQL injection payload '{payload}' returned {response.status_code}, "
|
||||
f"expected 401/422/429"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_sql_injection_rejected(self, client_no_override):
|
||||
"""Registration endpoint should reject SQL injection payloads."""
|
||||
payloads = [
|
||||
"' OR '1'='1",
|
||||
"admin'--; DROP TABLE users;--",
|
||||
"1' UNION SELECT * FROM users--",
|
||||
]
|
||||
for payload in payloads:
|
||||
response = await client_no_override.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": payload, "password": "password123", "name": "test"},
|
||||
)
|
||||
# 429 is acceptable — rate-limited is a valid security response
|
||||
assert response.status_code in (400, 422, 429), (
|
||||
f"SQL injection payload '{payload}' returned {response.status_code}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_path_param_injection(self, async_client, auth_headers):
|
||||
"""Path parameters should not be vulnerable to SQL injection.
|
||||
|
||||
UUID-type path parameters will fail validation for non-UUID inputs.
|
||||
"""
|
||||
injection_payloads = [
|
||||
"1' OR '1'='1",
|
||||
"1; DROP TABLE queries; --",
|
||||
"' UNION SELECT * FROM users--",
|
||||
]
|
||||
for payload in injection_payloads:
|
||||
response = await async_client.get(
|
||||
f"/api/v1/queries/{payload}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
# Non-UUID path params should return 422 (validation error)
|
||||
assert response.status_code in (404, 422), (
|
||||
f"Path param injection '{payload}' returned {response.status_code}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_brand_path_param_injection(self, async_client, auth_headers):
|
||||
"""Brand path parameters should reject SQL injection."""
|
||||
injection_payloads = [
|
||||
"1' OR '1'='1",
|
||||
"1; DROP TABLE brands; --",
|
||||
]
|
||||
for payload in injection_payloads:
|
||||
response = await async_client.get(
|
||||
f"/api/v1/brands/{payload}/",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code in (404, 422), (
|
||||
f"Brand path param injection '{payload}' returned {response.status_code}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forgot_password_sql_injection_rejected(self, client_no_override):
|
||||
"""Forgot-password endpoint should reject SQL injection payloads."""
|
||||
payloads = [
|
||||
"' OR '1'='1",
|
||||
"admin'--",
|
||||
]
|
||||
for payload in payloads:
|
||||
response = await client_no_override.post(
|
||||
"/api/v1/auth/forgot-password",
|
||||
json={"email": payload},
|
||||
)
|
||||
assert response.status_code in (200, 422, 429), (
|
||||
f"SQL injection payload '{payload}' returned {response.status_code}"
|
||||
)
|
||||
# Even if 200 (generic response), should not leak user existence
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
assert "message" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_password_sql_injection_rejected(self, client_no_override):
|
||||
"""Reset-password endpoint should handle injection payloads safely."""
|
||||
response = await client_no_override.post(
|
||||
"/api/v1/auth/reset-password",
|
||||
json={
|
||||
"token": "' OR '1'='1",
|
||||
"new_password": "newpassword123",
|
||||
},
|
||||
)
|
||||
# Token should be rejected (invalid or expired), not cause SQL error
|
||||
assert response.status_code in (400, 422, 429), (
|
||||
f"Reset password injection returned {response.status_code}"
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# XSS Protection Tests
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestXSSProtection:
|
||||
"""Verify that XSS attack vectors are properly mitigated.
|
||||
|
||||
For a pure JSON API, XSS payloads stored as-is in the database is
|
||||
expected behavior — JSON responses are not rendered as HTML by
|
||||
browsers. The real XSS protections are:
|
||||
1. Content-Type: application/json (prevents HTML rendering)
|
||||
2. Security headers (X-XSS-Protection, X-Content-Type-Options, etc.)
|
||||
3. Frontend escaping when rendering data in HTML
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_returns_json_content_type(self, async_client, auth_headers):
|
||||
"""All API responses must have Content-Type: application/json.
|
||||
|
||||
This is the primary XSS defense for JSON APIs — browsers will
|
||||
not execute scripts in JSON responses.
|
||||
"""
|
||||
endpoints = [
|
||||
("/api/v1/brands/", "GET"),
|
||||
("/api/v1/queries/", "GET"),
|
||||
]
|
||||
for path, method in endpoints:
|
||||
response = await async_client.get(path, headers=auth_headers)
|
||||
if response.status_code == 200:
|
||||
content_type = response.headers.get("content-type", "")
|
||||
assert "application/json" in content_type, (
|
||||
f"Response for {path} has Content-Type '{content_type}', "
|
||||
f"expected 'application/json'"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_brand_name_xss_not_executable(self, async_client, auth_headers):
|
||||
"""XSS payloads in brand name should be stored as plain text.
|
||||
|
||||
In a JSON API, script tags are stored as text and not executed
|
||||
because the response Content-Type is application/json.
|
||||
The key assertion is that the response is valid JSON (not HTML).
|
||||
"""
|
||||
xss_payloads = [
|
||||
"<script>alert('xss')</script>",
|
||||
"<img src=x onerror=alert(1)>",
|
||||
"javascript:alert(1)",
|
||||
"<svg onload=alert(1)>",
|
||||
"<iframe src='javascript:alert(1)'>",
|
||||
]
|
||||
for payload in xss_payloads:
|
||||
response = await async_client.post(
|
||||
"/api/v1/brands/",
|
||||
json={"name": payload, "platforms": ["kimi"]},
|
||||
headers=auth_headers,
|
||||
)
|
||||
if response.status_code in (200, 201):
|
||||
# Verify the response is valid JSON (not HTML)
|
||||
data = response.json()
|
||||
assert isinstance(data, dict), "Response should be a JSON object"
|
||||
|
||||
# Verify Content-Type is application/json
|
||||
content_type = response.headers.get("content-type", "")
|
||||
assert "application/json" in content_type, (
|
||||
f"Content-Type should be application/json, got '{content_type}'"
|
||||
)
|
||||
|
||||
# Verify the XSS payload is stored as plain text
|
||||
# (it's the frontend's responsibility to escape when rendering)
|
||||
name = data.get("name", "")
|
||||
assert name == payload, (
|
||||
f"Brand name should store XSS payload as-is (plain text), "
|
||||
f"got '{name}' instead of '{payload}'"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_brand_update_xss_as_plain_text(self, async_client, async_session, test_user, auth_headers):
|
||||
"""XSS payloads in brand aliases should be stored as plain text."""
|
||||
brand = Brand(
|
||||
id=uuid.uuid4(),
|
||||
user_id=test_user.id,
|
||||
name="Safe Brand",
|
||||
platforms=["wenxin"],
|
||||
status="active",
|
||||
)
|
||||
async_session.add(brand)
|
||||
await async_session.commit()
|
||||
await async_session.refresh(brand)
|
||||
|
||||
xss_payloads = [
|
||||
"<script>alert('xss')</script>",
|
||||
"<img src=x onerror=alert(1)>",
|
||||
]
|
||||
for payload in xss_payloads:
|
||||
response = await async_client.put(
|
||||
f"/api/v1/brands/{brand.id}/",
|
||||
json={"aliases": [payload]},
|
||||
headers=auth_headers,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
content_type = response.headers.get("content-type", "")
|
||||
assert "application/json" in content_type
|
||||
# XSS payloads stored as plain text in JSON
|
||||
assert payload in data.get("aliases", [])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_keyword_xss_as_plain_text(self, async_client, auth_headers):
|
||||
"""XSS payloads in query keyword should be stored as plain text."""
|
||||
xss_payload = "<script>alert('xss')</script>"
|
||||
response = await async_client.post(
|
||||
"/api/v1/queries/",
|
||||
json={
|
||||
"keyword": xss_payload,
|
||||
"target_brand": "Test Brand",
|
||||
"platforms": ["wenxin"],
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
if response.status_code in (200, 201):
|
||||
data = response.json()
|
||||
content_type = response.headers.get("content-type", "")
|
||||
assert "application/json" in content_type
|
||||
# XSS payload stored as plain text
|
||||
assert data.get("keyword") == xss_payload
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_security_headers_present(self, async_client):
|
||||
"""Verify that security response headers are set on all responses."""
|
||||
response = await async_client.get("/health")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check essential security headers
|
||||
assert response.headers.get("x-content-type-options") == "nosniff", (
|
||||
"X-Content-Type-Options header missing or incorrect"
|
||||
)
|
||||
assert response.headers.get("x-frame-options") == "DENY", (
|
||||
"X-Frame-Options header missing or incorrect"
|
||||
)
|
||||
assert response.headers.get("x-xss-protection") == "1; mode=block", (
|
||||
"X-XSS-Protection header missing or incorrect"
|
||||
)
|
||||
assert response.headers.get("referrer-policy") == "strict-origin-when-cross-origin", (
|
||||
"Referrer-Policy header missing or incorrect"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_security_headers_on_api_endpoints(self, async_client, auth_headers):
|
||||
"""Security headers should be present on API endpoints too."""
|
||||
response = await async_client.get("/api/v1/brands/", headers=auth_headers)
|
||||
if response.status_code == 200:
|
||||
assert response.headers.get("x-content-type-options") == "nosniff"
|
||||
assert response.headers.get("x-frame-options") == "DENY"
|
||||
assert response.headers.get("x-xss-protection") == "1; mode=block"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Authentication Security Tests
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestAuthSecurity:
|
||||
"""Verify authentication security mechanisms."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_token_rejected(self, client_no_override):
|
||||
"""Expired JWT tokens should be rejected with 401."""
|
||||
# Create a token that expired 1 hour ago
|
||||
expired_payload = {
|
||||
"sub": str(uuid.uuid4()),
|
||||
"exp": datetime.utcnow() - timedelta(hours=1),
|
||||
"type": "access",
|
||||
}
|
||||
expired_token = jwt.encode(expired_payload, settings.JWT_SECRET, algorithm="HS256")
|
||||
|
||||
response = await client_no_override.get(
|
||||
"/api/v1/auth/me",
|
||||
headers={"Authorization": f"Bearer {expired_token}"},
|
||||
)
|
||||
assert response.status_code == 401, (
|
||||
f"Expired token should be rejected, got {response.status_code}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_token_rejected(self, client_no_override):
|
||||
"""Invalid JWT tokens should be rejected with 401."""
|
||||
invalid_tokens = [
|
||||
"invalid.token.here",
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid.payload",
|
||||
"null",
|
||||
"undefined",
|
||||
]
|
||||
for token in invalid_tokens:
|
||||
response = await client_no_override.get(
|
||||
"/api/v1/auth/me",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401, (
|
||||
f"Invalid token '{token[:20]}...' should be rejected, got {response.status_code}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_refresh_token(self, client_no_override):
|
||||
"""Invalid refresh tokens should return 401."""
|
||||
invalid_tokens = [
|
||||
"invalid.refresh.token",
|
||||
"eyJhbGciOiJIUzI1NiJ9.invalid.payload",
|
||||
]
|
||||
for token in invalid_tokens:
|
||||
response = await client_no_override.post(
|
||||
"/api/v1/auth/refresh",
|
||||
json={"refresh_token": token},
|
||||
)
|
||||
assert response.status_code == 401, (
|
||||
f"Invalid refresh token should be rejected, got {response.status_code}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_access_token_used_as_refresh_rejected(self, client_no_override, test_user):
|
||||
"""Access tokens should not be accepted as refresh tokens."""
|
||||
access_token = create_access_token(data={"sub": str(test_user.id)})
|
||||
|
||||
response = await client_no_override.post(
|
||||
"/api/v1/auth/refresh",
|
||||
json={"refresh_token": access_token},
|
||||
)
|
||||
assert response.status_code == 401, (
|
||||
"Access token used as refresh token should be rejected"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_token_used_as_access_rejected(self, client_no_override, test_user):
|
||||
"""Refresh tokens should not be accepted as access tokens."""
|
||||
refresh_token = create_refresh_token(data={"sub": str(test_user.id)})
|
||||
|
||||
response = await client_no_override.get(
|
||||
"/api/v1/auth/me",
|
||||
headers={"Authorization": f"Bearer {refresh_token}"},
|
||||
)
|
||||
assert response.status_code == 401, (
|
||||
"Refresh token used as access token should be rejected"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_authorization_header(self, client_no_override):
|
||||
"""Requests without Authorization header should return 401."""
|
||||
response = await client_no_override.get("/api/v1/auth/me")
|
||||
assert response.status_code in (401, 403), (
|
||||
f"Missing auth should return 401/403, got {response.status_code}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_user_data_isolation(
|
||||
self, async_session, test_user, second_user, auth_headers
|
||||
):
|
||||
"""User A should not be able to access User B's brand data."""
|
||||
# Create a brand for second_user
|
||||
brand = Brand(
|
||||
id=uuid.uuid4(),
|
||||
user_id=second_user.id,
|
||||
name="Second User's Brand",
|
||||
platforms=["wenxin"],
|
||||
status="active",
|
||||
)
|
||||
async_session.add(brand)
|
||||
await async_session.commit()
|
||||
await async_session.refresh(brand)
|
||||
|
||||
async def override_get_db():
|
||||
yield async_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
# Do NOT override get_current_user — let JWT auth work naturally
|
||||
|
||||
test_user_token = create_access_token(data={"sub": str(test_user.id)})
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
# test_user tries to access second_user's brand
|
||||
response = await client.get(
|
||||
f"/api/v1/brands/{brand.id}/",
|
||||
headers={"Authorization": f"Bearer {test_user_token}"},
|
||||
)
|
||||
assert response.status_code == 404, (
|
||||
f"User should not access another user's brand, got {response.status_code}"
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_user_query_isolation(
|
||||
self, async_session, test_user, second_user
|
||||
):
|
||||
"""User A should not be able to access User B's query data."""
|
||||
query = Query(
|
||||
id=uuid.uuid4(),
|
||||
user_id=second_user.id,
|
||||
keyword="Second User Query",
|
||||
target_brand="Second Brand",
|
||||
platforms=["wenxin"],
|
||||
status="active",
|
||||
)
|
||||
async_session.add(query)
|
||||
await async_session.commit()
|
||||
await async_session.refresh(query)
|
||||
|
||||
async def override_get_db():
|
||||
yield async_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
test_user_token = create_access_token(data={"sub": str(test_user.id)})
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
f"/api/v1/queries/{query.id}",
|
||||
headers={"Authorization": f"Bearer {test_user_token}"},
|
||||
)
|
||||
assert response.status_code == 404, (
|
||||
f"User should not access another user's query, got {response.status_code}"
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nonexistent_user_token_rejected(self, client_no_override):
|
||||
"""Token with nonexistent user_id should be rejected."""
|
||||
fake_user_id = str(uuid.uuid4())
|
||||
token = create_access_token(data={"sub": fake_user_id})
|
||||
|
||||
response = await client_no_override.get(
|
||||
"/api/v1/auth/me",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert response.status_code == 401, (
|
||||
f"Token for nonexistent user should be rejected, got {response.status_code}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_error_message_no_user_enumeration(self, client_no_override):
|
||||
"""Login error messages should not reveal whether email exists.
|
||||
|
||||
Both non-existent email and wrong password should return
|
||||
the same error status. Rate limiting (429) is also acceptable.
|
||||
"""
|
||||
# Non-existent email
|
||||
response1 = await client_no_override.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "nonexistent@example.com", "password": "password123"},
|
||||
)
|
||||
|
||||
# Existing email with wrong password
|
||||
response2 = await client_no_override.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "security_test@example.com", "password": "WrongPassword999!"},
|
||||
)
|
||||
|
||||
# Both should return the same status code (either 401 or 429 if rate-limited)
|
||||
# The key point: no information leakage about whether the user exists
|
||||
assert response1.status_code in (401, 429), (
|
||||
f"Non-existent email login returned {response1.status_code}, expected 401/429"
|
||||
)
|
||||
assert response2.status_code in (401, 429), (
|
||||
f"Wrong password login returned {response2.status_code}, expected 401/429"
|
||||
)
|
||||
|
||||
# When both return 401 (not rate-limited), verify same error structure
|
||||
if response1.status_code == 401 and response2.status_code == 401:
|
||||
# Both should have consistent error responses (no user enumeration)
|
||||
assert response1.status_code == response2.status_code
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
"""Update admin email from admin@ficher.com to admin@fischer.com"""
|
||||
import asyncio
|
||||
import sys
|
||||
sys.path.insert(0, '/Users/Chiguyong/Code/GEO/backend')
|
||||
|
||||
from sqlalchemy import text
|
||||
from app.database import AsyncSessionLocal
|
||||
|
||||
async def main():
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Check current user
|
||||
result = await session.execute(text("SELECT id, email FROM users WHERE email='admin@ficher.com'"))
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
print(f"Found user: id={row[0]}, email={row[1]}")
|
||||
await session.execute(text("UPDATE users SET email='admin@fischer.com' WHERE email='admin@ficher.com'"))
|
||||
await session.commit()
|
||||
print("Email updated to admin@fischer.com")
|
||||
else:
|
||||
print("No user found with admin@ficher.com")
|
||||
result2 = await session.execute(text("SELECT id, email FROM users WHERE email='admin@fischer.com'"))
|
||||
row2 = result2.fetchone()
|
||||
if row2:
|
||||
print(f"Already updated: id={row2[0]}, email={row2[1]}")
|
||||
else:
|
||||
print("No admin user found at all!")
|
||||
all_users = await session.execute(text("SELECT id, email FROM users LIMIT 10"))
|
||||
for u in all_users.fetchall():
|
||||
print(f" User: id={u[0]}, email={u[1]}")
|
||||
|
||||
# Verify
|
||||
verify = await session.execute(text("SELECT id, email FROM users WHERE email='admin@fischer.com'"))
|
||||
v = verify.fetchone()
|
||||
if v:
|
||||
print(f"Verified: id={v[0]}, email={v[1]}")
|
||||
else:
|
||||
print("WARNING: Verification failed - admin@fischer.com not found!")
|
||||
|
||||
asyncio.run(main())
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"csrfToken":"defd4951c4d87238088193a161570b32ea50fa5015753e6e6eeb4adc1d7c0f8c"}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
HTTP/1.1 200 OK
|
||||
vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch
|
||||
cache-control: private, no-cache, no-store
|
||||
content-type: application/json
|
||||
expires: 0
|
||||
pragma: no-cache
|
||||
set-cookie: next-auth.csrf-token=8ecba5438bf185bf0a664e52de0510a57ded93697162fbcf4d61435397cba604%7C6d4116c237ade107969662e104cfd7ab4580ea3306b948a5842c27202943ef5d; Path=/; HttpOnly; SameSite=Lax
|
||||
set-cookie: next-auth.callback-url=http%3A%2F%2Flocalhost%3A3000; Path=/; HttpOnly; SameSite=Lax
|
||||
Date: Sat, 23 May 2026 08:44:02 GMT
|
||||
Connection: keep-alive
|
||||
Keep-Alive: timeout=5
|
||||
Transfer-Encoding: chunked
|
||||
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"csrfToken":"8ecba5438bf185bf0a664e52de0510a57ded93697162fbcf4d61435397cba604"}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
# ============================================================
|
||||
# GEO Platform — 生产环境 Docker Compose 配置
|
||||
#
|
||||
# 生产部署前必须完成的配置:
|
||||
# 1. 创建 .env.production 文件(参考 .env.example),配置真实密钥:
|
||||
# - SECRET_KEY / NEXTAUTH_SECRET(使用随机强密码)
|
||||
# - DATABASE_URL(建议使用托管数据库,如 RDS / Cloud SQL)
|
||||
# - REDIS_URL(建议使用托管 Redis,如 ElastiCache)
|
||||
# - LLM API Keys(DASHSCOPE_API_KEY 等)
|
||||
# 2. 配置反向代理(Nginx / Caddy)并启用 HTTPS
|
||||
# 3. 将数据库和 Redis 卷挂载到持久化存储(或使用托管服务)
|
||||
# 4. 检查防火墙规则,生产环境不应暴露 5432 / 6379 端口到公网
|
||||
# 5. 使用 docker compose -f docker-compose.prod.yml up -d 启动
|
||||
# ============================================================
|
||||
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
container_name: geo_db_prod
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-geo_platform}
|
||||
# 生产环境不对外暴露数据库端口
|
||||
expose:
|
||||
- "5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB:-geo_platform}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1g
|
||||
cpus: '1.0'
|
||||
reservations:
|
||||
memory: 512m
|
||||
networks:
|
||||
- geo_internal
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: geo_redis_prod
|
||||
restart: always
|
||||
# 使用密码保护 Redis(生产必须配置)
|
||||
command: redis-server --requirepass ${REDIS_PASSWORD} --maxmemory 200mb --maxmemory-policy allkeys-lru
|
||||
expose:
|
||||
- "6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256m
|
||||
cpus: '0.5'
|
||||
reservations:
|
||||
memory: 128m
|
||||
networks:
|
||||
- geo_internal
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: geo_backend_prod
|
||||
restart: always
|
||||
expose:
|
||||
- "8000"
|
||||
env_file:
|
||||
- .env.production
|
||||
# 生产环境不挂载源代码目录,镜像内已包含完整代码
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
# 使用 Dockerfile 中定义的 gunicorn 启动命令
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1g
|
||||
cpus: '2.0'
|
||||
reservations:
|
||||
memory: 512m
|
||||
networks:
|
||||
- geo_internal
|
||||
- geo_public
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: geo_frontend_prod
|
||||
restart: always
|
||||
ports:
|
||||
# 通过反向代理访问,本地仅绑定 127.0.0.1
|
||||
- "127.0.0.1:3000:3000"
|
||||
env_file:
|
||||
- .env.production
|
||||
# 生产环境不挂载源代码目录
|
||||
depends_on:
|
||||
- backend
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
cpus: '1.0'
|
||||
reservations:
|
||||
memory: 256m
|
||||
networks:
|
||||
- geo_internal
|
||||
- geo_public
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
# 内部网络:服务间通信,不对外暴露
|
||||
geo_internal:
|
||||
driver: bridge
|
||||
internal: true
|
||||
# 公共网络:frontend/backend 对外提供服务
|
||||
geo_public:
|
||||
driver: bridge
|
||||
|
|
@ -18,6 +18,13 @@ services:
|
|||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
cpus: '0.5'
|
||||
reservations:
|
||||
memory: 256m
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
|
|
@ -32,6 +39,13 @@ services:
|
|||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128m
|
||||
cpus: '0.25'
|
||||
reservations:
|
||||
memory: 64m
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
|
|
@ -49,6 +63,13 @@ services:
|
|||
redis:
|
||||
condition: service_healthy
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512m
|
||||
cpus: '1.0'
|
||||
reservations:
|
||||
memory: 256m
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
|
|
@ -64,6 +85,13 @@ services:
|
|||
depends_on:
|
||||
- backend
|
||||
command: npm run dev
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256m
|
||||
cpus: '0.5'
|
||||
reservations:
|
||||
memory: 128m
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
# GEO 平台后续待办事项
|
||||
|
||||
## 优先级说明
|
||||
- 🔴 P0:阻断核心业务,必须立即处理
|
||||
- 🟠 P1:影响用户体验,本周内解决
|
||||
- 🟡 P2:功能增强,下一迭代安排
|
||||
- 🟢 P3:锦上添花,有空再做
|
||||
|
||||
---
|
||||
|
||||
## 🟠 P1 - 本周优先
|
||||
|
||||
### 1. 数据库迁移同步
|
||||
- [ ] 为 onboarding API 中可能缺少的字段创建 Alembic 迁移
|
||||
- [ ] 检查 brands/competitors 表结构是否完整匹配当前 API 需求
|
||||
- [ ] 运行 `alembic revision --autogenerate` 确认无漂移
|
||||
|
||||
### 2. 多 AI 平台真实对接验证
|
||||
- [ ] 验证文心一言平台适配器能否正常工作
|
||||
- [ ] 验证 Kimi(月之暗面)平台适配器能否正常工作(`MOONSHOT_API_KEY`)
|
||||
- [ ] 验证通义千问平台适配器能否正常工作(`TONGYI_API_KEY`)
|
||||
- [ ] 验证百度千帆平台适配器能否正常工作(`BAIDU_QIANFAN_API_KEY`)
|
||||
- [ ] 验证豆包(字节跳动)平台适配器能否正常工作(`DOUBAO_API_KEY`)
|
||||
- [ ] 配置各平台所需的 API Key 到 .env
|
||||
|
||||
### 3. 定时调度器验证
|
||||
- [ ] 确认 APScheduler 定时任务是否正常触发
|
||||
- [ ] 验证每日/每周查询自动执行
|
||||
- [ ] 测试调度器重启后任务恢复
|
||||
|
||||
---
|
||||
|
||||
## 🟡 P2 - 下一迭代
|
||||
|
||||
### 4. Agent 架构完善
|
||||
- [ ] Legacy Worker 迁移到 Agent 框架
|
||||
- [ ] Pipeline YAML 编排引擎增强(条件分支、循环)
|
||||
- [ ] Agent 监控 Dashboard(执行状态、耗时、成功率)
|
||||
- [ ] 新增内容审核 Agent
|
||||
|
||||
### 5. 内容生产完整链路
|
||||
- [ ] 完善 AI 内容生成质量(Prompt 优化)
|
||||
- [ ] 实现内容审核流程(人工 + AI 双审)
|
||||
- [ ] 多平台发布管理(知乎、百家号、头条等)
|
||||
- [ ] 发布后效果归因(发布 → 引用率变化关联)
|
||||
- [ ] 将 MockEmbedder 替换为 OpenAIEmbedder(生产环境)
|
||||
|
||||
### 6. 平台规则审查
|
||||
- [ ] 建立各 AI 平台收录规则库
|
||||
- [ ] 内容合规性自动检查
|
||||
- [ ] SEO/GEO 最佳实践建议引擎
|
||||
|
||||
### 7. 数据分析增强
|
||||
- [ ] 品牌健康分趋势预测
|
||||
- [ ] 竞品动态预警(某竞品引用率突增)
|
||||
- [ ] 行业基准对比
|
||||
- [ ] 自定义报告模板
|
||||
|
||||
---
|
||||
|
||||
## 🟢 P3 - 长期规划
|
||||
|
||||
### 8. 多租户与商业化
|
||||
- [ ] 订阅套餐计费实现(免费/Pro/Enterprise)
|
||||
- [ ] 多组织/团队协作
|
||||
- [ ] 使用量配额与计费
|
||||
- [ ] 发票与支付集成
|
||||
|
||||
### 9. 性能与可扩展性
|
||||
- [ ] 引用检测任务队列(Celery/RQ)替代内存调度
|
||||
- [ ] 数据库读写分离
|
||||
- [ ] CDN 静态资源加速
|
||||
- [ ] WebSocket 实时通知
|
||||
|
||||
### 10. 运维与部署
|
||||
- [ ] K8s 部署配置
|
||||
- [ ] CI/CD 中 README 的 GitHub 用户名替换
|
||||
- [ ] Sentry 错误监控接入(ErrorBoundary 已预留)
|
||||
- [ ] Grafana + Prometheus 监控大盘
|
||||
- [ ] 自动化备份策略
|
||||
|
||||
### 11. 用户体验
|
||||
- [ ] 邮件通知系统(监测报告推送)
|
||||
- [ ] 移动端适配
|
||||
- [ ] 国际化(i18n)
|
||||
- [ ] 用户引导优化(交互式教程)
|
||||
|
||||
---
|
||||
|
||||
## 已完成项目 ✅
|
||||
|
||||
- [x] JWT 认证 + NextAuth 集成(Access Token + Refresh Token 滑动过期)
|
||||
- [x] 品牌/竞品/查询 CRUD
|
||||
- [x] 引用检测(百炼 qwen3-coder-plus 真实调用)
|
||||
- [x] Onboarding 引导流程(前后端完整对接)
|
||||
- [x] 知识库 RAG 对接(MockEmbedder + RAGService)
|
||||
- [x] 内容管理 + RAG 知识库检索 + 版本管理
|
||||
- [x] 内容生产 Pipeline(生成→去AI化→GEO优化)
|
||||
- [x] 多用户数据隔离修复
|
||||
- [x] Agent 并发控制(Semaphore)
|
||||
- [x] LLM 令牌桶限流
|
||||
- [x] 前端 SWR + Zustand 状态管理
|
||||
- [x] 结构化日志 + RequestID + 性能监控
|
||||
- [x] 安全测试 + 前端测试体系
|
||||
- [x] GitHub Actions CI/CD
|
||||
- [x] 健康检查 /health + /ready
|
||||
- [x] 告警通知系统(告警列表 + 告警设置 + 未读计数)
|
||||
- [x] 内容分发管理(平台规则 + 格式化 + 发布排期)
|
||||
- [x] 监测优化 Analytics(发布记录 + 效果指标 + AI 洞察)
|
||||
- [x] 客户管理(Organization 映射)
|
||||
- [x] 生命周期项目管理(5 阶段 + 时间线)
|
||||
- [x] 订阅管理(套餐/订阅/取消/历史)
|
||||
- [x] 管理员后台(用户管理 + 套餐管理 + 系统统计)
|
||||
- [x] 多 LLM Provider 支持(OpenAI 兼容 + DeepSeek)
|
||||
- [x] Redis 缓存层(品牌列表 + 仪表盘 + 用户 Profile)
|
||||
- [x] 安全响应头(X-Content-Type-Options / X-Frame-Options / X-XSS-Protection)
|
||||
|
|
@ -0,0 +1,399 @@
|
|||
# GEO 平台操作流程
|
||||
|
||||
## 一、环境准备
|
||||
|
||||
### 1.1 系统要求
|
||||
- Node.js 18+
|
||||
- Python 3.11+
|
||||
- PostgreSQL 15
|
||||
- Redis 7
|
||||
- Docker & Docker Compose(推荐)
|
||||
|
||||
### 1.2 环境变量配置
|
||||
|
||||
在项目根目录 `.env` 文件中配置以下关键项:
|
||||
|
||||
| 变量名 | 示例值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `DATABASE_URL` | `postgresql+asyncpg://chiguyong@localhost:5432/geo_platform` | PostgreSQL 异步连接串 |
|
||||
| `REDIS_URL` | `redis://localhost:6379/0` | Redis 连接串 |
|
||||
| `JWT_SECRET` | `a3f8c2e1d7b9...` | JWT 签名密钥,**必须 ≥ 32 字符**,否则启动失败 |
|
||||
| `JWT_EXPIRE_HOURS` | `24` | JWT Access Token 过期时间(小时) |
|
||||
| `NEXT_PUBLIC_API_URL` | `http://localhost:8000` | 前端调用后端的 API 地址 |
|
||||
| `CORS_ORIGINS` | `http://localhost:3000,http://localhost:3001` | 允许跨域的前端地址 |
|
||||
| `ENABLE_LLM` | `true` | 启用真实 AI 引用检测(`false` 则使用 Mock) |
|
||||
| `OPENAI_API_KEY` | `sk-sp-c76f198d...` | 百炼 DashScope API 密钥 |
|
||||
| `OPENAI_BASE_URL` | `https://coding.dashscope.aliyuncs.com/v1` | 百炼 DashScope 端点(OpenAI 兼容格式) |
|
||||
| `DEFAULT_LLM_PROVIDER` | `openai` | LLM 提供商(`openai` / `deepseek`) |
|
||||
| `LLM_MODEL` | `qwen3-coder-plus` | 默认调用模型名 |
|
||||
| `ZHIPU_API_KEY` | _(可选)_ | 智谱 AI API Key |
|
||||
| `TONGYI_API_KEY` | _(可选)_ | 通义千问 API Key |
|
||||
| `MOONSHOT_API_KEY` | _(可选)_ | Kimi(月之暗面)API Key |
|
||||
| `BAIDU_QIANFAN_API_KEY` | _(可选)_ | 百度千帆 API Key |
|
||||
| `DOUBAO_API_KEY` | _(可选)_ | 豆包(字节跳动)API Key |
|
||||
| `API_RATE_LIMIT_RPM` | `10` | AI 平台 API 调用频率限制(每分钟请求数) |
|
||||
|
||||
> **提示**:`JWT_SECRET` 启动时会强制校验长度,不足 32 字符将直接退出进程。可用以下命令生成:
|
||||
> ```bash
|
||||
> python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## 二、系统启动
|
||||
|
||||
### 2.1 Docker 一键启动(推荐)
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
启动后访问:
|
||||
- 前端:http://localhost:3000
|
||||
- 后端 API:http://localhost:8000
|
||||
- API 文档(Swagger):http://localhost:8000/docs
|
||||
|
||||
Docker Compose 编排四个服务:
|
||||
|
||||
| 服务 | 镜像 | 端口 | 依赖 |
|
||||
|------|------|------|------|
|
||||
| `db` | postgres:15-alpine | 5432 | — |
|
||||
| `redis` | redis:7-alpine | 6379 | — |
|
||||
| `backend` | 自建(./backend) | 8000 | db + redis 健康检查 |
|
||||
| `frontend` | 自建(./frontend) | 3000 | backend |
|
||||
|
||||
数据持久化卷:`postgres_data`、`redis_data`
|
||||
|
||||
### 2.2 本地开发启动
|
||||
|
||||
后端:
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
alembic upgrade head
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
前端:
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
> **注意**:生产环境启动后端应使用 `--no-reload` 参数,避免文件监视导致的进程崩溃。
|
||||
|
||||
---
|
||||
|
||||
## 三、业务操作流程
|
||||
|
||||
### 3.1 用户注册与登录
|
||||
- 测试账号:`admin@fischer.com` / `Admin@123`
|
||||
- 注册流程:`POST /api/v1/auth/register` → 邮箱验证 → 登录
|
||||
- 登录流程:`POST /api/v1/auth/login` → 获取 Access Token + Refresh Token
|
||||
|
||||
### 3.2 Onboarding 引导(新用户首次登录)
|
||||
1. 创建品牌(填写品牌名称、行业)→ `POST /api/v1/onboarding/brand`
|
||||
2. 选择竞品(系统自动推荐 + 手动添加)→ `GET /api/v1/onboarding/competitor-recommendations`
|
||||
3. 查看品牌健康报告(AI 分析品牌在各平台的表现)→ `GET /api/v1/onboarding/health-report/{brand_id}`
|
||||
4. 获取行动建议(基于数据的优化建议)→ `GET /api/v1/onboarding/action-suggestions/{brand_id}`
|
||||
5. 完成引导 → `POST /api/v1/onboarding/complete/{brand_id}` → 进入仪表盘
|
||||
|
||||
### 3.3 品牌管理
|
||||
- 创建/编辑/删除品牌(`/api/v1/brands/`)
|
||||
- 管理品牌下的竞品(`/api/v1/brands/{brand_id}/competitors/`)
|
||||
- 查看品牌评分(`/api/v1/brands/{brand_id}/score/`、`/api/v1/brands/{brand_id}/score/history/`)
|
||||
- 配置品牌别名(用于多名称匹配)
|
||||
|
||||
### 3.4 查询管理
|
||||
- 创建监测查询词(如"XXX品牌怎么样")→ `POST /api/v1/queries/`
|
||||
- 查看查询列表 → `GET /api/v1/queries/`
|
||||
- 立即执行检测 → `POST /api/v1/queries/{query_id}/run-now`
|
||||
- 设置查询频率(即时/每日/每周),由 APScheduler 定时调度自动执行
|
||||
|
||||
### 3.5 引用检测
|
||||
- 系统自动调用 AI 平台检测品牌引用
|
||||
- 使用百炼 `qwen3-coder-plus` 模型分析引用情况
|
||||
- 记录:是否被引用、引用位置、情感倾向、置信度
|
||||
- 查看引用列表 → `GET /api/v1/citations/`
|
||||
- 查看引用统计 → `GET /api/v1/citations/stats`
|
||||
|
||||
### 3.6 数据分析与报告
|
||||
- 仪表盘统计:`GET /api/v1/dashboard/stats`
|
||||
- 综合评分(V2)、健康等级、较昨日变化
|
||||
- 五维度评分详情(提及率、推荐排名、情感评分、引用质量、竞争地位)
|
||||
- 各平台评分对比(含竞品)
|
||||
- 报告导出:`GET /api/v1/reports/export/csv`、`GET /api/v1/reports/export/pdf`
|
||||
- 引用详情查看与筛选
|
||||
|
||||
### 3.7 内容管理
|
||||
- 内容 CRUD:`/api/v1/contents/`(列表、创建、查看、更新、删除、发布)
|
||||
- AI 内容生成:`POST /api/v1/content/generate`
|
||||
- 流程:内容生成 → 去AI化 → GEO优化(三阶段 Pipeline)
|
||||
- 支持知识库 RAG 上下文增强
|
||||
- AI 选题生成:`POST /api/v1/content/generate-topics`
|
||||
- 品牌知识库条目:`/api/v1/contents/knowledge/`(CRUD)
|
||||
|
||||
### 3.8 知识库
|
||||
- 创建/列出/查看/删除知识库 → `/api/v1/knowledge/bases`
|
||||
- 上传文档到知识库 → `POST /api/v1/knowledge/bases/{kb_id}/documents`
|
||||
- 支持 text / markdown / url 三种来源
|
||||
- 自动分块 + 向量化(MockEmbedder / OpenAIEmbedder)
|
||||
- 文档管理(列出/删除)→ `/api/v1/knowledge/bases/{kb_id}/documents`
|
||||
- 查看文档分块 → `GET /api/v1/knowledge/bases/{kb_id}/documents/{doc_id}/chunks`
|
||||
- RAG 检索 → `POST /api/v1/knowledge/search`
|
||||
|
||||
### 3.9 内容分发
|
||||
- 获取支持平台列表 → `GET /api/v1/distribution/platforms`
|
||||
- 内容合规校验 → `POST /api/v1/distribution/validate`
|
||||
- 生成发布策略 → `POST /api/v1/distribution/strategy`
|
||||
- 内容格式化 → `POST /api/v1/distribution/format`
|
||||
- 创建发布排期 → `POST /api/v1/distribution/schedule`
|
||||
|
||||
### 3.10 监测优化(Analytics)
|
||||
- 记录发布 → `POST /api/v1/analytics/publish`
|
||||
- 更新效果指标 → `PUT /api/v1/analytics/metrics/{publish_id}`
|
||||
- 全局概览 → `GET /api/v1/analytics/overview`
|
||||
- 单内容详情 → `GET /api/v1/analytics/content/{publish_id}`
|
||||
- 排行榜 → `GET /api/v1/analytics/top`
|
||||
- 洞察列表 → `GET /api/v1/analytics/insights`
|
||||
- AI 生成洞察 → `POST /api/v1/analytics/insights/generate`
|
||||
- 标记洞察已应用 → `POST /api/v1/analytics/insights/{insight_id}/apply`
|
||||
|
||||
### 3.11 告警通知
|
||||
- 查看告警列表 → `GET /api/v1/alerts/`
|
||||
- 未读告警数 → `GET /api/v1/alerts/unread-count`
|
||||
- 全部标为已读 → `PATCH /api/v1/alerts/read-all`
|
||||
- 单条标为已读 → `PATCH /api/v1/alerts/{alert_id}/read`
|
||||
- 告警设置管理 → `/api/v1/alerts/settings`
|
||||
|
||||
### 3.12 客户管理
|
||||
- 客户(组织)CRUD → `/api/v1/clients/`
|
||||
- 查看客户下项目 → `GET /api/v1/clients/{client_id}/projects`
|
||||
|
||||
### 3.13 生命周期项目管理
|
||||
- 项目列表 → `GET /api/v1/lifecycle/projects/`
|
||||
- 项目统计 → `GET /api/v1/lifecycle/projects/stats`
|
||||
- 快速创建项目 → `POST /api/v1/lifecycle/projects/quick-start`
|
||||
- 项目时间线 → `GET /api/v1/lifecycle/projects/{project_id}/timeline`
|
||||
- 阶段管理 → `/api/v1/lifecycle/projects/{project_id}/stages`
|
||||
|
||||
### 3.14 Agent 管理
|
||||
- 列出所有 Agent → `GET /api/v1/agents/`
|
||||
- Agent 详情/配置 → `/api/v1/agents/{agent_name}`
|
||||
- 任务管理 → `/api/v1/agents/tasks/`(创建、查询状态、取消、日志)
|
||||
|
||||
### 3.15 订阅管理
|
||||
- 查看套餐 → `GET /api/v1/subscriptions/plans`
|
||||
- 当前订阅 → `GET /api/v1/subscriptions/current`
|
||||
- 订阅 → `POST /api/v1/subscriptions/subscribe`
|
||||
- 取消订阅 → `POST /api/v1/subscriptions/cancel`
|
||||
- 订阅历史 → `GET /api/v1/subscriptions/history`
|
||||
|
||||
---
|
||||
|
||||
## 四、API 接口总览
|
||||
|
||||
### 认证 `/api/v1/auth`
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/register` | 用户注册 |
|
||||
| POST | `/login` | 用户登录(返回 Access + Refresh Token) |
|
||||
| POST | `/refresh` | 刷新 Token(滑动过期) |
|
||||
| GET | `/me` | 获取当前用户信息 |
|
||||
| POST | `/forgot-password` | 忘记密码(发送重置链接) |
|
||||
| POST | `/reset-password` | 重置密码 |
|
||||
| POST | `/verify-email` | 验证邮箱 |
|
||||
| POST | `/resend-verification` | 重发验证码 |
|
||||
| PUT | `/change-password` | 修改密码 |
|
||||
| PUT | `/profile` | 更新个人资料 |
|
||||
|
||||
### 品牌管理 `/api/v1/brands`
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/` | 获取当前用户所有品牌 |
|
||||
| POST | `/` | 创建品牌 |
|
||||
| GET | `/{brand_id}/` | 获取品牌详情 |
|
||||
| PUT | `/{brand_id}/` | 更新品牌 |
|
||||
| DELETE | `/{brand_id}/` | 删除品牌 |
|
||||
| GET | `/{brand_id}/score/` | 获取品牌评分 |
|
||||
| GET | `/{brand_id}/score/history/` | 获取评分历史 |
|
||||
| * | `/{brand_id}/competitors/...` | 竞品管理(子路由) |
|
||||
|
||||
### 查询词 `/api/v1/queries`
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/` | 查询词列表(分页) |
|
||||
| POST | `/` | 创建查询词 |
|
||||
| GET | `/{query_id}` | 查询词详情 |
|
||||
| PUT | `/{query_id}` | 更新查询词 |
|
||||
| DELETE | `/{query_id}` | 删除查询词 |
|
||||
| POST | `/{query_id}/run-now` | 立即执行检测 |
|
||||
|
||||
### 引用数据 `/api/v1/citations`
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/` | 引用列表(支持按 query_id/platform/日期筛选) |
|
||||
| GET | `/stats` | 引用统计 |
|
||||
|
||||
### 报告 `/api/v1/reports`
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/export/csv` | 导出 CSV 报告 |
|
||||
| GET | `/export/pdf` | 导出 PDF 报告 |
|
||||
|
||||
### Onboarding `/api/v1/onboarding`
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/status` | 检查 Onboarding 状态 |
|
||||
| POST | `/brand` | 创建品牌(简化版) |
|
||||
| GET | `/competitor-recommendations` | 竞品推荐 |
|
||||
| GET | `/health-report/{brand_id}` | 品牌健康报告 |
|
||||
| GET | `/action-suggestions/{brand_id}` | 行动建议 |
|
||||
| POST | `/complete/{brand_id}` | 完成 Onboarding |
|
||||
|
||||
### 内容生产 `/api/v1/content`
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/generate` | 一键生成内容(Pipeline:生成→去AI化→GEO优化) |
|
||||
| POST | `/generate-topics` | AI 选题生成 |
|
||||
|
||||
### 内容管理 `/api/v1/contents`
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/` | 内容列表(分页、筛选) |
|
||||
| POST | `/` | 创建内容 |
|
||||
| GET | `/{content_id}` | 内容详情 |
|
||||
| PUT | `/{content_id}` | 更新内容 |
|
||||
| DELETE | `/{content_id}` | 删除内容 |
|
||||
| POST | `/{content_id}/publish` | 发布内容 |
|
||||
| GET | `/knowledge/` | 品牌知识库条目列表 |
|
||||
| POST | `/knowledge/` | 创建品牌知识库条目 |
|
||||
| PUT | `/knowledge/{knowledge_id}` | 更新品牌知识库条目 |
|
||||
| DELETE | `/knowledge/{knowledge_id}` | 删除品牌知识库条目 |
|
||||
|
||||
### 知识库 `/api/v1/knowledge`
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/bases` | 创建知识库 |
|
||||
| GET | `/bases` | 列出知识库 |
|
||||
| GET | `/bases/{kb_id}` | 知识库详情 |
|
||||
| DELETE | `/bases/{kb_id}` | 删除知识库 |
|
||||
| POST | `/bases/{kb_id}/documents` | 上传文档 |
|
||||
| GET | `/bases/{kb_id}/documents` | 列出文档 |
|
||||
| DELETE | `/bases/{kb_id}/documents/{doc_id}` | 删除文档 |
|
||||
| GET | `/bases/{kb_id}/documents/{doc_id}/chunks` | 查看文档分块 |
|
||||
| POST | `/search` | RAG 语义检索 |
|
||||
|
||||
### 仪表盘 `/api/v1/dashboard`
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/stats` | 仪表盘统计(综合评分、维度、平台、竞品对比) |
|
||||
|
||||
### 内容分发 `/api/v1/distribution`
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/platforms` | 支持平台列表 |
|
||||
| POST | `/validate` | 内容合规校验 |
|
||||
| POST | `/strategy` | 生成发布策略 |
|
||||
| POST | `/format` | 内容格式化 |
|
||||
| POST | `/schedule` | 创建发布排期 |
|
||||
|
||||
### 监测优化 `/api/v1/analytics`
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/publish` | 记录内容发布 |
|
||||
| PUT | `/metrics/{publish_id}` | 更新效果指标 |
|
||||
| GET | `/overview` | 全局效果概览 |
|
||||
| GET | `/content/{publish_id}` | 单内容详细表现 |
|
||||
| GET | `/top` | 表现最好内容排行 |
|
||||
| GET | `/insights` | 洞察列表 |
|
||||
| POST | `/insights/generate` | AI 生成洞察 |
|
||||
| POST | `/insights/{insight_id}/apply` | 标记洞察已应用 |
|
||||
|
||||
### 告警通知 `/api/v1/alerts`
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/` | 告警列表(支持类型/严重程度/已读状态/品牌筛选) |
|
||||
| GET | `/unread-count` | 未读告警数 |
|
||||
| PATCH | `/read-all` | 全部标为已读 |
|
||||
| PATCH | `/{alert_id}/read` | 单条标为已读 |
|
||||
| GET | `/settings` | 告警设置列表 |
|
||||
| PUT | `/settings` | 批量更新告警设置 |
|
||||
| PUT | `/settings/{setting_id}` | 更新单条告警设置 |
|
||||
|
||||
### 客户管理 `/api/v1/clients`
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/` | 客户列表 |
|
||||
| POST | `/` | 创建客户 |
|
||||
| GET | `/{client_id}` | 客户详情 |
|
||||
| PUT | `/{client_id}` | 更新客户 |
|
||||
| DELETE | `/{client_id}` | 删除客户 |
|
||||
| GET | `/{client_id}/projects` | 客户下的项目列表 |
|
||||
|
||||
### 生命周期 `/api/v1/lifecycle`
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/projects/` | 项目列表 |
|
||||
| GET | `/projects/stats` | 项目统计 |
|
||||
| POST | `/projects/quick-start` | 快速创建项目 |
|
||||
| GET | `/projects/{project_id}/timeline` | 项目时间线 |
|
||||
| GET | `/projects/{project_id}/stages` | 项目阶段列表 |
|
||||
| PUT | `/projects/{project_id}/stages/{stage_number}` | 更新阶段状态 |
|
||||
|
||||
### Agent 管理 `/api/v1/agents`
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/` | 列出所有 Agent |
|
||||
| GET | `/{agent_name}` | Agent 详情 |
|
||||
| GET | `/{agent_name}/config` | Agent 配置 |
|
||||
| PUT | `/{agent_name}/config` | 更新 Agent 配置 |
|
||||
| GET | `/tasks/` | 任务列表 |
|
||||
| POST | `/tasks/` | 创建任务(分发给 Agent) |
|
||||
| GET | `/tasks/{task_id}` | 任务状态 |
|
||||
| POST | `/tasks/{task_id}/cancel` | 取消任务 |
|
||||
| GET | `/tasks/{task_id}/logs` | 任务日志 |
|
||||
|
||||
### 订阅 `/api/v1/subscriptions`
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/plans` | 套餐列表 |
|
||||
| GET | `/current` | 当前订阅 |
|
||||
| POST | `/subscribe` | 订阅套餐 |
|
||||
| POST | `/cancel` | 取消订阅 |
|
||||
| GET | `/history` | 订阅历史 |
|
||||
|
||||
### 管理员 `/api/v1/admin`(需管理员权限)
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/stats` | 系统统计 |
|
||||
| GET | `/users` | 用户列表(分页、搜索) |
|
||||
| GET | `/users/{user_id}` | 用户详情 |
|
||||
| POST | `/users/{user_id}/toggle-active` | 启用/禁用用户 |
|
||||
| PUT | `/users/{user_id}/update-plan` | 更新用户套餐 |
|
||||
|
||||
---
|
||||
|
||||
## 五、监控与运维
|
||||
|
||||
### 5.1 健康检查
|
||||
- `GET /health` — 基础存活检查(Liveness),不依赖外部服务
|
||||
- `GET /ready` — 依赖就绪检查(Readiness),检测 DB + Redis 连通性
|
||||
- 返回 `200` + `{"status": "ready"}` 表示全部就绪
|
||||
- 返回 `503` + `{"status": "not_ready"}` 表示依赖异常
|
||||
|
||||
### 5.2 日志
|
||||
- 结构化 JSON 日志(`setup_logging()` 在启动时初始化)
|
||||
- 每个请求附带 `X-Request-ID`(由 `RequestIdMiddleware` 注入)
|
||||
- 慢请求(>1s)自动告警
|
||||
- 中间件执行链:RequestId → Metrics → RateLimit → RequestLogging → CORS → SecurityHeaders
|
||||
|
||||
### 5.3 安全特性
|
||||
- JWT 认证 + 用户级数据隔离(所有查询均按 `current_user.id` 过滤)
|
||||
- 用户+IP 组合限流(`RateLimitMiddleware`)
|
||||
- Agent 并发控制(Semaphore)
|
||||
- LLM 令牌桶限流(30 RPM)
|
||||
- 安全响应头:`X-Content-Type-Options: nosniff`、`X-Frame-Options: DENY`、`X-XSS-Protection: 1; mode=block`
|
||||
- 统一异常处理(不泄漏内部错误详情)
|
||||
- 密码重置/邮箱验证等敏感操作防止用户枚举
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
node_modules/
|
||||
.next/
|
||||
.env.local
|
||||
.env.*
|
||||
*.log
|
||||
.git/
|
||||
.gitignore
|
||||
README.md
|
||||
test-results/
|
||||
playwright-report/
|
||||
e2e/
|
||||
|
|
@ -1,14 +1,45 @@
|
|||
FROM node:20-alpine
|
||||
# ============================================================
|
||||
# Stage 1: Builder — 构建 Next.js 生产产物
|
||||
# ============================================================
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装依赖
|
||||
# 安装依赖(利用缓存层)
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# 复制应用代码
|
||||
# 复制源码并构建
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ============================================================
|
||||
# Stage 2: Runner — 只保留运行时必要文件
|
||||
# ============================================================
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# 创建非 root 用户运行应用
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs
|
||||
|
||||
# 复制 standalone 构建产物
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/ || exit 1
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,314 @@
|
|||
/**
|
||||
* useApi / usePaginatedApi / useApiMutation Hooks 单元测试
|
||||
*
|
||||
* 覆盖:加载/成功/错误状态、分页参数、mutation trigger
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import useSWR from "swr";
|
||||
import {
|
||||
useApi,
|
||||
usePaginatedApi,
|
||||
useApiMutation,
|
||||
swrFetcher,
|
||||
} from "@/lib/hooks/use-api";
|
||||
import type { PaginatedResponse } from "@/lib/hooks/use-api";
|
||||
|
||||
// ── Mock fetchWithAuth ────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@/lib/api/client", () => ({
|
||||
fetchWithAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchWithAuth } from "@/lib/api/client";
|
||||
const mockFetchWithAuth = vi.mocked(fetchWithAuth);
|
||||
|
||||
// ── swrFetcher ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("swrFetcher", () => {
|
||||
it("应调用 fetchWithAuth 并返回结果", async () => {
|
||||
mockFetchWithAuth.mockResolvedValueOnce({ data: "test" });
|
||||
|
||||
const result = await swrFetcher("/api/test");
|
||||
expect(result).toEqual({ data: "test" });
|
||||
expect(mockFetchWithAuth).toHaveBeenCalledWith("/api/test");
|
||||
});
|
||||
});
|
||||
|
||||
// ── useApi ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("useApi", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// 清除 SWR 缓存,避免测试间数据残留
|
||||
useSWR.clearCache?.();
|
||||
});
|
||||
|
||||
it("url 为 null 时应暂停请求", () => {
|
||||
const { result } = renderHook(() => useApi(null));
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.data).toBeUndefined();
|
||||
expect(mockFetchWithAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("请求成功应返回数据", async () => {
|
||||
const mockData = { items: [{ id: "1", name: "品牌" }], total: 1 };
|
||||
mockFetchWithAuth.mockResolvedValueOnce(mockData);
|
||||
|
||||
const { result } = renderHook(() => useApi("/api/brands-success"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual(mockData);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("请求失败应返回错误", async () => {
|
||||
mockFetchWithAuth.mockRejectedValueOnce(new Error("网络错误"));
|
||||
|
||||
const { result } = renderHook(() => useApi("/api/brands-error"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBeInstanceOf(Error);
|
||||
expect(result.current.error?.message).toBe("网络错误");
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh 应触发重新获取", async () => {
|
||||
mockFetchWithAuth.mockResolvedValue({ items: [] });
|
||||
|
||||
const { result } = renderHook(() => useApi("/api/brands-refresh"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
const callCount = mockFetchWithAuth.mock.calls.length;
|
||||
act(() => {
|
||||
result.current.refresh();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWithAuth.mock.calls.length).toBeGreaterThan(callCount);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── usePaginatedApi ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("usePaginatedApi", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSWR.clearCache?.();
|
||||
});
|
||||
|
||||
it("应正确构造分页 URL", async () => {
|
||||
const mockResponse: PaginatedResponse<{ id: string }> = {
|
||||
items: [{ id: "1" }],
|
||||
total: 10,
|
||||
};
|
||||
mockFetchWithAuth.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
renderHook(() =>
|
||||
usePaginatedApi("/api/brands-page1", { page: 1, pageSize: 10 })
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// page=1, pageSize=10 → offset=0, limit=10
|
||||
expect(mockFetchWithAuth).toHaveBeenCalledWith(
|
||||
"/api/brands-page1?limit=10&offset=0"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("第2页应正确计算 offset", async () => {
|
||||
const mockResponse: PaginatedResponse<{ id: string }> = {
|
||||
items: [],
|
||||
total: 20,
|
||||
};
|
||||
mockFetchWithAuth.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
renderHook(() =>
|
||||
usePaginatedApi("/api/brands-page2", { page: 2, pageSize: 5 })
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// page=2, pageSize=5 → offset=5, limit=5
|
||||
expect(mockFetchWithAuth).toHaveBeenCalledWith(
|
||||
"/api/brands-page2?limit=5&offset=5"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("baseUrl 为 null 时应暂停请求", () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePaginatedApi(null, { page: 1, pageSize: 10 })
|
||||
);
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it("应返回 total 和分页信息", async () => {
|
||||
const mockResponse: PaginatedResponse<{ id: string }> = {
|
||||
items: [{ id: "1" }, { id: "2" }],
|
||||
total: 25,
|
||||
};
|
||||
mockFetchWithAuth.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePaginatedApi("/api/brands-total", { page: 1, pageSize: 10 })
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual([{ id: "1" }, { id: "2" }]);
|
||||
expect(result.current.total).toBe(25);
|
||||
expect(result.current.page).toBe(1);
|
||||
expect(result.current.pageSize).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
it("setPage 应更新页码", async () => {
|
||||
mockFetchWithAuth.mockResolvedValue({
|
||||
items: [],
|
||||
total: 30,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePaginatedApi("/api/brands-setpage", { page: 1, pageSize: 10 })
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setPage(3);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.page).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
it("数据未加载时 total 应为 0", () => {
|
||||
// 用 null URL 确保无请求发出,total 为 0
|
||||
const { result } = renderHook(() =>
|
||||
usePaginatedApi(null, { page: 1, pageSize: 10 })
|
||||
);
|
||||
|
||||
expect(result.current.total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── useApiMutation ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("useApiMutation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSWR.clearCache?.();
|
||||
});
|
||||
|
||||
it("trigger 成功应返回数据", async () => {
|
||||
const mockResult = { id: "1", name: "新品牌" };
|
||||
mockFetchWithAuth.mockResolvedValueOnce(mockResult);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useApiMutation("/api/brands", "POST")
|
||||
);
|
||||
|
||||
let response: unknown;
|
||||
await act(async () => {
|
||||
response = await result.current.trigger({ name: "新品牌" });
|
||||
});
|
||||
|
||||
expect(response).toEqual(mockResult);
|
||||
expect(result.current.isMutating).toBe(false);
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("trigger 失败应返回 null 并设置 error", async () => {
|
||||
mockFetchWithAuth.mockRejectedValueOnce(new Error("服务器错误"));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useApiMutation("/api/brands", "POST")
|
||||
);
|
||||
|
||||
let response: unknown;
|
||||
await act(async () => {
|
||||
response = await result.current.trigger({ name: "失败" });
|
||||
});
|
||||
|
||||
expect(response).toBeNull();
|
||||
expect(result.current.error).toBeInstanceOf(Error);
|
||||
expect(result.current.error?.message).toBe("服务器错误");
|
||||
expect(result.current.isMutating).toBe(false);
|
||||
});
|
||||
|
||||
it("trigger 过程中 isMutating 应为 true", async () => {
|
||||
let resolveMutation: (value: unknown) => void;
|
||||
mockFetchWithAuth.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveMutation = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useApiMutation("/api/brands", "PUT")
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.trigger({ name: "更新" });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isMutating).toBe(true);
|
||||
});
|
||||
|
||||
resolveMutation!({ id: "1" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isMutating).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("reset 应清除错误状态", async () => {
|
||||
mockFetchWithAuth.mockRejectedValueOnce(new Error("出错了"));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useApiMutation("/api/brands", "DELETE")
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.trigger();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
result.current.reset();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("应正确传递 method 和 body", async () => {
|
||||
mockFetchWithAuth.mockResolvedValueOnce({ success: true });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useApiMutation("/api/brands/1", "PUT")
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.trigger({ name: "更新品牌" });
|
||||
});
|
||||
|
||||
expect(mockFetchWithAuth).toHaveBeenCalledWith("/api/brands/1", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ name: "更新品牌" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
/**
|
||||
* API Client (fetchWithAuth) 单元测试
|
||||
*
|
||||
* 覆盖:带 token 请求、错误处理(401/500/204)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fetchWithAuth, API_BASE, getApiUrl } from "@/lib/api/client";
|
||||
|
||||
// ── Mock next-auth/react ──────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("next-auth/react", () => ({
|
||||
getSession: vi.fn(() =>
|
||||
Promise.resolve({ accessToken: "session-token-123" })
|
||||
),
|
||||
}));
|
||||
|
||||
import { getSession } from "next-auth/react";
|
||||
const mockGetSession = vi.mocked(getSession);
|
||||
|
||||
// ── Mock global fetch ─────────────────────────────────────────────────────────
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = mockFetch;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
// ── 辅助 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function mockFetchResponse(options: {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json?: () => Promise<unknown>;
|
||||
}) {
|
||||
return {
|
||||
ok: options.ok,
|
||||
status: options.status,
|
||||
json: options.json ?? (() => Promise.resolve({})),
|
||||
};
|
||||
}
|
||||
|
||||
// ── getApiUrl ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("getApiUrl", () => {
|
||||
it("应拼接 API_BASE 和路径", () => {
|
||||
const url = getApiUrl("/api/v1/brands");
|
||||
expect(url).toBe(`${API_BASE}/api/v1/brands`);
|
||||
});
|
||||
});
|
||||
|
||||
// ── fetchWithAuth ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("fetchWithAuth", () => {
|
||||
it("显式传入 token 时应添加 Authorization 头", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockFetchResponse({ ok: true, status: 200, json: () => Promise.resolve({ data: "ok" }) })
|
||||
);
|
||||
|
||||
await fetchWithAuth("/api/test", {}, "my-secret-token");
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe(`${API_BASE}/api/test`);
|
||||
expect(options.headers.Authorization).toBe("Bearer my-secret-token");
|
||||
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||
});
|
||||
|
||||
it("未传入 token 且在浏览器环境下应从 session 获取", async () => {
|
||||
// 模拟浏览器环境
|
||||
const originalWindow = global.window;
|
||||
Object.defineProperty(global, "window", {
|
||||
value: { location: {} },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
mockGetSession.mockResolvedValueOnce({
|
||||
accessToken: "session-token-123",
|
||||
});
|
||||
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockFetchResponse({ ok: true, status: 200, json: () => Promise.resolve({ data: "ok" }) })
|
||||
);
|
||||
|
||||
await fetchWithAuth("/api/test", {});
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers.Authorization).toBe("Bearer session-token-123");
|
||||
|
||||
Object.defineProperty(global, "window", {
|
||||
value: originalWindow,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("session 无 accessToken 时不应添加 Authorization 头", async () => {
|
||||
// jsdom 环境下 typeof window !== "undefined",所以会走 session 路径
|
||||
mockGetSession.mockResolvedValueOnce({}); // session 没有 accessToken
|
||||
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockFetchResponse({ ok: true, status: 200, json: () => Promise.resolve({ data: "ok" }) })
|
||||
);
|
||||
|
||||
await fetchWithAuth("/api/test", {});
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it("session 获取失败时不应添加 Authorization 头", async () => {
|
||||
mockGetSession.mockRejectedValueOnce(new Error("session error"));
|
||||
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockFetchResponse({ ok: true, status: 200, json: () => Promise.resolve({ data: "ok" }) })
|
||||
);
|
||||
|
||||
await fetchWithAuth("/api/test", {});
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it("应合并自定义 headers", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockFetchResponse({ ok: true, status: 200, json: () => Promise.resolve({}) })
|
||||
);
|
||||
|
||||
await fetchWithAuth("/api/test", {
|
||||
headers: { "X-Custom": "value" },
|
||||
}, "token");
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||
expect(options.headers["X-Custom"]).toBe("value");
|
||||
expect(options.headers.Authorization).toBe("Bearer token");
|
||||
});
|
||||
|
||||
// ── 401 错误 ───────────────────────────────────────────────────────────
|
||||
|
||||
it("401 应抛出'登录已过期'错误", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockFetchResponse({ ok: false, status: 401 })
|
||||
);
|
||||
|
||||
await expect(fetchWithAuth("/api/test", {}, "token")).rejects.toThrow(
|
||||
"登录已过期,请重新登录"
|
||||
);
|
||||
});
|
||||
|
||||
// ── 500 错误 ───────────────────────────────────────────────────────────
|
||||
|
||||
it("500 错误应抛出包含状态码的错误", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockFetchResponse({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.resolve({ detail: "内部服务器错误" }),
|
||||
})
|
||||
);
|
||||
|
||||
await expect(fetchWithAuth("/api/test", {}, "token")).rejects.toThrow(
|
||||
"内部服务器错误"
|
||||
);
|
||||
});
|
||||
|
||||
it("500 错误且 JSON 解析失败应使用默认错误信息", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockFetchResponse({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.reject(new Error("invalid json")),
|
||||
})
|
||||
);
|
||||
|
||||
await expect(fetchWithAuth("/api/test", {}, "token")).rejects.toThrow(
|
||||
"请求失败 (HTTP 500)"
|
||||
);
|
||||
});
|
||||
|
||||
it("其他错误状态码应正确抛出", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockFetchResponse({
|
||||
ok: false,
|
||||
status: 422,
|
||||
json: () => Promise.resolve({ detail: "参数校验失败" }),
|
||||
})
|
||||
);
|
||||
|
||||
await expect(fetchWithAuth("/api/test", {}, "token")).rejects.toThrow(
|
||||
"参数校验失败"
|
||||
);
|
||||
});
|
||||
|
||||
// ── 成功响应 ────────────────────────────────────────────────────────────
|
||||
|
||||
it("200 应返回 JSON 数据", async () => {
|
||||
const mockData = { id: "1", name: "品牌" };
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockFetchResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(mockData),
|
||||
})
|
||||
);
|
||||
|
||||
const result = await fetchWithAuth("/api/test", {}, "token");
|
||||
expect(result).toEqual(mockData);
|
||||
});
|
||||
|
||||
it("204 应返回 null", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockFetchResponse({ ok: true, status: 204 })
|
||||
);
|
||||
|
||||
const result = await fetchWithAuth("/api/test", {}, "token");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
// ── 错误响应的 message 字段 ────────────────────────────────────────────
|
||||
|
||||
it("错误响应包含 message 字段时应优先使用", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockFetchResponse({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: () => Promise.resolve({ message: "品牌名称已存在" }),
|
||||
})
|
||||
);
|
||||
|
||||
await expect(fetchWithAuth("/api/test", {}, "token")).rejects.toThrow(
|
||||
"品牌名称已存在"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,440 @@
|
|||
/**
|
||||
* Brand Store 单元测试
|
||||
*
|
||||
* 覆盖:selectBrand / clearSelection / optimisticCreate / optimisticUpdate
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { useBrandStore } from "@/lib/stores/brand-store";
|
||||
import type { BrandListItem } from "@/types/brand";
|
||||
|
||||
// ── Mock 依赖 ────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@/lib/api/client", () => ({
|
||||
fetchWithAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/brands", () => ({
|
||||
brandsApi: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/stores/notification-store", () => ({
|
||||
useNotificationStore: {
|
||||
getState: () => ({
|
||||
addNotification: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// ── 测试数据 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_BRAND: BrandListItem = {
|
||||
id: "brand-1",
|
||||
name: "测试品牌",
|
||||
aliases: ["测试"],
|
||||
platforms: ["bing"],
|
||||
frequency: "weekly",
|
||||
status: "active",
|
||||
score: 85,
|
||||
last_queried_at: "2024-01-01T00:00:00Z",
|
||||
next_query_at: "2024-01-08T00:00:00Z",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const MOCK_BRAND_2: BrandListItem = {
|
||||
id: "brand-2",
|
||||
name: "另一个品牌",
|
||||
aliases: [],
|
||||
platforms: ["google"],
|
||||
frequency: "daily",
|
||||
status: "pending",
|
||||
score: null,
|
||||
last_queried_at: null,
|
||||
next_query_at: null,
|
||||
created_at: "2024-01-02T00:00:00Z",
|
||||
};
|
||||
|
||||
// ── 测试 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("useBrandStore", () => {
|
||||
beforeEach(() => {
|
||||
// 重置 store 到初始状态
|
||||
useBrandStore.setState({
|
||||
selectedBrandId: null,
|
||||
selectedBrandName: null,
|
||||
localBrands: [],
|
||||
optimisticAction: null,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── selectBrand / clearSelection ────────────────────────────────────────
|
||||
|
||||
describe("selectBrand / clearSelection", () => {
|
||||
it("应正确选中品牌", () => {
|
||||
const { selectBrand } = useBrandStore.getState();
|
||||
selectBrand("brand-1", "测试品牌");
|
||||
|
||||
const state = useBrandStore.getState();
|
||||
expect(state.selectedBrandId).toBe("brand-1");
|
||||
expect(state.selectedBrandName).toBe("测试品牌");
|
||||
});
|
||||
|
||||
it("选中品牌时 name 参数可选", () => {
|
||||
const { selectBrand } = useBrandStore.getState();
|
||||
selectBrand("brand-2");
|
||||
|
||||
const state = useBrandStore.getState();
|
||||
expect(state.selectedBrandId).toBe("brand-2");
|
||||
expect(state.selectedBrandName).toBeNull();
|
||||
});
|
||||
|
||||
it("应正确清除选中品牌", () => {
|
||||
useBrandStore.setState({
|
||||
selectedBrandId: "brand-1",
|
||||
selectedBrandName: "测试品牌",
|
||||
});
|
||||
|
||||
const { clearSelection } = useBrandStore.getState();
|
||||
clearSelection();
|
||||
|
||||
const state = useBrandStore.getState();
|
||||
expect(state.selectedBrandId).toBeNull();
|
||||
expect(state.selectedBrandName).toBeNull();
|
||||
});
|
||||
|
||||
it("切换选中品牌应覆盖旧值", () => {
|
||||
useBrandStore.setState({
|
||||
selectedBrandId: "brand-1",
|
||||
selectedBrandName: "旧品牌",
|
||||
});
|
||||
|
||||
const { selectBrand } = useBrandStore.getState();
|
||||
selectBrand("brand-2", "新品牌");
|
||||
|
||||
const state = useBrandStore.getState();
|
||||
expect(state.selectedBrandId).toBe("brand-2");
|
||||
expect(state.selectedBrandName).toBe("新品牌");
|
||||
});
|
||||
});
|
||||
|
||||
// ── syncFromSWR ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("syncFromSWR", () => {
|
||||
it("应同步 SWR 数据到本地副本", () => {
|
||||
const { syncFromSWR } = useBrandStore.getState();
|
||||
syncFromSWR([MOCK_BRAND, MOCK_BRAND_2]);
|
||||
|
||||
const state = useBrandStore.getState();
|
||||
expect(state.localBrands).toHaveLength(2);
|
||||
expect(state.localBrands[0].id).toBe("brand-1");
|
||||
expect(state.localBrands[1].id).toBe("brand-2");
|
||||
});
|
||||
|
||||
it("多次同步应替换而非追加", () => {
|
||||
const { syncFromSWR } = useBrandStore.getState();
|
||||
syncFromSWR([MOCK_BRAND]);
|
||||
syncFromSWR([MOCK_BRAND_2]);
|
||||
|
||||
const state = useBrandStore.getState();
|
||||
expect(state.localBrands).toHaveLength(1);
|
||||
expect(state.localBrands[0].id).toBe("brand-2");
|
||||
});
|
||||
});
|
||||
|
||||
// ── optimisticCreate ────────────────────────────────────────────────────
|
||||
|
||||
describe("optimisticCreate", () => {
|
||||
it("成功时应在本地添加临时条目后替换为真实数据", async () => {
|
||||
const { brandsApi } = await import("@/lib/api/brands");
|
||||
const created = vi.mocked(brandsApi.create).mockResolvedValueOnce(
|
||||
MOCK_BRAND as never
|
||||
);
|
||||
|
||||
const swrMutate = vi.fn();
|
||||
const { optimisticCreate } = useBrandStore.getState();
|
||||
|
||||
const result = await optimisticCreate(
|
||||
"test-token",
|
||||
{ name: "测试品牌", platforms: ["bing"] },
|
||||
swrMutate
|
||||
);
|
||||
|
||||
// 返回值应为 API 创建结果
|
||||
expect(result).toEqual(MOCK_BRAND);
|
||||
|
||||
// 乐观操作状态已清除
|
||||
expect(useBrandStore.getState().optimisticAction).toBeNull();
|
||||
|
||||
// 本地列表应包含真实数据(非临时 ID)
|
||||
const brands = useBrandStore.getState().localBrands;
|
||||
expect(brands).toHaveLength(1);
|
||||
expect(brands[0].id).toBe("brand-1");
|
||||
|
||||
// SWR mutate 应被调用
|
||||
expect(swrMutate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("失败时应回滚本地临时条目", async () => {
|
||||
const { brandsApi } = await import("@/lib/api/brands");
|
||||
vi.mocked(brandsApi.create).mockRejectedValueOnce(
|
||||
new Error("创建失败") as never
|
||||
);
|
||||
|
||||
const swrMutate = vi.fn();
|
||||
const { optimisticCreate } = useBrandStore.getState();
|
||||
|
||||
const result = await optimisticCreate(
|
||||
"test-token",
|
||||
{ name: "失败品牌" },
|
||||
swrMutate
|
||||
);
|
||||
|
||||
// 返回 null
|
||||
expect(result).toBeNull();
|
||||
|
||||
// 本地列表应回滚(为空)
|
||||
expect(useBrandStore.getState().localBrands).toHaveLength(0);
|
||||
|
||||
// 乐观操作状态已清除
|
||||
expect(useBrandStore.getState().optimisticAction).toBeNull();
|
||||
|
||||
// SWR mutate 不应被调用
|
||||
expect(swrMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("创建过程中 optimisticAction 应为 creating", async () => {
|
||||
const { brandsApi } = await import("@/lib/api/brands");
|
||||
let resolveCreate: (value: unknown) => void;
|
||||
const createPromise = new Promise((resolve) => {
|
||||
resolveCreate = resolve;
|
||||
});
|
||||
vi.mocked(brandsApi.create).mockReturnValueOnce(
|
||||
createPromise as never
|
||||
);
|
||||
|
||||
const { optimisticCreate } = useBrandStore.getState();
|
||||
const createResult = optimisticCreate("test-token", { name: "异步品牌" });
|
||||
|
||||
// API 调用期间
|
||||
expect(useBrandStore.getState().optimisticAction).toBe("creating");
|
||||
expect(useBrandStore.getState().localBrands).toHaveLength(1);
|
||||
expect(useBrandStore.getState().localBrands[0].name).toBe("异步品牌");
|
||||
|
||||
// 解析 API
|
||||
resolveCreate!(MOCK_BRAND);
|
||||
await createResult;
|
||||
|
||||
expect(useBrandStore.getState().optimisticAction).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── optimisticUpdate ────────────────────────────────────────────────────
|
||||
|
||||
describe("optimisticUpdate", () => {
|
||||
beforeEach(() => {
|
||||
useBrandStore.setState({
|
||||
localBrands: [MOCK_BRAND, MOCK_BRAND_2],
|
||||
});
|
||||
});
|
||||
|
||||
it("成功时应在本地乐观更新后替换为 API 返回数据", async () => {
|
||||
const updatedBrand: BrandListItem = {
|
||||
...MOCK_BRAND,
|
||||
name: "更新后品牌",
|
||||
};
|
||||
|
||||
const { brandsApi } = await import("@/lib/api/brands");
|
||||
vi.mocked(brandsApi.update).mockResolvedValueOnce(
|
||||
updatedBrand as never
|
||||
);
|
||||
|
||||
const swrMutate = vi.fn();
|
||||
const { optimisticUpdate } = useBrandStore.getState();
|
||||
|
||||
const result = await optimisticUpdate(
|
||||
"test-token",
|
||||
"brand-1",
|
||||
{ name: "更新后品牌" },
|
||||
swrMutate
|
||||
);
|
||||
|
||||
expect(result).toEqual(updatedBrand);
|
||||
expect(useBrandStore.getState().optimisticAction).toBeNull();
|
||||
|
||||
// 本地列表应包含更新后的数据
|
||||
const brands = useBrandStore.getState().localBrands;
|
||||
expect(brands.find((b) => b.id === "brand-1")?.name).toBe("更新后品牌");
|
||||
|
||||
expect(swrMutate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("失败时应回滚到原始数据", async () => {
|
||||
const { brandsApi } = await import("@/lib/api/brands");
|
||||
vi.mocked(brandsApi.update).mockRejectedValueOnce(
|
||||
new Error("更新失败") as never
|
||||
);
|
||||
|
||||
const swrMutate = vi.fn();
|
||||
const { optimisticUpdate } = useBrandStore.getState();
|
||||
|
||||
const result = await optimisticUpdate(
|
||||
"test-token",
|
||||
"brand-1",
|
||||
{ name: "回滚品牌" },
|
||||
swrMutate
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(useBrandStore.getState().optimisticAction).toBeNull();
|
||||
|
||||
// 本地列表应回滚到原始数据
|
||||
const brands = useBrandStore.getState().localBrands;
|
||||
expect(brands.find((b) => b.id === "brand-1")?.name).toBe("测试品牌");
|
||||
|
||||
expect(swrMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("本地无品牌时直接走 API", async () => {
|
||||
useBrandStore.setState({ localBrands: [] });
|
||||
|
||||
const updatedBrand: BrandListItem = { ...MOCK_BRAND, name: "新品牌" };
|
||||
const { brandsApi } = await import("@/lib/api/brands");
|
||||
vi.mocked(brandsApi.update).mockResolvedValueOnce(
|
||||
updatedBrand as never
|
||||
);
|
||||
|
||||
const swrMutate = vi.fn();
|
||||
const { optimisticUpdate } = useBrandStore.getState();
|
||||
|
||||
const result = await optimisticUpdate(
|
||||
"test-token",
|
||||
"brand-1",
|
||||
{ name: "新品牌" },
|
||||
swrMutate
|
||||
);
|
||||
|
||||
expect(result).toEqual(updatedBrand);
|
||||
expect(swrMutate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("更新过程中 optimisticAction 应为 updating", async () => {
|
||||
const { brandsApi } = await import("@/lib/api/brands");
|
||||
let resolveUpdate: (value: unknown) => void;
|
||||
const updatePromise = new Promise((resolve) => {
|
||||
resolveUpdate = resolve;
|
||||
});
|
||||
vi.mocked(brandsApi.update).mockReturnValueOnce(
|
||||
updatePromise as never
|
||||
);
|
||||
|
||||
const { optimisticUpdate } = useBrandStore.getState();
|
||||
const updateResult = optimisticUpdate(
|
||||
"test-token",
|
||||
"brand-1",
|
||||
{ name: "异步更新" }
|
||||
);
|
||||
|
||||
// API 调用期间
|
||||
expect(useBrandStore.getState().optimisticAction).toBe("updating");
|
||||
|
||||
// 本地应已乐观更新
|
||||
expect(
|
||||
useBrandStore.getState().localBrands.find((b) => b.id === "brand-1")?.name
|
||||
).toBe("异步更新");
|
||||
|
||||
resolveUpdate!({ ...MOCK_BRAND, name: "异步更新" });
|
||||
await updateResult;
|
||||
|
||||
expect(useBrandStore.getState().optimisticAction).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── optimisticDelete ────────────────────────────────────────────────────
|
||||
|
||||
describe("optimisticDelete", () => {
|
||||
beforeEach(() => {
|
||||
useBrandStore.setState({
|
||||
localBrands: [MOCK_BRAND, MOCK_BRAND_2],
|
||||
selectedBrandId: null,
|
||||
selectedBrandName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("成功时应在本地移除品牌", async () => {
|
||||
const { brandsApi } = await import("@/lib/api/brands");
|
||||
vi.mocked(brandsApi.delete).mockResolvedValueOnce(undefined as never);
|
||||
|
||||
const swrMutate = vi.fn();
|
||||
const { optimisticDelete } = useBrandStore.getState();
|
||||
|
||||
const result = await optimisticDelete("test-token", "brand-1", swrMutate);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(useBrandStore.getState().optimisticAction).toBeNull();
|
||||
|
||||
// 本地列表不再包含被删除品牌
|
||||
const brands = useBrandStore.getState().localBrands;
|
||||
expect(brands).toHaveLength(1);
|
||||
expect(brands.find((b) => b.id === "brand-1")).toBeUndefined();
|
||||
|
||||
expect(swrMutate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("失败时应恢复被删除的品牌", async () => {
|
||||
const { brandsApi } = await import("@/lib/api/brands");
|
||||
vi.mocked(brandsApi.delete).mockRejectedValueOnce(
|
||||
new Error("删除失败") as never
|
||||
);
|
||||
|
||||
const swrMutate = vi.fn();
|
||||
const { optimisticDelete } = useBrandStore.getState();
|
||||
|
||||
const result = await optimisticDelete("test-token", "brand-1", swrMutate);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(useBrandStore.getState().optimisticAction).toBeNull();
|
||||
|
||||
// 本地列表应恢复被删除品牌
|
||||
const brands = useBrandStore.getState().localBrands;
|
||||
expect(brands).toHaveLength(2);
|
||||
expect(brands.find((b) => b.id === "brand-1")).toBeDefined();
|
||||
});
|
||||
|
||||
it("删除当前选中品牌时应清除选择", async () => {
|
||||
useBrandStore.setState({
|
||||
selectedBrandId: "brand-1",
|
||||
selectedBrandName: "测试品牌",
|
||||
});
|
||||
|
||||
const { brandsApi } = await import("@/lib/api/brands");
|
||||
vi.mocked(brandsApi.delete).mockResolvedValueOnce(undefined as never);
|
||||
|
||||
const { optimisticDelete } = useBrandStore.getState();
|
||||
await optimisticDelete("test-token", "brand-1");
|
||||
|
||||
expect(useBrandStore.getState().selectedBrandId).toBeNull();
|
||||
expect(useBrandStore.getState().selectedBrandName).toBeNull();
|
||||
});
|
||||
|
||||
it("删除非选中品牌时不应影响选择", async () => {
|
||||
useBrandStore.setState({
|
||||
selectedBrandId: "brand-1",
|
||||
selectedBrandName: "测试品牌",
|
||||
});
|
||||
|
||||
const { brandsApi } = await import("@/lib/api/brands");
|
||||
vi.mocked(brandsApi.delete).mockResolvedValueOnce(undefined as never);
|
||||
|
||||
const { optimisticDelete } = useBrandStore.getState();
|
||||
await optimisticDelete("test-token", "brand-2");
|
||||
|
||||
expect(useBrandStore.getState().selectedBrandId).toBe("brand-1");
|
||||
expect(useBrandStore.getState().selectedBrandName).toBe("测试品牌");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
/**
|
||||
* Notification Store 单元测试
|
||||
*
|
||||
* 覆盖:addNotification / removeNotification / clearAll / 自动过期清除
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { useNotificationStore } from "@/lib/stores/notification-store";
|
||||
|
||||
describe("useNotificationStore", () => {
|
||||
beforeEach(() => {
|
||||
// 重置 store 到初始状态
|
||||
useNotificationStore.setState({ notifications: [] });
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── addNotification ────────────────────────────────────────────────────
|
||||
|
||||
describe("addNotification", () => {
|
||||
it("应添加一条通知到队列", () => {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
const id = addNotification({ type: "success", message: "操作成功" });
|
||||
|
||||
expect(id).toBeTruthy();
|
||||
expect(id).toMatch(/^notif-/);
|
||||
|
||||
const { notifications } = useNotificationStore.getState();
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0].type).toBe("success");
|
||||
expect(notifications[0].message).toBe("操作成功");
|
||||
});
|
||||
|
||||
it("应支持不同类型的通知", () => {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
|
||||
addNotification({ type: "success", message: "成功" });
|
||||
addNotification({ type: "error", message: "错误" });
|
||||
addNotification({ type: "warning", message: "警告" });
|
||||
addNotification({ type: "info", message: "信息" });
|
||||
|
||||
const { notifications } = useNotificationStore.getState();
|
||||
expect(notifications).toHaveLength(4);
|
||||
expect(notifications[0].type).toBe("success");
|
||||
expect(notifications[1].type).toBe("error");
|
||||
expect(notifications[2].type).toBe("warning");
|
||||
expect(notifications[3].type).toBe("info");
|
||||
});
|
||||
|
||||
it("应支持可选标题", () => {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
addNotification({ type: "info", message: "消息", title: "标题" });
|
||||
|
||||
const { notifications } = useNotificationStore.getState();
|
||||
expect(notifications[0].title).toBe("标题");
|
||||
});
|
||||
|
||||
it("默认过期时间应按类型自动设置", () => {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
|
||||
addNotification({ type: "success", message: "成功" });
|
||||
addNotification({ type: "error", message: "错误" });
|
||||
addNotification({ type: "warning", message: "警告" });
|
||||
addNotification({ type: "info", message: "信息" });
|
||||
|
||||
const { notifications } = useNotificationStore.getState();
|
||||
expect(notifications[0].duration).toBe(3000); // success
|
||||
expect(notifications[1].duration).toBe(5000); // error
|
||||
expect(notifications[2].duration).toBe(4000); // warning
|
||||
expect(notifications[3].duration).toBe(3000); // info
|
||||
});
|
||||
|
||||
it("应支持自定义过期时间", () => {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
addNotification({ type: "success", message: "自定义", duration: 10000 });
|
||||
|
||||
const { notifications } = useNotificationStore.getState();
|
||||
expect(notifications[0].duration).toBe(10000);
|
||||
});
|
||||
|
||||
it("duration 为 undefined 时应使用默认过期时间", () => {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
// 不传 duration,使用 error 类型的默认值 5000
|
||||
addNotification({ type: "error", message: "使用默认" });
|
||||
|
||||
const { notifications } = useNotificationStore.getState();
|
||||
expect(notifications[0].duration).toBe(5000);
|
||||
});
|
||||
|
||||
it("每条通知应有唯一 ID", () => {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
const id1 = addNotification({ type: "info", message: "第一条" });
|
||||
const id2 = addNotification({ type: "info", message: "第二条" });
|
||||
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── removeNotification ─────────────────────────────────────────────────
|
||||
|
||||
describe("removeNotification", () => {
|
||||
it("应移除指定 ID 的通知", () => {
|
||||
const { addNotification, removeNotification } =
|
||||
useNotificationStore.getState();
|
||||
|
||||
const id1 = addNotification({ type: "success", message: "保留" });
|
||||
const id2 = addNotification({ type: "error", message: "移除" });
|
||||
|
||||
removeNotification(id2);
|
||||
|
||||
const { notifications } = useNotificationStore.getState();
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0].id).toBe(id1);
|
||||
});
|
||||
|
||||
it("移除不存在的 ID 不应报错", () => {
|
||||
const { addNotification, removeNotification } =
|
||||
useNotificationStore.getState();
|
||||
|
||||
addNotification({ type: "info", message: "测试" });
|
||||
expect(() => removeNotification("non-existent")).not.toThrow();
|
||||
|
||||
const { notifications } = useNotificationStore.getState();
|
||||
expect(notifications).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("移除通知时应清除其定时器", () => {
|
||||
const { addNotification, removeNotification } =
|
||||
useNotificationStore.getState();
|
||||
|
||||
const id = addNotification({ type: "success", message: "提前移除" });
|
||||
removeNotification(id);
|
||||
|
||||
// 快进超过默认过期时间,不应再触发移除(避免对空列表操作)
|
||||
vi.advanceTimersByTime(5000);
|
||||
|
||||
const { notifications } = useNotificationStore.getState();
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 自动过期清除 ───────────────────────────────────────────────────────
|
||||
|
||||
describe("自动过期清除", () => {
|
||||
it("到达过期时间后应自动移除通知", () => {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
addNotification({ type: "success", message: "3秒后过期" });
|
||||
|
||||
// 快进 3 秒
|
||||
vi.advanceTimersByTime(3000);
|
||||
|
||||
const { notifications } = useNotificationStore.getState();
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("不同类型通知在不同时间过期", () => {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
addNotification({ type: "success", message: "3秒" });
|
||||
addNotification({ type: "error", message: "5秒" });
|
||||
|
||||
// 快进 3 秒,success 应被清除
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(useNotificationStore.getState().notifications).toHaveLength(1);
|
||||
expect(useNotificationStore.getState().notifications[0].type).toBe(
|
||||
"error"
|
||||
);
|
||||
|
||||
// 再快进 2 秒(共 5 秒),error 也应被清除
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(useNotificationStore.getState().notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("自定义 duration=0 的通知不会自动过期", () => {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
// duration=0 时 setTimeout(cb, 0) 会在下一个事件循环触发
|
||||
// 但 effectiveDuration !== null 为 true,所以会设置定时器
|
||||
// 这里测试 duration 传入 0 的行为
|
||||
addNotification({ type: "error", message: "0 毫秒过期", duration: 0 });
|
||||
|
||||
// 0 毫秒定时器应立即触发
|
||||
vi.advanceTimersByTime(1);
|
||||
|
||||
const { notifications } = useNotificationStore.getState();
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("自定义 duration 应在指定时间后过期", () => {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
addNotification({ type: "info", message: "1秒过期", duration: 1000 });
|
||||
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(useNotificationStore.getState().notifications).toHaveLength(1);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(useNotificationStore.getState().notifications).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── clearAll ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("clearAll", () => {
|
||||
it("应清空所有通知", () => {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
addNotification({ type: "success", message: "A" });
|
||||
addNotification({ type: "error", message: "B" });
|
||||
addNotification({ type: "warning", message: "C" });
|
||||
|
||||
expect(useNotificationStore.getState().notifications).toHaveLength(3);
|
||||
|
||||
const { clearAll } = useNotificationStore.getState();
|
||||
clearAll();
|
||||
|
||||
expect(useNotificationStore.getState().notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("clearAll 后定时器不应再触发", () => {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
addNotification({ type: "success", message: "3秒后过期" });
|
||||
|
||||
const { clearAll } = useNotificationStore.getState();
|
||||
clearAll();
|
||||
|
||||
// 快进超过过期时间
|
||||
vi.advanceTimersByTime(5000);
|
||||
|
||||
expect(useNotificationStore.getState().notifications).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -17,7 +16,6 @@ import {
|
|||
} from "@/components/ui/card";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
|
@ -36,8 +34,10 @@ export default function LoginPage() {
|
|||
if (result?.error) {
|
||||
setError("邮箱或密码错误");
|
||||
} else {
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
// 使用 window.location.href 进行完整页面加载,确保 SessionProvider
|
||||
// 重新初始化并获取最新的 session cookie,避免客户端导航时
|
||||
// useSession() 读到旧的 unauthenticated 缓存状态导致跳回登录页
|
||||
window.location.href = "/dashboard";
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
BrandFormDialog,
|
||||
AddBrandButton,
|
||||
} from "@/components/brand/BrandFormDialog";
|
||||
import { api } from "@/lib/api";
|
||||
import { PLATFORM_MAP } from "@/lib/platforms";
|
||||
import { useNotificationStore, useBrandStore } from "@/lib/stores";
|
||||
import type { BrandListItem, BrandListResponse } from "@/types/brand";
|
||||
import { Search, Star, Calendar, Edit, Trash2 } from "lucide-react";
|
||||
import {
|
||||
|
|
@ -24,45 +24,50 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useApi } from "@/lib/hooks/use-api";
|
||||
import { LoadingState, ErrorState, EmptyState } from "@/components/ui/api-states";
|
||||
|
||||
export default function BrandsPage() {
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const [brands, setBrands] = useState<BrandListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteBrand, setDeleteBrand] = useState<BrandListItem | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const fetchBrands = useCallback(async () => {
|
||||
const token = session?.accessToken;
|
||||
if (!token) return;
|
||||
const { data: brandsResponse, isLoading: loading, error: apiError, refresh: fetchBrands } =
|
||||
useApi<BrandListResponse>("/api/v1/brands/");
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = (await api.brands.list(token)) as BrandListResponse;
|
||||
setBrands(response.items || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "加载品牌失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [session?.accessToken]);
|
||||
const brands: BrandListItem[] = brandsResponse?.items ?? [];
|
||||
const error = apiError?.message ?? null;
|
||||
|
||||
// 同步 SWR 数据到 brand-store
|
||||
const syncFromSWR = useBrandStore((s) => s.syncFromSWR);
|
||||
useEffect(() => {
|
||||
fetchBrands();
|
||||
}, [fetchBrands]);
|
||||
if (brands.length > 0) {
|
||||
syncFromSWR(brands);
|
||||
}
|
||||
}, [brands, syncFromSWR]);
|
||||
|
||||
const addNotification = useNotificationStore((s) => s.addNotification);
|
||||
const optimisticDelete = useBrandStore((s) => s.optimisticDelete);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteBrand || !session?.accessToken) return;
|
||||
|
||||
try {
|
||||
setDeleting(true);
|
||||
await api.brands.delete(session.accessToken, deleteBrand.id);
|
||||
setDeleteBrand(null);
|
||||
fetchBrands();
|
||||
const success = await optimisticDelete(
|
||||
session.accessToken,
|
||||
deleteBrand.id,
|
||||
fetchBrands
|
||||
);
|
||||
if (success) {
|
||||
setDeleteBrand(null);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "删除失败");
|
||||
addNotification({
|
||||
type: "error",
|
||||
message: err instanceof Error ? err.message : "删除失败",
|
||||
});
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
|
|
@ -108,35 +113,22 @@ export default function BrandsPage() {
|
|||
<h2 className="text-2xl font-bold tracking-tight">品牌管理</h2>
|
||||
<p className="text-muted-foreground">管理您的品牌监控列表</p>
|
||||
</div>
|
||||
<Skeleton className="h-10 w-28" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<LoadingState rows={3} grid cols={3} rowHeight="h-40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-[60vh] flex-col items-center justify-center gap-2">
|
||||
<p className="text-destructive">{error}</p>
|
||||
<button
|
||||
onClick={fetchBrands}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">品牌管理</h2>
|
||||
<p className="text-muted-foreground">管理您的品牌监控列表</p>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorState error={error} onRetry={fetchBrands} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -150,17 +142,12 @@ export default function BrandsPage() {
|
|||
<p className="text-muted-foreground">管理您的品牌监控列表</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border bg-card py-16 text-card-foreground shadow-sm">
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<Star className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-semibold">暂无品牌</h3>
|
||||
<p className="mb-6 max-w-sm text-center text-muted-foreground">
|
||||
添加您的第一个品牌,开始监控其在AI搜索中的表现
|
||||
</p>
|
||||
<AddBrandButton onSuccess={fetchBrands} />
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={<Star className="h-6 w-6 text-primary" />}
|
||||
message="暂无品牌"
|
||||
description="添加您的第一个品牌,开始监控其在AI搜索中的表现"
|
||||
action={<AddBrandButton onSuccess={fetchBrands} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -29,7 +28,11 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { api } from "@/lib/api";
|
||||
import { fetchWithAuth } from "@/lib/api/client";
|
||||
import type { AdminStatsData, AdminUser, AdminUserListResponse, AdminActionResponse } from "@/lib/api/admin";
|
||||
import { useApi } from "@/lib/hooks/use-api";
|
||||
import { LoadingState, ErrorState } from "@/components/ui/api-states";
|
||||
import { clsx } from "clsx";
|
||||
import {
|
||||
Users,
|
||||
Search,
|
||||
|
|
@ -44,26 +47,6 @@ import {
|
|||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
|
||||
interface StatsData {
|
||||
total_users: number;
|
||||
total_queries: number;
|
||||
total_citations: number;
|
||||
citation_rate: number;
|
||||
today_active_users: number;
|
||||
}
|
||||
|
||||
interface AdminUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
plan: string;
|
||||
is_active: boolean;
|
||||
is_admin: boolean;
|
||||
email_verified: boolean;
|
||||
query_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const PLAN_OPTIONS = [
|
||||
{ value: "free", label: "免费版" },
|
||||
{ value: "starter", label: "入门版" },
|
||||
|
|
@ -74,15 +57,9 @@ const PLAN_OPTIONS = [
|
|||
const LIMIT = 10;
|
||||
|
||||
export default function AdminPage() {
|
||||
const { data: session } = useSession();
|
||||
const [stats, setStats] = useState<StatsData | null>(null);
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [totalUsers, setTotalUsers] = useState(0);
|
||||
const [skip, setSkip] = useState(0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [loadingStats, setLoadingStats] = useState(false);
|
||||
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
|
@ -91,48 +68,16 @@ export default function AdminPage() {
|
|||
const [selectedPlan, setSelectedPlan] = useState("");
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
const token = session?.accessToken;
|
||||
const { data: stats, isLoading: loadingStats, error: statsError } =
|
||||
useApi<AdminStatsData>("/api/v1/admin/stats");
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
loadStats();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
const usersUrl = `/api/v1/admin/users?skip=${skip}&limit=${LIMIT}${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
||||
const { data: usersData, isLoading: loadingUsers, error: usersError, refresh: refreshUsers } =
|
||||
useApi<AdminUserListResponse>(usersUrl);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
loadUsers();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token, skip, search]);
|
||||
|
||||
async function loadStats() {
|
||||
if (!token) return;
|
||||
setLoadingStats(true);
|
||||
try {
|
||||
const data = await api.admin.getStats(token);
|
||||
setStats(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "加载统计失败");
|
||||
} finally {
|
||||
setLoadingStats(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
if (!token) return;
|
||||
setLoadingUsers(true);
|
||||
try {
|
||||
const data = await api.admin.getUsers(token, { skip, limit: LIMIT, search: search || undefined });
|
||||
setUsers(data.items || []);
|
||||
setTotalUsers(data.total || 0);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "加载用户列表失败");
|
||||
} finally {
|
||||
setLoadingUsers(false);
|
||||
}
|
||||
}
|
||||
const users: AdminUser[] = usersData?.items ?? [];
|
||||
const totalUsers = usersData?.total ?? 0;
|
||||
const error = mutationError || statsError?.message || usersError?.message || null;
|
||||
|
||||
function openToggleDialog(user: AdminUser) {
|
||||
setSelectedUser(user);
|
||||
|
|
@ -148,22 +93,29 @@ export default function AdminPage() {
|
|||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!token || !selectedUser) return;
|
||||
if (!selectedUser) return;
|
||||
setActionLoading(true);
|
||||
setSuccess(null);
|
||||
setMutationError(null);
|
||||
try {
|
||||
if (dialogType === "toggle") {
|
||||
const res = await api.admin.toggleUserActive(token, selectedUser.id);
|
||||
const res = await fetchWithAuth(
|
||||
`/api/v1/admin/users/${selectedUser.id}/toggle-active`,
|
||||
{ method: "POST" }
|
||||
) as AdminActionResponse;
|
||||
setSuccess(res.message || "操作成功");
|
||||
} else {
|
||||
const res = await api.admin.updateUserPlan(token, selectedUser.id, selectedPlan);
|
||||
const res = await fetchWithAuth(
|
||||
`/api/v1/admin/users/${selectedUser.id}/update-plan`,
|
||||
{ method: "PUT", body: JSON.stringify({ plan: selectedPlan }) }
|
||||
) as AdminActionResponse;
|
||||
setSuccess(res.message || "套餐更新成功");
|
||||
}
|
||||
await loadUsers();
|
||||
refreshUsers();
|
||||
setDialogOpen(false);
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "操作失败");
|
||||
setMutationError(err instanceof Error ? err.message : "操作失败");
|
||||
setDialogOpen(false);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
|
|
@ -210,6 +162,19 @@ export default function AdminPage() {
|
|||
return d.toLocaleDateString("zh-CN");
|
||||
}
|
||||
|
||||
if (loadingStats && !stats) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">管理后台</h2>
|
||||
<p className="text-muted-foreground">系统统计与用户管理</p>
|
||||
</div>
|
||||
<LoadingState rows={4} grid cols={4} rowHeight="h-24" />
|
||||
<LoadingState rows={5} rowHeight="h-14" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
|
|
@ -236,8 +201,8 @@ export default function AdminPage() {
|
|||
{statCards.map((card) => (
|
||||
<Card key={card.title}>
|
||||
<CardContent className="flex items-center gap-4 p-6">
|
||||
<div className={cn("flex h-12 w-12 items-center justify-center rounded-lg", card.bg)}>
|
||||
<card.icon className={cn("h-6 w-6", card.color)} />
|
||||
<div className={clsx("flex h-12 w-12 items-center justify-center rounded-lg", card.bg)}>
|
||||
<card.icon className={clsx("h-6 w-6", card.color)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{card.title}</p>
|
||||
|
|
@ -271,88 +236,92 @@ export default function AdminPage() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>邮箱</TableHead>
|
||||
<TableHead>用户名</TableHead>
|
||||
<TableHead>套餐</TableHead>
|
||||
<TableHead>查询数</TableHead>
|
||||
<TableHead>邮箱验证</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>注册日期</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loadingUsers ? (
|
||||
{usersError ? (
|
||||
<ErrorState error={usersError} onRetry={refreshUsers} />
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="py-8 text-center text-muted-foreground">
|
||||
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
<TableHead>邮箱</TableHead>
|
||||
<TableHead>用户名</TableHead>
|
||||
<TableHead>套餐</TableHead>
|
||||
<TableHead>查询数</TableHead>
|
||||
<TableHead>邮箱验证</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>注册日期</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
) : users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="py-8 text-center text-muted-foreground">
|
||||
暂无用户数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">{user.email}</TableCell>
|
||||
<TableCell>{user.name || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{user.plan}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{user.query_count}</TableCell>
|
||||
<TableCell>
|
||||
{user.email_verified ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-emerald-600">
|
||||
<CheckCircle className="h-3 w-3" /> 已验证
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Ban className="h-3 w-3" /> 未验证
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.is_active ? (
|
||||
<Badge variant="default" className="bg-emerald-500 hover:bg-emerald-600">
|
||||
正常
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">禁用</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(user.created_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openToggleDialog(user)}
|
||||
>
|
||||
{user.is_active ? <Ban className="h-3 w-3 mr-1" /> : <UserCheck className="h-3 w-3 mr-1" />}
|
||||
{user.is_active ? "禁用" : "启用"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openPlanDialog(user)}
|
||||
>
|
||||
修改套餐
|
||||
</Button>
|
||||
</div>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loadingUsers ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="py-8 text-center text-muted-foreground">
|
||||
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="py-8 text-center text-muted-foreground">
|
||||
暂无用户数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">{user.email}</TableCell>
|
||||
<TableCell>{user.name || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{user.plan}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{user.query_count}</TableCell>
|
||||
<TableCell>
|
||||
{user.email_verified ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-emerald-600">
|
||||
<CheckCircle className="h-3 w-3" /> 已验证
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Ban className="h-3 w-3" /> 未验证
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.is_active ? (
|
||||
<Badge variant="default" className="bg-emerald-500 hover:bg-emerald-600">
|
||||
正常
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">禁用</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(user.created_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openToggleDialog(user)}
|
||||
>
|
||||
{user.is_active ? <Ban className="h-3 w-3 mr-1" /> : <UserCheck className="h-3 w-3 mr-1" />}
|
||||
{user.is_active ? "禁用" : "启用"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openPlanDialog(user)}
|
||||
>
|
||||
修改套餐
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
|
|
@ -428,7 +397,3 @@ export default function AdminPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function cn(...classes: (string | undefined | false)[]) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -23,8 +22,6 @@ import {
|
|||
AlertTriangle,
|
||||
Check,
|
||||
BarChart3,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
BarChart,
|
||||
|
|
@ -44,6 +41,8 @@ import {
|
|||
type TopContentItem,
|
||||
type InsightResponse,
|
||||
} from "@/lib/api";
|
||||
import { useApi } from "@/lib/hooks/use-api";
|
||||
import { LoadingState, ErrorState } from "@/components/ui/api-states";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -177,45 +176,30 @@ function MetricCard({
|
|||
subtext?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
className={`rounded-2xl border shadow-card hover:shadow-card-hover transition-all duration-200 hover:-translate-y-0.5 ${
|
||||
highlight
|
||||
? "border-primary/30 bg-gradient-to-br from-primary/5 to-primary/[0.02]"
|
||||
: "border-geo-border bg-white"
|
||||
}`}
|
||||
>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-xl ${iconBg}`}>
|
||||
{icon}
|
||||
</div>
|
||||
{highlight && (
|
||||
<Badge variant="outline" className="bg-primary/10 text-primary border-primary/20 text-xs font-medium">
|
||||
GEO核心指标
|
||||
</Badge>
|
||||
)}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium text-gray-500">{label}</p>
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${iconBg}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p
|
||||
className={`text-2xl font-bold tracking-tight ${highlight ? "text-primary" : "text-geo-text-primary"}`}
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{typeof value === "number" ? value.toLocaleString() : value}
|
||||
</p>
|
||||
<p className="text-xs text-geo-text-secondary">{label}</p>
|
||||
{subtext && <p className="text-xs text-primary font-medium">{subtext}</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<p
|
||||
className="text-3xl font-bold text-gray-900 leading-none"
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{typeof value === "number" ? value.toLocaleString() : value}
|
||||
</p>
|
||||
{subtext && <p className="mt-1.5 text-xs text-emerald-600 font-medium">{subtext}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyTopContent() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<BarChart3 className="h-10 w-10 text-muted-foreground/30 mb-3" />
|
||||
<p className="text-sm text-geo-text-secondary">暂无发布内容数据</p>
|
||||
<p className="text-xs text-geo-text-muted mt-1">发布内容后这里将展示表现排行榜</p>
|
||||
<BarChart3 className="h-10 w-10 text-gray-300 mb-3" />
|
||||
<p className="text-sm text-gray-500">暂无发布内容数据</p>
|
||||
<p className="text-xs text-gray-400 mt-1">发布内容后这里将展示表现排行榜</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -223,9 +207,9 @@ function EmptyTopContent() {
|
|||
function EmptyInsights() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Lightbulb className="h-10 w-10 text-muted-foreground/30 mb-3" />
|
||||
<p className="text-sm text-geo-text-secondary">暂无AI洞察</p>
|
||||
<p className="text-xs text-geo-text-muted mt-1">积累更多数据后,AI将为您生成优化建议</p>
|
||||
<Lightbulb className="h-10 w-10 text-gray-300 mb-3" />
|
||||
<p className="text-sm text-gray-500">暂无AI洞察</p>
|
||||
<p className="text-xs text-gray-400 mt-1">积累更多数据后,AI将为您生成优化建议</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -235,35 +219,52 @@ function EmptyInsights() {
|
|||
export default function AnalyticsPage() {
|
||||
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([]);
|
||||
const [timeRange, setTimeRange] = useState("30");
|
||||
const [appliedInsights, setAppliedInsights] = useState<Set<string>>(new Set());
|
||||
|
||||
const [overview, setOverview] = useState<OverviewStatsResponse | null>(null);
|
||||
const [topContent, setTopContent] = useState<TopContentItem[]>([]);
|
||||
const [insights, setInsights] = useState<InsightItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// SWR 数据获取
|
||||
const {
|
||||
data: overview,
|
||||
isLoading: overviewLoading,
|
||||
error: overviewError,
|
||||
refresh: refreshOverview,
|
||||
} = useApi<OverviewStatsResponse>("/api/v1/analytics/overview");
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchAnalyticsData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [overviewData, topData, insightsData] = await Promise.all([
|
||||
analyticsApi.getOverview(),
|
||||
analyticsApi.getTopContent(undefined, { limit: 5 }),
|
||||
analyticsApi.getInsights(undefined, { limit: 6 }),
|
||||
]);
|
||||
setOverview(overviewData);
|
||||
setTopContent(topData?.items ?? []);
|
||||
setInsights(mapApiInsights(insightsData ?? []));
|
||||
} catch (err) {
|
||||
console.error("Analytics fetch error:", err);
|
||||
setError(err instanceof Error ? err.message : "数据加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchAnalyticsData();
|
||||
}, []);
|
||||
const {
|
||||
data: topContentData,
|
||||
isLoading: topLoading,
|
||||
error: topError,
|
||||
refresh: refreshTop,
|
||||
} = useApi<{ items: TopContentItem[]; sort_by: string; total: number }>("/api/v1/analytics/top?limit=5");
|
||||
|
||||
const {
|
||||
data: insightsData,
|
||||
isLoading: insightsLoading,
|
||||
error: insightsError,
|
||||
refresh: refreshInsights,
|
||||
} = useApi<InsightResponse[]>("/api/v1/analytics/insights?limit=6");
|
||||
|
||||
const loading = overviewLoading || topLoading || insightsLoading;
|
||||
|
||||
// "用户未关联组织" 类错误视为空状态
|
||||
const isOrgError = (err: Error | undefined) =>
|
||||
err?.message.includes("未关联组织") || err?.message.includes("No organization");
|
||||
|
||||
const hasOrgError = isOrgError(overviewError) || isOrgError(topError) || isOrgError(insightsError);
|
||||
const error = !hasOrgError && (overviewError || topError || insightsError)
|
||||
? overviewError || topError || insightsError
|
||||
: undefined;
|
||||
|
||||
const topContent: TopContentItem[] = topContentData?.items ?? [];
|
||||
const rawInsights: InsightResponse[] = insightsData ?? [];
|
||||
const insights = mapApiInsights(rawInsights).map((ins) =>
|
||||
appliedInsights.has(ins.id) ? { ...ins, applied: true } : ins
|
||||
);
|
||||
|
||||
const handleRetry = () => {
|
||||
refreshOverview();
|
||||
refreshTop();
|
||||
refreshInsights();
|
||||
};
|
||||
|
||||
const togglePlatform = (key: string) => {
|
||||
setSelectedPlatforms((prev) =>
|
||||
|
|
@ -274,9 +275,7 @@ export default function AnalyticsPage() {
|
|||
const handleApplyInsight = async (insightId: string) => {
|
||||
try {
|
||||
await analyticsApi.applyInsight(undefined, insightId);
|
||||
setInsights((prev) =>
|
||||
prev.map((ins) => (ins.id === insightId ? { ...ins, applied: true } : ins))
|
||||
);
|
||||
setAppliedInsights((prev) => new Set(prev).add(insightId));
|
||||
} catch (err) {
|
||||
console.error("Apply insight error:", err);
|
||||
}
|
||||
|
|
@ -285,14 +284,10 @@ export default function AnalyticsPage() {
|
|||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-32 rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-72 rounded-2xl" />
|
||||
<Skeleton className="h-64 rounded-2xl" />
|
||||
<LoadingState rows={1} rowHeight="h-8" />
|
||||
<LoadingState rows={4} grid cols={4} rowHeight="h-32" />
|
||||
<LoadingState rows={1} rowHeight="h-72" />
|
||||
<LoadingState rows={1} rowHeight="h-64" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -300,16 +295,8 @@ export default function AnalyticsPage() {
|
|||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-geo-text-primary">数据监测中心</h1>
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-red-200 bg-red-50 py-16 text-center">
|
||||
<AlertCircle className="h-10 w-10 text-red-400 mb-3" />
|
||||
<p className="text-sm font-medium text-red-600">数据加载失败</p>
|
||||
<p className="text-xs text-red-500 mt-1">{error}</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1" />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">数据监测中心</h1>
|
||||
<ErrorState error={error} onRetry={handleRetry} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -324,12 +311,12 @@ export default function AnalyticsPage() {
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Top Area */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-geo-text-primary">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
数据监测中心
|
||||
</h1>
|
||||
<p className="text-sm text-geo-text-secondary mt-0.5">
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
全渠道内容表现追踪与AI引用洞察
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -379,27 +366,26 @@ export default function AnalyticsPage() {
|
|||
<MetricCard
|
||||
label="总发布"
|
||||
value={overview?.total_published ?? 0}
|
||||
icon={<FileText className="h-5 w-5 text-blue-500" />}
|
||||
icon={<FileText className="h-4 w-4 text-blue-500" />}
|
||||
iconBg="bg-blue-50"
|
||||
/>
|
||||
<MetricCard
|
||||
label="总曝光"
|
||||
value={overview?.total_views ?? 0}
|
||||
icon={<Eye className="h-5 w-5 text-purple-500" />}
|
||||
icon={<Eye className="h-4 w-4 text-purple-500" />}
|
||||
iconBg="bg-purple-50"
|
||||
/>
|
||||
<MetricCard
|
||||
label="总互动"
|
||||
value={overview?.total_interactions ?? 0}
|
||||
icon={<MessageCircle className="h-5 w-5 text-amber-500" />}
|
||||
icon={<MessageCircle className="h-4 w-4 text-amber-500" />}
|
||||
iconBg="bg-amber-50"
|
||||
/>
|
||||
<MetricCard
|
||||
label="AI引用数"
|
||||
value={overview?.total_ai_citations ?? 0}
|
||||
icon={<Quote className="h-5 w-5 text-primary" />}
|
||||
iconBg="bg-primary/10"
|
||||
highlight
|
||||
icon={<Quote className="h-4 w-4 text-emerald-500" />}
|
||||
iconBg="bg-emerald-50"
|
||||
subtext={
|
||||
overview?.avg_engagement_rate
|
||||
? `互动率 ${(overview.avg_engagement_rate * 100).toFixed(1)}%`
|
||||
|
|
@ -408,15 +394,14 @@ export default function AnalyticsPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Trend Chart — platform distribution */}
|
||||
<Card className="rounded-2xl border border-geo-border bg-white shadow-card">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-geo-text-primary">平台内容分布</h3>
|
||||
<Badge variant="outline" className="text-xs bg-geo-bg border-geo-border">
|
||||
实时数据
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Trend Chart */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-gray-500">平台内容分布</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
实时数据
|
||||
</Badge>
|
||||
</div>
|
||||
{trendData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<ComposedChart data={trendData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
|
||||
|
|
@ -437,8 +422,8 @@ export default function AnalyticsPage() {
|
|||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "hsl(var(--card))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
backgroundColor: "#FFFFFF",
|
||||
border: "1px solid #E5E7EB",
|
||||
borderRadius: "12px",
|
||||
fontSize: 12,
|
||||
}}
|
||||
|
|
@ -459,22 +444,20 @@ export default function AnalyticsPage() {
|
|||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-center">
|
||||
<BarChart3 className="h-10 w-10 text-muted-foreground/30 mb-3" />
|
||||
<p className="text-sm text-geo-text-secondary">暂无平台分布数据</p>
|
||||
<BarChart3 className="h-10 w-10 text-gray-300 mb-3" />
|
||||
<p className="text-sm text-gray-500">暂无平台分布数据</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Performance Table */}
|
||||
<Card className="rounded-2xl border border-geo-border bg-white shadow-card">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-geo-text-primary">内容表现排行榜</h3>
|
||||
<Badge variant="outline" className="text-xs bg-geo-bg border-geo-border">
|
||||
Top {topContent.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium text-gray-500">内容表现排行榜</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Top {topContent.length}
|
||||
</Badge>
|
||||
</div>
|
||||
{topContent.length === 0 ? (
|
||||
<EmptyTopContent />
|
||||
) : (
|
||||
|
|
@ -482,12 +465,12 @@ export default function AnalyticsPage() {
|
|||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="text-xs font-semibold text-geo-text-secondary uppercase tracking-wider w-16">排名</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-geo-text-secondary uppercase tracking-wider">标题</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-geo-text-secondary uppercase tracking-wider">平台</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-geo-text-secondary uppercase tracking-wider text-right">曝光</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-geo-text-secondary uppercase tracking-wider text-right">互动率</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-geo-text-secondary uppercase tracking-wider text-right">AI引用数</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wider w-16">排名</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wider">标题</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wider">平台</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wider text-right">曝光</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wider text-right">互动率</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wider text-right">AI引用数</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
@ -498,7 +481,7 @@ export default function AnalyticsPage() {
|
|||
? ((item.search_clicks / item.search_impressions) * 100).toFixed(1)
|
||||
: "0.0";
|
||||
return (
|
||||
<TableRow key={item.publish_record_id} className="hover:bg-geo-bg/50">
|
||||
<TableRow key={item.publish_record_id} className="hover:bg-gray-50">
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold ${
|
||||
|
|
@ -514,7 +497,7 @@ export default function AnalyticsPage() {
|
|||
{rank}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-medium text-geo-text-primary max-w-[240px] truncate">
|
||||
<TableCell className="text-sm font-medium text-gray-900 max-w-[240px] truncate">
|
||||
{item.content_title}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
|
@ -522,10 +505,10 @@ export default function AnalyticsPage() {
|
|||
{item.platform}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-geo-text-secondary text-right" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
<TableCell className="text-sm text-gray-500 text-right" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{(item.views || item.search_impressions).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-geo-text-secondary text-right" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
<TableCell className="text-sm text-gray-500 text-right" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{interactionRate}%
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
|
|
@ -541,40 +524,37 @@ export default function AnalyticsPage() {
|
|||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* AI Insights */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-geo-text-primary flex items-center gap-2">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-500 flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-primary" />
|
||||
AI 智能洞察
|
||||
</h3>
|
||||
{insights.length === 0 ? (
|
||||
<Card className="rounded-2xl border border-geo-border bg-white shadow-card">
|
||||
<CardContent className="p-5">
|
||||
<EmptyInsights />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<EmptyInsights />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{insights.map((insight) => {
|
||||
const styles = getSeverityStyles(insight.severity);
|
||||
return (
|
||||
<Card
|
||||
<div
|
||||
key={insight.id}
|
||||
className={`rounded-2xl border bg-white shadow-card hover:shadow-card-hover transition-all duration-200 ${styles.border}`}
|
||||
className={`rounded-xl border bg-white ${styles.border} p-5`}
|
||||
>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${styles.iconBg}`}>
|
||||
<InsightIcon name={insight.icon} className={`h-4.5 w-4.5 ${styles.iconColor}`} />
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${styles.iconBg}">
|
||||
<InsightIcon name={insight.icon} className={`h-4 w-4 ${styles.iconColor}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-semibold text-geo-text-primary leading-snug">
|
||||
<h4 className="text-sm font-semibold text-gray-900 leading-snug">
|
||||
{insight.title}
|
||||
</h4>
|
||||
<p className="text-xs text-geo-text-secondary mt-1 leading-relaxed">
|
||||
<p className="text-xs text-gray-500 mt-1 leading-relaxed">
|
||||
{insight.description}
|
||||
</p>
|
||||
{insight.recommendation && (
|
||||
|
|
@ -597,7 +577,7 @@ export default function AnalyticsPage() {
|
|||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -22,9 +21,10 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api";
|
||||
import { PLATFORM_MAP } from "@/lib/platforms";
|
||||
import { Check, X, Loader2, Quote, Filter } from "lucide-react";
|
||||
import { Check, X, Quote, Filter } from "lucide-react";
|
||||
import { useApi } from "@/lib/hooks/use-api";
|
||||
import { LoadingState } from "@/components/ui/api-states";
|
||||
|
||||
interface CitationItem {
|
||||
id: string;
|
||||
|
|
@ -43,69 +43,44 @@ interface QueryOption {
|
|||
}
|
||||
|
||||
export default function CitationsPage() {
|
||||
const { data: session } = useSession();
|
||||
const [citations, setCitations] = useState<CitationItem[]>([]);
|
||||
const [queries, setQueries] = useState<QueryOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [selectedQuery, setSelectedQuery] = useState<string>("all");
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<string>("all");
|
||||
const [startDate, setStartDate] = useState<string>("");
|
||||
const [endDate, setEndDate] = useState<string>("");
|
||||
const isFirstFilterEffect = useRef(true);
|
||||
// 用于手动触发筛选
|
||||
const [filterKey, setFilterKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.accessToken) return;
|
||||
loadQueries();
|
||||
loadCitations();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session?.accessToken]);
|
||||
// 构建引用记录查询 URL
|
||||
const citationsUrl = (() => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedQuery && selectedQuery !== "all") params.append("query_id", selectedQuery);
|
||||
if (selectedPlatform && selectedPlatform !== "all") params.append("platform", selectedPlatform);
|
||||
if (startDate) params.append("start_date", startDate);
|
||||
if (endDate) params.append("end_date", endDate);
|
||||
const qs = params.toString();
|
||||
// filterKey 作为虚拟参数,即使筛选条件不变也允许重新请求
|
||||
return `/api/v1/citations/${qs ? `?${qs}&_k=${filterKey}` : `?_k=${filterKey}`}`;
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.accessToken) return;
|
||||
if (isFirstFilterEffect.current) {
|
||||
isFirstFilterEffect.current = false;
|
||||
return;
|
||||
}
|
||||
loadCitations();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedQuery, selectedPlatform, startDate, endDate, session?.accessToken]);
|
||||
const {
|
||||
data: citationsData,
|
||||
isLoading,
|
||||
error: citationsError,
|
||||
refresh: refreshCitations,
|
||||
} = useApi<{ items: CitationItem[] }>(
|
||||
citationsUrl,
|
||||
{ dedupingInterval: 0 }
|
||||
);
|
||||
|
||||
async function loadQueries() {
|
||||
try {
|
||||
const data = await api.queries.list(session!.accessToken);
|
||||
setQueries(data.items || []);
|
||||
} catch {
|
||||
// 静默失败,查询词筛选为非必需
|
||||
}
|
||||
}
|
||||
const {
|
||||
data: queriesData,
|
||||
} = useApi<{ items: QueryOption[] }>("/api/v1/queries/");
|
||||
|
||||
async function loadCitations() {
|
||||
if (!session?.accessToken) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
if (selectedQuery && selectedQuery !== "all") params.append("query_id", selectedQuery);
|
||||
if (selectedPlatform && selectedPlatform !== "all") params.append("platform", selectedPlatform);
|
||||
if (startDate) params.append("start_date", startDate);
|
||||
if (endDate) params.append("end_date", endDate);
|
||||
|
||||
const data = await api.citations.list(
|
||||
session.accessToken,
|
||||
params.toString()
|
||||
);
|
||||
setCitations(data.items || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "加载引用记录失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
const citations: CitationItem[] = citationsData?.items ?? [];
|
||||
const queries: QueryOption[] = queriesData?.items ?? [];
|
||||
|
||||
function handleFilter() {
|
||||
loadCitations();
|
||||
setFilterKey((k) => k + 1);
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
|
|
@ -113,18 +88,17 @@ export default function CitationsPage() {
|
|||
setSelectedPlatform("all");
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setFilterKey((k) => k + 1);
|
||||
}
|
||||
|
||||
if (loading && citations.length === 0) {
|
||||
if (isLoading && citations.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">引用记录</h2>
|
||||
<p className="text-muted-foreground">查看各平台的引用检测结果</p>
|
||||
</div>
|
||||
<div className="flex h-[60vh] items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
<LoadingState rows={4} rowHeight="h-12" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -207,9 +181,9 @@ export default function CitationsPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error && (
|
||||
{citationsError && (
|
||||
<div className="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
{citationsError.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -134,10 +134,10 @@ function ContentCard({ item }: { item: Content }) {
|
|||
const dateStr = new Date(item.created_at).toLocaleDateString("zh-CN");
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border border-geo-border bg-white shadow-card hover:shadow-card-hover transition-all duration-200 hover:-translate-y-0.5 group cursor-pointer">
|
||||
<CardContent className="p-5">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 hover:border-gray-300 transition-colors group cursor-pointer">
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<h3 className="text-base font-semibold text-geo-text-primary leading-snug line-clamp-2 group-hover:text-primary transition-colors">
|
||||
<h3 className="text-base font-semibold text-gray-900 leading-snug line-clamp-2 group-hover:text-primary transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
|
@ -149,7 +149,7 @@ function ContentCard({ item }: { item: Content }) {
|
|||
{statusConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-geo-text-secondary">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<Type className="h-3.5 w-3.5" />
|
||||
|
|
@ -166,8 +166,8 @@ function ContentCard({ item }: { item: Content }) {
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -226,17 +226,17 @@ function PipelineTimeline({ steps }: { steps: PipelineStep[] }) {
|
|||
|
||||
function EmptyState({ onGenerate }: { onGenerate: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl border border-geo-border bg-white py-20 text-center shadow-card">
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<PenTool className="h-8 w-8 text-primary" />
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-gray-200 bg-white py-20 text-center">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
|
||||
<PenTool className="h-6 w-6 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-semibold text-geo-text-primary">还没有内容</h3>
|
||||
<p className="mb-8 max-w-sm text-sm text-geo-text-secondary">
|
||||
<h3 className="text-base font-semibold text-gray-900">还没有内容</h3>
|
||||
<p className="mt-2 mb-6 max-w-sm text-sm text-gray-500">
|
||||
让AI帮你创作第一篇内容,开启智能内容生产之旅
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onGenerate}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground rounded-xl px-6 py-2.5 h-auto text-base shadow-lg shadow-primary/20"
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
AI生成新内容
|
||||
|
|
@ -434,14 +434,15 @@ export default function ContentPage() {
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Top Area ── */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-geo-text-primary">内容工坊</h1>
|
||||
<p className="text-sm text-geo-text-secondary mt-0.5">AI驱动的内容生产流水线</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900">内容工坊</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">AI驱动的内容生产流水线</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleOpenDialog}
|
||||
className="shrink-0 bg-primary hover:bg-primary/90 text-primary-foreground rounded-xl px-5 py-2.5 h-auto shadow-lg shadow-primary/20 transition-all duration-200 hover:shadow-xl hover:shadow-primary/25 hover:-translate-y-0.5"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
AI生成新内容
|
||||
|
|
|
|||
|
|
@ -138,15 +138,15 @@ function PlatformCard({ platform }: { platform: PlatformInfo }) {
|
|||
];
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border border-geo-border bg-white shadow-card hover:shadow-card-hover transition-all duration-200">
|
||||
<CardContent className="p-5">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 hover:border-gray-300 transition-colors">
|
||||
<div className="p-5">
|
||||
{/* Title + Avatar */}
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<PlatformAvatar platformId={platform.id} name={platform.name} />
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-geo-text-primary">{platform.name}</h3>
|
||||
<p className="text-xs text-geo-text-secondary mt-0.5">
|
||||
<h3 className="text-base font-semibold text-gray-900">{platform.name}</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{platform.max_content_length.toLocaleString()} 字上限
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -158,7 +158,7 @@ function PlatformCard({ platform }: { platform: PlatformInfo }) {
|
|||
|
||||
{/* Best publish times */}
|
||||
{platform.best_publish_times.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-geo-text-secondary mb-3">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500 mb-3">
|
||||
<Clock className="h-3.5 w-3.5 text-primary" />
|
||||
<span>最佳发布时段:{platform.best_publish_times.slice(0, 3).join("、")}</span>
|
||||
</div>
|
||||
|
|
@ -166,7 +166,7 @@ function PlatformCard({ platform }: { platform: PlatformInfo }) {
|
|||
|
||||
{/* SEO tips preview */}
|
||||
{platform.seo_tips.length > 0 && (
|
||||
<div className="text-xs text-geo-text-muted mb-3 line-clamp-1">
|
||||
<div className="text-xs text-gray-400 mb-3 line-clamp-1">
|
||||
💡 {platform.seo_tips[0]}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -185,13 +185,13 @@ function PlatformCard({ platform }: { platform: PlatformInfo }) {
|
|||
{validations.map((v, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 text-sm">
|
||||
<ValidationIcon result={v.result} />
|
||||
<span className="text-geo-text-secondary">{v.message}</span>
|
||||
<span className="text-gray-500">{v.message}</span>
|
||||
</div>
|
||||
))}
|
||||
{platform.rules.slice(0, 3).map((rule, idx) => (
|
||||
<div key={`rule-${idx}`} className="flex items-start gap-2 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
|
||||
<span className="text-geo-text-secondary">{rule}</span>
|
||||
<span className="text-gray-500">{rule}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -200,25 +200,25 @@ function PlatformCard({ platform }: { platform: PlatformInfo }) {
|
|||
{/* Tags */}
|
||||
{platform.supported_media.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap pt-2 border-t border-geo-border">
|
||||
<Tag className="h-3 w-3 text-geo-text-muted" />
|
||||
<Tag className="h-3 w-3 text-gray-400" />
|
||||
{platform.supported_media.slice(0, 3).map((media) => (
|
||||
<Badge key={media} variant="outline" className="text-xs bg-geo-bg border-geo-border">
|
||||
<Badge key={media} variant="outline" className="text-xs bg-gray-50 border-gray-200">
|
||||
{media}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyPlatforms() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl border border-geo-border bg-white py-16 text-center shadow-card">
|
||||
<Globe className="h-12 w-12 text-muted-foreground/30 mb-3" />
|
||||
<h3 className="text-base font-semibold text-geo-text-primary">暂无平台配置</h3>
|
||||
<p className="mt-1.5 text-sm text-geo-text-secondary max-w-xs mx-auto">
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-gray-200 bg-white py-16 text-center">
|
||||
<Globe className="h-12 w-12 text-gray-300 mb-3" />
|
||||
<h3 className="text-base font-semibold text-gray-900">暂无平台配置</h3>
|
||||
<p className="mt-2 text-sm text-gray-500 max-w-xs mx-auto">
|
||||
后端暂无平台规则数据,请检查后端服务状态
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -228,9 +228,9 @@ function EmptyPlatforms() {
|
|||
function EmptyPublished() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Send className="h-10 w-10 text-muted-foreground/30 mb-3" />
|
||||
<p className="text-sm text-geo-text-secondary">暂无发布记录</p>
|
||||
<p className="text-xs text-geo-text-muted mt-1">在内容工坊生成内容并发布后,记录将在此显示</p>
|
||||
<Send className="h-10 w-10 text-gray-300 mb-3" />
|
||||
<p className="text-sm text-gray-500">暂无发布记录</p>
|
||||
<p className="text-xs text-gray-400 mt-1">在内容工坊生成内容并发布后,记录将在此显示</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -305,10 +305,10 @@ export default function DistributionPage() {
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Top Area */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-geo-text-primary">内容分发中心</h1>
|
||||
<p className="text-sm text-geo-text-secondary mt-0.5">多平台智能分发与发布管理</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900">内容分发中心</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">多平台智能分发与发布管理</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -326,7 +326,7 @@ export default function DistributionPage() {
|
|||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="platforms" className="space-y-6">
|
||||
<TabsList className="rounded-xl bg-white border border-geo-border p-1">
|
||||
<TabsList className="rounded-xl bg-white border border-gray-200 p-1">
|
||||
<TabsTrigger
|
||||
value="platforms"
|
||||
className="rounded-lg data-[state=active]:bg-primary data-[state=active]:text-primary-foreground text-sm px-4 py-1.5"
|
||||
|
|
@ -376,7 +376,7 @@ export default function DistributionPage() {
|
|||
|
||||
{/* Published Records Tab */}
|
||||
<TabsContent value="published">
|
||||
<Card className="rounded-2xl border border-geo-border bg-white shadow-card">
|
||||
<Card className="rounded-xl border border-gray-200 bg-white">
|
||||
<CardContent className="p-0">
|
||||
{publishRecords.length === 0 ? (
|
||||
<EmptyPublished />
|
||||
|
|
@ -384,10 +384,10 @@ export default function DistributionPage() {
|
|||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="text-xs font-semibold text-geo-text-secondary uppercase tracking-wider">标题</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-geo-text-secondary uppercase tracking-wider">平台</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-geo-text-secondary uppercase tracking-wider">发布时间</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-geo-text-secondary uppercase tracking-wider">状态</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wider">标题</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wider">平台</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wider">发布时间</TableHead>
|
||||
<TableHead className="text-xs font-semibold text-gray-500 uppercase tracking-wider">状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
@ -398,8 +398,8 @@ export default function DistributionPage() {
|
|||
? new Date(item.published_at).toLocaleString("zh-CN")
|
||||
: "—";
|
||||
return (
|
||||
<TableRow key={item.id} className="hover:bg-geo-bg/50">
|
||||
<TableCell className="text-sm font-medium text-geo-text-primary max-w-[280px] truncate">
|
||||
<TableRow key={item.id} className="hover:bg-gray-50">
|
||||
<TableCell className="text-sm font-medium text-gray-900 max-w-[280px] truncate">
|
||||
{item.content_title}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
|
@ -409,7 +409,7 @@ export default function DistributionPage() {
|
|||
{platformConfig.label}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-geo-text-secondary">{publishedAt}</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">{publishedAt}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={`text-xs font-medium ${statusConfig.className}`}>
|
||||
{statusConfig.label}
|
||||
|
|
@ -442,7 +442,7 @@ export default function DistributionPage() {
|
|||
{/* Best times */}
|
||||
{activePlatform.best_publish_times.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-geo-text-primary mb-2 flex items-center gap-1.5">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-2 flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
最佳发布时段
|
||||
</h4>
|
||||
|
|
@ -464,13 +464,13 @@ export default function DistributionPage() {
|
|||
{/* SEO Tips */}
|
||||
{activePlatform.seo_tips.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-geo-text-primary mb-2 flex items-center gap-1.5">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-2 flex items-center gap-1.5">
|
||||
<Tag className="h-3.5 w-3.5" />
|
||||
SEO 优化建议
|
||||
</h4>
|
||||
<ul className="space-y-1.5">
|
||||
{activePlatform.seo_tips.map((tip, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2 text-xs text-geo-text-secondary">
|
||||
<li key={idx} className="flex items-start gap-2 text-xs text-gray-500">
|
||||
<span className="mt-0.5 h-1.5 w-1.5 rounded-full bg-primary shrink-0" />
|
||||
{tip}
|
||||
</li>
|
||||
|
|
@ -482,13 +482,13 @@ export default function DistributionPage() {
|
|||
{/* Rules */}
|
||||
{activePlatform.rules.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-geo-text-primary mb-2 flex items-center gap-1.5">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-2 flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
发布规则
|
||||
</h4>
|
||||
<ul className="space-y-1.5">
|
||||
{activePlatform.rules.map((rule, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2 text-xs text-geo-text-secondary">
|
||||
<li key={idx} className="flex items-start gap-2 text-xs text-gray-500">
|
||||
<span className="mt-0.5 h-1.5 w-1.5 rounded-full bg-amber-400 shrink-0" />
|
||||
{rule}
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -13,7 +13,6 @@ import { Button } from "@/components/ui/button";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -47,7 +46,6 @@ import {
|
|||
Link,
|
||||
Type,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
|
|
@ -55,6 +53,8 @@ import {
|
|||
type KnowledgeBase,
|
||||
type KnowledgeDocument,
|
||||
} from "@/lib/api";
|
||||
import { useApi } from "@/lib/hooks/use-api";
|
||||
import { LoadingState, ErrorState } from "@/components/ui/api-states";
|
||||
|
||||
// ── 状态 Badge ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -106,12 +106,12 @@ function SourceTypeBadge({ type }: { type: string }) {
|
|||
function EmptyState({ onCreateClick }: { onCreateClick: () => void }) {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<BookOpen className="h-12 w-12 mx-auto text-muted-foreground/40" />
|
||||
<h3 className="mt-4 text-base font-semibold">还没有知识库</h3>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground max-w-xs mx-auto">
|
||||
<BookOpen className="h-12 w-12 mx-auto text-gray-300" />
|
||||
<h3 className="mt-4 text-base font-semibold text-gray-900">还没有知识库</h3>
|
||||
<p className="mt-2 text-sm text-gray-500 max-w-xs mx-auto">
|
||||
创建您的第一个知识库,为AI内容生产提供精准的知识支撑
|
||||
</p>
|
||||
<Button className="mt-4" onClick={onCreateClick}>
|
||||
<Button variant="outline" className="mt-4" onClick={onCreateClick}>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
创建知识库
|
||||
</Button>
|
||||
|
|
@ -134,31 +134,15 @@ function KnowledgeBaseCard({
|
|||
onDelete: (id: string) => void;
|
||||
onUpload: (id: string) => void;
|
||||
}) {
|
||||
const [documents, setDocuments] = useState<KnowledgeDocument[]>([]);
|
||||
const [docsLoading, setDocsLoading] = useState(false);
|
||||
const [docsError, setDocsError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpanded) return;
|
||||
async function fetchDocs() {
|
||||
try {
|
||||
setDocsLoading(true);
|
||||
setDocsError(null);
|
||||
const docs = await knowledgeApi.listDocuments(undefined, kb.id);
|
||||
setDocuments(docs ?? []);
|
||||
} catch (err) {
|
||||
setDocsError(err instanceof Error ? err.message : "文档加载失败");
|
||||
} finally {
|
||||
setDocsLoading(false);
|
||||
}
|
||||
}
|
||||
fetchDocs();
|
||||
}, [isExpanded, kb.id]);
|
||||
const docsUrl = isExpanded ? `/api/v1/knowledge/bases/${kb.id}/documents` : null;
|
||||
const { data: documents = [], isLoading: docsLoading, error: docsApiError, refresh: refreshDocs } =
|
||||
useApi<KnowledgeDocument[]>(docsUrl);
|
||||
const docsError = docsApiError?.message ?? null;
|
||||
|
||||
const handleDeleteDoc = async (docId: string) => {
|
||||
try {
|
||||
await knowledgeApi.deleteDocument(undefined, kb.id, docId);
|
||||
setDocuments((prev) => prev.filter((d) => d.id !== docId));
|
||||
refreshDocs();
|
||||
} catch (err) {
|
||||
console.error("Delete doc error:", err);
|
||||
}
|
||||
|
|
@ -166,11 +150,11 @@ function KnowledgeBaseCard({
|
|||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Card className="cursor-pointer" onClick={onToggle}>
|
||||
<Card className="cursor-pointer rounded-xl border border-gray-200" onClick={onToggle}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{kb.name}</CardTitle>
|
||||
<CardTitle className="text-base text-gray-900">{kb.name}</CardTitle>
|
||||
{kb.description && (
|
||||
<CardDescription className="line-clamp-2">{kb.description}</CardDescription>
|
||||
<CardDescription className="line-clamp-2 text-gray-500">{kb.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardFooter className="pt-0 flex items-center justify-between">
|
||||
|
|
@ -195,7 +179,7 @@ function KnowledgeBaseCard({
|
|||
</Card>
|
||||
|
||||
{isExpanded && (
|
||||
<Card>
|
||||
<Card className="rounded-xl border border-gray-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-semibold">文档列表</h4>
|
||||
|
|
@ -269,11 +253,22 @@ export default function KnowledgePage() {
|
|||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [expandedKbId, setExpandedKbId] = useState<string | null>(null);
|
||||
|
||||
// Data
|
||||
const [enterpriseBases, setEnterpriseBases] = useState<KnowledgeBase[]>([]);
|
||||
const [industryBases, setIndustryBases] = useState<KnowledgeBase[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// SWR data fetching
|
||||
const {
|
||||
data: enterpriseBases = [],
|
||||
isLoading: enterpriseLoading,
|
||||
error: enterpriseError,
|
||||
mutate: mutateEnterprise,
|
||||
} = useApi<KnowledgeBase[]>("/api/v1/knowledge/bases/?type=enterprise");
|
||||
|
||||
const {
|
||||
data: industryBases = [],
|
||||
isLoading: industryLoading,
|
||||
error: industryError,
|
||||
} = useApi<KnowledgeBase[]>("/api/v1/knowledge/bases/?type=industry");
|
||||
|
||||
const loading = enterpriseLoading || industryLoading;
|
||||
const error = enterpriseError?.message || industryError?.message || null;
|
||||
|
||||
// Create KB dialog
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
|
|
@ -292,27 +287,9 @@ export default function KnowledgePage() {
|
|||
const [uploadLoading, setUploadLoading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
|
||||
const fetchBases = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [enterpriseData, industryData] = await Promise.all([
|
||||
knowledgeApi.listBases(undefined, "enterprise"),
|
||||
knowledgeApi.listBases(undefined, "industry"),
|
||||
]);
|
||||
setEnterpriseBases(enterpriseData ?? []);
|
||||
setIndustryBases(industryData ?? []);
|
||||
} catch (err) {
|
||||
console.error("Knowledge bases fetch error:", err);
|
||||
setError(err instanceof Error ? err.message : "数据加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBases();
|
||||
}, [fetchBases]);
|
||||
const fetchBases = useCallback(() => {
|
||||
mutateEnterprise();
|
||||
}, [mutateEnterprise]);
|
||||
|
||||
const currentBases = activeTab === "enterprise" ? enterpriseBases : industryBases;
|
||||
const filteredBases = searchQuery
|
||||
|
|
@ -328,12 +305,12 @@ export default function KnowledgePage() {
|
|||
try {
|
||||
setCreateLoading(true);
|
||||
setCreateError(null);
|
||||
const created = await knowledgeApi.createBase(undefined, {
|
||||
await knowledgeApi.createBase(undefined, {
|
||||
name: newKbName.trim(),
|
||||
type: "enterprise",
|
||||
description: newKbDescription.trim() || undefined,
|
||||
});
|
||||
setEnterpriseBases((prev) => [created, ...prev]);
|
||||
mutateEnterprise();
|
||||
setCreateDialogOpen(false);
|
||||
setNewKbName("");
|
||||
setNewKbDescription("");
|
||||
|
|
@ -347,7 +324,7 @@ export default function KnowledgePage() {
|
|||
const handleDeleteKb = async (kbId: string) => {
|
||||
try {
|
||||
await knowledgeApi.deleteBase(undefined, kbId);
|
||||
setEnterpriseBases((prev) => prev.filter((kb) => kb.id !== kbId));
|
||||
mutateEnterprise();
|
||||
if (expandedKbId === kbId) setExpandedKbId(null);
|
||||
} catch (err) {
|
||||
console.error("Delete KB error:", err);
|
||||
|
|
@ -365,14 +342,7 @@ export default function KnowledgePage() {
|
|||
content: docSourceType !== "url" ? docContent : undefined,
|
||||
source_url: docSourceType === "url" ? docUrl : undefined,
|
||||
});
|
||||
// Update document count optimistically
|
||||
setEnterpriseBases((prev) =>
|
||||
prev.map((kb) =>
|
||||
kb.id === uploadKbId
|
||||
? { ...kb, document_count: kb.document_count + 1 }
|
||||
: kb
|
||||
)
|
||||
);
|
||||
mutateEnterprise();
|
||||
setUploadDialogOpen(false);
|
||||
setDocTitle("");
|
||||
setDocContent("");
|
||||
|
|
@ -394,12 +364,11 @@ export default function KnowledgePage() {
|
|||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-40 rounded-xl" />
|
||||
))}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">知识库</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">管理行业和企业知识,为AI内容生产提供智能支撑</p>
|
||||
</div>
|
||||
<LoadingState rows={3} grid cols={3} rowHeight="h-40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -407,23 +376,16 @@ export default function KnowledgePage() {
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">知识库</h1>
|
||||
<p className="text-geo-text-secondary mt-1">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">知识库</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
管理行业和企业知识,为AI内容生产提供智能支撑
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
<Button variant="ghost" size="sm" className="ml-auto h-7 text-xs" onClick={fetchBases}>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1" />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
<ErrorState error={error} onRetry={fetchBases} />
|
||||
)}
|
||||
|
||||
{/* Tab 切换 */}
|
||||
|
|
@ -449,7 +411,7 @@ export default function KnowledgePage() {
|
|||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => { setCreateError(null); setCreateDialogOpen(true); }}>
|
||||
<Button variant="outline" onClick={() => { setCreateError(null); setCreateDialogOpen(true); }}>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
创建知识库
|
||||
</Button>
|
||||
|
|
@ -483,16 +445,16 @@ export default function KnowledgePage() {
|
|||
|
||||
{filteredBases.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<BookOpen className="h-12 w-12 mx-auto text-muted-foreground/40" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">暂无行业知识库</p>
|
||||
<BookOpen className="h-12 w-12 mx-auto text-gray-300" />
|
||||
<p className="mt-4 text-sm text-gray-500">暂无行业知识库</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredBases.map((kb) => (
|
||||
<Card key={kb.id}>
|
||||
<Card key={kb.id} className="rounded-xl border border-gray-200">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base">{kb.name}</CardTitle>
|
||||
<CardTitle className="text-base text-gray-900">{kb.name}</CardTitle>
|
||||
<Badge variant="info" className="text-[10px]">平台维护</Badge>
|
||||
</div>
|
||||
{kb.description && (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
|
|
@ -8,10 +7,8 @@ import {
|
|||
StageProgress,
|
||||
AgentStatusCard,
|
||||
} from "@/components/business";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Sparkles,
|
||||
Search,
|
||||
|
|
@ -22,11 +19,10 @@ import {
|
|||
Plus,
|
||||
ArrowRight,
|
||||
Zap,
|
||||
TrendingUp,
|
||||
Quote,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { lifecycleApi, type GeoProject, type LifecycleStats } from "@/lib/api";
|
||||
import { type GeoProject, type LifecycleStats } from "@/lib/api";
|
||||
import { useApi } from "@/lib/hooks/use-api";
|
||||
import { LoadingState, ErrorState, EmptyState } from "@/components/ui/api-states";
|
||||
|
||||
/* ─── Helpers ─────────────────────────────────────────────────────────────────*/
|
||||
|
||||
|
|
@ -115,53 +111,50 @@ function getRecommendation(stage: GeoProject["current_stage"]) {
|
|||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [projects, setProjects] = useState<GeoProject[]>([]);
|
||||
const [stats, setStats] = useState<LifecycleStats | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [projectList, statsData] = await Promise.all([
|
||||
lifecycleApi.listProjects(),
|
||||
lifecycleApi.getStats(),
|
||||
]);
|
||||
setProjects(projectList ?? []);
|
||||
setStats(statsData);
|
||||
} catch (err) {
|
||||
console.error("Dashboard fetch error:", err);
|
||||
setError(err instanceof Error ? err.message : "数据加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
const {
|
||||
data: projects,
|
||||
isLoading: projectsLoading,
|
||||
error: projectsError,
|
||||
refresh: refreshProjects,
|
||||
} = useApi<GeoProject[]>("/api/v1/lifecycle/projects/");
|
||||
|
||||
const {
|
||||
data: stats,
|
||||
isLoading: statsLoading,
|
||||
error: statsError,
|
||||
refresh: refreshStats,
|
||||
} = useApi<LifecycleStats>("/api/v1/lifecycle/projects/stats");
|
||||
|
||||
const loading = projectsLoading || statsLoading;
|
||||
|
||||
// "用户未关联组织" 类错误视为空状态
|
||||
const isOrgError = (err: Error | undefined) =>
|
||||
err?.message.includes("未关联组织") || err?.message.includes("No organization");
|
||||
|
||||
const hasOrgError = isOrgError(projectsError) || isOrgError(statsError);
|
||||
const error =
|
||||
!hasOrgError && (projectsError || statsError) ? projectsError || statsError : undefined;
|
||||
|
||||
const safeProjects: GeoProject[] = hasOrgError ? [] : (projects ?? []);
|
||||
const safeStats: LifecycleStats | null = hasOrgError ? null : (stats ?? null);
|
||||
|
||||
const handleRetry = () => {
|
||||
refreshProjects();
|
||||
refreshStats();
|
||||
};
|
||||
|
||||
/* ─── Loading State ────────────────────────────────────────────────────────*/
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-8 w-56" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</div>
|
||||
<Skeleton className="h-20 w-full rounded-xl" />
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-36 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Skeleton className="h-48 w-full rounded-xl" />
|
||||
<Skeleton className="h-48 w-full rounded-xl" />
|
||||
<div className="mb-8">
|
||||
<LoadingState rows={1} rowHeight="h-8" />
|
||||
<div className="mt-2"><LoadingState rows={1} rowHeight="h-4" /></div>
|
||||
</div>
|
||||
<LoadingState rows={4} grid cols={4} rowHeight="h-24" />
|
||||
<LoadingState rows={1} rowHeight="h-28" />
|
||||
<LoadingState rows={2} grid cols={2} rowHeight="h-48" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -170,79 +163,55 @@ export default function DashboardPage() {
|
|||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-geo-text-primary">
|
||||
AI Visibility Dashboard
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-red-200 bg-red-50 py-16 text-center">
|
||||
<AlertCircle className="h-10 w-10 text-red-400 mb-3" />
|
||||
<p className="text-sm font-medium text-red-600">数据加载失败</p>
|
||||
<p className="text-xs text-red-500 mt-1">{error}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Overview</h1>
|
||||
</div>
|
||||
<ErrorState error={error} onRetry={handleRetry} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Empty State ──────────────────────────────────────────────────────────*/
|
||||
if (projects.length === 0) {
|
||||
if (safeProjects.length === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-geo-text-primary">
|
||||
AI Visibility Dashboard
|
||||
</h1>
|
||||
<p className="text-sm text-geo-text-secondary">
|
||||
GEO和SEO是AI营销时代的共生体
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-geo-border bg-white py-20 text-center shadow-geo-card">
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<Sparkles className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-semibold text-geo-text-primary">
|
||||
开始优化您的AI可见性
|
||||
</h3>
|
||||
<p className="mb-8 max-w-sm text-sm text-geo-text-secondary">
|
||||
创建第一个项目,系统将自动引导您完成从诊断分析到监测优化的全生命周期管理。
|
||||
</p>
|
||||
<Link href="/dashboard/lifecycle/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
创建项目
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Overview</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">GEO和SEO是AI营销时代的共生体</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={<Sparkles className="h-6 w-6 text-gray-400" />}
|
||||
message="开始优化您的AI可见性"
|
||||
description="创建第一个项目,系统将自动引导您完成从诊断分析到监测优化的全生命周期管理。"
|
||||
action={
|
||||
<Link href="/dashboard/lifecycle/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
创建项目
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Active Dashboard ─────────────────────────────────────────────────────*/
|
||||
const project = projects[0];
|
||||
const project = safeProjects[0];
|
||||
const stages = buildStages(project.current_stage);
|
||||
const recommendation = getRecommendation(project.current_stage);
|
||||
|
||||
const citationRate = stats?.avg_ai_citation_rate != null
|
||||
? `${(stats.avg_ai_citation_rate * 100).toFixed(1)}%`
|
||||
const citationRate = safeStats?.avg_ai_citation_rate != null
|
||||
? `${(safeStats.avg_ai_citation_rate * 100).toFixed(1)}%`
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1. Top Bar */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
{/* 1. Page Title */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-geo-text-primary">
|
||||
AI Visibility Dashboard
|
||||
</h1>
|
||||
<p className="text-sm text-geo-text-secondary">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Overview</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{project.brand_name} — {project.name}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -254,136 +223,115 @@ export default function DashboardPage() {
|
|||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 2. Stage Progress */}
|
||||
<Card className="rounded-xl shadow-geo-card">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-geo-text-muted">
|
||||
生命周期进度
|
||||
</p>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
当前阶段:
|
||||
{STAGE_CONFIG.find((s) => s.id === project.current_stage)?.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<StageProgress
|
||||
stages={stages}
|
||||
onStageClick={(stage) => {
|
||||
router.push(`/dashboard/${stage.id}`);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3. Metrics Grid */}
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* 2. KPI Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricCard
|
||||
label="活跃项目数"
|
||||
value={stats?.active_projects ?? 0}
|
||||
value={safeStats?.active_projects ?? 0}
|
||||
trend="neutral"
|
||||
trendLabel={`共 ${stats?.total_projects ?? 0} 个项目`}
|
||||
icon={<TrendingUp className="h-4 w-4" />}
|
||||
trendLabel={`共 ${safeStats?.total_projects ?? 0} 个项目`}
|
||||
size="default"
|
||||
/>
|
||||
<MetricCard
|
||||
label="内容产出统计"
|
||||
value={stats?.contents_produced ?? 0}
|
||||
value={safeStats?.contents_produced ?? 0}
|
||||
trend="up"
|
||||
trendLabel="全部内容"
|
||||
icon={<FileText className="h-4 w-4" />}
|
||||
size="default"
|
||||
/>
|
||||
<MetricCard
|
||||
label="AI引用率"
|
||||
value={citationRate}
|
||||
trend={stats?.avg_ai_citation_rate ? "up" : "neutral"}
|
||||
trend={safeStats?.avg_ai_citation_rate ? "up" : "neutral"}
|
||||
trendLabel="平均引用率"
|
||||
icon={<Quote className="h-4 w-4" />}
|
||||
size="default"
|
||||
/>
|
||||
<MetricCard
|
||||
label="已完成项目"
|
||||
value={stats?.completed_projects ?? 0}
|
||||
value={safeStats?.completed_projects ?? 0}
|
||||
trend="neutral"
|
||||
trendLabel={`活跃 ${stats?.active_projects ?? 0} 个`}
|
||||
icon={<AlertCircle className="h-4 w-4" />}
|
||||
trendLabel={`活跃 ${safeStats?.active_projects ?? 0} 个`}
|
||||
size="default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 4. Smart Recommendations + 5. Agent Activity */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Smart Recommendation */}
|
||||
<Card className="rounded-xl shadow-geo-card">
|
||||
<CardHeader className="pb-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-geo-text-muted">
|
||||
推荐下一步
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-start gap-4 rounded-lg border border-geo-border bg-geo-bg p-4 transition-colors hover:bg-white">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
{recommendation.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-geo-text-primary">
|
||||
{recommendation.title}
|
||||
</p>
|
||||
<p className="text-xs text-geo-text-secondary mt-0.5">
|
||||
{recommendation.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => router.push(recommendation.href)}
|
||||
>
|
||||
执行
|
||||
<ArrowRight className="ml-1 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* 3. Stage Progress */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm font-medium text-gray-500">生命周期进度</p>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
当前阶段:
|
||||
{STAGE_CONFIG.find((s) => s.id === project.current_stage)?.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<StageProgress
|
||||
stages={stages}
|
||||
onStageClick={(stage) => {
|
||||
router.push(`/dashboard/${stage.id}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start"
|
||||
onClick={() => router.push("/dashboard/agents")}
|
||||
>
|
||||
<Zap className="mr-2 h-3.5 w-3.5" />
|
||||
管理Agent
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start"
|
||||
onClick={() => router.push("/dashboard/lifecycle")}
|
||||
>
|
||||
<BarChart3 className="mr-2 h-3.5 w-3.5" />
|
||||
项目详情
|
||||
</Button>
|
||||
{/* 4. Two-column: Recommendation + Agent Activity */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* Recommendation */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<p className="text-sm font-medium text-gray-500 mb-4">推荐下一步</p>
|
||||
<div className="flex items-center gap-4 rounded-lg border border-gray-100 bg-gray-50 p-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
{recommendation.icon}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Agent Activity Summary */}
|
||||
<Card className="rounded-xl shadow-geo-card">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-geo-text-muted">
|
||||
Agent活动
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{recommendation.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{recommendation.description}
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard/agents"
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
查看全部
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-0">
|
||||
<Button
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => router.push(recommendation.href)}
|
||||
>
|
||||
执行
|
||||
<ArrowRight className="ml-1 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start"
|
||||
onClick={() => router.push("/dashboard/agents")}
|
||||
>
|
||||
<Zap className="mr-2 h-3.5 w-3.5" />
|
||||
管理Agent
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start"
|
||||
onClick={() => router.push("/dashboard/lifecycle")}
|
||||
>
|
||||
<BarChart3 className="mr-2 h-3.5 w-3.5" />
|
||||
项目详情
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Activity */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm font-medium text-gray-500">Agent活动</p>
|
||||
<Link
|
||||
href="/dashboard/agents"
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
查看全部
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{MOCK_AGENTS.map((agent) => (
|
||||
<AgentStatusCard
|
||||
key={agent.name}
|
||||
|
|
@ -394,8 +342,8 @@ export default function DashboardPage() {
|
|||
completedCount={agent.completedCount}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -31,8 +30,11 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/lib/api";
|
||||
import { fetchWithAuth } from "@/lib/api/client";
|
||||
import type { QueryListResponse, ApiQueryItem } from "@/lib/api/queries";
|
||||
import { PLATFORM_MAP, PLATFORMS } from "@/lib/platforms";
|
||||
import { useApi } from "@/lib/hooks/use-api";
|
||||
import { LoadingState, ErrorState, EmptyState } from "@/components/ui/api-states";
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
|
|
@ -40,22 +42,9 @@ import {
|
|||
Play,
|
||||
Loader2,
|
||||
Search,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
interface QueryItem {
|
||||
id: string;
|
||||
keyword: string;
|
||||
target_brand: string;
|
||||
brand_aliases?: string[];
|
||||
platforms: string[];
|
||||
frequency: string;
|
||||
status: string;
|
||||
last_queried_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface QueryFormData {
|
||||
keyword: string;
|
||||
target_brand: string;
|
||||
|
|
@ -78,10 +67,11 @@ const emptyForm: QueryFormData = {
|
|||
};
|
||||
|
||||
export default function QueriesPage() {
|
||||
const { data: session } = useSession();
|
||||
const [queries, setQueries] = useState<QueryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: queriesResponse, isLoading: loading, error: apiError, refresh: refreshQueries } =
|
||||
useApi<QueryListResponse>("/api/v1/queries/");
|
||||
|
||||
const queries: ApiQueryItem[] = (queriesResponse?.items ?? []);
|
||||
const error = apiError?.message ?? null;
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
|
@ -95,41 +85,25 @@ export default function QueriesPage() {
|
|||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
|
||||
function showSuccess(msg: string) {
|
||||
setSuccessMsg(msg);
|
||||
setTimeout(() => setSuccessMsg(null), 3000);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.accessToken) return;
|
||||
loadQueries();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session?.accessToken]);
|
||||
|
||||
async function loadQueries() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.queries.list(session!.accessToken);
|
||||
setQueries(data.items || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "加载查询词失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
setEditingId(null);
|
||||
setFormData(emptyForm);
|
||||
setFormErrors({});
|
||||
setMutationError(null);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function openEditDialog(item: QueryItem) {
|
||||
function openEditDialog(item: ApiQueryItem) {
|
||||
setEditingId(item.id);
|
||||
setFormErrors({});
|
||||
setMutationError(null);
|
||||
setFormData({
|
||||
keyword: item.keyword,
|
||||
target_brand: item.target_brand,
|
||||
|
|
@ -156,13 +130,11 @@ export default function QueriesPage() {
|
|||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!session?.accessToken) return;
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setMutationError(null);
|
||||
const payload = {
|
||||
keyword: formData.keyword.trim(),
|
||||
target_brand: formData.target_brand.trim(),
|
||||
|
|
@ -175,16 +147,22 @@ export default function QueriesPage() {
|
|||
};
|
||||
|
||||
if (editingId) {
|
||||
await api.queries.update(session.accessToken, editingId, payload);
|
||||
await fetchWithAuth(`/api/v1/queries/${editingId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} else {
|
||||
await api.queries.create(session.accessToken, payload);
|
||||
await fetchWithAuth("/api/v1/queries/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
setDialogOpen(false);
|
||||
setSaving(false);
|
||||
showSuccess(editingId ? "修改成功" : "添加成功");
|
||||
await loadQueries();
|
||||
refreshQueries();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "保存失败");
|
||||
setMutationError(err instanceof Error ? err.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
|
@ -195,31 +173,30 @@ export default function QueriesPage() {
|
|||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!session?.accessToken || !deletingId) return;
|
||||
if (!deletingId) return;
|
||||
try {
|
||||
setDeleting(true);
|
||||
await api.queries.delete(session.accessToken, deletingId);
|
||||
await fetchWithAuth(`/api/v1/queries/${deletingId}`, { method: "DELETE" });
|
||||
setDeleteDialogOpen(false);
|
||||
setDeletingId(null);
|
||||
showSuccess("删除成功");
|
||||
await loadQueries();
|
||||
refreshQueries();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "删除失败");
|
||||
setMutationError(err instanceof Error ? err.message : "删除失败");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRunQuery(id: string) {
|
||||
if (!session?.accessToken) return;
|
||||
setActionLoading(id);
|
||||
setError(null);
|
||||
setMutationError(null);
|
||||
try {
|
||||
await api.queries.runNow(session.accessToken, id);
|
||||
await fetchWithAuth(`/api/v1/queries/${id}/run-now`, { method: "POST" });
|
||||
showSuccess("查询已执行");
|
||||
await loadQueries();
|
||||
refreshQueries();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "立即查询失败");
|
||||
setMutationError(err instanceof Error ? err.message : "立即查询失败");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
|
|
@ -243,9 +220,21 @@ export default function QueriesPage() {
|
|||
<p className="text-muted-foreground">管理您的关键词查询任务</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-[60vh] items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<LoadingState rows={5} rowHeight="h-14" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">查询词管理</h2>
|
||||
<p className="text-muted-foreground">管理您的关键词查询任务</p>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorState error={error} onRetry={refreshQueries} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -368,6 +357,9 @@ export default function QueriesPage() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{mutationError && (
|
||||
<p className="text-xs text-destructive">{mutationError}</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
|
|
@ -389,25 +381,24 @@ export default function QueriesPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{error}
|
||||
{mutationError && !dialogOpen && (
|
||||
<div className="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{mutationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">查询词列表</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{queries.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Search className="mb-4 h-12 w-12 opacity-20" />
|
||||
<p>暂无查询词</p>
|
||||
<p className="text-sm">点击上方按钮添加您的第一个查询词</p>
|
||||
</div>
|
||||
) : (
|
||||
{queries.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Search className="h-6 w-6 text-gray-400" />}
|
||||
message="暂无查询词"
|
||||
description="点击右上角按钮添加您的第一个查询词"
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">查询词列表</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
|
@ -422,7 +413,7 @@ export default function QueriesPage() {
|
|||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{queries.map((item) => (
|
||||
{queries.map((item: ApiQueryItem) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium">
|
||||
{item.keyword}
|
||||
|
|
@ -493,9 +484,9 @@ export default function QueriesPage() {
|
|||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -21,7 +21,11 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { api } from "@/lib/api";
|
||||
import { reportsApi } from "@/lib/api/reports";
|
||||
import type { QueryListResponse } from "@/lib/api/queries";
|
||||
import type { CitationListResponse, CitationStats } from "@/lib/api/citations";
|
||||
import { useApi } from "@/lib/hooks/use-api";
|
||||
import { clsx } from "clsx";
|
||||
import {
|
||||
Loader2,
|
||||
FileDown,
|
||||
|
|
@ -35,77 +39,22 @@ import {
|
|||
BarChart3,
|
||||
} from "lucide-react";
|
||||
|
||||
interface QueryOption {
|
||||
id: string;
|
||||
keyword: string;
|
||||
}
|
||||
|
||||
interface CitationStats {
|
||||
total_queries: number;
|
||||
total_citations: number;
|
||||
citation_rate: number;
|
||||
avg_position: number | null;
|
||||
}
|
||||
|
||||
interface CitationRecord {
|
||||
id: string;
|
||||
query_id: string;
|
||||
platform: string;
|
||||
cited: boolean;
|
||||
citation_position: number | null;
|
||||
citation_text: string | null;
|
||||
competitor_brands: string[];
|
||||
confidence: number | null;
|
||||
match_type: string | null;
|
||||
queried_at: string;
|
||||
}
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
|
||||
export default function ReportsPage() {
|
||||
const { data: session } = useSession();
|
||||
const [queries, setQueries] = useState<QueryOption[]>([]);
|
||||
const [selectedQuery, setSelectedQuery] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const [stats, setStats] = useState<CitationStats | null>(null);
|
||||
const [recentCitations, setRecentCitations] = useState<CitationRecord[]>([]);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const { data: queriesData } = useApi<QueryListResponse>("/api/v1/queries/");
|
||||
const { data: statsData, isLoading: statsLoading } = useApi<CitationStats>("/api/v1/citations/stats/");
|
||||
const { data: citationsData, isLoading: citationsLoading } =
|
||||
useApi<CitationListResponse>("/api/v1/citations/?limit=10");
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.accessToken) return;
|
||||
loadQueries();
|
||||
loadPreview();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session?.accessToken]);
|
||||
|
||||
async function loadQueries() {
|
||||
try {
|
||||
const data = await api.queries.list(session!.accessToken);
|
||||
setQueries(data.items || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "加载查询词列表失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
if (!session?.accessToken) return;
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const statsData = await api.citations.getStats(session.accessToken);
|
||||
setStats(statsData);
|
||||
const listData = await api.citations.list(session.accessToken, "limit=10");
|
||||
setRecentCitations(listData.items || []);
|
||||
} catch (err) {
|
||||
// 预览数据加载失败不影响主功能
|
||||
console.error("预览数据加载失败", err);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}
|
||||
const queries = queriesData?.items ?? [];
|
||||
const recentCitations = citationsData?.items ?? [];
|
||||
|
||||
async function handleExportCSV() {
|
||||
if (!session?.accessToken) return;
|
||||
|
|
@ -149,7 +98,7 @@ export default function ReportsPage() {
|
|||
blob = await res.blob();
|
||||
filename = `report_${queryId}_${new Date().toISOString().split("T")[0]}.csv`;
|
||||
} else {
|
||||
blob = await api.reports.exportPDF(session.accessToken, queryId);
|
||||
blob = await reportsApi.exportPDF(session.accessToken, queryId);
|
||||
filename = `report_${queryId}_${new Date().toISOString().split("T")[0]}.pdf`;
|
||||
}
|
||||
|
||||
|
|
@ -173,28 +122,28 @@ export default function ReportsPage() {
|
|||
const statCards = [
|
||||
{
|
||||
title: "总查询",
|
||||
value: stats?.total_queries ?? 0,
|
||||
value: statsData?.total_queries ?? 0,
|
||||
icon: Search,
|
||||
color: "text-blue-600",
|
||||
bg: "bg-blue-50",
|
||||
},
|
||||
{
|
||||
title: "引用次数",
|
||||
value: stats?.total_citations ?? 0,
|
||||
value: statsData?.total_citations ?? 0,
|
||||
icon: Quote,
|
||||
color: "text-emerald-600",
|
||||
bg: "bg-emerald-50",
|
||||
},
|
||||
{
|
||||
title: "引用率",
|
||||
value: stats ? `${stats.citation_rate.toFixed(1)}%` : "0%",
|
||||
value: statsData ? `${statsData.citation_rate.toFixed(1)}%` : "0%",
|
||||
icon: Percent,
|
||||
color: "text-violet-600",
|
||||
bg: "bg-violet-50",
|
||||
},
|
||||
{
|
||||
title: "平均位置",
|
||||
value: stats?.avg_position ? stats.avg_position.toFixed(1) : "-",
|
||||
value: statsData?.avg_position ? statsData.avg_position.toFixed(1) : "-",
|
||||
icon: BarChart3,
|
||||
color: "text-amber-600",
|
||||
bg: "bg-amber-50",
|
||||
|
|
@ -316,13 +265,13 @@ export default function ReportsPage() {
|
|||
{statCards.map((card) => (
|
||||
<Card key={card.title}>
|
||||
<CardContent className="flex items-center gap-4 p-6">
|
||||
<div className={cn("flex h-12 w-12 items-center justify-center rounded-lg", card.bg)}>
|
||||
<card.icon className={cn("h-6 w-6", card.color)} />
|
||||
<div className={clsx("flex h-12 w-12 items-center justify-center rounded-lg", card.bg)}>
|
||||
<card.icon className={clsx("h-6 w-6", card.color)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{card.title}</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{previewLoading ? <Loader2 className="h-5 w-5 animate-spin" /> : card.value}
|
||||
{statsLoading ? <Loader2 className="h-5 w-5 animate-spin" /> : card.value}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -351,7 +300,7 @@ export default function ReportsPage() {
|
|||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{previewLoading ? (
|
||||
{citationsLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
|
||||
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
||||
|
|
@ -395,7 +344,3 @@ export default function ReportsPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function cn(...classes: (string | undefined | false)[]) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,20 +2,17 @@
|
|||
|
||||
import * as React from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useSession, getSession } from "next-auth/react";
|
||||
import { SideNav, NavGroup, NavItem } from "@/components/layout/side-nav";
|
||||
import { Header } from "@/components/layout/header";
|
||||
import { NotificationContainer } from "@/components/ui/notification-container";
|
||||
import { useUserStore } from "@/lib/stores/user-store";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FolderKanban,
|
||||
Search,
|
||||
Target,
|
||||
Sparkles,
|
||||
Share2,
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
Zap,
|
||||
Bot,
|
||||
Users,
|
||||
BarChart3,
|
||||
Share2,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
|
||||
|
|
@ -23,104 +20,50 @@ import {
|
|||
|
||||
const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
id: "overview",
|
||||
title: "概览",
|
||||
id: "menu",
|
||||
title: "菜单",
|
||||
items: [
|
||||
{
|
||||
id: "dashboard",
|
||||
label: "Dashboard",
|
||||
label: "仪表盘",
|
||||
href: "/dashboard",
|
||||
icon: <LayoutDashboard className="h-4 w-4" />,
|
||||
icon: <LayoutDashboard className="h-5 w-5" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "main",
|
||||
title: "核心功能",
|
||||
items: [
|
||||
{
|
||||
id: "content",
|
||||
label: "内容工坊",
|
||||
label: "内容管理",
|
||||
href: "/dashboard/content",
|
||||
icon: <Sparkles className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
id: "distribution",
|
||||
label: "分发中心",
|
||||
href: "/dashboard/distribution",
|
||||
icon: <Share2 className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
id: "analytics",
|
||||
label: "数据监测",
|
||||
href: "/dashboard/analytics",
|
||||
icon: <BarChart3 className="h-4 w-4" />,
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
id: "knowledge",
|
||||
label: "知识库",
|
||||
href: "/dashboard/knowledge",
|
||||
icon: <BookOpen className="h-4 w-4" />,
|
||||
icon: <BookOpen className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
id: "analytics",
|
||||
label: "数据监测",
|
||||
href: "/dashboard/analytics",
|
||||
icon: <BarChart3 className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
id: "distribution",
|
||||
label: "内容分发",
|
||||
href: "/dashboard/distribution",
|
||||
icon: <Share2 className="h-5 w-5" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lifecycle",
|
||||
title: "生命周期",
|
||||
id: "settings",
|
||||
title: "设置",
|
||||
items: [
|
||||
{
|
||||
id: "lifecycle-new",
|
||||
label: "快速开始",
|
||||
href: "/dashboard/lifecycle/new",
|
||||
icon: <Zap className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
id: "lifecycle",
|
||||
label: "项目管理",
|
||||
href: "/dashboard/lifecycle",
|
||||
icon: <FolderKanban className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
id: "diagnosis",
|
||||
label: "诊断分析",
|
||||
href: "/dashboard/diagnosis",
|
||||
icon: <Search className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
id: "strategy",
|
||||
label: "策略制定",
|
||||
href: "/dashboard/strategy",
|
||||
icon: <Target className="h-4 w-4" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
title: "工具",
|
||||
items: [
|
||||
{
|
||||
id: "agents",
|
||||
label: "AI Agents",
|
||||
href: "/dashboard/agents",
|
||||
icon: <Bot className="h-4 w-4" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "manage",
|
||||
title: "管理",
|
||||
items: [
|
||||
{
|
||||
id: "clients",
|
||||
label: "客户管理",
|
||||
href: "/dashboard/clients",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "设置",
|
||||
href: "/dashboard/settings",
|
||||
icon: <Settings className="h-4 w-4" />,
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -136,30 +79,43 @@ export default function DashboardLayout({
|
|||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { data: session, status } = useSession();
|
||||
const { status, update } = useSession();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [verifying, setVerifying] = React.useState(false);
|
||||
|
||||
// Redirect to login if unauthenticated
|
||||
// 同步 session 到 user store
|
||||
const syncFromSession = useUserStore((s) => s.syncFromSession);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status === "authenticated") {
|
||||
getSession().then((session) => {
|
||||
syncFromSession(session);
|
||||
});
|
||||
} else if (status === "unauthenticated") {
|
||||
syncFromSession(null);
|
||||
}
|
||||
}, [status, syncFromSession]);
|
||||
|
||||
// 当 useSession 返回 unauthenticated 时,用 getSession 双重确认,
|
||||
// 避免 SessionProvider 缓存未更新导致的误判重定向
|
||||
React.useEffect(() => {
|
||||
if (status === "unauthenticated") {
|
||||
router.replace("/login");
|
||||
setVerifying(true);
|
||||
getSession().then((session) => {
|
||||
setVerifying(false);
|
||||
if (session) {
|
||||
// SessionProvider 缓存未更新,强制刷新
|
||||
update();
|
||||
} else {
|
||||
router.replace("/login");
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [status, router]);
|
||||
|
||||
// Responsive: collapse on mobile by default
|
||||
const [collapsed, setCollapsed] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
const mq = window.matchMedia("(max-width: 768px)");
|
||||
setCollapsed(mq.matches);
|
||||
const handler = (e: MediaQueryListEvent) => setCollapsed(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
}, [status, router, update]);
|
||||
|
||||
// Compute activeId: match longest href prefix
|
||||
const activeId = React.useMemo(() => {
|
||||
// Sort by href length descending for longest-prefix match
|
||||
const sorted = [...ALL_NAV_ITEMS]
|
||||
.filter((item) => item.href)
|
||||
.sort((a, b) => (b.href?.length ?? 0) - (a.href?.length ?? 0));
|
||||
|
|
@ -176,19 +132,10 @@ export default function DashboardLayout({
|
|||
[router]
|
||||
);
|
||||
|
||||
// Build SideNavUser from session
|
||||
const sideNavUser = session?.user
|
||||
? {
|
||||
name: session.user.name ?? session.user.email ?? "用户",
|
||||
email: session.user.email ?? undefined,
|
||||
avatar: session.user.image ?? undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (status === "loading") {
|
||||
if (status === "loading" || verifying) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-geo-bg">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white/80" />
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-200 border-t-emerald-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -198,30 +145,30 @@ export default function DashboardLayout({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-geo-bg">
|
||||
{/* Fixed SideNav */}
|
||||
<aside
|
||||
className="fixed inset-y-0 left-0 z-40 flex flex-col"
|
||||
style={{ width: collapsed ? 64 : 240 }}
|
||||
>
|
||||
<div className="flex min-h-screen bg-gray-50">
|
||||
{/* Fixed SideNav - left side */}
|
||||
<aside className="fixed inset-y-0 left-0 z-40 flex flex-col w-60">
|
||||
<SideNav
|
||||
groups={NAV_GROUPS}
|
||||
activeId={activeId}
|
||||
collapsed={collapsed}
|
||||
onCollapsedChange={setCollapsed}
|
||||
onNavClick={handleNavClick}
|
||||
user={sideNavUser}
|
||||
className="h-full"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Main content with left margin matching SideNav width */}
|
||||
<main
|
||||
className="flex-1 min-h-screen transition-all duration-300"
|
||||
style={{ marginLeft: collapsed ? 64 : 240 }}
|
||||
>
|
||||
<div className="p-6">{children}</div>
|
||||
</main>
|
||||
{/* Main content area - offset by sidebar width */}
|
||||
<div className="flex flex-1 flex-col ml-60 min-h-screen">
|
||||
{/* Fixed Header at top */}
|
||||
<Header />
|
||||
|
||||
{/* Global Toast/Notification */}
|
||||
<NotificationContainer />
|
||||
|
||||
{/* Scrollable content area */}
|
||||
<main className="flex-1 bg-gray-50 p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,36 +43,30 @@ export function Step2Competitors({
|
|||
useState<string[]>(initialCompetitors);
|
||||
const [customCompetitor, setCustomCompetitor] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchRecommendations = async () => {
|
||||
if (!session?.accessToken) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = (await api.onboarding.getCompetitorRecommendations(
|
||||
session.accessToken,
|
||||
brandName,
|
||||
)) as CompetitorRecommendation[];
|
||||
setRecommendations(data || []);
|
||||
} catch (err) {
|
||||
console.error("获取竞品推荐失败:", err);
|
||||
setError("获取竞品推荐失败,请重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRecommendations = async () => {
|
||||
if (!session?.accessToken) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = (await api.onboarding.getCompetitorRecommendations(
|
||||
session.accessToken,
|
||||
brandName,
|
||||
)) as CompetitorRecommendation[];
|
||||
setRecommendations(data || []);
|
||||
} catch (err) {
|
||||
console.error("获取竞品推荐失败:", err);
|
||||
// 如果API不可用,使用模拟数据
|
||||
setRecommendations([
|
||||
{
|
||||
id: "1",
|
||||
name: `${brandName}主要竞争对手`,
|
||||
reason: "基于行业分析推荐",
|
||||
},
|
||||
{ id: "2", name: `${brandName}行业领先者`, reason: "市场份额领先" },
|
||||
{ id: "3", name: `${brandName}新兴竞争者`, reason: "增长势头强劲" },
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRecommendations();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session?.accessToken, brandName]);
|
||||
|
||||
const toggleCompetitor = (competitorName: string) => {
|
||||
|
|
@ -125,6 +119,14 @@ export function Step2Competitors({
|
|||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">加载中...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground mb-4">{error}</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button variant="outline" onClick={fetchRecommendations}>重新加载</Button>
|
||||
<Button variant="ghost" onClick={onSkip}>跳过此步骤</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : recommendations.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{recommendations.map((comp) => {
|
||||
|
|
|
|||
|
|
@ -46,52 +46,31 @@ export function Step4HealthReport({
|
|||
const { data: session } = useSession();
|
||||
const [report, setReport] = useState<BrandHealthReport | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchReport = async () => {
|
||||
if (!session?.accessToken) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = (await api.onboarding.getHealthReport(
|
||||
session.accessToken,
|
||||
brandId,
|
||||
)) as BrandHealthReport;
|
||||
setReport(data);
|
||||
} catch (err) {
|
||||
console.error("获取健康报告失败:", err);
|
||||
setError("获取健康报告失败,请重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchReport = async () => {
|
||||
if (!session?.accessToken) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = (await api.onboarding.getHealthReport(
|
||||
session.accessToken,
|
||||
brandId,
|
||||
)) as BrandHealthReport;
|
||||
setReport(data);
|
||||
} catch (err) {
|
||||
console.error("获取健康报告失败:", err);
|
||||
// 使用模拟数据
|
||||
setReport({
|
||||
brand_id: brandId,
|
||||
brand_name: brandName,
|
||||
overall_score: 68,
|
||||
platform_scores: {
|
||||
wenxin: 72,
|
||||
kimi: 65,
|
||||
tongyi: 70,
|
||||
baidu_ai: 68,
|
||||
yuanbao: 60,
|
||||
qingyan: 75,
|
||||
doubao: 62,
|
||||
},
|
||||
competitor_scores: [
|
||||
{ name: "竞品A", score: 75, is_leading: false },
|
||||
{ name: "竞品B", score: 58, is_leading: true },
|
||||
{ name: "竞品C", score: 82, is_leading: false },
|
||||
],
|
||||
strengths: ["在Kimi平台表现优秀", "品牌提及率稳定"],
|
||||
weaknesses: ["在腾讯元宝平台覆盖率低", "内容质量有待提升"],
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 模拟加载时间
|
||||
const timer = setTimeout(fetchReport, 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [session?.accessToken, brandId, brandName]);
|
||||
fetchReport();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session?.accessToken, brandId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -120,7 +99,7 @@ export function Step4HealthReport({
|
|||
);
|
||||
}
|
||||
|
||||
if (error || !report) {
|
||||
if (error || (!loading && !report)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<div className="mb-6 text-center">
|
||||
|
|
@ -138,12 +117,24 @@ export function Step4HealthReport({
|
|||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
上一步
|
||||
</Button>
|
||||
<Button onClick={() => window.location.reload()}>重新尝试</Button>
|
||||
<Button variant="outline" onClick={fetchReport}>重试</Button>
|
||||
<Button variant="ghost" onClick={() => onNext({
|
||||
brand_id: brandId,
|
||||
brand_name: brandName,
|
||||
overall_score: 0,
|
||||
platform_scores: {},
|
||||
competitor_scores: [],
|
||||
strengths: [],
|
||||
weaknesses: [],
|
||||
})}>跳过此步骤</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TypeScript 类型守卫:经过 loading 和 error 检查后,report 必定存在
|
||||
if (!report) return null;
|
||||
|
||||
const healthLevel = getHealthLevel(report.overall_score);
|
||||
const healthConfig = HEALTH_LEVELS[healthLevel];
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
LayoutDashboard,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { ActionSuggestion } from "@/types/onboarding";
|
||||
|
|
@ -62,61 +63,30 @@ export function Step5ActionSuggestions({
|
|||
const [suggestions, setSuggestions] = useState<ActionSuggestion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [completing, setCompleting] = useState(false);
|
||||
const [error] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchSuggestions = async () => {
|
||||
if (!session?.accessToken) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = (await api.onboarding.getActionSuggestions(
|
||||
session.accessToken,
|
||||
brandId,
|
||||
)) as ActionSuggestion[];
|
||||
setSuggestions(data || []);
|
||||
} catch (err) {
|
||||
console.error("获取行动建议失败:", err);
|
||||
setError("获取行动建议失败,请重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSuggestions = async () => {
|
||||
if (!session?.accessToken) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = (await api.onboarding.getActionSuggestions(
|
||||
session.accessToken,
|
||||
brandId,
|
||||
)) as ActionSuggestion[];
|
||||
setSuggestions(data || []);
|
||||
} catch (err) {
|
||||
console.error("获取行动建议失败:", err);
|
||||
// 使用模拟数据
|
||||
setSuggestions([
|
||||
{
|
||||
id: "1",
|
||||
title: "优化腾讯元宝平台内容",
|
||||
description:
|
||||
"在腾讯元宝平台的搜索结果中曝光率较低,建议增加相关内容布局",
|
||||
priority: "high",
|
||||
action_type: "improve_platform",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "提升整体内容质量",
|
||||
description: "内容被引用率偏低,建议优化内容的专业性和权威性",
|
||||
priority: "high",
|
||||
action_type: "optimize_content",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "添加新的竞品对手",
|
||||
description: "建议添加3个新的竞品以获得更全面的对比分析",
|
||||
priority: "medium",
|
||||
action_type: "add_competitor",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "提高查询频率",
|
||||
description: "当前每周查询可能错过重要信息,建议调整为每日查询",
|
||||
priority: "low",
|
||||
action_type: "increase_frequency",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 模拟加载时间
|
||||
const timer = setTimeout(fetchSuggestions, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
fetchSuggestions();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session?.accessToken, brandId]);
|
||||
|
||||
const handleComplete = async () => {
|
||||
|
|
@ -166,6 +136,25 @@ export function Step5ActionSuggestions({
|
|||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (!loading && error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<div className="mb-6 text-center">
|
||||
<div className="mb-4 inline-flex h-16 w-16 items-center justify-center rounded-full bg-red-50">
|
||||
<AlertTriangle className="h-8 w-8 text-red-600" />
|
||||
</div>
|
||||
<h2 className="mb-2 text-2xl font-bold">获取建议失败</h2>
|
||||
<p className="text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={fetchSuggestions}>重试</Button>
|
||||
<Button variant="ghost" onClick={onSkip}>稍后查看</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 按优先级分组
|
||||
const highPriority = suggestions.filter((s) => s.priority === "high");
|
||||
const mediumPriority = suggestions.filter((s) => s.priority === "medium");
|
||||
|
|
|
|||
|
|
@ -28,11 +28,14 @@
|
|||
--radius: 1rem; /* 16px base radius */
|
||||
|
||||
/* GEO Custom tokens */
|
||||
--geo-nav-bg: #1a1f2e;
|
||||
--geo-nav-hover: #252b3b;
|
||||
--geo-nav-bg: #FFFFFF;
|
||||
--geo-nav-hover: #F3F4F6;
|
||||
--geo-nav-active: #10B981;
|
||||
--geo-shadow-card: 0 2px 8px 0 rgba(0,0,0,0.06);
|
||||
--geo-shadow-card-hover: 0 8px 24px 0 rgba(0,0,0,0.10);
|
||||
--geo-nav-text: #374151;
|
||||
--geo-nav-text-active: #059669;
|
||||
--geo-nav-active-bg: #ECFDF5;
|
||||
--geo-shadow-card: 0 1px 3px rgba(0,0,0,0.04);
|
||||
--geo-shadow-card-hover: 0 4px 12px rgba(0,0,0,0.06);
|
||||
--geo-transition: 200ms ease;
|
||||
}
|
||||
|
||||
|
|
@ -84,7 +87,7 @@
|
|||
@apply bg-white rounded-lg border border-geo-border shadow-card transition-all duration-200;
|
||||
}
|
||||
.geo-card:hover {
|
||||
@apply -translate-y-0.5 shadow-card-hover;
|
||||
@apply shadow-card-hover border-primary/30;
|
||||
}
|
||||
|
||||
/* GEO Scrollbar */
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||
|
||||
const geistSans = localFont({
|
||||
src: "./fonts/GeistVF.woff",
|
||||
|
|
@ -29,7 +30,9 @@ export default function RootLayout({
|
|||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<Providers>{children}</Providers>
|
||||
<Providers>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 全局错误边界组件
|
||||
*
|
||||
* - 捕获子树中的 React 渲染错误,防止整个页面白屏
|
||||
* - 在开发环境输出详细错误栈到 console
|
||||
* - 预留 Sentry 集成点(搜索 TODO:SENTRY)
|
||||
* - 提供可重置的友好错误 UI
|
||||
*/
|
||||
|
||||
import React, { Component, ErrorInfo, ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
/** 自定义 fallback UI;不传则使用内置样式 */
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
this.setState({ errorInfo });
|
||||
|
||||
// 开发环境:完整错误栈
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.group("[ErrorBoundary] 捕获到未处理的渲染错误");
|
||||
console.error("Error:", error);
|
||||
console.error("Component Stack:", errorInfo.componentStack);
|
||||
console.groupEnd();
|
||||
} else {
|
||||
console.error("[ErrorBoundary]", error.message);
|
||||
}
|
||||
|
||||
// TODO:SENTRY — 生产环境错误上报
|
||||
// import * as Sentry from "@sentry/nextjs";
|
||||
// Sentry.captureException(error, { extra: { componentStack: errorInfo.componentStack } });
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({ hasError: false, error: null, errorInfo: null });
|
||||
};
|
||||
|
||||
render(): ReactNode {
|
||||
if (!this.state.hasError) {
|
||||
return this.props.children;
|
||||
}
|
||||
|
||||
// 使用自定义 fallback
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// 内置友好错误页面
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-lg p-8 text-center">
|
||||
{/* 图标 */}
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-50">
|
||||
<svg
|
||||
className="h-8 w-8 text-red-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
页面出现了错误
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
应用遇到了一个意外错误。您可以尝试刷新页面,或点击下方按钮重试。
|
||||
</p>
|
||||
|
||||
{/* 开发环境:展示错误摘要 */}
|
||||
{process.env.NODE_ENV !== "production" && this.state.error && (
|
||||
<details className="mb-6 text-left rounded-lg bg-red-50 p-4 text-xs text-red-700">
|
||||
<summary className="cursor-pointer font-medium select-none mb-1">
|
||||
错误详情(开发模式)
|
||||
</summary>
|
||||
<pre className="whitespace-pre-wrap break-all mt-2 opacity-80">
|
||||
{this.state.error.message}
|
||||
{this.state.errorInfo?.componentStack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2"
|
||||
>
|
||||
刷新页面
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export type TrendDirection = "up" | "down" | "neutral";
|
||||
|
||||
|
|
@ -31,59 +30,6 @@ export interface MetricCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||
size?: "sm" | "default" | "lg";
|
||||
}
|
||||
|
||||
const TrendArrow = ({ direction }: { direction: TrendDirection }) => {
|
||||
if (direction === "neutral") return null;
|
||||
const isUp = direction === "up";
|
||||
return (
|
||||
<svg
|
||||
className={cn("h-3.5 w-3.5", isUp ? "text-primary" : "text-destructive")}
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
>
|
||||
{isUp ? (
|
||||
<path d="M7 11V3M7 3L3.5 6.5M7 3L10.5 6.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
) : (
|
||||
<path d="M7 3V11M7 11L3.5 7.5M7 11L10.5 7.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const Sparkline = ({ data, trend }: { data: SparklinePoint[]; trend?: TrendDirection }) => {
|
||||
if (!data || data.length < 2) return null;
|
||||
const max = Math.max(...data.map((d) => d.value));
|
||||
const min = Math.min(...data.map((d) => d.value));
|
||||
const range = max - min || 1;
|
||||
const width = 80;
|
||||
const height = 28;
|
||||
const points = data.map((d, i) => {
|
||||
const x = (i / (data.length - 1)) * width;
|
||||
const y = height - ((d.value - min) / range) * height;
|
||||
return `${x},${y}`;
|
||||
});
|
||||
const isUp = trend !== "down";
|
||||
return (
|
||||
<svg width={width} height={height} className="overflow-visible">
|
||||
<polyline
|
||||
points={points.join(" ")}
|
||||
fill="none"
|
||||
stroke={isUp ? "currentColor" : "#ef4444"}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={isUp ? "text-primary" : "text-destructive"}
|
||||
/>
|
||||
{/* last dot */}
|
||||
<circle
|
||||
cx={parseFloat(points[points.length - 1].split(",")[0])}
|
||||
cy={parseFloat(points[points.length - 1].split(",")[1])}
|
||||
r="2.5"
|
||||
fill={isUp ? "#10B981" : "#ef4444"}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const MetricCard = React.forwardRef<HTMLDivElement, MetricCardProps>(
|
||||
(
|
||||
{
|
||||
|
|
@ -114,80 +60,64 @@ const MetricCard = React.forwardRef<HTMLDivElement, MetricCardProps>(
|
|||
}[size];
|
||||
|
||||
return (
|
||||
<Card
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("group relative overflow-hidden", className)}
|
||||
className={cn(
|
||||
"bg-white rounded-xl border border-gray-200",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("flex flex-col gap-2", paddingClass)}>
|
||||
{/* Label row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
{icon && (
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
{icon}
|
||||
</div>
|
||||
<div className={cn("flex items-stretch", paddingClass)}>
|
||||
{/* Left color bar */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-1 shrink-0 rounded-full mr-4",
|
||||
trend === "up" && "bg-emerald-500",
|
||||
trend === "down" && "bg-red-500",
|
||||
trend === "neutral" && "bg-gray-300"
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
|
||||
{/* Value + sparkline row */}
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"font-bold tracking-tight text-foreground leading-none",
|
||||
valueClass
|
||||
)}
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
{subValue && (
|
||||
<span className="text-xs text-muted-foreground">{subValue}</span>
|
||||
)}
|
||||
</div>
|
||||
{sparklineData && sparklineData.length >= 2 && (
|
||||
<div className="mb-0.5 opacity-80 group-hover:opacity-100 transition-opacity">
|
||||
<Sparkline data={sparklineData} trend={trend} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trend row */}
|
||||
{(trendValue || trendLabel) && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Label row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">
|
||||
{label}
|
||||
</p>
|
||||
{trendValue && (
|
||||
<div
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-0.5 text-xs font-semibold",
|
||||
trend === "up" && "text-primary",
|
||||
trend === "down" && "text-destructive",
|
||||
trend === "neutral" && "text-muted-foreground"
|
||||
"text-xs font-medium px-2 py-0.5 rounded-full",
|
||||
trend === "up" && "text-emerald-600 bg-emerald-50",
|
||||
trend === "down" && "text-red-600 bg-red-50",
|
||||
trend === "neutral" && "text-gray-500 bg-gray-50"
|
||||
)}
|
||||
>
|
||||
<TrendArrow direction={trend} />
|
||||
<span>{trendValue}</span>
|
||||
</div>
|
||||
)}
|
||||
{trendLabel && (
|
||||
<span className="text-xs text-muted-foreground">{trendLabel}</span>
|
||||
{trend === "up" && "+"}{trendValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* subtle background accent */}
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-0 right-0 h-16 w-16 rounded-tl-3xl opacity-5 transition-opacity duration-300 group-hover:opacity-10",
|
||||
trend === "up" && "bg-primary",
|
||||
trend === "down" && "bg-destructive",
|
||||
trend === "neutral" && "bg-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
{/* Value */}
|
||||
<p
|
||||
className={cn(
|
||||
"mt-2 font-bold text-gray-900 leading-none",
|
||||
valueClass
|
||||
)}
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
|
||||
{/* Trend label */}
|
||||
{trendLabel && (
|
||||
<p className="mt-1 text-xs text-gray-400">{trendLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,30 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import { useSession, signOut } from "next-auth/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LogOut, User } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Search, Bell } from "lucide-react";
|
||||
import { AlertBell } from "@/components/layout/alert-bell";
|
||||
|
||||
export function Header() {
|
||||
const { data: session } = useSession();
|
||||
const userName = session?.user?.name || session?.user?.email || "用户";
|
||||
const initials = userName.slice(0, 2).toUpperCase();
|
||||
|
||||
return (
|
||||
<header className="flex h-16 items-center justify-between border-b bg-white px-6 dark:bg-slate-950">
|
||||
<h1 className="text-lg font-semibold">GEO Platform</h1>
|
||||
<header className="flex h-16 items-center justify-between border-b border-gray-200 bg-white px-6">
|
||||
{/* Left: Search */}
|
||||
<div className="relative w-72">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索..."
|
||||
className="h-10 w-full rounded-full bg-gray-100 pl-10 pr-4 text-sm text-gray-700 placeholder-gray-400 outline-none transition-colors duration-150 focus:bg-gray-50 focus:ring-2 focus:ring-emerald-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Notification bell */}
|
||||
<AlertBell />
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<User className="h-4 w-4" />
|
||||
<span>{session?.user?.name || session?.user?.email || "用户"}</span>
|
||||
|
||||
{/* User avatar + name */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-emerald-100 text-sm font-semibold text-emerald-700">
|
||||
{initials}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">{userName}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
退出登录
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,50 +20,29 @@ export interface NavGroup {
|
|||
items: NavItem[];
|
||||
}
|
||||
|
||||
export interface SideNavUser {
|
||||
name: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
/** 完成度 0-100 */
|
||||
completionPercent?: number;
|
||||
completionLabel?: string;
|
||||
}
|
||||
|
||||
export interface SideNavProps extends React.HTMLAttributes<HTMLElement> {
|
||||
/** 品牌 Logo/名称区域 */
|
||||
logo?: React.ReactNode;
|
||||
/** 品牌名 */
|
||||
brandName?: string;
|
||||
/** 品牌图标 */
|
||||
brandIcon?: React.ReactNode;
|
||||
/** 导航分组 */
|
||||
groups: NavGroup[];
|
||||
/** 当前激活的导航项 id */
|
||||
activeId?: string;
|
||||
/** 导航项点击回调 */
|
||||
onNavClick?: (item: NavItem) => void;
|
||||
/** 底部用户信息 */
|
||||
user?: SideNavUser;
|
||||
/** 底部额外操作区 */
|
||||
footerExtra?: React.ReactNode;
|
||||
/** 是否折叠(仅展示图标) */
|
||||
collapsed?: boolean;
|
||||
/** 折叠切换回调 */
|
||||
onCollapsedChange?: (collapsed: boolean) => void;
|
||||
/** "更多工具"分组标签 */
|
||||
moreToolsLabel?: string;
|
||||
}
|
||||
|
||||
// ─── Sub-components ────────────────────────────────────────────────────────────
|
||||
// ─── NavItemRow ───────────────────────────────────────────────────────────────
|
||||
|
||||
const NavItemRow = React.memo(
|
||||
({
|
||||
item,
|
||||
active,
|
||||
collapsed,
|
||||
onClick,
|
||||
}: {
|
||||
item: NavItem;
|
||||
active: boolean;
|
||||
collapsed?: boolean;
|
||||
onClick?: () => void;
|
||||
}) => {
|
||||
return (
|
||||
|
|
@ -71,28 +50,21 @@ const NavItemRow = React.memo(
|
|||
type="button"
|
||||
disabled={item.disabled}
|
||||
onClick={onClick}
|
||||
title={collapsed ? item.label : undefined}
|
||||
className={cn(
|
||||
"group relative flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-geo-nav-active focus-visible:ring-offset-1 focus-visible:ring-offset-geo-nav",
|
||||
"group relative flex w-full items-center gap-3 rounded-lg py-2.5 px-4 text-sm font-medium transition-all duration-150",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1",
|
||||
"disabled:pointer-events-none disabled:opacity-40",
|
||||
active
|
||||
? "bg-geo-nav-active/15 text-geo-nav-active"
|
||||
: "text-white/70 hover:bg-white/8 hover:text-white",
|
||||
collapsed && "justify-center px-2"
|
||||
? "bg-emerald-50 text-emerald-600 font-semibold border-l-[3px] border-emerald-500"
|
||||
: "text-gray-600 hover:bg-gray-50 border-l-[3px] border-transparent"
|
||||
)}
|
||||
>
|
||||
{/* active indicator bar */}
|
||||
{active && (
|
||||
<span className="absolute left-0 top-1/2 h-4 w-0.5 -translate-y-1/2 rounded-full bg-geo-nav-active" />
|
||||
)}
|
||||
|
||||
{/* icon */}
|
||||
{item.icon && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center transition-colors duration-200",
|
||||
active ? "text-geo-nav-active" : "text-white/50 group-hover:text-white/80"
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center transition-colors duration-150",
|
||||
active ? "text-emerald-500" : "text-gray-400 group-hover:text-gray-600"
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
|
|
@ -100,18 +72,16 @@ const NavItemRow = React.memo(
|
|||
)}
|
||||
|
||||
{/* label */}
|
||||
{!collapsed && (
|
||||
<span className="flex-1 truncate text-left">{item.label}</span>
|
||||
)}
|
||||
<span className="flex-1 truncate text-left">{item.label}</span>
|
||||
|
||||
{/* badge */}
|
||||
{!collapsed && item.badge !== undefined && (
|
||||
{item.badge !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-[10px] font-bold",
|
||||
active
|
||||
? "bg-geo-nav-active text-white"
|
||||
: "bg-white/15 text-white/80"
|
||||
? "bg-emerald-500 text-white"
|
||||
: "bg-gray-200 text-gray-600"
|
||||
)}
|
||||
>
|
||||
{item.badge}
|
||||
|
|
@ -123,101 +93,17 @@ const NavItemRow = React.memo(
|
|||
);
|
||||
NavItemRow.displayName = "NavItemRow";
|
||||
|
||||
// ─── User info footer ──────────────────────────────────────────────────────────
|
||||
|
||||
const UserFooter = ({
|
||||
user,
|
||||
collapsed,
|
||||
}: {
|
||||
user: SideNavUser;
|
||||
collapsed?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 border-t border-white/10 pt-3",
|
||||
collapsed && "items-center"
|
||||
)}
|
||||
>
|
||||
{/* Completion progress bar */}
|
||||
{!collapsed && user.completionPercent !== undefined && (
|
||||
<div className="px-2">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs text-white/50">
|
||||
{user.completionLabel ?? "Profile Completion"}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-white/70">
|
||||
{user.completionPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-geo-nav-active transition-all duration-500"
|
||||
style={{ width: `${Math.min(100, Math.max(0, user.completionPercent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User row */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors duration-200 hover:bg-white/8 cursor-pointer",
|
||||
collapsed && "justify-center px-2"
|
||||
)}
|
||||
>
|
||||
{/* avatar */}
|
||||
<div className="relative shrink-0">
|
||||
{user.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
className="h-8 w-8 rounded-full object-cover ring-2 ring-white/20"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-geo-nav-active/30 text-xs font-bold text-geo-nav-active ring-2 ring-geo-nav-active/30">
|
||||
{user.name.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="absolute -bottom-px -right-px h-2.5 w-2.5 rounded-full border-2 border-geo-nav bg-primary" />
|
||||
</div>
|
||||
|
||||
{/* user info */}
|
||||
{!collapsed && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-white truncate">{user.name}</p>
|
||||
{user.email && (
|
||||
<p className="text-xs text-white/50 truncate">{user.email}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* chevron */}
|
||||
{!collapsed && (
|
||||
<svg className="h-3.5 w-3.5 shrink-0 text-white/30" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M5 4L8 7L5 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Main SideNav ──────────────────────────────────────────────────────────────
|
||||
// ─── Main SideNav ─────────────────────────────────────────────────────────────
|
||||
|
||||
const SideNav = React.forwardRef<HTMLElement, SideNavProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
logo,
|
||||
brandName = "GEO",
|
||||
brandName = "GEO Platform",
|
||||
brandIcon,
|
||||
groups,
|
||||
activeId,
|
||||
onNavClick,
|
||||
user,
|
||||
footerExtra,
|
||||
collapsed = false,
|
||||
onCollapsedChange,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
|
|
@ -226,75 +112,44 @@ const SideNav = React.forwardRef<HTMLElement, SideNavProps>(
|
|||
<nav
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-col h-full bg-geo-nav text-white transition-all duration-300 geo-scrollbar overflow-y-auto",
|
||||
collapsed ? "w-16" : "w-60",
|
||||
"flex flex-col h-full bg-white border-r border-gray-200 overflow-y-auto",
|
||||
"w-60",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* ── Brand header ── */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-4 shrink-0",
|
||||
collapsed && "justify-center px-3"
|
||||
)}
|
||||
>
|
||||
{logo && (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-geo-nav-active/20">
|
||||
{logo}
|
||||
<div className="flex items-center gap-3 px-5 py-5 border-b border-gray-200 pb-6 mb-4 shrink-0">
|
||||
{brandIcon && (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-emerald-500 text-white">
|
||||
{brandIcon}
|
||||
</div>
|
||||
)}
|
||||
{!collapsed && (
|
||||
<span className="text-base font-bold text-white tracking-tight truncate">
|
||||
{brandName}
|
||||
</span>
|
||||
)}
|
||||
{/* collapse toggle */}
|
||||
{!collapsed && onCollapsedChange && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCollapsedChange(true)}
|
||||
className="ml-auto flex h-6 w-6 items-center justify-center rounded-md text-white/40 hover:bg-white/10 hover:text-white transition-colors duration-200"
|
||||
title="折叠导航"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none">
|
||||
<path d="M10 4L6 8L10 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{collapsed && onCollapsedChange && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCollapsedChange(false)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-white/40 hover:bg-white/10 hover:text-white transition-colors duration-200"
|
||||
title="展开导航"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none">
|
||||
<path d="M6 4L10 8L6 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{!brandIcon && (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-emerald-500 text-sm font-bold text-white">
|
||||
G
|
||||
</div>
|
||||
)}
|
||||
<span className="text-base font-bold text-gray-900 tracking-tight truncate">
|
||||
{brandName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Nav groups ── */}
|
||||
<div className="flex-1 overflow-y-auto px-3 pb-3 geo-scrollbar space-y-5">
|
||||
{groups.map((group, gIdx) => (
|
||||
<div className="flex-1 overflow-y-auto px-3 pb-3 space-y-6">
|
||||
{groups.map((group) => (
|
||||
<div key={group.id}>
|
||||
{group.title && !collapsed && (
|
||||
<p className="mb-1.5 px-3 text-[10px] font-semibold uppercase tracking-widest text-white/30">
|
||||
{group.title && (
|
||||
<p className="mb-2 px-4 text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
{group.title}
|
||||
</p>
|
||||
)}
|
||||
{gIdx > 0 && !group.title && collapsed && (
|
||||
<div className="my-2 h-px bg-white/10" />
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
{group.items.map((item) => (
|
||||
<NavItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
active={item.id === activeId}
|
||||
collapsed={collapsed}
|
||||
onClick={() => onNavClick?.(item)}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -302,16 +157,6 @@ const SideNav = React.forwardRef<HTMLElement, SideNavProps>(
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="px-3 pb-4 shrink-0 space-y-2">
|
||||
{footerExtra && (
|
||||
<div className={cn("text-white/70", collapsed && "flex justify-center")}>
|
||||
{footerExtra}
|
||||
</div>
|
||||
)}
|
||||
{user && <UserFooter user={user} collapsed={collapsed} />}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 统一 API 状态组件
|
||||
* - LoadingState: 骨架屏加载状态
|
||||
* - ErrorState: 错误展示 + 重试按钮
|
||||
* - EmptyState: 空数据状态
|
||||
*/
|
||||
|
||||
import { AlertCircle, RefreshCw, Inbox } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
// ── LoadingState ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface LoadingStateProps {
|
||||
/** 骨架行数(默认 3) */
|
||||
rows?: number;
|
||||
/** 是否展示卡片网格布局(默认 false,展示列表) */
|
||||
grid?: boolean;
|
||||
/** 网格列数(grid=true 时生效,默认 3) */
|
||||
cols?: 2 | 3 | 4;
|
||||
/** 自定义高度类名(如 "h-24") */
|
||||
rowHeight?: string;
|
||||
}
|
||||
|
||||
export function LoadingState({
|
||||
rows = 3,
|
||||
grid = false,
|
||||
cols = 3,
|
||||
rowHeight = "h-24",
|
||||
}: LoadingStateProps) {
|
||||
const colClass = {
|
||||
2: "md:grid-cols-2",
|
||||
3: "md:grid-cols-2 lg:grid-cols-3",
|
||||
4: "md:grid-cols-2 lg:grid-cols-4",
|
||||
}[cols];
|
||||
|
||||
if (grid) {
|
||||
return (
|
||||
<div className={`grid gap-4 ${colClass}`}>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<Skeleton key={i} className={`${rowHeight} w-full rounded-xl`} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<Skeleton key={i} className={`${rowHeight} w-full rounded-xl`} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ErrorState ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ErrorStateProps {
|
||||
/** 错误对象或错误消息字符串 */
|
||||
error: Error | string | null | undefined;
|
||||
/** 点击重试的回调 */
|
||||
onRetry?: () => void;
|
||||
/** 重试按钮文字(默认"重试") */
|
||||
retryLabel?: string;
|
||||
/** 标题(默认"数据加载失败") */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function ErrorState({
|
||||
error,
|
||||
onRetry,
|
||||
retryLabel = "重试",
|
||||
title = "数据加载失败",
|
||||
}: ErrorStateProps) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: "发生未知错误,请稍后重试";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-red-200 bg-red-50 py-16 text-center">
|
||||
<AlertCircle className="h-10 w-10 text-red-400 mb-3" />
|
||||
<p className="text-sm font-medium text-red-600">{title}</p>
|
||||
<p className="text-xs text-red-500 mt-1 max-w-sm">{message}</p>
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={onRetry}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
{retryLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── EmptyState ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface EmptyStateProps {
|
||||
/** 主提示文字 */
|
||||
message?: string;
|
||||
/** 次级说明文字 */
|
||||
description?: string;
|
||||
/** 操作按钮(可选) */
|
||||
action?: React.ReactNode;
|
||||
/** 自定义图标 */
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
message = "暂无数据",
|
||||
description,
|
||||
action,
|
||||
icon,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-gray-200 bg-white py-16 text-center">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
|
||||
{icon ?? <Inbox className="h-6 w-6 text-gray-400" />}
|
||||
</div>
|
||||
<p className="text-base font-semibold text-gray-900">{message}</p>
|
||||
{description && (
|
||||
<p className="mt-2 mb-4 max-w-sm text-sm text-gray-500">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,19 +10,19 @@ const buttonVariants = cva(
|
|||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md hover:-translate-y-px",
|
||||
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90 hover:shadow-md hover:-translate-y-px",
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90 hover:shadow-md",
|
||||
outline:
|
||||
"border border-border bg-background text-foreground hover:bg-muted hover:border-primary/40 hover:-translate-y-px",
|
||||
"border border-border bg-background text-foreground hover:bg-muted hover:border-primary/40",
|
||||
secondary:
|
||||
"bg-muted text-foreground hover:bg-muted/80 hover:-translate-y-px",
|
||||
"bg-muted text-foreground hover:bg-muted/80",
|
||||
ghost:
|
||||
"text-foreground hover:bg-muted hover:text-foreground",
|
||||
link:
|
||||
"text-primary underline-offset-4 hover:underline p-0 h-auto",
|
||||
accent:
|
||||
"bg-accent text-accent-foreground shadow-sm hover:bg-accent/90 hover:shadow-md hover:-translate-y-px",
|
||||
"bg-accent text-accent-foreground shadow-sm hover:bg-accent/90 hover:shadow-md",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-5 py-2.5",
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ const cardVariants = cva(
|
|||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "shadow-card hover:shadow-card-hover hover:-translate-y-0.5",
|
||||
default: "shadow-sm hover:shadow-card-hover hover:border-primary/30",
|
||||
flat: "shadow-none",
|
||||
elevated: "shadow-md hover:shadow-hover hover:-translate-y-1",
|
||||
elevated: "shadow-md hover:shadow-lg hover:border-primary/30",
|
||||
ghost: "border-transparent shadow-none bg-transparent",
|
||||
},
|
||||
padding: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* 全局 Toast/通知 UI 组件
|
||||
*
|
||||
* 从 notification-store 读取通知队列,渲染为浮动通知列表。
|
||||
* 放置在 layout 层级,自动显示/消失。
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { X, CheckCircle, AlertTriangle, Info, AlertCircle } from "lucide-react";
|
||||
import { useNotificationStore } from "@/lib/stores/notification-store";
|
||||
import type { Notification, NotificationType } from "@/lib/stores/notification-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ── 图标映射 ────────────────────────────────────────────────────────────
|
||||
|
||||
const ICON_BY_TYPE: Record<NotificationType, React.ReactNode> = {
|
||||
success: <CheckCircle className="h-5 w-5 text-emerald-500" />,
|
||||
error: <AlertCircle className="h-5 w-5 text-red-500" />,
|
||||
warning: <AlertTriangle className="h-5 w-5 text-amber-500" />,
|
||||
info: <Info className="h-5 w-5 text-blue-500" />,
|
||||
};
|
||||
|
||||
// ── 样式映射 ────────────────────────────────────────────────────────────
|
||||
|
||||
const STYLE_BY_TYPE: Record<NotificationType, string> = {
|
||||
success: "border-emerald-200 bg-emerald-50 text-emerald-800",
|
||||
error: "border-red-200 bg-red-50 text-red-800",
|
||||
warning: "border-amber-200 bg-amber-50 text-amber-800",
|
||||
info: "border-blue-200 bg-blue-50 text-blue-800",
|
||||
};
|
||||
|
||||
// ── 单条通知 ────────────────────────────────────────────────────────────
|
||||
|
||||
function NotificationItem({
|
||||
notification,
|
||||
onRemove,
|
||||
}: {
|
||||
notification: Notification;
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-lg border px-4 py-3 shadow-sm",
|
||||
"animate-in slide-in-from-right-5 fade-in-0 duration-200",
|
||||
STYLE_BY_TYPE[notification.type]
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0 mt-0.5">{ICON_BY_TYPE[notification.type]}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{notification.title && (
|
||||
<p className="text-sm font-semibold leading-tight">
|
||||
{notification.title}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm leading-tight">{notification.message}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(notification.id)}
|
||||
className="shrink-0 text-current opacity-50 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 通知容器 ────────────────────────────────────────────────────────────
|
||||
|
||||
export function NotificationContainer() {
|
||||
const notifications = useNotificationStore((s) => s.notifications);
|
||||
const removeNotification = useNotificationStore((s) => s.removeNotification);
|
||||
|
||||
if (notifications.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm w-full pointer-events-none">
|
||||
{notifications.map((notification) => (
|
||||
<div key={notification.id} className="pointer-events-auto">
|
||||
<NotificationItem
|
||||
notification={notification}
|
||||
onRemove={removeNotification}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { test, expect, chromium } from "@playwright/test";
|
||||
|
||||
const TEST_USER = {
|
||||
email: "admin@fischer.com",
|
||||
password: "Admin@123",
|
||||
};
|
||||
|
||||
test.use({
|
||||
launchOptions: {
|
||||
executablePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
},
|
||||
});
|
||||
|
||||
test.describe("登录跳转测试(系统Chrome)", () => {
|
||||
test("登录成功后应跳转到dashboard并保持在dashboard", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
|
||||
await page.locator("#email").fill(TEST_USER.email);
|
||||
await page.locator("#password").fill(TEST_USER.password);
|
||||
await page.getByRole("button", { name: /登录/ }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15000 });
|
||||
await page.waitForTimeout(3000);
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await expect(page.getByText("Overview", { exact: false })).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue