Refactor models and routes for item management
Reorganized model structure by replacing 'object' and 'items' with a unified 'item' model using UUIDs, and moved base model logic into separate files. Updated routes to use the new item model and improved request/response handling. Enhanced user and setting models, added utility functions, and improved error handling throughout the codebase. Also added initial .idea project files and minor admin API improvements. Co-Authored-By: 砂糖橘 <54745033+Foxerine@users.noreply.github.com>
This commit is contained in:
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
17
.idea/Findreve.iml
generated
Normal file
17
.idea/Findreve.iml
generated
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?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>
|
||||||
|
<component name="TestRunnerService">
|
||||||
|
<option name="PROJECT_TEST_RUNNER" value="py.test" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
6
.idea/copilot.data.migration.agent.xml
generated
Normal file
6
.idea/copilot.data.migration.agent.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AgentMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/copilot.data.migration.ask.xml
generated
Normal file
6
.idea/copilot.data.migration.ask.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AskMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/copilot.data.migration.edit.xml
generated
Normal file
6
.idea/copilot.data.migration.edit.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="EditMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal 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
17
.idea/material_theme_project_new.xml
generated
Normal 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="-7c28e060:19966355718:-7ffa" />
|
||||||
|
</MTProjectMetadataState>
|
||||||
|
</option>
|
||||||
|
<option name="titleBarState">
|
||||||
|
<MTProjectTitleBarConfigState>
|
||||||
|
<option name="overrideColor" value="false" />
|
||||||
|
</MTProjectTitleBarConfigState>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
7
.idea/misc.xml
generated
Normal file
7
.idea/misc.xml
generated
Normal 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
8
.idea/modules.xml
generated
Normal 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>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
31
app.py
31
app.py
@@ -5,18 +5,21 @@ from contextlib import asynccontextmanager
|
|||||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
from slowapi.errors import RateLimitExceeded
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
|
||||||
|
from pkg.utils import raise_internal_error
|
||||||
from routes import (session, admin, object)
|
from routes import (session, admin, object)
|
||||||
import model.database
|
from model.database import Database
|
||||||
import os, asyncio
|
import os
|
||||||
import pkg.conf
|
import pkg.conf
|
||||||
|
|
||||||
# 初始化数据库
|
from loguru import logger
|
||||||
asyncio.run(model.database.Database().init_db())
|
|
||||||
|
Router = [admin, session, object]
|
||||||
|
|
||||||
# Findreve 的生命周期
|
# Findreve 的生命周期
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
await model.database.Database().init_db()
|
await Database().init_db()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# 定义 Findreve 服务器
|
# 定义 Findreve 服务器
|
||||||
@@ -28,10 +31,22 @@ app = FastAPI(
|
|||||||
lifespan=lifespan
|
lifespan=lifespan
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def handle_unexpected_exceptions(request: Request, exc: Exception):
|
||||||
|
"""
|
||||||
|
捕获所有未经处理的异常,防止敏感信息泄露。
|
||||||
|
"""
|
||||||
|
# 1. 为开发人员记录详细的、包含完整堆栈跟踪的错误日志
|
||||||
|
logger.exception(
|
||||||
|
f"An unhandled exception occurred for request: {request.method} {request.url.path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
raise_internal_error()
|
||||||
|
|
||||||
|
|
||||||
# 挂载后端路由
|
# 挂载后端路由
|
||||||
app.include_router(admin.Router)
|
for router in Router:
|
||||||
app.include_router(session.Router)
|
app.include_router(router.Router)
|
||||||
app.include_router(object.Router)
|
|
||||||
|
|
||||||
# 挂载Slowapi限流中间件
|
# 挂载Slowapi限流中间件
|
||||||
limiter = Limiter(key_func=get_remote_address)
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
from typing import Annotated, Literal
|
from typing import Annotated
|
||||||
|
|
||||||
|
import jwt
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
import JWT
|
|
||||||
import jwt
|
|
||||||
from jwt import InvalidTokenError
|
from jwt import InvalidTokenError
|
||||||
from model import database
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
import JWT
|
||||||
from model import User
|
from model import User
|
||||||
|
from model.database import Database
|
||||||
|
|
||||||
|
|
||||||
# 验证是否为管理员
|
# 验证是否为管理员
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
token: Annotated[str, Depends(JWT.oauth2_scheme)],
|
token: Annotated[str, Depends(JWT.oauth2_scheme)],
|
||||||
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
session: Annotated[AsyncSession, Depends(Database.get_session)],
|
||||||
) -> User:
|
) -> User:
|
||||||
'''
|
"""
|
||||||
验证用户身份并返回当前用户信息。
|
验证用户身份并返回当前用户信息。
|
||||||
'''
|
"""
|
||||||
not_login_exception = HTTPException(
|
not_login_exception = HTTPException(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
detail="Login required",
|
detail="Login required",
|
||||||
@@ -26,7 +29,7 @@ async def get_current_user(
|
|||||||
payload = jwt.decode(token, await JWT.get_secret_key(), algorithms=[JWT.ALGORITHM])
|
payload = jwt.decode(token, await JWT.get_secret_key(), algorithms=[JWT.ALGORITHM])
|
||||||
username = payload.get("sub")
|
username = payload.get("sub")
|
||||||
stored_account = await User.get(session, User.email == username)
|
stored_account = await User.get(session, User.email == username)
|
||||||
if username is None or not stored_account.email == username:
|
if username is None or stored_account.email != username:
|
||||||
raise not_login_exception
|
raise not_login_exception
|
||||||
return stored_account
|
return stored_account
|
||||||
except InvalidTokenError:
|
except InvalidTokenError:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from . import token
|
from .response import DefaultResponse, TokenResponse, TokenData
|
||||||
from .setting import Setting
|
from .setting import Setting, SettingResponse
|
||||||
from .object import Object
|
from .item import Item, ItemDataResponse, ItemTypeEnum, ItemStatusEnum
|
||||||
from .user import User
|
from .user import User, UserTypeEnum
|
||||||
|
from .database import Database
|
||||||
|
|||||||
151
model/base.py
151
model/base.py
@@ -1,151 +0,0 @@
|
|||||||
# model/base.py
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import 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: datetime | None = 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: int | None = Field(default=None, primary_key=True, description="主键ID")
|
|
||||||
3
model/base/__init__.py
Normal file
3
model/base/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .sqlmodel_base import SQLModelBase
|
||||||
|
from .table_base import TableBase, UUIDTableBase
|
||||||
|
|
||||||
5
model/base/sqlmodel_base.py
Normal file
5
model/base/sqlmodel_base.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from pydantic import ConfigDict
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
class SQLModelBase(SQLModel):
|
||||||
|
model_config = ConfigDict(use_attribute_docstrings=True)
|
||||||
200
model/base/table_base.py
Normal file
200
model/base/table_base.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Union, TypeVar, Type, Literal, override, Optional, Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import DateTime, BinaryExpression, ClauseElement
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from sqlmodel import Field, select, Relationship, SQLModel
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
from sqlalchemy.sql._typing import _OnClauseArgument
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncAttrs
|
||||||
|
|
||||||
|
T = TypeVar("T", bound="TableBase")
|
||||||
|
M = TypeVar("M", bound="SQLModel")
|
||||||
|
|
||||||
|
now = lambda: datetime.now()
|
||||||
|
now_date = lambda: datetime.now().date()
|
||||||
|
|
||||||
|
class TableBase(AsyncAttrs):
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
|
||||||
|
created_at: datetime = Field(default_factory=now)
|
||||||
|
updated_at: datetime = Field(
|
||||||
|
sa_type=DateTime,
|
||||||
|
sa_column_kwargs={"default": now, "onupdate": now},
|
||||||
|
default_factory=now
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def add(cls: Type[T], session: AsyncSession, instances: T | list[T], refresh: bool = True) -> T | list[T]:
|
||||||
|
"""
|
||||||
|
新增一条记录
|
||||||
|
:param session: 数据库会话
|
||||||
|
:param instances:
|
||||||
|
:param refresh:
|
||||||
|
:return: 新增的实例对象
|
||||||
|
|
||||||
|
usage:
|
||||||
|
item1 = Item(...)
|
||||||
|
item2 = Item(...)
|
||||||
|
|
||||||
|
Item.add(session, [item1, item2])
|
||||||
|
|
||||||
|
item1_id = item1.id
|
||||||
|
"""
|
||||||
|
is_list = False
|
||||||
|
if isinstance(instances, list):
|
||||||
|
is_list = True
|
||||||
|
session.add_all(instances)
|
||||||
|
else:
|
||||||
|
session.add(instances)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
if refresh:
|
||||||
|
if is_list:
|
||||||
|
for instance in instances:
|
||||||
|
await session.refresh(instance)
|
||||||
|
else:
|
||||||
|
await session.refresh(instances)
|
||||||
|
|
||||||
|
return instances
|
||||||
|
|
||||||
|
async def save(self: T, session: AsyncSession, load: Optional[Relationship] = None) -> T:
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
await session.refresh(self)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self: T,
|
||||||
|
session: AsyncSession,
|
||||||
|
other: M,
|
||||||
|
extra_data: dict = None,
|
||||||
|
exclude_unset: bool = True
|
||||||
|
) -> T:
|
||||||
|
"""
|
||||||
|
更新记录
|
||||||
|
:param session: 数据库会话
|
||||||
|
:param other:
|
||||||
|
:param extra_data:
|
||||||
|
:param exclude_unset:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
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[T], session: AsyncSession, instances: T | list[T]) -> None:
|
||||||
|
"""
|
||||||
|
删除一些记录
|
||||||
|
:param session: 数据库会话
|
||||||
|
:param instances:
|
||||||
|
:return: None
|
||||||
|
|
||||||
|
usage:
|
||||||
|
item1 = Item.get(...)
|
||||||
|
item2 = Item.get(...)
|
||||||
|
|
||||||
|
Item.delete(session, [item1, item2])
|
||||||
|
|
||||||
|
"""
|
||||||
|
if isinstance(instances, list):
|
||||||
|
for instance in instances:
|
||||||
|
await session.delete(instance)
|
||||||
|
else:
|
||||||
|
await session.delete(instances)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get(
|
||||||
|
cls: Type[T],
|
||||||
|
session: AsyncSession,
|
||||||
|
condition: BinaryExpression | ClauseElement | None,
|
||||||
|
*,
|
||||||
|
offset: int | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
fetch_mode: Literal["one", "first", "all"] = "first",
|
||||||
|
join: Type[T] | tuple[Type[T], _OnClauseArgument] | None = None,
|
||||||
|
options: list | None = None,
|
||||||
|
load: Union[Relationship, None] = None,
|
||||||
|
order_by: list[ClauseElement] | None = None
|
||||||
|
) -> T | list[T] | 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":
|
||||||
|
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[T], session: AsyncSession, id: int, load: Union[Relationship, None] = None) -> T:
|
||||||
|
"""此方法和 await session.get(cls, 主键)的区别就是当不存在时不返回None,
|
||||||
|
而是会抛出fastapi 404 异常"""
|
||||||
|
instance = await cls.get(session, cls.id == id, load=load)
|
||||||
|
if not instance:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
return instance
|
||||||
|
|
||||||
|
class UUIDTableBase(TableBase):
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
"""override"""
|
||||||
|
|
||||||
|
@override
|
||||||
|
async def get_exist_one(cls: Type[T], session: AsyncSession, id: uuid.UUID, load: Union[Relationship, None] = None) -> T:
|
||||||
|
return super().get_exist_one(session, id, load) # type: ignore
|
||||||
77
model/item.py
Normal file
77
model/item.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import TYPE_CHECKING, Self, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlmodel import Field, Relationship
|
||||||
|
|
||||||
|
from .base import SQLModelBase, UUIDTableBase
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .user import User
|
||||||
|
|
||||||
|
class ItemTypeEnum(StrEnum):
|
||||||
|
normal = 'normal'
|
||||||
|
car = 'car'
|
||||||
|
|
||||||
|
class ItemStatusEnum(StrEnum):
|
||||||
|
ok = 'ok'
|
||||||
|
lost = 'lost'
|
||||||
|
|
||||||
|
class ItemBase(SQLModelBase):
|
||||||
|
type: ItemTypeEnum = ItemTypeEnum.normal
|
||||||
|
"""物品的类型"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
"""物品名称"""
|
||||||
|
|
||||||
|
icon: str | None = None
|
||||||
|
"""物品图标"""
|
||||||
|
|
||||||
|
status: ItemStatusEnum = ItemStatusEnum.ok
|
||||||
|
"""物品状态"""
|
||||||
|
|
||||||
|
phone: str | None = None
|
||||||
|
"""联系电话"""
|
||||||
|
|
||||||
|
description: str | None = None
|
||||||
|
"""物品描述"""
|
||||||
|
|
||||||
|
class Item(ItemBase, UUIDTableBase, table=True):
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
"""物品过期时间"""
|
||||||
|
|
||||||
|
lost_at: datetime | None = None
|
||||||
|
"""物品丢失的时间"""
|
||||||
|
|
||||||
|
find_ip: str | None = None
|
||||||
|
"""最后一次发现的IP地址"""
|
||||||
|
|
||||||
|
user_id: UUID = Field(foreign_key='user.id', ondelete='CASCADE')
|
||||||
|
"""所属用户ID"""
|
||||||
|
|
||||||
|
user: 'User' = Relationship(back_populates='items')
|
||||||
|
|
||||||
|
parent_item_id: UUID | None = Field(foreign_key='item.id', ondelete='RESTRICT')
|
||||||
|
parent_item: Optional['Item'] = Relationship(back_populates='sub_items', sa_relationship_kwargs={'remote_side': 'Item.id'})
|
||||||
|
sub_items: list['Item'] = Relationship(back_populates='parent_item', passive_deletes='all')
|
||||||
|
|
||||||
|
class ItemDataUpdateRequest(ItemBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ItemDataResponse(ItemBase):
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
"""物品过期时间"""
|
||||||
|
|
||||||
|
lost_at: datetime | None = None
|
||||||
|
"""物品丢失的时间"""
|
||||||
|
|
||||||
|
class ItemDataResponseAdmin(ItemBase):
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
"""物品过期时间"""
|
||||||
|
|
||||||
|
lost_at: datetime | None = None
|
||||||
|
"""物品丢失的时间"""
|
||||||
|
|
||||||
|
user_id: UUID = Field(foreign_key='user.id')
|
||||||
|
"""所属用户ID"""
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
class Item(BaseModel):
|
|
||||||
id: int
|
|
||||||
type: str
|
|
||||||
key: str
|
|
||||||
name: str
|
|
||||||
icon: str
|
|
||||||
status: str
|
|
||||||
phone: int
|
|
||||||
lost_description: str | None
|
|
||||||
find_ip: str | None
|
|
||||||
create_time: str
|
|
||||||
lost_time: str | None
|
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from sqlmodel import select
|
|
||||||
from .setting import Setting
|
from .setting import Setting
|
||||||
from .user import User
|
from .user import User, UserTypeEnum
|
||||||
from pkg import Password
|
from pkg import Password
|
||||||
|
|
||||||
default_settings: list[Setting] = [
|
default_settings: list[Setting] = [
|
||||||
Setting(type='string', name='version', value='1.0.0'),
|
Setting(type='string', name='version', value='2.0.0'), # 版本号,用于考虑是否需要数据迁移
|
||||||
Setting(type='int', name='jwt_token_exp', value='30'),
|
Setting(type='int', name='jwt_token_exp', value='30'), # JWT Token 访问令牌
|
||||||
Setting(type='string', name='server_chan_key', value=''),
|
Setting(type='string', name='server_chan_key', value=''), # Server 酱推送密钥
|
||||||
]
|
]
|
||||||
|
|
||||||
async def migration(session):
|
async def migration(session):
|
||||||
@@ -24,39 +23,32 @@ async def migration(session):
|
|||||||
names = [s.name for s in settings]
|
names = [s.name for s in settings]
|
||||||
existed_settings = await Setting.get(
|
existed_settings = await Setting.get(
|
||||||
session,
|
session,
|
||||||
Setting.name.in_(names),
|
Setting.name in names,
|
||||||
fetch_mode="all"
|
fetch_mode='all'
|
||||||
)
|
)
|
||||||
existed: set[str] = {s.name for s in (existed_settings or [])}
|
existed: set[str] = {s.name for s in (existed_settings or [])}
|
||||||
|
|
||||||
to_insert = [s for s in settings if s.name not in existed]
|
to_insert = [s for s in settings if s.name not in existed]
|
||||||
if to_insert:
|
if to_insert:
|
||||||
await Setting.add(session, to_insert, refresh=False)
|
await Setting.add(session, to_insert)
|
||||||
|
|
||||||
if await User.get(session, User.id == 1):
|
|
||||||
# 已有超级管理员用户,说明不是第一次运行
|
|
||||||
|
|
||||||
# 修复数据库id为1的用户不是管理员的问题
|
|
||||||
admin_user = await User.get(session, User.id == 1)
|
|
||||||
if admin_user and not admin_user.is_admin:
|
|
||||||
admin_user.is_admin = True
|
|
||||||
await User.update(session, admin_user, refresh=False)
|
|
||||||
|
|
||||||
# 已有用户,直接返回
|
|
||||||
return
|
|
||||||
|
|
||||||
|
if not await User.get(session, User.role == UserTypeEnum.super_admin):
|
||||||
# 生成初始密码与密钥
|
# 生成初始密码与密钥
|
||||||
admin_password = Password.generate()
|
admin_password = Password.generate()
|
||||||
logger.warning("当前无管理员用户,已自动创建初始管理员用户:")
|
logger.warning("当前无管理员用户,已自动创建初始管理员用户:")
|
||||||
logger.warning("邮箱: admin@yxqi.cn")
|
logger.warning("邮箱: admin@yxqi.cn")
|
||||||
logger.warning(f"密码: {admin_password}")
|
logger.warning(f"密码: {admin_password}")
|
||||||
|
|
||||||
|
User._initializing = True
|
||||||
|
|
||||||
admin_user = User(
|
admin_user = User(
|
||||||
id=1,
|
|
||||||
email='admin@yxqi.cn',
|
email='admin@yxqi.cn',
|
||||||
username='Admin',
|
username='Admin',
|
||||||
password=Password.hash(admin_password),
|
password=Password.hash(admin_password),
|
||||||
is_admin=True
|
role=UserTypeEnum.super_admin,
|
||||||
|
_initializing=True
|
||||||
)
|
)
|
||||||
|
|
||||||
await User.add(session, admin_user, refresh=False)
|
await User.add(session, admin_user)
|
||||||
|
|
||||||
|
User._initializing = False
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
from typing import Literal, TYPE_CHECKING
|
|
||||||
from sqlmodel import Field, Column, String, DateTime, Relationship
|
|
||||||
from .base import TableBase, IdMixin
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .user import User
|
|
||||||
|
|
||||||
class Object(IdMixin, TableBase, table=True):
|
|
||||||
|
|
||||||
user_id: int = Field(foreign_key="user.id", index=True, nullable=False, description="所属用户ID")
|
|
||||||
key: str = Field(index=True, nullable=False, unique=True, description="物品外部ID")
|
|
||||||
type: Literal['normal', 'car'] = Field(
|
|
||||||
default='normal',
|
|
||||||
description="物品类型",
|
|
||||||
sa_column=Column(
|
|
||||||
String,
|
|
||||||
default='normal',
|
|
||||||
nullable=False
|
|
||||||
)
|
|
||||||
)
|
|
||||||
name: str = Field(nullable=False, description="物品名称")
|
|
||||||
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="联系电话")
|
|
||||||
description: 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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
user: "User" = Relationship(back_populates="objects")
|
|
||||||
@@ -1,20 +1,15 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
class DefaultResponse(BaseModel):
|
class DefaultResponse(BaseModel):
|
||||||
code: int = 0
|
code: int = 0
|
||||||
data: dict | list | bool | None = None
|
data: dict | list | bool | None
|
||||||
msg: str = ""
|
msg: str = ""
|
||||||
|
|
||||||
class ObjectData(BaseModel):
|
# FastAPI 鉴权返回模型
|
||||||
id: int
|
class TokenResponse(BaseModel):
|
||||||
type: Literal['normal', 'car']
|
access_token: str
|
||||||
key: str
|
|
||||||
name: str
|
class TokenData(BaseModel):
|
||||||
icon: str
|
username: str | None = None
|
||||||
status: Literal['ok', 'lost']
|
|
||||||
phone: str
|
|
||||||
context: str | None = None
|
|
||||||
lost_description: str | None = None
|
|
||||||
create_time: str
|
|
||||||
lost_time: str | None = None
|
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
from sqlmodel import Field
|
from sqlmodel import Field
|
||||||
from .base import TableBase
|
from .base import TableBase, SQLModelBase
|
||||||
|
|
||||||
class Setting(TableBase, table=True):
|
|
||||||
|
|
||||||
type: str = Field(index=True, nullable=False, description="设置类型")
|
class SettingBase(SQLModelBase):
|
||||||
name: str = Field(primary_key=True, nullable=False, description="设置名称") # name 为唯一主键
|
type: str = Field(index=True)
|
||||||
value: str | None = Field(description="设置值")
|
"""设置类型"""
|
||||||
|
|
||||||
|
name: str = Field(index=True, unique=True) # name 为唯一主键
|
||||||
|
"""设置名称"""
|
||||||
|
|
||||||
|
value: str | None
|
||||||
|
"""设置值"""
|
||||||
|
|
||||||
|
class Setting(SettingBase, TableBase, table=True):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class SettingResponse(SettingBase):
|
||||||
|
pass
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
# FastAPI 鉴权模型
|
|
||||||
class Token(BaseModel):
|
|
||||||
access_token: str
|
|
||||||
token_type: str
|
|
||||||
|
|
||||||
class TokenData(BaseModel):
|
|
||||||
username: str | None = None
|
|
||||||
@@ -1,16 +1,81 @@
|
|||||||
from typing import TYPE_CHECKING
|
from enum import StrEnum
|
||||||
from sqlmodel import Field, Column, String, Boolean, Relationship
|
from typing import ClassVar
|
||||||
from .base import TableBase, IdMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
import sqlalchemy as sa
|
||||||
from .object import Object
|
from pydantic import EmailStr
|
||||||
|
from sqlalchemy import event
|
||||||
|
from sqlalchemy.orm.session import Session as SessionClass
|
||||||
|
from sqlmodel import Field, Relationship
|
||||||
|
|
||||||
class User(IdMixin, TableBase, table=True):
|
from .base import SQLModelBase, UUIDTableBase
|
||||||
|
from .item import Item
|
||||||
|
|
||||||
email: str = Field(sa_column=Column(String(100), index=True, unique=True))
|
|
||||||
username: str = Field(sa_column=Column(String(50), index=True, unique=True))
|
|
||||||
password: str = Field(sa_column=Column(String(100)))
|
|
||||||
|
|
||||||
is_admin: bool = Field(default=False, sa_column=Column(Boolean, default=False))
|
class UserTypeEnum(StrEnum):
|
||||||
|
normal_user = 'normal_user'
|
||||||
|
admin = 'admin'
|
||||||
|
super_admin = 'super_admin'
|
||||||
|
|
||||||
objects: list["Object"] = Relationship(back_populates="user")
|
class UserBase(SQLModelBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class User(UserBase, UUIDTableBase, table=True):
|
||||||
|
email: EmailStr = Field(index=True, unique=True)
|
||||||
|
"""邮箱"""
|
||||||
|
|
||||||
|
username: str = Field(index=True, unique=True)
|
||||||
|
"""用户名"""
|
||||||
|
|
||||||
|
password: str
|
||||||
|
"""Argon2算法哈希后的密码"""
|
||||||
|
|
||||||
|
two_factor_secret: str | None = None
|
||||||
|
"""两步验证的密钥"""
|
||||||
|
|
||||||
|
role: UserTypeEnum = Field(default=UserTypeEnum.normal_user, index=True)
|
||||||
|
"""用户的权限等级"""
|
||||||
|
|
||||||
|
items: list[Item] = Relationship(back_populates='user', cascade_delete=True)
|
||||||
|
"""物品关系"""
|
||||||
|
|
||||||
|
_initializing: ClassVar[bool] = False
|
||||||
|
"""标记当前是否处于初始化阶段,初始化阶段允许创建 super_admin"""
|
||||||
|
|
||||||
|
@event.listens_for(SessionClass, "before_flush")
|
||||||
|
def check_super_admin_immutability(session, flush_context, instances):
|
||||||
|
"""
|
||||||
|
在事务刷新到数据库前,集中检查所有关于 super_admin 的不合法操作。
|
||||||
|
此监听器确保超级管理员的角色和存在性是不可变的。
|
||||||
|
"""
|
||||||
|
# 检查1: 禁止创建新的 super_admin
|
||||||
|
for obj in session.new:
|
||||||
|
if isinstance(obj, User) and obj.role == UserTypeEnum.super_admin and not User._initializing:
|
||||||
|
raise ValueError("业务规则:不允许创建新的超级管理员。")
|
||||||
|
|
||||||
|
# 检查2: 禁止删除已存在的 super_admin
|
||||||
|
for obj in session.deleted:
|
||||||
|
if isinstance(obj, User):
|
||||||
|
state = sa.inspect(obj)
|
||||||
|
# 直接从对象被删除前的状态获取角色,避免不必要的 lazy load
|
||||||
|
original_role = state.committed_state.get('role')
|
||||||
|
if original_role == UserTypeEnum.super_admin:
|
||||||
|
username = state.committed_state.get('username', f'(ID: {obj.id})')
|
||||||
|
raise ValueError(f"业务规则:不允许删除超级管理员 '{username}'。")
|
||||||
|
|
||||||
|
# 检查3: 禁止与 super_admin 相关的角色变更
|
||||||
|
for obj in session.dirty:
|
||||||
|
if isinstance(obj, User):
|
||||||
|
state = sa.inspect(obj)
|
||||||
|
# 仅在 'role' 字段确实被修改时才进行检查
|
||||||
|
if "role" in state.committed_state:
|
||||||
|
history = state.attrs.role.history
|
||||||
|
original_role = history.deleted[0]
|
||||||
|
new_role = history.added[0]
|
||||||
|
|
||||||
|
# 场景 a: 禁止将 super_admin 降级
|
||||||
|
if original_role == UserTypeEnum.super_admin:
|
||||||
|
raise ValueError(f"业务规则:不允许将超级管理员 '{obj.username}' 的角色降级。")
|
||||||
|
|
||||||
|
# 场景 b: 禁止将任何用户提升为 super_admin
|
||||||
|
if new_role == UserTypeEnum.super_admin:
|
||||||
|
raise ValueError(f"业务规则:不允许将用户 '{obj.username}' 提升为超级管理员。")
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
from .password import Password
|
from .password import Password
|
||||||
|
|
||||||
|
|||||||
2
pkg/sms/smsbao.py
Normal file
2
pkg/sms/smsbao.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
class SmsBao():
|
||||||
|
async def get
|
||||||
75
pkg/utils.py
Normal file
75
pkg/utils.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
from typing import Any, NoReturn, TYPE_CHECKING
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from starlette.status import (
|
||||||
|
HTTP_400_BAD_REQUEST,
|
||||||
|
HTTP_401_UNAUTHORIZED,
|
||||||
|
HTTP_403_FORBIDDEN,
|
||||||
|
HTTP_404_NOT_FOUND,
|
||||||
|
HTTP_409_CONFLICT,
|
||||||
|
HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
HTTP_501_NOT_IMPLEMENTED,
|
||||||
|
HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
HTTP_504_GATEWAY_TIMEOUT, HTTP_402_PAYMENT_REQUIRED,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
# --- Request and Response Helpers ---
|
||||||
|
|
||||||
|
def ensure_request_param(to_check: Any, detail: str) -> None:
|
||||||
|
"""
|
||||||
|
Ensures a parameter exists. If not, raises a 400 Bad Request.
|
||||||
|
This function returns None if the check passes.
|
||||||
|
"""
|
||||||
|
if not to_check:
|
||||||
|
raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail=detail)
|
||||||
|
|
||||||
|
def raise_bad_request(detail: str = '') -> NoReturn:
|
||||||
|
"""Raises an HTTP 400 Bad Request exception."""
|
||||||
|
raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail=detail)
|
||||||
|
|
||||||
|
def raise_not_found(detail: str) -> NoReturn:
|
||||||
|
"""Raises an HTTP 404 Not Found exception."""
|
||||||
|
raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail=detail)
|
||||||
|
|
||||||
|
def raise_internal_error(detail: str = "服务器出现故障,请稍后再试或联系管理员") -> NoReturn:
|
||||||
|
"""Raises an HTTP 500 Internal Server Error exception."""
|
||||||
|
raise HTTPException(status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail=detail)
|
||||||
|
|
||||||
|
def raise_forbidden(detail: str) -> NoReturn:
|
||||||
|
"""Raises an HTTP 403 Forbidden exception."""
|
||||||
|
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail=detail)
|
||||||
|
|
||||||
|
def raise_unauthorized(detail: str) -> NoReturn:
|
||||||
|
"""Raises an HTTP 401 Unauthorized exception."""
|
||||||
|
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=detail)
|
||||||
|
|
||||||
|
def raise_conflict(detail: str) -> NoReturn:
|
||||||
|
"""Raises an HTTP 409 Conflict exception."""
|
||||||
|
raise HTTPException(status_code=HTTP_409_CONFLICT, detail=detail)
|
||||||
|
|
||||||
|
def raise_too_many_requests(detail: str) -> NoReturn:
|
||||||
|
"""Raises an HTTP 429 Too Many Requests exception."""
|
||||||
|
raise HTTPException(status_code=HTTP_429_TOO_MANY_REQUESTS, detail=detail)
|
||||||
|
|
||||||
|
def raise_not_implemented(detail: str = "尚未支持这种方法") -> NoReturn:
|
||||||
|
"""Raises an HTTP 501 Not Implemented exception."""
|
||||||
|
raise HTTPException(status_code=HTTP_501_NOT_IMPLEMENTED, detail=detail)
|
||||||
|
|
||||||
|
def raise_service_unavailable(detail: str) -> NoReturn:
|
||||||
|
"""Raises an HTTP 503 Service Unavailable exception."""
|
||||||
|
raise HTTPException(status_code=HTTP_503_SERVICE_UNAVAILABLE, detail=detail)
|
||||||
|
|
||||||
|
def raise_gateway_timeout(detail: str) -> NoReturn:
|
||||||
|
"""Raises an HTTP 504 Gateway Timeout exception."""
|
||||||
|
raise HTTPException(status_code=HTTP_504_GATEWAY_TIMEOUT, detail=detail)
|
||||||
|
|
||||||
|
def raise_insufficient_quota(detail: str = "积分不足,请充值") -> NoReturn:
|
||||||
|
raise HTTPException(status_code=HTTP_402_PAYMENT_REQUIRED, detail=detail)
|
||||||
|
|
||||||
|
# --- End of Request and Response Helpers ---
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
from fastapi import APIRouter
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from model.response import DefaultResponse
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from middleware.admin import is_admin
|
from middleware.admin import is_admin
|
||||||
|
from model import database, Setting, SettingResponse
|
||||||
|
from model.response import DefaultResponse
|
||||||
|
|
||||||
Router = APIRouter(
|
Router = APIRouter(
|
||||||
prefix='/api/admin',
|
prefix='/api/admin',
|
||||||
@@ -26,3 +29,51 @@ async def verity_admin() -> DefaultResponse:
|
|||||||
- 若不是管理员,抛出 `401` 错误
|
- 若不是管理员,抛出 `401` 错误
|
||||||
'''
|
'''
|
||||||
return DefaultResponse(data=True)
|
return DefaultResponse(data=True)
|
||||||
|
|
||||||
|
@Router.get(
|
||||||
|
path='api/admin/settings',
|
||||||
|
summary='获取设置项',
|
||||||
|
description='获取设置项, 留空则获取所有',
|
||||||
|
response_model=DefaultResponse,
|
||||||
|
response_description='设置项列表'
|
||||||
|
)
|
||||||
|
async def get_settings(
|
||||||
|
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
||||||
|
name: str | None = None
|
||||||
|
) -> DefaultResponse:
|
||||||
|
data = []
|
||||||
|
|
||||||
|
if name:
|
||||||
|
setting = await Setting.get(session, Setting.name == name)
|
||||||
|
if setting:
|
||||||
|
data.append(SettingResponse.model_validate(setting))
|
||||||
|
else:
|
||||||
|
raise HTTPException(404, detail="Setting not found")
|
||||||
|
else:
|
||||||
|
settings = await Setting.get(session, fetch_mode="all")
|
||||||
|
if settings:
|
||||||
|
data = [SettingResponse.model_validate(s) for s in settings]
|
||||||
|
|
||||||
|
return DefaultResponse(data=data)
|
||||||
|
|
||||||
|
|
||||||
|
@Router.put(
|
||||||
|
path='api/admin/settings',
|
||||||
|
summary='更新设置项',
|
||||||
|
description='更新设置项',
|
||||||
|
response_model=DefaultResponse,
|
||||||
|
response_description='更新结果'
|
||||||
|
)
|
||||||
|
async def update_settings(
|
||||||
|
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
||||||
|
name: str,
|
||||||
|
value: str
|
||||||
|
) -> DefaultResponse:
|
||||||
|
setting = await Setting.get(session, Setting.name == name)
|
||||||
|
if not setting:
|
||||||
|
raise HTTPException(404, detail="Setting not found")
|
||||||
|
|
||||||
|
setting.value = value
|
||||||
|
await Setting.save(session)
|
||||||
|
|
||||||
|
return DefaultResponse(data=True)
|
||||||
215
routes/object.py
215
routes/object.py
@@ -1,16 +1,17 @@
|
|||||||
import random
|
from typing import Annotated, Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Query, HTTPException
|
from fastapi import APIRouter, Request, Query, HTTPException
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from loguru import logger
|
||||||
from slowapi import Limiter
|
from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
from model import database, Object, Setting
|
|
||||||
from model import User
|
|
||||||
from model.items import Item
|
|
||||||
from middleware.user import get_current_user
|
|
||||||
from loguru import logger
|
|
||||||
from model.response import DefaultResponse, ObjectData
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from typing import Annotated, Literal
|
|
||||||
|
from middleware.user import get_current_user
|
||||||
|
from model import DefaultResponse, ItemDataResponse, User, database, Setting, Item
|
||||||
|
from model.item import ItemDataUpdateRequest
|
||||||
|
from pkg.utils import raise_not_found, raise_bad_request, raise_internal_error, raise_service_unavailable
|
||||||
|
|
||||||
limiter = Limiter(key_func=get_remote_address)
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
|
||||||
@@ -32,21 +33,21 @@ async def get_items(
|
|||||||
token: Annotated[User, Depends(get_current_user)],
|
token: Annotated[User, Depends(get_current_user)],
|
||||||
id: int | None = Query(default=None, ge=1, description='物品ID'),
|
id: int | None = Query(default=None, ge=1, description='物品ID'),
|
||||||
key: str | None = Query(default=None, description='物品序列号')):
|
key: str | None = Query(default=None, description='物品序列号')):
|
||||||
'''
|
"""
|
||||||
获得物品信息。
|
获得物品信息。
|
||||||
|
|
||||||
不传参数返回所有信息,否则可传入 `id` 或 `key` 进行筛选。
|
不传参数返回所有信息,否则可传入 `id` 或 `key` 进行筛选。
|
||||||
'''
|
"""
|
||||||
|
|
||||||
# 根据条件查询物品,只获取当前用户的物品
|
# 根据条件查询物品,只获取当前用户的物品
|
||||||
if id is not None:
|
if id is not None:
|
||||||
results = await Object.get(session, (Object.id == id) & (Object.user_id == token.id))
|
results = await Item.get(session, (Item.id == id) & (Item.user_id == token.id))
|
||||||
results = [results] if results else []
|
results = [results] if results else []
|
||||||
elif key is not None:
|
elif key is not None:
|
||||||
results = await Object.get(session, (Object.key == key) & (Object.user_id == token.id))
|
results = await Item.get(session, (Item.key == key) & (Item.user_id == token.id))
|
||||||
results = [results] if results else []
|
results = [results] if results else []
|
||||||
else:
|
else:
|
||||||
results = await Object.get(session, Object.user_id == token.id, fetch_mode="all")
|
results = await Item.get(session, Item.user_id == token.id, fetch_mode="all")
|
||||||
|
|
||||||
if results:
|
if results:
|
||||||
items = []
|
items = []
|
||||||
@@ -54,7 +55,7 @@ async def get_items(
|
|||||||
items.append(Item(
|
items.append(Item(
|
||||||
id=obj.id,
|
id=obj.id,
|
||||||
type=obj.type,
|
type=obj.type,
|
||||||
key=obj.key,
|
key=obj.id,
|
||||||
name=obj.name,
|
name=obj.name,
|
||||||
icon=obj.icon or "",
|
icon=obj.icon or "",
|
||||||
status=obj.status or "",
|
status=obj.status or "",
|
||||||
@@ -77,14 +78,10 @@ async def get_items(
|
|||||||
)
|
)
|
||||||
async def add_items(
|
async def add_items(
|
||||||
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
||||||
token: Annotated[User, Depends(get_current_user)],
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
key: str,
|
request: ItemDataUpdateRequest
|
||||||
type: Literal['normal', 'car'],
|
|
||||||
name: str,
|
|
||||||
icon: str,
|
|
||||||
phone: str
|
|
||||||
) -> DefaultResponse:
|
) -> DefaultResponse:
|
||||||
'''
|
"""
|
||||||
添加物品信息。
|
添加物品信息。
|
||||||
|
|
||||||
- **key**: 物品的关键字
|
- **key**: 物品的关键字
|
||||||
@@ -92,20 +89,11 @@ async def add_items(
|
|||||||
- **name**: 物品的名称
|
- **name**: 物品的名称
|
||||||
- **icon**: 物品的图标
|
- **icon**: 物品的图标
|
||||||
- **phone**: 联系电话
|
- **phone**: 联系电话
|
||||||
'''
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 创建新物品对象,关联当前用户
|
# 创建新物品对象,关联当前用户
|
||||||
new_object = Object(
|
await Item.add(session, Item.model_validate(request))
|
||||||
key=key,
|
|
||||||
type=type,
|
|
||||||
name=name,
|
|
||||||
icon=icon,
|
|
||||||
phone=phone,
|
|
||||||
user_id=token.id
|
|
||||||
)
|
|
||||||
# 使用 base.py 中的 add 方法
|
|
||||||
await Object.add(session, new_object)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to add item: {e}")
|
logger.error(f"Failed to add item: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -113,7 +101,7 @@ async def add_items(
|
|||||||
return DefaultResponse(data=True)
|
return DefaultResponse(data=True)
|
||||||
|
|
||||||
@Router.patch(
|
@Router.patch(
|
||||||
path='/items',
|
path='/items/{item_id}',
|
||||||
summary='更新物品信息',
|
summary='更新物品信息',
|
||||||
description='更新现有物品的信息',
|
description='更新现有物品的信息',
|
||||||
response_model=DefaultResponse,
|
response_model=DefaultResponse,
|
||||||
@@ -121,18 +109,11 @@ async def add_items(
|
|||||||
)
|
)
|
||||||
async def update_items(
|
async def update_items(
|
||||||
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
||||||
token: Annotated[User, Depends(get_current_user)],
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
id: int = Query(ge=1),
|
item_id: UUID,
|
||||||
key: str | None = None,
|
request: ItemDataUpdateRequest
|
||||||
name: str | None = None,
|
|
||||||
icon: str | None = None,
|
|
||||||
status: str | None = None,
|
|
||||||
phone: int | None = None,
|
|
||||||
lost_description: str | None = None,
|
|
||||||
find_ip: str | None = None,
|
|
||||||
lost_time: str | None = None
|
|
||||||
) -> DefaultResponse:
|
) -> DefaultResponse:
|
||||||
'''
|
"""
|
||||||
更新物品信息。
|
更新物品信息。
|
||||||
|
|
||||||
只有 `id` 是必填参数,其余参数都是可选的,在不传入任何值的时候将不做任何更改。
|
只有 `id` 是必填参数,其余参数都是可选的,在不传入任何值的时候将不做任何更改。
|
||||||
@@ -147,39 +128,14 @@ async def update_items(
|
|||||||
- **find_ip**: 找到物品的IP
|
- **find_ip**: 找到物品的IP
|
||||||
- **lost_time**: 物品丢失时间
|
- **lost_time**: 物品丢失时间
|
||||||
|
|
||||||
'''
|
"""
|
||||||
try:
|
|
||||||
# 获取现有物品,验证归属权
|
# 获取现有物品,验证归属权
|
||||||
obj = await Object.get(session, (Object.id == id) & (Object.user_id == token.id))
|
obj = await Item.get(session, (Item.id == item_id) & (Item.user_id == user.id))
|
||||||
if not obj:
|
if not obj:
|
||||||
raise HTTPException(status_code=404, detail="Item not found or access denied")
|
raise_not_found("Item not found or access denied")
|
||||||
|
|
||||||
# 更新字段
|
await obj.update(session, request)
|
||||||
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:
|
|
||||||
return DefaultResponse(data=True)
|
return DefaultResponse(data=True)
|
||||||
|
|
||||||
@Router.delete(
|
@Router.delete(
|
||||||
@@ -191,26 +147,20 @@ async def update_items(
|
|||||||
)
|
)
|
||||||
async def delete_items(
|
async def delete_items(
|
||||||
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
||||||
token: Annotated[User, Depends(get_current_user)],
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
id: int
|
id: int
|
||||||
) -> DefaultResponse:
|
) -> DefaultResponse:
|
||||||
'''
|
"""
|
||||||
删除物品信息。
|
删除物品信息。
|
||||||
|
|
||||||
- **id**: 物品的ID
|
- **id**: 物品的ID
|
||||||
'''
|
"""
|
||||||
try:
|
|
||||||
# 获取现有物品,验证归属权
|
# 获取现有物品,验证归属权
|
||||||
obj = await Object.get(session, (Object.id == id) & (Object.user_id == token.id))
|
obj = await Item.get(session, (Item.id == id) & (Item.user_id == user.id))
|
||||||
if not obj:
|
if not obj:
|
||||||
raise HTTPException(status_code=404, detail="Item not found or access denied")
|
raise_not_found("Item not found or access denied")
|
||||||
# 使用 base.py 中的 delete 方法
|
await Item.delete(session, obj)
|
||||||
await Object.delete(session, obj)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
else:
|
|
||||||
return DefaultResponse(data=True)
|
return DefaultResponse(data=True)
|
||||||
|
|
||||||
@Router.get(
|
@Router.get(
|
||||||
@@ -224,47 +174,21 @@ async def get_object(
|
|||||||
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
||||||
item_key: str,
|
item_key: str,
|
||||||
request: Request
|
request: Request
|
||||||
):
|
) -> DefaultResponse:
|
||||||
"""
|
"""
|
||||||
获取物品信息 / Get object information
|
获取物品信息 / Get object information
|
||||||
"""
|
"""
|
||||||
|
object_data = await Item.get(session, Item.key == item_key)
|
||||||
object_data = await Object.get(session, Object.key == item_key)
|
|
||||||
|
|
||||||
if object_data:
|
if object_data:
|
||||||
if object_data.status == 'lost':
|
if object_data.status == 'lost':
|
||||||
# 物品已标记为丢失,更新IP地址
|
# 物品已标记为丢失,更新IP地址
|
||||||
await Object.update(
|
object_data.find_ip = str(request.client.host)
|
||||||
session,
|
object_data = await object_data.save(session)
|
||||||
id=object_data.id,
|
|
||||||
find_ip=str(request.client.host)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 添加一些随机延迟,类似JWT身份验证时根据延迟爆破引发的问题
|
return DefaultResponse(data=ItemDataResponse.model_validate(object_data))
|
||||||
await asyncio.sleep(random.uniform(0.10, 0.30))
|
else:
|
||||||
|
raise_not_found('物品不存在或出现异常')
|
||||||
print(object_data)
|
|
||||||
return DefaultResponse(
|
|
||||||
data=ObjectData(
|
|
||||||
id=object_data.id,
|
|
||||||
type=object_data.type,
|
|
||||||
key=object_data.key,
|
|
||||||
name=object_data.name,
|
|
||||||
icon=object_data.icon,
|
|
||||||
status=object_data.status,
|
|
||||||
phone=object_data.phone,
|
|
||||||
lost_description=object_data.lost_description,
|
|
||||||
create_time=object_data.create_time,
|
|
||||||
lost_time=object_data.lost_time
|
|
||||||
).model_dump()
|
|
||||||
)
|
|
||||||
else: return JSONResponse(
|
|
||||||
status_code=404,
|
|
||||||
content=DefaultResponse(
|
|
||||||
code=404,
|
|
||||||
msg='物品不存在或出现异常'
|
|
||||||
).model_dump()
|
|
||||||
)
|
|
||||||
|
|
||||||
@Router.put(
|
@Router.put(
|
||||||
path='/{item_id}',
|
path='/{item_id}',
|
||||||
@@ -274,7 +198,7 @@ async def get_object(
|
|||||||
response_description="挪车通知结果"
|
response_description="挪车通知结果"
|
||||||
)
|
)
|
||||||
@limiter.limit(
|
@limiter.limit(
|
||||||
limit_value="1/30minute", # 每30分钟允许1次请求
|
limit_value="1/5minute", # 每5分钟允许1次请求
|
||||||
error_message="小主已经通知过车主了,请稍安勿躁~"
|
error_message="小主已经通知过车主了,请稍安勿躁~"
|
||||||
)
|
)
|
||||||
async def notify_move_car(
|
async def notify_move_car(
|
||||||
@@ -283,7 +207,8 @@ async def notify_move_car(
|
|||||||
item_id: int,
|
item_id: int,
|
||||||
phone: str = None,
|
phone: str = None,
|
||||||
):
|
):
|
||||||
"""通知车主进行挪车 / Notify car owner to move the car
|
"""
|
||||||
|
通知车主进行挪车 / Notify car owner to move the car
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
item_id (int): 物品ID / Item ID
|
item_id (int): 物品ID / Item ID
|
||||||
@@ -291,36 +216,18 @@ async def notify_move_car(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# 检查是否存在该物品
|
# 检查是否存在该物品
|
||||||
object_data = await Object.get(session, Object.id == item_id)
|
object_data = await Item.get(session, Item.id == item_id)
|
||||||
if not object_data:
|
if not object_data:
|
||||||
return JSONResponse(
|
raise_not_found()
|
||||||
status_code=404,
|
|
||||||
content=DefaultResponse(
|
|
||||||
code=404,
|
|
||||||
msg='物品不存在或出现异常'
|
|
||||||
).model_dump()
|
|
||||||
)
|
|
||||||
|
|
||||||
# 检查物品类型是否为车辆
|
# 检查物品类型是否为车辆
|
||||||
if object_data.type != 'car':
|
if object_data.type != 'car':
|
||||||
return JSONResponse(
|
raise_bad_request("Item is not car")
|
||||||
status_code=400,
|
|
||||||
content=DefaultResponse(
|
|
||||||
code=400,
|
|
||||||
msg='该物品不是车辆,无法发送挪车通知'
|
|
||||||
).model_dump()
|
|
||||||
)
|
|
||||||
|
|
||||||
# 发起挪车通知(目前仅适配Server酱)
|
# 发起挪车通知(目前仅适配Server酱)
|
||||||
server_chan_key = await Setting.get(session, Setting.name == 'server_chan_key')
|
server_chan_key = await Setting.get(session, Setting.name == 'server_chan_key')
|
||||||
if not server_chan_key:
|
if not server_chan_key:
|
||||||
return JSONResponse(
|
raise_internal_error('未配置Server酱,无法发送挪车通知')
|
||||||
status_code=500,
|
|
||||||
content=DefaultResponse(
|
|
||||||
code=500,
|
|
||||||
msg='未配置Server酱,无法发送挪车通知'
|
|
||||||
).model_dump()
|
|
||||||
)
|
|
||||||
|
|
||||||
title = "挪车通知 - Findreve"
|
title = "挪车通知 - Findreve"
|
||||||
description = f"您的车辆“{object_data.name}”被请求挪车。\n\n"
|
description = f"您的车辆“{object_data.name}”被请求挪车。\n\n"
|
||||||
@@ -342,21 +249,15 @@ async def notify_move_car(
|
|||||||
return DefaultResponse(msg='挪车通知发送成功')
|
return DefaultResponse(msg='挪车通知发送成功')
|
||||||
else:
|
else:
|
||||||
error_msg = resp_json.get('message')
|
error_msg = resp_json.get('message')
|
||||||
logger.error(f"Failed to send notification via Server Chan: error_code={resp_json.get('code')}, error_message={error_msg}, item_id={item_id}, response={resp_json}")
|
logger.error(
|
||||||
return JSONResponse(
|
f"Failed to send notification via Server Chan: error_code={resp_json.get('code')}, "
|
||||||
status_code=500,
|
f"error_message={error_msg}, item_id={item_id}, response={resp_json}"
|
||||||
content=DefaultResponse(
|
|
||||||
code=500,
|
|
||||||
msg=f"挪车通知发送失败,Server酱返回错误:{error_msg}"
|
|
||||||
).model_dump()
|
|
||||||
)
|
)
|
||||||
|
raise_service_unavailable('Server酱出现问题,发送失败')
|
||||||
else:
|
else:
|
||||||
response_text = await resp.text()
|
response_text = await resp.text()
|
||||||
logger.error(f"Failed to send notification via Server Chan: http_status={resp.status}, item_id={item_id}, response_body={response_text}, url={resp.url}")
|
logger.error(
|
||||||
return JSONResponse(
|
f"Failed to send notification via Server Chan: http_status={resp.status}, item_id={item_id}, "
|
||||||
status_code=500,
|
f"response_body={response_text}, url={resp.url}"
|
||||||
content=DefaultResponse(
|
|
||||||
code=500,
|
|
||||||
msg=f"挪车通知发送失败,HTTP状态码:{resp.status}"
|
|
||||||
).model_dump()
|
|
||||||
)
|
)
|
||||||
|
raise_internal_error('挪车通知发送失败')
|
||||||
|
|||||||
@@ -9,18 +9,23 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
|||||||
from pkg import Password
|
from pkg import Password
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from model.token import Token
|
|
||||||
from model import Setting, User, database
|
from model import Setting, User, database
|
||||||
|
from model.response import TokenResponse
|
||||||
|
|
||||||
Router = APIRouter(tags=["令牌 session"])
|
Router = APIRouter(tags=["令牌 session"])
|
||||||
|
|
||||||
# 创建令牌
|
# 创建访问令牌
|
||||||
async def create_access_token(session: AsyncSession, data: dict, expires_delta: timedelta | None = None):
|
async def create_access_token(
|
||||||
|
session: AsyncSession,
|
||||||
|
data: dict,
|
||||||
|
expires_delta: timedelta | None = None
|
||||||
|
):
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
if expires_delta:
|
if expires_delta:
|
||||||
expire = datetime.now(timezone.utc) + expires_delta
|
expire = datetime.now(timezone.utc) + expires_delta
|
||||||
else:
|
else:
|
||||||
expire = datetime.now(timezone.utc) + timedelta(minutes=await Setting.get(session, 'jwt_token_exp'))
|
jwt_exp_setting = await Setting.get(session, Setting.name == 'jwt_token_exp')
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(int(jwt_exp_setting.value))
|
||||||
to_encode.update({"exp": expire})
|
to_encode.update({"exp": expire})
|
||||||
encoded_jwt = jwt.encode(to_encode, key=await JWT.get_secret_key(), algorithm='HS256')
|
encoded_jwt = jwt.encode(to_encode, key=await JWT.get_secret_key(), algorithm='HS256')
|
||||||
return encoded_jwt
|
return encoded_jwt
|
||||||
@@ -45,13 +50,13 @@ async def authenticate_user(session: AsyncSession, username: str, password: str)
|
|||||||
path="/api/token",
|
path="/api/token",
|
||||||
summary="获取访问令牌",
|
summary="获取访问令牌",
|
||||||
description="使用用户名和密码获取访问令牌",
|
description="使用用户名和密码获取访问令牌",
|
||||||
response_model=Token,
|
response_model=TokenResponse,
|
||||||
response_description="访问令牌"
|
response_description="访问令牌"
|
||||||
)
|
)
|
||||||
async def login_for_access_token(
|
async def login_for_access_token(
|
||||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||||
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
||||||
) -> Token:
|
) -> TokenResponse:
|
||||||
user = await authenticate_user(
|
user = await authenticate_user(
|
||||||
session=session,
|
session=session,
|
||||||
username=form_data.username,
|
username=form_data.username,
|
||||||
@@ -63,10 +68,11 @@ async def login_for_access_token(
|
|||||||
detail="Incorrect username or password",
|
detail="Incorrect username or password",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
access_token_expires = timedelta(hours=1)
|
|
||||||
access_token = await create_access_token(
|
access_token = await create_access_token(
|
||||||
session=session,
|
session=session,
|
||||||
data={"sub": form_data.username},
|
data={"sub": user.email},
|
||||||
expires_delta=access_token_expires
|
)
|
||||||
|
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=access_token,
|
||||||
)
|
)
|
||||||
return Token(access_token=access_token, token_type="bearer")
|
|
||||||
20
routes/site.py
Normal file
20
routes/site.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
from model.response import DefaultResponse
|
||||||
|
from pkg import conf
|
||||||
|
|
||||||
|
Router = APIRouter(prefix='/api/site', tags=['站点 Site'])
|
||||||
|
|
||||||
|
@Router.get(
|
||||||
|
path='/ping',
|
||||||
|
summary='站点健康检查',
|
||||||
|
description='检查站点是否在线',
|
||||||
|
response_model=DefaultResponse,
|
||||||
|
response_description='站点在线'
|
||||||
|
)
|
||||||
|
async def ping():
|
||||||
|
"""
|
||||||
|
站点健康检查接口。
|
||||||
|
|
||||||
|
:return: Findreve 版本号
|
||||||
|
"""
|
||||||
|
return DefaultResponse(data=conf.VERSION)
|
||||||
Reference in New Issue
Block a user