diff --git a/Dockerfile b/Dockerfile index 7db69db..e658070 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 '*'"] \ No newline at end of file diff --git a/apps/admin/services.py b/apps/admin/services.py index a6549ba..3f8edba 100644 --- a/apps/admin/services.py +++ b/apps/admin/services.py @@ -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( diff --git a/apps/admin/views.py b/apps/admin/views.py index 33e428e..f63d762 100644 --- a/apps/admin/views.py +++ b/apps/admin/views.py @@ -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="更新成功") \ No newline at end of file diff --git a/apps/base/migrations/migrations_006_transfer_stats.py b/apps/base/migrations/migrations_006_transfer_stats.py new file mode 100644 index 0000000..6d79775 --- /dev/null +++ b/apps/base/migrations/migrations_006_transfer_stats.py @@ -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); + """ + ) diff --git a/apps/base/models.py b/apps/base/models.py index 2d299c4..28b3d4a 100644 --- a/apps/base/models.py +++ b/apps/base/models.py @@ -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" -) +) \ No newline at end of file diff --git a/apps/base/views.py b/apps/base/views.py index 9d872ab..0b9e499 100644 --- a/apps/base/views.py +++ b/apps/base/views.py @@ -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() diff --git a/core/settings.py b/core/settings.py index c8f5e4b..713366f 100644 --- a/core/settings.py +++ b/core/settings.py @@ -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) \ No newline at end of file