feat: 重构模型和响应结构,优化用户和对象管理逻辑,添加 Dockerfile

This commit is contained in:
2025-12-18 18:28:41 +08:00
parent 68343c710b
commit 89e837d91c
18 changed files with 493 additions and 270 deletions

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY . .
EXPOSE 5213
CMD ["uv", "run", "fastapi", "run", "main.py", "--host", "0.0.0.0", "--port", "5213"]

11
main.py
View File

@@ -1,10 +1,11 @@
from fastapi import FastAPI from fastapi import FastAPI
from routers import routers
from pkg.conf import appmeta from pkg.conf import appmeta
from pkg.lifespan import lifespan
from models.database import init_db from models.database import init_db
from models.migration import migration from models.migration import migration
from pkg.lifespan import lifespan from pkg.JWT import JWT
from pkg.JWT import jwt as JWT from routers import routers
# 添加初始化数据库启动项 # 添加初始化数据库启动项
lifespan.add_startup(init_db) lifespan.add_startup(init_db)
@@ -32,6 +33,6 @@ if __name__ == "__main__":
import uvicorn import uvicorn
if appmeta.debug: if appmeta.debug:
uvicorn.run(app='main:app', host=appmeta.host, port=appmeta.port, reload=True) uvicorn.run(app='main:app', reload=True)
else: else:
uvicorn.run(app=app, host=appmeta.host, port=appmeta.port) uvicorn.run(app=app)

View File

