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] =?UTF-8?q?=E5=88=9D=E6=AD=A5=E8=BF=81=E7=A7=BB=E6=95=B0?= =?UTF-8?q?=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