迁移服务层
This commit is contained in:
1
.idea/.name
generated
Normal file
1
.idea/.name
generated
Normal file
@@ -0,0 +1 @@
|
|||||||
|
password.py
|
||||||
6
.idea/copilot.data.migration.ask2agent.xml
generated
Normal file
6
.idea/copilot.data.migration.ask2agent.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Ask2AgentMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
51
AGENTS.md
Normal file
51
AGENTS.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# 仓库指南
|
||||||
|
|
||||||
|
## 项目结构与模块
|
||||||
|
- `main.py` 负责使用 `pkg/*` 配置与日志启动 FastAPI,并加载 `app.py` 中定义的应用。
|
||||||
|
- `routes/` 存放核心路由(`admin.py`、`session.py`、`object.py`、`site.py`),新增模块后请在 `app.py` 注册。
|
||||||
|
- `model/` 汇集 SQLModel 表、数据库工具与响应模型;共享字段请复用 `model/base/` 混入。
|
||||||
|
- `middleware/` 管理认证与限流;公共工具位于 `pkg/`;Vue 构建产物保存在 `dist/`,视觉资产位于 `docs/`。
|
||||||
|
|
||||||
|
## 构建、测试与开发
|
||||||
|
- 创建虚拟环境(`python -m venv .venv`)并激活,随后执行 `pip install -r requirements.txt`。
|
||||||
|
- 通过 `python main.py` 启动后端;流程会生成 `.env`、初始化 SQLite `data.db`,`DEBUG=true` 时开启热重载。
|
||||||
|
- 在前端仓库运行 `yarn install && yarn build`,将生成的 `dist/` 拷贝回项目根目录并刷新服务。
|
||||||
|
- 使用 `pytest` 执行自动化检查;重构期间可通过 `-k` 聚焦相关用例。
|
||||||
|
|
||||||
|
## 入职流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
F1[前端开发者入职] --> F2[克隆仓库]
|
||||||
|
F2 --> F3[安装后端依赖<br/>pip install -r requirements.txt]
|
||||||
|
F3 --> F4[构建前端<br/>yarn install && yarn build]
|
||||||
|
F4 --> F5[复制 dist/ 到仓库根目录]
|
||||||
|
F5 --> F6[在浏览器完成冒烟测试]
|
||||||
|
```
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
B1[后端开发者入职] --> B2[克隆仓库]
|
||||||
|
B2 --> B3[创建 .venv 并安装依赖]
|
||||||
|
B3 --> B4[运行 python main.py]
|
||||||
|
B4 --> B5[使用 curl/httpie 访问 /api]
|
||||||
|
B5 --> B6[补充测试并执行 pytest]
|
||||||
|
B6 --> B7[整理发现并提交 PR]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 编码风格与命名
|
||||||
|
- 统一使用 Python 3.8+、四空格缩进,并在公共接口添加类型注解;仅对复杂逻辑补充文档字符串。
|
||||||
|
- 函数使用 `snake_case`,数据模型使用 `PascalCase`,配置与日志归于 `pkg/`(`pkg/logger.py` 封装`loguru`)。
|
||||||
|
- 所有代码、注释、提交信息与评审讨论均使用简体中文。
|
||||||
|
|
||||||
|
## 测试规范
|
||||||
|
- 在 `tests/` 中镜像业务目录(如 `tests/test_session.py`),以 `test_<行为>()` 命名测试函数。
|
||||||
|
- 通过 `pytest` fixture 启动临时 SQLite 数据库,并在 PR 中说明手工验证或覆盖率缺口。
|
||||||
|
|
||||||
|
## 提交与 Pull Request
|
||||||
|
- 提交信息保持简洁的祈使句(例如 `新增通知发送器`),仅在必要时补充作用域。
|
||||||
|
- PR 需关联议题、突出模型或接口变更、列出迁移与测试结果,并附上影响界面的截图或 `curl` 示例。
|
||||||
|
|
||||||
|
## 安全与配置提示
|
||||||
|
- 机密数据仅存放于 `.env`;依赖 `pkg/env.ensure_env_file()` 生成默认值,勿直接修改源代码。
|
||||||
|
- 调整 `middleware/` 或 `routes/` 后须复验认证流程与 SlowAPI 限流,确保防护完整。
|
||||||
@@ -21,6 +21,7 @@ class Password():
|
|||||||
"""
|
"""
|
||||||
return secrets.token_hex(length)
|
return secrets.token_hex(length)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def hash(
|
def hash(
|
||||||
password: str
|
password: str
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -34,6 +35,7 @@ class Password():
|
|||||||
"""
|
"""
|
||||||
return _ph.hash(password)
|
return _ph.hash(password)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def verify(
|
def verify(
|
||||||
stored_password: str,
|
stored_password: str,
|
||||||
provided_password: str,
|
provided_password: str,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi import Depends
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 import database
|
||||||
from model.response import DefaultResponse
|
from model.response import DefaultResponse
|
||||||
|
from services import admin as admin_service
|
||||||
|
|
||||||
Router = APIRouter(
|
Router = APIRouter(
|
||||||
prefix='/api/admin',
|
prefix='/api/admin',
|
||||||
@@ -41,19 +41,7 @@ async def get_settings(
|
|||||||
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
) -> DefaultResponse:
|
) -> DefaultResponse:
|
||||||
data = []
|
data = await admin_service.fetch_settings(session=session, name=name)
|
||||||
|
|
||||||
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)
|
return DefaultResponse(data=data)
|
||||||
|
|
||||||
|
|
||||||
@@ -69,11 +57,5 @@ async def update_settings(
|
|||||||
name: str,
|
name: str,
|
||||||
value: str
|
value: str
|
||||||
) -> DefaultResponse:
|
) -> DefaultResponse:
|
||||||
setting = await Setting.get(session, Setting.name == name)
|
result = await admin_service.update_setting_value(session=session, name=name, value=value)
|
||||||
if not setting:
|
return DefaultResponse(data=result)
|
||||||
raise HTTPException(404, detail="Setting not found")
|
|
||||||
|
|
||||||
setting.value = value
|
|
||||||
await Setting.save(session)
|
|
||||||
|
|
||||||
return DefaultResponse(data=True)
|
|
||||||
|
|||||||
149
routes/object.py
149
routes/object.py
@@ -1,25 +1,20 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Query, HTTPException
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
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 sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from dependencies import SessionDep
|
from dependencies import SessionDep
|
||||||
from middleware.user import get_current_user
|
from middleware.user import get_current_user
|
||||||
from model import DefaultResponse, ItemDataResponse, User, database, Setting, Item
|
from model import DefaultResponse, User, database
|
||||||
from model.item import ItemDataUpdateRequest, ItemTypeEnum
|
from model.item import ItemDataUpdateRequest
|
||||||
from pkg.sender import ServerChatBot, WeChatBot
|
from services import object as object_service
|
||||||
from pkg.utils import raise_not_found, raise_bad_request, raise_internal_error
|
|
||||||
|
|
||||||
from starlette.status import HTTP_204_NO_CONTENT
|
from starlette.status import HTTP_204_NO_CONTENT
|
||||||
|
|
||||||
limiter = Limiter(key_func=get_remote_address)
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
|
||||||
from fastapi import Depends
|
|
||||||
|
|
||||||
Router = APIRouter(prefix='/api/object', tags=['物品 Object'])
|
Router = APIRouter(prefix='/api/object', tags=['物品 Object'])
|
||||||
|
|
||||||
@Router.get(
|
@Router.get(
|
||||||
@@ -39,36 +34,13 @@ async def get_items(
|
|||||||
|
|
||||||
不传参数返回所有信息,否则可传入 `id` 或 `key` 进行筛选。
|
不传参数返回所有信息,否则可传入 `id` 或 `key` 进行筛选。
|
||||||
"""
|
"""
|
||||||
|
items = await object_service.list_items(
|
||||||
# 根据条件查询物品,只获取当前用户的物品
|
session=session,
|
||||||
if id is not None:
|
user=token,
|
||||||
results = await Item.get(session, (Item.id == id) & (Item.user_id == token.id))
|
item_id=id,
|
||||||
results = [results] if results else []
|
key=key,
|
||||||
elif key is not None:
|
)
|
||||||
results = await Item.get(session, (Item.key == key) & (Item.user_id == token.id))
|
return DefaultResponse(data=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:
|
|
||||||
items.append(Item(
|
|
||||||
id=obj.id,
|
|
||||||
type=obj.type,
|
|
||||||
key=obj.id,
|
|
||||||
name=obj.name,
|
|
||||||
icon=obj.icon or "",
|
|
||||||
status=obj.status or "",
|
|
||||||
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(),
|
|
||||||
lost_time=obj.lost_at.isoformat() if obj.lost_at else None
|
|
||||||
))
|
|
||||||
return DefaultResponse(data=items)
|
|
||||||
else:
|
|
||||||
return DefaultResponse(data=[])
|
|
||||||
|
|
||||||
@Router.post(
|
@Router.post(
|
||||||
path='/items',
|
path='/items',
|
||||||
@@ -85,16 +57,11 @@ async def add_items(
|
|||||||
"""
|
"""
|
||||||
添加物品信息。
|
添加物品信息。
|
||||||
"""
|
"""
|
||||||
try:
|
await object_service.create_item(
|
||||||
# 创建新物品对象,关联当前用户
|
session=session,
|
||||||
request_dict = request.model_dump()
|
user=user,
|
||||||
request_dict['user'] = user
|
request=request,
|
||||||
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))
|
|
||||||
|
|
||||||
@Router.patch(
|
@Router.patch(
|
||||||
path='/items/{item_id}',
|
path='/items/{item_id}',
|
||||||
@@ -123,14 +90,14 @@ async def update_items(
|
|||||||
- **lost_description**: 物品丢失描述
|
- **lost_description**: 物品丢失描述
|
||||||
- **find_ip**: 找到物品的IP
|
- **find_ip**: 找到物品的IP
|
||||||
- **lost_time**: 物品丢失时间
|
- **lost_time**: 物品丢失时间
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# 获取现有物品,验证归属权
|
|
||||||
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, exclude_unset=True)
|
await object_service.update_item(
|
||||||
|
session=session,
|
||||||
|
user=user,
|
||||||
|
item_id=item_id,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
@Router.delete(
|
@Router.delete(
|
||||||
path='/items/{item_id}',
|
path='/items/{item_id}',
|
||||||
@@ -146,14 +113,13 @@ async def delete_items(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
删除物品信息。
|
删除物品信息。
|
||||||
|
|
||||||
- **id**: 物品的ID
|
- **id**: 物品的ID
|
||||||
"""
|
"""
|
||||||
# 获取现有物品,验证归属权
|
await object_service.delete_item(
|
||||||
obj = await Item.get(session, (Item.id == item_id) & (Item.user_id == user.id))
|
session=session,
|
||||||
if not obj:
|
user=user,
|
||||||
raise_not_found("Item not found or access denied")
|
item_id=item_id,
|
||||||
await Item.delete(session, obj)
|
)
|
||||||
|
|
||||||
@Router.get(
|
@Router.get(
|
||||||
path='/{item_id}',
|
path='/{item_id}',
|
||||||
@@ -170,19 +136,12 @@ async def get_object(
|
|||||||
"""
|
"""
|
||||||
获取物品信息 / Get object information
|
获取物品信息 / Get object information
|
||||||
"""
|
"""
|
||||||
object_data = await Item.get(session, Item.id == item_id)
|
data = await object_service.retrieve_object(
|
||||||
|
session=session,
|
||||||
if object_data:
|
item_id=item_id,
|
||||||
if object_data.status == 'lost':
|
client_host=str(request.client.host),
|
||||||
# 物品已标记为丢失,更新IP地址
|
)
|
||||||
object_data.find_ip = str(request.client.host)
|
return DefaultResponse(data=data.model_dump())
|
||||||
object_data = await object_data.save(session)
|
|
||||||
|
|
||||||
data = ItemDataResponse.model_validate(object_data)
|
|
||||||
|
|
||||||
return DefaultResponse(data=data.model_dump())
|
|
||||||
else:
|
|
||||||
raise_not_found('物品不存在或出现异常')
|
|
||||||
|
|
||||||
@Router.post(
|
@Router.post(
|
||||||
path='/{item_id}/notify_move_car',
|
path='/{item_id}/notify_move_car',
|
||||||
@@ -192,7 +151,7 @@ async def get_object(
|
|||||||
response_description="挪车通知结果"
|
response_description="挪车通知结果"
|
||||||
)
|
)
|
||||||
async def notify_move_car(
|
async def notify_move_car(
|
||||||
request: Request,
|
_request: Request,
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
item_id: UUID,
|
item_id: UUID,
|
||||||
phone: str = None,
|
phone: str = None,
|
||||||
@@ -201,41 +160,13 @@ async def notify_move_car(
|
|||||||
通知车主进行挪车 / Notify car owner to move the car
|
通知车主进行挪车 / Notify car owner to move the car
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request (Request): ...
|
_request (Request): ...
|
||||||
session (AsyncSession): 数据库会话 / Database session
|
session (AsyncSession): 数据库会话 / Database session
|
||||||
item_id (int): 物品ID / Item ID
|
item_id (int): 物品ID / Item ID
|
||||||
phone (str): 挪车发起者电话 / Phone number of the person initiating the move. Defaults to None.
|
phone (str): 挪车发起者电话 / Phone number of the person initiating the move. Defaults to None.
|
||||||
"""
|
"""
|
||||||
# 检查是否存在该物品
|
await object_service.notify_move_car(
|
||||||
item_data = await Item.get_exist_one(session=session, id=item_id)
|
session=session,
|
||||||
|
item_id=item_id,
|
||||||
# 检查物品类型是否为车辆
|
phone=phone,
|
||||||
if item_data.type != ItemTypeEnum.car:
|
)
|
||||||
raise_bad_request("Item is not car")
|
|
||||||
|
|
||||||
# 发起挪车通知
|
|
||||||
server_chan_key = await Setting.get(session, Setting.name == '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"""您的车辆“{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'
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,50 +1,16 @@
|
|||||||
# 导入库
|
# 导入库
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from fastapi import Depends, HTTPException
|
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
|
||||||
from fastapi import APIRouter
|
|
||||||
import jwt, JWT
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
from pkg import Password
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from model import Setting, User, database
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from model import database
|
||||||
from model.response import TokenResponse
|
from model.response import TokenResponse
|
||||||
|
from services import session as session_service
|
||||||
|
|
||||||
Router = APIRouter(tags=["令牌 session"])
|
Router = APIRouter(tags=["令牌 session"])
|
||||||
|
|
||||||
# 创建访问令牌
|
|
||||||
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:
|
|
||||||
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
|
|
||||||
|
|
||||||
# 验证账号密码
|
|
||||||
async def authenticate_user(session: AsyncSession, username: str, password: str):
|
|
||||||
# 验证账号和密码
|
|
||||||
account = await User.get(session, User.email == username)
|
|
||||||
|
|
||||||
if not account:
|
|
||||||
logger.error("Account or password not set in settings.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if account.email != username or not Password.verify(account.password, password):
|
|
||||||
logger.error("Invalid username or password.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
return account
|
|
||||||
|
|
||||||
# FastAPI 登录路由 / FastAPI login route
|
# FastAPI 登录路由 / FastAPI login route
|
||||||
@Router.post(
|
@Router.post(
|
||||||
path="/api/token",
|
path="/api/token",
|
||||||
@@ -57,22 +23,16 @@ 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)],
|
||||||
) -> TokenResponse:
|
) -> TokenResponse:
|
||||||
user = await authenticate_user(
|
token_response = await session_service.login_for_access_token(
|
||||||
session=session,
|
session=session,
|
||||||
username=form_data.username,
|
username=form_data.username,
|
||||||
password=form_data.password
|
password=form_data.password,
|
||||||
)
|
)
|
||||||
if not user:
|
if not token_response:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
detail="Incorrect username or password",
|
detail="Incorrect username or password",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
access_token = await create_access_token(
|
|
||||||
session=session,
|
|
||||||
data={"sub": user.email},
|
|
||||||
)
|
|
||||||
|
|
||||||
return TokenResponse(
|
return token_response
|
||||||
access_token=access_token,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from model.response import DefaultResponse
|
from model.response import DefaultResponse
|
||||||
from pkg import conf
|
from services import site as site_service
|
||||||
|
|
||||||
Router = APIRouter(prefix='/api/site', tags=['站点 Site'])
|
Router = APIRouter(prefix='/api/site', tags=['站点 Site'])
|
||||||
|
|
||||||
@@ -17,4 +17,5 @@ async def ping():
|
|||||||
|
|
||||||
:return: Findreve 版本号
|
:return: Findreve 版本号
|
||||||
"""
|
"""
|
||||||
return DefaultResponse(data=conf.VERSION)
|
version = await site_service.get_version()
|
||||||
|
return DefaultResponse(data=version)
|
||||||
|
|||||||
13
services/__init__.py
Normal file
13
services/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""
|
||||||
|
服务层模块聚合。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from . import admin, object, session, site # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"admin",
|
||||||
|
"object",
|
||||||
|
"session",
|
||||||
|
"site",
|
||||||
|
]
|
||||||
52
services/admin.py
Normal file
52
services/admin.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""
|
||||||
|
管理员相关业务逻辑。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Iterable, List
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from model import Setting
|
||||||
|
from model import SettingResponse
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_settings(
|
||||||
|
session: AsyncSession,
|
||||||
|
name: str | None = None,
|
||||||
|
) -> List[SettingResponse]:
|
||||||
|
"""
|
||||||
|
按名称获取设置项,默认返回全部。
|
||||||
|
"""
|
||||||
|
data: list[SettingResponse] = []
|
||||||
|
|
||||||
|
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: Iterable[Setting] | None = await Setting.get(session, fetch_mode="all")
|
||||||
|
if settings:
|
||||||
|
data = [SettingResponse.model_validate(s) for s in settings]
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def update_setting_value(
|
||||||
|
session: AsyncSession,
|
||||||
|
name: str,
|
||||||
|
value: str,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
更新设置项的值。
|
||||||
|
"""
|
||||||
|
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 True
|
||||||
164
services/object.py
Normal file
164
services/object.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
"""
|
||||||
|
物品相关业务逻辑。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from loguru import logger
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from model import Item, ItemDataResponse, Setting, User
|
||||||
|
from model.item import ItemDataUpdateRequest, ItemTypeEnum
|
||||||
|
from pkg.sender import ServerChatBot, WeChatBot
|
||||||
|
from pkg.utils import raise_bad_request, raise_internal_error, raise_not_found
|
||||||
|
from starlette.status import HTTP_204_NO_CONTENT
|
||||||
|
|
||||||
|
|
||||||
|
async def list_items(
|
||||||
|
session: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
item_id: int | None = None,
|
||||||
|
key: str | None = None,
|
||||||
|
) -> List[Item]:
|
||||||
|
"""
|
||||||
|
根据条件获取当前用户的物品列表。
|
||||||
|
"""
|
||||||
|
if item_id is not None:
|
||||||
|
results = await Item.get(session, (Item.id == item_id) & (Item.user_id == user.id))
|
||||||
|
results = [results] if results else []
|
||||||
|
elif key is not None:
|
||||||
|
results = await Item.get(session, (Item.key == key) & (Item.user_id == user.id))
|
||||||
|
results = [results] if results else []
|
||||||
|
else:
|
||||||
|
results = await Item.get(session, Item.user_id == user.id, fetch_mode="all")
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
return []
|
||||||
|
|
||||||
|
items: list[Item] = []
|
||||||
|
for obj in results:
|
||||||
|
items.append(
|
||||||
|
Item(
|
||||||
|
id=obj.id,
|
||||||
|
type=obj.type,
|
||||||
|
key=obj.id,
|
||||||
|
name=obj.name,
|
||||||
|
icon=obj.icon or "",
|
||||||
|
status=obj.status or "",
|
||||||
|
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(),
|
||||||
|
lost_time=obj.lost_at.isoformat() if obj.lost_at else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
async def create_item(
|
||||||
|
session: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
request: ItemDataUpdateRequest,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
创建新的物品信息。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
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 exc: # noqa: BLE001
|
||||||
|
logger.error(f"Failed to add item: {exc}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def update_item(
|
||||||
|
session: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
item_id: UUID,
|
||||||
|
request: ItemDataUpdateRequest,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
更新物品信息。
|
||||||
|
"""
|
||||||
|
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, exclude_unset=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_item(
|
||||||
|
session: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
item_id: UUID,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
删除指定物品。
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
async def retrieve_object(
|
||||||
|
session: AsyncSession,
|
||||||
|
item_id: UUID,
|
||||||
|
client_host: str,
|
||||||
|
) -> ItemDataResponse:
|
||||||
|
"""
|
||||||
|
根据物品 ID 获取物品信息并视情况更新寻找者 IP。
|
||||||
|
"""
|
||||||
|
object_data = await Item.get(session, Item.id == item_id)
|
||||||
|
|
||||||
|
if not object_data:
|
||||||
|
raise_not_found("物品不存在或出现异常")
|
||||||
|
|
||||||
|
if object_data.status == "lost":
|
||||||
|
object_data.find_ip = client_host
|
||||||
|
object_data = await object_data.save(session)
|
||||||
|
|
||||||
|
return ItemDataResponse.model_validate(object_data)
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_move_car(
|
||||||
|
session: AsyncSession,
|
||||||
|
item_id: UUID,
|
||||||
|
phone: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
向车主发送挪车通知。
|
||||||
|
"""
|
||||||
|
item_data = await Item.get_exist_one(session=session, id=item_id)
|
||||||
|
|
||||||
|
if item_data.type != ItemTypeEnum.car:
|
||||||
|
raise_bad_request("Item is not car")
|
||||||
|
|
||||||
|
server_chan_key = await Setting.get(session, Setting.name == "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"您的车辆“{item_data.name}”被请求挪车。\n"
|
||||||
|
f"{f'请求挪车者电话:[{phone}](tel:{phone})' if phone else ''}\n"
|
||||||
|
"请尽快联系请求者并挪车。"
|
||||||
|
)
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
return HTTP_204_NO_CONTENT
|
||||||
74
services/session.py
Normal file
74
services/session.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""
|
||||||
|
会话服务,负责处理登录与令牌生成逻辑。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import JWT
|
||||||
|
import jwt
|
||||||
|
from loguru import logger
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from model import Setting, User
|
||||||
|
from model.response import TokenResponse
|
||||||
|
from pkg import Password
|
||||||
|
|
||||||
|
|
||||||
|
async def create_access_token(
|
||||||
|
session: AsyncSession,
|
||||||
|
data: dict[str, Any],
|
||||||
|
expires_delta: timedelta | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
创建访问令牌。
|
||||||
|
"""
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.now(timezone.utc) + expires_delta
|
||||||
|
else:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate_user(
|
||||||
|
session: AsyncSession,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
) -> User | None:
|
||||||
|
"""
|
||||||
|
验证用户名和密码,返回认证后的用户。
|
||||||
|
"""
|
||||||
|
account = await User.get(session, User.email == username)
|
||||||
|
|
||||||
|
if not account:
|
||||||
|
logger.error("Account or password not set in settings.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if account.email != username or not Password.verify(account.password, password):
|
||||||
|
logger.error("Invalid username or password.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
async def login_for_access_token(
|
||||||
|
session: AsyncSession,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
) -> TokenResponse | None:
|
||||||
|
"""
|
||||||
|
登录并生成访问令牌。
|
||||||
|
"""
|
||||||
|
user = await authenticate_user(session=session, username=username, password=password)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
access_token = await create_access_token(
|
||||||
|
session=session,
|
||||||
|
data={"sub": user.email},
|
||||||
|
)
|
||||||
|
return TokenResponse(access_token=access_token)
|
||||||
12
services/site.py
Normal file
12
services/site.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""
|
||||||
|
站点信息服务。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pkg import conf
|
||||||
|
|
||||||
|
|
||||||
|
async def get_version() -> str:
|
||||||
|
"""
|
||||||
|
返回站点版本信息。
|
||||||
|
"""
|
||||||
|
return conf.VERSION
|
||||||
Reference in New Issue
Block a user