fix: 恢复 rsync 误删的近期开发(agents/skills/mcp/knowledge/workflow/tenant/billing/storage/privacy/promotion/achievement/automation/code-exec/developer/distribution/im-bot/portal/casdoor 等后端模块 + 对应前端页面路由, 补回 main.py 挂载与 App.tsx 路由
This commit is contained in:
1 parent
d95b847fee
commit
e8e5ffa1a9
65 files changed
+35624
-339
No files matched your search
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
资产中心管理 API(管理员)
|
||||
Phase 3.8: Asset Center Enhancement - Admin asset management, analytics, activity log
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_, func, desc, text
|
||||
from typing import Optional
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ...database import get_db, SessionLocal
|
||||
from ...models import Asset, AssetCategory, UserStorageQuota, User
|
||||
from ...auth import get_current_admin
|
||||
from ...middleware.log_service import LogService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
CATEGORY_LABELS = {
|
||||
"file": "文件", "novel": "小说", "image": "图片",
|
||||
"video": "视频", "audio": "音频", "custom": "自定义", "doc": "文档",
|
||||
}
|
||||
|
||||
|
||||
def _execute_raw(db, sql, params=None):
|
||||
result = db.execute(text(sql), params or {})
|
||||
try:
|
||||
rows = result.fetchall()
|
||||
return [dict(row._mapping) if hasattr(row, '_mapping') else dict(row) for row in rows]
|
||||
except:
|
||||
return []
|
||||
|
||||
|
||||
def _asset_to_dict(a):
|
||||
return {
|
||||
"id": a.id,
|
||||
"user_id": a.user_id,
|
||||
"name": a.name,
|
||||
"category": a.category,
|
||||
"sub_category": a.sub_category,
|
||||
"file_type": a.file_type,
|
||||
"size": a.size or 0,
|
||||
"description": a.description,
|
||||
"cover_url": a.cover_url,
|
||||
"url": a.url,
|
||||
"status": a.status,
|
||||
"created_at": a.created_at.strftime('%Y-%m-%d %H:%M:%S') if a.created_at else None,
|
||||
"updated_at": a.updated_at.strftime('%Y-%m-%d %H:%M:%S') if a.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Stats / Overview
|
||||
# ============================================================
|
||||
|
||||
@router.get("/stats", summary="资产中心总览")
|
||||
async def get_asset_stats(
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取资产中心总览数据"""
|
||||
# Total assets
|
||||
total_assets = _execute_raw(db, "SELECT COUNT(*) as cnt FROM assets WHERE status = 'active'")
|
||||
total = total_assets[0]["cnt"] if total_assets else 0
|
||||
|
||||
# Total size
|
||||
total_size = _execute_raw(db, "SELECT COALESCE(SUM(size), 0) as sz FROM assets WHERE status = 'active'")
|
||||
size = total_size[0]["sz"] if total_size else 0
|
||||
|
||||
# By category
|
||||
cat_stats = _execute_raw(db, """
|
||||
SELECT category, COUNT(*) as cnt, COALESCE(SUM(size), 0) as sz
|
||||
FROM assets WHERE status = 'active'
|
||||
GROUP BY category
|
||||
""")
|
||||
|
||||
# By file type
|
||||
type_stats = _execute_raw(db, """
|
||||
SELECT COALESCE(file_type, 'unknown') as ft, COUNT(*) as cnt, COALESCE(SUM(size), 0) as sz
|
||||
FROM assets WHERE status = 'active'
|
||||
GROUP BY file_type
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
|
||||
# Top users by storage
|
||||
top_users = _execute_raw(db, """
|
||||
SELECT a.user_id, u.username, u.avatar,
|
||||
COUNT(*) as asset_count, COALESCE(SUM(a.size), 0) as total_size
|
||||
FROM assets a
|
||||
LEFT JOIN users u ON a.user_id = u.id
|
||||
WHERE a.status = 'active'
|
||||
GROUP BY a.user_id, u.username, u.avatar
|
||||
ORDER BY total_size DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
|
||||
# Storage quota summary
|
||||
quota_stats = _execute_raw(db, """
|
||||
SELECT
|
||||
COUNT(*) as total_users,
|
||||
COALESCE(SUM(quota_bytes), 0) as total_quota,
|
||||
COALESCE(SUM(used_bytes), 0) as total_used
|
||||
FROM user_storage_quotas
|
||||
""")
|
||||
|
||||
# Recent 7 days trend
|
||||
trend = []
|
||||
for i in range(6, -1, -1):
|
||||
date = (datetime.now() - timedelta(days=i)).strftime('%Y-%m-%d')
|
||||
new_uploads = _execute_raw(db, """
|
||||
SELECT COUNT(*) as cnt FROM assets
|
||||
WHERE DATE(created_at) = :d AND status = 'active'
|
||||
""", {"d": date})
|
||||
trend.append({
|
||||
"date": date,
|
||||
"new_uploads": new_uploads[0]["cnt"] if new_uploads else 0,
|
||||
})
|
||||
|
||||
# Categories list
|
||||
categories = _execute_raw(db, """
|
||||
SELECT type, COUNT(*) as cnt FROM asset_categories GROUP BY type
|
||||
""")
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"total_assets": total,
|
||||
"total_size": size,
|
||||
"total_size_formatted": _format_size(size),
|
||||
"by_category": [{
|
||||
"category": r["category"],
|
||||
"label": CATEGORY_LABELS.get(r["category"], r["category"]),
|
||||
"count": r["cnt"],
|
||||
"size": r["sz"],
|
||||
"size_formatted": _format_size(r["sz"]),
|
||||
} for r in cat_stats],
|
||||
"by_file_type": [{
|
||||
"file_type": r["ft"],
|
||||
"count": r["cnt"],
|
||||
"size": r["sz"],
|
||||
"size_formatted": _format_size(r["sz"]),
|
||||
} for r in type_stats],
|
||||
"top_users": [{
|
||||
"user_id": r["user_id"],
|
||||
"username": r["username"] or "Unknown",
|
||||
"avatar": r["avatar"],
|
||||
"asset_count": r["asset_count"],
|
||||
"total_size": r["total_size"],
|
||||
"total_size_formatted": _format_size(r["total_size"]),
|
||||
} for r in top_users],
|
||||
"quota_summary": {
|
||||
"total_users": quota_stats[0]["total_users"] if quota_stats else 0,
|
||||
"total_quota": quota_stats[0]["total_quota"] if quota_stats else 0,
|
||||
"total_used": quota_stats[0]["total_used"] if quota_stats else 0,
|
||||
"total_quota_formatted": _format_size(quota_stats[0]["total_quota"]) if quota_stats else "0",
|
||||
"total_used_formatted": _format_size(quota_stats[0]["total_used"]) if quota_stats else "0",
|
||||
"usage_percent": round((quota_stats[0]["total_used"] / quota_stats[0]["total_quota"] * 100), 2) if quota_stats and quota_stats[0]["total_quota"] > 0 else 0,
|
||||
},
|
||||
"trend_7d": trend,
|
||||
"categories": [{"type": r["type"], "count": r["cnt"]} for r in categories],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _format_size(bytes_val):
|
||||
if not bytes_val or bytes_val == 0:
|
||||
return "0 B"
|
||||
if bytes_val < 1024:
|
||||
return f"{bytes_val} B"
|
||||
if bytes_val < 1024 * 1024:
|
||||
return f"{bytes_val / 1024:.1f} KB"
|
||||
if bytes_val < 1024 * 1024 * 1024:
|
||||
return f"{bytes_val / (1024 * 1024):.1f} MB"
|
||||
return f"{bytes_val / (1024 * 1024 * 1024):.2f} GB"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Asset list & management
|
||||
# ============================================================
|
||||
|
||||
@router.get("/", summary="List all assets")
|
||||
async def list_all_assets(
|
||||
user_id: str = Query(None),
|
||||
category: str = Query(None),
|
||||
file_type: str = Query(None),
|
||||
keyword: str = Query(None),
|
||||
status: str = Query("active"),
|
||||
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),
|
||||
):
|
||||
"""List all user assets"""
|
||||
where_clauses = []
|
||||
params = {}
|
||||
if status:
|
||||
where_clauses.append("a.status = :status")
|
||||
params["status"] = status
|
||||
if user_id:
|
||||
where_clauses.append("a.user_id = :uid")
|
||||
params["uid"] = user_id
|
||||
if category:
|
||||
where_clauses.append("a.category = :cat")
|
||||
params["cat"] = category
|
||||
if file_type:
|
||||
where_clauses.append("a.file_type = :ft")
|
||||
params["ft"] = file_type
|
||||
if keyword:
|
||||
where_clauses.append("(a.name LIKE :kw OR a.description LIKE :kw)")
|
||||
params["kw"] = "%" + keyword + "%"
|
||||
|
||||
where_sql = " AND ".join(where_clauses)
|
||||
where_clause = "WHERE " + where_sql if where_sql else ""
|
||||
|
||||
count_sql = "SELECT COUNT(*) as cnt FROM assets a " + where_clause
|
||||
count_rows = _execute_raw(db, count_sql, params)
|
||||
total = count_rows[0]["cnt"] if count_rows else 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
list_sql = "SELECT a.id, a.user_id, a.name, a.category, a.sub_category, a.file_type, a.size, a.description, a.cover_url, a.url, a.status, a.created_at, a.updated_at, u.username, u.avatar FROM assets a LEFT JOIN users u ON a.user_id = u.id " + where_clause + " ORDER BY a.created_at DESC LIMIT :limit OFFSET :offset"
|
||||
list_params = dict(params)
|
||||
list_params["limit"] = page_size
|
||||
list_params["offset"] = offset
|
||||
rows = _execute_raw(db, list_sql, list_params)
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
items.append({
|
||||
"id": r["id"], "user_id": r["user_id"], "name": r["name"],
|
||||
"category": r["category"], "sub_category": r["sub_category"],
|
||||
"file_type": r["file_type"], "size": r["size"] or 0,
|
||||
"size_formatted": _format_size(r["size"] or 0),
|
||||
"description": r["description"], "cover_url": r["cover_url"],
|
||||
"url": r["url"], "status": r["status"],
|
||||
"username": r["username"] or "Unknown", "avatar": r["avatar"],
|
||||
"created_at": r["created_at"].strftime("%Y-%m-%d %H:%M:%S") if r["created_at"] else None,
|
||||
"updated_at": r["updated_at"].strftime("%Y-%m-%d %H:%M:%S") if r["updated_at"] else None,
|
||||
})
|
||||
|
||||
return {"code": 0, "data": {"total": total, "page": page, "page_size": page_size, "items": items}}
|
||||
|
||||
|
||||
|
||||
@router.delete("/{asset_id}", summary="管理员删除资产")
|
||||
async def admin_delete_asset(
|
||||
asset_id: str,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""管理员删除用户资产"""
|
||||
asset = db.query(Asset).filter(Asset.id == asset_id).first()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="资产不存在")
|
||||
|
||||
asset_name = asset.name
|
||||
asset.status = "deleted"
|
||||
asset.updated_at = datetime.now()
|
||||
db.commit()
|
||||
|
||||
LogService.log_action(db, "admin", admin_user["id"], "delete_asset", f"Deleted asset: {asset_name}")
|
||||
|
||||
return {"code": 0, "message": "资产已删除"}
|
||||
|
||||
|
||||
@router.get("/activity", summary="最近资产活动")
|
||||
async def get_asset_activity(
|
||||
limit: int = Query(30, le=100),
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取最近的资产活动"""
|
||||
activities = _execute_raw(db, """
|
||||
SELECT a.id, a.name, a.category, a.file_type, a.size, a.status,
|
||||
a.user_id, u.username, u.avatar,
|
||||
a.created_at, a.updated_at
|
||||
FROM assets a
|
||||
LEFT JOIN users u ON a.user_id = u.id
|
||||
ORDER BY a.updated_at DESC
|
||||
LIMIT :lim
|
||||
""", {"lim": limit})
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": [{
|
||||
"id": r["id"],
|
||||
"name": r["name"],
|
||||
"category": r["category"],
|
||||
"category_label": CATEGORY_LABELS.get(r["category"], r["category"]),
|
||||
"file_type": r["file_type"],
|
||||
"size": r["size"] or 0,
|
||||
"size_formatted": _format_size(r["size"] or 0),
|
||||
"status": r["status"],
|
||||
"user_id": r["user_id"],
|
||||
"username": r["username"] or "Unknown",
|
||||
"avatar": r["avatar"],
|
||||
"created_at": r["created_at"].strftime('%Y-%m-%d %H:%M:%S') if r["created_at"] else None,
|
||||
"updated_at": r["updated_at"].strftime('%Y-%m-%d %H:%M:%S') if r["updated_at"] else None,
|
||||
} for r in activities]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/detail", summary="用户资产详情")
|
||||
async def get_user_asset_detail(
|
||||
user_id: str,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取指定用户的资产详情"""
|
||||
# User info
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
# Asset stats
|
||||
stats = _execute_raw(db, """
|
||||
SELECT category, COUNT(*) as cnt, COALESCE(SUM(size), 0) as sz
|
||||
FROM assets WHERE user_id = :uid AND status = 'active'
|
||||
GROUP BY category
|
||||
""", {"uid": user_id})
|
||||
|
||||
total = _execute_raw(db, """
|
||||
SELECT COUNT(*) as cnt, COALESCE(SUM(size), 0) as sz
|
||||
FROM assets WHERE user_id = :uid AND status = 'active'
|
||||
""", {"uid": user_id})
|
||||
|
||||
# Quota
|
||||
quota = db.query(UserStorageQuota).filter(UserStorageQuota.user_id == user_id).first()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"avatar": user.avatar,
|
||||
"membership_level": user.membership_level,
|
||||
},
|
||||
"total_assets": total[0]["cnt"] if total else 0,
|
||||
"total_size": total[0]["sz"] if total else 0,
|
||||
"total_size_formatted": _format_size(total[0]["sz"]) if total else "0 B",
|
||||
"by_category": [{
|
||||
"category": r["category"],
|
||||
"label": CATEGORY_LABELS.get(r["category"], r["category"]),
|
||||
"count": r["cnt"],
|
||||
"size": r["sz"],
|
||||
"size_formatted": _format_size(r["sz"]),
|
||||
} for r in stats],
|
||||
"quota": {
|
||||
"quota_bytes": quota.quota_bytes if quota else 0,
|
||||
"used_bytes": quota.used_bytes if quota else 0,
|
||||
"quota_formatted": _format_size(quota.quota_bytes) if quota else "0 B",
|
||||
"used_formatted": _format_size(quota.used_bytes) if quota else "0 B",
|
||||
"usage_percent": round((quota.used_bytes / quota.quota_bytes * 100), 2) if quota and quota.quota_bytes > 0 else 0,
|
||||
} if quota else None,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user