feat: 添加 Docker 部署配置 + 安全加固 + 数据导出 API

- 新增 backend/Dockerfile + frontend/Dockerfile (多阶段构建)
- 新增 docker-compose.yml (postgres + redis + backend + frontend)
- 新增 .env.example 模板 (前后端)
- 新增 export API: 任务数据导出 + 审计日志导出 (CSV + 流式响应)
- 安全加固: CORS 从环境变量配置, 安全 headers 中间件
- 生产环境自动禁用 API 文档 (Swagger/Redoc)
- 添加 ENVIRONMENT, CORS_ORIGINS 配置项
- 前端启用 Next.js standalone 输出模式

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-02-09 17:43:28 +08:00
parent e0bd3f2911
commit 86a7865808
10 changed files with 538 additions and 31 deletions

View File

@ -1,21 +1,42 @@
# 应用配置 # ===========================
# 秒思智能审核平台 - 后端环境变量
# ===========================
# 复制此文件为 .env 并填入实际值
# cp .env.example .env
# --- 应用 ---
APP_NAME=秒思智能审核平台 APP_NAME=秒思智能审核平台
APP_VERSION=1.0.0 APP_VERSION=1.0.0
DEBUG=false DEBUG=false
# 数据库 # --- 数据库 ---
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/miaosi DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/miaosi
# Redis # --- Redis ---
REDIS_URL=redis://localhost:6379/0 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 SECRET_KEY=your-secret-key-change-in-production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# AI 配置 (可选,也可通过 API 配置) # --- AI 服务 (中转服务商) ---
AI_PROVIDER=doubao AI_PROVIDER=oneapi
AI_API_KEY= AI_API_KEY=
AI_API_BASE_URL= AI_API_BASE_URL=
# 加密密钥 (生产环境必须更换,用于加密 API Key) # --- 阿里云 OSS ---
ENCRYPTION_KEY=your-32-byte-encryption-key-here 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

View File

@ -1,30 +1,56 @@
# 基础镜像 # ===========================
FROM python:3.11-slim # 秒思智能审核平台 - Backend Dockerfile
# 多阶段构建,基于 python:3.13-slim
# ===========================
# 设置工作目录 # ---------- Stage 1: 构建依赖 ----------
WORKDIR /app FROM python:3.13-slim AS builder
# 安装系统依赖 (FFmpeg 用于视频处理) WORKDIR /build
# 安装编译依赖
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
libpq-dev \
gcc \ gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# 复制依赖文件 # 复制依赖描述文件
COPY pyproject.toml . COPY pyproject.toml .
# 安装 Python 依赖 # 安装 Python 依赖到 /build/deps
RUN pip install --no-cache-dir -e . 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 .
# 创建临时目录 # 创建非 root 用户
RUN mkdir -p /tmp/videos 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 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"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

234
backend/app/api/export.py Normal file
View File

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

View File

@ -10,6 +10,10 @@ class Settings(BaseSettings):
APP_NAME: str = "秒思智能审核平台" APP_NAME: str = "秒思智能审核平台"
APP_VERSION: str = "1.0.0" APP_VERSION: str = "1.0.0"
DEBUG: bool = False 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" DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/miaosi"

View File

@ -1,32 +1,59 @@
"""FastAPI 应用入口""" """FastAPI 应用入口"""
from fastapi import FastAPI from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from app.config import settings from app.config import settings
from app.logging_config import setup_logging from app.logging_config import setup_logging
from app.middleware.rate_limit import RateLimitMiddleware 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 # Initialize logging
logger = setup_logging() logger = setup_logging()
# 创建应用 # 环境判断
_is_production = settings.ENVIRONMENT == "production"
# 创建应用(生产环境禁用 API 文档)
app = FastAPI( app = FastAPI(
title=settings.APP_NAME, title=settings.APP_NAME,
version=settings.APP_VERSION, version=settings.APP_VERSION,
description="AI 营销内容合规审核平台 API", description="AI 营销内容合规审核平台 API",
docs_url="/docs" if settings.DEBUG else None, docs_url=None if _is_production else "/docs",
redoc_url="/redoc" if settings.DEBUG else None, 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"] if settings.DEBUG else ["https://miaosi.ai"], allow_origins=_cors_origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], 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 # Rate limiting
app.add_middleware(RateLimitMiddleware, default_limit=60, window_seconds=60) 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(briefs.router, prefix="/api/v1")
app.include_router(organizations.router, prefix="/api/v1") app.include_router(organizations.router, prefix="/api/v1")
app.include_router(dashboard.router, prefix="/api/v1") app.include_router(dashboard.router, prefix="/api/v1")
app.include_router(export.router, prefix="/api/v1")
@app.on_event("startup") @app.on_event("startup")
@ -57,5 +85,5 @@ async def root():
return { return {
"message": f"Welcome to {settings.APP_NAME}", "message": f"Welcome to {settings.APP_NAME}",
"version": settings.APP_VERSION, "version": settings.APP_VERSION,
"docs": "/docs" if settings.DEBUG else "disabled", "docs": "disabled" if _is_production else "/docs",
} }

81
docker-compose.yml Normal file
View File

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

33
frontend/.dockerignore Normal file
View File

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

13
frontend/.env.example Normal file
View File

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

64
frontend/Dockerfile Normal file
View File

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

View File

@ -2,6 +2,9 @@
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
// Docker 部署时使用 standalone 输出模式
output: 'standalone',
// 环境变量 // 环境变量
env: { env: {
API_BASE_URL: process.env.API_BASE_URL || 'http://localhost:8000', API_BASE_URL: process.env.API_BASE_URL || 'http://localhost:8000',