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:
@@ -1,9 +1,12 @@
|
||||
from fastapi import APIRouter
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import Depends
|
||||
from model.response import DefaultResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from middleware.admin import is_admin
|
||||
|
||||
from model import database, Setting, SettingResponse
|
||||
from model.response import DefaultResponse
|
||||
|
||||
Router = APIRouter(
|
||||
prefix='/api/admin',
|
||||
@@ -25,4 +28,52 @@ async def verity_admin() -> DefaultResponse:
|
||||
- 若为管理员,返回 `True`
|
||||
- 若不是管理员,抛出 `401` 错误
|
||||
'''
|
||||
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)
|
||||
247
routes/object.py
247
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.responses import JSONResponse
|
||||
from loguru import logger
|
||||
from slowapi import Limiter
|
||||
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 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)
|
||||
|
||||
@@ -32,21 +33,21 @@ async def get_items(
|
||||
token: Annotated[User, Depends(get_current_user)],
|
||||
id: int | None = Query(default=None, ge=1, description='物品ID'),
|
||||
key: str | None = Query(default=None, description='物品序列号')):
|
||||
'''
|
||||
"""
|
||||
获得物品信息。
|
||||
|
||||
|
||||
不传参数返回所有信息,否则可传入 `id` 或 `key` 进行筛选。
|
||||
'''
|
||||
"""
|
||||
|
||||
# 根据条件查询物品,只获取当前用户的物品
|
||||
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 []
|
||||
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 []
|
||||
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:
|
||||
items = []
|
||||
@@ -54,7 +55,7 @@ async def get_items(
|
||||
items.append(Item(
|
||||
id=obj.id,
|
||||
type=obj.type,
|
||||
key=obj.key,
|
||||
key=obj.id,
|
||||
name=obj.name,
|
||||
icon=obj.icon or "",
|
||||
status=obj.status or "",
|
||||
@@ -77,35 +78,22 @@ async def get_items(
|
||||
)
|
||||
async def add_items(
|
||||
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
||||
token: Annotated[User, Depends(get_current_user)],
|
||||
key: str,
|
||||
type: Literal['normal', 'car'],
|
||||
name: str,
|
||||
icon: str,
|
||||
phone: str
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
request: ItemDataUpdateRequest
|
||||
) -> DefaultResponse:
|
||||
'''
|
||||
"""
|
||||
添加物品信息。
|
||||
|
||||
|
||||
- **key**: 物品的关键字
|
||||
- **type**: 物品的类型
|
||||
- **name**: 物品的名称
|
||||
- **icon**: 物品的图标
|
||||
- **phone**: 联系电话
|
||||
'''
|
||||
"""
|
||||
|
||||
try:
|
||||
# 创建新物品对象,关联当前用户
|
||||
new_object = Object(
|
||||
key=key,
|
||||
type=type,
|
||||
name=name,
|
||||
icon=icon,
|
||||
phone=phone,
|
||||
user_id=token.id
|
||||
)
|
||||
# 使用 base.py 中的 add 方法
|
||||
await Object.add(session, new_object)
|
||||
await Item.add(session, Item.model_validate(request))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add item: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -113,7 +101,7 @@ async def add_items(
|
||||
return DefaultResponse(data=True)
|
||||
|
||||
@Router.patch(
|
||||
path='/items',
|
||||
path='/items/{item_id}',
|
||||
summary='更新物品信息',
|
||||
description='更新现有物品的信息',
|
||||
response_model=DefaultResponse,
|
||||
@@ -121,22 +109,15 @@ async def add_items(
|
||||
)
|
||||
async def update_items(
|
||||
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
||||
token: Annotated[User, Depends(get_current_user)],
|
||||
id: int = Query(ge=1),
|
||||
key: str | None = None,
|
||||
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:
|
||||
'''
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
item_id: UUID,
|
||||
request: ItemDataUpdateRequest
|
||||
) -> DefaultResponse:
|
||||
"""
|
||||
更新物品信息。
|
||||
|
||||
|
||||
只有 `id` 是必填参数,其余参数都是可选的,在不传入任何值的时候将不做任何更改。
|
||||
|
||||
|
||||
- **id**: 物品的ID
|
||||
- **key**: 物品的序列号 **不建议修改此项,这样会导致生成的物品二维码直接失效**
|
||||
- **name**: 物品的名称
|
||||
@@ -146,41 +127,16 @@ async def update_items(
|
||||
- **lost_description**: 物品丢失描述
|
||||
- **find_ip**: 找到物品的IP
|
||||
- **lost_time**: 物品丢失时间
|
||||
|
||||
'''
|
||||
try:
|
||||
# 获取现有物品,验证归属权
|
||||
obj = await Object.get(session, (Object.id == id) & (Object.user_id == token.id))
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Item not found or access denied")
|
||||
|
||||
# 更新字段
|
||||
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)
|
||||
|
||||
"""
|
||||
# 获取现有物品,验证归属权
|
||||
obj = await Item.get(session, (Item.id == item_id) & (Item.user_id == user.id))
|
||||
if not obj:
|
||||
raise_not_found("Item not found or access denied")
|
||||
|
||||
await obj.update(session, request)
|
||||
|
||||
return DefaultResponse(data=True)
|
||||
|
||||
@Router.delete(
|
||||
path='/items',
|
||||
@@ -191,27 +147,21 @@ async def update_items(
|
||||
)
|
||||
async def delete_items(
|
||||
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
||||
token: Annotated[User, Depends(get_current_user)],
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
id: int
|
||||
) -> DefaultResponse:
|
||||
'''
|
||||
"""
|
||||
删除物品信息。
|
||||
|
||||
|
||||
- **id**: 物品的ID
|
||||
'''
|
||||
try:
|
||||
# 获取现有物品,验证归属权
|
||||
obj = await Object.get(session, (Object.id == id) & (Object.user_id == token.id))
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Item not found or access denied")
|
||||
# 使用 base.py 中的 delete 方法
|
||||
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)
|
||||
"""
|
||||
# 获取现有物品,验证归属权
|
||||
obj = await Item.get(session, (Item.id == id) & (Item.user_id == user.id))
|
||||
if not obj:
|
||||
raise_not_found("Item not found or access denied")
|
||||
await Item.delete(session, obj)
|
||||
|
||||
return DefaultResponse(data=True)
|
||||
|
||||
@Router.get(
|
||||
path='/{item_key}',
|
||||
@@ -224,47 +174,21 @@ async def get_object(
|
||||
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
||||
item_key: str,
|
||||
request: Request
|
||||
):
|
||||
) -> DefaultResponse:
|
||||
"""
|
||||
获取物品信息 / Get object information
|
||||
"""
|
||||
|
||||
object_data = await Object.get(session, Object.key == item_key)
|
||||
object_data = await Item.get(session, Item.key == item_key)
|
||||
|
||||
if object_data:
|
||||
if object_data.status == 'lost':
|
||||
# 物品已标记为丢失,更新IP地址
|
||||
await Object.update(
|
||||
session,
|
||||
id=object_data.id,
|
||||
find_ip=str(request.client.host)
|
||||
)
|
||||
object_data.find_ip = str(request.client.host)
|
||||
object_data = await object_data.save(session)
|
||||
|
||||
# 添加一些随机延迟,类似JWT身份验证时根据延迟爆破引发的问题
|
||||
await asyncio.sleep(random.uniform(0.10, 0.30))
|
||||
|
||||
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()
|
||||
)
|
||||
return DefaultResponse(data=ItemDataResponse.model_validate(object_data))
|
||||
else:
|
||||
raise_not_found('物品不存在或出现异常')
|
||||
|
||||
@Router.put(
|
||||
path='/{item_id}',
|
||||
@@ -274,7 +198,7 @@ async def get_object(
|
||||
response_description="挪车通知结果"
|
||||
)
|
||||
@limiter.limit(
|
||||
limit_value="1/30minute", # 每30分钟允许1次请求
|
||||
limit_value="1/5minute", # 每5分钟允许1次请求
|
||||
error_message="小主已经通知过车主了,请稍安勿躁~"
|
||||
)
|
||||
async def notify_move_car(
|
||||
@@ -283,7 +207,8 @@ async def notify_move_car(
|
||||
item_id: int,
|
||||
phone: str = None,
|
||||
):
|
||||
"""通知车主进行挪车 / Notify car owner to move the car
|
||||
"""
|
||||
通知车主进行挪车 / Notify car owner to move the car
|
||||
|
||||
Args:
|
||||
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:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=DefaultResponse(
|
||||
code=404,
|
||||
msg='物品不存在或出现异常'
|
||||
).model_dump()
|
||||
)
|
||||
raise_not_found()
|
||||
|
||||
# 检查物品类型是否为车辆
|
||||
if object_data.type != 'car':
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content=DefaultResponse(
|
||||
code=400,
|
||||
msg='该物品不是车辆,无法发送挪车通知'
|
||||
).model_dump()
|
||||
)
|
||||
raise_bad_request("Item is not car")
|
||||
|
||||
# 发起挪车通知(目前仅适配Server酱)
|
||||
server_chan_key = await Setting.get(session, Setting.name == 'server_chan_key')
|
||||
if not server_chan_key:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=DefaultResponse(
|
||||
code=500,
|
||||
msg='未配置Server酱,无法发送挪车通知'
|
||||
).model_dump()
|
||||
)
|
||||
raise_internal_error('未配置Server酱,无法发送挪车通知')
|
||||
|
||||
title = "挪车通知 - Findreve"
|
||||
description = f"您的车辆“{object_data.name}”被请求挪车。\n\n"
|
||||
@@ -342,21 +249,15 @@ async def notify_move_car(
|
||||
return DefaultResponse(msg='挪车通知发送成功')
|
||||
else:
|
||||
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}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=DefaultResponse(
|
||||
code=500,
|
||||
msg=f"挪车通知发送失败,Server酱返回错误:{error_msg}"
|
||||
).model_dump()
|
||||
logger.error(
|
||||
f"Failed to send notification via Server Chan: error_code={resp_json.get('code')}, "
|
||||
f"error_message={error_msg}, item_id={item_id}, response={resp_json}"
|
||||
)
|
||||
raise_service_unavailable('Server酱出现问题,发送失败')
|
||||
else:
|
||||
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}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=DefaultResponse(
|
||||
code=500,
|
||||
msg=f"挪车通知发送失败,HTTP状态码:{resp.status}"
|
||||
).model_dump()
|
||||
)
|
||||
logger.error(
|
||||
f"Failed to send notification via Server Chan: http_status={resp.status}, item_id={item_id}, "
|
||||
f"response_body={response_text}, url={resp.url}"
|
||||
)
|
||||
raise_internal_error('挪车通知发送失败')
|
||||
|
||||
@@ -9,18 +9,23 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from pkg import Password
|
||||
from loguru import logger
|
||||
|
||||
from model.token import Token
|
||||
from model import Setting, User, database
|
||||
from model.response import TokenResponse
|
||||
|
||||
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()
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
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})
|
||||
encoded_jwt = jwt.encode(to_encode, key=await JWT.get_secret_key(), algorithm='HS256')
|
||||
return encoded_jwt
|
||||
@@ -45,13 +50,13 @@ async def authenticate_user(session: AsyncSession, username: str, password: str)
|
||||
path="/api/token",
|
||||
summary="获取访问令牌",
|
||||
description="使用用户名和密码获取访问令牌",
|
||||
response_model=Token,
|
||||
response_model=TokenResponse,
|
||||
response_description="访问令牌"
|
||||
)
|
||||
async def login_for_access_token(
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
||||
) -> Token:
|
||||
) -> TokenResponse:
|
||||
user = await authenticate_user(
|
||||
session=session,
|
||||
username=form_data.username,
|
||||
@@ -63,10 +68,11 @@ async def login_for_access_token(
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
access_token_expires = timedelta(hours=1)
|
||||
access_token = await create_access_token(
|
||||
session=session,
|
||||
data={"sub": form_data.username},
|
||||
expires_delta=access_token_expires
|
||||
data={"sub": user.email},
|
||||
)
|
||||
return Token(access_token=access_token, token_type="bearer")
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
)
|
||||
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