Phase C5: Storage Quota - user/membership tier quota management
This commit is contained in:
1 parent
6ccf6d6867
commit
4b49333f9d
4 files changed
+982
-452
No files matched your search
@@ -104,6 +104,328 @@ def config_to_dict(config) -> dict:
|
|||||||
return result
|
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,
|
async def test_oss_connection(provider: str, endpoint: str, access_key: str, secret_key: str,
|
||||||
bucket: str, region: str = "") -> Dict[str, Any]:
|
bucket: str, region: str = "") -> Dict[str, Any]:
|
||||||
"""测试OSS连接 - 使用httpx发送HTTP请求到对象存储端点"""
|
"""测试OSS连接 - 使用httpx发送HTTP请求到对象存储端点"""
|
||||||
|
|||||||
@@ -486,6 +486,7 @@ class AIModel(Base):
|
|||||||
id = Column(String(36), primary_key=True, default=gen_uuid)
|
id = Column(String(36), primary_key=True, default=gen_uuid)
|
||||||
provider_id = Column(String(36), ForeignKey("model_providers.id"), nullable=False)
|
provider_id = Column(String(36), ForeignKey("model_providers.id"), nullable=False)
|
||||||
name = Column(String(100), 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)
|
api_key = Column(String(255), default=None)
|
||||||
endpoint = Column(String(500), default=None)
|
endpoint = Column(String(500), default=None)
|
||||||
capabilities = Column(JSON, default=None)
|
capabilities = Column(JSON, default=None)
|
||||||
@@ -1004,6 +1005,22 @@ class OSSStorageConfig(Base):
|
|||||||
created_at = Column(DateTime, default=datetime.now)
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
updated_at = Column(DateTime, default=datetime.now, onupdate=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):
|
class AutomatedTask(Base):
|
||||||
__tablename__ = "automated_tasks"
|
__tablename__ = "automated_tasks"
|
||||||
|
|
||||||
|
|||||||
@@ -513,6 +513,19 @@ export const adminAPI = {
|
|||||||
toggleAdminStorageConfig: (id: string) => put<any>(`/admin/storage/${id}/toggle`),
|
toggleAdminStorageConfig: (id: string) => put<any>(`/admin/storage/${id}/toggle`),
|
||||||
setDefaultAdminStorageConfig: (id: string) => put<any>(`/admin/storage/${id}/set-default`),
|
setDefaultAdminStorageConfig: (id: string) => put<any>(`/admin/storage/${id}/set-default`),
|
||||||
testAdminStorageConfig: (data: any) => post<any>('/admin/storage/test', data),
|
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)
|
// 自动化任务调度 (Phase 3.2)
|
||||||
getAdminAutomationStats: () => get<any>('/admin/automation/stats/overview'),
|
getAdminAutomationStats: () => get<any>('/admin/automation/stats/overview'),
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user