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:
parent
a8be7bbca9
commit
8eb8100cf4
137
CLAUDE.md
Normal file
137
CLAUDE.md
Normal file
@ -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 工具访问
|
||||||
@ -5,6 +5,8 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
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 (
|
from app.schemas.auth import (
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
@ -234,13 +236,15 @@ async def refresh_token(
|
|||||||
|
|
||||||
@router.post("/logout")
|
@router.post("/logout")
|
||||||
async def logout(
|
async def logout(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
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": "已退出登录"}
|
return {"message": "已退出登录"}
|
||||||
|
|||||||
@ -23,8 +23,6 @@ from app.schemas.review import (
|
|||||||
ViolationSource,
|
ViolationSource,
|
||||||
SoftRiskWarning,
|
SoftRiskWarning,
|
||||||
)
|
)
|
||||||
from app.services.ai_service import AIServiceFactory
|
|
||||||
from app.services.ai_client import OpenAICompatibleClient
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/videos", tags=["videos"])
|
router = APIRouter(prefix="/videos", tags=["videos"])
|
||||||
|
|
||||||
@ -205,177 +203,3 @@ async def get_review_result(
|
|||||||
violations=violations,
|
violations=violations,
|
||||||
soft_warnings=soft_warnings,
|
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 pydantic_settings import BaseSettings
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
@ -34,9 +35,27 @@ class Settings(BaseSettings):
|
|||||||
OSS_BUCKET_NAME: str = "miaosi-files"
|
OSS_BUCKET_NAME: str = "miaosi-files"
|
||||||
OSS_BUCKET_DOMAIN: str = "" # 公开访问域名,如 https://miaosi-files.oss-cn-hangzhou.aliyuncs.com
|
OSS_BUCKET_DOMAIN: str = "" # 公开访问域名,如 https://miaosi-files.oss-cn-hangzhou.aliyuncs.com
|
||||||
|
|
||||||
|
# 加密密钥
|
||||||
|
ENCRYPTION_KEY: str = ""
|
||||||
|
|
||||||
# 文件上传限制
|
# 文件上传限制
|
||||||
MAX_FILE_SIZE_MB: int = 500 # 最大文件大小 500MB
|
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:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
case_sensitive = True
|
case_sensitive = True
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from app.config import settings
|
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
|
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=["*"],
|
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(health.router, prefix="/api/v1")
|
||||||
app.include_router(auth.router, prefix="/api/v1")
|
app.include_router(auth.router, prefix="/api/v1")
|
||||||
|
|||||||
0
backend/app/middleware/__init__.py
Normal file
0
backend/app/middleware/__init__.py
Normal file
71
backend/app/middleware/rate_limit.py
Normal file
71
backend/app/middleware/rate_limit.py
Normal file
@ -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 角色)
|
生成 STS 临时凭证(需要配置 RAM 角色)
|
||||||
|
|
||||||
注意:此方法需要安装 aliyun-python-sdk-sts
|
当前使用 Policy 签名方式,STS 方式为可选增强。
|
||||||
如果不使用 STS,可以使用上面的 generate_upload_policy 方法
|
如需启用 STS,请安装 aliyun-python-sdk-sts 并配置 RAM 角色。
|
||||||
"""
|
"""
|
||||||
# TODO: 实现 STS 临时凭证生成
|
# 回退到 Policy 签名方式
|
||||||
# 需要安装 aliyun-python-sdk-core 和 aliyun-python-sdk-sts
|
return generate_upload_policy(
|
||||||
raise NotImplementedError("STS 临时凭证生成暂未实现,请使用 generate_upload_policy")
|
max_size_mb=settings.MAX_FILE_SIZE_MB,
|
||||||
|
expire_seconds=duration_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_file_url(file_key: str) -> str:
|
def get_file_url(file_key: str) -> str:
|
||||||
|
|||||||
1
frontend/.eslintrc.json
Normal file
1
frontend/.eslintrc.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"extends":"next/core-web-vitals"}
|
||||||
44
frontend/app/agency/error.tsx
Normal file
44
frontend/app/agency/error.tsx
Normal file
@ -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 (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full min-h-[400px] gap-4">
|
||||||
|
<div className="w-14 h-14 bg-accent-coral/15 rounded-2xl flex items-center justify-center">
|
||||||
|
<AlertTriangle className="w-7 h-7 text-accent-coral" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-text-primary">页面加载失败</h2>
|
||||||
|
<p className="text-text-secondary text-sm max-w-sm text-center">
|
||||||
|
{error.message || '发生未知错误,请重试'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.href = '/agency'}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-bg-elevated text-text-secondary rounded-xl text-sm font-medium hover:bg-bg-card transition-colors border border-border-subtle"
|
||||||
|
>
|
||||||
|
<Home className="w-4 h-4" />
|
||||||
|
回到首页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-accent-indigo text-white rounded-xl text-sm font-medium hover:bg-accent-indigo/90 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
10
frontend/app/agency/loading.tsx
Normal file
10
frontend/app/agency/loading.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export default function AgencyLoading() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full min-h-[400px]">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-8 h-8 border-2 border-border-subtle border-t-accent-indigo rounded-full animate-spin" />
|
||||||
|
<p className="text-text-tertiary text-sm">加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -585,7 +585,7 @@ export default function AgencyCompanyPage() {
|
|||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="p-3 rounded-lg bg-accent-indigo/10 border border-accent-indigo/20">
|
<div className="p-3 rounded-lg bg-accent-indigo/10 border border-accent-indigo/20">
|
||||||
<p className="text-sm text-accent-indigo">
|
<p className="text-sm text-accent-indigo">
|
||||||
💡 提示:点击右上角"查询企业"按钮,输入公司名称可自动填充工商信息
|
💡 提示:点击右上角“查询企业”按钮,输入公司名称可自动填充工商信息
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
44
frontend/app/brand/error.tsx
Normal file
44
frontend/app/brand/error.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { AlertTriangle, RefreshCw, Home } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function BrandError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Brand section error:', error)
|
||||||
|
}, [error])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full min-h-[400px] gap-4">
|
||||||
|
<div className="w-14 h-14 bg-accent-coral/15 rounded-2xl flex items-center justify-center">
|
||||||
|
<AlertTriangle className="w-7 h-7 text-accent-coral" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-text-primary">页面加载失败</h2>
|
||||||
|
<p className="text-text-secondary text-sm max-w-sm text-center">
|
||||||
|
{error.message || '发生未知错误,请重试'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.href = '/brand'}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-bg-elevated text-text-secondary rounded-xl text-sm font-medium hover:bg-bg-card transition-colors border border-border-subtle"
|
||||||
|
>
|
||||||
|
<Home className="w-4 h-4" />
|
||||||
|
回到首页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-accent-indigo text-white rounded-xl text-sm font-medium hover:bg-accent-indigo/90 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
10
frontend/app/brand/loading.tsx
Normal file
10
frontend/app/brand/loading.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export default function BrandLoading() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full min-h-[400px]">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-8 h-8 border-2 border-border-subtle border-t-accent-indigo rounded-full animate-spin" />
|
||||||
|
<p className="text-text-tertiary text-sm">加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -302,7 +302,7 @@ export default function CreateProjectPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-text-tertiary mt-3">
|
<p className="text-xs text-text-tertiary mt-3">
|
||||||
仅显示已在"代理商管理"中添加的代理商
|
仅显示已在“代理商管理”中添加的代理商
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -338,7 +338,7 @@ export default function BrandVideoReviewPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [taskId])
|
}, [taskId, toast])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTask()
|
loadTask()
|
||||||
|
|||||||
@ -363,7 +363,7 @@ export default function AppealQuotaPage() {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-sm font-medium text-text-primary">申诉次数规则</span>
|
<span className="text-sm font-medium text-text-primary">申诉次数规则</span>
|
||||||
<span className="text-[13px] text-text-secondary leading-relaxed">
|
<span className="text-[13px] text-text-secondary leading-relaxed">
|
||||||
每个任务初始有 1 次申诉机会,不同任务独立计算。如需更多次数,可点击"申请增加"向代理商发送请求,无需填写理由。代理商可增加的次数无上限。
|
每个任务初始有 1 次申诉机会,不同任务独立计算。如需更多次数,可点击“申请增加”向代理商发送请求,无需填写理由。代理商可增加的次数无上限。
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
FileText,
|
FileText,
|
||||||
Image,
|
Image as ImageIcon,
|
||||||
Send,
|
Send,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Loader2
|
Loader2
|
||||||
@ -422,7 +422,7 @@ export default function AppealDetailPage() {
|
|||||||
className="flex items-center gap-3 px-4 py-3 bg-bg-elevated rounded-xl cursor-pointer hover:bg-bg-page transition-colors"
|
className="flex items-center gap-3 px-4 py-3 bg-bg-elevated rounded-xl cursor-pointer hover:bg-bg-page transition-colors"
|
||||||
>
|
>
|
||||||
{attachment.type === 'image' ? (
|
{attachment.type === 'image' ? (
|
||||||
<Image className="w-5 h-5 text-accent-indigo" />
|
<ImageIcon className="w-5 h-5 text-accent-indigo" />
|
||||||
) : (
|
) : (
|
||||||
<FileText className="w-5 h-5 text-accent-indigo" />
|
<FileText className="w-5 h-5 text-accent-indigo" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
FileText,
|
FileText,
|
||||||
Image,
|
Image as ImageIcon,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Loader2
|
Loader2
|
||||||
@ -468,7 +468,7 @@ export default function NewAppealPage() {
|
|||||||
className="flex items-center gap-2 px-3 py-2 bg-bg-elevated rounded-lg"
|
className="flex items-center gap-2 px-3 py-2 bg-bg-elevated rounded-lg"
|
||||||
>
|
>
|
||||||
{file.type === 'image' ? (
|
{file.type === 'image' ? (
|
||||||
<Image className="w-4 h-4 text-accent-indigo" />
|
<ImageIcon className="w-4 h-4 text-accent-indigo" />
|
||||||
) : (
|
) : (
|
||||||
<FileText className="w-4 h-4 text-accent-indigo" />
|
<FileText className="w-4 h-4 text-accent-indigo" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
44
frontend/app/creator/error.tsx
Normal file
44
frontend/app/creator/error.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { AlertTriangle, RefreshCw, Home } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function CreatorError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Creator section error:', error)
|
||||||
|
}, [error])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full min-h-[400px] gap-4">
|
||||||
|
<div className="w-14 h-14 bg-accent-coral/15 rounded-2xl flex items-center justify-center">
|
||||||
|
<AlertTriangle className="w-7 h-7 text-accent-coral" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-text-primary">页面加载失败</h2>
|
||||||
|
<p className="text-text-secondary text-sm max-w-sm text-center">
|
||||||
|
{error.message || '发生未知错误,请重试'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.href = '/creator'}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-bg-elevated text-text-secondary rounded-xl text-sm font-medium hover:bg-bg-card transition-colors border border-border-subtle"
|
||||||
|
>
|
||||||
|
<Home className="w-4 h-4" />
|
||||||
|
回到首页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-accent-indigo text-white rounded-xl text-sm font-medium hover:bg-accent-indigo/90 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
10
frontend/app/creator/loading.tsx
Normal file
10
frontend/app/creator/loading.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export default function CreatorLoading() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full min-h-[400px]">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-8 h-8 border-2 border-border-subtle border-t-accent-indigo rounded-full animate-spin" />
|
||||||
|
<p className="text-text-tertiary text-sm">加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
frontend/app/error.tsx
Normal file
47
frontend/app/error.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
export default function Error({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Application error:', error)
|
||||||
|
}, [error])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-bg-page flex items-center justify-center">
|
||||||
|
<div className="text-center max-w-md">
|
||||||
|
<div className="w-16 h-16 bg-accent-coral/15 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||||
|
<svg className="w-8 h-8 text-accent-coral" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold text-text-primary mb-2">
|
||||||
|
出错了
|
||||||
|
</h2>
|
||||||
|
<p className="text-text-secondary mb-8">
|
||||||
|
应用遇到了一个错误,请尝试刷新页面
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.href = '/'}
|
||||||
|
className="px-6 py-3 bg-bg-elevated text-text-secondary rounded-xl font-medium hover:bg-bg-card transition-colors border border-border-subtle"
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="px-6 py-3 bg-accent-indigo text-white rounded-xl font-medium hover:bg-accent-indigo/90 transition-colors"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
10
frontend/app/loading.tsx
Normal file
10
frontend/app/loading.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-bg-page flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-10 h-10 border-3 border-border-subtle border-t-accent-indigo rounded-full animate-spin" />
|
||||||
|
<p className="text-text-tertiary text-sm">加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
frontend/app/not-found.tsx
Normal file
23
frontend/app/not-found.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-bg-page flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-8xl font-bold text-accent-indigo mb-4">404</h1>
|
||||||
|
<h2 className="text-2xl font-semibold text-text-primary mb-2">
|
||||||
|
页面未找到
|
||||||
|
</h2>
|
||||||
|
<p className="text-text-secondary mb-8">
|
||||||
|
您访问的页面不存在或已被移除
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 bg-accent-indigo text-white rounded-xl font-medium hover:bg-accent-indigo/90 transition-colors"
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -29,7 +29,8 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(({
|
|||||||
id,
|
id,
|
||||||
...props
|
...props
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const inputId = id ?? useId();
|
const generatedId = useId();
|
||||||
|
const inputId = id ?? generatedId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${fullWidth ? 'w-full' : ''}`}>
|
<div className={`${fullWidth ? 'w-full' : ''}`}>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user