lxylxy123321 1 неделя назад
Родитель
Сommit
4430f45f69

+ 6 - 6
DEPLOY.md

@@ -3,7 +3,7 @@
 ## 架构
 
 ```
-151 (主节点)                      253 (算力节点)
+151 (主节点)                      46 (算力节点)
 ├── PostgreSQL                    ├── SSH 服务
 ├── 后端 API (8010)               ├── NFS 挂载 151:/root/Fine-tuning/backend/data
 ├── 前端 (3000)                   ├── Python 训练环境 (transformers/peft/torch)
@@ -18,17 +18,17 @@ git pull
 
 # 编辑 .env.docker 或 docker-compose.yml 设置算力节点
 vi backend/.env.docker
-# 修改 COMPUTE_NODE_HOST=192.168.91.253
+# 修改 COMPUTE_NODE_HOST=183.220.37.46
 
 # 配置 SSH 免密登录到 253
-ssh-copy-id root@192.168.91.253
+ssh-copy-id root@183.220.37.46
 
 # 启动
 docker compose down -v
 docker compose up -d --build
 ```
 
-## 253 配置步骤
+## 46 配置步骤
 
 ### 1. 安装 NFS 客户端
 
@@ -52,7 +52,7 @@ mount -t nfs 192.168.92.151:/root/Fine-tuning/backend/data /root/Fine-tuning/bac
 ### 3. 安装 Python 训练依赖
 
 ```bash
-# 在 253 上执行(确保代码路径一致)
+# 在 46 上执行(确保代码路径一致)
 cd /root/Fine-tuning/backend
 pip install -r requirements.txt
 ```
@@ -81,7 +81,7 @@ exportfs -ra
 
 ```bash
 # 在 151 上测试 SSH 连接
-ssh root@192.168.91.253 "python --version"
+ssh root@183.220.37.46 "python --version"
 
 # 测试远程命令
 docker exec finetune-backend python -c "from app.config import get_settings; s=get_settings(); print(s.use_remote_compute)"

+ 4 - 4
README.md

@@ -90,7 +90,7 @@ npm run dev
 |------|------|--------|
 | BACKEND_HOST | 服务地址 | `0.0.0.0` |
 | BACKEND_PORT | 服务端口 | `8000` |
-| BACKEND_CORS_ORIGINS | 允许的跨域来源 | `http://192.168.91.253:5173` |
+| BACKEND_CORS_ORIGINS | 允许的跨域来源 | `http://183.220.37.46:5173` |
 | DATA_DIR | 数据根目录 | `/root/Fine-tuning/backend/data` |
 | DATABASE_URL | 数据库连接串 | `sqlite+aiosqlite:///root/Fine-tuning/backend/data/finetuning.db` |
 | DEFAULT_PEFT_METHOD | 默认微调方法 | `lora` |
@@ -101,9 +101,9 @@ npm run dev
 
 | 变量 | 说明 | 默认值 |
 |------|------|--------|
-| VITE_API_BASE_URL | 后端 API 地址 | `http://192.168.91.253:8000/api/v1` |
-| VITE_WS_BASE_URL | WebSocket 地址 | `ws://192.168.91.253:8000/ws` |
+| VITE_API_BASE_URL | 后端 API 地址 | `http://183.220.37.46:8000/api/v1` |
+| VITE_WS_BASE_URL | WebSocket 地址 | `ws://183.220.37.46:8000/ws` |
 
 ## API 文档
 
-启动后端后访问:`http://192.168.91.253:8000/docs`
+启动后端后访问:`http://183.220.37.46:8000/docs`

+ 1 - 1
backend/.env

@@ -7,7 +7,7 @@ BACKEND_HOST=0.0.0.0
 BACKEND_PORT=8010
 BACKEND_ENV=production
 BACKEND_LOG_LEVEL=INFO
-BACKEND_CORS_ORIGINS=http://192.168.91.253:5173
+BACKEND_CORS_ORIGINS=http://183.220.37.46:5173
 
 # 数据库
 DATABASE_URL=postgresql+asyncpg://finetune:finetune123@localhost:5432/finetuning

+ 13 - 1
backend/.env.example

@@ -7,7 +7,7 @@ BACKEND_HOST=0.0.0.0
 BACKEND_PORT=8000
 BACKEND_ENV=production
 BACKEND_LOG_LEVEL=INFO
-BACKEND_CORS_ORIGINS=http://192.168.91.253:5173
+BACKEND_CORS_ORIGINS=http://183.220.37.46:5173
 
 # 数据库
 DATABASE_URL=postgresql+asyncpg://finetune:finetune123@localhost:5432/finetuning
@@ -46,3 +46,15 @@ QLORA_DOUBLE_QUANT=true
 # 上传限制
 MAX_UPLOAD_SIZE_MB=500
 ALLOWED_DATASET_FORMATS=jsonl,csv,parquet,json
