diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a872bd0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,137 @@ +# CLAUDE.md — 秒思智能审核平台 + +## 常用命令 + +### 前端 (frontend/) +```bash +cd frontend && npm run dev # 开发服务器 http://localhost:3000 +cd frontend && npm run build # 生产构建(含类型检查) +cd frontend && npm run lint # ESLint 检查 +cd frontend && npm test # Vitest 测试 +cd frontend && npm run test:coverage # 覆盖率报告 +``` + +### 后端 (backend/) +```bash +cd backend && uvicorn app.main:app --reload # 开发服务器 http://localhost:8000 +cd backend && pytest # 运行测试 +cd backend && pytest --cov # 带覆盖率 +cd backend && pytest -m "not slow" # 跳过慢测试 +cd backend && alembic upgrade head # 执行数据库迁移 +cd backend && alembic revision --autogenerate -m "msg" # 生成迁移 +``` + +### Docker +```bash +cd backend && docker-compose up # 启动 PostgreSQL + Redis + API + Celery +``` + +## 项目架构 + +**秒思智能审核平台** — AI 营销内容合规审核系统,支持品牌方/代理商/达人三端。 + +``` +video-compliance-ai/ +├── frontend/ Next.js 14 + TypeScript + TailwindCSS (App Router) +├── backend/ FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL +├── documents/ 产品文档 (PRD, 设计稿, 约定等) +└── scripts/ 工具脚本 +``` + +### 前端结构 +``` +frontend/ +├── app/ +│ ├── login/, register/ 认证页面 +│ ├── creator/ 达人端(任务/上传/申诉) +│ ├── agency/ 代理商端(审核/管理/报表) +│ └── brand/ 品牌方端(项目/规则/AI配置) +├── components/ui/ 通用 UI 组件 +├── lib/api.ts Axios API 客户端(所有后端接口已封装) +├── lib/taskStageMapper.ts 任务阶段 → UI 状态映射 +├── hooks/ 自定义 Hooks(useOSSUpload 等) +├── contexts/ AuthContext, SSEContext +└── types/ TypeScript 类型定义(与后端 schema 对齐) +``` + +### 后端结构 +``` +backend/app/ +├── main.py FastAPI 应用入口,API 前缀 /api/v1 +├── config.py Pydantic Settings 配置 +├── database.py SQLAlchemy async session +├── celery_app.py Celery 配置 +├── api/ 路由(auth, tasks, projects, briefs, organizations, dashboard, sse, upload, scripts, videos, rules, ai_config) +├── models/ SQLAlchemy ORM 模型 +├── schemas/ Pydantic 请求/响应 schema +├── services/ 业务逻辑层 +├── tasks/ Celery 异步任务 +└── utils/ 工具函数 +``` + +## 关键约定 + +### 认证与多租户 +- JWT 双 Token:access 15min + refresh 7天 +- localStorage keys:`miaosi_access_token`, `miaosi_refresh_token`, `miaosi_user` +- 品牌方 = 租户,数据按品牌方隔离 +- 组织关系多对多:品牌方 ↔ 代理商 ↔ 达人 + +### ID 规范 +- 语义化前缀 + 6位数字:`BR`(品牌方), `AG`(代理商), `CR`(达人), `PJ`(项目), `TK`(任务), `BF`(Brief) + +### Mock 模式 +- `USE_MOCK` 标志从 `contexts/AuthContext.tsx` 导出 +- 开发环境或 `NEXT_PUBLIC_USE_MOCK=true` 时为 true +- 每个页面在 `loadData()` 中先检查 `USE_MOCK`,为 true 则使用本地 mock 数据 + +### 前端数据加载模式 +```typescript +const loadData = useCallback(async () => { + if (USE_MOCK) { setData(mockData); setLoading(false); return } + try { + const res = await api.someMethod() + setData(res) + } catch (err) { + toast.error('加载失败') + } finally { + setLoading(false) + } +}, [toast]) +useEffect(() => { loadData() }, [loadData]) +``` + +### AI 服务 +- 通过中转服务商(OneAPI/OneInAll)调用,不直连 AI 厂商 +- 配置项:`AI_PROVIDER`, `AI_API_KEY`, `AI_API_BASE_URL` + +### 文件上传 +- 阿里云 OSS 直传,前端通过 `useOSSUpload` hook 处理 +- 流程:`api.getUploadPolicy()` → POST 到 OSS → `api.fileUploaded()` 回调 + +### 实时推送 +- SSE (Server-Sent Events),端点 `/api/v1/sse/events` +- 前端通过 `SSEContext` 提供 `subscribe(eventType, handler)` API + +## 设计系统 + +### 暗色主题配色 +- 背景:`bg-page`(#0B0B0E), `bg-card`(#16161A), `bg-elevated`(#1A1A1E) +- 文字:`text-primary`(#FAFAF9), `text-secondary`(#6B6B70), `text-tertiary`(#4A4A50) +- 强调色:`accent-indigo`(#6366F1), `accent-green`(#32D583), `accent-coral`(#E85A4F), `accent-amber`(#FFB547) +- 边框:`border-subtle`(#2A2A2E), `border-strong`(#3A3A40) + +### 字体 +- 正文:DM Sans +- 展示:Fraunces + +## 任务审核流程 +``` +脚本上传 → AI审核 → 代理商审核 → 品牌终审 → 视频上传 → AI审核 → 代理商审核 → 品牌终审 → 完成 +``` +对应 `TaskStage`:`script_upload` → `script_ai_review` → `script_agency_review` → `script_brand_review` → `video_upload` → ... → `completed` + +## 注意事项 +- 后端 Celery 异步任务(视频审核处理)尚未完整实现 +- 数据库已有 3 个 Alembic 迁移版本 +- `.pen` 文件是加密设计文件,只能通过 Pencil MCP 工具访问 diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 4bf1b83..acd38fa 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -5,6 +5,8 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db +from app.api.deps import get_current_user +from app.models.user import User from app.schemas.auth import ( RegisterRequest, LoginRequest, @@ -234,13 +236,15 @@ async def refresh_token( @router.post("/logout") async def logout( + current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), - # TODO: 添加认证依赖 ): """ 退出登录 - - 清除 refresh token + - 清除 refresh token,使其失效 """ - # TODO: 实现退出登录 + current_user.refresh_token = None + current_user.refresh_token_expires_at = None + await db.commit() return {"message": "已退出登录"} diff --git a/backend/app/api/videos.py b/backend/app/api/videos.py index 7eb7e0f..9c4915e 100644 --- a/backend/app/api/videos.py +++ b/backend/app/api/videos.py @@ -23,8 +23,6 @@ from app.schemas.review import ( ViolationSource, SoftRiskWarning, ) -from app.services.ai_service import AIServiceFactory -from app.services.ai_client import OpenAICompatibleClient router = APIRouter(prefix="/videos", tags=["videos"]) @@ -205,177 +203,3 @@ async def get_review_result( violations=violations, soft_warnings=soft_warnings, ) - - -# ==================== AI 辅助审核方法 ==================== - -async def _perform_ai_video_review( - task: ReviewTask, - ai_client: OpenAICompatibleClient, - text_model: str, - vision_model: str, - audio_model: str, - db: AsyncSession, -) -> dict: - """ - 使用 AI 执行视频审核 - - 流程: - 1. 下载视频 - 2. ASR 转写 - 3. 提取关键帧 - 4. 视觉分析 (竞品 Logo) - 5. OCR 字幕 - 6. 生成报告 - """ - violations = [] - score = 100 - - try: - # 更新进度: 开始处理 - task.status = DBTaskStatus.PROCESSING - task.progress = 10 - task.current_step = "下载视频" - await db.flush() - - # TODO: 实际实现需要集成视频处理库 - # 1. 下载视频 - # video_path = await download_video(task.video_url) - - # 2. ASR 转写 - task.progress = 30 - task.current_step = "语音转写" - await db.flush() - - # asr_result = await ai_client.audio_transcription( - # audio_url=task.video_url, # 需要提取音频 - # model=audio_model, - # ) - # transcript = asr_result.content - - # 3. 提取关键帧 - task.progress = 50 - task.current_step = "提取关键帧" - await db.flush() - - # frames = await extract_keyframes(video_path) - - # 4. 视觉分析 - task.progress = 70 - task.current_step = "视觉分析" - await db.flush() - - # 检测竞品 Logo - # if task.competitors: - # vision_prompt = f""" - # 分析这些视频截图,检测是否包含以下竞品品牌的 Logo 或标识: - # 竞品列表: {task.competitors} - # - # 如果发现竞品,请返回: - # 1. 竞品名称 - # 2. 出现的帧编号 - # 3. 置信度 (0-1) - # """ - # vision_result = await ai_client.vision_analysis( - # image_urls=frames, - # prompt=vision_prompt, - # model=vision_model, - # ) - - # 5. 文本综合分析 - task.progress = 85 - task.current_step = "综合分析" - await db.flush() - - # analysis_prompt = f""" - # 作为广告合规审核专家,请分析以下视频脚本内容: - # - # 脚本内容: - # {transcript} - # - # 请检查: - # 1. 是否包含广告法违禁词(最好、第一、最佳等极限词) - # 2. 是否包含虚假功效宣称 - # 3. 品牌信息是否正确 - # - # 返回 JSON 格式: - # {{"violations": [...], "score": 0-100, "summary": "..."}} - # """ - # analysis_result = await ai_client.chat_completion( - # messages=[{"role": "user", "content": analysis_prompt}], - # model=text_model, - # ) - - # 6. 完成审核 - task.progress = 100 - task.current_step = "审核完成" - task.status = DBTaskStatus.COMPLETED - task.score = score - task.summary = "审核完成,未发现违规" if not violations else f"发现 {len(violations)} 处违规" - task.violations = [v.model_dump() for v in violations] if violations else [] - - await db.flush() - - return { - "score": score, - "summary": task.summary, - "violations": violations, - } - - except Exception as e: - task.status = DBTaskStatus.FAILED - task.error_message = str(e) - await db.flush() - raise - - -# ==================== 后台任务入口 ==================== - -async def process_video_review_task( - review_id: str, - tenant_id: str, - db: AsyncSession, -): - """ - 处理视频审核任务(由 Celery 或后台任务调用) - """ - # 获取任务 - result = await db.execute( - select(ReviewTask).where( - and_( - ReviewTask.id == review_id, - ReviewTask.tenant_id == tenant_id, - ) - ) - ) - task = result.scalar_one_or_none() - - if not task: - return - - # 获取 AI 客户端 - ai_client = await AIServiceFactory.get_client(tenant_id, db) - - if not ai_client: - # 没有配置 AI,使用规则引擎审核 - task.status = DBTaskStatus.COMPLETED - task.score = 100 - task.summary = "审核完成(规则引擎)" - task.progress = 100 - task.current_step = "审核完成" - await db.flush() - return - - # 获取模型配置 - config = await AIServiceFactory.get_config(tenant_id, db) - models = config.models - - # 执行 AI 审核 - await _perform_ai_video_review( - task=task, - ai_client=ai_client, - text_model=models.get("text", "gpt-4o"), - vision_model=models.get("vision", "gpt-4o"), - audio_model=models.get("audio", "whisper-1"), - db=db, - ) diff --git a/backend/app/config.py b/backend/app/config.py index 18e34e9..a633411 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,4 +1,5 @@ """应用配置""" +import warnings from pydantic_settings import BaseSettings from functools import lru_cache @@ -34,9 +35,27 @@ class Settings(BaseSettings): OSS_BUCKET_NAME: str = "miaosi-files" OSS_BUCKET_DOMAIN: str = "" # 公开访问域名,如 https://miaosi-files.oss-cn-hangzhou.aliyuncs.com + # 加密密钥 + ENCRYPTION_KEY: str = "" + # 文件上传限制 MAX_FILE_SIZE_MB: int = 500 # 最大文件大小 500MB + def __init__(self, **kwargs): + super().__init__(**kwargs) + if self.SECRET_KEY == "your-secret-key-change-in-production": + warnings.warn( + "SECRET_KEY 使用默认值,请在 .env 中设置安全的密钥!", + UserWarning, + stacklevel=2, + ) + if not self.ENCRYPTION_KEY: + warnings.warn( + "ENCRYPTION_KEY 未设置,API 密钥将无法安全存储!", + UserWarning, + stacklevel=2, + ) + class Config: env_file = ".env" case_sensitive = True diff --git a/backend/app/main.py b/backend/app/main.py index 11c2081..85f9b38 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,6 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.config import settings +from app.middleware.rate_limit import RateLimitMiddleware from app.api import health, auth, upload, scripts, videos, tasks, rules, ai_config, sse, projects, briefs, organizations, dashboard # 创建应用 @@ -22,6 +23,9 @@ app.add_middleware( allow_headers=["*"], ) +# Rate limiting +app.add_middleware(RateLimitMiddleware, default_limit=60, window_seconds=60) + # 注册路由 app.include_router(health.router, prefix="/api/v1") app.include_router(auth.router, prefix="/api/v1") diff --git a/backend/app/middleware/__init__.py b/backend/app/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/middleware/rate_limit.py b/backend/app/middleware/rate_limit.py new file mode 100644 index 0000000..163ff7d --- /dev/null +++ b/backend/app/middleware/rate_limit.py @@ -0,0 +1,71 @@ +""" +简单的速率限制中间件 +基于内存的滑动窗口计数器 +""" +import time +from collections import defaultdict +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse + + +class RateLimitMiddleware(BaseHTTPMiddleware): + """ + 速率限制中间件 + + - 默认: 60 次/分钟 per IP + - 登录/注册: 10 次/分钟 per IP + """ + + def __init__(self, app, default_limit: int = 60, window_seconds: int = 60): + super().__init__(app) + self.default_limit = default_limit + self.window_seconds = window_seconds + self.requests: dict[str, list[float]] = defaultdict(list) + # Stricter limits for auth endpoints + self.strict_paths = {"/api/v1/auth/login", "/api/v1/auth/register"} + self.strict_limit = 10 + + async def dispatch(self, request: Request, call_next): + client_ip = request.client.host if request.client else "unknown" + path = request.url.path + now = time.time() + + # Determine rate limit + if path in self.strict_paths: + key = f"{client_ip}:{path}" + limit = self.strict_limit + else: + key = client_ip + limit = self.default_limit + + # Clean old entries + window_start = now - self.window_seconds + self.requests[key] = [t for t in self.requests[key] if t > window_start] + + # Check limit + if len(self.requests[key]) >= limit: + return JSONResponse( + status_code=429, + content={"detail": "请求过于频繁,请稍后再试"}, + ) + + # Record request + self.requests[key].append(now) + + # Periodic cleanup (every 1000 requests to this key) + if len(self.requests) > 10000: + self._cleanup(now) + + response = await call_next(request) + return response + + def _cleanup(self, now: float): + """Clean up expired entries""" + window_start = now - self.window_seconds + expired_keys = [ + k for k, v in self.requests.items() + if not v or v[-1] < window_start + ] + for k in expired_keys: + del self.requests[k] diff --git a/backend/app/services/oss.py b/backend/app/services/oss.py index e07a285..9877373 100644 --- a/backend/app/services/oss.py +++ b/backend/app/services/oss.py @@ -87,12 +87,14 @@ def generate_sts_token( """ 生成 STS 临时凭证(需要配置 RAM 角色) - 注意:此方法需要安装 aliyun-python-sdk-sts - 如果不使用 STS,可以使用上面的 generate_upload_policy 方法 + 当前使用 Policy 签名方式,STS 方式为可选增强。 + 如需启用 STS,请安装 aliyun-python-sdk-sts 并配置 RAM 角色。 """ - # TODO: 实现 STS 临时凭证生成 - # 需要安装 aliyun-python-sdk-core 和 aliyun-python-sdk-sts - raise NotImplementedError("STS 临时凭证生成暂未实现,请使用 generate_upload_policy") + # 回退到 Policy 签名方式 + return generate_upload_policy( + max_size_mb=settings.MAX_FILE_SIZE_MB, + expire_seconds=duration_seconds, + ) def get_file_url(file_key: str) -> str: diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json new file mode 100644 index 0000000..2541221 --- /dev/null +++ b/frontend/.eslintrc.json @@ -0,0 +1 @@ +{"extends":"next/core-web-vitals"} diff --git a/frontend/app/agency/error.tsx b/frontend/app/agency/error.tsx new file mode 100644 index 0000000..0cee017 --- /dev/null +++ b/frontend/app/agency/error.tsx @@ -0,0 +1,44 @@ +'use client' + +import { useEffect } from 'react' +import { AlertTriangle, RefreshCw, Home } from 'lucide-react' + +export default function AgencyError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + console.error('Agency section error:', error) + }, [error]) + + return ( +
+ {error.message || '发生未知错误,请重试'} +
+加载中...
+- 💡 提示:点击右上角"查询企业"按钮,输入公司名称可自动填充工商信息 + 💡 提示:点击右上角“查询企业”按钮,输入公司名称可自动填充工商信息
+ {error.message || '发生未知错误,请重试'} +
+加载中...
+- 仅显示已在"代理商管理"中添加的代理商 + 仅显示已在“代理商管理”中添加的代理商
diff --git a/frontend/app/brand/review/video/[id]/page.tsx b/frontend/app/brand/review/video/[id]/page.tsx index 79872e3..bbc668a 100644 --- a/frontend/app/brand/review/video/[id]/page.tsx +++ b/frontend/app/brand/review/video/[id]/page.tsx @@ -338,7 +338,7 @@ export default function BrandVideoReviewPage() { } finally { setLoading(false) } - }, [taskId]) + }, [taskId, toast]) useEffect(() => { loadTask() diff --git a/frontend/app/creator/appeal-quota/page.tsx b/frontend/app/creator/appeal-quota/page.tsx index ed7fc18..56dcda1 100644 --- a/frontend/app/creator/appeal-quota/page.tsx +++ b/frontend/app/creator/appeal-quota/page.tsx @@ -363,7 +363,7 @@ export default function AppealQuotaPage() {+ {error.message || '发生未知错误,请重试'} +
+加载中...
++ 应用遇到了一个错误,请尝试刷新页面 +
+加载中...
++ 您访问的页面不存在或已被移除 +
+ + 返回首页 + +