@@ -5,7 +5,7 @@ from jwt import InvalidTokenError
import jwt import jwt
from models.user import User from models.user import User
from pkg.JWT import jwt as JWT from pkg.JWT import JWT
from .dependencies import SessionDep from .dependencies import SessionDep
credentials_exception = HTTPException( credentials_exception = HTTPException(

View File

@@ -1,17 +1,35 @@
from . import response from . import response
from .user import User from .user import (
from .user_authn import UserAuthn LoginRequest,
ThemeResponse,
TokenResponse,
User,
UserBase,
UserPublic,
UserResponse,
UserSettingResponse,
WebAuthnInfo,
)
from .user_authn import AuthnResponse, UserAuthn
from .download import Download from .download import Download
from .object import Object, ObjectType from .group import Group, GroupBase, GroupOptionsBase, GroupResponse
from .group import Group
from .node import Node from .node import Node
from .object import (
DirectoryCreateRequest,
DirectoryResponse,
Object,
ObjectBase,
ObjectResponse,
ObjectType,
PolicyResponse,
)
from .order import Order from .order import Order
from .policy import Policy from .policy import Policy
from .redeem import Redeem from .redeem import Redeem
from .report import Report from .report import Report
from .setting import Setting from .setting import Setting, SettingsType, SiteConfigResponse
from .share import Share from .share import Share
from .source_link import SourceLink from .source_link import SourceLink
from .storage_pack import StoragePack from .storage_pack import StoragePack

View File

@@ -1,6 +1,6 @@
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime
from typing import Union, List, TypeVar, Type, Literal, override, Optional, Any from typing import Union, List, TypeVar, Type, Literal, override, Optional
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import DateTime, BinaryExpression, ClauseElement from sqlalchemy import DateTime, BinaryExpression, ClauseElement

View File

@@ -1,13 +1,75 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlmodel import Field, Relationship, text from sqlmodel import Field, Relationship, text
from .base import TableBase
from .base import TableBase, SQLModelBase
if TYPE_CHECKING: if TYPE_CHECKING:
from .user import User from .user import User
class GroupOptions(TableBase, table=True): # ==================== Base 模型 ====================
class GroupBase(SQLModelBase):
"""用户组基础字段,供数据库模型和 DTO 共享"""
name: str
"""用户组名称"""
class GroupOptionsBase(SQLModelBase):
"""用户组选项基础字段,供数据库模型和 DTO 共享"""
share_download: bool = False
"""是否允许分享下载"""
share_free: bool = False
"""是否免积分分享"""
relocate: bool = False
"""是否允许文件重定位"""
source_batch: int = 0
"""批量获取源地址数量"""
select_node: bool = False
"""是否允许选择节点"""
advance_delete: bool = False
"""是否允许高级删除"""
# ==================== DTO 模型 ====================
class GroupResponse(GroupBase, GroupOptionsBase):
"""用户组响应 DTO"""
id: int
"""用户组ID"""
allow_share: bool = False
"""是否允许分享"""
allow_remote_download: bool = False
"""是否允许离线下载"""
allow_archive_download: bool = False
"""是否允许打包下载"""
compress: bool = False
"""是否允许压缩"""
webdav: bool = False
"""是否允许WebDAV"""
allow_webdav_proxy: bool = False
"""是否允许WebDAV代理"""
# ==================== 数据库模型 ====================
class GroupOptions(GroupOptionsBase, TableBase, table=True):
"""用户组选项模型""" """用户组选项模型"""
group_id: int = Field(foreign_key="group.id", unique=True) group_id: int = Field(foreign_key="group.id", unique=True)
@@ -19,41 +81,23 @@ class GroupOptions(TableBase, table=True):
archive_task: bool = False archive_task: bool = False
"""是否允许创建打包任务""" """是否允许创建打包任务"""
share_download: bool = False
"""是否允许分享下载"""
share_free: bool = False
"""是否免积分分享"""
webdav_proxy: bool = False webdav_proxy: bool = False
"""是否允许WebDAV代理""" """是否允许WebDAV代理"""
aria2: bool = False aria2: bool = False
"""是否允许使用aria2""" """是否允许使用aria2"""
relocate: bool = False
"""是否允许文件重定位"""
source_batch: int = 10
"""批量获取源地址数量"""
redirected_source: bool = False redirected_source: bool = False
"""是否使用重定向源""" """是否使用重定向源"""
available_nodes: str = "[]" available_nodes: str = "[]"
"""可用节点ID列表JSON数组""" """可用节点ID列表JSON数组"""
select_node: bool = False
"""是否允许选择节点"""
advance_delete: bool = False
"""是否允许高级删除"""
# 反向关系 # 反向关系
group: "Group" = Relationship(back_populates="options") group: "Group" = Relationship(back_populates="options")
class Group(TableBase, table=True): class Group(GroupBase, TableBase, table=True):
"""用户组模型""" """用户组模型"""
name: str = Field(max_length=255, unique=True) name: str = Field(max_length=255, unique=True)

View File

@@ -1,7 +1,7 @@
from .setting import Setting, SettingsType from .setting import Setting, SettingsType
from .user import ThemeResponse
from pkg.conf.appmeta import BackendVersion from pkg.conf.appmeta import BackendVersion
from .response import ThemeModel
from pkg.password.pwd import Password from pkg.password.pwd import Password
from loguru import logger as log from loguru import logger as log
@@ -74,7 +74,7 @@ Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; verti
Setting(name="hot_share_num", value="10", type=SettingsType.SHARE), Setting(name="hot_share_num", value="10", type=SettingsType.SHARE),
Setting(name="gravatar_server", value="https://www.gravatar.com/", type=SettingsType.AVATAR), Setting(name="gravatar_server", value="https://www.gravatar.com/", type=SettingsType.AVATAR),
Setting(name="defaultTheme", value="#3f51b5", type=SettingsType.BASIC), Setting(name="defaultTheme", value="#3f51b5", type=SettingsType.BASIC),
Setting(name="themes", value=ThemeModel().model_dump_json(), type=SettingsType.BASIC), Setting(name="themes", value=ThemeResponse().model_dump_json(), type=SettingsType.BASIC),
Setting(name="aria2_token", value="", type=SettingsType.ARIA2), Setting(name="aria2_token", value="", type=SettingsType.ARIA2),
Setting(name="aria2_rpcurl", value="", type=SettingsType.ARIA2), Setting(name="aria2_rpcurl", value="", type=SettingsType.ARIA2),
Setting(name="aria2_temp_path", value="", type=SettingsType.ARIA2), Setting(name="aria2_temp_path", value="", type=SettingsType.ARIA2),

View File

@@ -1,8 +1,11 @@
from typing import TYPE_CHECKING, Optional from datetime import datetime
from typing import TYPE_CHECKING, Literal, Optional
from enum import StrEnum from enum import StrEnum
from sqlmodel import Field, Relationship, UniqueConstraint, CheckConstraint, Index from sqlmodel import Field, Relationship, UniqueConstraint, CheckConstraint, Index
from .base import TableBase
from .base import TableBase, SQLModelBase
if TYPE_CHECKING: if TYPE_CHECKING:
from .user import User from .user import User
@@ -17,7 +20,90 @@ class ObjectType(StrEnum):
FOLDER = "folder" FOLDER = "folder"
class Object(TableBase, table=True): # ==================== Base 模型 ====================
class ObjectBase(SQLModelBase):
"""对象基础字段,供数据库模型和 DTO 共享"""
name: str
"""对象名称(文件名或目录名)"""
type: ObjectType
"""对象类型file 或 folder"""
size: int = 0
"""文件大小(字节),目录为 0"""
# ==================== DTO 模型 ====================
class DirectoryCreateRequest(SQLModelBase):
"""创建目录请求 DTO"""
path: str
"""目录路径,如 /docs/images"""
policy_id: int | None = None
"""存储策略ID不指定则继承父目录"""
class ObjectResponse(ObjectBase):
"""对象响应 DTO"""
id: str
"""对象ID"""
path: str
"""对象路径"""
thumb: bool = False
"""是否有缩略图"""
date: datetime
"""对象修改时间"""
create_date: datetime
"""对象创建时间"""
source_enabled: bool = False
"""是否启用离线下载源"""
class PolicyResponse(SQLModelBase):
"""存储策略响应 DTO"""
id: str
"""策略ID"""
name: str
"""策略名称"""
type: Literal["local", "qiniu", "tencent", "aliyun", "onedrive", "google_drive", "dropbox", "webdav", "remote"]
"""存储类型"""
max_size: int = 0
"""单文件最大限制单位字节0表示不限制"""
file_type: list[str] = []
"""允许的文件类型列表,空列表表示不限制"""
class DirectoryResponse(SQLModelBase):
"""目录响应 DTO"""
parent: str | None = None
"""父目录ID根目录为None"""
objects: list[ObjectResponse] = []
"""目录下的对象列表"""
policy: PolicyResponse
"""存储策略"""
# ==================== 数据库模型 ====================
class Object(ObjectBase, TableBase, table=True):
""" """
统一对象模型 统一对象模型

View File

@@ -1,140 +1,26 @@
""" """
响应模型定义 通用响应模型定义
""" """
from pydantic import BaseModel, Field from typing import Any
from typing import Literal, Union, Optional
from datetime import datetime, timezone
from uuid import uuid4 from uuid import uuid4
class ResponseModel(BaseModel): from sqlmodel import Field
"""
默认响应模型
"""
code: int = Field(default=0, description="系统内部状态码, 0表示成功其他表示失败", lt=60000, gt=0)
data: Union[dict, list, str, int, float, None] = Field(None, description="响应数据")
msg: str | None = Field(default=None, description="响应消息,可以是错误消息或信息提示")
instance_id: str = Field(default_factory=lambda: str(uuid4()), description="实例ID用于标识请求的唯一性")
class ThemeModel(BaseModel):
"""
主题模型
"""
primary: str = Field(default="#3f51b5", description="Primary color")
secondary: str = Field(default="#f50057", description="Secondary color")
accent: str = Field(default="#9c27b0", description="Accent color")
dark: str = Field(default="#1d1d1d", description="Dark color")
dark_page: str = Field(default="#121212", description="Dark page color")
positive: str = Field(default="#21ba45", description="Positive color")
negative: str = Field(default="#c10015", description="Negative color")
info: str = Field(default="#31ccec", description="Info color")
warning: str = Field(default="#f2c037", description="Warning color")
class TokenModel(BaseModel): from .base import SQLModelBase
"""
访问令牌模型
"""
access_expires: datetime = Field(default=None, description="访问令牌的过期时间")
access_token: str = Field(default=None, description="访问令牌")
refresh_expires: datetime = Field(default=None, description="刷新令牌的过期时间")
refresh_token: str = Field(default=None, description="刷新令牌")
class GroupModel(BaseModel):
"""
用户组模型
"""
id: int = Field(default=None, description="用户组ID")
name: str = Field(default=None, description="用户组名称")
allowShare: bool = Field(default=False, description="是否允许分享")
allowRomoteDownload: bool = Field(default=False, description="是否允许离线下载")
allowArchiveDownload: bool = Field(default=False, description="是否允许打包下载")
shareFree: bool = Field(default=False, description="是否允许免积分下载分享")
shareDownload: bool = Field(default=False, description="是否允许下载分享")
compress: bool = Field(default=False)
webdav: bool = Field(default=False, description="是否允许WebDAV")
allowWebDAVProxy: bool = Field(default=False, description="是否允许WebDAV代理")
relocate: bool = Field(default=False, description="是否使用重定向的下载链接")
sourceBatch: int = Field(default=0)
selectNode: bool = Field(default=False, description="是否允许选择离线下载节点")
advanceDelete: bool = Field(default=False, description="是否允许高级删除")
class UserModel(BaseModel): class ResponseModel(SQLModelBase):
""" """通用响应模型"""
用户模型
"""
id: int = Field(default=None, description="用户ID")
username: str = Field(default=None, description="用户名")
nickname: str = Field(default=None, description="用户昵称")
status: bool = Field(default=0, description="用户状态")
avatar: Literal['default', 'gravatar', 'file'] = Field(default='default', description="头像类型")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), description="用户创建时间")
preferred_theme: ThemeModel = Field(default_factory=ThemeModel, description="用户首选主题")
score: int = Field(default=0, description="用户积分")
anonymous: bool = Field(default=False, description="是否为匿名用户")
group: GroupModel = Field(default_factory=None, description="用户所属用户组")
tags: list = Field(default_factory=list, description="用户标签列表")
class SiteConfigModel(ResponseModel):
"""
站点配置模型
"""
title: str = Field(default="DiskNext", description="网站标题")
themes: dict = Field(default_factory=dict, description="网站主题配置")
default_theme: dict = Field(description="默认主题RGB色号")
site_notice: str | None = Field(default=None, description="网站公告")
user: dict = Field(default_factory=dict, description="用户信息")
logo_light: str | None = Field(default=None, description="网站Logo URL")
logo_dark: str | None = Field(default=None, description="网站Logo URL深色模式")
captcha_type: Literal['none', 'default', 'gcaptcha', 'cloudflare turnstile'] = Field(default='none', description="验证码类型")
captcha_key: str | None = Field(default=None, description="验证码密钥")
class AuthnModel(BaseModel): code: int = Field(default=0, ge=0, lt=60000)
""" """系统内部状态码0表示成功其他表示失败"""
WebAuthn模型
"""
id: str = Field(default=None, description="ID")
fingerprint: str = Field(default=None, description="指纹")
class UserSettingModel(BaseModel): data: dict[str, Any] | list[Any] | str | int | float | None = None
""" """响应数据"""
用户设置模型
"""
authn: Optional[AuthnModel] = Field(default=None, description="认证信息")
group_expires: datetime | None = Field(default=None, description="用户组过期时间")
prefer_theme: str = Field(default="#5898d4", description="用户首选主题")
qq: str | bool = Field(default=False, description="QQ号")
themes: dict = Field(default_factory=dict, description="用户主题配置")
two_factor: bool = Field(default=False, description="是否启用两步验证")
uid: int = Field(default=0, description="用户UID")
class ObjectModel(BaseModel): msg: str | None = None
id: str = Field(default=..., description="对象ID") """响应消息,可以是错误消息或信息提示"""
name: str = Field(default=..., description="对象名称")
path: str = Field(default=..., description="对象路径")
thumb: bool = Field(default=False, description="是否有缩略图")
size: int = Field(default=None, description="对象大小,单位字节")
type: Literal['file', 'folder'] = Field(default=..., description="对象类型file表示文件folder表示文件夹")
date: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), description="对象创建或修改时间")
create_date: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), description="对象创建时间")
source_enabled: bool = Field(default=False, description="是否启用离线下载源")
class PolicyModel(BaseModel): instance_id: str = Field(default_factory=lambda: str(uuid4()))
''' """实例ID用于标识请求的唯一性"""
存储策略模型
'''
id: str = Field(default=..., description="策略ID")
name: str = Field(default=..., description="策略名称")
type: Literal['local', 'qiniu', 'tencent', 'aliyun', 'onedrive', 'google_drive', 'dropbox', 'webdav', 'remote'] = Field(default=..., description="存储类型")
max_size: int = Field(default=0, description="单文件最大限制单位字节0表示不限制")
file_type: list = Field(default_factory=list, description="允许的文件类型列表,空列表表示不限制")
class DirectoryModel(BaseModel):
'''
目录模型
'''
parent: str | None
"""父目录ID根目录为None"""
objects: list[ObjectModel] = Field(default_factory=list, description="目录下的对象列表")
policy: PolicyModel = Field(default_factory=PolicyModel, description="存储策略")

