Phase 3.8: Asset Center Enhancement - fix list endpoint with raw SQL, admin asset management deployed
This commit is contained in:
1 parent
63741b3f0f
commit
065d2f05f6
4 files changed
+364
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ const AdminAutomation = lazy(() => import('@/pages/admin/Automation'));
|
|||||||
const AdminDeveloper = lazy(() => import('@/pages/admin/Developer'));
|
const AdminDeveloper = lazy(() => import('@/pages/admin/Developer'));
|
||||||
const AdminMcp = lazy(() => import('@/pages/admin/Mcp'));
|
const AdminMcp = lazy(() => import('@/pages/admin/Mcp'));
|
||||||
const AdminStorage = lazy(() => import('@/pages/admin/Storage'));
|
const AdminStorage = lazy(() => import('@/pages/admin/Storage'));
|
||||||
|
const AdminAssetCenter = lazy(() => import('@/pages/admin/AssetCenter'));
|
||||||
const AdminWorkflow = lazy(() => import('@/pages/admin/Workflow'));
|
const AdminWorkflow = lazy(() => import('@/pages/admin/Workflow'));
|
||||||
|
|
||||||
// Loading fallback
|
// Loading fallback
|
||||||
@@ -128,6 +129,7 @@ const App: React.FC = () => {
|
|||||||
<Route path="settings" element={<AdminSiteSettings />} />
|
<Route path="settings" element={<AdminSiteSettings />} />
|
||||||
<Route path="logs" element={<AdminLogs />} />
|
<Route path="logs" element={<AdminLogs />} />
|
||||||
<Route path="storage" element={<AdminStorage />} />
|
<Route path="storage" element={<AdminStorage />} />
|
||||||
|
<Route path="asset-center" element={<AdminAssetCenter />} />
|
||||||
<Route path="automation" element={<AdminAutomation />} />
|
<Route path="automation" element={<AdminAutomation />} />
|
||||||
<Route path="developer" element={<AdminDeveloper />} />
|
<Route path="developer" element={<AdminDeveloper />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -509,6 +509,10 @@ export const adminAPI = {
|
|||||||
syncAdminKnowledge: (kbId: string, data: any) => post<any>(`/admin/knowledge/external/sync/${kbId}`, data),
|
syncAdminKnowledge: (kbId: string, data: any) => post<any>(`/admin/knowledge/external/sync/${kbId}`, data),
|
||||||
batchDeleteAdminKnowledge: (ids: string[]) => post<any>('/admin/knowledge/batch/delete', ids),
|
batchDeleteAdminKnowledge: (ids: string[]) => post<any>('/admin/knowledge/batch/delete', ids),
|
||||||
// OSS存储配置 (Phase 3.1)
|
// OSS存储配置 (Phase 3.1)
|
||||||
|
getAssetStats: () => get<any>('/admin/assets/stats'),
|
||||||
|
getAdminAssets: (query: string) => get<any>(`/admin/assets/?${query}`),
|
||||||
|
deleteAdminAsset: (id: string) => del<any>(`/admin/assets/${id}`),
|
||||||
|
getAssetActivity: () => get<any>('/admin/assets/activity'),
|
||||||
getAdminStorageStats: () => get<any>('/admin/storage/stats/overview'),
|
getAdminStorageStats: () => get<any>('/admin/storage/stats/overview'),
|
||||||
getAdminStorageConfigs: (params?: any) => {
|
getAdminStorageConfigs: (params?: any) => {
|
||||||
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
|
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const navGroups: NavGroup[] = [
|
|||||||
{ path: '/admin/settings', icon: 'settings', label: '站点设置' },
|
{ path: '/admin/settings', icon: 'settings', label: '站点设置' },
|
||||||
{ path: '/admin/logs', icon: 'logs', label: '日志管理' },
|
{ path: '/admin/logs', icon: 'logs', label: '日志管理' },
|
||||||
{ path: '/admin/storage', icon: 'storage', label: '存储管理' },
|
{ path: '/admin/storage', icon: 'storage', label: '存储管理' },
|
||||||
|
{ path: '/admin/asset-center', icon: 'database', label: '资产中心' },
|
||||||
{ path: '/admin/automation', icon: 'automation', label: '自动化任务' },
|
{ path: '/admin/automation', icon: 'automation', label: '自动化任务' },
|
||||||
{ path: '/admin/developer', icon: 'developer', label: '开发者工具' },
|
{ path: '/admin/developer', icon: 'developer', label: '开发者工具' },
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in new issue
Block a user