Use forked frontend builds for test admin UI

This commit is contained in:
Orion
2026-06-05 11:14:13 +08:00
parent 1460b51163
commit 9d978ca68e
32 changed files with 307 additions and 1303 deletions
+30 -1
View File
@@ -1,4 +1,5 @@
import hashlib
import json
import os
import time
from datetime import datetime, timedelta
@@ -8,7 +9,7 @@ from core.response import APIResponse
from core.storage import FileStorageInterface, storages
from core.settings import settings
from core.config import refresh_settings
from apps.base.models import FileCodes, KeyValue, file_codes_pydantic
from apps.base.models import FileCodes, KeyValue, TransferStats, file_codes_pydantic
from apps.base.utils import get_expire_info, get_file_path_name
from fastapi import HTTPException
from core.settings import data_root
@@ -82,6 +83,34 @@ class FileService:
return f"{self.FILE_METADATA_KEY_PREFIX}{file_id}"
async def _delete_file_code(self, file_code: FileCodes):
try:
name = (
f"{file_code.prefix or ''}{file_code.suffix or ''}"
or file_code.uuid_file_name
or file_code.code
)
await TransferStats.create(
action="delete",
file_code_id=file_code.id,
code=file_code.code,
name=name,
size=int(file_code.size or 0),
ip="admin-delete",
file_path=file_code.file_path or "",
expired_at=file_code.expired_at,
deleted_at=datetime.now(),
meta=json.dumps(
{
"prefix": file_code.prefix or "",
"suffix": file_code.suffix or "",
"uuid_file_name": file_code.uuid_file_name or "",
"created_at": str(file_code.created_at),
},
ensure_ascii=False,
),
)
except Exception:
pass
if file_code.text is None:
await self.file_storage.delete_file(file_code)
await KeyValue.filter(key=self._file_metadata_key(file_code.id)).delete()
+176 -1
View File
@@ -30,7 +30,7 @@ from apps.admin.schemas import (
UpdateFileData,
)
from core.response import APIResponse
from apps.base.models import FileCodes, KeyValue
from apps.base.models import FileCodes, KeyValue, TransferStats
from apps.admin.dependencies import create_token
from core.settings import settings
from core.utils import get_now, verify_password
@@ -174,6 +174,181 @@ async def admin_activities(
return APIResponse(detail=result)
@admin_api.get("/analytics")
async def analytics(start: str = "", end: str = ""):
now = await get_now()
def parse_day(value: str, fallback: datetime.datetime) -> datetime.datetime:
if not value:
return fallback
try:
return datetime.datetime.strptime(value[:10], "%Y-%m-%d")
except Exception:
return fallback
end_day = parse_day(end, now.replace(hour=0, minute=0, second=0, microsecond=0))
start_day = parse_day(start, end_day - datetime.timedelta(days=29))
end_exclusive = end_day + datetime.timedelta(days=1)
stats = await TransferStats.filter(
created_at__gte=start_day,
created_at__lt=end_exclusive,
)
files = await FileCodes.all()
file_map = {item.code: item for item in files}
day_map = {}
cursor = start_day
while cursor < end_exclusive:
key = cursor.strftime("%Y-%m-%d")
day_map[key] = {
"date": key,
"downloads": 0,
"uploads": 0,
"downloadTraffic": 0,
"uploadTraffic": 0,
}
cursor += datetime.timedelta(days=1)
file_stats = {}
for item in stats:
key = item.created_at.strftime("%Y-%m-%d")
row = day_map.get(key)
if row and item.action == "download":
row["downloads"] += 1
row["downloadTraffic"] += item.size or 0
elif row and item.action == "upload":
row["uploads"] += 1
row["uploadTraffic"] += item.size or 0
if not item.code:
continue
bucket = file_stats.setdefault(
item.code,
{
"downloads": 0,
"downloadTraffic": 0,
"uploads": 0,
"uploadTraffic": 0,
"name": item.name or item.code,
"size": int(item.size or 0),
"uploaded_at": None,
"expired_at": item.expired_at,
"deleted_at": None,
"deleted": False,
},
)
bucket["name"] = bucket["name"] or item.name or item.code
bucket["size"] = bucket["size"] or int(item.size or 0)
bucket["expired_at"] = bucket["expired_at"] or item.expired_at
if item.action == "upload":
bucket["uploads"] += 1
bucket["uploadTraffic"] += item.size or 0
bucket["uploaded_at"] = bucket["uploaded_at"] or item.created_at
elif item.action == "download":
bucket["downloads"] += 1
bucket["downloadTraffic"] += item.size or 0
elif item.action == "delete":
bucket["deleted"] = True
bucket["deleted_at"] = item.deleted_at or item.created_at
for file_item in files:
current_name = (
f"{file_item.prefix or ''}{file_item.suffix or ''}"
or file_item.uuid_file_name
or file_item.code
)
bucket = file_stats.setdefault(
file_item.code,
{
"downloads": 0,
"downloadTraffic": 0,
"uploads": 0,
"uploadTraffic": 0,
"name": current_name,
"size": int(file_item.size or 0),
"uploaded_at": file_item.created_at,
"expired_at": file_item.expired_at,
"deleted_at": None,
"deleted": False,
},
)
bucket["name"] = bucket["name"] or current_name
bucket["size"] = bucket["size"] or int(file_item.size or 0)
bucket["uploaded_at"] = bucket["uploaded_at"] or file_item.created_at
bucket["expired_at"] = bucket["expired_at"] or file_item.expired_at
def iso(value):
return value.isoformat() if value else ""
def history_row(code: str, data: dict) -> dict:
file_item = file_map.get(code)
uploaded_at = data.get("uploaded_at") or (
file_item.created_at if file_item else None
)
expired_at = data.get("expired_at") or (
file_item.expired_at if file_item else None
)
return {
"code": code,
"name": data.get("name") or code,
"size": str(data.get("size") or 0),
"download_count": data.get("downloads") or 0,
"download_traffic": str(data.get("downloadTraffic") or 0),
"upload_count": data.get("uploads") or 0,
"upload_traffic": str(data.get("uploadTraffic") or 0),
"current": bool(file_item),
"deleted": bool(data.get("deleted")) and not bool(file_item),
"created_at": iso(uploaded_at),
"uploaded_at": iso(uploaded_at),
"expired_at": iso(expired_at),
"deleted_at": iso(data.get("deleted_at")),
}
def sort_time(value) -> float:
return value.timestamp() if value else 0
history = sorted(
file_stats.items(),
key=lambda kv: (
sort_time(kv[1].get("deleted_at") or kv[1].get("uploaded_at")),
kv[1].get("downloads") or 0,
kv[1].get("downloadTraffic") or 0,
),
reverse=True,
)
top_files = sorted(
[item for item in history if (item[1].get("downloads") or 0) > 0],
key=lambda kv: (kv[1].get("downloads") or 0, kv[1].get("downloadTraffic") or 0),
reverse=True,
)[:5]
return APIResponse(
detail={
"range": {
"start": start_day.strftime("%Y-%m-%d"),
"end": end_day.strftime("%Y-%m-%d"),
},
"totals": {
"totalDownloads": sum(1 for item in stats if item.action == "download"),
"downloadedFiles": len(
{item.code for item in stats if item.action == "download" and item.code}
),
"downloadTraffic": str(
sum(item.size or 0 for item in stats if item.action == "download")
),
"historicalFiles": len(file_stats),
"currentFiles": len(files),
"totalUploads": sum(1 for item in stats if item.action == "upload"),
"uploadTraffic": str(
sum(item.size or 0 for item in stats if item.action == "upload")
),
},
"daily": list(day_map.values()),
"topFiles": [history_row(code, data) for code, data in top_files],
"historyFiles": [history_row(code, data) for code, data in history],
}
)
@admin_api.delete("/file/delete")
async def file_delete(
data: IDData,
@@ -0,0 +1,31 @@
from tortoise import connections
async def migrate():
conn = connections.get("default")
await conn.execute_script(
"""
CREATE TABLE IF NOT EXISTS transfer_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action VARCHAR(16) NOT NULL,
file_code_id INT NULL,
code VARCHAR(255) NULL,
name VARCHAR(512) NOT NULL DEFAULT '',
size BIGINT NOT NULL DEFAULT 0,
ip VARCHAR(64) NOT NULL DEFAULT '',
file_path VARCHAR(1024) NOT NULL DEFAULT '',
expired_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL,
meta TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_transfer_stats_action_created
ON transfer_stats(action, created_at);
CREATE INDEX IF NOT EXISTS idx_transfer_stats_file_code_id
ON transfer_stats(file_code_id);
CREATE INDEX IF NOT EXISTS idx_transfer_stats_code
ON transfer_stats(code);
CREATE INDEX IF NOT EXISTS idx_transfer_stats_deleted_at
ON transfer_stats(deleted_at);
"""
)
+18
View File
@@ -54,6 +54,24 @@ class UploadChunk(models.Model):
completed = fields.BooleanField(default=False)
class TransferStats(models.Model):
id = fields.IntField(pk=True)
action = fields.CharField(max_length=16, index=True)
file_code_id = fields.IntField(null=True, index=True)
code = fields.CharField(max_length=255, null=True, index=True)
name = fields.CharField(max_length=512, default="")
size = fields.BigIntField(default=0)
ip = fields.CharField(max_length=64, default="")
file_path = fields.CharField(max_length=1024, default="")
expired_at = fields.DatetimeField(null=True)
deleted_at = fields.DatetimeField(null=True, index=True)
meta = fields.TextField(null=True)
created_at = fields.DatetimeField(auto_now_add=True, index=True)
class Meta:
table = "transfer_stats"
class KeyValue(Model):
id: Optional[int] = fields.IntField(pk=True)
key: Optional[str] = fields.CharField(
+45 -5
View File
@@ -12,7 +12,7 @@ from pydantic import BaseModel, ValidationError
from starlette import status
from apps.admin.dependencies import share_required_login
from apps.base.models import FileCodes, UploadChunk, PresignUploadSession
from apps.base.models import FileCodes, UploadChunk, PresignUploadSession, TransferStats
from apps.base.schemas import (
SelectFileModel,
InitChunkUploadModel,
@@ -59,6 +59,7 @@ class FileUploadService:
file_path: str,
expire_value: int,
expire_style: str,
ip: str = "",
**extra_fields,
) -> str:
"""统一创建FileCodes记录,返回code"""
@@ -67,7 +68,7 @@ class FileUploadService:
)
prefix, suffix = os.path.splitext(file_name)
await FileCodes.create(
file_code = await FileCodes.create(
code=code,
prefix=prefix,
suffix=suffix,
@@ -79,6 +80,7 @@ class FileUploadService:
used_count=used_count,
**extra_fields,
)
await record_transfer_stat("upload", file_code, file_size, ip)
return code
@@ -100,6 +102,36 @@ async def create_file_code(code, **kwargs):
return await FileCodes.create(code=code, **kwargs)
async def record_transfer_stat(
action: str,
file_code: FileCodes,
size: Optional[int] = None,
ip: str = "",
deleted_at=None,
meta: str = "",
) -> None:
try:
name = (
f"{file_code.prefix or ''}{file_code.suffix or ''}"
or file_code.uuid_file_name
or file_code.code
)
await TransferStats.create(
action=action,
file_code_id=file_code.id,
code=file_code.code,
name=name,
size=int(size if size is not None else (file_code.size or 0)),
ip=ip or "",
file_path=file_code.file_path or "",
expired_at=file_code.expired_at,
deleted_at=deleted_at,
meta=meta or "",
)
except Exception:
pass
@share_api.post("/text/", dependencies=[Depends(share_required_login)])
async def share_text(
text: str = Form(...),
@@ -115,7 +147,7 @@ async def share_text(
expired_at, expired_count, used_count, code = await get_expire_info(
expire_value, expire_style
)
await create_file_code(
file_code = await create_file_code(
code=code,
text=text,
expired_at=expired_at,
@@ -124,6 +156,7 @@ async def share_text(
size=len(text),
prefix="Text",
)
await record_transfer_stat("upload", file_code, text_size, ip)
ip_limit["upload"].add_ip(ip)
return APIResponse(detail={"code": code})
@@ -144,7 +177,7 @@ async def share_file(
path, suffix, prefix, uuid_file_name, save_path = await get_file_path_name(file)
file_storage: FileStorageInterface = storages[settings.file_storage]()
await file_storage.save_file(file, save_path)
await create_file_code(
file_code = await create_file_code(
code=code,
prefix=prefix,
suffix=suffix,
@@ -155,6 +188,7 @@ async def share_file(
expired_count=expired_count,
used_count=used_count,
)
await record_transfer_stat("upload", file_code, file_size, ip)
ip_limit["upload"].add_ip(ip)
return APIResponse(detail={"code": code, "name": file.filename})
@@ -245,6 +279,7 @@ async def get_code_file(code: str, ip: str = Depends(ip_limit["error"])):
assert isinstance(file_code, FileCodes)
await update_file_usage(file_code)
await record_transfer_stat("download", file_code, file_code.size, ip)
return await file_storage.get_file_response(file_code)
@@ -258,6 +293,7 @@ async def select_file(data: SelectFileModel, ip: str = Depends(ip_limit["error"]
assert isinstance(file_code, FileCodes)
await update_file_usage(file_code)
await record_transfer_stat("download", file_code, file_code.size, ip)
return APIResponse(detail=await build_select_detail(file_code, file_storage))
@@ -271,6 +307,7 @@ async def download_file(key: str, code: str, ip: str = Depends(ip_limit["error"]
if not has:
return APIResponse(code=404, detail="文件不存在")
assert isinstance(file_code, FileCodes)
await record_transfer_stat("download", file_code, file_code.size, ip)
return (
APIResponse(detail=file_code.text)
if file_code.text
@@ -557,7 +594,7 @@ async def complete_upload(
expired_at, expired_count, used_count, code = await get_expire_info(
data.expire_value, data.expire_style
)
await FileCodes.create(
file_code = await FileCodes.create(
code=code,
file_hash=file_hash, # 使用合并后计算的哈希
is_chunked=True,
@@ -571,6 +608,7 @@ async def complete_upload(
prefix=prefix,
suffix=suffix,
)
await record_transfer_stat("upload", file_code, chunk_info.file_size, ip)
# 清理临时文件
await storage.clean_chunks(upload_id, save_path)
# 清理数据库中的分片记录
@@ -697,6 +735,7 @@ async def presign_upload_proxy(
os.path.dirname(session.save_path),
session.expire_value,
session.expire_style,
ip=ip,
)
await session.delete()
@@ -721,6 +760,7 @@ async def presign_upload_confirm(upload_id: str, ip: str = Depends(ip_limit["upl
os.path.dirname(session.save_path),
session.expire_value,
session.expire_style,
ip=ip,
)
await session.delete()