清理项目配置文件,移除不再使用的.idea文件和更新文档中的Python版本要求
This commit is contained in:
8
.idea/.gitignore
generated
vendored
8
.idea/.gitignore
generated
vendored
@@ -1,8 +0,0 @@
|
|||||||
# 默认忽略的文件
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# 基于编辑器的 HTTP 客户端请求
|
|
||||||
/httpRequests/
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
/dataSources/
|
|
||||||
/dataSources.local.xml
|
|
||||||
1
.idea/.name
generated
1
.idea/.name
generated
@@ -1 +0,0 @@
|
|||||||
password.py
|
|
||||||
17
.idea/Findreve.iml
generated
17
.idea/Findreve.iml
generated
@@ -1,17 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="PYTHON_MODULE" version="4">
|
|
||||||
<component name="NewModuleRootManager">
|
|
||||||
<content url="file://$MODULE_DIR$">
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
|
||||||
</content>
|
|
||||||
<orderEntry type="jdk" jdkName="Python 3.12 (Findreve)" jdkType="Python SDK" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
</component>
|
|
||||||
<component name="PyDocumentationSettings">
|
|
||||||
<option name="format" value="PLAIN" />
|
|
||||||
<option name="myDocStringFormat" value="Plain" />
|
|
||||||
</component>
|
|
||||||
<component name="TestRunnerService">
|
|
||||||
<option name="PROJECT_TEST_RUNNER" value="py.test" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
||||||
6
.idea/copilot.data.migration.agent.xml
generated
6
.idea/copilot.data.migration.agent.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="AgentMigrationStateService">
|
|
||||||
<option name="migrationStatus" value="COMPLETED" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/copilot.data.migration.ask.xml
generated
6
.idea/copilot.data.migration.ask.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="AskMigrationStateService">
|
|
||||||
<option name="migrationStatus" value="COMPLETED" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/copilot.data.migration.ask2agent.xml
generated
6
.idea/copilot.data.migration.ask2agent.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Ask2AgentMigrationStateService">
|
|
||||||
<option name="migrationStatus" value="COMPLETED" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/copilot.data.migration.edit.xml
generated
6
.idea/copilot.data.migration.edit.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="EditMigrationStateService">
|
|
||||||
<option name="migrationStatus" value="COMPLETED" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
6
.idea/inspectionProfiles/profiles_settings.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<component name="InspectionProjectProfileManager">
|
|
||||||
<settings>
|
|
||||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
|
||||||
<version value="1.0" />
|
|
||||||
</settings>
|
|
||||||
</component>
|
|
||||||
17
.idea/material_theme_project_new.xml
generated
17
.idea/material_theme_project_new.xml
generated
@@ -1,17 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="MaterialThemeProjectNewConfig">
|
|
||||||
<option name="metadata">
|
|
||||||
<MTProjectMetadataState>
|
|
||||||
<option name="migrated" value="true" />
|
|
||||||
<option name="pristineConfig" value="false" />
|
|
||||||
<option name="userId" value="-7c28e060:19966355718:-7ffa" />
|
|
||||||
</MTProjectMetadataState>
|
|
||||||
</option>
|
|
||||||
<option name="titleBarState">
|
|
||||||
<MTProjectTitleBarConfigState>
|
|
||||||
<option name="overrideColor" value="false" />
|
|
||||||
</MTProjectTitleBarConfigState>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
7
.idea/misc.xml
generated
7
.idea/misc.xml
generated
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Black">
|
|
||||||
<option name="sdkName" value="Python 3.12 (Findreve)" />
|
|
||||||
</component>
|
|
||||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (Findreve)" project-jdk-type="Python SDK" />
|
|
||||||
</project>
|
|
||||||
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectModuleManager">
|
|
||||||
<modules>
|
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/Findreve.iml" filepath="$PROJECT_DIR$/.idea/Findreve.iml" />
|
|
||||||
</modules>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@@ -34,7 +34,7 @@ flowchart TD
|
|||||||
```
|
```
|
||||||
|
|
||||||
## 编码风格与命名
|
## 编码风格与命名
|
||||||
- 统一使用 Python 3.8+、四空格缩进,并在公共接口添加类型注解;仅对复杂逻辑补充文档字符串。
|
- 统一使用 Python 3.13+、四空格缩进,并在公共接口添加类型注解;仅对复杂逻辑补充文档字符串。
|
||||||
- 函数使用 `snake_case`,数据模型使用 `PascalCase`,配置与日志归于 `pkg/`(`pkg/logger.py` 封装`loguru`)。
|
- 函数使用 `snake_case`,数据模型使用 `PascalCase`,配置与日志归于 `pkg/`(`pkg/logger.py` 封装`loguru`)。
|
||||||
- 所有代码、注释、提交信息与评审讨论均使用简体中文。
|
- 所有代码、注释、提交信息与评审讨论均使用简体中文。
|
||||||
|
|
||||||
|
|||||||
@@ -61,18 +61,16 @@ chmod +x ./findreve
|
|||||||
|
|
||||||
启动后, Findreve 会在程序的根目录自动创建 SQLite 数据库,并在
|
启动后, Findreve 会在程序的根目录自动创建 SQLite 数据库,并在
|
||||||
终端显示管理员账号密码。请注意,账号密码仅显示一次,请注意保管。
|
终端显示管理员账号密码。请注意,账号密码仅显示一次,请注意保管。
|
||||||
账号默认为 `admin@yuxiaoqiu.cn`
|
账号默认为 `admin@yxqi.cn`
|
||||||
|
|
||||||
Upon launch, Findreve will create a SQLite database in the project's root directory and
|
Upon launch, Findreve will create a SQLite database in the project's root directory and
|
||||||
display the administrator's account and password in the console.
|
display the administrator's account and password in the console.
|
||||||
|
|
||||||
## 构建
|
## 构建
|
||||||
|
|
||||||
> 当前版本的 Findreve Core 无法正常工作,因为我们正在尝试[重构数据库组件以使用ORM](https://github.com/Findreve/Findreve/issues/8)
|
你需要安装Python 3.13 以上的版本。然后,clone 本仓库到您的服务器并解压,然后安装下面的依赖:
|
||||||
|
|
||||||
你需要安装Python 3.8 以上的版本。然后,clone 本仓库到您的服务器并解压,然后安装下面的依赖:
|
You need to have Python 3.13 or higher installed on your server. Then, clone this repository
|
||||||
|
|
||||||
You need to have Python 3.8 or higher installed on your server. Then, clone this repository
|
|
||||||
to your server and install the required dependencies:
|
to your server and install the required dependencies:
|
||||||
|
|
||||||
> `pip install -r requirements.txt`
|
> `pip install -r requirements.txt`
|
||||||
|
|||||||
13
app.py
13
app.py
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi import Request, HTTPException
|
from fastapi import Request
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
@@ -11,6 +11,7 @@ from routes import (session, admin, object)
|
|||||||
from model.database import Database
|
from model.database import Database
|
||||||
import os
|
import os
|
||||||
import pkg.conf
|
import pkg.conf
|
||||||
|
from pkg import utils
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -54,20 +55,20 @@ app.state.limiter = limiter
|
|||||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def read_root():
|
async def frontend_index():
|
||||||
if not os.path.exists("dist/index.html"):
|
if not os.path.exists("dist/index.html"):
|
||||||
raise HTTPException(status_code=404)
|
utils.raise_not_found("Index not found")
|
||||||
return FileResponse("dist/index.html")
|
return FileResponse("dist/index.html")
|
||||||
|
|
||||||
# 回退路由
|
# 回退路由
|
||||||
@app.get("/{path:path}")
|
@app.get("/{path:path}")
|
||||||
async def serve_spa(request: Request, path: str):
|
async def frontend_path(path: str):
|
||||||
if not os.path.exists("dist/index.html"):
|
if not os.path.exists("dist/index.html"):
|
||||||
raise HTTPException(status_code=404)
|
utils.raise_not_found("Index not found, please build frontend first.")
|
||||||
|
|
||||||
# 排除API路由
|
# 排除API路由
|
||||||
if path.startswith("api/"):
|
if path.startswith("api/"):
|
||||||
raise HTTPException(status_code=404)
|
utils.raise_not_found("API route not found")
|
||||||
|
|
||||||
# 检查是否是静态资源请求
|
# 检查是否是静态资源请求
|
||||||
if path.startswith("assets/") and os.path.exists(f"dist/{path}"):
|
if path.startswith("assets/") and os.path.exists(f"dist/{path}"):
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
from typing import Annotated, Literal
|
from typing import Annotated
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from fastapi import HTTPException
|
|
||||||
import JWT
|
|
||||||
import jwt
|
|
||||||
from jwt import InvalidTokenError
|
|
||||||
from model import database
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
from model import User
|
|
||||||
|
from model.user import UserTypeEnum
|
||||||
from .user import get_current_user
|
from .user import get_current_user
|
||||||
|
from pkg import utils
|
||||||
|
from model import User
|
||||||
|
from model import database
|
||||||
|
|
||||||
# 验证是否为管理员
|
# 验证是否为管理员
|
||||||
async def is_admin(
|
async def is_admin(
|
||||||
token: Annotated[str, Depends(get_current_user)],
|
token: Annotated[str, Depends(get_current_user)],
|
||||||
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
||||||
) -> Literal[True]:
|
) -> User:
|
||||||
'''
|
'''
|
||||||
验证是否为管理员。
|
验证是否为管理员。
|
||||||
|
|
||||||
@@ -21,14 +20,25 @@ async def is_admin(
|
|||||||
>>> APIRouter(dependencies=[Depends(is_admin)])
|
>>> APIRouter(dependencies=[Depends(is_admin)])
|
||||||
'''
|
'''
|
||||||
|
|
||||||
not_admin_exception = HTTPException(
|
user = await get_current_user(token, session)
|
||||||
status_code=403,
|
if user.role == UserTypeEnum.normal_user:
|
||||||
detail="Admin access required",
|
utils.raise_forbidden("Admin access required")
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
else:
|
||||||
)
|
return user
|
||||||
|
|
||||||
|
async def is_super_admin(
|
||||||
|
token: Annotated[str, Depends(is_admin)],
|
||||||
|
session: Annotated[AsyncSession, Depends(database.Database.get_session)],
|
||||||
|
) -> User:
|
||||||
|
'''
|
||||||
|
验证是否为超级管理员。
|
||||||
|
|
||||||
|
使用方法:
|
||||||
|
>>> APIRouter(dependencies=[Depends(is_super_admin)])
|
||||||
|
'''
|
||||||
|
|
||||||
user = await get_current_user(token, session)
|
user = await get_current_user(token, session)
|
||||||
if not user.is_admin:
|
if user.role != UserTypeEnum.super_admin:
|
||||||
raise not_admin_exception
|
utils.raise_forbidden("Super admin access required")
|
||||||
else:
|
else:
|
||||||
return True
|
return user
|
||||||
@@ -2,16 +2,14 @@ from typing import Annotated
|
|||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from fastapi import HTTPException
|
|
||||||
from jwt import InvalidTokenError
|
from jwt import InvalidTokenError
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
import JWT
|
import JWT
|
||||||
from model import User
|
from model import User
|
||||||
from model.database import Database
|
from model.database import Database
|
||||||
|
from pkg import utils
|
||||||
|
|
||||||
|
|
||||||
# 验证是否为管理员
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
token: Annotated[str, Depends(JWT.oauth2_scheme)],
|
token: Annotated[str, Depends(JWT.oauth2_scheme)],
|
||||||
session: Annotated[AsyncSession, Depends(Database.get_session)],
|
session: Annotated[AsyncSession, Depends(Database.get_session)],
|
||||||
@@ -19,18 +17,13 @@ async def get_current_user(
|
|||||||
"""
|
"""
|
||||||
验证用户身份并返回当前用户信息。
|
验证用户身份并返回当前用户信息。
|
||||||
"""
|
"""
|
||||||
not_login_exception = HTTPException(
|
|
||||||
status_code=401,
|
|
||||||
detail="Login required",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, await JWT.get_secret_key(), algorithms=[JWT.ALGORITHM])
|
payload = jwt.decode(token, await JWT.get_secret_key(), algorithms=[JWT.ALGORITHM])
|
||||||
username = payload.get("sub")
|
username = payload.get("sub")
|
||||||
stored_account = await User.get(session, User.email == username)
|
stored_account = await User.get(session, User.email == username)
|
||||||
if username is None or stored_account.email != username:
|
if username is None or stored_account.email != username:
|
||||||
raise not_login_exception
|
utils.raise_unauthorized("Login required")
|
||||||
return stored_account
|
return stored_account
|
||||||
except InvalidTokenError:
|
except InvalidTokenError:
|
||||||
raise not_login_exception
|
utils.raise_unauthorized("Login required")
|
||||||
@@ -3,7 +3,6 @@ from contextlib import asynccontextmanager
|
|||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from datetime import datetime
|
|||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlmodel import Field, Relationship
|
from sqlmodel import Field, Relationship
|
||||||
|
|
||||||
from .base import SQLModelBase, UUIDTableBase
|
from .base import SQLModelBase, UUIDTableBase
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from model.base import SQLModelBase
|
from model.base import SQLModelBase
|
||||||
|
|
||||||
|
"""
|
||||||
|
[TODO] 弃用,改成 ResponseBase:
|
||||||
|
|
||||||
|
class ResponseBase(BaseModel):
|
||||||
|
code: int = 0
|
||||||
|
msg: str = ""
|
||||||
|
request_id: UUID
|
||||||
|
|
||||||
|
再根据需要继承
|
||||||
|
"""
|
||||||
class DefaultResponse(BaseModel):
|
class DefaultResponse(BaseModel):
|
||||||
code: int = 0
|
code: int = 0
|
||||||
data: dict | list | bool | SQLModelBase | None = None
|
data: dict | list | bool | SQLModelBase | None = None
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ class User(UserBase, UUIDTableBase, table=True):
|
|||||||
email: EmailStr = Field(index=True, unique=True)
|
email: EmailStr = Field(index=True, unique=True)
|
||||||
"""邮箱"""
|
"""邮箱"""
|
||||||
|
|
||||||
username: str = Field(index=True, unique=True)
|
nickname: str
|
||||||
"""用户名"""
|
"""昵称"""
|
||||||
|
|
||||||
password: str
|
password: str
|
||||||
"""Argon2算法哈希后的密码"""
|
"""Argon2算法哈希后的密码"""
|
||||||
|
|||||||
@@ -2,14 +2,28 @@ import secrets
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
from argon2.exceptions import VerifyMismatchError
|
from argon2.exceptions import VerifyMismatchError
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
_ph = PasswordHasher()
|
_ph = PasswordHasher()
|
||||||
|
|
||||||
class Password():
|
class PasswordStatus(StrEnum):
|
||||||
|
"""密码校验状态枚举"""
|
||||||
|
|
||||||
|
VALID = "valid"
|
||||||
|
"""密码校验通过"""
|
||||||
|
|
||||||
|
INVALID = "invalid"
|
||||||
|
"""密码校验失败"""
|
||||||
|
|
||||||
|
EXPIRED = "expired"
|
||||||
|
"""密码哈希已过时,建议重新哈希"""
|
||||||
|
|
||||||
|
class Password:
|
||||||
|
"""密码处理工具类,包含密码生成、哈希和验证功能"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate(
|
def generate(
|
||||||
length: int = 8
|
length: int = 8
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
生成指定长度的随机密码。
|
生成指定长度的随机密码。
|
||||||
@@ -23,7 +37,7 @@ class Password():
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def hash(
|
def hash(
|
||||||
password: str
|
password: str
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
使用 Argon2 生成密码的哈希值。
|
使用 Argon2 生成密码的哈希值。
|
||||||
@@ -37,38 +51,29 @@ class Password():
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def verify(
|
def verify(
|
||||||
stored_password: str,
|
hash: str,
|
||||||
provided_password: str,
|
password: str
|
||||||
debug: bool = False
|
) -> PasswordStatus:
|
||||||
) -> bool:
|
|
||||||
"""
|
"""
|
||||||
验证存储的 Argon2 哈希值与用户提供的密码是否匹配。
|
验证存储的 Argon2 哈希值与用户提供的密码是否匹配。
|
||||||
|
|
||||||
:param stored_password: 数据库中存储的 Argon2 哈希字符串
|
:param hash: 数据库中存储的 Argon2 哈希字符串
|
||||||
:param provided_password: 用户本次提供的密码
|
:param password: 用户本次提供的密码
|
||||||
:param debug: 是否输出调试信息
|
|
||||||
:return: 如果密码匹配返回 True, 否则返回 False
|
:return: 如果密码匹配返回 True, 否则返回 False
|
||||||
"""
|
"""
|
||||||
if debug:
|
|
||||||
logger.info(f"验证密码: (哈希) {stored_password}")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# verify 函数会自动解析 stored_password 中的盐和参数
|
# verify 函数会自动解析 stored_password 中的盐和参数
|
||||||
_ph.verify(stored_password, provided_password)
|
_ph.verify(hash, password)
|
||||||
|
|
||||||
# 检查哈希参数是否已过时。如果返回True,
|
# 检查哈希参数是否已过时。如果返回True,
|
||||||
# 意味着你应该使用新的参数重新哈希密码并更新存储。
|
# 意味着你应该使用新的参数重新哈希密码并更新存储。
|
||||||
# 这是一个很好的实践,可以随着时间推移增强安全性。
|
# 这是一个很好的实践,可以随着时间推移增强安全性。
|
||||||
if _ph.check_needs_rehash(stored_password):
|
if _ph.check_needs_rehash(hash):
|
||||||
logger.warning("密码哈希参数已过时,建议重新哈希并更新。")
|
logger.warning("密码哈希参数已过时,建议重新哈希并更新。")
|
||||||
|
return PasswordStatus.EXPIRED
|
||||||
|
|
||||||
return True
|
return PasswordStatus.VALID
|
||||||
except VerifyMismatchError:
|
except VerifyMismatchError:
|
||||||
# 这是预期的异常,当密码不匹配时触发。
|
# 这是预期的异常,当密码不匹配时触发。
|
||||||
if debug:
|
return PasswordStatus.INVALID
|
||||||
logger.info("密码不匹配")
|
# 其他异常(如哈希格式错误)应该传播,让调用方感知系统问题
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
# 捕获其他可能的错误
|
|
||||||
logger.error(f"密码验证过程中发生未知错误: {e}")
|
|
||||||
return False
|
|
||||||
|
|||||||
31
pkg/utils.py
31
pkg/utils.py
@@ -16,7 +16,7 @@ from starlette.status import (
|
|||||||
HTTP_504_GATEWAY_TIMEOUT,
|
HTTP_504_GATEWAY_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Request and Response Helpers ---
|
# --- 400 ---
|
||||||
|
|
||||||
def ensure_request_param(to_check: Any, detail: str) -> None:
|
def ensure_request_param(to_check: Any, detail: str) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -30,21 +30,21 @@ def raise_bad_request(detail: str = '') -> NoReturn:
|
|||||||
"""Raises an HTTP 400 Bad Request exception."""
|
"""Raises an HTTP 400 Bad Request exception."""
|
||||||
raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail=detail)
|
raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail=detail)
|
||||||
|
|
||||||
def raise_not_found(detail: str) -> NoReturn:
|
def raise_unauthorized(detail: str) -> NoReturn:
|
||||||
"""Raises an HTTP 404 Not Found exception."""
|
"""Raises an HTTP 401 Unauthorized exception."""
|
||||||
raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail=detail)
|
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=detail)
|
||||||
|
|
||||||
def raise_internal_error(detail: str = "服务器出现故障,请稍后再试或联系管理员") -> NoReturn:
|
def raise_insufficient_quota(detail: str = "积分不足,请充值") -> NoReturn:
|
||||||
"""Raises an HTTP 500 Internal Server Error exception."""
|
"""Raises an HTTP 402 Payment Required exception."""
|
||||||
raise HTTPException(status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail=detail)
|
raise HTTPException(status_code=HTTP_402_PAYMENT_REQUIRED, detail=detail)
|
||||||
|
|
||||||
def raise_forbidden(detail: str) -> NoReturn:
|
def raise_forbidden(detail: str) -> NoReturn:
|
||||||
"""Raises an HTTP 403 Forbidden exception."""
|
"""Raises an HTTP 403 Forbidden exception."""
|
||||||
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail=detail)
|
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail=detail)
|
||||||
|
|
||||||
def raise_unauthorized(detail: str) -> NoReturn:
|
def raise_not_found(detail: str) -> NoReturn:
|
||||||
"""Raises an HTTP 401 Unauthorized exception."""
|
"""Raises an HTTP 404 Not Found exception."""
|
||||||
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail=detail)
|
raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail=detail)
|
||||||
|
|
||||||
def raise_conflict(detail: str) -> NoReturn:
|
def raise_conflict(detail: str) -> NoReturn:
|
||||||
"""Raises an HTTP 409 Conflict exception."""
|
"""Raises an HTTP 409 Conflict exception."""
|
||||||
@@ -54,6 +54,12 @@ def raise_too_many_requests(detail: str) -> NoReturn:
|
|||||||
"""Raises an HTTP 429 Too Many Requests exception."""
|
"""Raises an HTTP 429 Too Many Requests exception."""
|
||||||
raise HTTPException(status_code=HTTP_429_TOO_MANY_REQUESTS, detail=detail)
|
raise HTTPException(status_code=HTTP_429_TOO_MANY_REQUESTS, detail=detail)
|
||||||
|
|
||||||
|
# --- 500 ---
|
||||||
|
|
||||||
|
def raise_internal_error(detail: str = "服务器出现故障,请稍后再试或联系管理员") -> NoReturn:
|
||||||
|
"""Raises an HTTP 500 Internal Server Error exception."""
|
||||||
|
raise HTTPException(status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail=detail)
|
||||||
|
|
||||||
def raise_not_implemented(detail: str = "尚未支持这种方法") -> NoReturn:
|
def raise_not_implemented(detail: str = "尚未支持这种方法") -> NoReturn:
|
||||||
"""Raises an HTTP 501 Not Implemented exception."""
|
"""Raises an HTTP 501 Not Implemented exception."""
|
||||||
raise HTTPException(status_code=HTTP_501_NOT_IMPLEMENTED, detail=detail)
|
raise HTTPException(status_code=HTTP_501_NOT_IMPLEMENTED, detail=detail)
|
||||||
@@ -65,8 +71,3 @@ def raise_service_unavailable(detail: str) -> NoReturn:
|
|||||||
def raise_gateway_timeout(detail: str) -> NoReturn:
|
def raise_gateway_timeout(detail: str) -> NoReturn:
|
||||||
"""Raises an HTTP 504 Gateway Timeout exception."""
|
"""Raises an HTTP 504 Gateway Timeout exception."""
|
||||||
raise HTTPException(status_code=HTTP_504_GATEWAY_TIMEOUT, detail=detail)
|
raise HTTPException(status_code=HTTP_504_GATEWAY_TIMEOUT, detail=detail)
|
||||||
|
|
||||||
def raise_insufficient_quota(detail: str = "积分不足,请充值") -> NoReturn:
|
|
||||||
raise HTTPException(status_code=HTTP_402_PAYMENT_REQUIRED, detail=detail)
|
|
||||||
|
|
||||||
# --- End of Request and Response Helpers ---
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# 导入库
|
# 导入库
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from model import database
|
from model import database
|
||||||
from model.response import TokenResponse
|
from model.response import TokenResponse
|
||||||
from services import session as session_service
|
from services import session as session_service
|
||||||
|
from pkg import utils
|
||||||
|
|
||||||
Router = APIRouter(tags=["令牌 session"])
|
Router = APIRouter(tags=["令牌 session"])
|
||||||
|
|
||||||
@@ -29,10 +29,6 @@ async def login_for_access_token(
|
|||||||
password=form_data.password,
|
password=form_data.password,
|
||||||
)
|
)
|
||||||
if not token_response:
|
if not token_response:
|
||||||
raise HTTPException(
|
utils.raise_unauthorized("Incorrect username or password")
|
||||||
status_code=401,
|
|
||||||
detail="Incorrect username or password",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
return token_response
|
return token_response
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
|
|
||||||
from typing import Iterable, List
|
from typing import Iterable, List
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from model import Setting
|
from model import Setting
|
||||||
from model import SettingResponse
|
from model import SettingResponse
|
||||||
|
from pkg import utils
|
||||||
|
|
||||||
|
|
||||||
async def fetch_settings(
|
async def fetch_settings(
|
||||||
@@ -25,7 +25,7 @@ async def fetch_settings(
|
|||||||
if setting:
|
if setting:
|
||||||
data.append(SettingResponse.model_validate(setting))
|
data.append(SettingResponse.model_validate(setting))
|
||||||
else:
|
else:
|
||||||
raise HTTPException(404, detail="Setting not found")
|
utils.raise_not_found("Setting not found")
|
||||||
else:
|
else:
|
||||||
settings: Iterable[Setting] | None = await Setting.get(session, fetch_mode="all")
|
settings: Iterable[Setting] | None = await Setting.get(session, fetch_mode="all")
|
||||||
if settings:
|
if settings:
|
||||||
@@ -44,7 +44,7 @@ async def update_setting_value(
|
|||||||
"""
|
"""
|
||||||
setting = await Setting.get(session, Setting.name == name)
|
setting = await Setting.get(session, Setting.name == name)
|
||||||
if not setting:
|
if not setting:
|
||||||
raise HTTPException(404, detail="Setting not found")
|
utils.raise_not_found("Setting not found")
|
||||||
|
|
||||||
setting.value = value
|
setting.value = value
|
||||||
await Setting.save(session)
|
await Setting.save(session)
|
||||||
|
|||||||
@@ -5,15 +5,14 @@
|
|||||||
from typing import List
|
from typing import List
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import status
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from model import Item, ItemDataResponse, Setting, User
|
from model import Item, ItemDataResponse, Setting, User
|
||||||
from model.item import ItemDataUpdateRequest, ItemTypeEnum
|
from model.item import ItemDataUpdateRequest, ItemTypeEnum
|
||||||
from pkg.sender import ServerChatBot, WeChatBot
|
from pkg.sender import ServerChatBot, WeChatBot
|
||||||
from pkg.utils import raise_bad_request, raise_internal_error, raise_not_found
|
from pkg import utils
|
||||||
from starlette.status import HTTP_204_NO_CONTENT
|
|
||||||
|
|
||||||
|
|
||||||
async def list_items(
|
async def list_items(
|
||||||
@@ -72,7 +71,7 @@ async def create_item(
|
|||||||
await Item.add(session, Item.model_validate(request_dict))
|
await Item.add(session, Item.model_validate(request_dict))
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.error(f"Failed to add item: {exc}")
|
logger.error(f"Failed to add item: {exc}")
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
utils.raise_internal_error(str(exc))
|
||||||
|
|
||||||
|
|
||||||
async def update_item(
|
async def update_item(
|
||||||
@@ -86,7 +85,7 @@ async def update_item(
|
|||||||
"""
|
"""
|
||||||
obj = await Item.get(session, (Item.id == item_id) & (Item.user_id == user.id))
|
obj = await Item.get(session, (Item.id == item_id) & (Item.user_id == user.id))
|
||||||
if not obj:
|
if not obj:
|
||||||
raise_not_found("Item not found or access denied")
|
utils.raise_not_found("Item not found or access denied")
|
||||||
|
|
||||||
await obj.update(session, request, exclude_unset=True)
|
await obj.update(session, request, exclude_unset=True)
|
||||||
|
|
||||||
@@ -101,7 +100,7 @@ async def delete_item(
|
|||||||
"""
|
"""
|
||||||
obj = await Item.get(session, (Item.id == item_id) & (Item.user_id == user.id))
|
obj = await Item.get(session, (Item.id == item_id) & (Item.user_id == user.id))
|
||||||
if not obj:
|
if not obj:
|
||||||
raise_not_found("Item not found or access denied")
|
utils.raise_not_found("Item not found or access denied")
|
||||||
await Item.delete(session, obj)
|
await Item.delete(session, obj)
|
||||||
|
|
||||||
|
|
||||||
@@ -116,7 +115,7 @@ async def retrieve_object(
|
|||||||
object_data = await Item.get(session, Item.id == item_id)
|
object_data = await Item.get(session, Item.id == item_id)
|
||||||
|
|
||||||
if not object_data:
|
if not object_data:
|
||||||
raise_not_found("物品不存在或出现异常")
|
utils.raise_not_found("物品不存在或出现异常")
|
||||||
|
|
||||||
if object_data.status == "lost":
|
if object_data.status == "lost":
|
||||||
object_data.find_ip = client_host
|
object_data.find_ip = client_host
|
||||||
@@ -136,12 +135,12 @@ async def notify_move_car(
|
|||||||
item_data = await Item.get_exist_one(session=session, id=item_id)
|
item_data = await Item.get_exist_one(session=session, id=item_id)
|
||||||
|
|
||||||
if item_data.type != ItemTypeEnum.car:
|
if item_data.type != ItemTypeEnum.car:
|
||||||
raise_bad_request("Item is not car")
|
utils.raise_bad_request("Item is not car")
|
||||||
|
|
||||||
server_chan_key = await Setting.get(session, Setting.name == "server_chan_key")
|
server_chan_key = await Setting.get(session, Setting.name == "server_chan_key")
|
||||||
wechat_bot_key = await Setting.get(session, Setting.name == "wechat_bot_key")
|
wechat_bot_key = await Setting.get(session, Setting.name == "wechat_bot_key")
|
||||||
if not (server_chan_key.value or wechat_bot_key.value):
|
if not (server_chan_key.value or wechat_bot_key.value):
|
||||||
raise_internal_error("未配置Server酱,无法发送挪车通知")
|
utils.raise_internal_error("未配置Server酱,无法发送挪车通知")
|
||||||
|
|
||||||
title = "挪车通知 - Findreve"
|
title = "挪车通知 - Findreve"
|
||||||
description = (
|
description = (
|
||||||
@@ -161,4 +160,4 @@ async def notify_move_car(
|
|||||||
version="v1",
|
version="v1",
|
||||||
)
|
)
|
||||||
|
|
||||||
return HTTP_204_NO_CONTENT
|
return status.HTTP_204_NO_CONTENT
|
||||||
|
|||||||
@@ -3,32 +3,25 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
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 sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
from typing import Any
|
||||||
|
import jwt
|
||||||
|
|
||||||
from model import Setting, User
|
from model import Setting, User
|
||||||
from model.response import TokenResponse
|
from model.response import TokenResponse
|
||||||
from pkg import Password
|
from pkg import Password, utils
|
||||||
|
import JWT
|
||||||
|
|
||||||
async def create_access_token(
|
async def create_access_token(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
expires_delta: timedelta | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
创建访问令牌。
|
创建访问令牌。
|
||||||
"""
|
"""
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
if expires_delta:
|
jwt_exp_setting = await Setting.get(session, Setting.name == "jwt_token_exp")
|
||||||
expire = datetime.now(timezone.utc) + expires_delta
|
expire = datetime.now(timezone.utc) + timedelta(int(jwt_exp_setting.value))
|
||||||
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})
|
to_encode.update({"exp": expire})
|
||||||
encoded_jwt = jwt.encode(to_encode, key=await JWT.get_secret_key(), algorithm="HS256")
|
encoded_jwt = jwt.encode(to_encode, key=await JWT.get_secret_key(), algorithm="HS256")
|
||||||
return encoded_jwt
|
return encoded_jwt
|
||||||
@@ -38,19 +31,14 @@ async def authenticate_user(
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
username: str,
|
username: str,
|
||||||
password: str,
|
password: str,
|
||||||
) -> User | None:
|
) -> User:
|
||||||
"""
|
"""
|
||||||
验证用户名和密码,返回认证后的用户。
|
验证用户名和密码,返回认证后的用户。
|
||||||
"""
|
"""
|
||||||
account = await User.get(session, User.email == username)
|
account = await User.get(session, User.email == username)
|
||||||
|
|
||||||
if not account:
|
if not account or account.email != username or not Password.verify(account.password, password):
|
||||||
logger.error("Account or password not set in settings.")
|
utils.raise_unauthorized("Account or password is incorrect")
|
||||||
return None
|
|
||||||
|
|
||||||
if account.email != username or not Password.verify(account.password, password):
|
|
||||||
logger.error("Invalid username or password.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
return account
|
return account
|
||||||
|
|
||||||
@@ -59,13 +47,11 @@ async def login_for_access_token(
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
username: str,
|
username: str,
|
||||||
password: str,
|
password: str,
|
||||||
) -> TokenResponse | None:
|
) -> TokenResponse:
|
||||||
"""
|
"""
|
||||||
登录并生成访问令牌。
|
登录并生成访问令牌。
|
||||||
"""
|
"""
|
||||||
user = await authenticate_user(session=session, username=username, password=password)
|
user = await authenticate_user(session=session, username=username, password=password)
|
||||||
if not user:
|
|
||||||
return None
|
|
||||||
|
|
||||||
access_token = await create_access_token(
|
access_token = await create_access_token(
|
||||||
session=session,
|
session=session,
|
||||||
|
|||||||
Reference in New Issue
Block a user