+
+# --- SSO 统一认证 ---
+SSO_BASE_URL=http://192.168.92.61:8200
+SSO_CLIENT_ID=WviiGL8KQE20tQhmhQPQhhJ5QpFK51F6
+SSO_CLIENT_SECRET=9WXP88hEHJiHRSiUdmx7ip5oQPzY0bnJNsEswQoO4sk6juCplyJTcnAiZsv7e3lJ
+SSO_REDIRECT_URI=http://localhost:3000/auth/callback
+SSO_FRONTEND_URL=http://localhost:3000
+SSO_SCOPE=email
+SSO_LOGOUT_REDIRECT_URL=http://192.168.92.61:9200/login
+JWT_SECRET_KEY=change-me-in-production-use-a-long-random-string
+JWT_ACCESS_EXPIRE_MINUTES=20
+JWT_REFRESH_EXPIRE_HOURS=24

+ 215 - 0
backend/app/api/auth.py

@@ -0,0 +1,215 @@
+import uuid
+from datetime import datetime, timedelta, timezone
+from urllib.parse import urlencode
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import RedirectResponse
+from pydantic import BaseModel
+from sqlalchemy import select
+
+from app.config import get_settings
+from app.core.auth import get_current_user
+from app.core.db import RefreshTokenModel, UserModel, async_session
+from app.core.security import create_access_token, create_refresh_token
+from app.core.sso_client import exchange_code_for_token, fetch_sso_userinfo
+
+router = APIRouter()
+settings = get_settings()
+
+
+class CodeExchangeRequest(BaseModel):
+    code: str
+
+
+class RefreshRequest(BaseModel):
+    refresh_token: str
+
+
+class LogoutRequest(BaseModel):
+    token: str
+    refresh_token: str
+
+
+async def _sync_user(sso_info: dict) -> UserModel:
+    username = sso_info.get("username", sso_info.get("sub", "unknown"))
+    role_codes = [r.get("code", "") for r in sso_info.get("roles", [])]
+
+    async with async_session() as session:
+        result = await session.execute(select(UserModel).where(UserModel.username == username))
+        user = result.scalar_one_or_none()
+
+        if not user:
+            user = UserModel(
+                id=str(uuid.uuid4()),
+                username=username,
+                email=sso_info.get("email"),
+                real_name=sso_info.get("real_name"),
+                avatar_url=sso_info.get("avatar_url"),
+                company=sso_info.get("company"),
+                department=sso_info.get("department"),
+                position=sso_info.get("position"),
+                roles=role_codes,
+                is_active=1,
+            )
+            session.add(user)
+        else:
+            user.roles = role_codes
+            user.email = sso_info.get("email", user.email)
+            user.updated_at = datetime.now(timezone.utc)
+
+        await session.commit()
+        await session.refresh(user)
+        return user
+
+
+@router.post("/api/oauth/exchange-code")
+async def exchange_code(req: CodeExchangeRequest):
+    if not req.code:
+        return {"code": "100001", "message": "缺少授权码", "data": None}
+
+    try:
+        token_resp = await exchange_code_for_token(req.code)
+        sso_access_token = token_resp.get("access_token")
+        if not sso_access_token:
+            raise HTTPException(status_code=500, detail="登录失败: 获取令牌失败")
+
+        sso_userinfo = await fetch_sso_userinfo(sso_access_token)
+        if not sso_userinfo.get("username") and not sso_userinfo.get("sub"):
+            raise HTTPException(status_code=500, detail="登录失败: 用户信息格式异常")
+
+        user = await _sync_user(sso_userinfo)
+
+        local_token = create_access_token(
+            user_id=user.id, username=user.username, roles=user.roles or [],
+        )
+        refresh_token_str = create_refresh_token()
+        expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.jwt_refresh_expire_hours)
+
+        async with async_session() as session:
+            rt = RefreshTokenModel(
+                id=str(uuid.uuid4()),
+                user_id=user.id,
+                token=refresh_token_str,
+                expires_at=expires_at,
+            )
+            session.add(rt)
+            await session.commit()
+
+        return {
+            "code": "000000",
+            "message": "登录成功",
+            "data": {
+                "token": local_token,
+                "refresh_token": refresh_token_str,
+                "user": {
+                    "id": user.id,
+                    "username": user.username,
+                    "email": user.email,
+                    "phone": None,
+                    "is_superuser": bool(user.is_superuser),
+                    "is_active": bool(user.is_active),
+                    "roles": user.roles,
+                },
+            },
+        }
+    except HTTPException:
+        raise
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"登录失败: {str(e)}")
+
+
+@router.get("/auth/sso/authorize")
+async def sso_authorize(redirect: bool = Query(False)):
+    params = urlencode({
+        "response_type": "code",
+        "client_id": settings.sso_client_id,
+        "redirect_uri": settings.sso_redirect_uri,
+        "scope": settings.sso_scope,
+    })
+    authorize_url = f"{settings.sso_base_url}/oauth/authorize?{params}"
+    if redirect:
+        return RedirectResponse(url=authorize_url)
+    return {"code": "000000", "message": "获取授权URL成功", "data": {"authorize_url": authorize_url}}
+
+
+@router.post("/api/v1/auth/refresh")
+async def refresh_token_endpoint(req: RefreshRequest):
+    async with async_session() as session:
+        result = await session.execute(
+            select(RefreshTokenModel).where(
+                RefreshTokenModel.token == req.refresh_token,
+                RefreshTokenModel.revoked == 0,
+                RefreshTokenModel.expires_at > datetime.now(timezone.utc),
+            )
+        )
+        rt = result.scalar_one_or_none()
+        if not rt:
+            raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
+
+        result = await session.execute(select(UserModel).where(UserModel.id == rt.user_id))
+        user = result.scalar_one_or_none()
+        if not user or not user.is_active:
+            raise HTTPException(status_code=401, detail="User not found")
+
+        rt.revoked = 1
+        new_token_str = create_refresh_token()
+        new_expires = datetime.now(timezone.utc) + timedelta(hours=settings.jwt_refresh_expire_hours)
+        new_rt = RefreshTokenModel(
+            id=str(uuid.uuid4()), user_id=user.id, token=new_token_str, expires_at=new_expires,
+        )
+        session.add(new_rt)
+        await session.commit()
+
+        new_access = create_access_token(
+            user_id=user.id, username=user.username, roles=user.roles or [],
+        )
+        return {
+            "code": "000000",
+            "message": "刷新成功",
+            "data": {"token": new_access, "refresh_token": new_token_str},
+        }
+
+
+@router.post("/api/v1/auth/logout")
+async def logout(req: LogoutRequest, current_user: dict = Depends(get_current_user)):
+    async with async_session() as session:
+        result = await session.execute(
+            select(RefreshTokenModel).where(RefreshTokenModel.token == req.refresh_token)
+        )
+        rt = result.scalar_one_or_none()
+        if rt:
+            rt.revoked = 1
+            await session.commit()
+
+    return {
+        "code": "000000",
+        "message": "登出成功",
+        "data": {"sso_logout_url": settings.sso_logout_redirect_url},
+    }
+
+
+@router.get("/api/v1/auth/userinfo")
+async def get_userinfo(current_user: dict = Depends(get_current_user)):
+    user_id = current_user.get("sub")
+    async with async_session() as session:
+        result = await session.execute(select(UserModel).where(UserModel.id == user_id))
+        user = result.scalar_one_or_none()
+        if not user:
+            raise HTTPException(status_code=404, detail="User not found")
+        return {
+            "code": "000000",
+            "data": {
+                "id": user.id,
+                "username": user.username,
+                "email": user.email,
+                "real_name": user.real_name,
+                "roles": user.roles,
+                "avatar_url": user.avatar_url,
+                "permissions": [],
+            },
+        }
+
+
+@router.get("/api/v1/auth/me")
+async def get_me(current_user: dict = Depends(get_current_user)):
+    return await get_userinfo(current_user)

+ 14 - 1
backend/app/config.py

@@ -66,7 +66,7 @@ class Settings(BaseSettings):
     backend_port: int = 8000
     backend_env: str = "production"
     backend_log_level: str = "INFO"
-    backend_cors_origins: list[str] = ["http://192.168.91.253:5173"]
+    backend_cors_origins: list[str] = ["http://183.220.37.46:5173"]
 
     # --- 数据库 ---
     database_url: str = "postgresql+asyncpg://finetune:finetune123@localhost:5432/finetuning"
@@ -98,6 +98,19 @@ class Settings(BaseSettings):
     max_upload_size_mb: int = 500
     allowed_dataset_formats: str = "jsonl,csv,parquet,json"
 
+    # --- SSO 统一认证 ---
+    sso_base_url: str = "http://192.168.92.61:8200"
+    sso_client_id: str = "5bdce571-c092-45ff-a491-44a14a000426"
+    sso_client_secret: str = "hmDeOtXZVbeo2AZ-x58yPssZLg4Tcb1W"
+    sso_redirect_uri: str = "http://localhost:3000/auth/callback"
+    sso_frontend_url: str = "http://localhost:3000"
+    sso_scope: str = "email"
+    sso_logout_redirect_url: str = "http://192.168.92.61:9200/login"
+    jwt_secret_key: str = "change-me-in-production-use-a-long-random-string"
+    jwt_algorithm: str = "HS256"
+    jwt_access_expire_minutes: int = 20
+    jwt_refresh_expire_hours: int = 24
+
     @field_validator("backend_cors_origins", mode="before")
     @classmethod
     def parse_cors_origins(cls, v):

+ 37 - 0
backend/app/core/auth.py

@@ -0,0 +1,37 @@
+import jwt
+from fastapi import Depends, HTTPException, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from sqlalchemy import select
+
+from app.core.db import UserModel, async_session
+from app.core.security import decode_access_token
+
+security = HTTPBearer(auto_error=False)
+
+
+async def get_current_user(
+    credentials: HTTPAuthorizationCredentials = Depends(security),
+) -> dict:
+    if not credentials:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
+    try:
+        payload = decode_access_token(credentials.credentials)
+        if payload.get("type") != "access":
+            raise HTTPException(status_code=401, detail="Invalid token type")
+    except jwt.ExpiredSignatureError:
+        raise HTTPException(status_code=401, detail="Token expired")
+    except jwt.InvalidTokenError:
+        raise HTTPException(status_code=401, detail="Invalid token")
+    return payload
+
+
+async def get_current_active_user(
+    current_user: dict = Depends(get_current_user),
+) -> dict:
+    user_id = current_user.get("sub")
+    async with async_session() as session:
+        result = await session.execute(select(UserModel).where(UserModel.id == user_id))
+        user = result.scalar_one_or_none()
+        if not user or not user.is_active:
+            raise HTTPException(status_code=401, detail="User not found or inactive")
+    return current_user

+ 31 - 1
backend/app/core/db.py

@@ -1,6 +1,7 @@
 from datetime import datetime
+import uuid
 
-from sqlalchemy import Column, DateTime, Float, Integer, String, Text
+from sqlalchemy import JSON, Column, DateTime, Float, Integer, String, Text
 from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
 from sqlalchemy.orm import declarative_base
 
@@ -128,6 +129,35 @@ class DeployTaskModel(Base):
     created_at = Column(DateTime, default=datetime.utcnow)
 
 
+class UserModel(Base):
+    __tablename__ = "users"
+
+    id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+    username = Column(String(128), unique=True, nullable=False, index=True)
+    email = Column(String(256), nullable=True)
+    real_name = Column(String(128), nullable=True)
+    avatar_url = Column(String(512), nullable=True)
+    company = Column(String(128), nullable=True)
+    department = Column(String(128), nullable=True)
+    position = Column(String(128), nullable=True)
+    roles = Column(JSON, default=list)
+    is_active = Column(Integer, default=1, nullable=False)
+    is_superuser = Column(Integer, default=0, nullable=False)
+    created_at = Column(DateTime, default=datetime.utcnow)
+    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+
+class RefreshTokenModel(Base):
+    __tablename__ = "refresh_tokens"
+
+    id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+    user_id = Column(String(36), nullable=False, index=True)
+    token = Column(String(512), unique=True, nullable=False, index=True)
+    expires_at = Column(DateTime, nullable=False)
+    revoked = Column(Integer, default=0, nullable=False)
+    created_at = Column(DateTime, default=datetime.utcnow)
+
+
 async def get_db() -> AsyncSession:
     async with async_session() as session:
         yield session

+ 29 - 0
backend/app/core/security.py

@@ -0,0 +1,29 @@
+import uuid
+from datetime import datetime, timedelta, timezone
+
+import jwt
+
+from app.config import get_settings
+
+settings = get_settings()
+
+
+def create_access_token(user_id: str, username: str, roles: list[str]) -> str:
+    expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_access_expire_minutes)
+    payload = {
+        "sub": user_id,
+        "username": username,
+        "roles": roles,
+        "exp": expire,
+        "iat": datetime.now(timezone.utc),
+        "type": "access",
+    }
+    return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
+
+
+def create_refresh_token() -> str:
+    return f"rt_{uuid.uuid4().hex}{uuid.uuid4().hex[:16]}"
+
+
+def decode_access_token(token: str) -> dict:
+    return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])

+ 32 - 0
backend/app/core/sso_client.py

@@ -0,0 +1,32 @@
+import httpx
+
+from app.config import get_settings
+
+settings = get_settings()
+
+
+async def exchange_code_for_token(code: str) -> dict:
+    async with httpx.AsyncClient() as client:
+        resp = await client.post(
+            f"{settings.sso_base_url}/oauth/token",
+            data={
+                "grant_type": "authorization_code",
+                "code": code,
+                "redirect_uri": settings.sso_redirect_uri,
+                "client_id": settings.sso_client_id,
+                "client_secret": settings.sso_client_secret,
+            },
+            headers={"Content-Type": "application/x-www-form-urlencoded"},
+        )
+        resp.raise_for_status()
+        return resp.json()
+
+
+async def fetch_sso_userinfo(access_token: str) -> dict:
+    async with httpx.AsyncClient() as client:
+        resp = await client.get(
+            f"{settings.sso_base_url}/oauth/userinfo",
+            headers={"Authorization": f"Bearer {access_token}"},
+        )
+        resp.raise_for_status()
+        return resp.json()

+ 15 - 0
backend/app/core/websocket.py

@@ -61,6 +61,21 @@ def datetime_now() -> str:
 
 @router.websocket("/ws/training/{job_id}")
 async def training_websocket(websocket: WebSocket, job_id: str) -> None:
+    token = websocket.query_params.get("token")
+    if token:
+        try:
+            from app.core.security import decode_access_token
+            payload = decode_access_token(token)
+            if payload.get("type") != "access":
+                await websocket.close(code=4001, reason="Invalid token")
+                return
+        except Exception:
+            await websocket.close(code=4001, reason="Token expired or invalid")
+            return
+    else:
+        await websocket.close(code=4001, reason="Authentication required")
+        return
+
     await websocket.accept()
     async with _lock:
         _connections.setdefault(job_id, set()).add(websocket)

+ 31 - 7
backend/main.py

@@ -9,7 +9,7 @@ os.environ["TORCH_FLASH_ATTN"] = "0"
 
 from contextlib import asynccontextmanager
 
-from fastapi import FastAPI
+from fastapi import Depends, FastAPI
 from fastapi.middleware.cors import CORSMiddleware
 
 from app.config import get_settings
@@ -60,13 +60,37 @@ def create_app() -> FastAPI:
     from app.api import evaluation as evaluation_api
     from app.api import deployment as deployment_api
     from app.api import inference as inference_api
+    from app.api import auth as auth_api
+    from app.core.auth import get_current_active_user
 
-    app.include_router(models_api.router, prefix="/api/v1/models", tags=["models"])
-    app.include_router(datasets_api.router, prefix="/api/v1/datasets", tags=["datasets"])
-    app.include_router(training_api.router, prefix="/api/v1/training", tags=["training"])
-    app.include_router(evaluation_api.router, prefix="/api/v1/evaluation", tags=["evaluation"])
-    app.include_router(deployment_api.router, prefix="/api/v1/deployment", tags=["deployment"])
-    app.include_router(inference_api.router, prefix="/api/v1/inference", tags=["inference"])
+    # 认证路由(无 prefix,端点自带完整路径)
+    app.include_router(auth_api.router)
+
+    # 已有路由:添加认证依赖保护
+    app.include_router(
+        models_api.router, prefix="/api/v1/models", tags=["models"],
+        dependencies=[Depends(get_current_active_user)],
+    )
+    app.include_router(
+        datasets_api.router, prefix="/api/v1/datasets", tags=["datasets"],
+        dependencies=[Depends(get_current_active_user)],
+    )
+    app.include_router(
+        training_api.router, prefix="/api/v1/training", tags=["training"],
+        dependencies=[Depends(get_current_active_user)],
+    )
+    app.include_router(
+        evaluation_api.router, prefix="/api/v1/evaluation", tags=["evaluation"],
+        dependencies=[Depends(get_current_active_user)],
+    )
+    app.include_router(
+        deployment_api.router, prefix="/api/v1/deployment", tags=["deployment"],
+        dependencies=[Depends(get_current_active_user)],
+    )
+    app.include_router(
+        inference_api.router, prefix="/api/v1/inference", tags=["inference"],
+        dependencies=[Depends(get_current_active_user)],
+    )
 
     # WebSocket
     from app.core.websocket import router as ws_router

+ 3 - 0
backend/requirements.txt

@@ -29,3 +29,6 @@ peft>=0.13.0
 trl>=0.12.0
 accelerate>=1.0.0
 bitsandbytes>=0.44.0
+# SSO 认证
+PyJWT>=2.8.0
+httpx>=0.27.0

+ 9 - 0
frontend/nginx.conf

@@ -19,6 +19,15 @@ server {
         proxy_read_timeout 600s;
     }
 
+    # 反向代理 SSO 认证请求到后端
+    location /auth/ {
+        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;
+    }
+
     # WebSocket 代理
     location /ws/ {
         proxy_pass http://backend:8010;

+ 30 - 12
frontend/src/App.tsx

@@ -1,6 +1,7 @@
 import { lazy, Suspense } from 'react'
-import { Routes, Route } from 'react-router-dom'
+import { Routes, Route, Navigate } from 'react-router-dom'
 import { Layout } from './components/layout/Layout'
+import { useAuth } from './contexts/AuthContext'
 
 const Dashboard = lazy(() => import('./pages/Dashboard').then(m => ({ default: m.Dashboard })))
 const Models = lazy(() => import('./pages/Models').then(m => ({ default: m.Models })))
@@ -9,23 +10,40 @@ const Training = lazy(() => import('./pages/Training').then(m => ({ default: m.T
 const Evaluation = lazy(() => import('./pages/Evaluation').then(m => ({ default: m.Evaluation })))
 const Deployment = lazy(() => import('./pages/Deployment').then(m => ({ default: m.Deployment })))
 const Inference = lazy(() => import('./pages/Inference').then(m => ({ default: m.Inference })))
+const Login = lazy(() => import('./pages/AuthLogin').then(m => ({ default: m.Login })))
+const AuthCallback = lazy(() => import('./pages/AuthCallback').then(m => ({ default: m.AuthCallback })))
 
 function PageFallback() {
   return <div style={{ padding: 24, color: '#999' }}>加载中...</div>
 }
 
+function AuthRoute({ children }: { children: React.ReactNode }) {
+  const { isAuthenticated } = useAuth()
+  return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />
+}
+
 export default function App() {
   return (
-    <Layout>
-      <Routes>
-        <Route path="/" element={<Suspense fallback={<PageFallback />}><Dashboard /></Suspense>} />
-        <Route path="/models" element={<Suspense fallback={<PageFallback />}><Models /></Suspense>} />
-        <Route path="/datasets" element={<Suspense fallback={<PageFallback />}><Datasets /></Suspense>} />
-        <Route path="/training" element={<Suspense fallback={<PageFallback />}><Training /></Suspense>} />
-        <Route path="/evaluation" element={<Suspense fallback={<PageFallback />}><Evaluation /></Suspense>} />
-        <Route path="/deployment" element={<Suspense fallback={<PageFallback />}><Deployment /></Suspense>} />
-        <Route path="/inference" element={<Suspense fallback={<PageFallback />}><Inference /></Suspense>} />
-      </Routes>
-    </Layout>
+    <Routes>
+      <Route path="/login" element={<Suspense fallback={<PageFallback />}><Login /></Suspense>} />
+      <Route path="/auth/callback" element={<Suspense fallback={<PageFallback />}><AuthCallback /></Suspense>} />
+      <Route path="/*" element={
+        <AuthRoute>
+          <Layout>
+            <Suspense fallback={<PageFallback />}>
+              <Routes>
+                <Route path="/" element={<Dashboard />} />
+                <Route path="/models" element={<Models />} />
+                <Route path="/datasets" element={<Datasets />} />
+                <Route path="/training" element={<Training />} />
+                <Route path="/evaluation" element={<Evaluation />} />
+                <Route path="/deployment" element={<Deployment />} />
+                <Route path="/inference" element={<Inference />} />
+              </Routes>
+            </Suspense>
+          </Layout>
+        </AuthRoute>
+      } />
+    </Routes>
   )
 }

+ 61 - 3
frontend/src/api/client.ts

@@ -1,10 +1,68 @@
-// 统一的 fetch 包装器:非 2xx 状态码自动抛出错误
+// 统一的 fetch 包装器:自动携带 Token、处理刷新和滑动过期
+let tokenRefreshPromise: Promise<string> | null = null
+
+async function doRefreshToken(rt: string): Promise<string> {
+  tokenRefreshPromise = (async () => {
+    try {
+      const res = await fetch('/api/v1/auth/refresh', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ refresh_token: rt }),
+      })
+      if (!res.ok) throw new Error('Refresh failed')
+      const data = await res.json()
+      const newToken = data.data.token
+      const newRt = data.data.refresh_token
+      localStorage.setItem('token', newToken)
+      localStorage.setItem('refresh_token', newRt)
+      return newToken
+    } finally {
+      tokenRefreshPromise = null
+    }
+  })()
+  return tokenRefreshPromise
+}
+
 async function apiFetch(url: string, init?: RequestInit): Promise<Response> {
-  const res = await fetch(url, init)
+  const headers: Record<string, string> = { ...(init?.headers as Record<string, string> || {}) }
+  const storedToken = localStorage.getItem('token')
+  if (storedToken) {
+    headers['Authorization'] = `Bearer ${storedToken}`
+  }
+  // FormData 时不要设 Content-Type
+  if (!(init?.body instanceof FormData)) {
+    headers['Content-Type'] = headers['Content-Type'] || 'application/json'
+  }
+
+  let res = await fetch(url, { ...init, headers })
+
+  // 滑动过期:检测 X-New-Token 响应头
+  const newToken = res.headers.get('X-New-Token')
+  if (newToken) {
+    localStorage.setItem('token', newToken)
+  }
+
+  // 401 时尝试用 refresh_token 刷新一次
+  if (res.status === 401 && storedToken) {
+    const storedRt = localStorage.getItem('refresh_token')
+    if (storedRt) {
+      try {
+        const freshToken = await doRefreshToken(storedRt)
+        headers['Authorization'] = `Bearer ${freshToken}`
+        res = await fetch(url, { ...init, headers })
+      } catch {
+        localStorage.removeItem('token')
+        localStorage.removeItem('refresh_token')
+        localStorage.removeItem('user')
+        window.location.href = '/login'
+      }
+    }
+  }
+
   if (!res.ok) {
     try {
       const err = await res.json()
-      throw new Error(err.detail || err.error || `Request failed: ${res.status}`)
+      throw new Error(err.detail || err.error || err.message || `Request failed: ${res.status}`)
     } catch (e) {
       if (e instanceof Error) throw e
       throw new Error(`Request failed: ${res.status}`)

+ 7 - 1
frontend/src/api/websocket.ts

@@ -8,7 +8,13 @@ class WSManager {
     if (this.ws) return
     const url = baseUrl || (import.meta.env.VITE_WS_BASE_URL as string) || 'ws://127.0.0.1:8000/ws'
     // If relative path, resolve to current origin
-    const wsUrl = url.startsWith('ws') ? url : `${window.location.protocol === 'https:' ? 'wss://' : 'ws://'}${window.location.host}${url}`
+    let wsUrl = url.startsWith('ws') ? url : `${window.location.protocol === 'https:' ? 'wss://' : 'ws://'}${window.location.host}${url}`
+    // Append token for authentication
+    const token = localStorage.getItem('token')
+    if (token) {
+      wsUrl += wsUrl.includes('?') ? '&' : '?'
+      wsUrl += `token=${encodeURIComponent(token)}`
+    }
     this.url = wsUrl
 
     try {

+ 17 - 0
frontend/src/components/layout/Layout.tsx

@@ -1,4 +1,5 @@
 import { Link, useLocation } from 'react-router-dom'
+import { useAuth } from '../../contexts/AuthContext'
 
 const NAV_ITEMS = [
   { path: '/', label: '仪表盘' },
@@ -12,6 +13,7 @@ const NAV_ITEMS = [
 
 export function Layout({ children }: { children: React.ReactNode }) {
   const location = useLocation()
+  const { user, logout } = useAuth()
 
   return (
     <div style={{ display: 'flex', minHeight: '100vh', fontFamily: 'system-ui, sans-serif' }}>
@@ -38,6 +40,21 @@ export function Layout({ children }: { children: React.ReactNode }) {
             {item.label}
           </Link>
         ))}
+        <div style={{ marginTop: 'auto', padding: '16px', borderTop: '1px solid #333' }}>
+          <div style={{ fontSize: 13, color: '#ccc', marginBottom: 8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
+            {user?.real_name || user?.username || '用户'}
+          </div>
+          <button
+            onClick={logout}
+            style={{
+              width: '100%', padding: '6px 12px', background: 'transparent',
+              border: '1px solid #555', borderRadius: 4, color: '#999',
+              cursor: 'pointer', fontSize: 12,
+            }}
+          >
+            退出登录
+          </button>
+        </div>
       </nav>
       <main style={{ flex: 1, padding: 24, background: '#f5f5f5' }}>{children}</main>
     </div>

+ 97 - 0
frontend/src/contexts/AuthContext.tsx

@@ -0,0 +1,97 @@
+import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
+
+export interface User {
+  id: string
+  username: string
+  email?: string
+  real_name?: string
+  roles: string[]
+  is_active: boolean
+}
+
+interface AuthState {
+  user: User | null
+  token: string | null
+  refreshToken: string | null
+  isAuthenticated: boolean
+  login: (token: string, refreshToken: string, user: User) => void
+  logout: () => Promise<void>
+}
+
+const AuthContext = createContext<AuthState | null>(null)
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+  const [user, setUser] = useState<User | null>(null)
+  const [token, setToken] = useState<string | null>(null)
+  const [refreshToken, setRefreshToken] = useState<string | null>(null)
+
+  useEffect(() => {
+    const stored = localStorage.getItem('token')
+    const storedRt = localStorage.getItem('refresh_token')
+    const storedUser = localStorage.getItem('user')
+    if (stored && storedUser) {
+      setToken(stored)
+      setRefreshToken(storedRt)
+      setUser(JSON.parse(storedUser))
+    }
+  }, [])
+
+  const login = useCallback((t: string, rt: string, u: User) => {
+    setToken(t)
+    setRefreshToken(rt)
+    setUser(u)
+    localStorage.setItem('token', t)
+    localStorage.setItem('refresh_token', rt)
+    localStorage.setItem('user', JSON.stringify(u))
+  }, [])
+
+  const logout = useCallback(async () => {
+    const currentToken = localStorage.getItem('token')
+    const currentRt = localStorage.getItem('refresh_token')
+    if (currentToken && currentRt) {
+      try {
+        const res = await fetch('/api/v1/auth/logout', {
+          method: 'POST',
+          headers: {
+            'Content-Type': 'application/json',
+            Authorization: `Bearer ${currentToken}`,
+          },
+          body: JSON.stringify({ token: currentToken, refresh_token: currentRt }),
+        })
+        const data = await res.json()
+        if (data.data?.sso_logout_url) {
+          clearAuth()
+          window.location.href = data.data.sso_logout_url
+          return
+        }
+      } catch {
+        // ignore network errors during logout
+      }
+    }
+    clearAuth()
+    window.location.href = '/login'
+  }, [])
+
+  const clearAuth = useCallback(() => {
+    setToken(null)
+    setRefreshToken(null)
+    setUser(null)
+    localStorage.removeItem('token')
+    localStorage.removeItem('refresh_token')
+    localStorage.removeItem('user')
+  }, [])
+
+  return (
+    <AuthContext.Provider
+      value={{ user, token, refreshToken, isAuthenticated: !!token, login, logout }}
+    >
+      {children}
+    </AuthContext.Provider>
+  )
+}
+
+export function useAuth() {
+  const ctx = useContext(AuthContext)
+  if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
+  return ctx
+}

+ 5 - 2
frontend/src/main.tsx

@@ -2,13 +2,16 @@ import { StrictMode } from 'react'
 import { createRoot } from 'react-dom/client'
 import { BrowserRouter } from 'react-router-dom'
 import { Toaster } from 'sonner'
+import { AuthProvider } from './contexts/AuthContext'
 import App from './App'
 
 createRoot(document.getElementById('root')!).render(
   <StrictMode>
     <BrowserRouter>
-      <App />
-      <Toaster position="top-right" />
+      <AuthProvider>
+        <App />
+        <Toaster position="top-right" />
+      </AuthProvider>
     </BrowserRouter>
   </StrictMode>,
 )

+ 55 - 0
frontend/src/pages/AuthCallback.tsx

@@ -0,0 +1,55 @@
+import { useEffect, useState } from 'react'
+import { useNavigate, useSearchParams } from 'react-router-dom'
+import { useAuth } from '../contexts/AuthContext'
+
+export function AuthCallback() {
+  const [searchParams] = useSearchParams()
+  const navigate = useNavigate()
+  const { login } = useAuth()
+  const [error, setError] = useState('')
+
+  useEffect(() => {
+    const code = searchParams.get('code')
+    if (!code) {
+      setError('缺少授权码')
+      setTimeout(() => navigate('/login'), 3000)
+      return
+    }
+
+    fetch('/api/oauth/exchange-code', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ code }),
+    })
+      .then(r => r.json())
+      .then(result => {
+        if (result.code === '000000') {
+          login(result.data.token, result.data.refresh_token, result.data.user)
+          navigate('/')
+        } else {
+          setError(result.message || '登录失败')
+          setTimeout(() => navigate('/login'), 3000)
+        }
+      })
+      .catch(() => {
+        setError('网络错误,请重试')
+        setTimeout(() => navigate('/login'), 3000)
+      })
+  }, [searchParams, login, navigate])
+
+  if (error) {
+    return (
+      <div style={{ padding: 40, textAlign: 'center', background: '#0f0f23', minHeight: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', color: '#fff' }}>
+        <h2>登录失败</h2>
+        <p style={{ color: '#e94560' }}>{error}</p>
+        <p style={{ color: '#999', fontSize: 14 }}>3秒后跳转到登录页...</p>
+      </div>
+    )
+  }
+
+  return (
+    <div style={{ padding: 40, textAlign: 'center', background: '#0f0f23', minHeight: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', color: '#fff' }}>
+      <h2>正在登录,请稍候...</h2>
+    </div>
+  )
+}

+ 54 - 0
frontend/src/pages/AuthLogin.tsx

@@ -0,0 +1,54 @@
+import { useEffect } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useAuth } from '../contexts/AuthContext'
+
+export function Login() {
+  const { isAuthenticated } = useAuth()
+  const navigate = useNavigate()
+
+  useEffect(() => {
+    if (isAuthenticated) navigate('/')
+  }, [isAuthenticated, navigate])
+
+  const handleSSOLogin = async () => {
+    try {
+      const res = await fetch('/auth/sso/authorize?redirect=true')
+      // 如果不是 redirect=true,则获取 URL 并跳转
+      if (!res.redirected) {
+        const data = await res.json()
+        window.location.href = data.data?.authorize_url
+      }
+    } catch {
+      // 降级:直接跳转 SSO 授权页
+      window.location.href = '/auth/sso/authorize?redirect=true'
+    }
+  }
+
+  return (
+    <div style={{
+      display: 'flex', justifyContent: 'center', alignItems: 'center',
+      minHeight: '100vh', background: '#0f0f23',
+    }}>
+      <div style={{
+        background: '#1a1a2e', borderRadius: 12, padding: 48, width: 420,
+        boxShadow: '0 4px 24px rgba(0,0,0,0.4)', textAlign: 'center',
+        border: '1px solid #333',
+      }}>
+        <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8, color: '#fff' }}>
+          PEFT Fine-Tuning Platform
+        </h1>
+        <p style={{ color: '#999', marginBottom: 32, fontSize: 14 }}>统一认证登录</p>
+        <button
+          onClick={handleSSOLogin}
+          style={{
+            width: '100%', padding: '12px 24px', borderRadius: 8,
+            border: 'none', background: '#e94560', color: '#fff',
+            fontSize: 16, fontWeight: 600, cursor: 'pointer',
+          }}
+        >
+          使用统一认证平台登录
+        </button>
+      </div>
+    </div>
+  )
+}

+ 6 - 2
frontend/vite.config.ts

@@ -7,11 +7,15 @@ export default defineConfig({
     port: 5173,
     proxy: {
       '/api': {
-        target: 'http://192.168.91.253:8010',
+        target: 'http://183.220.37.46:8010',
+        changeOrigin: true,
+      },
+      '/auth': {
+        target: 'http://183.220.37.46:8010',
         changeOrigin: true,
       },
       '/ws': {
-        target: 'ws://192.168.91.253:8010',
+        target: 'ws://183.220.37.46:8010',
         ws: true,
       },
     },