test/custom-admin-ui #3

Merged
orion merged 673 commits from test/custom-admin-ui into master 2026-06-05 17:20:58 +08:00
7 changed files with 417 additions and 23 deletions
+19 -3
View File
@@ -9,13 +9,28 @@ RUN corepack enable && \
WORKDIR /build
# 克隆并构建 2024 主题
RUN git clone --depth 1 https://github.com/vastsa/FileCodeBoxFronted.git /build/fronted-2024 && \
ARG FRONTEND_2024_REPO=https://git.orionc.me/orion/FileCodeBoxFronted.git
ARG FRONTEND_2024_BRANCH=main
ARG FRONTEND_CACHE_BUST=1
RUN echo "$FRONTEND_CACHE_BUST" >/tmp/frontend-cache-bust && \
git clone --depth 1 --branch "$FRONTEND_2024_BRANCH" "$FRONTEND_2024_REPO" /build/fronted-2024 && \
cd /build/fronted-2024 && \
pnpm install --frozen-lockfile --prod=false && \
pnpm run build
# 克隆并构建 2026 主题
ARG FRONTEND_2026_REPO=https://git.orionc.me/orion/FileCodeBoxFronted2026.git
ARG FRONTEND_2026_BRANCH=main
RUN echo "$FRONTEND_CACHE_BUST" >/tmp/frontend-2026-cache-bust && \
git clone --depth 1 --branch "$FRONTEND_2026_BRANCH" "$FRONTEND_2026_REPO" /build/fronted-2026 && \
cd /build/fronted-2026 && \
pnpm install --frozen-lockfile --prod=false && \
pnpm run build
# 克隆并构建 2023 主题
RUN git clone --depth 1 https://github.com/vastsa/FileCodeBoxFronted2023.git /build/fronted-2023 && \
ARG FRONTEND_2023_REPO=https://git.orionc.me/orion/FileCodeBoxFronted2023.git
ARG FRONTEND_2023_BRANCH=main
RUN git clone --depth 1 --branch "$FRONTEND_2023_BRANCH" "$FRONTEND_2023_REPO" /build/fronted-2023 && \
cd /build/fronted-2023 && \
npm install --legacy-peer-deps && \
npm run build
@@ -36,6 +51,7 @@ RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
# 从构建阶段复制编译好的前端主题
COPY --from=frontend-builder /build/fronted-2024/dist ./themes/2024
COPY --from=frontend-builder /build/fronted-2026/dist ./themes/2026
COPY --from=frontend-builder /build/fronted-2023/dist ./themes/2023
# 安装 Python 依赖
@@ -50,4 +66,4 @@ ENV HOST="0.0.0.0" \
EXPOSE 12345
# 生产环境启动命令
CMD ["sh", "-c", "exec uvicorn main:app --host \"$HOST\" --port \"$PORT\" --workers \"$WORKERS\" --log-level \"$LOG_LEVEL\" --proxy-headers --forwarded-allow-ips '*'"]
CMD ["sh", "-c", "exec uvicorn main:app --host \"$HOST\" --port \"$PORT\" --workers \"$WORKERS\" --log-level \"$LOG_LEVEL\" --proxy-headers --forwarded-allow-ips '*'"]
+66 -8
View File
@@ -1,14 +1,15 @@
import hashlib
import json
import os
import time
from datetime import datetime, timedelta
from typing import Any, Optional
from typing import Any, Dict, List, Optional, Tuple
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
@@ -81,7 +82,47 @@ class FileService:
def _file_metadata_key(self, file_id: int) -> str:
return f"{self.FILE_METADATA_KEY_PREFIX}{file_id}"
def _dedupe_download_stats(self, stats: List[TransferStats]) -> List[TransferStats]:
deduped: List[TransferStats] = []
recent_downloads: Dict[Tuple[Optional[int], str, int], datetime] = {}
for stat in stats:
key = (stat.file_code_id, stat.ip or "", int(stat.size or 0))
previous = recent_downloads.get(key)
if previous and (stat.created_at - previous).total_seconds() <= 5:
continue
recent_downloads[key] = stat.created_at
deduped.append(stat)
return deduped
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()
@@ -614,7 +655,7 @@ class FileService:
is_permanent=is_permanent,
can_download=can_download,
)
timeline = self._build_file_timeline(
timeline = await self._build_file_timeline(
file_code=file_code,
detail=detail,
now=now,
@@ -1176,7 +1217,7 @@ class FileService:
},
}
def _build_file_timeline(
async def _build_file_timeline(
self,
file_code: FileCodes,
detail: dict[str, Any],
@@ -1220,7 +1261,7 @@ class FileService:
"key": "expiration_policy",
"status": "unlimited",
"severity": "success",
"timestamp": None,
"timestamp": file_code.created_at,
}
)
elif file_code.expired_at is not None:
@@ -1242,7 +1283,7 @@ class FileService:
"key": "download_limit",
"status": "exhausted" if exhausted else "active",
"severity": "danger" if exhausted else "info",
"timestamp": None,
"timestamp": file_code.created_at,
"value": remaining_downloads,
}
)
@@ -1252,20 +1293,37 @@ class FileService:
"key": "download_limit",
"status": "unlimited",
"severity": "success",
"timestamp": None,
"timestamp": file_code.created_at,
"value": None,
}
)
download_stats = await TransferStats.filter(
action="download", file_code_id=file_code.id
).order_by("created_at")
download_stats = self._dedupe_download_stats(download_stats)
timeline.append(
{
"key": "retrieved",
"status": "done" if file_code.used_count > 0 else "pending",
"severity": "success" if file_code.used_count > 0 else "neutral",
"timestamp": None,
"timestamp": (
download_stats[-1].created_at if download_stats else file_code.created_at
),
"value": file_code.used_count,
}
)
for index, stat in enumerate(download_stats, start=1):
timeline.append(
{
"key": "retrieved_event",
"status": "done",
"severity": "success",
"timestamp": stat.created_at,
"value": index,
"detail": stat.ip or "",
}
)
return timeline
def _seconds_between(
+225 -2
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,229 @@ 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)
raw_stats = await TransferStats.filter(
created_at__gte=start_day,
created_at__lt=end_exclusive,
).order_by("created_at", "id")
stats = []
recent_downloads = {}
for item in raw_stats:
if item.action == "download":
dedupe_key = (
item.file_code_id or item.code or "",
item.ip or "",
int(item.size or 0),
)
previous = recent_downloads.get(dedupe_key)
if previous and (item.created_at - previous).total_seconds() <= 5:
continue
recent_downloads[dedupe_key] = item.created_at
stats.append(item)
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"] = current_name
bucket["size"] = int(file_item.size or 0)
bucket["uploaded_at"] = file_item.created_at or bucket["uploaded_at"]
bucket["expired_at"] = file_item.expired_at
bucket["deleted"] = False
bucket["deleted_at"] = None
created_key = (
file_item.created_at.strftime("%Y-%m-%d") if file_item.created_at else ""
)
created_row = day_map.get(created_key)
if bucket["uploads"] <= 0:
bucket["uploads"] = 1
bucket["uploadTraffic"] = int(file_item.size or 0)
if created_row:
created_row["uploads"] += 1
created_row["uploadTraffic"] += int(file_item.size or 0)
missing_downloads = max(0, int(file_item.used_count or 0) - bucket["downloads"])
if missing_downloads:
traffic = missing_downloads * int(file_item.size or 0)
bucket["downloads"] += missing_downloads
bucket["downloadTraffic"] += traffic
if created_row:
created_row["downloads"] += missing_downloads
created_row["downloadTraffic"] += traffic
def iso(value):
return value.isoformat() if value else ""
def current_file_expired(file_item) -> bool:
if not file_item:
return False
if file_item.expired_at and file_item.expired_at < now:
return True
return file_item.expired_count == 0
def history_row(code: str, data: dict) -> dict:
file_item = file_map.get(code)
current_name = ""
if file_item:
current_name = (
f"{file_item.prefix or ''}{file_item.suffix or ''}"
or file_item.uuid_file_name
or file_item.code
)
uploaded_at = file_item.created_at if file_item else data.get("uploaded_at")
expired_at = file_item.expired_at if file_item else data.get("expired_at")
size = int(file_item.size or 0) if file_item else data.get("size") or 0
return {
"code": code,
"name": current_name or data.get("name") or code,
"size": str(size),
"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),
"expired": current_file_expired(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]
daily_rows = list(day_map.values())
return APIResponse(
detail={
"range": {
"start": start_day.strftime("%Y-%m-%d"),
"end": end_day.strftime("%Y-%m-%d"),
},
"totals": {
"totalDownloads": sum(row["downloads"] for row in daily_rows),
"downloadedFiles": len(
[item for item in file_stats.values() if (item.get("downloads") or 0) > 0]
),
"downloadTraffic": str(
sum(row["downloadTraffic"] for row in daily_rows)
),
"historicalFiles": len(file_stats),
"currentFiles": len(files),
"totalUploads": sum(row["uploads"] for row in daily_rows),
"uploadTraffic": str(
sum(row["uploadTraffic"] for row in daily_rows)
),
},
"daily": daily_rows,
"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,
@@ -601,4 +824,4 @@ async def update_file(
count=1,
meta={"fields": sorted(update_data.keys())},
)
return APIResponse(detail="更新成功")
return APIResponse(detail="更新成功")
@@ -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);
"""
)
+19 -1
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(
@@ -89,4 +107,4 @@ upload_chunk_pydantic = pydantic_model_creator(UploadChunk, name="UploadChunk")
key_value_pydantic = pydantic_model_creator(KeyValue, name="KeyValue")
presign_upload_session_pydantic = pydantic_model_creator(
PresignUploadSession, name="PresignUploadSession"
)
)
+50 -8
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)
@@ -257,7 +292,9 @@ async def select_file(data: SelectFileModel, ip: str = Depends(ip_limit["error"]
return APIResponse(code=404, detail=file_code)
assert isinstance(file_code, FileCodes)
await update_file_usage(file_code)
if file_code.text is not None:
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))
@@ -267,10 +304,12 @@ async def download_file(key: str, code: str, ip: str = Depends(ip_limit["error"]
if await get_select_token(code) != key:
ip_limit["error"].add_ip(ip)
raise HTTPException(status_code=403, detail="下载鉴权失败")
has, file_code = await get_code_file_by_code(code, False)
has, file_code = await get_code_file_by_code(code)
if not has:
return APIResponse(code=404, detail="文件不存在")
return APIResponse(code=404, detail=file_code)
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=file_code.text)
if file_code.text
@@ -557,7 +596,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 +610,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 +737,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 +762,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()
+7 -1
View File
@@ -62,6 +62,12 @@ DEFAULT_CONFIG = {
"author": "Lan",
"version": "1.0",
},
{
"name": "2026",
"key": "themes/2026",
"author": "Orion",
"version": "1.0",
},
],
"themesSelect": "themes/2024",
"errorMinute": 1,
@@ -98,4 +104,4 @@ class Settings:
return {**self.default_config, **self.user_config}.items()
settings = Settings(DEFAULT_CONFIG)
settings = Settings(DEFAULT_CONFIG)