fix: P0 安全加固 + 前端错误边界 + ESLint 修复
后端: - 实现登出 API(清除 refresh token) - 清除 videos.py 中已被 Celery 任务取代的死代码 - 添加速率限制中间件(60次/分钟,登录10次/分钟) - 添加 SECRET_KEY/ENCRYPTION_KEY 默认值警告 - OSS STS 方法回退到 Policy 签名(不再抛异常) 前端: - 添加全局 404/error/loading 页面 - 添加三端 error.tsx + loading.tsx 错误边界 - 修复 useId 条件调用违反 Hooks 规则 - 修复未转义引号和 Image 命名冲突 - 添加 ESLint 配置 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a8be7bbca9
commit
8eb8100cf4
@@ -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": "已退出登录"}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user