905 lines
32 KiB
Python
905 lines
32 KiB
Python
"""
|
|
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
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
|
# Storage Quota Management (Phase C5)
|
|
# ============================================================
|
|
|
|
DEFAULT_QUOTAS = {
|
|
0: 1073741824, # Free: 1GB
|
|
2: 10737418240, # Pro: 10GB
|
|
3: 107374182400, # Enterprise: 100GB
|
|
}
|
|
|
|
TIER_LABELS = {0: "\u514d\u8d39\u7248", 2: "\u4e13\u4e1a\u7248", 3: "\u4f01\u4e1a\u7248"}
|
|
|
|
|
|
class QuotaAdjustRequest(BaseModel):
|
|
quota_bytes: Optional[int] = Field(None, ge=0)
|
|
quota_type: Optional[str] = Field(None, max_length=20)
|
|
|
|
|
|
def ensure_user_quota(user_id: str, membership_level: int, plan_id: str = None, db: Session = None) -> dict:
|
|
from ...models import UserStorageQuota
|
|
quota = db.query(UserStorageQuota).filter(UserStorageQuota.user_id == user_id).first()
|
|
default_bytes = DEFAULT_QUOTAS.get(membership_level, DEFAULT_QUOTAS[0])
|
|
if not quota:
|
|
quota = UserStorageQuota(
|
|
id=gen_uuid(), user_id=user_id, quota_bytes=default_bytes,
|
|
used_bytes=0, quota_type="membership",
|
|
membership_level=membership_level, plan_id=plan_id,
|
|
)
|
|
db.add(quota)
|
|
db.commit()
|
|
db.refresh(quota)
|
|
elif quota.quota_type == "membership" and quota.membership_level != membership_level:
|
|
quota.quota_bytes = default_bytes
|
|
quota.membership_level = membership_level
|
|
quota.plan_id = plan_id
|
|
db.commit()
|
|
db.refresh(quota)
|
|
return {
|
|
"id": quota.id, "user_id": quota.user_id,
|
|
"quota_bytes": quota.quota_bytes, "used_bytes": quota.used_bytes,
|
|
"quota_type": quota.quota_type, "membership_level": quota.membership_level,
|
|
"plan_id": quota.plan_id,
|
|
"created_at": quota.created_at.isoformat() if quota.created_at else None,
|
|
"updated_at": quota.updated_at.isoformat() if quota.updated_at else None,
|
|
}
|
|
|
|
|
|
def recalculate_storage_usage(user_id: str, db: Session) -> int:
|
|
from ...models import KnowledgeDocument, KnowledgeBase
|
|
total = db.query(func.coalesce(func.sum(KnowledgeDocument.file_size), 0)).join(
|
|
KnowledgeBase, KnowledgeDocument.knowledge_base_id == KnowledgeBase.id
|
|
).filter(
|
|
KnowledgeBase.user_id == user_id,
|
|
).scalar() or 0
|
|
return int(total)
|
|
|
|
|
|
@router.get("/quota/overview", summary="Storage Quota Overview")
|
|
async def get_quota_overview(
|
|
admin_user=Depends(get_current_admin),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
from ...models import UserStorageQuota, User
|
|
total_quota_users = db.query(func.count(UserStorageQuota.id)).scalar() or 0
|
|
total_quota_bytes = db.query(func.coalesce(func.sum(UserStorageQuota.quota_bytes), 0)).scalar() or 0
|
|
total_used_bytes = db.query(func.coalesce(func.sum(UserStorageQuota.used_bytes), 0)).scalar() or 0
|
|
total_users = db.query(func.count(User.id)).filter(User.status == "active").scalar() or 0
|
|
users_without_quota = total_users - total_quota_users
|
|
by_level = {}
|
|
level_stats = db.query(
|
|
UserStorageQuota.membership_level,
|
|
func.count(UserStorageQuota.id),
|
|
func.coalesce(func.sum(UserStorageQuota.quota_bytes), 0),
|
|
func.coalesce(func.sum(UserStorageQuota.used_bytes), 0),
|
|
).group_by(UserStorageQuota.membership_level).all()
|
|
for level, count, total_q, total_u in level_stats:
|
|
by_level[str(level)] = {
|
|
"label": TIER_LABELS.get(level, f"Level {level}"),
|
|
"user_count": count,
|
|
"total_quota_bytes": int(total_q),
|
|
"total_used_bytes": int(total_u),
|
|
}
|
|
over_quota_count = db.query(func.count(UserStorageQuota.id)).filter(
|
|
UserStorageQuota.quota_bytes > 0,
|
|
UserStorageQuota.used_bytes > UserStorageQuota.quota_bytes
|
|
).scalar() or 0
|
|
return {
|
|
"code": 0,
|
|
"data": {
|
|
"total_users": total_users,
|
|
"quota_users": total_quota_users,
|
|
"users_without_quota": users_without_quota,
|
|
"total_quota_bytes": int(total_quota_bytes),
|
|
"total_used_bytes": int(total_used_bytes),
|
|
"over_quota_count": over_quota_count,
|
|
"by_level": by_level,
|
|
"default_quotas": {str(k): v for k, v in DEFAULT_QUOTAS.items()},
|
|
"tier_labels": TIER_LABELS,
|
|
},
|
|
"message": "ok"
|
|
}
|
|
|
|
|
|
@router.get("/quota/users", summary="User Storage Quota List")
|
|
async def list_user_quotas(
|
|
keyword: str = Query(None),
|
|
membership_level: int = Query(None),
|
|
over_quota: bool = Query(None),
|
|
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),
|
|
):
|
|
from ...models import UserStorageQuota, User
|
|
results = db.query(
|
|
User.id, User.username, User.email, User.membership_level, User.real_name,
|
|
UserStorageQuota.id.label("quota_id"),
|
|
UserStorageQuota.quota_bytes, UserStorageQuota.used_bytes,
|
|
UserStorageQuota.quota_type, UserStorageQuota.updated_at,
|
|
).join(UserStorageQuota, User.id == UserStorageQuota.user_id, isouter=True
|
|
).filter(User.status == "active")
|
|
if keyword:
|
|
results = results.filter(
|
|
or_(User.username.ilike(f"%{keyword}%"), User.email.ilike(f"%{keyword}%"))
|
|
)
|
|
if membership_level is not None:
|
|
results = results.filter(User.membership_level == membership_level)
|
|
total = results.count()
|
|
items = results.order_by(desc(UserStorageQuota.used_bytes)).offset(
|
|
(page - 1) * page_size).limit(page_size).all()
|
|
user_list = []
|
|
for row in items:
|
|
uid, username, email, level, real_name, qid, qbytes, ubytes, qtype, updated = row
|
|
quota_b = int(qbytes or DEFAULT_QUOTAS.get(level or 0, DEFAULT_QUOTAS[0]))
|
|
used_b = int(ubytes or 0)
|
|
usage_pct = round(used_b / quota_b * 100, 1) if quota_b > 0 else 0
|
|
user_list.append({
|
|
"user_id": uid, "username": username, "email": email or "",
|
|
"real_name": real_name or "", "membership_level": level or 0,
|
|
"tier_label": TIER_LABELS.get(level or 0, "\u514d\u8d39\u7248"),
|
|
"quota_id": qid, "quota_bytes": quota_b, "used_bytes": used_b,
|
|
"usage_percent": usage_pct, "is_over_quota": usage_pct >= 100,
|
|
"quota_type": qtype or "membership",
|
|
"updated_at": updated.isoformat() if updated else None,
|
|
})
|
|
if over_quota:
|
|
user_list = [u for u in user_list if u["is_over_quota"]]
|
|
return {
|
|
"code": 0,
|
|
"data": {
|
|
"items": user_list,
|
|
"total": len(user_list) if over_quota else total,
|
|
"page": page, "page_size": page_size,
|
|
},
|
|
"message": "ok"
|
|
}
|
|
|
|
|
|
@router.get("/quota/users/{user_id}", summary="Get User Quota Detail")
|
|
async def get_user_quota(
|
|
user_id: str,
|
|
admin_user=Depends(get_current_admin),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
from ...models import UserStorageQuota, User, KnowledgeDocument, KnowledgeBase
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
quota = db.query(UserStorageQuota).filter(UserStorageQuota.user_id == user_id).first()
|
|
actual_used = recalculate_storage_usage(user_id, db)
|
|
file_count = db.query(func.count(KnowledgeDocument.id)).join(
|
|
KnowledgeBase, KnowledgeDocument.knowledge_base_id == KnowledgeBase.id
|
|
).filter(KnowledgeBase.user_id == user_id).scalar() or 0
|
|
file_type_stats = {}
|
|
type_counts = db.query(
|
|
KnowledgeDocument.file_type, func.count(KnowledgeDocument.id),
|
|
func.coalesce(func.sum(KnowledgeDocument.file_size), 0),
|
|
).join(
|
|
KnowledgeBase, KnowledgeDocument.knowledge_base_id == KnowledgeBase.id
|
|
).filter(KnowledgeBase.user_id == user_id
|
|
).group_by(KnowledgeDocument.file_type).all()
|
|
for ftype, cnt, fsize in type_counts:
|
|
file_type_stats[ftype] = {"count": cnt, "total_bytes": int(fsize)}
|
|
quota_data = None
|
|
if quota:
|
|
quota_data = {
|
|
"id": quota.id, "quota_bytes": int(quota.quota_bytes),
|
|
"used_bytes": actual_used, "original_used": int(quota.used_bytes),
|
|
"quota_type": quota.quota_type, "membership_level": quota.membership_level,
|
|
"plan_id": quota.plan_id,
|
|
"created_at": quota.created_at.isoformat() if quota.created_at else None,
|
|
"updated_at": quota.updated_at.isoformat() if quota.updated_at else None,
|
|
}
|
|
if actual_used != int(quota.used_bytes):
|
|
quota.used_bytes = actual_used
|
|
db.commit()
|
|
return {
|
|
"code": 0,
|
|
"data": {
|
|
"user": {
|
|
"id": user.id, "username": user.username,
|
|
"email": user.email or "", "real_name": user.real_name or "",
|
|
"membership_level": user.membership_level or 0,
|
|
"tier_label": TIER_LABELS.get(user.membership_level or 0, "\u514d\u8d39\u7248"),
|
|
},
|
|
"quota": quota_data, "file_count": file_count,
|
|
"file_type_stats": file_type_stats,
|
|
},
|
|
"message": "ok"
|
|
}
|
|
|
|
|
|
@router.put("/quota/users/{user_id}", summary="Adjust User Quota")
|
|
async def adjust_user_quota(
|
|
user_id: str,
|
|
req: QuotaAdjustRequest,
|
|
admin_user=Depends(get_current_admin),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
from ...models import UserStorageQuota, User
|
|
user = db.query(User).filter(User.id == user_id).first()
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
quota = db.query(UserStorageQuota).filter(UserStorageQuota.user_id == user_id).first()
|
|
if not quota:
|
|
default_bytes = DEFAULT_QUOTAS.get(user.membership_level or 0, DEFAULT_QUOTAS[0])
|
|
quota = UserStorageQuota(
|
|
id=gen_uuid(), user_id=user_id, quota_bytes=default_bytes,
|
|
used_bytes=0, quota_type="membership",
|
|
membership_level=user.membership_level or 0,
|
|
)
|
|
db.add(quota)
|
|
db.flush()
|
|
if req.quota_bytes is not None:
|
|
quota.quota_bytes = req.quota_bytes
|
|
if req.quota_type is not None:
|
|
quota.quota_type = req.quota_type
|
|
quota.used_bytes = recalculate_storage_usage(user_id, db)
|
|
db.commit()
|
|
db.refresh(quota)
|
|
LogService.log_audit(
|
|
admin_id=admin_user.id, admin_name=admin_user.username,
|
|
action="adjust_storage_quota", target_type="user_storage_quota",
|
|
target_id=quota.id,
|
|
detail=f"Adjusted quota for {user.username} to {quota.quota_bytes} bytes"
|
|
)
|
|
usage_pct = round(quota.used_bytes / quota.quota_bytes * 100, 1) if quota.quota_bytes > 0 else 0
|
|
return {
|
|
"code": 0,
|
|
"data": {
|
|
"id": quota.id, "user_id": quota.user_id,
|
|
"quota_bytes": int(quota.quota_bytes),
|
|
"used_bytes": int(quota.used_bytes),
|
|
"quota_type": quota.quota_type, "usage_percent": usage_pct,
|
|
},
|
|
"message": "Quota adjusted successfully"
|
|
}
|
|
|
|
|
|
@router.post("/quota/recalculate/{user_id}", summary="Recalculate User Storage")
|
|
async def recalculate_user_quota(
|
|
user_id: str,
|
|
admin_user=Depends(get_current_admin),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
from ...models import UserStorageQuota
|
|
quota = db.query(UserStorageQuota).filter(UserStorageQuota.user_id == user_id).first()
|
|
actual_used = recalculate_storage_usage(user_id, db)
|
|
if quota:
|
|
quota.used_bytes = actual_used
|
|
db.commit()
|
|
return {
|
|
"code": 0,
|
|
"data": {
|
|
"user_id": user_id, "used_bytes": actual_used,
|
|
"quota_bytes": int(quota.quota_bytes),
|
|
},
|
|
"message": f"Recalculated: {actual_used} bytes"
|
|
}
|
|
else:
|
|
return {
|
|
"code": 0,
|
|
"data": {"user_id": user_id, "used_bytes": actual_used},
|
|
"message": "No quota record for this user"
|
|
}
|
|
|
|
|
|
@router.post("/quota/init-all", summary="Init All User Quotas")
|
|
async def init_all_user_quotas(
|
|
admin_user=Depends(get_current_admin),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
from ...models import UserStorageQuota, User
|
|
users = db.query(User).filter(User.status == "active").all()
|
|
created, skipped = 0, 0
|
|
for user in users:
|
|
existing = db.query(UserStorageQuota).filter(
|
|
UserStorageQuota.user_id == user.id).first()
|
|
if existing:
|
|
skipped += 1
|
|
continue
|
|
default_bytes = DEFAULT_QUOTAS.get(user.membership_level or 0, DEFAULT_QUOTAS[0])
|
|
used = recalculate_storage_usage(user.id, db)
|
|
quota = UserStorageQuota(
|
|
id=gen_uuid(), user_id=user.id, quota_bytes=default_bytes,
|
|
used_bytes=used, quota_type="membership",
|
|
membership_level=user.membership_level or 0,
|
|
plan_id=user.membership_plan_id,
|
|
)
|
|
db.add(quota)
|
|
created += 1
|
|
db.commit()
|
|
return {
|
|
"code": 0,
|
|
"data": {"created": created, "skipped": skipped, "total": len(users)},
|
|
"message": f"Init complete: {created} new, {skipped} existing, {len(users)} total"
|
|
}
|
|
|
|
|
|
|
|
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": "连接测试完成"
|
|
}
|