diff --git a/backend/.env.example b/backend/.env.example index 09b7c56..a7d4f31 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,21 +1,42 @@ -# 应用配置 +# =========================== +# 秒思智能审核平台 - 后端环境变量 +# =========================== +# 复制此文件为 .env 并填入实际值 +# cp .env.example .env + +# --- 应用 --- APP_NAME=秒思智能审核平台 APP_VERSION=1.0.0 DEBUG=false -# 数据库 +# --- 数据库 --- DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/miaosi -# Redis +# --- Redis --- REDIS_URL=redis://localhost:6379/0 -# JWT 密钥 (生产环境必须更换) +# --- JWT --- +# 生产环境务必更换为随机密钥: python -c "import secrets; print(secrets.token_urlsafe(64))" SECRET_KEY=your-secret-key-change-in-production +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 -# AI 配置 (可选,也可通过 API 配置) -AI_PROVIDER=doubao +# --- AI 服务 (中转服务商) --- +AI_PROVIDER=oneapi AI_API_KEY= AI_API_BASE_URL= -# 加密密钥 (生产环境必须更换,用于加密 API Key) -ENCRYPTION_KEY=your-32-byte-encryption-key-here +# --- 阿里云 OSS --- +OSS_ACCESS_KEY_ID= +OSS_ACCESS_KEY_SECRET= +OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com +OSS_BUCKET_NAME=miaosi-files +OSS_BUCKET_DOMAIN= + +# --- 加密密钥 --- +# 用于加密存储 API 密钥等敏感数据 +# 生成方法: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +ENCRYPTION_KEY= + +# --- 文件上传 --- +MAX_FILE_SIZE_MB=500 diff --git a/backend/Dockerfile b/backend/Dockerfile index 062e90f..a1f3fbf 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,30 +1,56 @@ -# 基础镜像 -FROM python:3.11-slim +# =========================== +# 秒思智能审核平台 - Backend Dockerfile +# 多阶段构建,基于 python:3.13-slim +# =========================== -# 设置工作目录 -WORKDIR /app +# ---------- Stage 1: 构建依赖 ---------- +FROM python:3.13-slim AS builder -# 安装系统依赖 (FFmpeg 用于视频处理) +WORKDIR /build + +# 安装编译依赖 RUN apt-get update && apt-get install -y --no-install-recommends \ - ffmpeg \ - libpq-dev \ gcc \ + libpq-dev \ && rm -rf /var/lib/apt/lists/* -# 复制依赖文件 +# 复制依赖描述文件 COPY pyproject.toml . -# 安装 Python 依赖 -RUN pip install --no-cache-dir -e . +# 安装 Python 依赖到 /build/deps +RUN pip install --no-cache-dir --prefix=/build/deps . + +# ---------- Stage 2: 运行时镜像 ---------- +FROM python:3.13-slim AS runtime + +WORKDIR /app + +# 安装运行时系统依赖(FFmpeg 用于视频处理,libpq 用于 PostgreSQL) +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + libpq5 \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# 从 builder 阶段复制已安装的 Python 依赖 +COPY --from=builder /build/deps /usr/local # 复制应用代码 -COPY . . +COPY app/ ./app/ +COPY alembic/ ./alembic/ +COPY alembic.ini . +COPY pyproject.toml . -# 创建临时目录 -RUN mkdir -p /tmp/videos +# 创建非 root 用户 +RUN groupadd -r miaosi && useradd -r -g miaosi -d /app -s /sbin/nologin miaosi \ + && mkdir -p /tmp/videos \ + && chown -R miaosi:miaosi /app /tmp/videos + +USER miaosi -# 暴露端口 EXPOSE 8000 -# 默认命令 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/api/export.py b/backend/app/api/export.py new file mode 100644 index 0000000..91f3b25 --- /dev/null +++ b/backend/app/api/export.py @@ -0,0 +1,234 @@ +""" +数据导出 API +支持导出任务数据和审计日志为 CSV 格式 +""" +import csv +import io +from typing import Optional +from datetime import datetime, date + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from app.database import get_db +from app.models.user import User, UserRole +from app.models.task import Task, TaskStage +from app.models.project import Project +from app.models.organization import Brand, Agency, Creator +from app.models.audit_log import AuditLog +from app.api.deps import get_current_user, require_roles + +router = APIRouter(prefix="/export", tags=["数据导出"]) + + +def _iter_csv(header: list[str], rows: list[list[str]]): + """ + 生成 CSV 流式响应的迭代器。 + 首行输出 UTF-8 BOM + 表头,之后逐行输出数据。 + """ + buf = io.StringIO() + writer = csv.writer(buf) + + # 写入 BOM + 表头 + writer.writerow(header) + yield "\ufeff" + buf.getvalue() + buf.seek(0) + buf.truncate(0) + + # 逐行写入数据 + for row in rows: + writer.writerow(row) + yield buf.getvalue() + buf.seek(0) + buf.truncate(0) + + +def _format_datetime(dt: Optional[datetime]) -> str: + """格式化日期时间为字符串""" + if dt is None: + return "" + return dt.strftime("%Y-%m-%d %H:%M:%S") + + +def _format_stage(stage: Optional[TaskStage]) -> str: + """将任务阶段转换为中文标签""" + if stage is None: + return "" + stage_labels = { + TaskStage.SCRIPT_UPLOAD: "待上传脚本", + TaskStage.SCRIPT_AI_REVIEW: "脚本AI审核中", + TaskStage.SCRIPT_AGENCY_REVIEW: "脚本代理商审核中", + TaskStage.SCRIPT_BRAND_REVIEW: "脚本品牌方终审中", + TaskStage.VIDEO_UPLOAD: "待上传视频", + TaskStage.VIDEO_AI_REVIEW: "视频AI审核中", + TaskStage.VIDEO_AGENCY_REVIEW: "视频代理商审核中", + TaskStage.VIDEO_BRAND_REVIEW: "视频品牌方终审中", + TaskStage.COMPLETED: "已完成", + TaskStage.REJECTED: "已驳回", + } + return stage_labels.get(stage, stage.value) + + +@router.get("/tasks") +async def export_tasks( + project_id: Optional[str] = Query(None, description="按项目ID筛选"), + start_date: Optional[date] = Query(None, description="开始日期 (YYYY-MM-DD)"), + end_date: Optional[date] = Query(None, description="结束日期 (YYYY-MM-DD)"), + current_user: User = Depends(require_roles(UserRole.BRAND, UserRole.AGENCY)), + db: AsyncSession = Depends(get_db), +): + """ + 导出任务数据为 CSV + + - 仅限品牌方和代理商角色 + - 支持按项目ID、时间范围筛选 + - 返回 CSV 文件流 + """ + # 构建查询,预加载关联数据 + query = ( + select(Task) + .options( + selectinload(Task.project).selectinload(Project.brand), + selectinload(Task.agency), + selectinload(Task.creator), + ) + .order_by(Task.created_at.desc()) + ) + + # 根据角色限定数据范围 + if current_user.role == UserRole.BRAND: + result = await db.execute( + select(Brand).where(Brand.user_id == current_user.id) + ) + brand = result.scalar_one_or_none() + if not brand: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="品牌方信息不存在", + ) + # 品牌方只能导出自己项目下的任务 + query = query.join(Task.project).where(Project.brand_id == brand.id) + + elif current_user.role == UserRole.AGENCY: + result = await db.execute( + select(Agency).where(Agency.user_id == current_user.id) + ) + agency = result.scalar_one_or_none() + if not agency: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="代理商信息不存在", + ) + # 代理商只能导出自己负责的任务 + query = query.where(Task.agency_id == agency.id) + + # 可选筛选条件 + if project_id: + query = query.where(Task.project_id == project_id) + + if start_date: + query = query.where(Task.created_at >= datetime.combine(start_date, datetime.min.time())) + + if end_date: + query = query.where(Task.created_at <= datetime.combine(end_date, datetime.max.time())) + + result = await db.execute(query) + tasks = result.scalars().all() + + # 构建 CSV 数据 + header = ["任务ID", "任务名称", "项目名称", "阶段", "达人名称", "代理商名称", "创建时间", "更新时间"] + rows = [] + for task in tasks: + rows.append([ + task.id, + task.name, + task.project.name if task.project else "", + _format_stage(task.stage), + task.creator.name if task.creator else "", + task.agency.name if task.agency else "", + _format_datetime(task.created_at), + _format_datetime(task.updated_at), + ]) + + # 生成文件名 + filename = f"tasks_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + + return StreamingResponse( + _iter_csv(header, rows), + media_type="text/csv", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + }, + ) + + +@router.get("/audit-logs") +async def export_audit_logs( + start_date: Optional[date] = Query(None, description="开始日期 (YYYY-MM-DD)"), + end_date: Optional[date] = Query(None, description="结束日期 (YYYY-MM-DD)"), + action: Optional[str] = Query(None, description="操作类型筛选 (如 login, create_project, review_task)"), + current_user: User = Depends(require_roles(UserRole.BRAND)), + db: AsyncSession = Depends(get_db), +): + """ + 导出审计日志为 CSV + + - 仅限品牌方角色 + - 支持按时间范围、操作类型筛选 + - 返回 CSV 文件流 + """ + # 验证品牌方身份 + result = await db.execute( + select(Brand).where(Brand.user_id == current_user.id) + ) + brand = result.scalar_one_or_none() + if not brand: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="品牌方信息不存在", + ) + + # 构建查询 + query = select(AuditLog).order_by(AuditLog.created_at.desc()) + + if start_date: + query = query.where(AuditLog.created_at >= datetime.combine(start_date, datetime.min.time())) + + if end_date: + query = query.where(AuditLog.created_at <= datetime.combine(end_date, datetime.max.time())) + + if action: + query = query.where(AuditLog.action == action) + + result = await db.execute(query) + logs = result.scalars().all() + + # 构建 CSV 数据 + header = ["日志ID", "操作类型", "资源类型", "资源ID", "操作用户", "用户角色", "详情", "IP地址", "操作时间"] + rows = [] + for log in logs: + rows.append([ + str(log.id), + log.action or "", + log.resource_type or "", + log.resource_id or "", + log.user_name or "", + log.user_role or "", + log.detail or "", + log.ip_address or "", + _format_datetime(log.created_at), + ]) + + # 生成文件名 + filename = f"audit_logs_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + + return StreamingResponse( + _iter_csv(header, rows), + media_type="text/csv", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + }, + ) diff --git a/backend/app/config.py b/backend/app/config.py index a633411..a9dd1e9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -10,6 +10,10 @@ class Settings(BaseSettings): APP_NAME: str = "秒思智能审核平台" APP_VERSION: str = "1.0.0" DEBUG: bool = False + ENVIRONMENT: str = "development" # development | staging | production + + # CORS(逗号分隔的允许来源列表) + CORS_ORIGINS: str = "http://localhost:3000" # 数据库 DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/miaosi" diff --git a/backend/app/main.py b/backend/app/main.py index 3ef1fcb..8a5495b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,32 +1,59 @@ """FastAPI 应用入口""" -from fastapi import FastAPI +from fastapi import FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.base import BaseHTTPMiddleware from app.config import settings from app.logging_config import setup_logging 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, export # Initialize logging logger = setup_logging() -# 创建应用 +# 环境判断 +_is_production = settings.ENVIRONMENT == "production" + +# 创建应用(生产环境禁用 API 文档) app = FastAPI( title=settings.APP_NAME, version=settings.APP_VERSION, description="AI 营销内容合规审核平台 API", - docs_url="/docs" if settings.DEBUG else None, - redoc_url="/redoc" if settings.DEBUG else None, + docs_url=None if _is_production else "/docs", + redoc_url=None if _is_production else "/redoc", ) -# CORS 配置 +# CORS 配置(从环境变量读取允许的来源) +_cors_origins = [ + origin.strip() + for origin in settings.CORS_ORIGINS.split(",") + if origin.strip() +] app.add_middleware( CORSMiddleware, - allow_origins=["*"] if settings.DEBUG else ["https://miaosi.ai"], + allow_origins=_cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) + +# Security headers middleware +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next) -> Response: + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + if _is_production: + response.headers["Strict-Transport-Security"] = ( + "max-age=63072000; includeSubDomains; preload" + ) + return response + + +app.add_middleware(SecurityHeadersMiddleware) + # Rate limiting app.add_middleware(RateLimitMiddleware, default_limit=60, window_seconds=60) @@ -44,6 +71,7 @@ app.include_router(projects.router, prefix="/api/v1") app.include_router(briefs.router, prefix="/api/v1") app.include_router(organizations.router, prefix="/api/v1") app.include_router(dashboard.router, prefix="/api/v1") +app.include_router(export.router, prefix="/api/v1") @app.on_event("startup") @@ -57,5 +85,5 @@ async def root(): return { "message": f"Welcome to {settings.APP_NAME}", "version": settings.APP_VERSION, - "docs": "/docs" if settings.DEBUG else "disabled", + "docs": "disabled" if _is_production else "/docs", } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e0dba71 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,81 @@ +version: "3.8" + +services: + # ---- PostgreSQL 数据库 ---- + postgres: + image: postgres:16-alpine + container_name: miaosi-postgres + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-miaosi} + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + + # ---- Redis 缓存 / 消息队列 ---- + redis: + image: redis:7-alpine + container_name: miaosi-redis + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + + # ---- FastAPI 后端 ---- + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: miaosi-backend + ports: + - "${BACKEND_PORT:-8000}:8000" + env_file: + - ./backend/.env + environment: + # 覆盖数据库和 Redis 地址,指向 Docker 内部网络 + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-miaosi} + REDIS_URL: redis://redis:6379/0 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - video_temp:/tmp/videos + restart: unless-stopped + + # ---- Next.js 前端 ---- + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8000} + NEXT_PUBLIC_USE_MOCK: "false" + container_name: miaosi-frontend + ports: + - "${FRONTEND_PORT:-3000}:3000" + environment: + NODE_ENV: production + depends_on: + - backend + restart: unless-stopped + +volumes: + postgres_data: + redis_data: + video_temp: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..a8cd81f --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,33 @@ +# Git +.git +.gitignore + +# Dependencies (will be installed in Docker) +node_modules + +# Build output +.next +out +dist + +# Testing +coverage +.vitest + +# IDE +.idea +.vscode +*.swp +*.swo + +# Environment +.env +.env.local +.env.*.local + +# Misc +*.log +*.tmp +README.md +Dockerfile +.dockerignore diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..5789b55 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,13 @@ +# =========================== +# 秒思智能审核平台 - 前端环境变量 +# =========================== +# 复制此文件为 .env.local 并填入实际值 +# cp .env.example .env.local + +# --- API 地址 --- +# 后端 API 基础 URL(浏览器端访问) +NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 + +# --- Mock 模式 --- +# 设为 true 使用前端 mock 数据(development 环境下默认开启) +NEXT_PUBLIC_USE_MOCK=false diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..123f5cb --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,64 @@ +# =========================== +# 秒思智能审核平台 - Frontend Dockerfile +# 多阶段构建,基于 node:20-alpine +# =========================== + +# ---------- Stage 1: 安装依赖 ---------- +FROM node:20-alpine AS deps + +WORKDIR /app + +# 复制依赖描述文件 +COPY package.json package-lock.json ./ + +# 安装生产依赖 +RUN npm ci --ignore-scripts + +# ---------- Stage 2: 构建应用 ---------- +FROM node:20-alpine AS builder + +WORKDIR /app + +# 从 deps 阶段复制 node_modules +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# 构建时环境变量(NEXT_PUBLIC_ 前缀的变量在构建时注入) +ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 +ARG NEXT_PUBLIC_USE_MOCK=false + +ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL +ENV NEXT_PUBLIC_USE_MOCK=$NEXT_PUBLIC_USE_MOCK + +# 启用 standalone 输出模式并构建 +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +# ---------- Stage 3: 运行时镜像 ---------- +FROM node:20-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +# 创建非 root 用户 +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +# 从 builder 阶段复制 standalone 产物 +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1 + +CMD ["node", "server.js"] diff --git a/frontend/next.config.js b/frontend/next.config.js index 235fc0f..2b4d8af 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -2,6 +2,9 @@ const nextConfig = { reactStrictMode: true, + // Docker 部署时使用 standalone 输出模式 + output: 'standalone', + // 环境变量 env: { API_BASE_URL: process.env.API_BASE_URL || 'http://localhost:8000',