diff --git a/app.py b/app.py index c0d4a7a..93eddb2 100644 --- a/app.py +++ b/app.py @@ -2,6 +2,9 @@ from fastapi import FastAPI from fastapi.responses import FileResponse from fastapi import Request, HTTPException from contextlib import asynccontextmanager +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.util import get_remote_address +from slowapi.errors import RateLimitExceeded from routes import (session, admin, object) import model.database import os, asyncio @@ -30,6 +33,11 @@ app.include_router(admin.Router) app.include_router(session.Router) app.include_router(object.Router) +# 挂载Slowapi限流中间件 +limiter = Limiter(key_func=get_remote_address) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + @app.get("/") def read_root(): if not os.path.exists("dist/index.html"): diff --git a/middleware/admin.py b/middleware/admin.py new file mode 100644 index 0000000..7c344ac --- /dev/null +++ b/middleware/admin.py @@ -0,0 +1,34 @@ +from typing import Annotated, Literal +from fastapi import Depends +from fastapi import HTTPException +import JWT +import jwt +from jwt import InvalidTokenError +from model import database +from sqlmodel.ext.asyncio.session import AsyncSession +from model import User +from .user import get_current_user + +# 验证是否为管理员 +async def is_admin( + token: Annotated[str, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(database.Database.get_session)], +) -> Literal[True]: + ''' + 验证是否为管理员。 + + 使用方法: + >>> APIRouter(dependencies=[Depends(is_admin)]) + ''' + + not_admin_exception = HTTPException( + status_code=403, + detail="Admin access required", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user = await get_current_user(token, session) + if not user.is_admin: + raise not_admin_exception + else: + return True \ No newline at end of file diff --git a/middleware/user.py b/middleware/user.py new file mode 100644 index 0000000..c9e2486 --- /dev/null +++ b/middleware/user.py @@ -0,0 +1,33 @@ +from typing import Annotated, Literal +from fastapi import Depends +from fastapi import HTTPException +import JWT +import jwt +from jwt import InvalidTokenError +from model import database +from sqlmodel.ext.asyncio.session import AsyncSession +from model import User + +# 验证是否为管理员 +async def get_current_user( + token: Annotated[str, Depends(JWT.oauth2_scheme)], + session: Annotated[AsyncSession, Depends(database.Database.get_session)], +) -> User: + ''' + 验证用户身份并返回当前用户信息。 + ''' + not_login_exception = HTTPException( + status_code=401, + detail="Login required", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = jwt.decode(token, await JWT.get_secret_key(), algorithms=[JWT.ALGORITHM]) + username = payload.get("sub") + stored_account = await User.get(session, User.email == username) + if username is None or not stored_account.email == username: + raise not_login_exception + return stored_account + except InvalidTokenError: + raise not_login_exception \ No newline at end of file diff --git a/model/__init__.py b/model/__init__.py index 61d1c24..5b4e3be 100644 --- a/model/__init__.py +++ b/model/__init__.py @@ -1,3 +1,4 @@ from . import token from .setting import Setting -from .object import Object \ No newline at end of file +from .object import Object +from .user import User \ No newline at end of file diff --git a/model/migration.py b/model/migration.py index a57483e..eb711ad 100644 --- a/model/migration.py +++ b/model/migration.py @@ -1,12 +1,13 @@ from loguru import logger from sqlmodel import select from .setting import Setting +from .user import User from pkg import Password default_settings: list[Setting] = [ Setting(type='string', name='version', value='1.0.0'), - Setting(type='int', name='ver', value='1'), - Setting(type='string', name='account', value='admin@yuxiaoqiu.cn'), + Setting(type='int', name='jwt_token_exp', value='30'), + Setting(type='string', name='server_chan_key', value=''), ] async def migration(session): @@ -16,21 +17,46 @@ async def migration(session): if await Setting.get(session, Setting.name == 'version'): # 已有数据,说明不是第一次运行,直接返回 return - - # 生成初始密码与密钥 - admin_password = Password.generate() - logger.warning(f"密码(请牢记,后续不再显示): {admin_password}") - - 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] - exist_stmt = select(Setting.name).where(Setting.name.in_(names)) - exist_rs = await session.exec(exist_stmt) - existed: set[str] = set(exist_rs.all()) + existed_settings = await Setting.get( + session, + Setting.name.in_(names), + fetch_mode="all" + ) + existed: set[str] = {s.name for s in (existed_settings or [])} to_insert = [s for s in settings if s.name not in existed] if to_insert: - # 使用你写好的通用新增方法(是类方法),并传入会话 - await Setting.add(session, to_insert, refresh=False) \ No newline at end of file + await Setting.add(session, to_insert, refresh=False) + + 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 + + # 生成初始密码与密钥 + admin_password = Password.generate() + logger.warning("当前无管理员用户,已自动创建初始管理员用户:") + logger.warning("邮箱: admin@yxqi.cn") + logger.warning(f"密码: {admin_password}") + + admin_user = User( + id=1, + email='admin@yxqi.cn', + username='Admin', + password=Password.hash(admin_password), + is_admin=True + ) + + await User.add(session, admin_user, refresh=False) \ No newline at end of file diff --git a/model/object.py b/model/object.py index 36e98d2..c836530 100644 --- a/model/object.py +++ b/model/object.py @@ -1,28 +1,45 @@ -from typing import Literal -from sqlmodel import Field, Column, String, DateTime +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): - key: str = Field(index=True, nullable=False, description="物品外部ID") + 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) + 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) + sa_column=Column( + String, + default='ok', + nullable=False + ) ) phone: str | None = Field(default=None, description="联系电话") - context: 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) - ) \ No newline at end of file + sa_column=Column( + DateTime, + nullable=True + ) + ) + + user: "User" = Relationship(back_populates="objects") \ No newline at end of file diff --git a/model/user.py b/model/user.py new file mode 100644 index 0000000..ca54bff --- /dev/null +++ b/model/user.py @@ -0,0 +1,16 @@ +from typing import TYPE_CHECKING +from sqlmodel import Field, Column, String, Boolean, Relationship +from .base import TableBase, IdMixin + +if TYPE_CHECKING: + from .object import Object + +class User(IdMixin, TableBase, table=True): + + 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)) + + objects: list["Object"] = Relationship(back_populates="user") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index b1bd31f..4e4b562 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/routes/admin.py b/routes/admin.py index e636e8d..60f7cfd 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -1,44 +1,8 @@ from fastapi import APIRouter -from typing import Annotated, Literal -from fastapi import Depends, Query -from fastapi import HTTPException -import JWT -import jwt -from jwt import InvalidTokenError -from model import database +from fastapi import Depends 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)], -) -> Literal[True]: - ''' - 验证是否为管理员。 - - 使用方法: - >>> APIRouter(dependencies=[Depends(is_admin)]) - ''' - credentials_exception = HTTPException( - status_code=401, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - - try: - payload = jwt.decode(token, await JWT.get_secret_key(), algorithms=[JWT.ALGORITHM]) - username = payload.get("sub") - 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 - except InvalidTokenError: - raise credentials_exception +from middleware.admin import is_admin Router = APIRouter( @@ -61,185 +25,4 @@ async def verity_admin() -> DefaultResponse: - 若为管理员,返回 `True` - 若不是管理员,抛出 `401` 错误 ''' - return DefaultResponse(data=True) - -@Router.get( - path='/items', - summary='获取物品信息', - description='返回物品信息列表', - response_model=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='物品序列号')): - ''' - 获得物品信息。 - - 不传参数返回所有信息,否则可传入 `id` 或 `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: - 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=items) - else: - return DefaultResponse(data=[]) - -@Router.post( - path='/items', - summary='添加物品信息', - description='添加新的物品信息', - response_model=DefaultResponse, - response_description='添加物品成功' -) -async def add_items( - session: Annotated[AsyncSession, Depends(database.Database.get_session)], - key: str, - type: Literal['normal', 'car'], - name: str, - icon: str, - phone: str -) -> DefaultResponse: - ''' - 添加物品信息。 - - - **key**: 物品的关键字 - - **type**: 物品的类型 - - **name**: 物品的名称 - - **icon**: 物品的图标 - - **phone**: 联系电话 - ''' - - try: - # 创建新物品对象 - new_object = Object( - key=key, - type=type, - 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: - return DefaultResponse(data=True) - -@Router.patch( - path='/items', - summary='更新物品信息', - description='更新现有物品的信息', - response_model=DefaultResponse, - 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, - 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: - ''' - 更新物品信息。 - - 只有 `id` 是必填参数,其余参数都是可选的,在不传入任何值的时候将不做任何更改。 - - - **id**: 物品的ID - - **key**: 物品的序列号 **不建议修改此项,这样会导致生成的物品二维码直接失效** - - **name**: 物品的名称 - - **icon**: 物品的图标 - - **status**: 物品的状态 - - **phone**: 联系电话 - - **lost_description**: 物品丢失描述 - - **find_ip**: 找到物品的IP - - **lost_time**: 物品丢失时间 - - ''' - try: - # 获取现有物品 - 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: - return DefaultResponse(data=True) - -@Router.delete( - path='/items', - summary='删除物品信息', - description='删除指定的物品信息', - response_model=DefaultResponse, - response_description='删除物品成功' -) -async def delete_items( - session: Annotated[AsyncSession, Depends(database.Database.get_session)], - id: int) -> DefaultResponse: - ''' - 删除物品信息。 - - - **id**: 物品的ID - ''' - try: - # 获取现有物品 - 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: - return DefaultResponse(data=True) \ No newline at end of file + return DefaultResponse(data=True) \ No newline at end of file diff --git a/routes/object.py b/routes/object.py index 4bda28d..cbb5961 100644 --- a/routes/object.py +++ b/routes/object.py @@ -1,13 +1,218 @@ import random -from fastapi import APIRouter, Request +from fastapi import APIRouter, Request, Query, HTTPException from fastapi.responses import JSONResponse -from model.database import Database +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 + +limiter = Limiter(key_func=get_remote_address) + +from fastapi import Depends import asyncio import aiohttp Router = APIRouter(prefix='/api/object', tags=['物品 Object']) +@Router.get( + path='/items', + summary='获取物品信息', + description='返回物品信息列表', + response_model=DefaultResponse, + response_description='物品信息列表' +) +async def get_items( + session: Annotated[AsyncSession, Depends(database.Database.get_session)], + 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 = [results] if results else [] + elif key is not None: + results = await Object.get(session, (Object.key == key) & (Object.user_id == token.id)) + results = [results] if results else [] + else: + results = await Object.get(session, Object.user_id == token.id, fetch_mode="all") + + 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.description, + 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=items) + else: + return DefaultResponse(data=[]) + +@Router.post( + path='/items', + summary='添加物品信息', + description='添加新的物品信息', + response_model=DefaultResponse, + response_description='添加物品成功' +) +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 +) -> 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) + except Exception as e: + logger.error(f"Failed to add item: {e}") + raise HTTPException(status_code=500, detail=str(e)) + else: + return DefaultResponse(data=True) + +@Router.patch( + path='/items', + summary='更新物品信息', + description='更新现有物品的信息', + response_model=DefaultResponse, + response_description='更新物品成功' +) +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: + ''' + 更新物品信息。 + + 只有 `id` 是必填参数,其余参数都是可选的,在不传入任何值的时候将不做任何更改。 + + - **id**: 物品的ID + - **key**: 物品的序列号 **不建议修改此项,这样会导致生成的物品二维码直接失效** + - **name**: 物品的名称 + - **icon**: 物品的图标 + - **status**: 物品的状态 + - **phone**: 联系电话 + - **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) + +@Router.delete( + path='/items', + summary='删除物品信息', + description='删除指定的物品信息', + response_model=DefaultResponse, + response_description='删除物品成功' +) +async def delete_items( + session: Annotated[AsyncSession, Depends(database.Database.get_session)], + token: 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) + @Router.get( path='/{item_key}', summary="获取物品信息", @@ -15,36 +220,44 @@ Router = APIRouter(prefix='/api/object', tags=['物品 Object']) response_model=DefaultResponse, response_description="物品信息" ) -async def get_object(item_key: str, request: Request): +async def get_object( + session: Annotated[AsyncSession, Depends(database.Database.get_session)], + item_key: str, + request: Request +): """ 获取物品信息 / Get object information """ - - db = Database() - await db.init_db() - object_data = await db.get_object(key=item_key) + + object_data = await Object.get(session, Object.key == item_key) if object_data: - if object_data[5] == 'lost': + if object_data.status == 'lost': # 物品已标记为丢失,更新IP地址 - await db.update_object(id=object_data[0], find_ip=str(request.client.host)) - + await Object.update( + session, + id=object_data.id, + find_ip=str(request.client.host) + ) + # 添加一些随机延迟,类似JWT身份验证时根据延迟爆破引发的问题 await asyncio.sleep(random.uniform(0.10, 0.30)) print(object_data) - return DefaultResponse(data=ObjectData( - id=object_data[0], - type=object_data[1], - key=object_data[2], - name=object_data[3], - icon=object_data[4], - status=object_data[5], - phone=object_data[6], - lost_description=object_data[7], - create_time=object_data[9], - lost_time=object_data[10] - ).model_dump()) + 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( @@ -60,7 +273,13 @@ async def get_object(item_key: str, request: Request): response_model=DefaultResponse, response_description="挪车通知结果" ) +@limiter.limit( + limit_value="1/30minute", # 每30分钟允许1次请求 + error_message="小主已经通知过车主了,请稍安勿躁~" +) async def notify_move_car( + request: Request, + session: Annotated[AsyncSession, Depends(database.Database.get_session)], item_id: int, phone: str = None, ): @@ -70,11 +289,9 @@ async def notify_move_car( item_id (int): 物品ID / Item ID phone (str): 挪车发起者电话 / Phone number of the person initiating the move. Defaults to None. """ - db = Database() - await db.init_db() # 检查是否存在该物品 - object_data = await db.get_object(id=item_id) + object_data = await Object.get(session, Object.id == item_id) if not object_data: return JSONResponse( status_code=404, @@ -85,7 +302,7 @@ async def notify_move_car( ) # 检查物品类型是否为车辆 - if object_data[1] != 'car': + if object_data.type != 'car': return JSONResponse( status_code=400, content=DefaultResponse( @@ -95,7 +312,7 @@ async def notify_move_car( ) # 发起挪车通知(目前仅适配Server酱) - server_chan_key = await db.get_setting('server_chan_key') + server_chan_key = await Setting.get(session, Setting.name == 'server_chan_key') if not server_chan_key: return JSONResponse( status_code=500, @@ -106,14 +323,14 @@ async def notify_move_car( ) title = "挪车通知 - Findreve" - description = f"您的车辆“{object_data[3]}”被请求挪车。\n\n" + description = f"您的车辆“{object_data.name}”被请求挪车。\n\n" if phone: description += f"请求挪车者电话:[{phone}](tel:{phone})\n\n" description += "请尽快联系请求者并挪车。" async with aiohttp.ClientSession() as session: async with session.post( - url=f"https://sctapi.ftqq.com/{server_chan_key}.send", + url=f"https://sctapi.ftqq.com/{server_chan_key.value}.send", data={ "title": title, "desp": description @@ -124,18 +341,22 @@ async def notify_move_car( if resp_json.get('code') == 0: 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酱返回错误:{resp_json.get('message')}" + msg=f"挪车通知发送失败,Server酱返回错误:{error_msg}" ).model_dump() ) 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}" + msg=f"挪车通知发送失败,HTTP状态码:{resp.status}" ).model_dump() ) \ No newline at end of file diff --git a/routes/session.py b/routes/session.py index 5dfcbee..9e68fbd 100644 --- a/routes/session.py +++ b/routes/session.py @@ -10,17 +10,17 @@ from pkg import Password from loguru import logger from model.token import Token -from model import Setting, database +from model import Setting, User, database Router = APIRouter(tags=["令牌 session"]) # 创建令牌 -async def create_access_token(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=15) + expire = datetime.now(timezone.utc) + timedelta(minutes=await Setting.get(session, 'jwt_token_exp')) to_encode.update({"exp": expire}) encoded_jwt = jwt.encode(to_encode, key=await JWT.get_secret_key(), algorithm='HS256') return encoded_jwt @@ -28,18 +28,17 @@ async def create_access_token(data: dict, expires_delta: timedelta | None = None # 验证账号密码 async def authenticate_user(session: AsyncSession, username: str, password: str): # 验证账号和密码 - account = await Setting.get(session, Setting.name == 'account') - stored_password = await Setting.get(session, Setting.name == 'password') + account = await User.get(session, User.email == username) - if not account or not stored_password: + if not account: logger.error("Account or password not set in settings.") return False - if account.value != username or not Password.verify(stored_password.value, password): + if account.email != username or not Password.verify(account.password, password): logger.error("Invalid username or password.") return False - return {'is_authenticated': True} + return account # FastAPI 登录路由 / FastAPI login route @Router.post( @@ -66,6 +65,8 @@ async def login_for_access_token( ) access_token_expires = timedelta(hours=1) access_token = await create_access_token( - data={"sub": form_data.username}, expires_delta=access_token_expires + session=session, + data={"sub": form_data.username}, + expires_delta=access_token_expires ) return Token(access_token=access_token, token_type="bearer") \ No newline at end of file