迁移服务层

This commit is contained in:
2025-10-10 18:11:36 +08:00
parent a71cde7b82
commit 93830c3d03
13 changed files with 437 additions and 188 deletions

1
.idea/.name generated Normal file
View File

@@ -0,0 +1 @@
password.py

View 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
View 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 限流,确保防护完整。

View File

@@ -20,7 +20,8 @@ class Password():
:rtype: str
"""
return secrets.token_hex(length)
@staticmethod
def hash(
password: str
) -> str:
@@ -34,6 +35,7 @@ class Password():
"""
return _ph.hash(password)
@staticmethod
def verify(
stored_password: str,
provided_password: str,

View File

@@ -1,12 +1,12 @@
from typing import Annotated
from fastapi import APIRouter, HTTPException
from fastapi import Depends
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from middleware.admin import is_admin
from model import database, Setting, SettingResponse
from model import database
from model.response import DefaultResponse
from services import admin as admin_service
Router = APIRouter(
prefix='/api/admin',
@@ -41,19 +41,7 @@ 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]
data = await admin_service.fetch_settings(session=session, name=name)
return DefaultResponse(data=data)
@@ -69,11 +57,5 @@ async def update_settings(
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)
result = await admin_service.update_setting_value(session=session, name=name, value=value)
return DefaultResponse(data=result)

View File

@@ -1,25 +1,20 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Request, Query, HTTPException
from loguru import logger
from fastapi import APIRouter, Depends, Query, Request
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, ItemTypeEnum
from pkg.sender import ServerChatBot, WeChatBot
from pkg.utils import raise_not_found, raise_bad_request, raise_internal_error
from model import DefaultResponse, User, database
from model.item import ItemDataUpdateRequest
from services import object as object_service
from starlette.status import HTTP_204_NO_CONTENT
limiter = Limiter(key_func=get_remote_address)
from fastapi import Depends
Router = APIRouter(prefix='/api/object', tags=['物品 Object'])
@Router.get(
@@ -39,36 +34,13 @@ async def get_items(
不传参数返回所有信息,否则可传入 `id` 或 `key` 进行筛选。
"""
# 根据条件查询物品,只获取当前用户的物品
if id is not None:
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 Item.get(session, (Item.key == key) & (Item.user_id == token.id))
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=[])
items = await object_service.list_items(
session=session,
user=token,
item_id=id,
key=key,
)
return DefaultResponse(data=items)
@Router.post(
path='/items',
@@ -85,16 +57,11 @@ async def add_items(
"""
添加物品信息。
"""
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 e:
logger.error(f"Failed to add item: {e}")
raise HTTPException(status_code=500, detail=str(e))
await object_service.create_item(
session=session,
user=user,
request=request,
)
@Router.patch(
path='/items/{item_id}',
@@ -123,14 +90,14 @@ async def update_items(
- **lost_description**: 物品丢失描述
- **find_ip**: 找到物品的IP
- **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(
path='/items/{item_id}',
@@ -146,14 +113,13 @@ async def delete_items(
):
"""
删除物品信息。
- **id**: 物品的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)
await object_service.delete_item(
session=session,
user=user,
item_id=item_id,
)
@Router.get(
path='/{item_id}',
@@ -170,19 +136,12 @@ async def get_object(
"""
获取物品信息 / Get object information
"""
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)
data = ItemDataResponse.model_validate(object_data)
return DefaultResponse(data=data.model_dump())
else:
raise_not_found('物品不存在或出现异常')
data = await object_service.retrieve_object(
session=session,
item_id=item_id,
client_host=str(request.client.host),
)
return DefaultResponse(data=data.model_dump())
@Router.post(
path='/{item_id}/notify_move_car',
@@ -192,7 +151,7 @@ async def get_object(
response_description="挪车通知结果"
)
async def notify_move_car(
request: Request,
_request: Request,
session: SessionDep,
item_id: UUID,
phone: str = None,
@@ -201,41 +160,13 @@ async def notify_move_car(
通知车主进行挪车 / Notify car owner to move the car
Args:
request (Request): ...
_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.
"""
# 检查是否存在该物品
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}”被请求挪车。
{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'
)
await object_service.notify_move_car(
session=session,
item_id=item_id,
phone=phone,
)

View File

@@ -1,50 +1,16 @@
# 导入库
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 services import session as session_service
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
@Router.post(
path="/api/token",
@@ -57,22 +23,16 @@ async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
) -> TokenResponse:
user = await authenticate_user(
session=session,
username=form_data.username,
password=form_data.password
token_response = await session_service.login_for_access_token(
session=session,
username=form_data.username,
password=form_data.password,
)
if not user:
if not token_response:
raise HTTPException(
status_code=401,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = await create_access_token(
session=session,
data={"sub": user.email},
)
return TokenResponse(
access_token=access_token,
)
return token_response

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter
from model.response import DefaultResponse
from pkg import conf
from services import site as site_service
Router = APIRouter(prefix='/api/site', tags=['站点 Site'])
@@ -17,4 +17,5 @@ async def ping():
:return: Findreve 版本号
"""
return DefaultResponse(data=conf.VERSION)
version = await site_service.get_version()
return DefaultResponse(data=version)

13
services/__init__.py Normal file
View File

@@ -0,0 +1,13 @@
"""
服务层模块聚合。
"""
from . import admin, object, session, site # noqa: F401
__all__ = [
"admin",
"object",
"session",
"site",
]

52
services/admin.py Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,12 @@
"""
站点信息服务。
"""
from pkg import conf
async def get_version() -> str:
"""
返回站点版本信息。
"""
return conf.VERSION