From 6c512805e83f3feb6fbe6e73f6fa02c29abce994 Mon Sep 17 00:00:00 2001 From: Yuerchu Date: Thu, 14 Aug 2025 22:30:40 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E5=88=9B=E5=BB=BA=E9=83=A8=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 + model/__init__.py | 10 +- model/base.py | 229 +++++++++++++++++++++++++++++++++++++++++++++ model/database.py | 128 +++++++------------------ model/migration.py | 51 ++++++++++ model/object.py | 48 ++++++++++ model/setting.py | 22 +++++ 7 files changed, 394 insertions(+), 97 deletions(-) create mode 100644 model/base.py create mode 100644 model/migration.py create mode 100644 model/object.py create mode 100644 model/setting.py diff --git a/README.md b/README.md index 04836d4..1fba819 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,9 @@ Upon launch, Findreve will create a SQLite database in the project's root direct display the administrator's account and password in the console. ## 构建 + +> 当前版本的 Findreve Core 无法正常工作,因为我们正在尝试[重构数据库组件以使用ORM](https://github.com/Findreve/Findreve/issues/8) + 你需要安装Python 3.8 以上的版本。然后,clone 本仓库到您的服务器并解压,然后安装下面的依赖: You need to have Python 3.8 or higher installed on your server. Then, clone this repository diff --git a/model/__init__.py b/model/__init__.py index d0d2fd8..bb1b942 100644 --- a/model/__init__.py +++ b/model/__init__.py @@ -1 +1,9 @@ -from . import token \ No newline at end of file +from . import token + +from .object import Object +from .setting import Setting + +__all__ = [ + "Object", + "Setting" +] \ No newline at end of file diff --git a/model/base.py b/model/base.py new file mode 100644 index 0000000..f2a228a --- /dev/null +++ b/model/base.py @@ -0,0 +1,229 @@ +from datetime import datetime, timezone +from typing import Optional, Type, TypeVar, Union, Literal, List + +from sqlalchemy import DateTime, BinaryExpression, ClauseElement +from sqlalchemy.orm import selectinload +from sqlmodel import SQLModel, Field, select, Relationship +from sqlalchemy.ext.asyncio.session import AsyncSession +from sqlalchemy.sql._typing import _OnClauseArgument +from sqlalchemy.ext.asyncio import AsyncAttrs + +B = TypeVar('B', bound='BaseModel') +M = TypeVar('M', bound='SQLModel') + +utcnow = lambda: datetime.now(tz=timezone.utc) + +class BaseModel(AsyncAttrs): + __abstract__ = True + + id: Optional[int] = Field(default=None, primary_key=True, description="主键ID") + created_at: datetime = Field( + default_factory=utcnow, + description="创建时间", + ) + updated_at: datetime = Field( + sa_type=DateTime, + description="更新时间", + sa_column_kwargs={"default": utcnow, "onupdate": utcnow}, + default_factory=utcnow + ) + deleted_at: Optional[datetime] = Field( + default=None, + description="删除时间", + sa_column={"nullable": True} + ) + + @classmethod + async def add( + cls: Type[B], + session: AsyncSession, + instances: B | List[B], + refresh: bool = True + ) -> B | List[B]: + """ + 新增一条记录 + + :param session: 异步会话对象 + :param instances: 实例或实例列表 + :param refresh: 是否刷新实例 + :return: 新增的实例或实例列表 + + Example: + + >>> from model.base import BaseModel + > from model.object import Object + > from database import Database + > import asyncio + > async def main(): + > async with Database.get_session() as session: + > obj = Object(key="12345", name="Test Object", icon="icon.png") + > added_obj = await BaseModel.add(session, obj) + > print(added_obj) + > asyncio.run(main()) + + """ + is_list = False + if isinstance(instances, list): + is_list = True + session.add_all(instances) + else: + session.add(instances) + + await session.commit() + + if refresh: + if is_list: + for instance in instances: + await session.refresh(instance) + else: + await session.refresh(instances) + + return instances + + async def save( + self: B, + session: AsyncSession, + load: Union[Relationship, None] + ): + """ + 保存当前实例到数据库 + + :param session: 异步会话对象 + :param load: 需要加载的关系属性 + :return: None + + """ + session.add(self) + await session.commit() + + if load is not None: + cls = type(self) + return await cls.get(session, self.id, load=load) + else: + await session.refresh(self) + return self + + async def update( + self: B, + session: AsyncSession, + other: M, + extra_data: dict = None, + exclude_unset: bool = True, + ) -> B: + """ + 更新当前实例 + + :param session: 异步会话对象 + :param other: 用于更新的实例 + :param extra_data: 额外的数据字典 + :param exclude_unset: 是否排除未设置的字段 + :return: 更新后的实例 + """ + self.sqlmodel_update( + other.model_dump( + exclude_unset=exclude_unset + ), + update=extra_data + ) + + session.add(self) + + await session.commit() + await session.refresh(self) + + return self + + @classmethod + async def delete( + cls: Type[B], + session: AsyncSession, + instance: B | list[B], + ) -> None: + """ + 删除实例 + + :param session: 异步会话对象 + :param instance: 实例或实例列表 + :return: None + """ + + if isinstance(instance, list): + for inst in instance: + await session.delete(inst) + else: + await session.delete(instance) + + await session.commit() + + @classmethod + async def get( + cls: Type[B], + session: AsyncSession, + condition: BinaryExpression | ClauseElement | None, + *, + offset: int | None = None, + limit: int | None = None, + fetch_mode: Literal["one", "first", "all"] = "first", + join: Type[B] | tuple[Type[B], _OnClauseArgument] | None = None, + options: list | None = None, + load: Union[Relationship, None] = None, + order_by: list[ClauseElement] | None = None + ) -> B | List[B] | None: + """ + 异步获取模型实例 + + 参数: + session: 异步数据库会话 + condition: SQLAlchemy查询条件,如Model.id == 1 + offset: 结果偏移量 + limit: 结果数量限制 + options: 查询选项,如selectinload(Model.relation),异步访问关系属性必备,不然会报错 + fetch_mode: 获取模式 - "one"/"all"/"first" + join: 要联接的模型类 + + 返回: + 根据fetch_mode返回相应的查询结果 + """ + statement = select(cls) + + if condition is not None: + statement = statement.where(condition) + + if join is not None: + statement = statement.join(*join) + + if options: + statement = statement.options(*options) + + if load: + statement = statement.options(selectinload(load)) + + if order_by is not None: + statement = statement.order_by(*order_by) + + if offset: + statement = statement.offset(offset) + + if limit: + statement = statement.limit(limit) + + result = await session.exec(statement) + + if fetch_mode == "one": + return result.one() + elif fetch_mode == "first": + return result.first() + elif fetch_mode == "all": + return list(result.all()) + else: + raise ValueError(f"无效的 fetch_mode: {fetch_mode}") + + @classmethod + async def get_exist_one(cls: Type[B], session: AsyncSession, id: int, load: Union[Relationship, None] = None) -> B: + """此方法和 await session.get(cls, 主键)的区别就是当不存在时不返回None, + 而是会抛出fastapi 404 异常""" + instance = await cls.get(session, cls.id == id, load=load) + if not instance: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Not found") + return instance \ No newline at end of file diff --git a/model/database.py b/model/database.py index ddc7e62..e1804cd 100644 --- a/model/database.py +++ b/model/database.py @@ -1,20 +1,28 @@ -''' -Author: 于小丘 海枫 -Date: 2024-10-02 15:23:34 -LastEditors: Yuerchu admin@yuxiaoqiu.cn -LastEditTime: 2024-11-29 20:05:03 -FilePath: /Findreve/model.py -Description: Findreve 数据库组件 model - -Copyright (c) 2018-2024 by 于小丘Yuerchu, All Rights Reserved. -''' - import aiosqlite from datetime import datetime -import tool -import logging from typing import Optional +from sqlmodel import SQLModel +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine +from sqlmodel.ext.asyncio.session import AsyncSession +from sqlalchemy.orm import sessionmaker +from typing import AsyncGenerator + +ASYNC_DATABASE_URL = "sqlite+aiosqlite:///data.db" + +engine: AsyncEngine = create_async_engine( + ASYNC_DATABASE_URL, + echo=True, + connect_args={ + "check_same_thread": False + } if ASYNC_DATABASE_URL.startswith("sqlite") else None, + future=True, + # pool_size=POOL_SIZE, + # max_overflow=64, +) + +_async_session_factory = sessionmaker(engine, class_=AsyncSession) + # 数据库类 class Database: @@ -24,90 +32,18 @@ class Database: db_path: str = "data.db" # db_path 数据库文件路径,默认为 data.db ): self.db_path = db_path + + async def get_session() -> AsyncGenerator[AsyncSession, None]: + async with _async_session_factory() as session: + yield session - async def init_db(self): - """初始化数据库和表""" - logging.info("开始初始化数据库和表") - - create_objects_table = """ - CREATE TABLE IF NOT EXISTS fr_objects ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - key TEXT NOT NULL, - name TEXT NOT NULL, - icon TEXT, - status TEXT, - phone TEXT, - context TEXT, - find_ip TEXT, - create_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - lost_at TIMESTAMP - ) - """ - - create_settings_table = """ - CREATE TABLE IF NOT EXISTS fr_settings ( - type TEXT, - name TEXT PRIMARY KEY, - value TEXT - ) - """ - - async with aiosqlite.connect(self.db_path) as db: - logging.info("连接到数据库") - await db.execute(create_objects_table) - logging.info("创建或验证fr_objects表") - await db.execute(create_settings_table) - logging.info("创建或验证fr_settings表") - - # 初始化设置表数据 - async with db.execute("SELECT name FROM fr_settings WHERE name = 'version'") as cursor: - if not await cursor.fetchone(): - await db.execute( - "INSERT INTO fr_settings (type, name, value) VALUES (?, ?, ?)", - ('string', 'version', '1.0.0') - ) - logging.info("插入初始版本信息: version 1.0.0") - - async with db.execute("SELECT name FROM fr_settings WHERE name = 'ver'") as cursor: - if not await cursor.fetchone(): - await db.execute( - "INSERT INTO fr_settings (type, name, value) VALUES (?, ?, ?)", - ('int', 'ver', '1') - ) - logging.info("插入初始版本号: ver 1") - - async with db.execute("SELECT name FROM fr_settings WHERE name = 'account'") as cursor: - if not await cursor.fetchone(): - account = 'admin@yuxiaoqiu.cn' - await db.execute( - "INSERT INTO fr_settings (type, name, value) VALUES (?, ?, ?)", - ('string', 'account', account) - ) - logging.info(f"插入初始账号信息: {account}") - print(f"账号: {account}") - - async with db.execute("SELECT name FROM fr_settings WHERE name = 'password'") as cursor: - if not await cursor.fetchone(): - password = tool.generate_password() - hashed_password = tool.hash_password(password) - await db.execute( - "INSERT INTO fr_settings (type, name, value) VALUES (?, ?, ?)", - ('string', 'password', hashed_password) - ) - logging.info("插入初始密码信息") - print(f"密码(请牢记,后续不再显示): {password}") - - async with db.execute("SELECT name FROM fr_settings WHERE name = 'SECRET_KEY'") as cursor: - if not await cursor.fetchone(): - secret_key = tool.generate_password(64) - await db.execute( - "INSERT INTO fr_settings (type, name, value) VALUES (?, ?, ?)", - ('string', 'SECRET_KEY', secret_key) - ) - logging.info("插入初始密钥信息") - - await db.commit() - logging.info("数据库初始化完成并提交更改") + async def init_db( + self, + url: str = ASYNC_DATABASE_URL + ): + """创建数据库结构""" + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) async def add_object(self, key: str, name: str, icon: str = None, phone: str = None): """ diff --git a/model/migration.py b/model/migration.py new file mode 100644 index 0000000..6e92f4b --- /dev/null +++ b/model/migration.py @@ -0,0 +1,51 @@ +""" +# 初始化设置表数据 + async with db.execute("SELECT name FROM fr_settings WHERE name = 'version'") as cursor: + if not await cursor.fetchone(): + await db.execute( + "INSERT INTO fr_settings (type, name, value) VALUES (?, ?, ?)", + ('string', 'version', '1.0.0') + ) + logging.info("插入初始版本信息: version 1.0.0") + + async with db.execute("SELECT name FROM fr_settings WHERE name = 'ver'") as cursor: + if not await cursor.fetchone(): + await db.execute( + "INSERT INTO fr_settings (type, name, value) VALUES (?, ?, ?)", + ('int', 'ver', '1') + ) + logging.info("插入初始版本号: ver 1") + + async with db.execute("SELECT name FROM fr_settings WHERE name = 'account'") as cursor: + if not await cursor.fetchone(): + account = 'admin@yuxiaoqiu.cn' + await db.execute( + "INSERT INTO fr_settings (type, name, value) VALUES (?, ?, ?)", + ('string', 'account', account) + ) + logging.info(f"插入初始账号信息: {account}") + print(f"账号: {account}") + + async with db.execute("SELECT name FROM fr_settings WHERE name = 'password'") as cursor: + if not await cursor.fetchone(): + password = tool.generate_password() + hashed_password = tool.hash_password(password) + await db.execute( + "INSERT INTO fr_settings (type, name, value) VALUES (?, ?, ?)", + ('string', 'password', hashed_password) + ) + logging.info("插入初始密码信息") + print(f"密码(请牢记,后续不再显示): {password}") + + async with db.execute("SELECT name FROM fr_settings WHERE name = 'SECRET_KEY'") as cursor: + if not await cursor.fetchone(): + secret_key = tool.generate_password(64) + await db.execute( + "INSERT INTO fr_settings (type, name, value) VALUES (?, ?, ?)", + ('string', 'SECRET_KEY', secret_key) + ) + logging.info("插入初始密钥信息") + + await db.commit() + logging.info("数据库初始化完成并提交更改") +""" \ No newline at end of file diff --git a/model/object.py b/model/object.py new file mode 100644 index 0000000..220abfc --- /dev/null +++ b/model/object.py @@ -0,0 +1,48 @@ +# my_project/models/download.py + +from typing import Literal, Optional, TYPE_CHECKING +from sqlmodel import Field, Column, SQLModel, String, DateTime +from .base import BaseModel +from datetime import datetime + +from .base import BaseModel + +""" +原建表语句: + +CREATE TABLE IF NOT EXISTS fr_objects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL, + name TEXT NOT NULL, + icon TEXT, + status TEXT, + phone TEXT, + context TEXT, + find_ip TEXT, + create_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + lost_at TIMESTAMP +""" + +if TYPE_CHECKING: + pass + +class Object(SQLModel, BaseModel, table=True): + __tablename__ = 'fr_objects' + + key: str = Field(index=True, nullable=False, description="物品外部ID") + type: Literal['object', 'box'] = Field( + default='object', + description="物品类型", + sa_column=Column(String, default='object', nullable=False) + ) + name: str = Field(nullable=False, description="物品名称") + icon: Optional[str] = Field(default=None, description="物品图标") + status: Optional[str] = Field(default=None, description="物品状态") + phone: Optional[str] = Field(default=None, description="联系电话") + context: Optional[str] = Field(default=None, description="物品描述") + find_ip: Optional[str] = Field(default=None, description="最后一次发现的IP地址") + lost_at: Optional[datetime] = Field( + default=None, + description="物品标记为丢失的时间", + sa_column=Column(DateTime, nullable=True) + ) \ No newline at end of file diff --git a/model/setting.py b/model/setting.py new file mode 100644 index 0000000..f7cd143 --- /dev/null +++ b/model/setting.py @@ -0,0 +1,22 @@ +from typing import TYPE_CHECKING, Optional +from sqlmodel import Field, SQLModel +from .base import BaseModel + +""" +原建表语句: + +CREATE TABLE IF NOT EXISTS fr_settings ( + type TEXT, + name TEXT PRIMARY KEY, + value TEXT +""" + +if TYPE_CHECKING: + pass + +class Setting(SQLModel, BaseModel, table=True): + __tablename__ = 'fr_settings' + + type: str = Field(index=True, nullable=False, description="设置类型") + name: str = Field(index=True, primary_key=True, nullable=False, description="设置名称") + value: Optional[str] = Field(description="设置值") \ No newline at end of file From db77d6033b1464cd06692fa607a97639e688ff6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8E=E5=B0=8F=E4=B8=98?= Date: Sun, 28 Sep 2025 11:49:26 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E5=88=9D=E6=AD=A5=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/.gitignore | 8 + .idea/Findreve.iml | 14 ++ .idea/dataSources.xml | 13 ++ .idea/inspectionProfiles/Project_Default.xml | 29 ++++ .../inspectionProfiles/profiles_settings.xml | 6 + .idea/material_theme_project_new.xml | 17 ++ .idea/misc.xml | 7 + .idea/modules.xml | 8 + .idea/runConfigurations/Findreve.xml | 26 +++ .idea/vcs.xml | 6 + Findreve.code-workspace | 3 + app.py | 20 +-- model/__init__.py | 8 +- model/base.py | 154 +++++------------- model/database.py | 14 +- model/migration.py | 33 ++++ model/object.py | 6 +- model/setting.py | 15 +- pkg/conf.py | 9 + routes/session.py | 6 +- 20 files changed, 249 insertions(+), 153 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/Findreve.iml create mode 100644 .idea/dataSources.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/material_theme_project_new.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/runConfigurations/Findreve.xml create mode 100644 .idea/vcs.xml create mode 100644 pkg/conf.py diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..35410ca --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/Findreve.iml b/.idea/Findreve.iml new file mode 100644 index 0000000..3708bfa --- /dev/null +++ b/.idea/Findreve.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..bae54b5 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,13 @@ + + + + + sqlite.xerial + true + Findreve Data + org.sqlite.JDBC + jdbc:sqlite:$PROJECT_DIR$/data.db + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..ef59b15 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,29 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml new file mode 100644 index 0000000..844ac1b --- /dev/null +++ b/.idea/material_theme_project_new.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..82554e2 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..cd62433 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Findreve.xml b/.idea/runConfigurations/Findreve.xml new file mode 100644 index 0000000..f4ac18e --- /dev/null +++ b/.idea/runConfigurations/Findreve.xml @@ -0,0 +1,26 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Findreve.code-workspace b/Findreve.code-workspace index 6470250..1abc245 100644 --- a/Findreve.code-workspace +++ b/Findreve.code-workspace @@ -8,6 +8,9 @@ }, { "path": "../Findreve-NiceGUI" + }, + { + "path": "../vein-based-iov-simulator-main" } ], "settings": {} diff --git a/app.py b/app.py index 0ad36a0..6c0e9b0 100644 --- a/app.py +++ b/app.py @@ -5,21 +5,11 @@ from contextlib import asynccontextmanager from routes import (session, admin, object) import model.database import os, asyncio +import pkg.conf # 初始化数据库 asyncio.run(model.database.Database().init_db()) -# 定义程序参数 -APP_NAME: str = 'Findreve' -VERSION: str = '2.0.0' -summary='标记、追踪与找回 —— 就这么简单。' -description='Findreve 是一款强大且直观的解决方案,旨在帮助您管理个人物品,'\ - '并确保丢失后能够安全找回。每个物品都会被分配一个 唯一 ID ,'\ - '并生成一个 安全链接 ,可轻松嵌入到 二维码 或 NFC 标签 中。'\ - '当扫描该代码时,会将拾得者引导至一个专门的网页,上面显示物品详情和您的联系信息,'\ - '既保障隐私又便于沟通。无论您是在管理个人物品还是专业资产,'\ - 'Findreve 都能以高效、简便的方式弥合丢失与找回之间的距离。' - # Findreve 的生命周期 @asynccontextmanager async def lifespan(app: FastAPI): @@ -28,10 +18,10 @@ async def lifespan(app: FastAPI): # 定义 Findreve 服务器 app = FastAPI( - title=APP_NAME, - version=VERSION, - summary=summary, - description=description, + title=pkg.conf.APP_NAME, + version=pkg.conf.VERSION, + summary=pkg.conf.summary, + description=pkg.conf.description, lifespan=lifespan ) diff --git a/model/__init__.py b/model/__init__.py index bb1b942..61d1c24 100644 --- a/model/__init__.py +++ b/model/__init__.py @@ -1,9 +1,3 @@ from . import token - -from .object import Object from .setting import Setting - -__all__ = [ - "Object", - "Setting" -] \ No newline at end of file +from .object import Object \ No newline at end of file diff --git a/model/base.py b/model/base.py index f2a228a..a0abcf0 100644 --- a/model/base.py +++ b/model/base.py @@ -1,26 +1,26 @@ +# model/base.py from datetime import datetime, timezone from typing import Optional, Type, TypeVar, Union, Literal, List from sqlalchemy import DateTime, BinaryExpression, ClauseElement from sqlalchemy.orm import selectinload -from sqlmodel import SQLModel, Field, select, Relationship from sqlalchemy.ext.asyncio.session import AsyncSession -from sqlalchemy.sql._typing import _OnClauseArgument from sqlalchemy.ext.asyncio import AsyncAttrs +from sqlmodel import SQLModel, Field, select, Relationship +from sqlalchemy.sql._typing import _OnClauseArgument -B = TypeVar('B', bound='BaseModel') +B = TypeVar('B', bound='TableBase') M = TypeVar('M', bound='SQLModel') utcnow = lambda: datetime.now(tz=timezone.utc) -class BaseModel(AsyncAttrs): +class TableBase(AsyncAttrs, SQLModel): __abstract__ = True - - id: Optional[int] = Field(default=None, primary_key=True, description="主键ID") + created_at: datetime = Field( default_factory=utcnow, description="创建时间", - ) + ) updated_at: datetime = Field( sa_type=DateTime, description="更新时间", @@ -28,11 +28,11 @@ class BaseModel(AsyncAttrs): default_factory=utcnow ) deleted_at: Optional[datetime] = Field( - default=None, + default=None, description="删除时间", sa_column={"nullable": True} ) - + @classmethod async def add( cls: Type[B], @@ -40,69 +40,34 @@ class BaseModel(AsyncAttrs): instances: B | List[B], refresh: bool = True ) -> B | List[B]: - """ - 新增一条记录 - - :param session: 异步会话对象 - :param instances: 实例或实例列表 - :param refresh: 是否刷新实例 - :return: 新增的实例或实例列表 - - Example: - - >>> from model.base import BaseModel - > from model.object import Object - > from database import Database - > import asyncio - > async def main(): - > async with Database.get_session() as session: - > obj = Object(key="12345", name="Test Object", icon="icon.png") - > added_obj = await BaseModel.add(session, obj) - > print(added_obj) - > asyncio.run(main()) - - """ - is_list = False - if isinstance(instances, list): - is_list = True + is_list = isinstance(instances, list) + if is_list: session.add_all(instances) else: session.add(instances) - await session.commit() - if refresh: if is_list: - for instance in instances: - await session.refresh(instance) + for i in instances: + await session.refresh(i) else: await session.refresh(instances) - return instances - + async def save( self: B, session: AsyncSession, - load: Union[Relationship, None] + load: Union[Relationship, None] = None, # 设默认值,避免必须传 ): - """ - 保存当前实例到数据库 - - :param session: 异步会话对象 - :param load: 需要加载的关系属性 - :return: None - - """ session.add(self) await session.commit() - if load is not None: cls = type(self) - return await cls.get(session, self.id, load=load) + return await cls.get(session, cls.id == self.id, load=load) # 若该模型没有 id,请别用 load 模式 else: await session.refresh(self) return self - + async def update( self: B, session: AsyncSession, @@ -110,105 +75,59 @@ class BaseModel(AsyncAttrs): extra_data: dict = None, exclude_unset: bool = True, ) -> B: - """ - 更新当前实例 - - :param session: 异步会话对象 - :param other: 用于更新的实例 - :param extra_data: 额外的数据字典 - :param exclude_unset: 是否排除未设置的字段 - :return: 更新后的实例 - """ self.sqlmodel_update( - other.model_dump( - exclude_unset=exclude_unset - ), + other.model_dump(exclude_unset=exclude_unset), update=extra_data ) - session.add(self) - await session.commit() await session.refresh(self) - return self - + @classmethod async def delete( cls: Type[B], session: AsyncSession, instance: B | list[B], ) -> None: - """ - 删除实例 - - :param session: 异步会话对象 - :param instance: 实例或实例列表 - :return: None - """ - if isinstance(instance, list): for inst in instance: await session.delete(inst) else: await session.delete(instance) - await session.commit() - + @classmethod async def get( - cls: Type[B], - session: AsyncSession, - condition: BinaryExpression | ClauseElement | None, - *, - offset: int | None = None, - limit: int | None = None, - fetch_mode: Literal["one", "first", "all"] = "first", - join: Type[B] | tuple[Type[B], _OnClauseArgument] | None = None, - options: list | None = None, - load: Union[Relationship, None] = None, - order_by: list[ClauseElement] | None = None + cls: Type[B], + session: AsyncSession, + condition: BinaryExpression | ClauseElement | None, + *, + offset: int | None = None, + limit: int | None = None, + fetch_mode: Literal["one", "first", "all"] = "first", + join: Type[B] | tuple[Type[B], _OnClauseArgument] | None = None, + options: list | None = None, + load: Union[Relationship, None] = None, + order_by: list[ClauseElement] | None = None ) -> B | List[B] | None: - """ - 异步获取模型实例 - - 参数: - session: 异步数据库会话 - condition: SQLAlchemy查询条件,如Model.id == 1 - offset: 结果偏移量 - limit: 结果数量限制 - options: 查询选项,如selectinload(Model.relation),异步访问关系属性必备,不然会报错 - fetch_mode: 获取模式 - "one"/"all"/"first" - join: 要联接的模型类 - - 返回: - 根据fetch_mode返回相应的查询结果 - """ statement = select(cls) - if condition is not None: statement = statement.where(condition) - if join is not None: statement = statement.join(*join) - if options: statement = statement.options(*options) - if load: statement = statement.options(selectinload(load)) - if order_by is not None: statement = statement.order_by(*order_by) - if offset: statement = statement.offset(offset) - if limit: statement = statement.limit(limit) result = await session.exec(statement) - if fetch_mode == "one": return result.one() elif fetch_mode == "first": @@ -217,13 +136,16 @@ class BaseModel(AsyncAttrs): return list(result.all()) else: raise ValueError(f"无效的 fetch_mode: {fetch_mode}") - + @classmethod async def get_exist_one(cls: Type[B], session: AsyncSession, id: int, load: Union[Relationship, None] = None) -> B: - """此方法和 await session.get(cls, 主键)的区别就是当不存在时不返回None, - 而是会抛出fastapi 404 异常""" instance = await cls.get(session, cls.id == id, load=load) if not instance: from fastapi import HTTPException raise HTTPException(status_code=404, detail="Not found") - return instance \ No newline at end of file + return instance + + +# 需要“自增 id 主键”的模型才混入它;Setting 不混入 +class IdMixin(SQLModel): + id: Optional[int] = Field(default=None, primary_key=True, description="主键ID") \ No newline at end of file diff --git a/model/database.py b/model/database.py index e1804cd..44037ad 100644 --- a/model/database.py +++ b/model/database.py @@ -1,3 +1,4 @@ +from contextlib import asynccontextmanager import aiosqlite from datetime import datetime from typing import Optional @@ -8,6 +9,9 @@ from sqlmodel.ext.asyncio.session import AsyncSession from sqlalchemy.orm import sessionmaker from typing import AsyncGenerator +import warnings +from .migration import migration + ASYNC_DATABASE_URL = "sqlite+aiosqlite:///data.db" engine: AsyncEngine = create_async_engine( @@ -21,7 +25,7 @@ engine: AsyncEngine = create_async_engine( # max_overflow=64, ) -_async_session_factory = sessionmaker(engine, class_=AsyncSession) +_async_session_factory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) # 数据库类 class Database: @@ -33,6 +37,8 @@ class Database: ): self.db_path = db_path + @staticmethod + @asynccontextmanager async def get_session() -> AsyncGenerator[AsyncSession, None]: async with _async_session_factory() as session: yield session @@ -44,6 +50,9 @@ class Database: """创建数据库结构""" async with engine.begin() as conn: await conn.run_sync(SQLModel.metadata.create_all) + + async with self.get_session() as session: + await migration(session) # 执行迁移脚本 async def add_object(self, key: str, name: str, icon: str = None, phone: str = None): """ @@ -54,6 +63,9 @@ class Database: :param icon: 图标 :param phone: 电话 """ + + warnings.warn("因需要迁移至ORM,此方法已被废弃", DeprecationWarning) + async with aiosqlite.connect(self.db_path) as db: async with db.execute("SELECT 1 FROM fr_objects WHERE key = ?", (key,)) as cursor: if await cursor.fetchone(): diff --git a/model/migration.py b/model/migration.py index 6e92f4b..c829735 100644 --- a/model/migration.py +++ b/model/migration.py @@ -1,3 +1,36 @@ +from typing import Sequence +from sqlmodel import select +from .setting import Setting +import tool + +default_settings: list[Setting] = [ + Setting(type='string', name='version', value='1.0.0'), + Setting(type='int', name='ver', value='1'), + Setting(type='string', name='account', value='admin@yuxiaoqiu.cn'), +] + +async def migration(session): + # 先准备基础配置 + settings: list[Setting] = default_settings.copy() + + # 生成初始密码与密钥 + admin_password = tool.generate_password() + print(f"密码(请牢记,后续不再显示): {admin_password}") + + settings.append(Setting(type='string', name='password', value=tool.hash_password(admin_password))) + settings.append(Setting(type='string', name='SECRET_KEY', value=tool.generate_password(64))) + + # 读取库里已存在的 name,避免主键冲突 + names = [s.name for s in settings] + exist_stmt = select(Setting.name).where(Setting.name.in_(names)) + exist_rs = await session.exec(exist_stmt) + existed: set[str] = set(exist_rs.all()) + + to_insert = [s for s in settings if s.name not in existed] + if to_insert: + # 使用你写好的通用新增方法(是类方法),并传入会话 + await Setting.add(session, to_insert, refresh=False) + """ # 初始化设置表数据 async with db.execute("SELECT name FROM fr_settings WHERE name = 'version'") as cursor: diff --git a/model/object.py b/model/object.py index 220abfc..43c80a9 100644 --- a/model/object.py +++ b/model/object.py @@ -2,11 +2,9 @@ from typing import Literal, Optional, TYPE_CHECKING from sqlmodel import Field, Column, SQLModel, String, DateTime -from .base import BaseModel +from .base import TableBase, IdMixin from datetime import datetime -from .base import BaseModel - """ 原建表语句: @@ -26,7 +24,7 @@ CREATE TABLE IF NOT EXISTS fr_objects ( if TYPE_CHECKING: pass -class Object(SQLModel, BaseModel, table=True): +class Object(IdMixin, TableBase, table=True): __tablename__ = 'fr_objects' key: str = Field(index=True, nullable=False, description="物品外部ID") diff --git a/model/setting.py b/model/setting.py index f7cd143..bf56756 100644 --- a/model/setting.py +++ b/model/setting.py @@ -1,22 +1,23 @@ +# model/setting.py from typing import TYPE_CHECKING, Optional -from sqlmodel import Field, SQLModel -from .base import BaseModel +from sqlmodel import Field +from .base import TableBase """ -原建表语句: - +原表: CREATE TABLE IF NOT EXISTS fr_settings ( type TEXT, name TEXT PRIMARY KEY, value TEXT +) """ if TYPE_CHECKING: pass -class Setting(SQLModel, BaseModel, table=True): +class Setting(TableBase, table=True): __tablename__ = 'fr_settings' type: str = Field(index=True, nullable=False, description="设置类型") - name: str = Field(index=True, primary_key=True, nullable=False, description="设置名称") - value: Optional[str] = Field(description="设置值") \ No newline at end of file + name: str = Field(primary_key=True, nullable=False, description="设置名称") # name 为唯一主键 + value: Optional[str] = Field(description="设置值") diff --git a/pkg/conf.py b/pkg/conf.py new file mode 100644 index 0000000..89260e4 --- /dev/null +++ b/pkg/conf.py @@ -0,0 +1,9 @@ +APP_NAME: str = 'Findreve' +VERSION: str = '2.0.0' +summary='标记、追踪与找回 —— 就这么简单。' +description='Findreve 是一款强大且直观的解决方案,旨在帮助您管理个人物品,'\ + '并确保丢失后能够安全找回。每个物品都会被分配一个 唯一 ID ,'\ + '并生成一个 安全链接 ,可轻松嵌入到 二维码 或 NFC 标签 中。'\ + '当扫描该代码时,会将拾得者引导至一个专门的网页,上面显示物品详情和您的联系信息,'\ + '既保障隐私又便于沟通。无论您是在管理个人物品还是专业资产,'\ + 'Findreve 都能以高效、简便的方式弥合丢失与找回之间的距离。' \ No newline at end of file diff --git a/routes/session.py b/routes/session.py index 4c5c5ce..6e006f9 100644 --- a/routes/session.py +++ b/routes/session.py @@ -7,7 +7,7 @@ from fastapi import APIRouter import jwt, JWT from model.token import Token -from model import database +from model import Setting, database from tool import verify_password Router = APIRouter(tags=["令牌 session"]) @@ -26,8 +26,8 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None): # 验证账号密码 async def authenticate_user(username: str, password: str): # 验证账号和密码 - account = await database.Database().get_setting('account') - stored_password = await database.Database().get_setting('password') + account = await Setting.get('setting', 'account') + stored_password = await Setting.get('setting', 'password') if account != username or not verify_password(stored_password, password): return False