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/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/app.py b/app.py index e89af34..b5ebc5e 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-ootc' -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 d0d2fd8..61d1c24 100644 --- a/model/__init__.py +++ b/model/__init__.py @@ -1 +1,3 @@ -from . import token \ No newline at end of file +from . import token +from .setting import Setting +from .object import Object \ No newline at end of file diff --git a/model/base.py b/model/base.py new file mode 100644 index 0000000..a0abcf0 --- /dev/null +++ b/model/base.py @@ -0,0 +1,151 @@ +# 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 sqlalchemy.ext.asyncio.session import AsyncSession +from sqlalchemy.ext.asyncio import AsyncAttrs +from sqlmodel import SQLModel, Field, select, Relationship +from sqlalchemy.sql._typing import _OnClauseArgument + +B = TypeVar('B', bound='TableBase') +M = TypeVar('M', bound='SQLModel') + +utcnow = lambda: datetime.now(tz=timezone.utc) + +class TableBase(AsyncAttrs, SQLModel): + __abstract__ = True + + 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]: + 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 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] = None, # 设默认值,避免必须传 + ): + session.add(self) + await session.commit() + if load is not None: + cls = type(self) + 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, + other: M, + extra_data: dict = None, + exclude_unset: bool = True, + ) -> B: + 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: + 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: + 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: + 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 + + +# 需要“自增 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 0e8ab0c..244a872 100644 --- a/model/database.py +++ b/model/database.py @@ -1,19 +1,31 @@ -''' -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. -''' - +from contextlib import asynccontextmanager import aiosqlite from datetime import datetime -import tool -import logging -from typing import Literal, Optional +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 + +import warnings +from .migration import migration + +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, expire_on_commit=False) # 数据库类 class Database: @@ -24,104 +36,28 @@ class Database: db_path: str = "data.db" # db_path 数据库文件路径,默认为 data.db ): self.db_path = db_path - - async def init_db(self): - """初始化数据库和表""" - logging.info("开始初始化数据库和表") - - create_objects_table = """ - CREATE TABLE IF NOT EXISTS fr_objects ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - type TEXT NOT NULL, - 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 add_object( - self, - key: str, - type: Literal['normal', 'car'], - name: str, - icon: str = None, - phone: str = None, - ): + @staticmethod + @asynccontextmanager + async def get_session() -> AsyncGenerator[AsyncSession, None]: + async with _async_session_factory() as session: + yield session + + 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 with self.get_session() as session: + await migration(session) # 执行迁移脚本 + + async def add_object(self, key: str, name: str, icon: str = None, phone: str = None): """ 添加新对象 - :param type: 对象类型 :param key: 序列号 :param name: 名称 :param icon: 图标 @@ -135,15 +71,14 @@ class Database: now = datetime.now() now = now.strftime("%Y-%m-%d %H:%M:%S") await db.execute( - "INSERT INTO fr_objects (key, name, icon, phone, create_at, status, type) VALUES (?, ?, ?, ?, ?, 'ok', ?)", - (key, name, icon, phone, now, type) + "INSERT INTO fr_objects (key, name, icon, phone, create_at, status) VALUES (?, ?, ?, ?, ?, 'ok')", + (key, name, icon, phone, now) ) await db.commit() async def update_object( self, id: int, - type: Literal['normal', 'car'] = None, key: str = None, name: str = None, icon: str = None, @@ -151,13 +86,11 @@ class Database: phone: int = None, lost_description: Optional[str] = None, find_ip: Optional[str] = None, - lost_time: Optional[str] = None - ): + lost_time: Optional[str] = None): """ 更新对象信息 :param id: 对象ID - :param type: 对象类型 :param key: 序列号 :param name: 名称 :param icon: 图标 @@ -185,18 +118,13 @@ class Database: f"phone = COALESCE(?, phone), " f"context = COALESCE(?, context), " f"find_ip = COALESCE(?, find_ip), " - f"lost_at = COALESCE(?, lost_at), " - f"type = COALESCE(?, type) " + f"lost_at = COALESCE(?, lost_at) " f"WHERE id = ?", - (key, name, icon, status, phone, lost_description, find_ip, lost_time, type, id) + (key, name, icon, status, phone, lost_description, find_ip, lost_time, id) ) await db.commit() - async def get_object( - self, - id: int = None, - key: str = None - ): + async def get_object(self, id: int = None, key: str = None): """ 获取对象 diff --git a/model/migration.py b/model/migration.py new file mode 100644 index 0000000..c829735 --- /dev/null +++ b/model/migration.py @@ -0,0 +1,84 @@ +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: + 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..43c80a9 --- /dev/null +++ b/model/object.py @@ -0,0 +1,46 @@ +# my_project/models/download.py + +from typing import Literal, Optional, TYPE_CHECKING +from sqlmodel import Field, Column, SQLModel, String, DateTime +from .base import TableBase, IdMixin +from datetime import datetime + +""" +原建表语句: + +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(IdMixin, TableBase, 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..bf56756 --- /dev/null +++ b/model/setting.py @@ -0,0 +1,23 @@ +# model/setting.py +from typing import TYPE_CHECKING, Optional +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(TableBase, table=True): + __tablename__ = 'fr_settings' + + type: str = Field(index=True, nullable=False, description="设置类型") + 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