初步迁移数据库

This commit is contained in:
2025-09-28 11:49:26 +08:00
parent 6c512805e8
commit db77d6033b
20 changed files with 249 additions and 153 deletions

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

14
.idea/Findreve.iml generated Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.12 (Findreve)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>

13
.idea/dataSources.xml generated Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="data.db" uuid="c73289bb-3157-4ec3-b3f4-744da38bc64a">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<remarks>Findreve Data</remarks>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/data.db</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@@ -0,0 +1,29 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="PyInterpreterInspection" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="PyMissingTypeHintsInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="PyPep8Inspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="E402" />
<option value="E713" />
<option value="E271" />
<option value="E302" />
<option value="E265" />
<option value="W292" />
</list>
</option>
</inspection_tool>
<inspection_tool class="PyPep8NamingInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false">
<option name="ignoredErrors">
<list>
<option value="N802" />
<option value="N806" />
</list>
</option>
</inspection_tool>
<inspection_tool class="Stylelint" enabled="true" level="ERROR" enabled_by_default="true" />
</profile>
</component>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

17
.idea/material_theme_project_new.xml generated Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="migrated" value="true" />
<option name="pristineConfig" value="false" />
<option name="userId" value="4dc9a07a:18f958e7499:-7ffe" />
</MTProjectMetadataState>
</option>
<option name="titleBarState">
<MTProjectTitleBarConfigState>
<option name="overrideColor" value="false" />
</MTProjectTitleBarConfigState>
</option>
</component>
</project>

7
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.12 (Findreve)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (Findreve)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Findreve.iml" filepath="$PROJECT_DIR$/.idea/Findreve.iml" />
</modules>
</component>
</project>

26
.idea/runConfigurations/Findreve.xml generated Normal file
View File

@@ -0,0 +1,26 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="启动Findreve" type="PythonConfigurationType" factoryName="Python">
<module name="Findreve" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="SDK_NAME" value="Python 3.12 (Findreve)" />
<option name="WORKING_DIRECTORY" value="C:\Users\admin\Documents\Code\Findreve" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/main.py" />
<option name="PARAMETERS" value="" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -8,6 +8,9 @@
}, },
{ {
"path": "../Findreve-NiceGUI" "path": "../Findreve-NiceGUI"
},
{
"path": "../vein-based-iov-simulator-main"
} }
], ],
"settings": {} "settings": {}

20
app.py
View File

@@ -5,21 +5,11 @@ from contextlib import asynccontextmanager
from routes import (session, admin, object) from routes import (session, admin, object)
import model.database import model.database
import os, asyncio import os, asyncio
import pkg.conf
# 初始化数据库 # 初始化数据库
asyncio.run(model.database.Database().init_db()) asyncio.run(model.database.Database().init_db())
# 定义程序参数
APP_NAME: str = 'Findreve'
VERSION: str = '2.0.0'
summary='标记、追踪与找回 —— 就这么简单。'
description='Findreve 是一款强大且直观的解决方案,旨在帮助您管理个人物品,'\
'并确保丢失后能够安全找回。每个物品都会被分配一个 唯一 ID '\
'并生成一个 安全链接 ,可轻松嵌入到 二维码 或 NFC 标签 中。'\
'当扫描该代码时,会将拾得者引导至一个专门的网页,上面显示物品详情和您的联系信息,'\
'既保障隐私又便于沟通。无论您是在管理个人物品还是专业资产,'\
'Findreve 都能以高效、简便的方式弥合丢失与找回之间的距离。'
# Findreve 的生命周期 # Findreve 的生命周期
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
@@ -28,10 +18,10 @@ async def lifespan(app: FastAPI):
# 定义 Findreve 服务器 # 定义 Findreve 服务器
app = FastAPI( app = FastAPI(
title=APP_NAME, title=pkg.conf.APP_NAME,
version=VERSION, version=pkg.conf.VERSION,
summary=summary, summary=pkg.conf.summary,
description=description, description=pkg.conf.description,
lifespan=lifespan lifespan=lifespan
) )

View File

@@ -1,9 +1,3 @@
from . import token from . import token
from .object import Object
from .setting import Setting from .setting import Setting
from .object import Object
__all__ = [
"Object",
"Setting"
]

View File

