diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 35410ca..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-# 默认忽略的文件
-/shelf/
-/workspace.xml
-# 基于编辑器的 HTTP 客户端请求
-/httpRequests/
-# Datasource local storage ignored files
-/dataSources/
-/dataSources.local.xml
diff --git a/.idea/Findreve.iml b/.idea/Findreve.iml
deleted file mode 100644
index 3708bfa..0000000
--- a/.idea/Findreve.iml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml
deleted file mode 100644
index bae54b5..0000000
--- a/.idea/dataSources.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
- 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
deleted file mode 100644
index ef59b15..0000000
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
deleted file mode 100644
index 105ce2d..0000000
--- a/.idea/inspectionProfiles/profiles_settings.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml
deleted file mode 100644
index 844ac1b..0000000
--- a/.idea/material_theme_project_new.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index 82554e2..0000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index cd62433..0000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/Findreve.xml b/.idea/runConfigurations/Findreve.xml
deleted file mode 100644
index f4ac18e..0000000
--- a/.idea/runConfigurations/Findreve.xml
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 35eb1dd..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/JWT.py b/JWT.py
index 7fcd52a..4cc7e44 100644
--- a/JWT.py
+++ b/JWT.py
@@ -22,7 +22,7 @@ async def get_secret_key() -> str:
global _SECRET_KEY_CACHE
if _SECRET_KEY_CACHE is None:
- async with Database.get_session() as session:
+ async with Database.session_context() as session:
setting = await Setting.get(
session=session,
condition=(Setting.name == 'SECRET_KEY')
diff --git a/model/base.py b/model/base.py
index a0abcf0..830a432 100644
--- a/model/base.py
+++ b/model/base.py
@@ -1,6 +1,6 @@
# model/base.py
from datetime import datetime, timezone
-from typing import Optional, Type, TypeVar, Union, Literal, List
+from typing import Type, TypeVar, Union, Literal, List
from sqlalchemy import DateTime, BinaryExpression, ClauseElement
from sqlalchemy.orm import selectinload
@@ -27,7 +27,7 @@ class TableBase(AsyncAttrs, SQLModel):
sa_column_kwargs={"default": utcnow, "onupdate": utcnow},
default_factory=utcnow
)
- deleted_at: Optional[datetime] = Field(
+ deleted_at: datetime | None = Field(
default=None,
description="删除时间",
sa_column={"nullable": True}
@@ -148,4 +148,4 @@ class TableBase(AsyncAttrs, SQLModel):
# 需要“自增 id 主键”的模型才混入它;Setting 不混入
class IdMixin(SQLModel):
- id: Optional[int] = Field(default=None, primary_key=True, description="主键ID")
\ No newline at end of file
+ id: int | None = 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 cbcb623..cc7b8f9 100644
--- a/model/database.py
+++ b/model/database.py
@@ -42,12 +42,25 @@ class Database:
async with _async_session_factory() as session:
yield session
+ @staticmethod
+ @asynccontextmanager
+ async def session_context() -> AsyncGenerator[AsyncSession, None]:
+ """
+ 提供异步上下文管理器用于直接获取数据库会话
+
+ 使用示例:
+ async with Database.session_context() as session:
+ # 执行数据库操作
+ pass
+ """
+ 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)
# For internal use, create a temporary context manager
- get_session_cm = asynccontextmanager(self.get_session)
- async with get_session_cm() as session:
+ async with self.session_context() as session:
await migration(session) # 执行迁移脚本
\ No newline at end of file
diff --git a/model/items.py b/model/items.py
index b39ed3a..47d9d18 100644
--- a/model/items.py
+++ b/model/items.py
@@ -1,5 +1,4 @@
from pydantic import BaseModel
-from typing import Optional
class Item(BaseModel):
id: int
@@ -9,8 +8,7 @@ class Item(BaseModel):
icon: str
status: str
phone: int
- lost_description: Optional[str]
- find_ip: Optional[str]
+ lost_description: str | None
+ find_ip: str | None
create_time: str
- lost_time: Optional[str]
-
\ No newline at end of file
+ lost_time: str | None
diff --git a/model/migration.py b/model/migration.py
index a903a56..1b9fdca 100644
--- a/model/migration.py
+++ b/model/migration.py
@@ -1,7 +1,7 @@
from loguru import logger
from sqlmodel import select
from .setting import Setting
-import tool
+from pkg.password import Password
default_settings: list[Setting] = [
Setting(type='string', name='version', value='1.0.0'),
@@ -18,11 +18,11 @@ async def migration(session):
return
# 生成初始密码与密钥
- admin_password = tool.generate_password()
+ admin_password = Password.generate()
logger.warning(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)))
+ settings.append(Setting(type='string', name='password', value=Password.hash(admin_password)))
+ settings.append(Setting(type='string', name='SECRET_KEY', value=Password.generate(64)))
# 读取库里已存在的 name,避免主键冲突
names = [s.name for s in settings]
diff --git a/model/object.py b/model/object.py
index 43c80a9..853aa62 100644
--- a/model/object.py
+++ b/model/object.py
@@ -1,45 +1,29 @@
# my_project/models/download.py
-from typing import Literal, Optional, TYPE_CHECKING
-from sqlmodel import Field, Column, SQLModel, String, DateTime
+from typing import Literal
+from sqlmodel import Field, Column, 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',
+ type: Literal['normal', 'car'] = Field(
+ default='normal',
description="物品类型",
- sa_column=Column(String, default='object', nullable=False)
+ sa_column=Column(String, default='normal', 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(
+ icon: str | None = Field(default=None, description="物品图标")
+ status: Literal['ok', 'lost'] = Field(
+ default='ok',
+ description="物品状态",
+ sa_column=Column(String, default='ok', nullable=False)
+ )
+ phone: str | None = Field(default=None, description="联系电话")
+ context: str | None = Field(default=None, description="物品描述")
+ find_ip: str | None = Field(default=None, description="最后一次发现的IP地址")
+ lost_at: datetime | None = Field(
default=None,
description="物品标记为丢失的时间",
sa_column=Column(DateTime, nullable=True)
diff --git a/model/response.py b/model/response.py
index bf6169a..2b99bfe 100644
--- a/model/response.py
+++ b/model/response.py
@@ -1,5 +1,5 @@
from pydantic import BaseModel
-from typing import Literal, Optional
+from typing import Literal
class DefaultResponse(BaseModel):
code: int = 0
diff --git a/model/setting.py b/model/setting.py
index bf56756..058b543 100644
--- a/model/setting.py
+++ b/model/setting.py
@@ -1,5 +1,4 @@
# model/setting.py
-from typing import TYPE_CHECKING, Optional
from sqlmodel import Field
from .base import TableBase
@@ -12,12 +11,8 @@ CREATE TABLE IF NOT EXISTS fr_settings (
)
"""
-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="设置值")
+ value: str | None = Field(description="设置值")
diff --git a/pkg/password.py b/pkg/password.py
new file mode 100644
index 0000000..05d2050
--- /dev/null
+++ b/pkg/password.py
@@ -0,0 +1,72 @@
+import secrets
+from loguru import logger
+from argon2 import PasswordHasher
+from argon2.exceptions import VerifyMismatchError
+
+_ph = PasswordHasher()
+
+class Password():
+
+ @staticmethod
+ def generate(
+ length: int = 8
+ ) -> str:
+ """
+ 生成指定长度的随机密码。
+
+ :param length: 密码长度
+ :type length: int
+ :return: 随机密码
+ :rtype: str
+ """
+ return secrets.token_hex(length)
+
+ def hash(
+ password: str
+ ) -> str:
+ """
+ 使用 Argon2 生成密码的哈希值。
+
+ 返回的哈希字符串已经包含了所有需要验证的信息(盐、算法参数等)。
+
+ :param password: 需要哈希的原始密码
+ :return: Argon2 哈希字符串
+ """
+ return _ph.hash(password)
+
+ def verify(
+ stored_password: str,
+ provided_password: str,
+ debug: bool = False
+ ) -> bool:
+ """
+ 验证存储的 Argon2 哈希值与用户提供的密码是否匹配。
+
+ :param stored_password: 数据库中存储的 Argon2 哈希字符串
+ :param provided_password: 用户本次提供的密码
+ :param debug: 是否输出调试信息
+ :return: 如果密码匹配返回 True, 否则返回 False
+ """
+ if debug:
+ logger.info(f"验证密码: (哈希) {stored_password}")
+
+ try:
+ # verify 函数会自动解析 stored_password 中的盐和参数
+ _ph.verify(stored_password, provided_password)
+
+ # 检查哈希参数是否已过时。如果返回True,
+ # 意味着你应该使用新的参数重新哈希密码并更新存储。
+ # 这是一个很好的实践,可以随着时间推移增强安全性。
+ if _ph.check_needs_rehash(stored_password):
+ logger.warning("密码哈希参数已过时,建议重新哈希并更新。")
+
+ return True
+ except VerifyMismatchError:
+ # 这是预期的异常,当密码不匹配时触发。
+ if debug:
+ logger.info("密码不匹配")
+ return False
+ except Exception as e:
+ # 捕获其他可能的错误
+ logger.error(f"密码验证过程中发生未知错误: {e}")
+ return False
\ No newline at end of file
diff --git a/routes/admin.py b/routes/admin.py
index eb9c948..e636e8d 100644
--- a/routes/admin.py
+++ b/routes/admin.py
@@ -10,11 +10,12 @@ from model.response import DefaultResponse
from model.items import Item
from sqlmodel.ext.asyncio.session import AsyncSession
from model import Setting
+from model.object import Object
# 验证是否为管理员
async def is_admin(
token: Annotated[str, Depends(JWT.oauth2_scheme)],
- session: Annotated[AsyncSession, Depends(database.Database.get_session)]
+ session: Annotated[AsyncSession, Depends(database.Database.get_session)],
) -> Literal[True]:
'''
验证是否为管理员。
@@ -29,9 +30,10 @@ async def is_admin(
)
try:
- payload = jwt.decode(token, JWT.get_secret_key(), algorithms=[JWT.ALGORITHM])
+ payload = jwt.decode(token, await JWT.get_secret_key(), algorithms=[JWT.ALGORITHM])
username = payload.get("sub")
- if username is None or not await Setting.get(session, Setting.name == 'account') == username:
+ stored_account = await Setting.get(session, Setting.name == 'account')
+ if username is None or not stored_account.value == username:
raise credentials_exception
else:
return True
@@ -69,6 +71,7 @@ async def verity_admin() -> DefaultResponse:
response_description='物品信息列表'
)
async def get_items(
+ session: Annotated[AsyncSession, Depends(database.Database.get_session)],
id: int | None = Query(default=None, ge=1, description='物品ID'),
key: str | None = Query(default=None, description='物品序列号')):
'''
@@ -76,29 +79,33 @@ async def get_items(
不传参数返回所有信息,否则可传入 `id` 或 `key` 进行筛选。
'''
- results = await database.Database().get_object(id=id, key=key)
+ # 根据条件查询物品
+ if id is not None:
+ results = await Object.get(session, Object.id == id)
+ results = [results] if results else []
+ elif key is not None:
+ results = await Object.get(session, Object.key == key)
+ results = [results] if results else []
+ else:
+ results = await Object.get(session, None, fetch_mode="all")
- if results is not None:
- if not isinstance(results, list):
- items = [results]
- else:
- items = results
- item = []
- for i in items:
- item.append(Item(
- id=i[0],
- type=i[1],
- key=i[2],
- name=i[3],
- icon=i[4],
- status=i[5],
- phone=i[6],
- lost_description=i[7],
- find_ip=i[8],
- create_time=i[9],
- lost_time=i[10]
+ if results:
+ items = []
+ for obj in results:
+ items.append(Item(
+ id=obj.id,
+ type=obj.type,
+ key=obj.key,
+ name=obj.name,
+ icon=obj.icon or "",
+ status=obj.status or "",
+ phone=int(obj.phone) if obj.phone and obj.phone.isdigit() else 0,
+ lost_description=obj.context,
+ find_ip=obj.find_ip,
+ create_time=obj.created_at.isoformat(),
+ lost_time=obj.lost_at.isoformat() if obj.lost_at else None
))
- return DefaultResponse(data=item)
+ return DefaultResponse(data=items)
else:
return DefaultResponse(data=[])
@@ -110,6 +117,7 @@ async def get_items(
response_description='添加物品成功'
)
async def add_items(
+ session: Annotated[AsyncSession, Depends(database.Database.get_session)],
key: str,
type: Literal['normal', 'car'],
name: str,
@@ -127,13 +135,16 @@ async def add_items(
'''
try:
- await database.Database().add_object(
- key=key,
+ # 创建新物品对象
+ new_object = Object(
+ key=key,
type=type,
- name=name,
- icon=icon,
+ name=name,
+ icon=icon,
phone=phone
)
+ # 使用 base.py 中的 add 方法
+ await Object.add(session, new_object)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
else:
@@ -147,6 +158,7 @@ async def add_items(
response_description='更新物品成功'
)
async def update_items(
+ session: Annotated[AsyncSession, Depends(database.Database.get_session)],
id: int = Query(ge=1),
key: str | None = None,
name: str | None = None,
@@ -174,12 +186,32 @@ async def update_items(
'''
try:
- await database.Database().update_object(
- id=id,
- key=key, name=name, icon=icon, status=status, phone=phone,
- lost_description=lost_description, find_ip=find_ip,
- lost_time=lost_time
- )
+ # 获取现有物品
+ obj = await Object.get_exist_one(session, id)
+
+ # 更新字段
+ if key is not None:
+ obj.key = key
+ if name is not None:
+ obj.name = name
+ if icon is not None:
+ obj.icon = icon
+ if status is not None:
+ obj.status = status
+ if phone is not None:
+ obj.phone = str(phone)
+ if lost_description is not None:
+ obj.context = lost_description
+ if find_ip is not None:
+ obj.find_ip = find_ip
+ if lost_time is not None:
+ from datetime import datetime
+ obj.lost_at = datetime.fromisoformat(lost_time)
+
+ # 保存更新
+ await obj.save(session)
+ except HTTPException:
+ raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
else:
@@ -193,6 +225,7 @@ async def update_items(
response_description='删除物品成功'
)
async def delete_items(
+ session: Annotated[AsyncSession, Depends(database.Database.get_session)],
id: int) -> DefaultResponse:
'''
删除物品信息。
@@ -200,7 +233,12 @@ async def delete_items(
- **id**: 物品的ID
'''
try:
- await database.Database().delete_object(id=id)
+ # 获取现有物品
+ obj = await Object.get_exist_one(session, id)
+ # 使用 base.py 中的 delete 方法
+ await Object.delete(session, obj)
+ except HTTPException:
+ raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
else:
diff --git a/routes/object.py b/routes/object.py
index e81ffc3..4bda28d 100644
--- a/routes/object.py
+++ b/routes/object.py
@@ -68,7 +68,7 @@ async def notify_move_car(
Args:
item_id (int): 物品ID / Item ID
- phone (str, optional): 挪车发起者电话 / Phone number of the person initiating the move. Defaults to None.
+ phone (str): 挪车发起者电话 / Phone number of the person initiating the move. Defaults to None.
"""
db = Database()
await db.init_db()
diff --git a/routes/session.py b/routes/session.py
index 288396b..236a24b 100644
--- a/routes/session.py
+++ b/routes/session.py
@@ -6,7 +6,7 @@ from fastapi.security import OAuth2PasswordRequestForm
from fastapi import APIRouter
import jwt, JWT
from sqlmodel.ext.asyncio.session import AsyncSession
-from tool import verify_password
+from pkg.password import Password
from loguru import logger
from model.token import Token
@@ -35,7 +35,7 @@ async def authenticate_user(session: AsyncSession, username: str, password: str)
logger.error("Account or password not set in settings.")
return False
- if account.value != username or not verify_password(stored_password.value, password):
+ if account.value != username or not Password.verify(stored_password.value, password):
logger.error("Invalid username or password.")
return False
diff --git a/tool.py b/tool.py
index 4d14d04..86ce08b 100644
--- a/tool.py
+++ b/tool.py
@@ -1,9 +1,4 @@
-import hashlib
-import binascii
-import logging
from datetime import datetime, timezone
-import os
-import secrets
def format_phone(
phone: str,
@@ -36,69 +31,6 @@ def format_phone(
return separator.join(result)
-def generate_password(
- length: int = 8
- ) -> str:
- """
- 生成指定长度的随机密码。
-
- :param length: 密码长度
- :type length: int
- :return: 随机密码
- :rtype: str
- """
- import secrets
-
- return secrets.token_hex(length)
-
-def hash_password(
- password: str
- ) -> str:
- """
- 生成密码的加盐哈希值。
-
- :param password: 需要哈希的原始密码
- :type password: str
- :return: 包含盐值和哈希值的字符串
- :rtype: str
-
- 使用SHA-256和PBKDF2算法对密码进行加盐哈希,返回盐值和哈希值的组合。
- """
- salt = hashlib.sha256(os.urandom(60)).hexdigest().encode('ascii')
- pwdhash = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000)
- pwdhash = binascii.hexlify(pwdhash)
- return (salt + pwdhash).decode('ascii')
-
-def verify_password(
- stored_password: str,
- provided_password: str,
- debug: bool = False
- ) -> bool:
- """
- 验证存储的密码哈希值与用户提供的密码是否匹配。
-
- :param stored_password: 存储的密码哈希值(包含盐值)
- :type stored_password: str
- :param provided_password: 用户提供的密码
- :type provided_password: str
- :param debug: 是否输出调试信息,将会输出原密码和哈希值
- :type debug: bool
- :return: 如果密码匹配返回True,否则返回False
- :rtype: bool
-
- 从存储的密码哈希中提取盐值,使用相同的哈希算法验证用户提供的密码。
- """
- salt = stored_password[:64]
- stored_password = stored_password[64:]
- pwdhash = hashlib.pbkdf2_hmac('sha256',
- provided_password.encode('utf-8'),
- salt.encode('ascii'),
- 100000)
- pwdhash = binascii.hexlify(pwdhash).decode('ascii')
- if debug:
- logging.info(f"原密码: {provided_password}, 哈希值: {pwdhash}, 存储哈希值: {stored_password}")
- return secrets.compare_digest(pwdhash, stored_password)
-
def format_time_diff(
target_time: datetime | str
) -> str: