Parcourir la source

feat: Docker 部署支持 + SSO 不强制跳转 + 管理后台缓存失效

- Docker Compose 一键部署(PostgreSQL/Redis/Backend/Nginx)
- 合并 Nginx 同时服务用户端和管理后台前端
- SSO 登录改为按钮选择,不再强制跳转
- 管理后台模型操作后自动清除 Redis 缓存
- OAuth 回调端点支持 HashRouter 场景
- 清理 SQL 初始化脚本(28 张表)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
mengboxin137-blip il y a 15 heures
Parent
commit
4ace39aa15

+ 29 - 0
.env

@@ -0,0 +1,29 @@
+# ============================================================
+# Maas-Model-platform 环境变量配置
+# ============================================================
+
+# ==================== 数据库配置 ====================
+DB_USER=postgres
+DB_PASSWORD=postgres123
+DB_NAME=aigcspace_test
+DB_PORT_EXTERNAL=5433
+
+# ==================== Redis 配置 ====================
+REDIS_PASSWORD=redis123
+REDIS_PORT_EXTERNAL=6380
+
+# ==================== 后端配置 ====================
+BACKEND_PORT=8010
+DEBUG=false
+
+# ==================== 前端配置 ====================
+NGINX_PORT=8088
+
+# ==================== SSO 统一认证配置 ====================
+SSO_BASE_URL=http://192.168.92.61:8200
+SSO_CLIENT_ID=0SkYt2YdRx5xZjuMO15eWn6v4Dpz9UVr
+SSO_CLIENT_SECRET=GA7lJ9VnSLChF9uyms9E1sa899X3Ht1dvgZ0eFARuFSM1xTIfsnlPToWBMY17j5T
+SSO_REDIRECT_URI=http://192.168.92.150/api/oauth/callback
+SSO_ADMIN_REDIRECT_URI=http://192.168.92.150/api/admin/oauth/callback
+SSO_SCOPE=profile email
+SSO_LOGOUT_REDIRECT_URL=http://192.168.92.61:9200/login

+ 24 - 0
Dockerfile

@@ -0,0 +1,24 @@
+# 合并构建用户端前端和管理后台前端
+# 阶段1: 构建用户端前端
+FROM node:18-alpine AS user-builder
+WORKDIR /app
+COPY frontend/package.json ./
+RUN npm install --registry https://registry.npmmirror.com
+COPY frontend/ .
+RUN npm run build
+
+# 阶段2: 构建管理后台前端
+FROM node:18-alpine AS admin-builder
+WORKDIR /app
+COPY admin-frontend/package.json ./
+RUN npm install --registry https://registry.npmmirror.com
+COPY admin-frontend/ .
+RUN npm run build
+
+# 阶段3: Nginx 运行
+FROM nginx:alpine
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+COPY --from=user-builder /app/dist /usr/share/nginx/user
+COPY --from=admin-builder /app/dist /usr/share/nginx/admin
+EXPOSE 80
+CMD ["nginx", "-g", "daemon off;"]

+ 30 - 0
admin-frontend/Dockerfile

@@ -0,0 +1,30 @@
+# 管理后台 Dockerfile - 多阶段构建
+# 阶段1: 构建
+FROM node:18-alpine AS builder
+
+WORKDIR /app
+
+# 复制 package.json
+COPY package.json ./
+
+# 安装依赖
+RUN npm install --registry https://registry.npmmirror.com
+
+# 复制源代码
+COPY . .
+
+# 构建
+RUN npm run build
+
+# 阶段2: 运行
+FROM nginx:alpine
+
+# 复制构建产物
+COPY --from=builder /app/dist /usr/share/nginx/html
+
+# 复制 nginx 配置
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+
+EXPOSE 80
+
+CMD ["nginx", "-g", "daemon off;"]

+ 27 - 0
admin-frontend/nginx.conf

@@ -0,0 +1,27 @@
+server {
+    listen 80;
+    server_name localhost;
+
+    root /usr/share/nginx/html;
+    index index.html;
+
+    # 前端路由支持
+    location / {
+        try_files $uri $uri/ /index.html;
+    }
+
+    # API 代理
+    location /api/ {
+        proxy_pass http://backend:8010;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+
+    # 健康检查代理
+    location /health {
+        proxy_pass http://backend:8010;
+        proxy_set_header Host $host;
+    }
+}

+ 27 - 0
backend/Dockerfile

@@ -0,0 +1,27 @@
+# 后端 Dockerfile
+FROM python:3.11-slim
+
+WORKDIR /app
+
+# 安装系统依赖
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    gcc \
+    libpq-dev \
+    && rm -rf /var/lib/apt/lists/*
+
+# 复制依赖文件
+COPY requirements.txt .
+
+# 安装 Python 依赖
+RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
+
+# 复制项目代码
+COPY . .
+
+# 创建日志目录
+RUN mkdir -p logs
+
+EXPOSE 8010
+
+# 使用 gunicorn 启动
+CMD ["gunicorn", "main:app", "-c", "gunicorn.conf.py"]

Fichier diff supprimé car celui-ci est trop grand
+ 6337 - 0
backend/aigcspace_clean.sql


+ 3 - 2
backend/app/core/async_database.py

@@ -35,8 +35,9 @@ DB_POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800"))  # 30分钟回收连
 # 慢查询阈值配置(毫秒)
 SLOW_QUERY_THRESHOLD_MS = int(os.getenv("SLOW_QUERY_THRESHOLD_MS", "1000"))
 
-# 构建异步数据库连接URL(使用 asyncpg 驱动)
-ASYNC_DATABASE_URL = f"postgresql+asyncpg://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
+# 构建异步数据库连接URL(使用 asyncpg 驱动,对密码进行URL编码)
+from urllib.parse import quote_plus
+ASYNC_DATABASE_URL = f"postgresql+asyncpg://{DB_USER}:{quote_plus(DB_PASSWORD)}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
 
 # 创建异步数据库引擎
 async_engine = create_async_engine(

+ 3 - 2
backend/app/database.py

@@ -31,8 +31,9 @@ DB_MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "10"))
 DB_POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "10"))   # 等待连接超时缩短到 10s,快速失败
 DB_POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800"))
 
-# 构建数据库连接URL
-DATABASE_URL = f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
+# 构建数据库连接URL(对密码进行URL编码,处理特殊字符如@)
+from urllib.parse import quote_plus
+DATABASE_URL = f"postgresql://{DB_USER}:{quote_plus(DB_PASSWORD)}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
 
 # 创建数据库引擎
 engine = create_engine(

+ 2 - 0
backend/app/middleware/rate_limit_middleware.py

@@ -23,6 +23,8 @@ EXCLUDED_PATHS = [
     "/docs",
     "/openapi.json",
     "/redoc",
+    "/api/sso/config",
+    "/api/auth/verify",
 ]
 
 

+ 36 - 5
backend/app/routers/admin_model_router.py

@@ -4,6 +4,7 @@
 提供模型列表、详情、创建、更新、价格设置等API端点
 Requirements: 7.1-7.4, 8.1-8.5, 9.1-9.4, 10.1-10.5, 11.1-11.7
 """
+import logging
 from typing import Optional
 
 from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
@@ -20,8 +21,33 @@ from app.services.operation_log_service import OperationLogService
 from app.dependencies.admin_auth import get_current_admin
 from app.models.admin import AdminUser
 
+logger = logging.getLogger(__name__)
+
 router = APIRouter(prefix="/api/admin/models", tags=["模型管理"])
 
+
+def _invalidate_model_caches():
+    """清除模型相关的所有 Redis 缓存"""
+    try:
+        from app.core.redis import redis_manager
+        r = redis_manager.get_sync_client()
+    except Exception:
+        return
+    if not r:
+        return
+    try:
+        for pattern in ("model_list:*", "model_featured:*", "model_pricing:*", "model_keywords"):
+            cursor = 0
+            while True:
+                cursor, keys = r.scan(cursor, match=pattern, count=100)
+                if keys:
+                    r.delete(*keys)
+                if cursor == 0:
+                    break
+    except Exception as e:
+        logger.debug(f"模型缓存清除失败: {e}")
+
+
 ERROR_MESSAGES = {
     "MODEL_NOT_FOUND": "模型不存在",
     "MODEL_TITLE_EXISTS": "模型标识已存在",
@@ -95,7 +121,8 @@ def create_model(
     
     try:
         model_id = service.create_model(data)
-        
+        _invalidate_model_caches()
+
         # 记录操作日志
         log_service = OperationLogService(db)
         log_service.create_log(
@@ -129,7 +156,8 @@ def update_model(
     
     try:
         service.update_model(model_id, data)
-        
+        _invalidate_model_caches()
+
         # 记录操作日志
         log_service = OperationLogService(db)
         log_service.create_log(
@@ -164,7 +192,8 @@ def update_model_price(
     
     try:
         service.update_model_price(model_id, data)
-        
+        _invalidate_model_caches()
+
         # 记录操作日志
         log_service = OperationLogService(db)
         log_service.create_log(
@@ -199,7 +228,8 @@ def update_model_status(
     
     try:
         service.update_model_status(model_id, data.field, data.value)
-        
+        _invalidate_model_caches()
+
         # 记录操作日志
         log_service = OperationLogService(db)
         log_service.create_log(
@@ -244,7 +274,8 @@ def batch_update_api_enabled(
         synchronize_session=False
     )
     db.commit()
-    
+    _invalidate_model_caches()
+
     # 记录操作日志
     log_service = OperationLogService(db)
     log_service.create_log(

+ 125 - 2
backend/app/routers/oauth_sso_router.py

@@ -4,7 +4,8 @@ import uuid
 import os
 
 import httpx
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import HTMLResponse
 from pydantic import BaseModel
 from sqlalchemy.orm import Session
 
@@ -37,6 +38,10 @@ def _get_sso_config():
         sso = data.get("sso", {})
     except Exception:
         sso = {}
+    # sso_enable_redirect 控制是否启用SSO重定向
+    enabled = sso.get("sso_enable_redirect")
+    if enabled is None:
+        enabled = True  # 默认启用SSO
     return {
         "base_url": (sso.get("sso_base_url") or SSO_BASE_URL).rstrip("/"),
         "client_id": sso.get("sso_client_id") or SSO_CLIENT_ID,
@@ -45,7 +50,7 @@ def _get_sso_config():
         "admin_redirect_uri": sso.get("sso_admin_redirect_uri") or SSO_ADMIN_REDIRECT_URI,
         "scope": sso.get("sso_scope") or SSO_SCOPE,
         "logout_url": sso.get("sso_logout_redirect_url") or SSO_LOGOUT_URL,
-        "enabled": bool(sso.get("sso_enabled", True)),
+        "enabled": bool(enabled),
     }
 
 
@@ -60,6 +65,7 @@ def get_sso_public_config():
         f"{cfg['base_url']}/oauth/authorize"
         f"?response_type=code&client_id={cfg['client_id']}"
         f"&redirect_uri={cfg['redirect_uri']}&scope={cfg['scope']}"
+        f"&state=/"
     )
     return {
         "sso_enabled": cfg["enabled"] and bool(cfg["client_id"]),
@@ -156,6 +162,122 @@ async def exchange_code(req: CodeRequest, db: Session = Depends(get_db)):
     }
 
 
+# ============ OAuth 回调(处理 SSO 服务器的 code 回传) ============
+
+async def _exchange_code_and_build_html(code: str, redirect_path: str, is_admin: bool = False) -> str:
+    """用 code 换 local JWT,返回自动登录并跳转的 HTML 页面。"""
+    from app.database import SessionLocal
+    from app.models.user import User
+    from app.models.admin import AdminUser
+    from app.services.user_service import UserService
+    from app.services.auth_service import AuthService
+    from app.services.admin_auth_service import AdminAuthService
+    from app.schemas.user_schema import UserCreate
+
+    cfg = _get_sso_config()
+
+    async with httpx.AsyncClient(timeout=15) as client:
+        try:
+            resp = await client.post(
+                f"{cfg['base_url']}/oauth/token",
+                data={
+                    "grant_type": "authorization_code",
+                    "code": code,
+                    "redirect_uri": cfg["admin_redirect_uri"] if is_admin else cfg["redirect_uri"],
+                    "client_id": cfg["client_id"],
+                    "client_secret": cfg["client_secret"],
+                },
+                headers={"Content-Type": "application/x-www-form-urlencoded"},
+            )
+        except httpx.RequestError:
+            return "<html><body><script>alert('Cannot connect to SSO');window.location='/login';</script></body></html>"
+
+        if resp.status_code != 200:
+            try:
+                err = resp.json()
+            except Exception:
+                err = {}
+            msg = err.get("error_description") or "SSO login failed"
+            import html as _html
+            return f"<html><body><script>alert('{_html.escape(msg)}');window.location='/login';</script></body></html>"
+
+        access_token = resp.json().get("access_token")
+        if not access_token:
+            return "<html><body><script>alert('No access_token from SSO');window.location='/login';</script></body></html>"
+
+        try:
+            info_resp = await client.get(
+                f"{cfg['base_url']}/oauth/userinfo",
+                headers={"Authorization": f"Bearer {access_token}"},
+            )
+        except httpx.RequestError:
+            return "<html><body><script>alert('Failed to get userinfo');window.location='/login';</script></body></html>"
+
+        if info_resp.status_code != 200:
+            return "<html><body><script>alert('Failed to get userinfo');window.location='/login';</script></body></html>"
+
+        userinfo = info_resp.json()
+
+    username = userinfo.get("username") or userinfo.get("sub") or ""
+    if not username:
+        return "<html><body><script>alert('No username from SSO');window.location='/login';</script></body></html>"
+
+    db = SessionLocal()
+    try:
+        if is_admin:
+            admin = db.query(AdminUser).filter(AdminUser.username == username).first()
+            if not admin:
+                admin = AdminUser(
+                    username=username,
+                    password_hash=AdminAuthService.hash_password(uuid.uuid4().hex[:16]),
+                    nickname=userinfo.get("real_name") or username,
+                    status="active",
+                )
+                db.add(admin)
+                db.commit()
+                db.refresh(admin)
+            local_token = AdminAuthService.create_token(admin.id, admin.username)
+        else:
+            user = db.query(User).filter(User.username == username).first()
+            if not user:
+                payload = {"username": username, "password": uuid.uuid4().hex[:16], "nickname": userinfo.get("real_name") or username}
+                if userinfo.get("email"):
+                    payload["email"] = userinfo["email"]
+                user = UserService(db).create_user(UserCreate(**payload))
+            elif userinfo.get("email") and user.email != userinfo["email"]:
+                user.email = userinfo["email"]
+                db.commit()
+            local_token = AuthService.create_access_token(user.id)
+    finally:
+        db.close()
+
+    import html as _html
+    safe_redirect = _html.escape(redirect_path or "/")
+    return (
+        "<!DOCTYPE html><html><head><title>登录中...</title></head><body>"
+        "<script>"
+        f"localStorage.setItem('aigc_space_token','{local_token}');"
+        f"window.location.replace('{safe_redirect}');"
+        "</script>"
+        "<p>正在登录,请稍候...</p>"
+        "</body></html>"
+    )
+
+
+@router.get("/api/oauth/callback")
+async def oauth_user_callback(code: str = Query(...), state: str = Query(default="/")):
+    """用户端 OAuth 回调:SSO 服务器 redirect 到此地址,后端换 token 后返回自动登录 HTML。"""
+    html_content = await _exchange_code_and_build_html(code, state, is_admin=False)
+    return HTMLResponse(content=html_content)
+
+
+@router.get("/api/admin/oauth/callback")
+async def oauth_admin_callback(code: str = Query(...), state: str = Query(default="/admin/")):
+    """管理后台 OAuth 回调:同上,但签发管理员 token。"""
+    html_content = await _exchange_code_and_build_html(code, state, is_admin=True)
+    return HTMLResponse(content=html_content)
+
+
 # ============ 管理员 SSO 接口 ============
 
 @router.get("/api/admin/sso/config")
@@ -166,6 +288,7 @@ def get_admin_sso_config():
         f"{cfg['base_url']}/oauth/authorize"
         f"?response_type=code&client_id={cfg['client_id']}"
         f"&redirect_uri={cfg['admin_redirect_uri']}&scope={cfg['scope']}"
+        f"&state=/admin/"
     )
     return {
         "sso_enabled": cfg["enabled"] and bool(cfg["client_id"]),

+ 5 - 1
backend/app/routers/sso_router.py

@@ -36,6 +36,10 @@ def _get_sso_config() -> dict:
         sso = data.get("sso", {})
     except Exception:
         sso = {}
+    # sso_enable_redirect 控制是否启用SSO重定向
+    enabled = sso.get("sso_enable_redirect")
+    if enabled is None:
+        enabled = True  # 默认启用SSO
     return {
         "base_url": (sso.get("sso_base_url") or SSO_BASE_URL).rstrip("/"),
         "client_id": sso.get("sso_client_id") or SSO_CLIENT_ID,
@@ -43,7 +47,7 @@ def _get_sso_config() -> dict:
         "redirect_uri": sso.get("sso_redirect_uri") or SSO_REDIRECT_URI,
         "scope": sso.get("sso_scope") or SSO_SCOPE,
         "logout_redirect_url": sso.get("sso_logout_redirect_url") or SSO_LOGOUT_REDIRECT_URL,
-        "enabled": bool(sso.get("sso_enabled", True)),
+        "enabled": bool(enabled),
     }
 
 

+ 90 - 0
docker-compose.yml

@@ -0,0 +1,90 @@
+services:
+  # PostgreSQL 数据库
+  postgres:
+    image: postgres:15-alpine
+    container_name: maas-postgres
+    restart: unless-stopped
+    environment:
+      POSTGRES_USER: ${DB_USER:-postgres}
+      POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres123}
+      POSTGRES_DB: ${DB_NAME:-aigcspace_test}
+    ports:
+      - "${DB_PORT_EXTERNAL:-5433}:5432"
+    volumes:
+      - postgres_data:/var/lib/postgresql/data
+      - ./backend/aigcspace_clean.sql:/docker-entrypoint-initdb.d/init.sql
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"]
+      interval: 10s
+      timeout: 5s
+      retries: 5
+
+  # Redis 缓存
+  redis:
+    image: redis:7-alpine
+    container_name: maas-redis
+    restart: unless-stopped
+    command: redis-server --requirepass ${REDIS_PASSWORD:-redis123}
+    ports:
+      - "${REDIS_PORT_EXTERNAL:-6380}:6379"
+    volumes:
+      - redis_data:/data
+    healthcheck:
+      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redis123}", "ping"]
+      interval: 10s
+      timeout: 5s
+      retries: 5
+
+  # 后端服务
+  backend:
+    build:
+      context: ./backend
+      dockerfile: Dockerfile
+    container_name: maas-backend
+    restart: unless-stopped
+    ports:
+      - "${BACKEND_PORT:-8010}:8010"
+    environment:
+      - DB_HOST=postgres
+      - DB_PORT=5432
+      - DB_USER=${DB_USER:-postgres}
+      - DB_PASSWORD=${DB_PASSWORD:-postgres123}
+      - DB_NAME=${DB_NAME:-aigcspace_test}
+      - REDIS_URL=redis://redis:6379
+      - REDIS_PASSWORD=${REDIS_PASSWORD:-redis123}
+      - APP_HOST=0.0.0.0
+      - APP_PORT=8010
+      - DEBUG=${DEBUG:-false}
+      # SSO 配置
+      - SSO_BASE_URL=${SSO_BASE_URL:-http://192.168.92.61:8200}
+      - SSO_CLIENT_ID=${SSO_CLIENT_ID:-0SkYt2YdRx5xZjuMO15eWn6v4Dpz9UVr}
+      - SSO_CLIENT_SECRET=${SSO_CLIENT_SECRET:-GA7lJ9VnSLChF9uyms9E1sa899X3Ht1dvgZ0eFARuFSM1xTIfsnlPToWBMY17j5T}
+      - SSO_REDIRECT_URI=${SSO_REDIRECT_URI:-http://192.168.92.150/api/oauth/callback}
+      - SSO_ADMIN_REDIRECT_URI=${SSO_ADMIN_REDIRECT_URI:-http://192.168.92.150/api/admin/oauth/callback}
+      - SSO_SCOPE=${SSO_SCOPE:-profile email}
+      - SSO_LOGOUT_REDIRECT_URL=${SSO_LOGOUT_REDIRECT_URL:-http://192.168.92.61:9200/login}
+    depends_on:
+      postgres:
+        condition: service_healthy
+      redis:
+        condition: service_healthy
+    volumes:
+      - backend_logs:/app/logs
+      - ./backend/data:/app/data
+
+  # Nginx 同时服务用户端前端和管理后台
+  nginx:
+    build:
+      context: .
+      dockerfile: Dockerfile
+    container_name: maas-nginx
+    restart: unless-stopped
+    ports:
+      - "${NGINX_PORT:-80}:80"
+    depends_on:
+      - backend
+
+volumes:
+  postgres_data:
+  redis_data:
+  backend_logs:

+ 1 - 33
frontend/App.tsx

@@ -34,55 +34,28 @@ const pagePathMap: Record<PageId, string> = {
 // 公开路由(不需要登录)
 const publicRoutes = ['/login', '/register', '/sso-callback', '/auth/callback', '/user-agreement', '/privacy-policy'];
 
-const CasLoginRedirect: React.FC<{ targetPath: string }> = ({ targetPath }) => {
-  useEffect(() => {
-    authService.startCasLogin(targetPath || '/');
-  }, [targetPath]);
-
-  return (
-    <div className="min-h-screen bg-gray-50 flex items-center justify-center">
-      <div className="flex flex-col items-center gap-4">
-        <Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
-        <span className="text-gray-600">正在跳转统一认证登录...</span>
-      </div>
-    </div>
-  );
-};
-
 // 认证状态检查组件
 const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
   const location = useLocation();
   const [isChecking, setIsChecking] = useState(true);
   const [isAuthenticated, setIsAuthenticated] = useState(false);
-  const [ssoEnabled, setSsoEnabled] = useState<boolean | null>(null);
 
   useEffect(() => {
     const checkAuth = async () => {
-      // 如果是公开路由,不需要检查
       if (publicRoutes.includes(location.pathname)) {
         setIsChecking(false);
         return;
       }
 
-      // 检查本地是否有Token
       if (!authService.isAuthenticated()) {
-        // 没有Token,查询 SSO 是否启用再决定跳哪
-        const enabled = await authService.isSsoEnabled();
-        setSsoEnabled(enabled);
         setIsAuthenticated(false);
         setIsChecking(false);
         return;
       }
 
-      // 有Token,验证并刷新
       try {
         const isValid = await authService.checkAndRefreshToken();
         setIsAuthenticated(isValid);
-        if (!isValid) {
-          const enabled = await authService.isSsoEnabled();
-          setSsoEnabled(enabled);
-          console.log('[AuthGuard] Token invalid, redirecting to login');
-        }
       } catch (error) {
         console.error('[AuthGuard] Auth check failed:', error);
         setIsAuthenticated(false);
@@ -116,13 +89,8 @@ const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
     );
   }
 
-  // 未登录且不是公开路由
+  // 未登录且不是公开路由 → 一律跳登录页(登录页上有 SSO 按钮供用户选择)
   if (!isAuthenticated && !publicRoutes.includes(location.pathname)) {
-    const targetPath = `${location.pathname}${location.search || ''}`;
-    // SSO 启用时跳 CAS,否则跳普通登录页
-    if (ssoEnabled) {
-      return <CasLoginRedirect targetPath={targetPath} />;
-    }
     return <Navigate to="/login" state={{ from: location }} replace />;
   }
 

+ 30 - 0
frontend/Dockerfile

@@ -0,0 +1,30 @@
+# 前端 Dockerfile - 多阶段构建
+# 阶段1: 构建
+FROM node:18-alpine AS builder
+
+WORKDIR /app
+
+# 复制 package.json
+COPY package.json ./
+
+# 安装依赖
+RUN npm install --registry https://registry.npmmirror.com
+
+# 复制源代码
+COPY . .
+
+# 构建
+RUN npm run build
+
+# 阶段2: 运行
+FROM nginx:alpine
+
+# 复制构建产物
+COPY --from=builder /app/dist /usr/share/nginx/html
+
+# 复制 nginx 配置
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+
+EXPOSE 80
+
+CMD ["nginx", "-g", "daemon off;"]

+ 1 - 13
frontend/components/Navbar.tsx

@@ -1,7 +1,7 @@
 
 import React, { useState, useEffect, useCallback, useRef } from 'react';
 import { useNavigate } from 'react-router-dom';
-import { LogIn, User, LogOut, ChevronDown, BookOpen, ShieldCheck, ShieldAlert } from '../icons/commonIcons';
+import { LogIn, User, LogOut, ChevronDown, ShieldCheck, ShieldAlert } from '../icons/commonIcons';
 import { Lightbulb } from '../icons/commonIcons';
 import { authService, UserInfo } from '../services/authService';
 import { BrandingContext } from '../App';
@@ -58,11 +58,6 @@ const Navbar: React.FC = () => {
     navigate('/profile');
   };
 
-  const handleMyClasses = () => {
-    setShowDropdown(false);
-    navigate('/my-classes');
-  };
-
   const handleLogout = async () => {
     setShowDropdown(false);
 
@@ -171,13 +166,6 @@ const Navbar: React.FC = () => {
                     <User className="w-4 h-4" />
                     <span>个人中心</span>
                   </button>
-                  <button
-                    onClick={handleMyClasses}
-                    className="w-full flex items-center space-x-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 hover:text-blue-600 transition-colors"
-                  >
-                    <BookOpen className="w-4 h-4" />
-                    <span>我的班级</span>
-                  </button>
                   <button
                     onClick={handleLogout}
                     className="w-full flex items-center space-x-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 hover:text-red-600 transition-colors"

+ 27 - 0
frontend/nginx.conf

@@ -0,0 +1,27 @@
+server {
+    listen 80;
+    server_name localhost;
+
+    root /usr/share/nginx/html;
+    index index.html;
+
+    # 前端路由支持
+    location / {
+        try_files $uri $uri/ /index.html;
+    }
+
+    # API 代理
+    location /api/ {
+        proxy_pass http://backend:8010;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+
+    # 健康检查代理
+    location /health {
+        proxy_pass http://backend:8010;
+        proxy_set_header Host $host;
+    }
+}

+ 0 - 30
frontend/services/authService.ts

@@ -70,8 +70,6 @@ const TOKEN_KEY = 'aigc_space_token';
 const REFRESH_TOKEN_KEY = 'aigc_space_refresh_token';
 const USER_INFO_KEY = 'aigc_space_user_info';
 const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8010';
-const SSO_LOGIN_URL = import.meta.env.VITE_SSO_LOGIN_URL || 'https://aigc.cdut.edu.cn/sso/login';
-const SSO_LOGOUT_URL = import.meta.env.VITE_SSO_LOGOUT_URL || 'https://aigc.cdut.edu.cn/sso/logout';
 
 // 认证状态变化事件名
 const AUTH_CHANGE_EVENT = 'auth_state_change';
@@ -238,34 +236,6 @@ class AuthService {
     }
   }
 
-  /**
-   * 获取CAS统一认证登录地址(使用后端动态配置)
-   */
-  getSsoLoginUrl(targetPath?: string, casLoginUrl?: string): string {
-    const rawTarget = targetPath && targetPath.trim() !== '' ? targetPath : '/';
-    const normalizedTarget =
-      typeof window !== 'undefined' && rawTarget.startsWith('/') && !rawTarget.startsWith('//')
-        ? `${window.location.origin}${rawTarget}`
-        : rawTarget;
-
-    // Backend callback URL that CAS should call back to (service)
-    const backendCallback = `${window.location.origin}/sso/login?targetUrl=${encodeURIComponent(normalizedTarget)}`;
-    const serviceParam = new URLSearchParams({ service: backendCallback });
-    const loginUrl = casLoginUrl || SSO_LOGIN_URL;
-    return `${loginUrl}?${serviceParam.toString()}`;
-  }
-
-  /**
-   * 跳转到CAS统一认证登录(动态读取后端配置)
-   */
-  async startCasLogin(targetPath?: string): Promise<void> {
-    const cfg = await this.getSsoConfig();
-    const casLoginUrl = cfg?.cas_login_url || SSO_LOGIN_URL;
-    if (typeof window !== 'undefined') {
-      window.location.href = this.getSsoLoginUrl(targetPath, casLoginUrl);
-    }
-  }
-
   /**
    * 查询后端 SSO 配置(含是否启用、CAS 登录地址等)
    */

+ 33 - 0
nginx.conf

@@ -0,0 +1,33 @@
+server {
+    listen 80;
+    server_name _;
+
+    # 用户端前端
+    location / {
+        root /usr/share/nginx/user;
+        index index.html;
+        try_files $uri $uri/ /index.html;
+    }
+
+    # 管理后台前端
+    location /admin/ {
+        alias /usr/share/nginx/admin/;
+        try_files $uri $uri/ /admin/index.html;
+        add_header Cache-Control "no-cache, no-store, must-revalidate";
+    }
+
+    # 后端 API(含 OAuth 回调)
+    location /api/ {
+        proxy_pass http://backend:8010;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+
+    # 健康检查
+    location /health {
+        proxy_pass http://backend:8010;
+        proxy_set_header Host $host;
+    }
+}

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff