liuq 3 miesięcy temu
rodzic
commit
2640b96534

+ 1 - 0
.dockerignore

@@ -52,6 +52,7 @@ npm-debug.log*
 yarn-debug.log*
 yarn-error.log*
 pnpm-debug.log*
+backend/backups/
 
 # Node / Frontend
 node_modules/

+ 1 - 0
.gitignore

@@ -49,6 +49,7 @@ htmlcov/
 npm-debug.log*
 yarn-debug.log*
 yarn-error.log*
+backend/backups/
 
 # Frontend
 node_modules/

+ 2 - 1
backend/app/api/v1/api.py

@@ -1,6 +1,6 @@
 from fastapi import APIRouter
 
-from app.api.v1.endpoints import auth, users, apps, utils, simple_auth, oidc, open_api, logs, system_logs
+from app.api.v1.endpoints import auth, users, apps, utils, simple_auth, oidc, open_api, logs, system_logs, backup
 
 api_router = APIRouter()
 api_router.include_router(auth.router, prefix="/auth", tags=["认证 (Auth)"])
@@ -8,6 +8,7 @@ api_router.include_router(users.router, prefix="/users", tags=["用户管理 (Us
 api_router.include_router(apps.router, prefix="/apps", tags=["应用管理 (Applications)"])
 api_router.include_router(logs.router, prefix="/logs", tags=["操作日志 (Logs)"])
 api_router.include_router(system_logs.router, prefix="/system-logs", tags=["后台日志 (System Logs)"])
+api_router.include_router(backup.router, prefix="/backups", tags=["数据备份 (Backup)"])
 api_router.include_router(utils.router, prefix="/utils", tags=["工具 (Utils)"])
 api_router.include_router(simple_auth.router, prefix="/simple", tags=["简易认证 (SimpleAuth)"])
 api_router.include_router(oidc.router, prefix="/oidc", tags=["OIDC (OpenID Connect)"])

+ 144 - 0
backend/app/api/v1/endpoints/backup.py

@@ -0,0 +1,144 @@
+from typing import Any, List, Optional
+from datetime import datetime
+from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import FileResponse
+from sqlalchemy.orm import Session
+from sqlalchemy import desc, asc
+from app.api.v1 import deps
+from app.schemas.backup import (
+    BackupRecord, 
+    BackupSettings, 
+    BackupSettingsUpdate, 
+    BackupRecordList,
+    RestorePreviewResponse,
+    RestoreRequest
+)
+from app.models.backup import BackupRecord as BackupRecordModel
+from app.services.backup_service import BackupService
+from app.models.user import User, UserRole
+
+router = APIRouter()
+
+@router.post("/create", response_model=BackupRecord)
+def create_backup(
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+) -> Any:
+    """
+    Manually create a backup.
+    """
+    if current_user.role != UserRole.SUPER_ADMIN:
+         raise HTTPException(status_code=403, detail="Not enough permissions")
+         
+    return BackupService.create_backup(db)
+
+@router.get("/", response_model=BackupRecordList)
+def read_backups(
+    skip: int = 0,
+    limit: int = 20,
+    start_date: Optional[str] = None, # YYYY-MM-DD
+    end_date: Optional[str] = None,   # YYYY-MM-DD
+    sort_order: str = "desc", # "desc" or "asc"
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+) -> Any:
+    """
+    Retrieve backup records with pagination, filtering and sorting.
+    """
+    if current_user.role != UserRole.SUPER_ADMIN:
+         raise HTTPException(status_code=403, detail="Not enough permissions")
+    
+    query = db.query(BackupRecordModel)
+    
+    if start_date:
+        try:
+            start_dt = datetime.strptime(start_date, "%Y-%m-%d")
+            query = query.filter(BackupRecordModel.created_at >= start_dt)
+        except ValueError:
+            pass # Ignore invalid date format
+            
+    if end_date:
+        try:
+            end_dt = datetime.strptime(end_date, "%Y-%m-%d")
+            end_dt = end_dt.replace(hour=23, minute=59, second=59)
+            query = query.filter(BackupRecordModel.created_at <= end_dt)
+        except ValueError:
+            pass
+
+    total = query.count()
+    
+    if sort_order == "asc":
+        query = query.order_by(asc(BackupRecordModel.created_at))
+    else:
+        query = query.order_by(desc(BackupRecordModel.created_at))
+        
+    items = query.offset(skip).limit(limit).all()
+    
+    return {"total": total, "items": items}
+
+@router.get("/settings", response_model=BackupSettings)
+def read_backup_settings(
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+) -> Any:
+    if current_user.role != UserRole.SUPER_ADMIN:
+         raise HTTPException(status_code=403, detail="Not enough permissions")
+    return BackupService.get_settings(db)
+
+@router.put("/settings", response_model=BackupSettings)
+def update_backup_settings(
+    settings_in: BackupSettingsUpdate,
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+) -> Any:
+    if current_user.role != UserRole.SUPER_ADMIN:
+         raise HTTPException(status_code=403, detail="Not enough permissions")
+    return BackupService.update_settings(db, settings_in.auto_backup_enabled, settings_in.backup_time)
+
+@router.get("/download/{id}")
+def download_backup(
+    id: int,
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    if current_user.role != UserRole.SUPER_ADMIN:
+         raise HTTPException(status_code=403, detail="Not enough permissions")
+    
+    backup = db.query(BackupRecordModel).filter(BackupRecordModel.id == id).first()
+    if not backup:
+        raise HTTPException(status_code=404, detail="Backup not found")
+        
+    return FileResponse(backup.file_path, filename=backup.filename, media_type='application/zip')
+
+@router.get("/{id}/restore/preview", response_model=RestorePreviewResponse)
+def preview_restore(
+    id: int,
+    type: str, # APPLICATIONS, USERS, MAPPINGS
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    if current_user.role != UserRole.SUPER_ADMIN:
+         raise HTTPException(status_code=403, detail="Not enough permissions")
+         
+    return BackupService.preview_restore(db, id, type)
+
+@router.post("/{id}/restore")
+def restore_backup(
+    id: int,
+    request: RestoreRequest,
+    db: Session = Depends(deps.get_db),
+    current_user: User = Depends(deps.get_current_active_user),
+):
+    if current_user.role != UserRole.SUPER_ADMIN:
+         raise HTTPException(status_code=403, detail="Not enough permissions")
+         
+    return BackupService.restore_data(
+        db, 
+        current_user, 
+        id, 
+        request.restore_type, 
+        request.field_mapping,
+        request.password,
+        request.captcha_id,
+        request.captcha_code
+    )

+ 7 - 0
backend/app/core/scheduler.py

@@ -0,0 +1,7 @@
+from apscheduler.schedulers.asyncio import AsyncIOScheduler
+
+scheduler = AsyncIOScheduler()
+
+def get_scheduler():
+    return scheduler
+

+ 14 - 0
backend/app/main.py

@@ -10,6 +10,9 @@ from app.core.config import settings
 from app.core.database import Base, engine
 from app.core.cache import redis_client
 from app.core.logging_config import setup_logging
+from app.core.scheduler import scheduler
+from app.services.backup_service import BackupService
+from app.core.database import SessionLocal
 
 # Configure Logging
 logger = setup_logging()
@@ -56,8 +59,19 @@ async def lifespan(app: FastAPI):
     logger.info("Waiting for Database and Redis...")
     init_db()
     check_redis()
+    
+    # Init Scheduler
+    logger.info("Starting Scheduler...")
+    scheduler.start()
+    try:
+        with SessionLocal() as db:
+            BackupService.init_scheduler(db)
+    except Exception as e:
+        logger.error(f"Failed to init scheduler: {e}")
+
     yield
     # Shutdown logic if any (e.g. close connections)
+    scheduler.shutdown()
     logger.info("Shutting down...")
 
 app = FastAPI(

+ 34 - 0
backend/app/models/backup.py

@@ -0,0 +1,34 @@
+import enum
+from sqlalchemy import Column, Integer, String, DateTime, Boolean, Enum, Text
+from sqlalchemy.sql import func
+from app.core.database import Base
+
+class BackupType(str, enum.Enum):
+    MANUAL = "MANUAL"
+    AUTO = "AUTO"
+
+class BackupRecord(Base):
+    __tablename__ = "backup_records"
+
+    id = Column(Integer, primary_key=True, index=True)
+    filename = Column(String(255), nullable=False)
+    file_path = Column(String(512), nullable=False)
+    backup_type = Column(Enum(BackupType), default=BackupType.MANUAL, nullable=False)
+    created_at = Column(DateTime(timezone=True), server_default=func.now())
+    
+    # "users,applications,mappings"
+    content_types = Column(String(255), nullable=True) 
+    file_size = Column(Integer, nullable=True) # Bytes
+
+class BackupSettings(Base):
+    __tablename__ = "backup_settings"
+
+    id = Column(Integer, primary_key=True, index=True)
+    auto_backup_enabled = Column(Boolean, default=False)
+    # 备份时间,例如 "02:00" (每天凌晨2点)
+    backup_time = Column(String(10), default="02:00")
+    # 上次自动备份时间
+    last_backup_at = Column(DateTime(timezone=True), nullable=True)
+    
+    updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
+

+ 50 - 0
backend/app/schemas/backup.py

@@ -0,0 +1,50 @@
+from typing import Optional, List, Dict
+from pydantic import BaseModel
+from datetime import datetime
+from app.models.backup import BackupType
+
+class BackupRecordBase(BaseModel):
+    filename: str
+    backup_type: BackupType
+    content_types: Optional[str] = None
+    file_size: Optional[int] = None
+
+class BackupRecordCreate(BackupRecordBase):
+    file_path: str
+
+class BackupRecord(BackupRecordBase):
+    id: int
+    created_at: datetime
+
+    class Config:
+        from_attributes = True
+
+class BackupRecordList(BaseModel):
+    total: int
+    items: List[BackupRecord]
+
+class BackupSettingsBase(BaseModel):
+    auto_backup_enabled: bool
+    backup_time: str # "HH:MM"
+
+class BackupSettingsUpdate(BackupSettingsBase):
+    pass
+
+class BackupSettings(BackupSettingsBase):
+    id: int
+    last_backup_at: Optional[datetime] = None
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+class RestorePreviewResponse(BaseModel):
+    csv_headers: List[str]
+    db_columns: List[str]
+
+class RestoreRequest(BaseModel):
+    restore_type: str  # "APPLICATIONS", "USERS", "MAPPINGS"
+    field_mapping: Dict[str, str] # csv_header -> db_column
+    password: str
+    captcha_id: str
+    captcha_code: str

+ 333 - 0
backend/app/services/backup_service.py

@@ -0,0 +1,333 @@
+import os
+import shutil
+import zipfile
+import pandas as pd
+import io
+import csv
+from datetime import datetime
+from typing import List, Dict, Any
+from sqlalchemy.orm import Session
+from sqlalchemy import inspect
+from fastapi import HTTPException
+from app.core.database import SessionLocal
+from app.models.application import Application
+from app.models.mapping import AppUserMapping
+from app.models.user import User
+from app.models.backup import BackupRecord, BackupType, BackupSettings
+from app.core.scheduler import scheduler
+from app.core.security import verify_password
+from app.services.captcha_service import CaptchaService
+from app.services.log_service import LogService
+from app.schemas.operation_log import ActionType
+from apscheduler.triggers.cron import CronTrigger
+
+# Ensure backup directory exists relative to backend root
+BACKUP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "backups")
+
+class BackupService:
+    @staticmethod
+    def ensure_backup_dir():
+        if not os.path.exists(BACKUP_DIR):
+            os.makedirs(BACKUP_DIR, exist_ok=True)
+
+    @staticmethod
+    def create_backup(db: Session, backup_type: BackupType = BackupType.MANUAL) -> BackupRecord:
+        BackupService.ensure_backup_dir()
+        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+        base_filename = f"backup_{timestamp}"
+        zip_filename = f"{base_filename}.zip"
+        zip_filepath = os.path.join(BACKUP_DIR, zip_filename)
+        
+        # Temp dir for csvs
+        temp_dir = os.path.join(BACKUP_DIR, f"temp_{timestamp}")
+        os.makedirs(temp_dir, exist_ok=True)
+        
+        try:
+            # 1. Export Applications
+            apps = db.query(Application).all()
+            apps_df = pd.read_sql(db.query(Application).statement, db.bind)
+            apps_df.to_csv(os.path.join(temp_dir, "applications.csv"), index=False, encoding='utf-8-sig')
+            
+            # 2. Export Mappings (Separate by Application)
+            mappings_dir = os.path.join(temp_dir, "mappings")
+            os.makedirs(mappings_dir, exist_ok=True)
+            
+            for app in apps:
+                # Use app_id (string) for filename, sanitized
+                safe_app_id = "".join([c for c in app.app_id if c.isalnum() or c in ('-', '_')])
+                if not safe_app_id:
+                    safe_app_id = f"app_{app.id}"
+                
+                app_mappings_query = db.query(AppUserMapping).filter(AppUserMapping.app_id == app.id)
+                # Check if there are any mappings
+                if app_mappings_query.count() > 0:
+                    app_mappings_df = pd.read_sql(app_mappings_query.statement, db.bind)
+                    filename = f"mappings_{safe_app_id}.csv"
+                    app_mappings_df.to_csv(os.path.join(mappings_dir, filename), index=False, encoding='utf-8-sig')
+
+            # 3. Export Users
+            users_df = pd.read_sql(db.query(User).statement, db.bind)
+            users_df.to_csv(os.path.join(temp_dir, "users.csv"), index=False, encoding='utf-8-sig')
+            
+            # 4. Zip
+            with zipfile.ZipFile(zip_filepath, 'w', zipfile.ZIP_DEFLATED) as zipf:
+                for root, dirs, files in os.walk(temp_dir):
+                    for file in files:
+                        file_path = os.path.join(root, file)
+                        zipf.write(file_path, file.replace(temp_dir, "").lstrip(os.sep))
+                        
+            # Get file size
+            file_size = os.path.getsize(zip_filepath)
+            
+            # Record
+            backup_record = BackupRecord(
+                filename=zip_filename,
+                file_path=zip_filepath,
+                backup_type=backup_type,
+                content_types="users,applications,mappings",
+                file_size=file_size
+            )
+            db.add(backup_record)
+            db.commit()
+            db.refresh(backup_record)
+            
+            return backup_record
+            
+        except Exception as e:
+            # Cleanup zip if failed
+            if os.path.exists(zip_filepath):
+                os.remove(zip_filepath)
+            raise e
+            
+        finally:
+            # Cleanup temp
+            if os.path.exists(temp_dir):
+                shutil.rmtree(temp_dir)
+
+    @staticmethod
+    def get_settings(db: Session) -> BackupSettings:
+        settings = db.query(BackupSettings).first()
+        if not settings:
+            settings = BackupSettings(auto_backup_enabled=False, backup_time="02:00")
+            db.add(settings)
+            db.commit()
+            db.refresh(settings)
+        return settings
+
+    @staticmethod
+    def update_settings(db: Session, auto_backup_enabled: bool, backup_time: str):
+        settings = BackupService.get_settings(db)
+        settings.auto_backup_enabled = auto_backup_enabled
+        settings.backup_time = backup_time
+        settings.updated_at = datetime.now()
+        db.commit()
+        db.refresh(settings)
+        
+        # Update Scheduler
+        BackupService.configure_scheduler(settings)
+        
+        return settings
+
+    @staticmethod
+    def configure_scheduler(settings: BackupSettings):
+        job_id = "auto_backup_job"
+        if scheduler.get_job(job_id):
+            scheduler.remove_job(job_id)
+            
+        if settings.auto_backup_enabled:
+            try:
+                hour, minute = settings.backup_time.split(":")
+                trigger = CronTrigger(hour=int(hour), minute=int(minute))
+                scheduler.add_job(
+                    BackupService.perform_auto_backup,
+                    trigger=trigger,
+                    id=job_id,
+                    replace_existing=True
+                )
+            except ValueError:
+                # Handle invalid time format if necessary
+                pass
+
+    @staticmethod
+    def perform_auto_backup():
+        db = SessionLocal()
+        try:
+            BackupService.create_backup(db, BackupType.AUTO)
+            # Update last_backup_at
+            settings = BackupService.get_settings(db)
+            settings.last_backup_at = datetime.now()
+            db.commit()
+        finally:
+            db.close()
+            
+    @staticmethod
+    def init_scheduler(db: Session):
+        settings = BackupService.get_settings(db)
+        BackupService.configure_scheduler(settings)
+
+    # --- Restore Logic ---
+
+    @staticmethod
+    def get_model_columns(model):
+        return [c.key for c in inspect(model).mapper.column_attrs]
+
+    @staticmethod
+    def preview_restore(db: Session, backup_id: int, restore_type: str):
+        backup = db.query(BackupRecord).filter(BackupRecord.id == backup_id).first()
+        if not backup or not os.path.exists(backup.file_path):
+            raise HTTPException(status_code=404, detail="Backup file not found")
+
+        csv_filename = ""
+        model = None
+        
+        if restore_type == "APPLICATIONS":
+            csv_filename = "applications.csv"
+            model = Application
+        elif restore_type == "USERS":
+            csv_filename = "users.csv"
+            model = User
+        elif restore_type == "MAPPINGS":
+            # For mappings, we just check the first file in mappings/ dir to get headers
+            # Logic: list zip contents, find first file starting with mappings/
+            model = AppUserMapping
+        else:
+             raise HTTPException(status_code=400, detail="Invalid restore type")
+
+        db_columns = BackupService.get_model_columns(model)
+        csv_headers = []
+
+        try:
+            with zipfile.ZipFile(backup.file_path, 'r') as zipf:
+                target_file = None
+                
+                if restore_type == "MAPPINGS":
+                    for name in zipf.namelist():
+                        if name.startswith("mappings/") and name.endswith(".csv"):
+                            target_file = name
+                            break
+                else:
+                    if csv_filename in zipf.namelist():
+                        target_file = csv_filename
+                
+                if not target_file:
+                     # It's possible the backup doesn't have this file (e.g. empty mappings)
+                     return {"csv_headers": [], "db_columns": db_columns}
+
+                with zipf.open(target_file, 'r') as f:
+                    # zipf.open returns bytes, need text wrapper
+                    wrapper = io.TextIOWrapper(f, encoding='utf-8-sig')
+                    reader = csv.reader(wrapper)
+                    try:
+                        csv_headers = next(reader)
+                    except StopIteration:
+                        csv_headers = []
+        except zipfile.BadZipFile:
+            raise HTTPException(status_code=400, detail="Invalid backup file format")
+
+        return {"csv_headers": csv_headers, "db_columns": db_columns}
+
+    @staticmethod
+    def restore_data(
+        db: Session, 
+        current_user: User,
+        backup_id: int, 
+        restore_type: str, 
+        field_mapping: Dict[str, str],
+        password: str,
+        captcha_id: str,
+        captcha_code: str
+    ):
+        # 1. Verification
+        if not CaptchaService.verify_captcha(captcha_id, captcha_code):
+             raise HTTPException(status_code=400, detail="验证码错误")
+        
+        if not verify_password(password, current_user.password_hash):
+             raise HTTPException(status_code=400, detail="密码错误")
+
+        backup = db.query(BackupRecord).filter(BackupRecord.id == backup_id).first()
+        if not backup or not os.path.exists(backup.file_path):
+            raise HTTPException(status_code=404, detail="Backup file not found")
+
+        # 2. Determine Model and Files
+        model = None
+        target_files = []
+        
+        if restore_type == "APPLICATIONS":
+            target_files = ["applications.csv"]
+            model = Application
+        elif restore_type == "USERS":
+            target_files = ["users.csv"]
+            model = User
+        elif restore_type == "MAPPINGS":
+             with zipfile.ZipFile(backup.file_path, 'r') as zipf:
+                target_files = [name for name in zipf.namelist() if name.startswith("mappings/") and name.endswith(".csv")]
+             model = AppUserMapping
+        else:
+             raise HTTPException(status_code=400, detail="Invalid restore type")
+
+        # 3. Process Restore
+        restored_count = 0
+        try:
+            with zipfile.ZipFile(backup.file_path, 'r') as zipf:
+                for filename in target_files:
+                    if filename not in zipf.namelist():
+                        continue
+                        
+                    with zipf.open(filename, 'r') as f:
+                        wrapper = io.TextIOWrapper(f, encoding='utf-8-sig')
+                        # Use DictReader but we need to map headers manually based on field_mapping
+                        # Actually we can just read rows and map values
+                        reader = csv.DictReader(wrapper)
+                        
+                        for row in reader:
+                            # Construct data dict based on mapping
+                            # field_mapping: { "csv_col": "db_col" }
+                            data = {}
+                            for csv_col, db_col in field_mapping.items():
+                                if csv_col in row and db_col: # if db_col is not empty/none
+                                    val = row[csv_col]
+                                    # Handle special conversions if needed (e.g. boolean, nulls)
+                                    if val == "":
+                                        val = None
+                                    data[db_col] = val
+                            
+                            # Upsert Logic
+                            # We assume 'id' is present if mapped. 
+                            # If id exists, merge. Else add.
+                            if 'id' in data and data['id']:
+                                existing = db.query(model).filter(model.id == data['id']).first()
+                                if existing:
+                                    for k, v in data.items():
+                                        setattr(existing, k, v)
+                                else:
+                                    obj = model(**data)
+                                    db.add(obj)
+                            else:
+                                # No ID, just add? Might create duplicates.
+                                # Ideally we should map unique keys. 
+                                # For now, let's assume ID is required for restore to work correctly with relationships
+                                obj = model(**data)
+                                db.add(obj)
+                            
+                            restored_count += 1
+            
+            db.commit()
+            
+            # Log Operation
+            LogService.create_log(
+                db=db,
+                operator_id=current_user.id,
+                action_type=ActionType.UPDATE, # Using UPDATE generic for Restore
+                details={
+                    "event": "restore_data",
+                    "type": restore_type,
+                    "backup_id": backup_id,
+                    "count": restored_count
+                }
+            )
+            
+            return {"message": f"Successfully restored {restored_count} records", "count": restored_count}
+
+        except Exception as e:
+            db.rollback()
+            raise HTTPException(status_code=500, detail=f"Restore failed: {str(e)}")

+ 1 - 0
backend/requirements.txt

@@ -26,3 +26,4 @@ alibabacloud_dypnsapi20170525>=1.0.0
 alibabacloud_dysmsapi20170525>=1.0.0
 alibabacloud_tea_openapi>=0.3.0
 alibabacloud_tea_util>=0.3.0
+apscheduler>=3.10.0

+ 93 - 0
frontend/src/api/backup.ts

@@ -0,0 +1,93 @@
+import request from '../utils/request';
+
+export interface BackupRecord {
+  id: number;
+  filename: string;
+  backup_type: 'MANUAL' | 'AUTO';
+  content_types: string;
+  file_size: number;
+  created_at: string;
+}
+
+export interface BackupListResponse {
+  total: number;
+  items: BackupRecord[];
+}
+
+export interface BackupSettings {
+  id: number;
+  auto_backup_enabled: boolean;
+  backup_time: string; // HH:MM
+  last_backup_at?: string;
+}
+
+export interface BackupQueryParams {
+  skip?: number;
+  limit?: number;
+  start_date?: string;
+  end_date?: string;
+  sort_order?: 'asc' | 'desc';
+}
+
+export interface RestorePreviewResponse {
+  csv_headers: string[];
+  db_columns: string[];
+}
+
+export interface RestoreRequest {
+  restore_type: 'APPLICATIONS' | 'USERS' | 'MAPPINGS';
+  field_mapping: Record<string, string>;
+  password: string;
+  captcha_id: string;
+  captcha_code: string;
+}
+
+export const getBackups = (params?: BackupQueryParams) => {
+  return request({
+    url: '/backups/',
+    method: 'get',
+    params,
+  });
+};
+
+export const createBackup = () => {
+  return request({
+    url: '/backups/create',
+    method: 'post',
+  });
+};
+
+export const getBackupSettings = () => {
+  return request({
+    url: '/backups/settings',
+    method: 'get',
+  });
+};
+
+export const updateBackupSettings = (data: { auto_backup_enabled: boolean; backup_time: string }) => {
+  return request({
+    url: '/backups/settings',
+    method: 'put',
+    data,
+  });
+};
+
+export const getDownloadUrl = (id: number) => {
+  return `/backups/download/${id}`;
+};
+
+export const previewRestore = (id: number, type: string) => {
+  return request({
+    url: `/backups/${id}/restore/preview`,
+    method: 'get',
+    params: { type }
+  });
+};
+
+export const restoreBackup = (id: number, data: RestoreRequest) => {
+  return request({
+    url: `/backups/${id}/restore`,
+    method: 'post',
+    data
+  });
+};

+ 12 - 0
frontend/src/router/index.ts

@@ -73,6 +73,18 @@ const routes: Array<RouteRecordRaw> = [
         component: () => import('../views/admin/maintenance/SystemLogs.vue'),
         meta: { requiresAdmin: true }
       },
+      {
+        path: 'backup',
+        name: 'DataBackup',
+        component: () => import('../views/admin/maintenance/DataBackup.vue'),
+        meta: { requiresAdmin: true }
+      },
+      {
+        path: 'restore',
+        name: 'DataRestore',
+        component: () => import('../views/admin/maintenance/DataRestore.vue'),
+        meta: { requiresAdmin: true }
+      },
       {
         path: 'changelog',
         name: 'Changelog',

+ 9 - 1
frontend/src/views/Dashboard.vue

@@ -38,6 +38,14 @@
               <el-icon><Document /></el-icon>
               <span>后台日志</span>
             </el-menu-item>
+            <el-menu-item index="/dashboard/backup">
+              <el-icon><Download /></el-icon>
+              <span>数据备份</span>
+            </el-menu-item>
+            <el-menu-item index="/dashboard/restore">
+              <el-icon><RefreshRight /></el-icon>
+              <span>数据还原</span>
+            </el-menu-item>
           </el-sub-menu>
 
           <el-menu-item 
@@ -104,7 +112,7 @@
 import { computed, onMounted, ref, reactive } from 'vue'
 import { useRouter } from 'vue-router'
 import { useAuthStore } from '../store/auth'
-import { Grid, List, QuestionFilled, User, ArrowDown, Connection, Monitor, Document } from '@element-plus/icons-vue'
+import { Grid, List, QuestionFilled, User, ArrowDown, Connection, Monitor, Document, Download, RefreshRight } from '@element-plus/icons-vue'
 import { ElMessage, FormInstance, FormRules } from 'element-plus'
 import api from '../utils/request'
 

+ 326 - 0
frontend/src/views/admin/maintenance/DataBackup.vue

@@ -0,0 +1,326 @@
+<template>
+  <div class="app-container">
+    <el-card>
+      <template #header>
+        <div class="card-header">
+          <span>数据备份管理</span>
+        </div>
+      </template>
+
+      <!-- Settings Section -->
+      <div class="settings-section">
+        <h3>自动备份设置</h3>
+        <el-form :inline="true" :model="settingsForm" class="settings-form">
+          <el-form-item label="开启自动备份">
+            <el-switch v-model="settingsForm.auto_backup_enabled" />
+          </el-form-item>
+          <el-form-item label="备份时间 (每天)">
+            <el-time-select
+              v-model="settingsForm.backup_time"
+              start="00:00"
+              step="00:30"
+              end="23:30"
+              placeholder="选择时间"
+              :disabled="!settingsForm.auto_backup_enabled"
+            />
+          </el-form-item>
+          <el-form-item>
+            <el-button type="primary" @click="saveSettings" :loading="savingSettings">保存设置</el-button>
+          </el-form-item>
+        </el-form>
+        <div v-if="settingsForm.last_backup_at" class="last-backup-info">
+          上次自动备份时间: {{ formatDate(settingsForm.last_backup_at) }}
+        </div>
+      </div>
+
+      <el-divider />
+
+      <!-- Manual Backup Section -->
+      <div class="manual-backup-section">
+        <div class="header-actions">
+           <h3>备份记录</h3>
+           <el-button type="success" @click="handleManualBackup" :loading="creatingBackup">
+            立即备份
+           </el-button>
+        </div>
+        
+        <!-- Search Filter -->
+        <div class="filter-container">
+           <el-form :inline="true" :model="queryParams" class="demo-form-inline">
+             <el-form-item label="日期范围">
+               <el-date-picker
+                 v-model="dateRange"
+                 type="daterange"
+                 range-separator="至"
+                 start-placeholder="开始日期"
+                 end-placeholder="结束日期"
+                 value-format="YYYY-MM-DD"
+                 @change="handleFilterChange"
+               />
+             </el-form-item>
+             <el-form-item label="排序">
+                <el-select v-model="queryParams.sort_order" placeholder="选择排序" @change="handleFilterChange" style="width: 120px">
+                   <el-option label="时间倒序" value="desc" />
+                   <el-option label="时间正序" value="asc" />
+                </el-select>
+             </el-form-item>
+             <el-form-item>
+                <el-button type="primary" @click="handleFilterChange">查询</el-button>
+             </el-form-item>
+           </el-form>
+        </div>
+      </div>
+
+      <!-- Backup List Section -->
+      <div class="list-section">
+        <el-table :data="backupList" style="width: 100%" v-loading="loadingList">
+          <el-table-column prop="filename" label="文件名" min-width="200" />
+          <el-table-column prop="created_at" label="备份时间" width="180">
+             <template #default="scope">
+                {{ formatDate(scope.row.created_at) }}
+             </template>
+          </el-table-column>
+          <el-table-column prop="backup_type" label="类型" width="100">
+            <template #default="scope">
+              <el-tag :type="scope.row.backup_type === 'AUTO' ? 'info' : 'success'">
+                {{ scope.row.backup_type === 'AUTO' ? '自动' : '手动' }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="file_size" label="大小" width="120">
+            <template #default="scope">
+              {{ formatSize(scope.row.file_size) }}
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="120">
+            <template #default="scope">
+              <el-button type="primary" link @click="handleDownload(scope.row)">
+                下载
+              </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+        
+        <!-- Pagination -->
+        <div class="pagination-container">
+          <el-pagination
+            v-model:current-page="currentPage"
+            v-model:page-size="pageSize"
+            :page-sizes="[10, 20, 50, 100]"
+            layout="total, sizes, prev, pager, next, jumper"
+            :total="total"
+            @size-change="handleSizeChange"
+            @current-change="handleCurrentChange"
+          />
+        </div>
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue'
+import { ElMessage } from 'element-plus'
+import { 
+  getBackups, 
+  createBackup, 
+  getBackupSettings, 
+  updateBackupSettings,
+  getDownloadUrl,
+  type BackupRecord 
+} from '../../../api/backup'
+
+// --- State ---
+const settingsForm = reactive({
+  auto_backup_enabled: false,
+  backup_time: '02:00',
+  last_backup_at: null as string | null
+})
+const savingSettings = ref(false)
+
+const creatingBackup = ref(false)
+
+const backupList = ref<BackupRecord[]>([])
+const loadingList = ref(false)
+const total = ref(0)
+const currentPage = ref(1)
+const pageSize = ref(20)
+
+const dateRange = ref<string[] | null>(null)
+const queryParams = reactive({
+  sort_order: 'desc'
+})
+
+// --- Methods ---
+
+const loadSettings = async () => {
+  try {
+    const res = await getBackupSettings()
+    const data = res.data
+    settingsForm.auto_backup_enabled = data.auto_backup_enabled
+    settingsForm.backup_time = data.backup_time
+    settingsForm.last_backup_at = data.last_backup_at
+  } catch (e) {
+    // ElMessage.error('加载设置失败')
+    console.error(e)
+  }
+}
+
+const saveSettings = async () => {
+  savingSettings.value = true
+  try {
+    await updateBackupSettings({
+      auto_backup_enabled: settingsForm.auto_backup_enabled,
+      backup_time: settingsForm.backup_time
+    })
+    ElMessage.success('设置保存成功')
+  } catch (e) {
+    ElMessage.error('保存设置失败')
+  } finally {
+    savingSettings.value = false
+  }
+}
+
+const loadBackups = async () => {
+  loadingList.value = true
+  try {
+    const params: any = {
+       skip: (currentPage.value - 1) * pageSize.value,
+       limit: pageSize.value,
+       sort_order: queryParams.sort_order
+    }
+    
+    if (dateRange.value && dateRange.value.length === 2) {
+        params.start_date = dateRange.value[0]
+        params.end_date = dateRange.value[1]
+    }
+    
+    const res = await getBackups(params)
+    // Adjust for new response structure { total, items }
+    if (res.data && Array.isArray(res.data.items)) {
+       backupList.value = res.data.items
+       total.value = res.data.total
+    } else {
+       // Fallback for old API if cached or mismatch
+       backupList.value = Array.isArray(res.data) ? res.data : []
+       total.value = backupList.value.length
+    }
+
+  } catch (e) {
+    ElMessage.error('加载备份列表失败')
+  } finally {
+    loadingList.value = false
+  }
+}
+
+const handleFilterChange = () => {
+    currentPage.value = 1
+    loadBackups()
+}
+
+const handleSizeChange = (val: number) => {
+  pageSize.value = val
+  loadBackups()
+}
+
+const handleCurrentChange = (val: number) => {
+  currentPage.value = val
+  loadBackups()
+}
+
+const handleManualBackup = async () => {
+  creatingBackup.value = true
+  try {
+    await createBackup()
+    ElMessage.success('备份成功')
+    // Reset to first page to see new backup
+    currentPage.value = 1
+    queryParams.sort_order = 'desc' 
+    loadBackups()
+  } catch (e) {
+    ElMessage.error('备份失败')
+  } finally {
+    creatingBackup.value = false
+  }
+}
+
+const handleDownload = async (record: BackupRecord) => {
+  try {
+    const token = localStorage.getItem('token')
+    const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1'
+    const relativeUrl = getDownloadUrl(record.id)
+    
+    const response = await fetch(`${baseUrl}${relativeUrl}`, {
+      headers: {
+        'Authorization': `Bearer ${token}`
+      }
+    })
+    
+    if (!response.ok) throw new Error('Download failed')
+    
+    const blob = await response.blob()
+    const downloadUrl = window.URL.createObjectURL(blob)
+    const a = document.createElement('a')
+    a.href = downloadUrl
+    a.download = record.filename
+    document.body.appendChild(a)
+    a.click()
+    document.body.removeChild(a)
+    window.URL.revokeObjectURL(downloadUrl)
+  } catch (e) {
+    ElMessage.error('下载失败')
+  }
+}
+
+const formatSize = (bytes: number) => {
+  if (!bytes && bytes !== 0) return '未知'
+  if (bytes === 0) return '0 B'
+  const k = 1024
+  const sizes = ['B', 'KB', 'MB', 'GB']
+  const i = Math.floor(Math.log(bytes) / Math.log(k))
+  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+}
+
+const formatDate = (dateStr: string) => {
+    if (!dateStr) return '-'
+    return new Date(dateStr).toLocaleString()
+}
+
+// --- Lifecycle ---
+onMounted(() => {
+  loadSettings()
+  loadBackups()
+})
+</script>
+
+<style scoped>
+.app-container {
+  padding: 20px;
+}
+.settings-section, .manual-backup-section, .list-section {
+  padding: 10px 0;
+}
+.last-backup-info {
+    font-size: 12px;
+    color: #666;
+    margin-top: -10px;
+    margin-bottom: 10px;
+}
+.header-actions {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 15px;
+}
+.filter-container {
+    background-color: #f5f7fa;
+    padding: 15px 15px 0 15px;
+    border-radius: 4px;
+    margin-bottom: 20px;
+}
+.pagination-container {
+    margin-top: 20px;
+    display: flex;
+    justify-content: flex-end;
+}
+</style>

+ 354 - 0
frontend/src/views/admin/maintenance/DataRestore.vue

@@ -0,0 +1,354 @@
+<template>
+  <div class="app-container">
+    <el-card>
+      <template #header>
+        <div class="card-header">
+          <span>数据还原管理</span>
+          <el-alert
+             title="警告:数据还原是高风险操作,请谨慎执行。建议还原前先进行备份。"
+             type="warning"
+             show-icon
+             :closable="false"
+             style="margin-left: 20px; flex: 1"
+          />
+        </div>
+      </template>
+
+      <!-- Step 1: Select Backup -->
+      <div v-show="activeStep === 0">
+        <h3>第一步:选择备份文件</h3>
+        <el-table :data="backupList" style="width: 100%" v-loading="loadingList" highlight-current-row @current-change="handleBackupSelect">
+          <el-table-column width="50">
+             <template #default="scope">
+                <el-radio v-model="selectedBackupId" :label="scope.row.id">&nbsp;</el-radio>
+             </template>
+          </el-table-column>
+          <el-table-column prop="filename" label="文件名" min-width="200" />
+          <el-table-column prop="created_at" label="备份时间" width="180">
+             <template #default="scope">
+                {{ formatDate(scope.row.created_at) }}
+             </template>
+          </el-table-column>
+          <el-table-column prop="backup_type" label="类型" width="100">
+            <template #default="scope">
+              <el-tag :type="scope.row.backup_type === 'AUTO' ? 'info' : 'success'">
+                {{ scope.row.backup_type === 'AUTO' ? '自动' : '手动' }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="file_size" label="大小" width="120">
+            <template #default="scope">
+              {{ formatSize(scope.row.file_size) }}
+            </template>
+          </el-table-column>
+        </el-table>
+        
+        <div class="step-footer">
+           <el-pagination
+            v-model:current-page="currentPage"
+            v-model:page-size="pageSize"
+            layout="total, prev, pager, next"
+            :total="total"
+            @current-change="loadBackups"
+          />
+           <el-button type="primary" :disabled="!selectedBackupId" @click="nextStep">下一步</el-button>
+        </div>
+      </div>
+
+      <!-- Step 2: Select Type & Map Fields -->
+      <div v-show="activeStep === 1">
+        <h3>第二步:选择还原内容与字段映射</h3>
+        
+        <el-form label-width="120px">
+           <el-form-item label="还原内容">
+             <el-radio-group v-model="restoreType" @change="handleTypeChange">
+               <el-radio label="APPLICATIONS">应用管理数据 (Applications)</el-radio>
+               <el-radio label="MAPPINGS">账号映射数据 (Mappings)</el-radio>
+               <el-radio label="USERS">用户数据 (Users)</el-radio>
+             </el-radio-group>
+             <div class="type-hint">
+                <el-alert v-if="restoreType === 'MAPPINGS'" title="注意:请确保对应的应用已经存在或已还原,否则可能导致还原失败。" type="warning" show-icon :closable="false" />
+                <el-alert v-if="restoreType === 'APPLICATIONS'" title="还原应用数据可能会覆盖现有的应用配置。" type="info" show-icon :closable="false" />
+             </div>
+           </el-form-item>
+        </el-form>
+
+        <div v-loading="loadingPreview" class="mapping-container">
+           <h4>字段映射配置</h4>
+           <div v-if="csvHeaders.length === 0 && !loadingPreview" class="no-data-hint">
+              无法从备份中读取到该类型的CSV文件头,请确认备份文件包含该数据。
+           </div>
+           <el-table v-else :data="mappingData" border style="width: 100%">
+              <el-table-column prop="csv" label="备份文件字段 (CSV Header)" />
+              <el-table-column label="数据库字段 (DB Column)">
+                 <template #default="scope">
+                    <el-select v-model="scope.row.db" placeholder="选择目标字段" clearable filterable>
+                       <el-option v-for="col in dbColumns" :key="col" :label="col" :value="col" />
+                    </el-select>
+                 </template>
+              </el-table-column>
+           </el-table>
+        </div>
+
+        <div class="step-footer">
+           <el-button @click="activeStep = 0">上一步</el-button>
+           <el-button type="primary" :disabled="csvHeaders.length === 0" @click="openConfirmDialog">开始还原</el-button>
+        </div>
+      </div>
+
+    </el-card>
+
+    <!-- Confirmation Dialog -->
+    <el-dialog v-model="confirmVisible" title="安全验证与确认" width="500px">
+       <el-alert
+         title="操作不可逆!"
+         description="确认还原操作?这将修改数据库中的数据。请再次确认已做好备份。"
+         type="error"
+         show-icon
+         :closable="false"
+         style="margin-bottom: 20px"
+       />
+       
+       <el-form :model="confirmForm" label-width="80px">
+          <el-form-item label="登录密码">
+             <el-input v-model="confirmForm.password" type="password" show-password placeholder="请输入当前登录密码" />
+          </el-form-item>
+          <el-form-item label="验证码">
+             <div class="captcha-row">
+               <el-input v-model="confirmForm.captcha_code" placeholder="验证码" style="width: 150px" />
+               <div class="captcha-img" @click="refreshCaptcha" v-if="captchaImage">
+                  <img :src="captchaImage" alt="captcha" />
+               </div>
+             </div>
+          </el-form-item>
+       </el-form>
+
+       <template #footer>
+          <el-button @click="confirmVisible = false">取消</el-button>
+          <el-button type="danger" :loading="restoring" @click="handleRestore">确认还原</el-button>
+       </template>
+    </el-dialog>
+
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted, computed, watch } from 'vue'
+import { ElMessage } from 'element-plus'
+import { 
+  getBackups, 
+  previewRestore,
+  restoreBackup,
+  type BackupRecord 
+} from '../../../api/backup'
+import api from '../../../utils/request'
+
+// --- State ---
+const activeStep = ref(0)
+const backupList = ref<BackupRecord[]>([])
+const loadingList = ref(false)
+const total = ref(0)
+const currentPage = ref(1)
+const pageSize = ref(10)
+const selectedBackupId = ref<number | null>(null)
+
+const restoreType = ref('APPLICATIONS')
+const loadingPreview = ref(false)
+const csvHeaders = ref<string[]>([])
+const dbColumns = ref<string[]>([])
+const mappingData = ref<{csv: string, db: string}[]>([])
+
+const confirmVisible = ref(false)
+const restoring = ref(false)
+const confirmForm = reactive({
+  password: '',
+  captcha_id: '',
+  captcha_code: ''
+})
+const captchaImage = ref('')
+
+// --- Methods: Step 1 ---
+const loadBackups = async () => {
+  loadingList.value = true
+  try {
+    const res = await getBackups({
+       skip: (currentPage.value - 1) * pageSize.value,
+       limit: pageSize.value,
+       sort_order: 'desc'
+    })
+    if (res.data && Array.isArray(res.data.items)) {
+       backupList.value = res.data.items
+       total.value = res.data.total
+    } else {
+       backupList.value = []
+    }
+  } catch (e) {
+    ElMessage.error('加载备份列表失败')
+  } finally {
+    loadingList.value = false
+  }
+}
+
+const handleBackupSelect = (row: BackupRecord | undefined) => {
+    if (row) selectedBackupId.value = row.id
+}
+
+const nextStep = () => {
+    if (!selectedBackupId.value) return
+    activeStep.value = 1
+    loadPreview()
+}
+
+// --- Methods: Step 2 ---
+const loadPreview = async () => {
+   if (!selectedBackupId.value) return
+   loadingPreview.value = true
+   try {
+      const res = await previewRestore(selectedBackupId.value, restoreType.value)
+      csvHeaders.value = res.data.csv_headers
+      dbColumns.value = res.data.db_columns
+      
+      // Auto mapping
+      mappingData.value = csvHeaders.value.map(header => {
+         // Try exact match
+         let match = dbColumns.value.find(col => col === header)
+         // Try simple normalization (e.g. secret -> app_secret?) - Optional
+         return {
+            csv: header,
+            db: match || ''
+         }
+      })
+   } catch (e) {
+      ElMessage.error('读取备份文件失败或格式错误')
+      csvHeaders.value = []
+   } finally {
+      loadingPreview.value = false
+   }
+}
+
+const handleTypeChange = () => {
+    loadPreview()
+}
+
+// --- Methods: Confirmation ---
+const refreshCaptcha = async () => {
+  try {
+    const res = await api.get('/utils/captcha')
+    confirmForm.captcha_id = res.data.captcha_id
+    captchaImage.value = res.data.image
+    confirmForm.captcha_code = ''
+  } catch (e) {
+    ElMessage.error('获取验证码失败')
+  }
+}
+
+const openConfirmDialog = () => {
+   confirmForm.password = ''
+   refreshCaptcha()
+   confirmVisible.value = true
+}
+
+const handleRestore = async () => {
+    if (!confirmForm.password || !confirmForm.captcha_code) {
+        ElMessage.warning('请输入密码和验证码')
+        return
+    }
+    
+    // Build mapping dict
+    const field_mapping: Record<string, string> = {}
+    mappingData.value.forEach(item => {
+        if (item.db) {
+            field_mapping[item.csv] = item.db
+        }
+    })
+    
+    restoring.value = true
+    try {
+        const res = await restoreBackup(selectedBackupId.value!, {
+            restore_type: restoreType.value as any,
+            field_mapping,
+            password: confirmForm.password,
+            captcha_id: confirmForm.captcha_id,
+            captcha_code: confirmForm.captcha_code
+        })
+        
+        ElMessage.success(res.data.message || '还原成功')
+        confirmVisible.value = false
+        activeStep.value = 0
+    } catch (e: any) {
+        // Handle error message
+        const msg = e.response?.data?.detail || '还原失败'
+        ElMessage.error(msg)
+        // Refresh captcha on failure
+        refreshCaptcha()
+    } finally {
+        restoring.value = false
+    }
+}
+
+// --- Utils ---
+const formatSize = (bytes: number) => {
+  if (!bytes && bytes !== 0) return '未知'
+  if (bytes === 0) return '0 B'
+  const k = 1024
+  const sizes = ['B', 'KB', 'MB', 'GB']
+  const i = Math.floor(Math.log(bytes) / Math.log(k))
+  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+}
+
+const formatDate = (dateStr: string) => {
+    if (!dateStr) return '-'
+    return new Date(dateStr).toLocaleString()
+}
+
+onMounted(() => {
+   loadBackups()
+})
+</script>
+
+<style scoped>
+.app-container {
+  padding: 20px;
+}
+.card-header {
+  display: flex;
+  align-items: center;
+}
+.step-footer {
+    margin-top: 20px;
+    display: flex;
+    justify-content: flex-end;
+    gap: 10px;
+    align-items: center;
+}
+.type-hint {
+    margin-top: 10px;
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+}
+.mapping-container {
+    margin-top: 30px;
+}
+.no-data-hint {
+    color: #909399;
+    padding: 20px;
+    text-align: center;
+    border: 1px dashed #dcdfe6;
+}
+.captcha-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+.captcha-img {
+  cursor: pointer;
+  height: 32px;
+  display: flex;
+  align-items: center;
+}
+.captcha-img img {
+    height: 100%;
+}
+</style>
+