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