From b2ba75a66682912fe3ef45baae11a7f3c6c43a99 Mon Sep 17 00:00:00 2001 From: qyyfw Date: Fri, 3 Jul 2026 14:45:10 +0800 Subject: [PATCH] Phase 3.1: OSS storage config management (MinIO/AliyunOSS/AWS S3) Backend: - Add OSSStorageConfig model (models.py) - Create storage_admin.py API (9 endpoints): stats, CRUD, toggle, set-default, test connection - Register route /app/api/admin/storage in main.py - Create oss_storage_configs table in MySQL Frontend: - Add Storage.tsx admin page (713 lines) with stats, table, create/edit modal - Register /admin/storage route in App.tsx - Add nav item and icon in AdminLayout.tsx - Add 9 API methods in api/index.ts --- backend/python/app/api/admin/storage_admin.py | 582 ++++++++++++++ backend/python/app/models.py | 19 + backend/python/main.py | 645 ++++++++++------ frontend/src/App.tsx | 377 +++++---- frontend/src/api/index.ts | 14 + .../src/components/layout/AdminLayout.tsx | 383 ++++++---- frontend/src/pages/admin/Storage.tsx | 713 ++++++++++++++++++ 7 files changed, 2265 insertions(+), 468 deletions(-) create mode 100644 backend/python/app/api/admin/storage_admin.py create mode 100644 frontend/src/pages/admin/Storage.tsx diff --git a/backend/python/app/api/admin/storage_admin.py b/backend/python/app/api/admin/storage_admin.py new file mode 100644 index 0000000..12dfc93 --- /dev/null +++ b/backend/python/app/api/admin/storage_admin.py @@ -0,0 +1,582 @@ +""" +OSS存储配置管理 API +Phase 3.1: 支持 MinIO / 阿里云OSS / AWS S3 等对象存储配置 +""" +from fastapi import APIRouter, Depends, HTTPException, Query, Body +from sqlalchemy import or_, func, desc, asc, text +from sqlalchemy.orm import Session +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Any +from datetime import datetime +import uuid +import json +import logging +import httpx + +from ...database import get_db +from ...models import ( + gen_uuid, +) +from ...auth import get_current_admin +from ...middleware.log_service import LogService + +router = APIRouter() +logger = logging.getLogger("aiflow") + + +# ============================================================ +# Pydantic Models +# ============================================================ + +class OSSConfigCreate(BaseModel): + name: str = Field(min_length=1, max_length=200) + provider: str = Field(min_length=1, max_length=20) + endpoint: str = Field(min_length=1, max_length=500) + access_key: str = Field(min_length=1, max_length=255) + secret_key: str = Field(min_length=1, max_length=255) + bucket: str = Field(min_length=1, max_length=200) + region: Optional[str] = "" + base_path: Optional[str] = "" + is_default: bool = False + is_enabled: bool = True + config: Optional[Dict[str, Any]] = None + + +class OSSConfigUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=200) + provider: Optional[str] = Field(None, min_length=1, max_length=20) + endpoint: Optional[str] = Field(None, min_length=1, max_length=500) + access_key: Optional[str] = Field(None, min_length=1, max_length=255) + secret_key: Optional[str] = Field(None, min_length=1, max_length=255) + bucket: Optional[str] = Field(None, min_length=1, max_length=200) + region: Optional[str] = None + base_path: Optional[str] = None + is_default: Optional[bool] = None + is_enabled: Optional[bool] = None + config: Optional[Dict[str, Any]] = None + + +class OSSConnectionTest(BaseModel): + """测试连接请求 - 可以测试已保存的配置或临时配置""" + config_id: Optional[str] = None # 已保存的配置ID + # 以下为临时测试参数 + provider: Optional[str] = None + endpoint: Optional[str] = None + access_key: Optional[str] = None + secret_key: Optional[str] = None + bucket: Optional[str] = None + region: Optional[str] = None + + +# ============================================================ +# Helper Functions +# ============================================================ + +def config_to_dict(config) -> dict: + """将OSSStorageConfig模型转换为字典""" + result = { + "id": config.id, + "name": config.name, + "provider": config.provider, + "endpoint": config.endpoint, + "access_key": config.access_key[:8] + "****" if config.access_key and len(config.access_key) > 8 else "****", + "secret_key": "****", # 永远不返回完整密钥 + "bucket": config.bucket, + "region": config.region or "", + "base_path": config.base_path or "", + "is_default": getattr(config, "is_default", False), + "is_enabled": config.is_enabled, + "status": config.status or "disconnected", + "created_at": config.created_at.isoformat() if config.created_at else None, + "updated_at": config.updated_at.isoformat() if config.updated_at else None, + } + + # 安全处理 JSON 字段 + config_raw = getattr(config, "config", None) + if config_raw: + try: + result["config"] = json.loads(config_raw) if isinstance(config_raw, str) else config_raw + except Exception: + result["config"] = {} + else: + result["config"] = {} + + return result + + +async def test_oss_connection(provider: str, endpoint: str, access_key: str, secret_key: str, + bucket: str, region: str = "") -> Dict[str, Any]: + """测试OSS连接 - 使用httpx发送HTTP请求到对象存储端点""" + import time + start_time = time.time() + + try: + if provider in ("minio", "aws_s3"): + # 使用 AWS S3 兼容 API 测试 + # ListBuckets 请求 + import hmac + import hashlib + from urllib.parse import urlparse + + parsed = urlparse(endpoint) + host = parsed.netloc or parsed.path + if not host: + return {"success": False, "error": "无法解析端点地址", "latency_ms": 0} + + # 先尝试简单的 bucket HEAD 请求 + bucket_url = f"{endpoint.rstrip('/')}/{bucket}" + async with httpx.AsyncClient(timeout=10.0, verify=False) as client: + resp = await client.head(bucket_url) + latency = int((time.time() - start_time) * 1000) + + if resp.status_code in (200, 403): + # 200: bucket存在且有权限; 403: bucket存在但无权限(也是连通的表现) + return { + "success": True, + "message": f"连接成功!Bucket '{bucket}' 可访问", + "latency_ms": latency, + "status_code": resp.status_code + } + elif resp.status_code == 404: + return { + "success": False, + "error": f"Bucket '{bucket}' 不存在", + "latency_ms": latency + } + else: + return { + "success": False, + "error": f"连接失败: HTTP {resp.status_code}", + "latency_ms": latency + } + + elif provider == "aliyun_oss": + # 阿里云OSS: 简单HEAD请求 + bucket_url = f"{endpoint.rstrip('/')}/{bucket}" if not endpoint.endswith(bucket) else endpoint + async with httpx.AsyncClient(timeout=10.0, verify=False) as client: + resp = await client.head(bucket_url) + latency = int((time.time() - start_time) * 1000) + + if resp.status_code in (200, 403): + return { + "success": True, + "message": f"连接成功!Bucket '{bucket}' 可访问", + "latency_ms": latency, + "status_code": resp.status_code + } + elif resp.status_code == 404: + return { + "success": False, + "error": f"Bucket '{bucket}' 不存在", + "latency_ms": latency + } + else: + return { + "success": False, + "error": f"连接失败: HTTP {resp.status_code}", + "latency_ms": latency + } + + else: + return {"success": False, "error": f"不支持的存储类型: {provider}", "latency_ms": 0} + + except httpx.ConnectError: + latency = int((time.time() - start_time) * 1000) + return {"success": False, "error": f"无法连接到 {endpoint}", "latency_ms": latency} + except httpx.TimeoutException: + latency = int((time.time() - start_time) * 1000) + return {"success": False, "error": "连接超时(10秒)", "latency_ms": latency} + except Exception as e: + latency = int((time.time() - start_time) * 1000) + return {"success": False, "error": f"测试失败: {str(e)}", "latency_ms": latency} + + +# ============================================================ +# API Endpoints +# ============================================================ + +@router.get("/stats/overview", summary="存储配置统计概览") +async def get_storage_stats( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取存储配置的统计信息""" + from ...models import OSSStorageConfig + + total = db.query(func.count(OSSStorageConfig.id)).scalar() or 0 + enabled_count = db.query(func.count(OSSStorageConfig.id)).filter( + OSSStorageConfig.is_enabled == True + ).scalar() or 0 + default_count = db.query(func.count(OSSStorageConfig.id)).filter( + OSSStorageConfig.is_default == True + ).scalar() or 0 + + # 按类型统计 + by_provider = {} + providers = db.query(OSSStorageConfig.provider, func.count(OSSStorageConfig.id)).group_by( + OSSStorageConfig.provider + ).all() + for provider, count in providers: + by_provider[provider] = count + + # 按状态统计 + by_status = {} + statuses = db.query(OSSStorageConfig.status, func.count(OSSStorageConfig.id)).group_by( + OSSStorageConfig.status + ).all() + for status, count in statuses: + by_status[status] = count + + return { + "code": 0, + "data": { + "total": total, + "enabled_count": enabled_count, + "default_count": default_count, + "by_provider": by_provider, + "by_status": by_status, + }, + "message": "ok" + } + + +@router.get("/", summary="获取OSS存储配置列表") +async def list_storage_configs( + provider: str = Query(None, description="按提供商筛选"), + is_enabled: bool = Query(None, description="按启用状态筛选"), + keyword: str = Query(None, description="搜索关键词"), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取OSS存储配置列表,支持分页和筛选""" + from ...models import OSSStorageConfig + + query = db.query(OSSStorageConfig) + + if provider: + query = query.filter(OSSStorageConfig.provider == provider) + if is_enabled is not None: + query = query.filter(OSSStorageConfig.is_enabled == is_enabled) + if keyword: + keyword_filter = or_( + OSSStorageConfig.name.ilike(f"%{keyword}%"), + OSSStorageConfig.endpoint.ilike(f"%{keyword}%"), + OSSStorageConfig.bucket.ilike(f"%{keyword}%"), + ) + query = query.filter(keyword_filter) + + total = query.count() + items = query.order_by(desc(OSSStorageConfig.is_default), desc(OSSStorageConfig.updated_at)) \ + .offset((page - 1) * page_size).limit(page_size).all() + + return { + "code": 0, + "data": { + "items": [config_to_dict(item) for item in items], + "total": total, + "page": page, + "page_size": page_size, + }, + "message": "ok" + } + + +@router.get("/{config_id}", summary="获取OSS存储配置详情") +async def get_storage_config( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取单个OSS存储配置的详细信息""" + from ...models import OSSStorageConfig + + config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == config_id).first() + if not config: + raise HTTPException(status_code=404, detail="存储配置不存在") + + return { + "code": 0, + "data": config_to_dict(config), + "message": "ok" + } + + +@router.post("/", summary="创建OSS存储配置") +async def create_storage_config( + req: OSSConfigCreate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """创建新的OSS存储配置""" + from ...models import OSSStorageConfig + + # 检查名称重复 + existing = db.query(OSSStorageConfig).filter(OSSStorageConfig.name == req.name).first() + if existing: + raise HTTPException(status_code=400, detail="配置名称已存在") + + # 如果设为默认,先取消其他默认 + if req.is_default: + db.query(OSSStorageConfig).filter(OSSStorageConfig.is_default == True).update( + {"is_default": False} + ) + + config = OSSStorageConfig( + id=gen_uuid(), + name=req.name, + provider=req.provider, + endpoint=req.endpoint, + access_key=req.access_key, + secret_key=req.secret_key, + bucket=req.bucket, + region=req.region or "", + base_path=req.base_path or "", + is_default=req.is_default, + is_enabled=req.is_enabled, + status="disconnected", + config=req.config, + ) + + db.add(config) + db.commit() + db.refresh(config) + + # 审计日志 + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="create_oss_config", + target_type="oss_storage_config", + target_id=config.id, + detail=f"创建了OSS存储配置: {config.name} ({config.provider})" + ) + + return { + "code": 0, + "data": config_to_dict(config), + "message": "创建成功" + } + + +@router.put("/{config_id}", summary="更新OSS存储配置") +async def update_storage_config( + config_id: str, + req: OSSConfigUpdate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """更新OSS存储配置""" + from ...models import OSSStorageConfig + + config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == config_id).first() + if not config: + raise HTTPException(status_code=404, detail="存储配置不存在") + + # 检查名称重复 + if req.name is not None and req.name != config.name: + existing = db.query(OSSStorageConfig).filter( + OSSStorageConfig.name == req.name, + OSSStorageConfig.id != config_id + ).first() + if existing: + raise HTTPException(status_code=400, detail="配置名称已存在") + + # 如果设为默认,先取消其他默认 + if req.is_default and not config.is_default: + db.query(OSSStorageConfig).filter(OSSStorageConfig.is_default == True).update( + {"is_default": False} + ) + + # 更新字段 + update_fields = ["name", "provider", "endpoint", "access_key", "secret_key", + "bucket", "region", "base_path", "is_default", "is_enabled"] + for field in update_fields: + value = getattr(req, field, None) + if value is not None: + setattr(config, field, value) + # 如果修改了连接参数,重置状态 + if field in ("endpoint", "access_key", "secret_key", "bucket", "provider"): + config.status = "disconnected" + + if req.config is not None: + config.config = req.config + + config.updated_at = datetime.now() + + db.commit() + db.refresh(config) + + # 审计日志 + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="update_oss_config", + target_type="oss_storage_config", + target_id=config.id, + detail=f"更新了OSS存储配置: {config.name}" + ) + + return { + "code": 0, + "data": config_to_dict(config), + "message": "更新成功" + } + + +@router.delete("/{config_id}", summary="删除OSS存储配置") +async def delete_storage_config( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """删除OSS存储配置""" + from ...models import OSSStorageConfig + + config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == config_id).first() + if not config: + raise HTTPException(status_code=404, detail="存储配置不存在") + + name = config.name + db.delete(config) + db.commit() + + # 审计日志 + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="delete_oss_config", + target_type="oss_storage_config", + target_id=config_id, + detail=f"删除了OSS存储配置: {name}" + ) + + return { + "code": 0, + "data": None, + "message": f"已删除配置: {name}" + } + + +@router.put("/{config_id}/toggle", summary="启用/禁用存储配置") +async def toggle_storage_config( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """切换存储配置的启用/禁用状态""" + from ...models import OSSStorageConfig + + config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == config_id).first() + if not config: + raise HTTPException(status_code=404, detail="存储配置不存在") + + config.is_enabled = not config.is_enabled + config.updated_at = datetime.now() + db.commit() + db.refresh(config) + + action = "启用" if config.is_enabled else "禁用" + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="toggle_oss_config", + target_type="oss_storage_config", + target_id=config.id, + detail=f"{action}了OSS存储配置: {config.name}" + ) + + return { + "code": 0, + "data": config_to_dict(config), + "message": f"已{action}" + } + + +@router.put("/{config_id}/set-default", summary="设为默认存储配置") +async def set_default_storage_config( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """将指定配置设为默认存储""" + from ...models import OSSStorageConfig + + config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == config_id).first() + if not config: + raise HTTPException(status_code=404, detail="存储配置不存在") + + # 取消所有默认 + db.query(OSSStorageConfig).filter(OSSStorageConfig.is_default == True).update( + {"is_default": False} + ) + + # 设置新默认 + config.is_default = True + config.updated_at = datetime.now() + db.commit() + db.refresh(config) + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="set_default_oss_config", + target_type="oss_storage_config", + target_id=config.id, + detail=f"将 {config.name} 设为默认存储配置" + ) + + return { + "code": 0, + "data": config_to_dict(config), + "message": f"已将 {config.name} 设为默认存储" + } + + +@router.post("/test", summary="测试OSS连接") +async def test_storage_connection( + req: OSSConnectionTest, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """测试OSS存储连接""" + from ...models import OSSStorageConfig + + provider = req.provider + endpoint = req.endpoint + access_key = req.access_key + secret_key = req.secret_key + bucket = req.bucket + region = req.region or "" + + # 如果提供了config_id,从数据库获取配置 + if req.config_id: + config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == req.config_id).first() + if not config: + raise HTTPException(status_code=404, detail="存储配置不存在") + provider = config.provider + endpoint = config.endpoint + access_key = config.access_key + secret_key = config.secret_key + bucket = config.bucket + region = config.region or "" + + if not all([provider, endpoint, access_key, secret_key, bucket]): + raise HTTPException(status_code=400, detail="缺少必要的连接参数") + + result = await test_oss_connection(provider, endpoint, access_key, secret_key, bucket, region) + + # 如果是已保存的配置,更新连接状态 + if req.config_id: + config = db.query(OSSStorageConfig).filter(OSSStorageConfig.id == req.config_id).first() + if config: + config.status = "connected" if result["success"] else "disconnected" + config.updated_at = datetime.now() + db.commit() + + return { + "code": 0, + "data": result, + "message": "连接测试完成" + } diff --git a/backend/python/app/models.py b/backend/python/app/models.py index e90de0c..e1624fb 100644 --- a/backend/python/app/models.py +++ b/backend/python/app/models.py @@ -979,3 +979,22 @@ class ExternalKBService(Base): status = Column(String(20), default="disconnected") created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + +class OSSStorageConfig(Base): + __tablename__ = "oss_storage_configs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(200), nullable=False) + provider = Column(String(20), nullable=False) + endpoint = Column(String(500), nullable=False) + access_key = Column(String(255), nullable=False) + secret_key = Column(String(255), nullable=False) + bucket = Column(String(200), nullable=False) + region = Column(String(100), default="") + base_path = Column(String(300), default="") + is_default = Column(Boolean, default=False) + is_enabled = Column(Boolean, default=True) + status = Column(String(20), default="disconnected") + config = Column(JSON, default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) diff --git a/backend/python/main.py b/backend/python/main.py index fafe610..13bbb55 100644 --- a/backend/python/main.py +++ b/backend/python/main.py @@ -1,217 +1,432 @@ -""" -Qihu-AIFlow FastAPI 主应用 -完整后端API,监听18080端口,所有接口以 /app 为前缀 -""" -import sys -import os -import time -import uuid -import logging -from contextlib import asynccontextmanager -from fastapi import FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware -from starlette.middleware.base import BaseHTTPMiddleware -from datetime import datetime - -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution -from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin -from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD -from app.database import SessionLocal -from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel - -# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1) -import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册 - -# 初始化文件日志(按天轮转) -access_logger = setup_file_logging() -logger = logging.getLogger("aiflow") - - -class APITrackingMiddleware(BaseHTTPMiddleware): - """API调用统计中间件 - 记录请求耗时,标记慢请求""" - - async def dispatch(self, request: Request, call_next): - request_id = str(uuid.uuid4()) - start_time = time.time() - path = request.url.path - method = request.method - - # 跳过健康检查和静态资源 - skip_paths = ["/app/api/health", "/favicon.ico"] - should_skip = any(path.startswith(p) for p in skip_paths) - - try: - response = await call_next(request) - elapsed = time.time() - start_time - status_code = response.status_code - - if not should_skip: - access_logger.info( - f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)" - ) - - # 异步记录API调用统计 - try: - db = SessionLocal() - try: - _record_api_call(db, method, path, status_code, elapsed) - finally: - db.close() - except Exception as e: - logger.warning(f"API统计记录失败: {e}") - - return response - except Exception as e: - elapsed = time.time() - start_time - if not should_skip: - access_logger.info( - f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}" - ) - raise - - async def __call__(self, scope, receive, send): - if scope["type"] != "http": - await self.app(scope, receive, send) - return - await super().__call__(scope, receive, send) - - -def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float): - """记录API调用到数据库统计表""" - # 简化路径以减少基数(去掉动态ID) - parts = path.split("/") - simplified_parts = [] - for part in parts: - # 将UUID样式的路径段替换为{id} - if len(part) == 36 and part.count("-") == 4: - simplified_parts.append("{id}") - # 将纯数字路径段替换为{id} - elif part.isdigit(): - simplified_parts.append("{id}") - else: - simplified_parts.append(part) - simplified_path = "/".join(simplified_parts) - - # 统计记录通过数据库实现,简化版记录到日志 - is_error = status_code >= 400 - is_slow = elapsed >= SLOW_REQUEST_THRESHOLD - - if is_slow: - logger.warning( - f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)" - ) - - -@asynccontextmanager -async def lifespan(app: FastAPI): - logger.info("Qihu-AIFlow v2.0.0 启动中...") - - # 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行) - try: - from app.services.evolution_service import EvolutionWatcherManager - from app.models import UserEvolutionConfig - db = SessionLocal() - try: - configs = db.query(UserEvolutionConfig).filter( - UserEvolutionConfig.watcher_status == "running" - ).all() - if configs: - watcher = EvolutionWatcherManager() - for cfg in configs: - try: - watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal()) - logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline") - except Exception as e: - logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}") - logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline") - else: - logger.info("[Evolution] 无待恢复的进化配置") - finally: - db.close() - except Exception as e: - logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}") - - # 启动Redis事件总线 - try: - from app.event_bus import get_event_bus - event_bus = get_event_bus() - event_bus.start(num_consumers=2) - logger.info("[EventBus] Redis事件总线已启动") - except Exception as e: - logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}") - - logger.info("Qihu-AIFlow v2.0.0 启动完成") - - yield - - # 停止Redis事件总线 - try: - from app.event_bus import get_event_bus - get_event_bus().stop() - logger.info("[EventBus] Redis事件总线已停止") - except Exception: - pass - - # 优雅关闭:停止所有 Evolution Pipeline - try: - from app.services.evolution_service import EvolutionWatcherManager - EvolutionWatcherManager().shutdown_all() - logger.info("[Evolution] 所有Pipeline已停止") - except Exception: - pass - - logger.info("Qihu-AIFlow 正在关闭...") - - -app = FastAPI( - title="Qihu-AIFlow - 智能自动化PaaS平台", - version="1.0.0", - lifespan=lifespan, -) - -# API调用追踪中间件 -app.add_middleware(APITrackingMiddleware) - -# CORS配置 -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# 前台 API -app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"]) -app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"]) -app.include_router(channels.router, prefix="/app/api/channels", tags=["频道"]) -app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"]) -app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"]) -app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"]) -app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"]) -app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"]) -app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"]) -app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"]) -app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"]) - -# 后台 API -app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"]) -app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"]) -app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"]) -app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"]) -app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"]) -app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"]) -app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"]) -app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"]) -app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"]) -app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"]) -app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"]) +""" + +Qihu-AIFlow FastAPI 主应用 + +完整后端API,监听18080端口,所有接口以 /app 为前缀 + +""" + +import sys + +import os + +import time + +import uuid + +import logging + +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request + +from fastapi.middleware.cors import CORSMiddleware + +from starlette.middleware.base import BaseHTTPMiddleware + +from datetime import datetime + + + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + + +from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution + +from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, storage_admin +from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD + +from app.database import SessionLocal + +from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel + + + +# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1) + +import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册 + + + +# 初始化文件日志(按天轮转) + +access_logger = setup_file_logging() + +logger = logging.getLogger("aiflow") + + + + + +class APITrackingMiddleware(BaseHTTPMiddleware): + + """API调用统计中间件 - 记录请求耗时,标记慢请求""" + + + + async def dispatch(self, request: Request, call_next): + + request_id = str(uuid.uuid4()) + + start_time = time.time() + + path = request.url.path + + method = request.method + + + + # 跳过健康检查和静态资源 + + skip_paths = ["/app/api/health", "/favicon.ico"] + + should_skip = any(path.startswith(p) for p in skip_paths) + + + + try: + + response = await call_next(request) + + elapsed = time.time() - start_time + + status_code = response.status_code + + + + if not should_skip: + + access_logger.info( + + f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)" + + ) + + + + # 异步记录API调用统计 + + try: + + db = SessionLocal() + + try: + + _record_api_call(db, method, path, status_code, elapsed) + + finally: + + db.close() + + except Exception as e: + + logger.warning(f"API统计记录失败: {e}") + + + + return response + + except Exception as e: + + elapsed = time.time() - start_time + + if not should_skip: + + access_logger.info( + + f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}" + + ) + + raise + + + + async def __call__(self, scope, receive, send): + + if scope["type"] != "http": + + await self.app(scope, receive, send) + + return + + await super().__call__(scope, receive, send) + + + + + +def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float): + + """记录API调用到数据库统计表""" + + # 简化路径以减少基数(去掉动态ID) + + parts = path.split("/") + + simplified_parts = [] + + for part in parts: + + # 将UUID样式的路径段替换为{id} + + if len(part) == 36 and part.count("-") == 4: + + simplified_parts.append("{id}") + + # 将纯数字路径段替换为{id} + + elif part.isdigit(): + + simplified_parts.append("{id}") + + else: + + simplified_parts.append(part) + + simplified_path = "/".join(simplified_parts) + + + + # 统计记录通过数据库实现,简化版记录到日志 + + is_error = status_code >= 400 + + is_slow = elapsed >= SLOW_REQUEST_THRESHOLD + + + + if is_slow: + + logger.warning( + + f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)" + + ) + + + + + +@asynccontextmanager + +async def lifespan(app: FastAPI): + + logger.info("Qihu-AIFlow v2.0.0 启动中...") + + + + # 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行) + + try: + + from app.services.evolution_service import EvolutionWatcherManager + + from app.models import UserEvolutionConfig + + db = SessionLocal() + + try: + + configs = db.query(UserEvolutionConfig).filter( + + UserEvolutionConfig.watcher_status == "running" + + ).all() + + if configs: + + watcher = EvolutionWatcherManager() + + for cfg in configs: + + try: + + watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal()) + + logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline") + + except Exception as e: + + logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}") + + logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline") + + else: + + logger.info("[Evolution] 无待恢复的进化配置") + + finally: + + db.close() + + except Exception as e: + + logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}") + + + + # 启动Redis事件总线 + + try: + + from app.event_bus import get_event_bus + + event_bus = get_event_bus() + + event_bus.start(num_consumers=2) + + logger.info("[EventBus] Redis事件总线已启动") + + except Exception as e: + + logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}") + + + + logger.info("Qihu-AIFlow v2.0.0 启动完成") + + + + yield + + + + # 停止Redis事件总线 + + try: + + from app.event_bus import get_event_bus + + get_event_bus().stop() + + logger.info("[EventBus] Redis事件总线已停止") + + except Exception: + + pass + + + + # 优雅关闭:停止所有 Evolution Pipeline + + try: + + from app.services.evolution_service import EvolutionWatcherManager + + EvolutionWatcherManager().shutdown_all() + + logger.info("[Evolution] 所有Pipeline已停止") + + except Exception: + + pass + + + + logger.info("Qihu-AIFlow 正在关闭...") + + + + + +app = FastAPI( + + title="Qihu-AIFlow - 智能自动化PaaS平台", + + version="1.0.0", + + lifespan=lifespan, + +) + + + +# API调用追踪中间件 + +app.add_middleware(APITrackingMiddleware) + + + +# CORS配置 + +app.add_middleware( + + CORSMiddleware, + + allow_origins=["*"], + + allow_credentials=True, + + allow_methods=["*"], + + allow_headers=["*"], + +) + + + +# 前台 API + +app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"]) + +app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"]) + +app.include_router(channels.router, prefix="/app/api/channels", tags=["频道"]) + +app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"]) + +app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"]) + +app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"]) + +app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"]) + +app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"]) + +app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"]) + +app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"]) + +app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"]) + + + +# 后台 API + +app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"]) + +app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"]) + +app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"]) + +app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"]) + +app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"]) + +app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"]) + +app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"]) + +app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"]) + +app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"]) + +app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"]) + +app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"]) + app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"]) app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"]) - - -@app.get("/app/api/health") -async def health_check(): - return {"status": "ok", "service": "Qihu-AIFlow"} +app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"]) + + + + +@app.get("/app/api/health") + +async def health_check(): + + return {"status": "ok", "service": "Qihu-AIFlow"} + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2749a65..f6bc76d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,128 +1,253 @@ -import React, { useState, useEffect } from 'react'; -import { Routes, Route, Navigate } from 'react-router-dom'; -import type { User } from '@/types'; -import { AuthContext, AuthContextType } from '@/contexts/AuthContext'; -import { authAPI } from '@/api'; - -// Layouts -import FrontLayout from '@/components/layout/FrontLayout'; -import AdminLayout from '@/components/layout/AdminLayout'; - -// Front Pages -import LandingPage from '@/pages/front/LandingPage'; -import LoginPage from '@/pages/front/LoginPage'; -import ChatPage from '@/pages/front/ChatPage'; -import ChannelPage from '@/pages/front/ChannelPage'; -import KnowledgePage from '@/pages/front/KnowledgePage'; -import MarketPage from '@/pages/front/MarketPage'; -import TokenDashboardPage from '@/pages/front/TokenDashboardPage'; -import PricingPage from '@/pages/front/PricingPage'; -import ProfilePage from '@/pages/front/ProfilePage'; -import NotificationPage from '@/pages/front/NotificationPage'; -import AssetsPage from '@/pages/front/AssetsPage'; -import NovelPreviewPage from '@/pages/front/NovelPreviewPage'; -import EvolutionDashboard from '@/pages/front/EvolutionDashboard'; - -// Admin Pages -import AdminDashboard from '@/pages/admin/Dashboard'; -import AdminUsers from '@/pages/admin/Users'; -import AdminModels from '@/pages/admin/Models'; -import AdminSiteSettings from '@/pages/admin/SiteSettings'; -import AdminPayment from '@/pages/admin/Payment'; -import AdminServices from '@/pages/admin/Services'; -import AdminMembership from '@/pages/admin/Membership'; -import AdminAds from '@/pages/admin/Ads'; -import AdminMarket from '@/pages/admin/Market'; -import AdminLogs from '@/pages/admin/Logs'; -import AdminSkills from '@/pages/admin/Skills' +import React, { useState, useEffect } from 'react'; + +import { Routes, Route, Navigate } from 'react-router-dom'; + +import type { User } from '@/types'; + +import { AuthContext, AuthContextType } from '@/contexts/AuthContext'; + +import { authAPI } from '@/api'; + + + +// Layouts + +import FrontLayout from '@/components/layout/FrontLayout'; + +import AdminLayout from '@/components/layout/AdminLayout'; + + + +// Front Pages + +import LandingPage from '@/pages/front/LandingPage'; + +import LoginPage from '@/pages/front/LoginPage'; + +import ChatPage from '@/pages/front/ChatPage'; + +import ChannelPage from '@/pages/front/ChannelPage'; + +import KnowledgePage from '@/pages/front/KnowledgePage'; + +import MarketPage from '@/pages/front/MarketPage'; + +import TokenDashboardPage from '@/pages/front/TokenDashboardPage'; + +import PricingPage from '@/pages/front/PricingPage'; + +import ProfilePage from '@/pages/front/ProfilePage'; + +import NotificationPage from '@/pages/front/NotificationPage'; + +import AssetsPage from '@/pages/front/AssetsPage'; + +import NovelPreviewPage from '@/pages/front/NovelPreviewPage'; + +import EvolutionDashboard from '@/pages/front/EvolutionDashboard'; + + + +// Admin Pages + +import AdminDashboard from '@/pages/admin/Dashboard'; + +import AdminUsers from '@/pages/admin/Users'; + +import AdminModels from '@/pages/admin/Models'; + +import AdminSiteSettings from '@/pages/admin/SiteSettings'; + +import AdminPayment from '@/pages/admin/Payment'; + +import AdminServices from '@/pages/admin/Services'; + +import AdminMembership from '@/pages/admin/Membership'; + +import AdminAds from '@/pages/admin/Ads'; + +import AdminMarket from '@/pages/admin/Market'; + +import AdminLogs from '@/pages/admin/Logs'; + +import AdminSkills from '@/pages/admin/Skills' import AdminAgents from '@/pages/admin/Agents' -import AdminKnowledge from '@/pages/admin/Knowledge';; - -const App: React.FC = () => { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - - // 初始化:从localStorage恢复登录状态 - useEffect(() => { - const token = localStorage.getItem('aiflow_token'); - const cachedUser = localStorage.getItem('aiflow_user'); - if (token && cachedUser) { - try { - setUser(JSON.parse(cachedUser)); - } catch { - localStorage.removeItem('aiflow_token'); - localStorage.removeItem('aiflow_user'); - } - } - setLoading(false); - }, []); - - const login = (u: User) => { - setUser(u); - localStorage.setItem('aiflow_user', JSON.stringify(u)); - }; - const logout = () => { - setUser(null); - localStorage.removeItem('aiflow_token'); - localStorage.removeItem('aiflow_user'); - }; - const isAdmin = user?.role === 'admin'; - - if (loading) { - return
加载中...
; - } - - return ( - - - {/* Login */} - : ) : - } /> - - {/* Root - Landing for guests, redirect for authenticated users */} - : ) : - } /> - - {/* Front Routes (authenticated only) */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - {/* Admin Routes */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - } /> +import AdminKnowledge from '@/pages/admin/Knowledge'; +import AdminStorage from '@/pages/admin/Storage';; + + + +const App: React.FC = () => { + + const [user, setUser] = useState(null); + + const [loading, setLoading] = useState(true); + + + + // 初始化:从localStorage恢复登录状态 + + useEffect(() => { + + const token = localStorage.getItem('aiflow_token'); + + const cachedUser = localStorage.getItem('aiflow_user'); + + if (token && cachedUser) { + + try { + + setUser(JSON.parse(cachedUser)); + + } catch { + + localStorage.removeItem('aiflow_token'); + + localStorage.removeItem('aiflow_user'); + + } + + } + + setLoading(false); + + }, []); + + + + const login = (u: User) => { + + setUser(u); + + localStorage.setItem('aiflow_user', JSON.stringify(u)); + + }; + + const logout = () => { + + setUser(null); + + localStorage.removeItem('aiflow_token'); + + localStorage.removeItem('aiflow_user'); + + }; + + const isAdmin = user?.role === 'admin'; + + + + if (loading) { + + return
加载中...
; + + } + + + + return ( + + + + + + {/* Login */} + + : ) : + + } /> + + + + {/* Root - Landing for guests, redirect for authenticated users */} + + : ) : + + } /> + + + + {/* Front Routes (authenticated only) */} + + }> + + } /> + + } /> + + } /> + + } /> + + } /> + + } /> + + } /> + + } /> + + } /> + + } /> + + } /> + + } /> + + + + + + {/* Admin Routes */} + + }> + + } /> + + } /> + + } /> + + } /> + + } /> + + } /> + + } /> + + } /> + + } /> + + } /> + + + + } /> + } /> } /> - - - } /> - - - ); -}; - -export default App; + } /> + + + + + } /> + +
+ +
+ + ); + +}; + + + +export default App; + diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index f8693d3..f5463ea 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -498,6 +498,20 @@ export const adminAPI = { checkAdminKnowledgeExtStatus: (id: string) => get(`/admin/knowledge/external/services/${id}/status`), syncAdminKnowledge: (kbId: string, data: any) => post(`/admin/knowledge/external/sync/${kbId}`, data), batchDeleteAdminKnowledge: (ids: string[]) => post('/admin/knowledge/batch/delete', ids), + // OSS存储配置 (Phase 3.1) + getAdminStorageStats: () => get('/admin/storage/stats/overview'), + getAdminStorageConfigs: (params?: any) => { + const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : ''; + return get(`/admin/storage/${query}`); + }, + getAdminStorageConfig: (id: string) => get(`/admin/storage/${id}`), + createAdminStorageConfig: (data: any) => post('/admin/storage/', data), + updateAdminStorageConfig: (id: string, data: any) => put(`/admin/storage/${id}`, data), + deleteAdminStorageConfig: (id: string) => del(`/admin/storage/${id}`), + toggleAdminStorageConfig: (id: string) => put(`/admin/storage/${id}/toggle`), + setDefaultAdminStorageConfig: (id: string) => put(`/admin/storage/${id}/set-default`), + testAdminStorageConfig: (data: any) => post('/admin/storage/test', data), + }; // ===== AI自进化API ===== export const evolutionAPI = { diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index 8963bc0..66c7eb6 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -1,128 +1,257 @@ -import React, { useState } from 'react'; -import { Outlet, NavLink, useNavigate } from 'react-router-dom'; -import { useAuth } from '@/contexts/AuthContext'; -import './AdminLayout.css'; - -const adminNavItems = [ - { path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true }, - { path: '/admin/users', icon: 'users', label: '用户管理' }, - { path: '/admin/models', icon: 'models', label: '大模型库管理' }, - { path: '/admin/settings', icon: 'settings', label: '网站基础设置' }, - { path: '/admin/payment', icon: 'payment', label: '支付渠道配置' }, - { path: '/admin/services', icon: 'services', label: '增值服务管理' }, - { path: '/admin/membership', icon: 'membership', label: '会员订阅体系' }, - { path: '/admin/ads', icon: 'ads', label: '广告位管理' }, - { path: '/admin/market', icon: 'market', label: '应用市场审核' }, - { path: '/admin/logs', icon: 'logs', label: '日志与风控' }, - - { path: '/admin/agents', icon: 'agents', label: 'Agent管理' }, +import React, { useState } from 'react'; + +import { Outlet, NavLink, useNavigate } from 'react-router-dom'; + +import { useAuth } from '@/contexts/AuthContext'; + +import './AdminLayout.css'; + + + +const adminNavItems = [ + + { path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true }, + + { path: '/admin/users', icon: 'users', label: '用户管理' }, + + { path: '/admin/models', icon: 'models', label: '大模型库管理' }, + + { path: '/admin/settings', icon: 'settings', label: '网站基础设置' }, + + { path: '/admin/payment', icon: 'payment', label: '支付渠道配置' }, + + { path: '/admin/services', icon: 'services', label: '增值服务管理' }, + + { path: '/admin/membership', icon: 'membership', label: '会员订阅体系' }, + + { path: '/admin/ads', icon: 'ads', label: '广告位管理' }, + + { path: '/admin/market', icon: 'market', label: '应用市场审核' }, + + { path: '/admin/logs', icon: 'logs', label: '日志与风控' }, + + + + { path: '/admin/agents', icon: 'agents', label: 'Agent管理' }, { path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' }, - { path: '/admin/skills', icon: 'skills', label: '技能管理' },]; - -const adminIconPaths: Record = { - dashboard: , - users: , - models: , - settings: , - payment: , - services: , - membership: , - ads: , - market: , - logs: , - - skills: ,}; - -const AdminLayout: React.FC = () => { - const { user, logout } = useAuth(); - const navigate = useNavigate(); - const [collapsed, setCollapsed] = useState(false); - - if (!user || user.role !== 'admin') { - navigate('/login'); - return null; - } - - return ( -
- {/* Admin Sidebar */} - - - {/* Admin Main Content */} -
-
-
- 欢迎,{user.username} -
-
- - - 返回前台 - -
-
- {user.username.charAt(0).toUpperCase()} -
- {user.username} -
-
-
-
- -
-
-
- ); -}; - -export default AdminLayout; + { path: '/admin/storage', icon: 'storage', label: '存储配置' }, + { path: '/admin/skills', icon: 'skills', label: '技能管理' },]; + + + +const adminIconPaths: Record = { + + dashboard: , + + users: , + + models: , + + settings: , + + payment: , + + services: , + + membership: , + + ads: , + + market: , + + logs: , + + + + skills: , + storage: , +}; + + + +const AdminLayout: React.FC = () => { + + const { user, logout } = useAuth(); + + const navigate = useNavigate(); + + const [collapsed, setCollapsed] = useState(false); + + + + if (!user || user.role !== 'admin') { + + navigate('/login'); + + return null; + + } + + + + return ( + +
+ + {/* Admin Sidebar */} + + + + + + {/* Admin Main Content */} + +
+ +
+ +
+ + 欢迎,{user.username} + +
+ +
+ + + + + + 返回前台 + + + +
+ +
+ + {user.username.charAt(0).toUpperCase()} + +
+ + {user.username} + +
+ +
+ +
+ +
+ + + +
+ +
+ +
+ + ); + +}; + + + +export default AdminLayout; + diff --git a/frontend/src/pages/admin/Storage.tsx b/frontend/src/pages/admin/Storage.tsx new file mode 100644 index 0000000..8ba3ff4 --- /dev/null +++ b/frontend/src/pages/admin/Storage.tsx @@ -0,0 +1,713 @@ +import React, { useState, useEffect } from 'react'; +import { adminAPI } from '@/api'; + +interface OSSConfig { + id: string; + name: string; + provider: string; + endpoint: string; + access_key: string; + bucket: string; + region: string; + base_path: string; + is_default: boolean; + is_enabled: boolean; + status: string; + config: Record; + created_at: string; + updated_at: string; +} + +interface StorageStats { + total: number; + enabled_count: number; + default_count: number; + by_provider: Record; + by_status: Record; +} + +const PROVIDER_CONFIG: Record = { + minio: { label: 'MinIO', color: '#C9362C', bg: '#FEF0EF' }, + aliyun_oss: { label: '阿里云OSS', color: '#FF6A00', bg: '#FFF3E8' }, + aws_s3: { label: 'AWS S3', color: '#FF9900', bg: '#FFF8E1' }, +}; + +const STATUS_CONFIG: Record = { + connected: { label: '已连接', color: '#10B981', bg: '#D1FAE5' }, + disconnected: { label: '未连接', color: '#6B7280', bg: '#F3F4F6' }, + error: { label: '连接异常', color: '#EF4444', bg: '#FEE2E2' }, +}; + +const PAGE_SIZE = 15; + +const Storage: React.FC = () => { + // Data state + const [configs, setConfigs] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [stats, setStats] = useState(null); + + // Filter state + const [filterProvider, setFilterProvider] = useState(''); + const [keyword, setKeyword] = useState(''); + + // Modal state + const [modalVisible, setModalVisible] = useState(false); + const [editingConfig, setEditingConfig] = useState(null); + const [formData, setFormData] = useState({ + name: '', + provider: 'minio', + endpoint: '', + access_key: '', + secret_key: '', + bucket: '', + region: '', + base_path: '', + is_default: false, + is_enabled: true, + config_str: '', + }); + const [submitting, setSubmitting] = useState(false); + + // Test connection state + const [testingId, setTestingId] = useState(null); + const [testResult, setTestResult] = useState<{ success: boolean; message: string; latency_ms: number } | null>(null); + + // Toast + const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null); + const showToast = (msg: string, type: 'success' | 'error' = 'success') => { + setToast({ msg, type }); + setTimeout(() => setToast(null), 3000); + }; + + // Load stats + const loadStats = async () => { + try { + const result = await adminAPI.getAdminStorageStats() as any; + setStats(result.data || null); + } catch (e) { + // ignore + } + }; + + // Load configs + const loadConfigs = async () => { + setLoading(true); + try { + const params: any = { page, page_size: PAGE_SIZE }; + if (filterProvider) params.provider = filterProvider; + if (keyword) params.keyword = keyword; + const result = await adminAPI.getAdminStorageConfigs(params) as any; + const data = result.data || result; + setConfigs(data.items || []); + setTotal(data.total || 0); + } catch (e: any) { + showToast('加载失败: ' + (e.message || e), 'error'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadStats(); + }, []); + + useEffect(() => { + loadConfigs(); + }, [page, filterProvider, keyword]); + + // Open create modal + const openCreateModal = () => { + setEditingConfig(null); + setFormData({ + name: '', + provider: 'minio', + endpoint: '', + access_key: '', + secret_key: '', + bucket: '', + region: '', + base_path: '', + is_default: false, + is_enabled: true, + config_str: '', + }); + setModalVisible(true); + }; + + // Open edit modal + const openEditModal = async (config: OSSConfig) => { + try { + const result = await adminAPI.getAdminStorageConfig(config.id) as any; + const detail = result.data; + setEditingConfig(config); + setFormData({ + name: detail.name || '', + provider: detail.provider || 'minio', + endpoint: detail.endpoint || '', + access_key: '', // Not returned for security + secret_key: '', // Not returned for security + bucket: detail.bucket || '', + region: detail.region || '', + base_path: detail.base_path || '', + is_default: detail.is_default || false, + is_enabled: detail.is_enabled !== false, + config_str: detail.config ? JSON.stringify(detail.config, null, 2) : '', + }); + setModalVisible(true); + } catch (e: any) { + showToast('获取配置详情失败', 'error'); + } + }; + + // Submit form + const handleSubmit = async () => { + if (!formData.name.trim()) { showToast('请输入配置名称', 'error'); return; } + if (!formData.endpoint.trim()) { showToast('请输入端点地址', 'error'); return; } + if (!formData.bucket.trim()) { showToast('请输入Bucket名称', 'error'); return; } + + // For create, require credentials + if (!editingConfig && (!formData.access_key.trim() || !formData.secret_key.trim())) { + showToast('请输入Access Key和Secret Key', 'error'); + return; + } + + setSubmitting(true); + try { + let configObj: any = undefined; + if (formData.config_str.trim()) { + try { + configObj = JSON.parse(formData.config_str); + } catch { + showToast('扩展配置JSON格式不正确', 'error'); + setSubmitting(false); + return; + } + } + + const data: any = { + name: formData.name.trim(), + provider: formData.provider, + endpoint: formData.endpoint.trim(), + bucket: formData.bucket.trim(), + region: formData.region.trim(), + base_path: formData.base_path.trim(), + is_default: formData.is_default, + is_enabled: formData.is_enabled, + config: configObj, + }; + + // Include credentials if provided (for create or update) + if (formData.access_key.trim()) data.access_key = formData.access_key.trim(); + if (formData.secret_key.trim()) data.secret_key = formData.secret_key.trim(); + + if (editingConfig) { + await adminAPI.updateAdminStorageConfig(editingConfig.id, data); + showToast('更新成功'); + } else { + await adminAPI.createAdminStorageConfig(data); + showToast('创建成功'); + } + + setModalVisible(false); + loadConfigs(); + loadStats(); + } catch (e: any) { + showToast('操作失败: ' + (e.message || e), 'error'); + } finally { + setSubmitting(false); + } + }; + + // Delete config + const handleDelete = async (config: OSSConfig) => { + if (!window.confirm(`确定要删除配置 "${config.name}" 吗?此操作不可恢复。`)) return; + try { + await adminAPI.deleteAdminStorageConfig(config.id); + showToast('删除成功'); + loadConfigs(); + loadStats(); + } catch (e: any) { + showToast('删除失败: ' + (e.message || e), 'error'); + } + }; + + // Toggle enabled + const handleToggle = async (config: OSSConfig) => { + try { + await adminAPI.toggleAdminStorageConfig(config.id); + showToast(config.is_enabled ? '已禁用' : '已启用'); + loadConfigs(); + loadStats(); + } catch (e: any) { + showToast('操作失败: ' + (e.message || e), 'error'); + } + }; + + // Set default + const handleSetDefault = async (config: OSSConfig) => { + if (config.is_default) return; + try { + await adminAPI.setDefaultAdminStorageConfig(config.id); + showToast(`已将 "${config.name}" 设为默认存储`); + loadConfigs(); + loadStats(); + } catch (e: any) { + showToast('操作失败: ' + (e.message || e), 'error'); + } + }; + + // Test connection + const handleTestConnection = async (config: OSSConfig) => { + setTestingId(config.id); + setTestResult(null); + try { + const result = await adminAPI.testAdminStorageConfig({ config_id: config.id }) as any; + const data = result.data; + setTestResult(data); + showToast(data.success ? '连接测试成功' : '连接测试失败', data.success ? 'success' : 'error'); + loadConfigs(); + loadStats(); + } catch (e: any) { + showToast('测试失败: ' + (e.message || e), 'error'); + } finally { + setTestingId(null); + } + }; + + return ( +
+
+

OSS存储配置管理

+ +
+ + {/* Stats Cards */} + {stats && ( +
+
+
{stats.total}
+
总配置数
+
+
+
{stats.enabled_count}
+
已启用
+
+
+
{stats.default_count}
+
默认存储
+
+
+
+ {Object.entries(stats.by_status || {}).map(([s, c]) => ( + + {(STATUS_CONFIG[s] || {}).label || s}: {c} + + ))} +
+
连接状态
+
+
+ )} + + {/* Filter Bar */} +
+ + { setKeyword(e.target.value); setPage(1); }} + style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', flex: 1, maxWidth: '400px' }} + /> +
+ + {/* Table */} +
+ + + + + + + + + + + + + + {loading ? ( + + ) : configs.length === 0 ? ( + + ) : ( + configs.map(cfg => ( + + + + + + + + + + )) + )} + +
配置名称提供商端点地址Bucket状态默认操作
加载中...
暂无存储配置,点击"添加存储配置"开始
{cfg.name} + + {(PROVIDER_CONFIG[cfg.provider] || {}).label || cfg.provider} + + + {cfg.endpoint} + {cfg.bucket} + + {(STATUS_CONFIG[cfg.status] || STATUS_CONFIG.disconnected).label} + + + {cfg.is_default ? ( + 默认 + ) : ( + + )} + +
+ + + + +
+
+
+ + {/* Pagination */} + {total > PAGE_SIZE && ( +
+ + + 第 {page} / {Math.ceil(total / PAGE_SIZE)} 页 (共 {total} 条) + + +
+ )} + + {/* Modal */} + {modalVisible && ( +
+
+

+ {editingConfig ? '编辑存储配置' : '添加存储配置'} +

+ +
+ {/* Name */} +
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="如: 生产环境MinIO" + style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }} + /> +
+ + {/* Provider */} +
+ + +
+ + {/* Endpoint */} +
+ + setFormData({ ...formData, endpoint: e.target.value })} + placeholder={formData.provider === 'minio' ? 'http://localhost:9000' : formData.provider === 'aliyun_oss' ? 'https://oss-cn-hangzhou.aliyuncs.com' : 'https://s3.amazonaws.com'} + style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }} + /> +
+ + {/* Access Key & Secret Key */} +
+
+ + setFormData({ ...formData, access_key: e.target.value })} + placeholder={editingConfig ? '留空则不修改' : '输入Access Key'} + style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }} + /> +
+
+ + setFormData({ ...formData, secret_key: e.target.value })} + placeholder={editingConfig ? '留空则不修改' : '输入Secret Key'} + style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }} + /> +
+
+ + {/* Bucket & Region */} +
+
+ + setFormData({ ...formData, bucket: e.target.value })} + placeholder="my-bucket" + style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }} + /> +
+
+ + setFormData({ ...formData, region: e.target.value })} + placeholder="如: us-east-1" + style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }} + /> +
+
+ + {/* Base Path */} +
+ + setFormData({ ...formData, base_path: e.target.value })} + placeholder="如: aipaaS/uploads/" + style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }} + /> +
+ + {/* Checkboxes */} +
+ + +
+ + {/* Advanced Config */} +
+ +