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