From baf59b99031423a54598ebe88e36d7b93d00a6db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8E=E5=B0=8F=E4=B8=98?= Date: Thu, 8 Jan 2026 14:41:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=9F=A5=E7=9C=8B=E7=AB=99?= =?UTF-8?q?=E7=82=B9=E6=A6=82=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- models/__init__.py | 6 ++ models/model_base.py | 91 +++++++++++++++++ models/share.py | 67 +++++++++++++ routers/api/v1/admin/__init__.py | 111 +++++++++++++++++++-- routers/api/v1/object/__init__.py | 8 +- routers/api/v1/share/__init__.py | 159 +++++++++++++++++++++++++++--- 6 files changed, 417 insertions(+), 25 deletions(-) diff --git a/models/__init__.py b/models/__init__.py index a1b5367..b9147e6 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -90,4 +90,10 @@ from .model_base import ( MCPRequestBase, MCPResponseBase, ResponseBase, + # Admin Summary DTO + MetricsSummary, + LicenseInfo, + VersionInfo, + AdminSummaryData, + AdminSummaryResponse, ) \ No newline at end of file diff --git a/models/model_base.py b/models/model_base.py index e5307b5..a2f76f5 100644 --- a/models/model_base.py +++ b/models/model_base.py @@ -1,16 +1,107 @@ import uuid +from datetime import datetime from enum import StrEnum from sqlmodel import Field from .base import SQLModelBase + class ResponseBase(SQLModelBase): """通用响应模型""" instance_id: uuid.UUID = Field(default_factory=uuid.uuid4) """实例ID,用于标识请求的唯一性""" + +# ==================== Admin Summary DTO ==================== + + +class MetricsSummary(SQLModelBase): + """站点统计摘要""" + + dates: list[datetime] + """日期列表""" + + files: list[int] + """每日新增文件数""" + + users: list[int] + """每日新增用户数""" + + shares: list[int] + """每日新增分享数""" + + file_total: int + """文件总数""" + + user_total: int + """用户总数""" + + share_total: int + """分享总数""" + + entities_total: int + """实体总数""" + + generated_at: datetime + """生成时间""" + + +class LicenseInfo(SQLModelBase): + """许可证信息""" + + expired_at: datetime + """过期时间""" + + signed_at: datetime + """签发时间""" + + root_domains: list[str] + """根域名列表""" + + domains: list[str] + """域名列表""" + + vol_domains: list[str] + """卷域名列表""" + + +class VersionInfo(SQLModelBase): + """版本信息""" + + version: str + """版本号""" + + pro: bool + """是否为专业版""" + + commit: str + """提交哈希""" + + +class AdminSummaryData(SQLModelBase): + """管理员概况数据""" + + metrics_summary: MetricsSummary + """统计摘要""" + + site_urls: list[str] + """站点URL列表""" + + license: LicenseInfo + """许可证信息""" + + version: VersionInfo + """版本信息""" + + +class AdminSummaryResponse(ResponseBase): + """管理员概况响应""" + + data: AdminSummaryData | None = None + """响应数据""" + class MCPMethod(StrEnum): """MCP 方法枚举""" diff --git a/models/share.py b/models/share.py index 9538c3f..a4fc720 100644 --- a/models/share.py +++ b/models/share.py @@ -84,3 +84,70 @@ class Share(SQLModelBase, TableBaseMixin): """是否为目录分享(向后兼容属性)""" from .object import ObjectType return self.object.type == ObjectType.FOLDER if self.object else False + + +# ==================== DTO 模型 ==================== + +class ShareCreateRequest(SQLModelBase): + """创建分享请求 DTO""" + + object_id: UUID + """要分享的对象UUID""" + + password: str | None = None + """分享密码(可选)""" + + expires: datetime | None = None + """过期时间(可选,NULL为永不过期)""" + + remain_downloads: int | None = None + """剩余下载次数(可选,NULL为不限制)""" + + preview_enabled: bool = True + """是否允许预览""" + + score: int = 0 + """兑换此分享所需的积分""" + + +class ShareResponse(SQLModelBase): + """分享响应 DTO""" + + id: int + """分享ID""" + + code: str + """分享码""" + + object_id: UUID + """关联对象UUID""" + + source_name: str | None + """源名称""" + + views: int + """浏览次数""" + + downloads: int + """下载次数""" + + remain_downloads: int | None + """剩余下载次数""" + + expires: datetime | None + """过期时间""" + + preview_enabled: bool + """是否允许预览""" + + score: int + """积分""" + + created_at: datetime + """创建时间""" + + is_expired: bool + """是否已过期""" + + has_password: bool + """是否有密码""" diff --git a/routers/api/v1/admin/__init__.py b/routers/api/v1/admin/__init__.py index 3203819..1d29947 100644 --- a/routers/api/v1/admin/__init__.py +++ b/routers/api/v1/admin/__init__.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta from typing import Annotated from uuid import UUID @@ -13,7 +13,10 @@ from middleware.dependencies import SessionDep from models import ( Policy, PolicyOptions, PolicyType, User, ResponseBase, Group, GroupOptions, Setting, Object, ObjectType, Share, Task, + AdminSummaryResponse, MetricsSummary, LicenseInfo, VersionInfo, AdminSummaryData, ) +from models.setting import SettingsType +from utils.conf import appmeta from models.base import SQLModelBase from models.group import ( GroupCreateRequest, GroupUpdateRequest, GroupDetailResponse, GroupListResponse, @@ -25,7 +28,7 @@ from models.setting import SettingsUpdateRequest, SettingsGetResponse from models.object import AdminFileResponse, AdminFileListResponse, FileBanRequest from models.policy import GroupPolicyLink from service.storage import DirectoryCreationError, LocalStorageService -from utils import Password +from utils import Password, http_exceptions class PolicyTestPathRequest(SQLModelBase): @@ -158,14 +161,108 @@ admin_vas_router = APIRouter( description='Get site summary information', dependencies=[Depends(admin_required)], ) -def router_admin_get_summary() -> ResponseBase: +async def router_admin_get_summary(session: SessionDep) -> AdminSummaryResponse: """ - 获取站点概况信息,包括用户数、分享数、文件数等。 - + 获取站点概况信息,包括用户数、分享数、文件数等统计指标。 + + 响应数据结构: + - metrics_summary: 统计摘要(日期列表、每日增量、总计) + - site_urls: 站点URL列表 + - license: 许可证信息(过期时间、签发时间、域名配置) + - version: 版本信息(版本号、是否Pro、提交哈希) + Returns: - ResponseBase: 包含站点概况信息的响应模型。 + AdminSummaryResponse: 包含站点概况信息的响应模型。 """ - http_exceptions.raise_not_implemented() + # 统计最近 12 天的数据 + days_count = 12 + now = datetime.now() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + + dates: list[datetime] = [] + files: list[int] = [] + users: list[int] = [] + shares: list[int] = [] + + # 从 11 天前到今天 + for i in range(days_count - 1, -1, -1): + day_start = today_start - timedelta(days=i) + day_end = day_start + timedelta(days=1) + dates.append(day_start) + + # 统计每日新增 + file_count = await Object.count( + session, + Object.type == ObjectType.FILE, + created_after_datetime=day_start, + created_before_datetime=day_end, + ) + user_count = await User.count( + session, + created_after_datetime=day_start, + created_before_datetime=day_end, + ) + share_count = await Share.count( + session, + created_after_datetime=day_start, + created_before_datetime=day_end, + ) + + files.append(file_count) + users.append(user_count) + shares.append(share_count) + + # 统计总数 + file_total = await Object.count(session, Object.type == ObjectType.FILE) + user_total = await User.count(session) + share_total = await Share.count(session) + entities_total = await Object.count(session) + + metrics_summary = MetricsSummary( + dates=dates, + files=files, + users=users, + shares=shares, + file_total=file_total, + user_total=user_total, + share_total=share_total, + entities_total=entities_total, + generated_at=now, + ) + + # 获取站点 URL(从设置读取) + site_urls: list[str] = [] + site_url_setting = await Setting.get( + session, + and_(Setting.type == SettingsType.BASIC, Setting.name == "siteURL"), + ) + if site_url_setting and site_url_setting.value: + site_urls.append(site_url_setting.value) + + # 许可证信息(从设置读取或使用默认值) + license_info = LicenseInfo( + expired_at=now + timedelta(days=365), + signed_at=now, + root_domains=[], + domains=[], + vol_domains=[], + ) + + # 版本信息 + version_info = VersionInfo( + version=appmeta.BackendVersion, + pro=appmeta.IsPro, + commit="dev", + ) + + data = AdminSummaryData( + metrics_summary=metrics_summary, + site_urls=site_urls, + license=license_info, + version=version_info, + ) + + return AdminSummaryResponse(data=data) @admin_router.get( path='/news', diff --git a/routers/api/v1/object/__init__.py b/routers/api/v1/object/__init__.py index a0243a4..4cf136d 100644 --- a/routers/api/v1/object/__init__.py +++ b/routers/api/v1/object/__init__.py @@ -128,6 +128,10 @@ async def _copy_object_recursive( copied_count = 0 new_ids: list[UUID] = [] + # 在 save() 之前保存需要的属性值,避免 commit 后对象过期导致懒加载失败 + src_is_folder = src.is_folder + src_id = src.id + # 创建新的 Object 记录 new_obj = Object( name=src.name, @@ -152,8 +156,8 @@ async def _copy_object_recursive( new_ids.append(new_obj.id) # 如果是目录,递归复制子对象 - if src.is_folder: - children = await Object.get_children(session, user_id, src.id) + if src_is_folder: + children = await Object.get_children(session, user_id, src_id) for child in children: child_count, child_ids = await _copy_object_recursive( session, child, new_obj.id, user_id diff --git a/routers/api/v1/share/__init__.py b/routers/api/v1/share/__init__.py index 79590df..ba0d142 100644 --- a/routers/api/v1/share/__init__.py +++ b/routers/api/v1/share/__init__.py @@ -1,8 +1,19 @@ -from fastapi import APIRouter, Depends +from typing import Annotated, Literal +from uuid import uuid4 +from datetime import datetime + +from fastapi import APIRouter, Depends, Query, HTTPException +from loguru import logger as l from middleware.auth import auth_required +from middleware.dependencies import SessionDep from models import ResponseBase +from models.user import User +from models.share import Share, ShareCreateRequest, ShareResponse +from models.object import Object +from models.mixin import ListResponse, TableViewRequest from utils import http_exceptions +from utils.password.pwd import Password share_router = APIRouter( prefix='/share', @@ -10,7 +21,7 @@ share_router = APIRouter( ) @share_router.get( - path='/{info}/{id}', + path='/{id}', summary='获取分享', description='Get shared content by info type and ID.', ) @@ -227,31 +238,147 @@ def router_share_search_public(keywords: str, type: str = 'all') -> ResponseBase path='/', summary='创建新分享', description='Create a new share endpoint.', - dependencies=[Depends(auth_required)] ) -def router_share_create() -> ResponseBase: +async def router_share_create( + session: SessionDep, + user: Annotated[User, Depends(auth_required)], + request: ShareCreateRequest, +) -> ShareResponse: """ - Create a new share endpoint. - - Returns: - ResponseBase: A model containing the response data for the new share creation. + 创建新分享 + + 认证:需要 JWT token + + 流程: + 1. 验证对象存在且属于当前用户 + 2. 生成随机分享码(uuid4) + 3. 如果有密码则加密存储 + 4. 创建 Share 记录并保存 + 5. 返回分享信息 """ - http_exceptions.raise_not_implemented() + # 验证对象存在且属于当前用户 + obj = await Object.get(session, Object.id == request.object_id) + if not obj or obj.owner_id != user.id: + raise HTTPException(status_code=404, detail="对象不存在或无权限") + + # 生成分享码 + code = str(uuid4()) + + # 密码加密处理(如果有) + hashed_password = None + if request.password: + hashed_password = Password.hash(request.password) + + # 创建分享记录 + share = Share( + code=code, + password=hashed_password, + object_id=request.object_id, + user_id=user.id, + expires=request.expires, + remain_downloads=request.remain_downloads, + preview_enabled=request.preview_enabled, + score=request.score, + source_name=obj.name, + ) + share = await share.save(session) + + l.info(f"用户 {user.id} 创建分享: {share.code}") + + # 返回响应 + return ShareResponse( + id=share.id, + code=share.code, + object_id=share.object_id, + source_name=share.source_name, + views=share.views, + downloads=share.downloads, + remain_downloads=share.remain_downloads, + expires=share.expires, + preview_enabled=share.preview_enabled, + score=share.score, + created_at=share.created_at, + is_expired=share.expires is not None and share.expires < datetime.now(), + has_password=share.password is not None, + ) @share_router.get( path='/', summary='列出我的分享', description='Get a list of shares.', - dependencies=[Depends(auth_required)] ) -def router_share_list() -> ResponseBase: +async def router_share_list( + session: SessionDep, + user: Annotated[User, Depends(auth_required)], + offset: int = Query(default=0, ge=0), + limit: int = Query(default=50, le=100), + desc: bool = Query(default=True), + order: Literal["created_at", "updated_at"] = Query(default="created_at"), + keyword: str | None = Query(default=None), + expired: bool | None = Query(default=None), +) -> ListResponse[ShareResponse]: """ - Get a list of shares. - - Returns: - ResponseBase: A model containing the response data for the list of shares. + 列出我的分享 + + 认证:需要 JWT token + + 支持: + - 分页和排序 + - 关键字搜索(搜索 source_name) + - 过期状态筛选 """ - http_exceptions.raise_not_implemented() + # 构建基础条件 + condition = Share.user_id == user.id + + # 关键字搜索 + if keyword: + condition = condition & Share.source_name.ilike(f"%{keyword}%") + + # 过期状态筛选 + now = datetime.now() + if expired is True: + # 已过期:expires 不为 NULL 且 < 当前时间 + condition = condition & (Share.expires != None) & (Share.expires < now) + elif expired is False: + # 未过期:expires 为 NULL 或 >= 当前时间 + condition = condition & ((Share.expires == None) | (Share.expires >= now)) + + # 构建 table_view + table_view = TableViewRequest( + offset=offset, + limit=limit, + desc=desc, + order=order, + ) + + # 使用 get_with_count 获取分页数据 + result = await Share.get_with_count( + session, + condition, + table_view=table_view, + ) + + # 转换为响应模型 + items = [ + ShareResponse( + id=share.id, + code=share.code, + object_id=share.object_id, + source_name=share.source_name, + views=share.views, + downloads=share.downloads, + remain_downloads=share.remain_downloads, + expires=share.expires, + preview_enabled=share.preview_enabled, + score=share.score, + created_at=share.created_at, + is_expired=share.expires is not None and share.expires < now, + has_password=share.password is not None, + ) + for share in result.items + ] + + return ListResponse(count=result.count, items=items) @share_router.post( path='/save/{id}',