diff --git a/dependencies.py b/dependencies.py new file mode 100644 index 0000000..34b6484 --- /dev/null +++ b/dependencies.py @@ -0,0 +1,6 @@ +from typing import Annotated +from sqlmodel.ext.asyncio.session import AsyncSession +from fastapi import Depends +from model import database + +SessionDep = Annotated[AsyncSession, Depends(database.Database.get_session)] diff --git a/model/base/table_base.py b/model/base/table_base.py index 5478ff0..70854c8 100644 --- a/model/base/table_base.py +++ b/model/base/table_base.py @@ -196,5 +196,6 @@ class UUIDTableBase(TableBase): """override""" @override + @classmethod 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 + return await super().get_exist_one(session, id, load) diff --git a/model/item.py b/model/item.py index d828242..f09d1af 100644 --- a/model/item.py +++ b/model/item.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import StrEnum -from typing import TYPE_CHECKING, Self, Optional +from typing import TYPE_CHECKING, Optional from uuid import UUID from sqlmodel import Field, Relationship @@ -52,12 +52,19 @@ class Item(ItemBase, UUIDTableBase, table=True): user: 'User' = Relationship(back_populates='items') - parent_item_id: UUID | None = Field(foreign_key='item.id', ondelete='RESTRICT') + parent_item_id: UUID | None = Field(foreign_key='item.id', ondelete='RESTRICT', default=None) 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 + type: ItemTypeEnum | None = None + """物品的类型""" + + name: str | None = None + """物品名称""" + + status: ItemStatusEnum | None = None + """物品状态""" class ItemDataResponse(ItemBase): expires_at: datetime | None = None diff --git a/model/migration.py b/model/migration.py index 451a8a8..6665529 100644 --- a/model/migration.py +++ b/model/migration.py @@ -4,9 +4,11 @@ from .user import User, UserTypeEnum from pkg import Password default_settings: list[Setting] = [ - Setting(type='string', name='version', value='2.0.0'), # 版本号,用于考虑是否需要数据迁移 - Setting(type='int', name='jwt_token_exp', value='30'), # JWT Token 访问令牌 - Setting(type='string', name='server_chan_key', value=''), # Server 酱推送密钥 + Setting(type='string', name='version', value='2.0.0'), # 版本号,用于考虑是否需要数据迁移 + Setting(type='int', name='jwt_token_exp', value='30'), # JWT Token 访问令牌 + Setting(type='int', name='mentioned_channel', value='wechat_bot'), # 通知推送通道 + Setting(type='string', name='server_chan_key', value=''), # Server 酱推送密钥 + Setting(type='string', name='wechat_bot_key', value=''), # 企业微信机器人推送密钥 ] async def migration(session): diff --git a/model/response.py b/model/response.py index b54e8ac..f4ea8e3 100644 --- a/model/response.py +++ b/model/response.py @@ -1,10 +1,11 @@ -from datetime import datetime - from pydantic import BaseModel +from model.base import SQLModelBase + + class DefaultResponse(BaseModel): code: int = 0 - data: dict | list | bool | None + data: dict | list | bool | SQLModelBase | None = None msg: str = "" # FastAPI 鉴权返回模型 diff --git a/pkg/sender/__init__.py b/pkg/sender/__init__.py new file mode 100644 index 0000000..ea6d501 --- /dev/null +++ b/pkg/sender/__init__.py @@ -0,0 +1,2 @@ +from .wechat_bot import WeChatBot +from .server_chan import ServerChatBot \ No newline at end of file diff --git a/pkg/sender/server_chan.py b/pkg/sender/server_chan.py new file mode 100644 index 0000000..57defb1 --- /dev/null +++ b/pkg/sender/server_chan.py @@ -0,0 +1,42 @@ +from typing import Literal +from loguru import logger +from model import Setting +from sqlmodel.ext.asyncio.session import AsyncSession +from pkg.utils import raise_internal_error, raise_service_unavailable +import aiohttp + +class ServerChatBot: + async def get_url(session: AsyncSession): + server_chan_key = await Setting.get(session, Setting.name == "server_chan_key") + + if not server_chan_key.value: + raise_internal_error("Server酱未配置,请联系管理员") + + url = f"https://sctapi.ftqq.com/{server_chan_key.value}.send" + return url + + async def send_text( + session: AsyncSession, + title: str, + description: str, + ) -> None: + """发送的 Markdown 消息。 + + Args: + session (AsyncSession): 数据库会话 + title (str): 需要发送的标题 + description (str): 需要发送的文本消息 + """ + async with aiohttp.ClientSession() as http_session: + async with http_session.post( + url=await ServerChatBot.get_url(session), + data={ + "title": title, + "desp": description + } + ) as response: + if response.status != 200: + logger.error(f"Failed to send to Server Chan: {response.status}") + raise_internal_error("Server酱服务不可用,请稍后再试") + else: + logger.info("Server Chan message sent successfully") \ No newline at end of file diff --git a/pkg/sender/wechat_bot.py b/pkg/sender/wechat_bot.py new file mode 100644 index 0000000..3b7b726 --- /dev/null +++ b/pkg/sender/wechat_bot.py @@ -0,0 +1,102 @@ +from typing import Literal +from loguru import logger +from model import Setting +from sqlmodel.ext.asyncio.session import AsyncSession +from pkg.utils import raise_internal_error, raise_service_unavailable +import aiohttp + +webhook_url = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send" + +class WeChatBot: + async def get_key(session: AsyncSession): + key = await Setting.get(session, Setting.name == "wechat_bot_key") + + if not key.value: + raise_internal_error("企业微信机器人未配置,请联系管理员") + return key.value + + async def send_text( + session: AsyncSession, + text: str, + mentioned_all: bool = False, + mentioned_list: list[str] = [], + mentioned_mobile_list: list[str] = [] + ) -> None: + """发送文本类型的消息。 + + Args: + session (AsyncSession): 数据库会话 + text (str): 需要发送的文本消息 + mentioned_all (bool, optional): 是否提及所有人 Defaults to False. + mentioned_list (list[str], optional): 提及的用户列表 Defaults to []. + mentioned_mobile_list (list[str], optional): 提及的手机号码列表 Defaults to []. + """ + key = await WeChatBot.get_key(session) + + async with aiohttp.ClientSession() as http_session: + async with http_session.post( + url=f"{webhook_url}?key={key}", + json={ + "msgtype": "text", + "text": { + "content": text + }, + "mentioned_list": ["@all"] if mentioned_all else mentioned_list, + "mentioned_mobile_list": ["@all"] if mentioned_all else mentioned_mobile_list + } + ) as response: + if response.status != 200: + logger.error(f"Failed to send WeChat message: {response.status}") + raise_internal_error("企业微信机器人服务不可用,请稍后再试") + else: + resp_json = await response.json() + if resp_json.get("errcode") != 0: + logger.error(f"WeChat API error: {resp_json.get('errmsg')}") + raise_service_unavailable("发送企业微信消息失败,请稍后再试或联系管理员") + else: + logger.info("WeChat message sent successfully") + + async def send_markdown( + session: AsyncSession, + markdown: str, + version: Literal['v1', 'v2'], + mentioned_all: bool = False, + mentioned_list: list[str] = [], + mentioned_mobile_list: list[str] = [] + ) -> None: + key = await WeChatBot.get_key(session) + + if version == 'v1': + payload = { + "msgtype": "markdown", + "markdown": { + "content": markdown, + "mentioned_list": ["@all"] if mentioned_all else mentioned_list, + "mentioned_mobile_list": ["@all"] if mentioned_all else mentioned_mobile_list + } + } + elif version == 'v2': + payload = { + "msgtype": "markdown_v2", + "markdown_v2": { + "content": markdown, + "mentioned_list": ["@all"] if mentioned_all else mentioned_list, + "mentioned_mobile_list": ["@all"] if mentioned_all else mentioned_mobile_list + } + } + + async with aiohttp.ClientSession() as http_session: + async with http_session.post( + url=f"{webhook_url}?key={key}", + json=payload + ) as response: + if response.status != 200: + logger.error(f"Failed to send WeChat message: {response.status}") + raise_internal_error("企业微信机器人服务不可用,请稍后再试") + else: + resp_json = await response.json() + if resp_json.get("errcode") != 0: + logger.error(f"WeChat API error: {resp_json.get('errmsg')}") + raise_service_unavailable("发送企业微信消息失败,请稍后再试或联系管理员") + else: + logger.info("WeChat message sent successfully") \ No newline at end of file diff --git a/pkg/sms/smsbao.py b/pkg/sms/smsbao.py deleted file mode 100644 index f309297..0000000 --- a/pkg/sms/smsbao.py +++ /dev/null @@ -1,2 +0,0 @@ -class SmsBao(): - async def get \ No newline at end of file diff --git a/pkg/utils.py b/pkg/utils.py index 919e123..47a5571 100644 --- a/pkg/utils.py +++ b/pkg/utils.py @@ -1,10 +1,11 @@ -from typing import Any, NoReturn, TYPE_CHECKING +from typing import Any, NoReturn from fastapi import HTTPException from starlette.status import ( HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED, + HTTP_402_PAYMENT_REQUIRED, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT, @@ -12,13 +13,9 @@ from starlette.status import ( HTTP_500_INTERNAL_SERVER_ERROR, HTTP_501_NOT_IMPLEMENTED, HTTP_503_SERVICE_UNAVAILABLE, - HTTP_504_GATEWAY_TIMEOUT, HTTP_402_PAYMENT_REQUIRED, + HTTP_504_GATEWAY_TIMEOUT, ) -if TYPE_CHECKING: - from sqlmodel.ext.asyncio.session import AsyncSession - - # --- Request and Response Helpers --- def ensure_request_param(to_check: Any, detail: str) -> None: diff --git a/requirements.txt b/requirements.txt index 4e4b562..9c1bf17 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/routes/object.py b/routes/object.py index 3168500..10d44fc 100644 --- a/routes/object.py +++ b/routes/object.py @@ -1,23 +1,24 @@ -from typing import Annotated, Literal +from typing import Annotated 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 sqlalchemy.ext.asyncio import AsyncSession +from dependencies import SessionDep 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 +from model.item import ItemDataUpdateRequest, ItemTypeEnum +from pkg.sender import ServerChatBot, WeChatBot +from pkg.utils import raise_not_found, raise_bad_request, raise_internal_error + +from starlette.status import HTTP_204_NO_CONTENT limiter = Limiter(key_func=get_remote_address) from fastapi import Depends -import asyncio -import aiohttp Router = APIRouter(prefix='/api/object', tags=['物品 Object']) @@ -48,7 +49,7 @@ async def get_items( results = [results] if results else [] else: results = await Item.get(session, Item.user_id == token.id, fetch_mode="all") - + if results: items = [] for obj in results: @@ -59,7 +60,7 @@ async def get_items( name=obj.name, icon=obj.icon or "", status=obj.status or "", - phone=int(obj.phone) if obj.phone and obj.phone.isdigit() else 0, + phone=obj.phone if obj.phone and obj.phone.isdigit() else None, lost_description=obj.description, find_ip=obj.find_ip, create_time=obj.created_at.isoformat(), @@ -73,53 +74,48 @@ async def get_items( path='/items', summary='添加物品信息', description='添加新的物品信息', - response_model=DefaultResponse, + status_code=HTTP_204_NO_CONTENT, response_description='添加物品成功' ) async def add_items( session: Annotated[AsyncSession, Depends(database.Database.get_session)], user: Annotated[User, Depends(get_current_user)], request: ItemDataUpdateRequest -) -> DefaultResponse: +): """ 添加物品信息。 - - - **key**: 物品的关键字 - - **type**: 物品的类型 - - **name**: 物品的名称 - - **icon**: 物品的图标 - - **phone**: 联系电话 """ - try: # 创建新物品对象,关联当前用户 - await Item.add(session, Item.model_validate(request)) + request_dict = request.model_dump() + request_dict['user'] = user + request_dict['user_id'] = user.id + + await Item.add(session, Item.model_validate(request_dict)) 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/{item_id}', summary='更新物品信息', description='更新现有物品的信息', - response_model=DefaultResponse, + status_code=HTTP_204_NO_CONTENT, response_description='更新物品成功' ) async def update_items( session: Annotated[AsyncSession, Depends(database.Database.get_session)], user: Annotated[User, Depends(get_current_user)], item_id: UUID, - request: ItemDataUpdateRequest -) -> DefaultResponse: + request: ItemDataUpdateRequest, +): """ 更新物品信息。 只有 `id` 是必填参数,其余参数都是可选的,在不传入任何值的时候将不做任何更改。 - **id**: 物品的ID - - **key**: 物品的序列号 **不建议修改此项,这样会导致生成的物品二维码直接失效** + - **key**: 物品的序列号 - **name**: 物品的名称 - **icon**: 物品的图标 - **status**: 物品的状态 @@ -134,37 +130,33 @@ async def update_items( if not obj: raise_not_found("Item not found or access denied") - await obj.update(session, request) - - return DefaultResponse(data=True) + await obj.update(session, request, exclude_unset=True) @Router.delete( - path='/items', + path='/items/{item_id}', summary='删除物品信息', description='删除指定的物品信息', - response_model=DefaultResponse, + status_code=HTTP_204_NO_CONTENT, response_description='删除物品成功' ) async def delete_items( session: Annotated[AsyncSession, Depends(database.Database.get_session)], user: Annotated[User, Depends(get_current_user)], - id: int -) -> DefaultResponse: + item_id: UUID +): """ 删除物品信息。 - **id**: 物品的ID """ # 获取现有物品,验证归属权 - obj = await Item.get(session, (Item.id == id) & (Item.user_id == user.id)) + 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 Item.delete(session, obj) - return DefaultResponse(data=True) - @Router.get( - path='/{item_key}', + path='/{item_id}', summary="获取物品信息", description="根据物品键获取物品信息", response_model=DefaultResponse, @@ -172,92 +164,78 @@ async def delete_items( ) async def get_object( session: Annotated[AsyncSession, Depends(database.Database.get_session)], - item_key: str, + item_id: UUID, request: Request ) -> DefaultResponse: """ 获取物品信息 / Get object information """ - object_data = await Item.get(session, Item.key == item_key) - + object_data = await Item.get(session, Item.id == item_id) + if object_data: if object_data.status == 'lost': # 物品已标记为丢失,更新IP地址 object_data.find_ip = str(request.client.host) object_data = await object_data.save(session) - return DefaultResponse(data=ItemDataResponse.model_validate(object_data)) + data = ItemDataResponse.model_validate(object_data) + + return DefaultResponse(data=data.model_dump()) else: raise_not_found('物品不存在或出现异常') -@Router.put( - path='/{item_id}', +@Router.post( + path='/{item_id}/notify_move_car', summary="通知车主进行挪车", description="向车主发送挪车通知", - response_model=DefaultResponse, + status_code=HTTP_204_NO_CONTENT, response_description="挪车通知结果" ) -@limiter.limit( - limit_value="1/5minute", # 每5分钟允许1次请求 - error_message="小主已经通知过车主了,请稍安勿躁~" -) async def notify_move_car( request: Request, - session: Annotated[AsyncSession, Depends(database.Database.get_session)], - item_id: int, + session: SessionDep, + item_id: UUID, phone: str = None, ): """ 通知车主进行挪车 / Notify car owner to move the car Args: + request (Request): ... + session (AsyncSession): 数据库会话 / Database session item_id (int): 物品ID / Item ID phone (str): 挪车发起者电话 / Phone number of the person initiating the move. Defaults to None. """ - # 检查是否存在该物品 - object_data = await Item.get(session, Item.id == item_id) - if not object_data: - raise_not_found() - + item_data = await Item.get_exist_one(session=session, id=item_id) + # 检查物品类型是否为车辆 - if object_data.type != 'car': + if item_data.type != ItemTypeEnum.car: 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: + wechat_bot_key = await Setting.get(session, Setting.name == 'wechat_bot_key') + if not (server_chan_key.value or wechat_bot_key.value): raise_internal_error('未配置Server酱,无法发送挪车通知') - + title = "挪车通知 - Findreve" - 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.value}.send", - data={ - "title": title, - "desp": description - } - ) as resp: - if resp.status == 200: - resp_json = await resp.json() - 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')}, " - 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}, " - f"response_body={response_text}, url={resp.url}" - ) - raise_internal_error('挪车通知发送失败') + description = f"""您的车辆“{item_data.name}”被请求挪车。 +{f"请求挪车者电话:[{phone}](tel:{phone})" if phone else ""} +请尽快联系请求者并挪车。""" + + # 获取通知的方式 + mentioned_channel = (await Setting.get(session, Setting.name == 'mentioned_channel')).value + + if mentioned_channel == 'server_chan': + await ServerChatBot.send_text( + session=session, + title=title, + description=description + ) + elif mentioned_channel == 'wechat_bot': + await WeChatBot.send_markdown( + session=session, + markdown=f"# {title}\n\n{description}", + version='v1' + )