Phase C5: Storage Quota - user/membership tier quota management

This commit is contained in:
qyyfw committed 2026-07-06 08:13:51 +08:00
1 parent 6ccf6d6867
commit 4b49333f9d
4 files changed
+1039 -509

No files matched your search

@@ -104,6 +104,328 @@ def config_to_dict(config) -> dict:
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请求到对象存储端点"""
+17
View File
@@ -486,6 +486,7 @@ class AIModel(Base):
id = Column(String(36), primary_key=True, default=gen_uuid)
provider_id = Column(String(36), ForeignKey("model_providers.id"), nullable=False)
name = Column(String(100), nullable=False)
display_name = Column(String(200), default=None, comment="display name")
api_key = Column(String(255), default=None)
endpoint = Column(String(500), default=None)
capabilities = Column(JSON, default=None)
@@ -1004,6 +1005,22 @@ class OSSStorageConfig(Base):
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class UserStorageQuota(Base):
__tablename__ = "user_storage_quotas"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), nullable=False, unique=True)
quota_bytes = Column(BigInteger, nullable=False, default=1073741824)
used_bytes = Column(BigInteger, nullable=False, default=0)
quota_type = Column(String(20), nullable=False, default="membership")
membership_level = Column(Integer, default=0)
plan_id = Column(String(36), default=None)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class AutomatedTask(Base):
__tablename__ = "automated_tasks"
+13
View File
@@ -513,6 +513,19 @@ export const adminAPI = {
toggleAdminStorageConfig: (id: string) => put<any>(`/admin/storage/${id}/toggle`),
setDefaultAdminStorageConfig: (id: string) => put<any>(`/admin/storage/${id}/set-default`),
testAdminStorageConfig: (data: any) => post<any>('/admin/storage/test', data),
// Storage Quota APIs (Phase C5)
getStorageQuotaOverview: () => get<any>('/admin/storage/quota/overview'),
getStorageQuotaUsers: (params?: any) => {
if (params) {
const query = Object.entries(params).map(([k, v]) => v !== undefined && v !== '' && v !== null ? `${k}=${encodeURIComponent(String(v))}` : '').filter(Boolean).join('&');
return get<any>(`/admin/storage/quota/users${query ? '?' + query : ''}`);
}
return get<any>('/admin/storage/quota/users');
},
getStorageQuotaUser: (userId: string) => get<any>(`/admin/storage/quota/users/${userId}`),
adjustStorageQuota: (userId: string, data: any) => put<any>(`/admin/storage/quota/users/${userId}`, data),
recalcStorageQuota: (userId: string) => post<any>(`/admin/storage/quota/recalculate/${userId}`, {}),
initAllStorageQuotas: () => post<any>('/admin/storage/quota/init-all', {}),
// 自动化任务调度 (Phase 3.2)
getAdminAutomationStats: () => get<any>('/admin/automation/stats/overview'),
File diff suppressed because it is too large. Load diff