View File

@@ -1,7 +1,46 @@
from typing import Literal
from sqlmodel import Field, UniqueConstraint from sqlmodel import Field, UniqueConstraint
from .base import TableBase
from .base import TableBase, SQLModelBase
from enum import StrEnum from enum import StrEnum
# ==================== DTO 模型 ====================
class SiteConfigResponse(SQLModelBase):
"""站点配置响应 DTO"""
title: str = "DiskNext"
"""网站标题"""
themes: dict[str, str] = {}
"""网站主题配置"""
default_theme: dict[str, str] = {}
"""默认主题RGB色号"""
site_notice: str | None = None
"""网站公告"""
user: dict[str, str | int | bool] = {}
"""用户信息"""
logo_light: str | None = None
"""网站Logo URL"""
logo_dark: str | None = None
"""网站Logo URL深色模式"""
captcha_type: Literal["none", "default", "gcaptcha", "cloudflare turnstile"] = "none"
"""验证码类型"""
captcha_key: str | None = None
"""验证码密钥"""
# ==================== 数据库模型 ====================
class SettingsType(StrEnum): class SettingsType(StrEnum):
"""设置类型枚举""" """设置类型枚举"""

View File

@@ -1,8 +1,7 @@
from datetime import datetime from datetime import datetime
from typing import Optional, TYPE_CHECKING from typing import Literal, Optional, TYPE_CHECKING
from sqlmodel import Field, Relationship from sqlmodel import Field, Relationship
from pydantic import BaseModel
from .base import TableBase, SQLModelBase from .base import TableBase, SQLModelBase
@@ -21,23 +20,48 @@ if TYPE_CHECKING:
""" """
Option 需求 Option 需求
- 主题 跟随系统/浅色/深色 - 主题 跟随系统/浅色/深色
- 颜色方案 参考.response.ThemeModel - 颜色方案 参考 ThemeResponse
- 语言 - 语言
- 时区 - 时区
- 切换到不同存储策略是否提醒 - 切换到不同存储策略是否提醒
""" """
class LoginRequest(BaseModel):
"""
登录请求模型
"""
username: str = Field(..., description="用户名或邮箱")
password: str = Field(..., description="用户密码")
captcha: str | None = Field(None, description="验证码")
twoFaCode: str | None = Field(None, description="两步验证代码")
class WebAuthnInfo(BaseModel): # ==================== Base 模型 ====================
"""WebAuthn 信息模型"""
class UserBase(SQLModelBase):
"""用户基础字段,供数据库模型和 DTO 共享"""
username: str
"""用户名"""
status: bool = True
"""用户状态: True=正常, False=封禁"""
score: int = 0
"""用户积分"""
# ==================== DTO 模型 ====================
class LoginRequest(SQLModelBase):
"""登录请求 DTO"""
username: str
"""用户名或邮箱"""
password: str
"""用户密码"""
captcha: str | None = None
"""验证码"""
two_fa_code: str | None = None
"""两步验证代码"""
class WebAuthnInfo(SQLModelBase):
"""WebAuthn 信息 DTO"""
credential_id: str credential_id: str
"""凭证 ID""" """凭证 ID"""
@@ -57,12 +81,152 @@ class WebAuthnInfo(BaseModel):
transports: list[str] transports: list[str]
"""支持的传输方式""" """支持的传输方式"""
class User(TableBase, table=True):
class ThemeResponse(SQLModelBase):
"""主题响应 DTO"""
primary: str = "#3f51b5"
"""主色调"""
secondary: str = "#f50057"
"""次要色"""
accent: str = "#9c27b0"
"""强调色"""
dark: str = "#1d1d1d"
"""深色"""
dark_page: str = "#121212"
"""深色页面背景"""
positive: str = "#21ba45"
"""正面/成功色"""
negative: str = "#c10015"
"""负面/错误色"""
info: str = "#31ccec"
"""信息色"""
warning: str = "#f2c037"
"""警告色"""
class TokenResponse(SQLModelBase):
"""访问令牌响应 DTO"""
access_expires: datetime
"""访问令牌过期时间"""
access_token: str
"""访问令牌"""
refresh_expires: datetime
"""刷新令牌过期时间"""
refresh_token: str
"""刷新令牌"""
class UserResponse(UserBase):
"""用户响应 DTO"""
id: int
"""用户ID"""
nickname: str | None = None
"""用户昵称"""
avatar: Literal["default", "gravatar", "file"] = "default"
"""头像类型"""
created_at: datetime
"""用户创建时间"""
preferred_theme: ThemeResponse | None = None
"""用户首选主题"""
anonymous: bool = False
"""是否为匿名用户"""
group: "GroupResponse | None" = None
"""用户所属用户组"""
tags: list[str] = []
"""用户标签列表"""
class UserPublic(UserBase):
"""用户公开信息 DTO用于 API 响应"""
id: int | None = None
"""用户ID"""
nick: str | None = None
"""昵称"""
storage: int = 0
"""已用存储空间(字节)"""
avatar: str | None = None
"""头像地址"""
group_expires: datetime | None = None
"""用户组过期时间"""
group_id: int | None = None
"""所属用户组ID"""
created_at: datetime | None = None
"""创建时间"""
updated_at: datetime | None = None
"""更新时间"""
class UserSettingResponse(SQLModelBase):
"""用户设置响应 DTO"""
authn: "AuthnResponse | None" = None
"""认证信息"""
group_expires: datetime | None = None
"""用户组过期时间"""
prefer_theme: str = "#5898d4"
"""用户首选主题"""
qq: str | None = None
"""QQ号"""
themes: dict[str, str] = {}
"""用户主题配置"""
two_factor: bool = False
"""是否启用两步验证"""
uid: int = 0
"""用户UID"""
# 前向引用导入
from .group import GroupResponse # noqa: E402
from .user_authn import AuthnResponse # noqa: E402
# 更新前向引用
UserResponse.model_rebuild()
UserSettingResponse.model_rebuild()
# ==================== 数据库模型 ====================
class User(UserBase, TableBase, table=True):
"""用户模型""" """用户模型"""
username: str = Field(max_length=50, unique=True, index=True) username: str = Field(max_length=50, unique=True, index=True)
"""用户名,唯一,一经注册不可更改""" """用户名,唯一,一经注册不可更改"""
nick: str | None = Field(default=None, max_length=50) nick: str | None = Field(default=None, max_length=50)
"""用于公开展示的名字,可使用真实姓名或昵称""" """用于公开展示的名字,可使用真实姓名或昵称"""
@@ -81,10 +245,9 @@ class User(TableBase, table=True):
avatar: str | None = Field(default=None, max_length=255) avatar: str | None = Field(default=None, max_length=255)
"""头像地址""" """头像地址"""
options: str | None = Field(default=None) options: str | None = None
"""[TODO] 用户个人设置 需要更改,参考上方的需求""" """[TODO] 用户个人设置 需要更改,参考上方的需求"""
github_open_id: str | None = Field(default=None, unique=True, index=True) github_open_id: str | None = Field(default=None, unique=True, index=True)
"""Github OpenID""" """Github OpenID"""
@@ -94,7 +257,7 @@ class User(TableBase, table=True):
score: int = Field(default=0, sa_column_kwargs={"server_default": "0"}, ge=0) score: int = Field(default=0, sa_column_kwargs={"server_default": "0"}, ge=0)
"""用户积分""" """用户积分"""
group_expires: datetime | None = Field(default=None) group_expires: datetime | None = None
"""当前用户组过期时间""" """当前用户组过期时间"""
phone: str | None = Field(default=None, max_length=32, unique=True, index=True) phone: str | None = Field(default=None, max_length=32, unique=True, index=True)
@@ -108,7 +271,7 @@ class User(TableBase, table=True):
"""之前的用户组ID用于过期后恢复""" """之前的用户组ID用于过期后恢复"""
# [TODO] 待考虑:根目录 Object ID # [TODO] 待考虑:根目录 Object ID
# 关系 # 关系
group: "Group" = Relationship( group: "Group" = Relationship(
back_populates="user", back_populates="user",
@@ -122,7 +285,7 @@ class User(TableBase, table=True):
"foreign_keys": "User.previous_group_id" "foreign_keys": "User.previous_group_id"
} }
) )
downloads: list["Download"] = Relationship(back_populates="user") downloads: list["Download"] = Relationship(back_populates="user")
objects: list["Object"] = Relationship(back_populates="owner") objects: list["Object"] = Relationship(back_populates="owner")
"""用户的所有对象(文件和目录)""" """用户的所有对象(文件和目录)"""
@@ -137,41 +300,4 @@ class User(TableBase, table=True):
def to_public(self) -> "UserPublic": def to_public(self) -> "UserPublic":
"""转换为公开 DTO排除敏感字段""" """转换为公开 DTO排除敏感字段"""
return UserPublic.model_validate(self) return UserPublic.model_validate(self)
class UserPublic(SQLModelBase):
"""用户公开信息 DTO用于 API 响应"""
id: int | None = None
"""用户ID"""
username: str
"""用户名"""
nick: str | None = None
"""昵称"""
status: bool = True
"""用户状态"""
storage: int = 0
"""已用存储空间(字节)"""
avatar: str | None = None
"""头像地址"""
score: int = 0
"""用户积分"""
group_expires: datetime | None = None
"""用户组过期时间"""
group_id: int
"""所属用户组ID"""
created_at: datetime | None = None
"""创建时间"""
updated_at: datetime | None = None
"""更新时间"""

