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:
parent
e0bd3f2911
commit
86a7865808
@ -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
|
||||
|
||||
@ -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"]
|
||||
|
||||
234
backend/app/api/export.py
Normal file
234
backend/app/api/export.py
Normal 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}"',
|
||||
},
|
||||
)
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
81
docker-compose.yml
Normal file
81
docker-compose.yml
Normal 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
33
frontend/.dockerignore
Normal 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
13
frontend/.env.example
Normal 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
64
frontend/Dockerfile
Normal 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"]
|
||||
@ -2,6 +2,9 @@
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
|
||||
// Docker 部署时使用 standalone 输出模式
|
||||
output: 'standalone',
|
||||
|
||||
// 环境变量
|
||||
env: {
|
||||
API_BASE_URL: process.env.API_BASE_URL || 'http://localhost:8000',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user