test/custom-admin-ui #3
+18
-2
@@ -9,13 +9,28 @@ RUN corepack enable && \
|
|||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
# 克隆并构建 2024 主题
|
# 克隆并构建 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 && \
|
cd /build/fronted-2024 && \
|
||||||
pnpm install --frozen-lockfile --prod=false && \
|
pnpm install --frozen-lockfile --prod=false && \
|
||||||
pnpm run build
|
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 主题
|
# 克隆并构建 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 && \
|
cd /build/fronted-2023 && \
|
||||||
npm install --legacy-peer-deps && \
|
npm install --legacy-peer-deps && \
|
||||||
npm run build
|
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-2024/dist ./themes/2024
|
||||||
|
COPY --from=frontend-builder /build/fronted-2026/dist ./themes/2026
|
||||||
COPY --from=frontend-builder /build/fronted-2023/dist ./themes/2023
|
COPY --from=frontend-builder /build/fronted-2023/dist ./themes/2023
|
||||||
|
|
||||||
# 安装 Python 依赖
|
# 安装 Python 依赖
|
||||||
|
|||||||
+66
-8
@@ -1,14 +1,15 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
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.response import APIResponse
|
||||||
from core.storage import FileStorageInterface, storages
|
from core.storage import FileStorageInterface, storages
|
||||||
from core.settings import settings
|
from core.settings import settings
|
||||||
from core.config import refresh_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 apps.base.utils import get_expire_info, get_file_path_name
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from core.settings import data_root
|
from core.settings import data_root
|
||||||
@@ -81,7 +82,47 @@ class FileService:
|
|||||||
def _file_metadata_key(self, file_id: int) -> str:
|
def _file_metadata_key(self, file_id: int) -> str:
|
||||||
return f"{self.FILE_METADATA_KEY_PREFIX}{file_id}"
|
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):
|
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:
|
if file_code.text is None:
|
||||||
await self.file_storage.delete_file(file_code)
|
await self.file_storage.delete_file(file_code)
|
||||||
await KeyValue.filter(key=self._file_metadata_key(file_code.id)).delete()
|
await KeyValue.filter(key=self._file_metadata_key(file_code.id)).delete()
|
||||||
@@ -614,7 +655,7 @@ class FileService:
|
|||||||
is_permanent=is_permanent,
|
is_permanent=is_permanent,
|
||||||
can_download=can_download,
|
can_download=can_download,
|
||||||
)
|
)
|
||||||
timeline = self._build_file_timeline(
|
timeline = await self._build_file_timeline(
|
||||||
file_code=file_code,
|
file_code=file_code,
|
||||||
detail=detail,
|
detail=detail,
|
||||||
now=now,
|
now=now,
|
||||||
@@ -1176,7 +1217,7 @@ class FileService:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def _build_file_timeline(
|
async def _build_file_timeline(
|
||||||
self,
|
self,
|
||||||
file_code: FileCodes,
|
file_code: FileCodes,
|
||||||
detail: dict[str, Any],
|
detail: dict[str, Any],
|
||||||
@@ -1220,7 +1261,7 @@ class FileService:
|
|||||||
"key": "expiration_policy",
|
"key": "expiration_policy",
|
||||||
"status": "unlimited",
|
"status": "unlimited",
|
||||||
"severity": "success",
|
"severity": "success",
|
||||||
"timestamp": None,
|
"timestamp": file_code.created_at,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif file_code.expired_at is not None:
|
elif file_code.expired_at is not None:
|
||||||
@@ -1242,7 +1283,7 @@ class FileService:
|
|||||||
"key": "download_limit",
|
"key": "download_limit",
|
||||||
"status": "exhausted" if exhausted else "active",
|
"status": "exhausted" if exhausted else "active",
|
||||||
"severity": "danger" if exhausted else "info",
|
"severity": "danger" if exhausted else "info",
|
||||||
"timestamp": None,
|
"timestamp": file_code.created_at,
|
||||||
"value": remaining_downloads,
|
"value": remaining_downloads,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1252,20 +1293,37 @@ class FileService:
|
|||||||
"key": "download_limit",
|
"key": "download_limit",
|
||||||
"status": "unlimited",
|
"status": "unlimited",
|
||||||
"severity": "success",
|
"severity": "success",
|
||||||
"timestamp": None,
|
"timestamp": file_code.created_at,
|
||||||
"value": None,
|
"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(
|
timeline.append(
|
||||||
{
|
{
|
||||||
"key": "retrieved",
|
"key": "retrieved",
|
||||||
"status": "done" if file_code.used_count > 0 else "pending",
|
"status": "done" if file_code.used_count > 0 else "pending",
|
||||||
"severity": "success" if file_code.used_count > 0 else "neutral",
|
"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,
|
"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
|
return timeline
|
||||||
|
|
||||||
def _seconds_between(
|
def _seconds_between(
|
||||||
|
|||||||
+224
-1
@@ -30,7 +30,7 @@ from apps.admin.schemas import (
|
|||||||
UpdateFileData,
|
UpdateFileData,
|
||||||
)
|
)
|
||||||
from core.response import APIResponse
|
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 apps.admin.dependencies import create_token
|
||||||
from core.settings import settings
|
from core.settings import settings
|
||||||
from core.utils import get_now, verify_password
|
from core.utils import get_now, verify_password
|
||||||
@@ -174,6 +174,229 @@ async def admin_activities(
|
|||||||
return APIResponse(detail=result)
|
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")
|
@admin_api.delete("/file/delete")
|
||||||
async def file_delete(
|
async def file_delete(
|
||||||
data: IDData,
|
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);
|
||||||
|
"""
|
||||||
|
)
|
||||||
@@ -54,6 +54,24 @@ class UploadChunk(models.Model):
|
|||||||
completed = fields.BooleanField(default=False)
|
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):
|
class KeyValue(Model):
|
||||||
id: Optional[int] = fields.IntField(pk=True)
|
id: Optional[int] = fields.IntField(pk=True)
|
||||||
key: Optional[str] = fields.CharField(
|
key: Optional[str] = fields.CharField(
|
||||||
|
|||||||
+49
-7
@@ -12,7 +12,7 @@ from pydantic import BaseModel, ValidationError
|
|||||||
from starlette import status
|
from starlette import status
|
||||||
|
|
||||||
from apps.admin.dependencies import share_required_login
|
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 (
|
from apps.base.schemas import (
|
||||||
SelectFileModel,
|
SelectFileModel,
|
||||||
InitChunkUploadModel,
|
InitChunkUploadModel,
|
||||||
@@ -59,6 +59,7 @@ class FileUploadService:
|
|||||||
file_path: str,
|
file_path: str,
|
||||||
expire_value: int,
|
expire_value: int,
|
||||||
expire_style: str,
|
expire_style: str,
|
||||||
|
ip: str = "",
|
||||||
**extra_fields,
|
**extra_fields,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""统一创建FileCodes记录,返回code"""
|
"""统一创建FileCodes记录,返回code"""
|
||||||
@@ -67,7 +68,7 @@ class FileUploadService:
|
|||||||
)
|
)
|
||||||
prefix, suffix = os.path.splitext(file_name)
|
prefix, suffix = os.path.splitext(file_name)
|
||||||
|
|
||||||
await FileCodes.create(
|
file_code = await FileCodes.create(
|
||||||
code=code,
|
code=code,
|
||||||
prefix=prefix,
|
prefix=prefix,
|
||||||
suffix=suffix,
|
suffix=suffix,
|
||||||
@@ -79,6 +80,7 @@ class FileUploadService:
|
|||||||
used_count=used_count,
|
used_count=used_count,
|
||||||
**extra_fields,
|
**extra_fields,
|
||||||
)
|
)
|
||||||
|
await record_transfer_stat("upload", file_code, file_size, ip)
|
||||||
return code
|
return code
|
||||||
|
|
||||||
|
|
||||||
@@ -100,6 +102,36 @@ async def create_file_code(code, **kwargs):
|
|||||||
return await FileCodes.create(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)])
|
@share_api.post("/text/", dependencies=[Depends(share_required_login)])
|
||||||
async def share_text(
|
async def share_text(
|
||||||
text: str = Form(...),
|
text: str = Form(...),
|
||||||
@@ -115,7 +147,7 @@ async def share_text(
|
|||||||
expired_at, expired_count, used_count, code = await get_expire_info(
|
expired_at, expired_count, used_count, code = await get_expire_info(
|
||||||
expire_value, expire_style
|
expire_value, expire_style
|
||||||
)
|
)
|
||||||
await create_file_code(
|
file_code = await create_file_code(
|
||||||
code=code,
|
code=code,
|
||||||
text=text,
|
text=text,
|
||||||
expired_at=expired_at,
|
expired_at=expired_at,
|
||||||
@@ -124,6 +156,7 @@ async def share_text(
|
|||||||
size=len(text),
|
size=len(text),
|
||||||
prefix="Text",
|
prefix="Text",
|
||||||
)
|
)
|
||||||
|
await record_transfer_stat("upload", file_code, text_size, ip)
|
||||||
ip_limit["upload"].add_ip(ip)
|
ip_limit["upload"].add_ip(ip)
|
||||||
return APIResponse(detail={"code": code})
|
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)
|
path, suffix, prefix, uuid_file_name, save_path = await get_file_path_name(file)
|
||||||
file_storage: FileStorageInterface = storages[settings.file_storage]()
|
file_storage: FileStorageInterface = storages[settings.file_storage]()
|
||||||
await file_storage.save_file(file, save_path)
|
await file_storage.save_file(file, save_path)
|
||||||
await create_file_code(
|
file_code = await create_file_code(
|
||||||
code=code,
|
code=code,
|
||||||
prefix=prefix,
|
prefix=prefix,
|
||||||
suffix=suffix,
|
suffix=suffix,
|
||||||
@@ -155,6 +188,7 @@ async def share_file(
|
|||||||
expired_count=expired_count,
|
expired_count=expired_count,
|
||||||
used_count=used_count,
|
used_count=used_count,
|
||||||
)
|
)
|
||||||
|
await record_transfer_stat("upload", file_code, file_size, ip)
|
||||||
ip_limit["upload"].add_ip(ip)
|
ip_limit["upload"].add_ip(ip)
|
||||||
return APIResponse(detail={"code": code, "name": file.filename})
|
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)
|
assert isinstance(file_code, FileCodes)
|
||||||
await update_file_usage(file_code)
|
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)
|
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)
|
return APIResponse(code=404, detail=file_code)
|
||||||
|
|
||||||
assert isinstance(file_code, FileCodes)
|
assert isinstance(file_code, FileCodes)
|
||||||
|
if file_code.text is not None:
|
||||||
await update_file_usage(file_code)
|
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))
|
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:
|
if await get_select_token(code) != key:
|
||||||
ip_limit["error"].add_ip(ip)
|
ip_limit["error"].add_ip(ip)
|
||||||
raise HTTPException(status_code=403, detail="下载鉴权失败")
|
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:
|
if not has:
|
||||||
return APIResponse(code=404, detail="文件不存在")
|
return APIResponse(code=404, detail=file_code)
|
||||||
assert isinstance(file_code, FileCodes)
|
assert isinstance(file_code, FileCodes)
|
||||||
|
await update_file_usage(file_code)
|
||||||
|
await record_transfer_stat("download", file_code, file_code.size, ip)
|
||||||
return (
|
return (
|
||||||
APIResponse(detail=file_code.text)
|
APIResponse(detail=file_code.text)
|
||||||
if 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(
|
expired_at, expired_count, used_count, code = await get_expire_info(
|
||||||
data.expire_value, data.expire_style
|
data.expire_value, data.expire_style
|
||||||
)
|
)
|
||||||
await FileCodes.create(
|
file_code = await FileCodes.create(
|
||||||
code=code,
|
code=code,
|
||||||
file_hash=file_hash, # 使用合并后计算的哈希
|
file_hash=file_hash, # 使用合并后计算的哈希
|
||||||
is_chunked=True,
|
is_chunked=True,
|
||||||
@@ -571,6 +610,7 @@ async def complete_upload(
|
|||||||
prefix=prefix,
|
prefix=prefix,
|
||||||
suffix=suffix,
|
suffix=suffix,
|
||||||
)
|
)
|
||||||
|
await record_transfer_stat("upload", file_code, chunk_info.file_size, ip)
|
||||||
# 清理临时文件
|
# 清理临时文件
|
||||||
await storage.clean_chunks(upload_id, save_path)
|
await storage.clean_chunks(upload_id, save_path)
|
||||||
# 清理数据库中的分片记录
|
# 清理数据库中的分片记录
|
||||||
@@ -697,6 +737,7 @@ async def presign_upload_proxy(
|
|||||||
os.path.dirname(session.save_path),
|
os.path.dirname(session.save_path),
|
||||||
session.expire_value,
|
session.expire_value,
|
||||||
session.expire_style,
|
session.expire_style,
|
||||||
|
ip=ip,
|
||||||
)
|
)
|
||||||
|
|
||||||
await session.delete()
|
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),
|
os.path.dirname(session.save_path),
|
||||||
session.expire_value,
|
session.expire_value,
|
||||||
session.expire_style,
|
session.expire_style,
|
||||||
|
ip=ip,
|
||||||
)
|
)
|
||||||
|
|
||||||
await session.delete()
|
await session.delete()
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ DEFAULT_CONFIG = {
|
|||||||
"author": "Lan",
|
"author": "Lan",
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "2026",
|
||||||
|
"key": "themes/2026",
|
||||||
|
"author": "Orion",
|
||||||
|
"version": "1.0",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
"themesSelect": "themes/2024",
|
"themesSelect": "themes/2024",
|
||||||
"errorMinute": 1,
|
"errorMinute": 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user