View File

@@ -3,12 +3,26 @@ from typing import TYPE_CHECKING
from sqlalchemy import Column, Text from sqlalchemy import Column, Text
from sqlmodel import Field, Relationship from sqlmodel import Field, Relationship
from .base import TableBase from .base import TableBase, SQLModelBase
if TYPE_CHECKING: if TYPE_CHECKING:
from .user import User from .user import User
# ==================== DTO 模型 ====================
class AuthnResponse(SQLModelBase):
"""WebAuthn 响应 DTO"""
id: str
"""凭证ID"""
fingerprint: str
"""凭证指纹"""
# ==================== 数据库模型 ====================
class UserAuthn(TableBase, table=True): class UserAuthn(TableBase, table=True):
"""用户 WebAuthn 凭证模型,与 User 为多对一关系""" """用户 WebAuthn 凭证模型,与 User 为多对一关系"""

View File

@@ -1,31 +1,33 @@
from fastapi.security import OAuth2PasswordBearer
from models.setting import Setting
from models.database import get_session
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import and_
import jwt import jwt
from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer( oauth2_scheme = OAuth2PasswordBearer(
scheme_name='获取 JWT Bearer 令牌', scheme_name='获取 JWT Bearer 令牌',
description='用于获取 JWT Bearer 令牌,需要以表单的形式提交', description='用于获取 JWT Bearer 令牌,需要以表单的形式提交',
tokenUrl="/api/user/session", tokenUrl="/api/user/session",
) )
SECRET_KEY = '' SECRET_KEY = ''
async def load_secret_key() -> None: async def load_secret_key() -> None:
""" """
从数据库读取 JWT 的密钥。 从数据库读取 JWT 的密钥。
""" """
# 延迟导入以避免循环依赖
from models.database import get_session
from models.setting import Setting
global SECRET_KEY global SECRET_KEY
async for session in get_session(): async for session in get_session():
setting = await Setting.get( setting = await Setting.get(
session, session,
and_(Setting.type == "auth", Setting.name == "secret_key") (Setting.type == "auth") & (Setting.name == "secret_key")
) )
if setting: if setting:
SECRET_KEY = setting.value SECRET_KEY = setting.value
break
# 访问令牌 # 访问令牌
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> tuple[str, datetime]: def create_access_token(data: dict, expires_delta: timedelta | None = None) -> tuple[str, datetime]:

