diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..233deed --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +password.py \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 0000000..1f2ea11 --- /dev/null +++ b/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4a23f1a --- /dev/null +++ b/AGENTS.md @@ -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[安装后端依赖
pip install -r requirements.txt] + F3 --> F4[构建前端
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 限流,确保防护完整。 \ No newline at end of file diff --git a/pkg/password.py b/pkg/password.py index 885b1e3..4f302e2 100644 --- a/pkg/password.py +++ b/pkg/password.py @@ -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, diff --git a/routes/admin.py b/routes/admin.py index 134e6a3..1caa81b 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -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) \ No newline at end of file + result = await admin_service.update_setting_value(session=session, name=name, value=value) + return DefaultResponse(data=result) diff --git a/routes/object.py b/routes/object.py index 10d44fc..41c2cdf 100644 --- a/routes/object.py +++ b/routes/object.py @@ -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, + ) diff --git a/routes/session.py b/routes/session.py index 9ef8d8d..63bfb70 100644 --- a/routes/session.py +++ b/routes/session.py @@ -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, - ) \ No newline at end of file + return token_response diff --git a/routes/site.py b/routes/site.py index 88e8a86..bb65ba2 100644 --- a/routes/site.py +++ b/routes/site.py @@ -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) \ No newline at end of file + version = await site_service.get_version() + return DefaultResponse(data=version) diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..ae4a19d --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,13 @@ +""" +服务层模块聚合。 +""" + +from . import admin, object, session, site # noqa: F401 + + +__all__ = [ + "admin", + "object", + "session", + "site", +] diff --git a/services/admin.py b/services/admin.py new file mode 100644 index 0000000..c71c9bc --- /dev/null +++ b/services/admin.py @@ -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 diff --git a/services/object.py b/services/object.py new file mode 100644 index 0000000..1ff7bdb --- /dev/null +++ b/services/object.py @@ -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 diff --git a/services/session.py b/services/session.py new file mode 100644 index 0000000..ce98c23 --- /dev/null +++ b/services/session.py @@ -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) diff --git a/services/site.py b/services/site.py new file mode 100644 index 0000000..ea3345d --- /dev/null +++ b/services/site.py @@ -0,0 +1,12 @@ +""" +站点信息服务。 +""" + +from pkg import conf + + +async def get_version() -> str: + """ + 返回站点版本信息。 + """ + return conf.VERSION