Add notification sender and update item routes

This commit is contained in:
2025-10-06 01:03:52 +08:00
parent cd35c6fbed
commit a71cde7b82
12 changed files with 243 additions and 107 deletions

6
dependencies.py Normal file
View File

@@ -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)]

View File

@@ -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)

View File

@@ -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

View File

@@ -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):

View File

@@ -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 鉴权返回模型

2
pkg/sender/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from .wechat_bot import WeChatBot
from .server_chan import ServerChatBot

42
pkg/sender/server_chan.py Normal file
View File

@@ -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")

102
pkg/sender/wechat_bot.py Normal file
View File

@@ -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")

View File

@@ -1,2 +0,0 @@
class SmsBao():
async def get

View File

@@ -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:

Binary file not shown.

View File

@@ -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'
)