4
pkg/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
# 延迟导入以避免循环依赖
# JWT 和 lifespan 应在需要时直接从子模块导入
# from .JWT import JWT
# from .lifespan import lifespan

View File

@@ -18,11 +18,6 @@ debug: bool = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes") or Fal
if debug: if debug:
log.info("Debug mode is enabled. This is not recommended for production use.") log.info("Debug mode is enabled. This is not recommended for production use.")
host: str = os.getenv("HOST", "0.0.0.0")
port: int = int(os.getenv("PORT", 5213))
log.info(f"Starting DiskNext Server {BackendVersion} on {host}:{port}")
database_url: str = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///disknext.db") database_url: str = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///disknext.db")
tags_meta = [ tags_meta = [

View File

@@ -1,27 +1,25 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from middleware.auth import AuthRequired from middleware.auth import AuthRequired
from middleware.dependencies import SessionDep from middleware.dependencies import SessionDep
from models import Object, ObjectType, User, response from models import (
DirectoryCreateRequest,
DirectoryResponse,
Object,
ObjectResponse,
ObjectType,
PolicyResponse,
User,
response,
)
directory_router = APIRouter( directory_router = APIRouter(
prefix="/directory", prefix="/directory",
tags=["directory"] tags=["directory"]
) )
class DirectoryCreateRequest(BaseModel):
"""创建目录请求"""
path: str
"""目录路径,如 /docs/images"""
policy_id: int | None = None
"""存储策略ID不指定则继承父目录"""
@directory_router.get( @directory_router.get(
path="/{path:path}", path="/{path:path}",
summary="获取目录内容", summary="获取目录内容",
@@ -51,7 +49,7 @@ async def router_directory_get(
policy = await folder.awaitable_attrs.policy policy = await folder.awaitable_attrs.policy
objects = [ objects = [
response.ObjectModel( ObjectResponse(
id=str(child.id), id=str(child.id),
name=child.name, name=child.name,
path=f"/{child.name}", # TODO: 完整路径 path=f"/{child.name}", # TODO: 完整路径
@@ -66,16 +64,16 @@ async def router_directory_get(
] ]
return response.ResponseModel( return response.ResponseModel(
data=response.DirectoryModel( data=DirectoryResponse(
parent=str(folder.parent_id) if folder.parent_id else None, parent=str(folder.parent_id) if folder.parent_id else None,
objects=objects, objects=objects,
policy=response.PolicyModel( policy=PolicyResponse(
id=str(policy.id), id=str(policy.id),
name=policy.name, name=policy.name,
type=policy.type.value, type=policy.type.value,
max_size=policy.max_size, max_size=policy.max_size,
file_type=[], file_type=[],
) ),
) )
) )

View File

@@ -30,16 +30,16 @@ user_settings_router = APIRouter(
async def router_user_session( async def router_user_session(
session: SessionDep, session: SessionDep,
form_data: Annotated[OAuth2PasswordRequestForm, Depends()], form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> models.response.TokenModel: ) -> models.TokenResponse:
username = form_data.username username = form_data.username
password = form_data.password password = form_data.password
result = await service.user.Login( result = await service.user.Login(
session, session,
models.user.LoginRequest(username=username, password=password), models.LoginRequest(username=username, password=password),
) )
if isinstance(result, models.response.TokenModel): if isinstance(result, models.TokenResponse):
return result return result
elif result is None: elif result is None:
raise HTTPException(status_code=401, detail="Invalid username or password") raise HTTPException(status_code=401, detail="Invalid username or password")
@@ -203,13 +203,13 @@ async def router_user_me(
""" """
group = await models.Group.get(session, models.Group.id == user.group_id) group = await models.Group.get(session, models.Group.id == user.group_id)
user_group = models.response.GroupModel( user_group = models.GroupResponse(
id=group.id, id=group.id,
name=group.name, name=group.name,
allowShare=group.share_enabled, allow_share=group.share_enabled,
) )
users = models.response.UserModel( users = models.UserResponse(
id=user.id, id=user.id,
username=user.username, username=user.username,
nickname=user.nick, nickname=user.nick,
@@ -369,7 +369,7 @@ def router_user_settings() -> models.response.ResponseModel:
Returns: Returns:
dict: A dictionary containing the current user settings. dict: A dictionary containing the current user settings.
""" """
return models.response.ResponseModel(data=models.response.UserSettingModel().model_dump()) return models.response.ResponseModel(data=models.UserSettingResponse().model_dump())
@user_settings_router.post( @user_settings_router.post(
path='/avatar', path='/avatar',

View File

@@ -1,25 +1,22 @@
from loguru import logger as log from loguru import logger as log
from sqlalchemy import and_
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from models.response import TokenModel from models import LoginRequest, TokenResponse, User
from models import user from pkg.JWT.JWT import create_access_token, create_refresh_token
from models.user import User
from pkg.JWT.jwt import create_access_token, create_refresh_token
async def Login(session: AsyncSession, login_request: user.LoginRequest) -> TokenModel | bool | None: async def Login(session: AsyncSession, login_request: LoginRequest) -> TokenResponse | bool | None:
""" """
根据账号密码进行登录。 根据账号密码进行登录。
如果登录成功,返回一个 TokenModel 对象,包含访问令牌和刷新令牌以及它们的过期时间。 如果登录成功,返回一个 TokenResponse 对象,包含访问令牌和刷新令牌以及它们的过期时间。
如果登录异常,返回 `False`(未完成注册或账号被封禁)。 如果登录异常,返回 `False`(未完成注册或账号被封禁)。
如果登录失败,返回 `None`。 如果登录失败,返回 `None`。
:param session: 数据库会话 :param session: 数据库会话
:param login_request: 登录请求 :param login_request: 登录请求
:return: TokenModel 对象或状态码或 None :return: TokenResponse 对象或状态码或 None
""" """
from pkg.password.pwd import Password from pkg.password.pwd import Password
@@ -52,7 +49,7 @@ async def Login(session: AsyncSession, login_request: user.LoginRequest) -> Toke
access_token, access_expire = create_access_token(data={'sub': current_user.username}) access_token, access_expire = create_access_token(data={'sub': current_user.username})
refresh_token, refresh_expire = create_refresh_token(data={'sub': current_user.username}) refresh_token, refresh_expire = create_refresh_token(data={'sub': current_user.username})
return TokenModel( return TokenResponse(
access_token=access_token, access_token=access_token,
access_expires=access_expire, access_expires=access_expire,
refresh_token=refresh_token, refresh_token=refresh_token,