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:
chiguyong 2026-05-23 21:35:10 +08:00
parent eac12093d6
commit 9e63915f42
150 changed files with 13957 additions and 3726 deletions

14
.dockerignore Normal file
View File

@ -0,0 +1,14 @@
# 根目录 .dockerignore用于 docker-compose 构建上下文)
.git/
.gitignore
docs/
tests/
*.md
*.log
*.txt
.env
.env.*
.pytest_cache/
__pycache__/
*.pyc
*.pyo

View File

@ -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可选按需填写
# -----------------------------------------------------------------------------
# 智谱 AIChatGLM 系列)
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

101
.github/workflows/ci.yml vendored Normal file
View File

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

99
.github/workflows/pr-check.yml vendored Normal file
View File

@ -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
.gitignore vendored
View File

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

View File

@ -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代理的自动化任务处理能力、深度的内容分析与优化建议、完整的项目生命周期管理以及强大的智能知识检索功能。建议在生产环境中进一步完善错误日志、监控指标与缓存策略,持续优化查询性能与用户体验。

View File

@ -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 14App Router、React 18。
@ -479,6 +656,7 @@ C->>C : "根据权限渲染侧边栏"
- 样式Tailwind CSS、Tailwind 插件动画、class-variance-authority、clsx、tailwind-merge。
- 图表Recharts 用于可视化展示。
- 开发工具ESLint、TypeScript、PostCSS、Tailwind。
- **新增**PlaywrightE2E测试框架、测试工具链。
```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测试流程确保跨浏览器兼容性。
- **新增**:文档化业务组件使用规范,提升团队协作效率。
- **新增**:实施测试驱动开发,提高代码质量和稳定性。

View File

@ -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能力集成需求工作器系统扩展满足多平台适配需求分布式发布系统满足复杂工作流编排需求。

View File

@ -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代码规范
- **命名约定**
- 模块与类使用PascalCaseUserService、CitationDetector
- 函数与变量使用snake_caseget_user_by_id、process_data
- 常量使用UPPER_CASEMAX_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代码规范
- **命名约定**
- 接口与类型使用PascalCaseUserData、ApiResponse
- 变量与函数使用camelCasegetUserData、processFormData
- 枚举使用UPPER_CASEUserRole.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)

View File

@ -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 asyncaioredis、SQLAlchemy JSONB
- 前端依赖
- 框架与 UINext.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

View File

@ -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执行的性能和超时处理
- 端到端测试中评估完整业务流程的性能和稳定性

View File

@ -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
- Redislocalhost: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流水线与自动化部署
- 建议阶段
- 代码提交触发测试(含认证接口测试),通过后构建镜像并推送制品库,随后部署到目标环境。
- 回滚机制
- 支持一键回滚至上一个稳定版本。
- 配置管理
- 环境变量与密钥通过安全渠道注入,避免硬编码。
(本节为通用指导,不直接分析具体文件)
- 环境变量与密钥通过安全渠道注入,避免硬编码。

View File

@ -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/SQLAlchemyWeb 框架与 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

View File

@ -1,5 +1,8 @@
# GEO - AI搜索引擎品牌曝光度优化平台
[![CI Pipeline](https://github.com/YOUR_USERNAME/GEO/actions/workflows/ci.yml/badge.svg)](https://github.com/YOUR_USERNAME/GEO/actions/workflows/ci.yml)
[![PR Check](https://github.com/YOUR_USERNAME/GEO/actions/workflows/pr-check.yml/badge.svg)](https://github.com/YOUR_USERNAME/GEO/actions/workflows/pr-check.yml)
## 项目简介
GEOGenerative Engine Optimization是一个SaaS平台帮助品牌监测其在各大AI搜索引擎中的曝光度和引用情况。支持文心一言、Kimi、通义千问、豆包、讯飞星火、天工、清言等主流国内AI平台以及通用搜索引擎。

13
backend/.dockerignore Normal file
View File

@ -0,0 +1,13 @@
venv/
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.env
.env.*
*.log
tests/
alembic/versions/__pycache__/
.git/
.gitignore
README.md

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

46
backend/app/api/base.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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_idJWT 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/register5次/分钟/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):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

17
backend/pyproject.toml Normal file
View File

@ -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_*"]

View File

@ -1,6 +1,7 @@
# Web框架
fastapi>=0.109.0
uvicorn[standard]
gunicorn>=21.2.0
# 数据库
sqlalchemy>=2.0

View File

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

View File

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

View File

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

1
csrf.json Normal file
View File

@ -0,0 +1 @@
{"csrfToken":"defd4951c4d87238088193a161570b32ea50fa5015753e6e6eeb4adc1d7c0f8c"}

13
csrf_headers.txt Normal file
View File

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

1
csrf_resp.json Normal file
View File

@ -0,0 +1 @@
{"csrfToken":"8ecba5438bf185bf0a664e52de0510a57ded93697162fbcf4d61435397cba604"}

140
docker-compose.prod.yml Normal file
View File

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

View File

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

116
docs/后续待办事项.md Normal file
View File

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

399
docs/操作流程.md Normal file
View File

@ -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
- 后端 APIhttp://localhost:8000
- API 文档Swaggerhttp://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`
- 统一异常处理(不泄漏内部错误详情)
- 密码重置/邮箱验证等敏感操作防止用户枚举

11
frontend/.dockerignore Normal file
View File

@ -0,0 +1,11 @@
node_modules/
.next/
.env.local
.env.*
*.log
.git/
.gitignore
README.md
test-results/
playwright-report/
e2e/

View File

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

View File

@ -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: "更新品牌" }),
});
});
});

View File

@ -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(
"品牌名称已存在"
);
});
});

View File

@ -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("测试品牌");
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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生成新内容

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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