@@ -1,22 +1,22 @@
# model/base.py
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional, Type, TypeVar, Union, Literal, List from typing import Optional, Type, TypeVar, Union, Literal, List
from sqlalchemy import DateTime, BinaryExpression, ClauseElement from sqlalchemy import DateTime, BinaryExpression, ClauseElement
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlmodel import SQLModel, Field, select, Relationship
from sqlalchemy.ext.asyncio.session import AsyncSession from sqlalchemy.ext.asyncio.session import AsyncSession
from sqlalchemy.sql._typing import _OnClauseArgument
from sqlalchemy.ext.asyncio import AsyncAttrs 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') M = TypeVar('M', bound='SQLModel')
utcnow = lambda: datetime.now(tz=timezone.utc) utcnow = lambda: datetime.now(tz=timezone.utc)
class BaseModel(AsyncAttrs): class TableBase(AsyncAttrs, SQLModel):
__abstract__ = True __abstract__ = True
id: Optional[int] = Field(default=None, primary_key=True, description="主键ID")
created_at: datetime = Field( created_at: datetime = Field(
default_factory=utcnow, default_factory=utcnow,
description="创建时间", description="创建时间",
@@ -40,65 +40,30 @@ class BaseModel(AsyncAttrs):
instances: B | List[B], instances: B | List[B],
refresh: bool = True refresh: bool = True
) -> B | List[B]: ) -> B | List[B]:
""" is_list = isinstance(instances, list)
新增一条记录 if is_list:
: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) session.add_all(instances)
else: else:
session.add(instances) session.add(instances)
await session.commit() await session.commit()
if refresh: if refresh:
if is_list: if is_list:
for instance in instances: for i in instances:
await session.refresh(instance) await session.refresh(i)
else: else:
await session.refresh(instances) await session.refresh(instances)
return instances return instances
async def save( async def save(
self: B, self: B,
session: AsyncSession, session: AsyncSession,
load: Union[Relationship, None] load: Union[Relationship, None] = None, # 设默认值,避免必须传
): ):
"""
保存当前实例到数据库
:param session: 异步会话对象
:param load: 需要加载的关系属性
:return: None
"""
session.add(self) session.add(self)
await session.commit() await session.commit()
if load is not None: if load is not None:
cls = type(self) 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: else:
await session.refresh(self) await session.refresh(self)
return self return self
@@ -110,27 +75,13 @@ class BaseModel(AsyncAttrs):
extra_data: dict = None, extra_data: dict = None,
exclude_unset: bool = True, exclude_unset: bool = True,
) -> B: ) -> B:
"""
更新当前实例
:param session: 异步会话对象
:param other: 用于更新的实例
:param extra_data: 额外的数据字典
:param exclude_unset: 是否排除未设置的字段
:return: 更新后的实例
"""
self.sqlmodel_update( self.sqlmodel_update(
other.model_dump( other.model_dump(exclude_unset=exclude_unset),
exclude_unset=exclude_unset
),
update=extra_data update=extra_data
) )
session.add(self) session.add(self)
await session.commit() await session.commit()
await session.refresh(self) await session.refresh(self)
return self return self
@classmethod @classmethod
@@ -139,20 +90,11 @@ class BaseModel(AsyncAttrs):
session: AsyncSession, session: AsyncSession,
instance: B | list[B], instance: B | list[B],
) -> None: ) -> None:
"""
删除实例
:param session: 异步会话对象
:param instance: 实例或实例列表
:return: None
"""
if isinstance(instance, list): if isinstance(instance, list):
for inst in instance: for inst in instance:
await session.delete(inst) await session.delete(inst)
else: else:
await session.delete(instance) await session.delete(instance)
await session.commit() await session.commit()
@classmethod @classmethod
@@ -169,46 +111,23 @@ class BaseModel(AsyncAttrs):
load: Union[Relationship, None] = None, load: Union[Relationship, None] = None,
order_by: list[ClauseElement] | None = None order_by: list[ClauseElement] | None = None
) -> B | List[B] | 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) statement = select(cls)
if condition is not None: if condition is not None:
statement = statement.where(condition) statement = statement.where(condition)
if join is not None: if join is not None:
statement = statement.join(*join) statement = statement.join(*join)
if options: if options:
statement = statement.options(*options) statement = statement.options(*options)
if load: if load:
statement = statement.options(selectinload(load)) statement = statement.options(selectinload(load))
if order_by is not None: if order_by is not None:
statement = statement.order_by(*order_by) statement = statement.order_by(*order_by)
if offset: if offset:
statement = statement.offset(offset) statement = statement.offset(offset)
if limit: if limit:
statement = statement.limit(limit) statement = statement.limit(limit)
result = await session.exec(statement) result = await session.exec(statement)
if fetch_mode == "one": if fetch_mode == "one":
return result.one() return result.one()
elif fetch_mode == "first": elif fetch_mode == "first":
@@ -220,10 +139,13 @@ class BaseModel(AsyncAttrs):
@classmethod @classmethod
async def get_exist_one(cls: Type[B], session: AsyncSession, id: int, load: Union[Relationship, None] = None) -> B: 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) instance = await cls.get(session, cls.id == id, load=load)
if not instance: if not instance:
from fastapi import HTTPException from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Not found") raise HTTPException(status_code=404, detail="Not found")
return instance return instance
# 需要“自增 id 主键”的模型才混入它Setting 不混入
class IdMixin(SQLModel):
id: Optional[int] = Field(default=None, primary_key=True, description="主键ID")

View File

@@ -1,3 +1,4 @@
from contextlib import asynccontextmanager
import aiosqlite import aiosqlite
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
@@ -8,6 +9,9 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from typing import AsyncGenerator from typing import AsyncGenerator
import warnings
from .migration import migration
ASYNC_DATABASE_URL = "sqlite+aiosqlite:///data.db" ASYNC_DATABASE_URL = "sqlite+aiosqlite:///data.db"
engine: AsyncEngine = create_async_engine( engine: AsyncEngine = create_async_engine(
@@ -21,7 +25,7 @@ engine: AsyncEngine = create_async_engine(
# max_overflow=64, # max_overflow=64,
) )
_async_session_factory = sessionmaker(engine, class_=AsyncSession) _async_session_factory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
# 数据库类 # 数据库类
class Database: class Database:
@@ -33,6 +37,8 @@ class Database:
): ):
self.db_path = db_path self.db_path = db_path
@staticmethod
@asynccontextmanager
async def get_session() -> AsyncGenerator[AsyncSession, None]: async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with _async_session_factory() as session: async with _async_session_factory() as session:
yield session yield session
@@ -45,6 +51,9 @@ class Database:
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all) 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): async def add_object(self, key: str, name: str, icon: str = None, phone: str = None):
""" """
添加新对象 添加新对象
@@ -54,6 +63,9 @@ class Database:
:param icon: 图标 :param icon: 图标
:param phone: 电话 :param phone: 电话
""" """
warnings.warn("因需要迁移至ORM此方法已被废弃", DeprecationWarning)
async with aiosqlite.connect(self.db_path) as db: async with aiosqlite.connect(self.db_path) as db:
async with db.execute("SELECT 1 FROM fr_objects WHERE key = ?", (key,)) as cursor: async with db.execute("SELECT 1 FROM fr_objects WHERE key = ?", (key,)) as cursor:
if await cursor.fetchone(): if await cursor.fetchone():

View File

@@ -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: async with db.execute("SELECT name FROM fr_settings WHERE name = 'version'") as cursor:

View File

@@ -2,11 +2,9 @@
from typing import Literal, Optional, TYPE_CHECKING from typing import Literal, Optional, TYPE_CHECKING
from sqlmodel import Field, Column, SQLModel, String, DateTime from sqlmodel import Field, Column, SQLModel, String, DateTime
from .base import BaseModel from .base import TableBase, IdMixin
from datetime import datetime from datetime import datetime
from .base import BaseModel
""" """
原建表语句: 原建表语句:
@@ -26,7 +24,7 @@ CREATE TABLE IF NOT EXISTS fr_objects (
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
class Object(SQLModel, BaseModel, table=True): class Object(IdMixin, TableBase, table=True):
__tablename__ = 'fr_objects' __tablename__ = 'fr_objects'
key: str = Field(index=True, nullable=False, description="物品外部ID") key: str = Field(index=True, nullable=False, description="物品外部ID")

View File

@@ -1,22 +1,23 @@
# model/setting.py
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from sqlmodel import Field, SQLModel from sqlmodel import Field
from .base import BaseModel from .base import TableBase
""" """
建表语句
CREATE TABLE IF NOT EXISTS fr_settings ( CREATE TABLE IF NOT EXISTS fr_settings (
type TEXT, type TEXT,
name TEXT PRIMARY KEY, name TEXT PRIMARY KEY,
value TEXT value TEXT
)
""" """
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
class Setting(SQLModel, BaseModel, table=True): class Setting(TableBase, table=True):
__tablename__ = 'fr_settings' __tablename__ = 'fr_settings'
type: str = Field(index=True, nullable=False, description="设置类型") type: str = Field(index=True, nullable=False, description="设置类型")
name: str = Field(index=True, primary_key=True, nullable=False, description="设置名称") name: str = Field(primary_key=True, nullable=False, description="设置名称") # name 为唯一主键
value: Optional[str] = Field(description="设置值") value: Optional[str] = Field(description="设置值")

9
pkg/conf.py Normal file
View File

@@ -0,0 +1,9 @@
APP_NAME: str = 'Findreve'
VERSION: str = '2.0.0'
summary='标记、追踪与找回 —— 就这么简单。'
description='Findreve 是一款强大且直观的解决方案,旨在帮助您管理个人物品,'\
'并确保丢失后能够安全找回。每个物品都会被分配一个 唯一 ID '\
'并生成一个 安全链接 ,可轻松嵌入到 二维码 或 NFC 标签 中。'\
'当扫描该代码时,会将拾得者引导至一个专门的网页,上面显示物品详情和您的联系信息,'\
'既保障隐私又便于沟通。无论您是在管理个人物品还是专业资产,'\
'Findreve 都能以高效、简便的方式弥合丢失与找回之间的距离。'

View File

@@ -7,7 +7,7 @@ from fastapi import APIRouter
import jwt, JWT import jwt, JWT
from model.token import Token from model.token import Token
from model import database from model import Setting, database
from tool import verify_password from tool import verify_password
Router = APIRouter(tags=["令牌 session"]) 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): async def authenticate_user(username: str, password: str):
# 验证账号和密码 # 验证账号和密码
account = await database.Database().get_setting('account') account = await Setting.get('setting', 'account')
stored_password = await database.Database().get_setting('password') stored_password = await Setting.get('setting', 'password')
if account != username or not verify_password(stored_password, password): if account != username or not verify_password(stored_password, password):
return False return False