feat: 更新数据模型和API路由,优化用户信息获取及设置管理

This commit is contained in:
2025-12-02 21:36:19 +08:00
parent 22c8f7649f
commit 8b6e18f0e2
8 changed files with 171 additions and 72 deletions

20
PLAN.md
View File

@@ -28,7 +28,7 @@
- bool[WebDAV] - bool[WebDAV]
- bool[WebDAV 反代] - bool[WebDAV 反代]
- bool[离线下载] - bool[离线下载]
- Object - Object `计划:把 file 和 folder 合并为一个 Object 表,通过对象类型区分`
- 对象名 - 对象名
- 区分大小写 - 区分大小写
- 禁止名称为特殊字段 (如 `/`, `\`, `:`, `*`, `?`, `<`, `>`, `:`, `"`) - 禁止名称为特殊字段 (如 `/`, `\`, `:`, `*`, `?`, `<`, `>`, `:`, `"`)
@@ -42,6 +42,10 @@
- 音频:歌名、歌手名、专辑、流派... - 音频:歌名、歌手名、专辑、流派...
- 图片: 尺寸、ISO、曝光、拍摄设备、地理位置... - 图片: 尺寸、ISO、曝光、拍摄设备、地理位置...
- 其他需要记录的元数据 - 其他需要记录的元数据
- 当对象类型为 folder 时
- 当前文件夹的视图(网格/列表/画廊)
- 排序规则(按名称/大小/上传时间/修改时间)
- 排序方式(升序/降序)
- 当对象类型为 link 时 - 当对象类型为 link 时
- 目标对象ID - 目标对象ID
- 外键 - 外键
@@ -68,7 +72,7 @@
- 运行环境与目标 - 运行环境与目标
- 数据库类型:主要支持 PostgreSQL 18考虑兼容 SQLite/MySQL/早期版本PostgreSQL - 数据库类型:主要支持 PostgreSQL 18考虑兼容 SQLite/MySQL/早期版本PostgreSQL
- 驱动版本:做一定的向下兼容,主要支持 Python 3.14 - 驱动版本:做一定的向下兼容,主要支持 Python 3.13+
- 异步栈:全量异步 AsyncSession不接受兼容同步 - 异步栈:全量异步 AsyncSession不接受兼容同步
- 业务语义与数据模型 - 业务语义与数据模型
- 时间与时区:统一存储 UTC再根据用户选择的时区计算本地化时间 - 时间与时区:统一存储 UTC再根据用户选择的时区计算本地化时间
@@ -80,4 +84,16 @@
- 关系与级联 - 关系与级联
- 删除文件夹时,同时删除该文件夹内的所有子文件夹及其所有文件 - 删除文件夹时,同时删除该文件夹内的所有子文件夹及其所有文件
- 删除用户时同时删除该用户的所有文件文件夹分享Tag - 删除用户时同时删除该用户的所有文件文件夹分享Tag
- 文件预览与编辑
- 预览应用 Literal['嵌入网页式应用', 'WOPI协议式应用']
- 是否启用
- WOPI协议式应用 `https://{server_host}/hosting/discovery`
- 嵌入网页式应用
- 图标
- 名称
- 支持的文件类型列表
- 预览URL模板 支持魔法变量
- 最大文件大小
- 平台 支持列表 ['all', 'mobile', 'desktop']
- 是否在新窗口打开

12
main.py
View File

@@ -25,17 +25,7 @@ app = FastAPI(
# 挂载路由 # 挂载路由
for router in routers.Router: for router in routers.Router:
app.include_router( app.include_router(router, prefix='/api')
router,
prefix='/api',
responses={
200: {"description": "成功响应 Successful operation"},
401: {"description": "未授权 Unauthorized"},
403: {"description": "禁止访问 Forbidden"},
404: {"description": "未找到 Not found"},
500: {"description": "内部服务器错误 Internal server error"},
},
)
# 启动时打印欢迎信息 # 启动时打印欢迎信息
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -22,7 +22,7 @@ async def AuthRequired(
AuthRequired 需要登录 AuthRequired 需要登录
""" """
try: try:
payload = jwt.decode(token, JWT.SECRET_KEY, algorithms="HS256") payload = jwt.decode(token, JWT.SECRET_KEY, algorithms=["HS256"])
username = payload.get("sub") username = payload.get("sub")
if username is None: if username is None:
@@ -56,8 +56,7 @@ async def AdminRequired(
使用方法: 使用方法:
>>> APIRouter(dependencies=[Depends(AdminRequired)]) >>> APIRouter(dependencies=[Depends(AdminRequired)])
""" """
# TODO: 跨表联查时需要使用 awaitable_attrs group = await user.awaitable_attrs.group
# if await user.awaitable_attrs.group.admin: if group.admin:
if user.group.admin:
return user return user
raise HTTPException(status_code=403, detail="Admin Required") raise HTTPException(status_code=403, detail="Admin Required")

View File

@@ -8,18 +8,18 @@ from datetime import datetime, timezone
from uuid import uuid4 from uuid import uuid4
class ResponseModel(BaseModel): class ResponseModel(BaseModel):
''' """
默认响应模型 默认响应模型
''' """
code: int = Field(default=0, description="系统内部状态码, 0表示成功其他表示失败", lt=60000, gt=0) code: int = Field(default=0, description="系统内部状态码, 0表示成功其他表示失败", lt=60000, gt=0)
data: Union[dict, list, str, int, float, None] = Field(None, description="响应数据") data: Union[dict, list, str, int, float, None] = Field(None, description="响应数据")
msg: str | None = Field(default=None, description="响应消息,可以是错误消息或信息提示") msg: str | None = Field(default=None, description="响应消息,可以是错误消息或信息提示")
instance_id: str = Field(default_factory=lambda: str(uuid4()), description="实例ID用于标识请求的唯一性") instance_id: str = Field(default_factory=lambda: str(uuid4()), description="实例ID用于标识请求的唯一性")
class ThemeModel(BaseModel): class ThemeModel(BaseModel):
''' """
主题模型 主题模型
''' """
primary: str = Field(default="#3f51b5", description="Primary color") primary: str = Field(default="#3f51b5", description="Primary color")
secondary: str = Field(default="#f50057", description="Secondary color") secondary: str = Field(default="#f50057", description="Secondary color")
accent: str = Field(default="#9c27b0", description="Accent color") accent: str = Field(default="#9c27b0", description="Accent color")
@@ -31,18 +31,18 @@ class ThemeModel(BaseModel):
warning: str = Field(default="#f2c037", description="Warning color") warning: str = Field(default="#f2c037", description="Warning color")
class TokenModel(BaseModel): class TokenModel(BaseModel):
''' """
访问令牌模型 访问令牌模型
''' """
access_expires: datetime = Field(default=None, description="访问令牌的过期时间") access_expires: datetime = Field(default=None, description="访问令牌的过期时间")
access_token: str = Field(default=None, description="访问令牌") access_token: str = Field(default=None, description="访问令牌")
refresh_expires: datetime = Field(default=None, description="刷新令牌的过期时间") refresh_expires: datetime = Field(default=None, description="刷新令牌的过期时间")
refresh_token: str = Field(default=None, description="刷新令牌") refresh_token: str = Field(default=None, description="刷新令牌")
class GroupModel(BaseModel): class GroupModel(BaseModel):
''' """
用户组模型 用户组模型
''' """
id: int = Field(default=None, description="用户组ID") id: int = Field(default=None, description="用户组ID")
name: str = Field(default=None, description="用户组名称") name: str = Field(default=None, description="用户组名称")
allowShare: bool = Field(default=False, description="是否允许分享") allowShare: bool = Field(default=False, description="是否允许分享")
@@ -59,13 +59,13 @@ class GroupModel(BaseModel):
advanceDelete: bool = Field(default=False, description="是否允许高级删除") advanceDelete: bool = Field(default=False, description="是否允许高级删除")
class UserModel(BaseModel): class UserModel(BaseModel):
''' """
用户模型 用户模型
''' """
id: int = Field(default=None, description="用户ID") id: int = Field(default=None, description="用户ID")
username: str = Field(default=None, description="用户名") username: str = Field(default=None, description="用户名")
nickname: str = Field(default=None, description="用户昵称") nickname: str = Field(default=None, description="用户昵称")
status: int = Field(default=0, description="用户状态") status: bool = Field(default=0, description="用户状态")
avatar: Literal['default', 'gravatar', 'file'] = Field(default='default', description="头像类型") avatar: Literal['default', 'gravatar', 'file'] = Field(default='default', description="头像类型")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), description="用户创建时间") created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), description="用户创建时间")
preferred_theme: ThemeModel = Field(default_factory=ThemeModel, description="用户首选主题") preferred_theme: ThemeModel = Field(default_factory=ThemeModel, description="用户首选主题")
@@ -75,12 +75,12 @@ class UserModel(BaseModel):
tags: list = Field(default_factory=list, description="用户标签列表") tags: list = Field(default_factory=list, description="用户标签列表")
class SiteConfigModel(ResponseModel): class SiteConfigModel(ResponseModel):
''' """
站点配置模型 站点配置模型
''' """
title: str = Field(default="DiskNext", description="网站标题") title: str = Field(default="DiskNext", description="网站标题")
themes: dict = Field(default_factory=dict, description="网站主题配置") themes: dict = Field(default_factory=dict, description="网站主题配置")
default_theme: str = Field(default="default", description="默认主题RGB色号") default_theme: dict = Field(description="默认主题RGB色号")
site_notice: str | None = Field(default=None, description="网站公告") site_notice: str | None = Field(default=None, description="网站公告")
user: dict = Field(default_factory=dict, description="用户信息") user: dict = Field(default_factory=dict, description="用户信息")
logo_light: str | None = Field(default=None, description="网站Logo URL") logo_light: str | None = Field(default=None, description="网站Logo URL")
@@ -89,16 +89,16 @@ class SiteConfigModel(ResponseModel):
captcha_key: str | None = Field(default=None, description="验证码密钥") captcha_key: str | None = Field(default=None, description="验证码密钥")
class AuthnModel(BaseModel): class AuthnModel(BaseModel):
''' """
WebAuthn模型 WebAuthn模型
''' """
id: str = Field(default=None, description="ID") id: str = Field(default=None, description="ID")
fingerprint: str = Field(default=None, description="指纹") fingerprint: str = Field(default=None, description="指纹")
class UserSettingModel(BaseModel): class UserSettingModel(BaseModel):
''' """
用户设置模型 用户设置模型
''' """
authn: Optional[AuthnModel] = Field(default=None, description="认证信息") authn: Optional[AuthnModel] = Field(default=None, description="认证信息")
group_expires: datetime | None = Field(default=None, description="用户组过期时间") group_expires: datetime | None = Field(default=None, description="用户组过期时间")
prefer_theme: str = Field(default="#5898d4", description="用户首选主题") prefer_theme: str = Field(default="#5898d4", description="用户首选主题")

View File

@@ -1,7 +1,10 @@
from typing import Optional, TYPE_CHECKING
from datetime import datetime from datetime import datetime
from sqlmodel import Field, Relationship, UniqueConstraint from typing import Optional, TYPE_CHECKING
from .base import TableBase
from sqlmodel import Field, Relationship
from pydantic import BaseModel
from .base import TableBase, SQLModelBase
if TYPE_CHECKING: if TYPE_CHECKING:
from .group import Group from .group import Group
@@ -15,14 +18,44 @@ if TYPE_CHECKING:
from .task import Task from .task import Task
from .webdav import WebDAV from .webdav import WebDAV
"""
Option 需求
- 主题 跟随系统/浅色/深色
- 颜色方案 参考.response.ThemeModel
- 语言
- 时区
- 切换到不同存储策略是否提醒
"""
class WebAuthnInfo(BaseModel):
"""WebAuthn 信息模型"""
credential_id: str
"""凭证 ID"""
credential_public_key: str
"""凭证公钥"""
sign_count: int
"""签名计数器"""
credential_device_type: bool
"""是否为平台认证器"""
credential_backed_up: bool
"""凭证是否已备份"""
transports: list[str]
"""支持的传输方式"""
class User(TableBase, table=True): class User(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)
"""昵称""" """于公开展示的名字,可使用真实姓名或昵称"""
password: str = Field(max_length=255) password: str = Field(max_length=255)
"""用户密码(加密后)""" """用户密码(加密后)"""
@@ -30,31 +63,34 @@ class User(TableBase, table=True):
status: bool = Field(default=True, sa_column_kwargs={"server_default": "true"}) status: bool = Field(default=True, sa_column_kwargs={"server_default": "true"})
"""用户状态: True=正常, False=封禁""" """用户状态: True=正常, False=封禁"""
storage: int = Field(default=0, sa_column_kwargs={"server_default": "0"}) storage: int = Field(default=0, sa_column_kwargs={"server_default": "0"}, ge=0)
"""已用存储空间(字节)""" """已用存储空间(字节)"""
two_factor: str | None = Field(default=None, max_length=255) two_factor: str | None = Field(default=None, min_length=32, max_length=32)
"""两步验证密钥""" """两步验证密钥"""
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 = Field(default=None)
"""用户个人设置 (JSON格式)""" """[TODO] 用户个人设置 需要更改,参考上方的需求"""
authn: str | None = Field(default=None) authn: str | None = Field(default=None)
"""WebAuthn 凭证""" """[TODO] WebAuthn 凭证,可不存,也可设置一个或多个"""
open_id: str | None = Field(default=None, max_length=255, unique=True, index=True) github_open_id: str | None = Field(default=None, unique=True, index=True)
"""第三方登录OpenID""" """Github OpenID"""
score: int = Field(default=0, sa_column_kwargs={"server_default": "0"}) qq_open_id: str | None = Field(default=None, unique=True, index=True)
"""QQ OpenID"""
score: int = Field(default=0, sa_column_kwargs={"server_default": "0"}, ge=0)
"""用户积分""" """用户积分"""
group_expires: datetime | None = Field(default=None) group_expires: datetime | None = Field(default=None)
"""当前用户组过期时间""" """当前用户组过期时间"""
phone: str | None = Field(default=None, max_length=255, unique=True, index=True) phone: str | None = Field(default=None, max_length=32, unique=True, index=True)
"""手机号""" """手机号"""
# 外键 # 外键
@@ -63,6 +99,8 @@ class User(TableBase, table=True):
previous_group_id: int | None = Field(default=None, foreign_key="group.id") previous_group_id: int | None = Field(default=None, foreign_key="group.id")
"""之前的用户组ID用于过期后恢复""" """之前的用户组ID用于过期后恢复"""
# [TODO] 待考虑:根目录 Object ID
# 关系 # 关系
group: "Group" = Relationship( group: "Group" = Relationship(
@@ -87,4 +125,45 @@ class User(TableBase, table=True):
tags: list["Tag"] = Relationship(back_populates="user") tags: list["Tag"] = Relationship(back_populates="user")
tasks: list["Task"] = Relationship(back_populates="user") tasks: list["Task"] = Relationship(back_populates="user")
webdavs: list["WebDAV"] = Relationship(back_populates="user") webdavs: list["WebDAV"] = Relationship(back_populates="user")
def to_public(self) -> "UserPublic":
"""转换为公开 DTO排除敏感字段"""
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

@@ -14,8 +14,8 @@ class WebDAV(TableBase, table=True):
name: str = Field(max_length=255, description="WebDAV账户名") name: str = Field(max_length=255, description="WebDAV账户名")
password: str = Field(max_length=255, description="WebDAV密码") password: str = Field(max_length=255, description="WebDAV密码")
root: str = Field(default="/", sa_column_kwargs={"server_default": "'/'"}, description="根目录路径") root: str = Field(default="/", sa_column_kwargs={"server_default": "'/'"}, description="根目录路径")
readonly: bool = Field(default=False, sa_column_kwargs={"server_default": text("false")}, description="是否只读") readonly: bool = Field(default=False, description="是否只读")
use_proxy: bool = Field(default=False, sa_column_kwargs={"server_default": text("false")}, description="是否使用代理下载") use_proxy: bool = Field(default=False, description="是否使用代理下载")
# 外键 # 外键
user_id: int = Field(foreign_key="user.id", index=True, description="所属用户ID") user_id: int = Field(foreign_key="user.id", index=True, description="所属用户ID")

View File

@@ -1,8 +1,10 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from loguru import logger
from middleware.auth import AdminRequired from middleware.auth import AdminRequired
from middleware.dependencies import SessionDep from middleware.dependencies import SessionDep
from models import User from models import User
from models.user import UserPublic
from models.response import ResponseModel from models.response import ResponseModel
# 管理员根目录 /api/admin # 管理员根目录 /api/admin
@@ -234,17 +236,19 @@ def router_admin_delete_group(group_id: int) -> ResponseModel:
description='Get user information by ID', description='Get user information by ID',
dependencies=[Depends(AdminRequired)], dependencies=[Depends(AdminRequired)],
) )
def router_admin_get_user(user_id: int) -> ResponseModel: async def router_admin_get_user(session: SessionDep, user_id: int) -> ResponseModel:
""" """
根据用户ID获取用户信息包括用户名、邮箱、注册时间等。 根据用户ID获取用户信息包括用户名、邮箱、注册时间等。
Args: Args:
session(SessionDep): 数据库会话依赖项。
user_id (int): 用户ID。 user_id (int): 用户ID。
Returns: Returns:
ResponseModel: 包含用户信息的响应模型。 ResponseModel: 包含用户信息的响应模型。
""" """
pass user = await User.get_exist_one(session, user_id)
return ResponseModel(data=user.to_public().model_dump())
@admin_user_router.get( @admin_user_router.get(
path='/list', path='/list',
@@ -252,21 +256,33 @@ def router_admin_get_user(user_id: int) -> ResponseModel:
description='Get user list', description='Get user list',
dependencies=[Depends(AdminRequired)], dependencies=[Depends(AdminRequired)],
) )
def router_admin_get_users( async def router_admin_get_users(
session: SessionDep,
page: int = 1, page: int = 1,
page_size: int = 20 page_size: int = 20
) -> ResponseModel: ) -> ResponseModel:
""" """
获取用户列表,支持分页。 获取用户列表,支持分页。
Args: Args:
session: 数据库会话依赖项。
page (int): 页码默认为1。 page (int): 页码默认为1。
page_size (int, optional): 每页显示的用户数量默认为20。 page_size (int): 每页显示的用户数量默认为20。
Returns: Returns:
ResponseModel: 包含用户列表的响应模型。 ResponseModel: 包含用户列表的响应模型。
""" """
pass offset = (page - 1) * page_size
users: list[User] = await User.get(
session,
None,
fetch_mode="all",
offset=offset,
limit=page_size
)
return ResponseModel(
data=[user.to_public().model_dump() for user in users]
)
@admin_user_router.post( @admin_user_router.post(
path='/create', path='/create',
@@ -284,21 +300,14 @@ async def router_admin_create_user(
Returns: Returns:
ResponseModel: 包含创建结果的响应模型。 ResponseModel: 包含创建结果的响应模型。
""" """
try: existing_user = await User.get(session, User.username == user.username)
existing_user = await User.get(session, User.username == user.username) if existing_user:
if existing_user:
return ResponseModel(
code=400,
message="User with this username already exists."
)
await user.save(session)
except Exception as e:
return ResponseModel( return ResponseModel(
code=500, code=400,
message=str(e) msg="User with this username already exists."
) )
else: user = await user.save(session)
return ResponseModel(data=user.model_dump()) return ResponseModel(data=user.to_public().model_dump())
@admin_user_router.patch( @admin_user_router.patch(
path='/{user_id}', path='/{user_id}',

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from sqlalchemy import and_ from sqlalchemy import and_
import json
from middleware.dependencies import SessionDep from middleware.dependencies import SessionDep
from models.response import ResponseModel from models.response import ResponseModel
@@ -22,6 +23,11 @@ async def _get_setting_bool(session: SessionDep, type_: str, name: str) -> bool:
value = await _get_setting(session, type_, name) value = await _get_setting(session, type_, name)
return value == "1" if value else False return value == "1" if value else False
async def _get_setting_json(session: SessionDep, type_: str, name: str) -> dict | list | None:
"""获取 JSON 类型设置值"""
value = await _get_setting(session, type_, name)
return json.loads(value) if value else None
@site_router.get( @site_router.get(
path="/ping", path="/ping",
@@ -77,7 +83,7 @@ async def router_site_config(session: SessionDep):
"forgetCaptcha": await _get_setting_bool(session, "login", "forget_captcha"), "forgetCaptcha": await _get_setting_bool(session, "login", "forget_captcha"),
"emailActive": await _get_setting_bool(session, "login", "email_active"), "emailActive": await _get_setting_bool(session, "login", "email_active"),
"QQLogin": None, "QQLogin": None,
"themes": await _get_setting(session, "basic", "themes"), "themes": await _get_setting_json(session, "basic", "themes"),
"defaultTheme": await _get_setting(session, "basic", "defaultTheme"), "defaultTheme": await _get_setting(session, "basic", "defaultTheme"),
"score_enabled": None, "score_enabled": None,
"share_score_rate": None, "share_score_rate": None,