Optimize api with semantic HTTP status codes (#681)

This commit is contained in:
Dylan
2025-06-23 22:18:17 +08:00
committed by GitHub
parent f9bfe8f510
commit 6d5e741d94
20 changed files with 91 additions and 62 deletions

View File

@@ -93,7 +93,7 @@ class AuthService:
user = await self.user_verify(db, obj.username, obj.password)
captcha_code = await redis_client.get(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}')
if not captcha_code:
raise errors.ForbiddenError(msg='验证码失效,请重新获取')
raise errors.RequestError(msg='验证码失效,请重新获取')
if captcha_code.lower() != obj.captcha.lower():
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
await redis_client.delete(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}')
@@ -122,7 +122,7 @@ class AuthService:
except errors.NotFoundError as e:
log.error('登陆错误: 用户名不存在')
raise errors.NotFoundError(msg=e.msg)
except (errors.ForbiddenError, errors.CustomError) as e:
except (errors.RequestError, errors.CustomError) as e:
if not user:
log.error('登陆错误: 用户密码有误')
task = BackgroundTask(

View File

@@ -87,7 +87,7 @@ class DataRuleService:
async with async_db_session.begin() as db:
data_rule = await data_rule_dao.get_by_name(db, obj.name)
if data_rule:
raise errors.ForbiddenError(msg='数据规则已存在')
raise errors.ConflictError(msg='数据规则已存在')
await data_rule_dao.create(db, obj)
@staticmethod
@@ -105,7 +105,7 @@ class DataRuleService:
raise errors.NotFoundError(msg='数据规则不存在')
if data_rule.name != obj.name:
if await data_rule_dao.get_by_name(db, obj.name):
raise errors.ForbiddenError(msg='数据规则已存在')
raise errors.ConflictError(msg='数据规则已存在')
count = await data_rule_dao.update(db, pk, obj)
return count

View File

@@ -78,7 +78,7 @@ class DataScopeService:
async with async_db_session.begin() as db:
data_scope = await data_scope_dao.get_by_name(db, obj.name)
if data_scope:
raise errors.ForbiddenError(msg='数据范围已存在')
raise errors.ConflictError(msg='数据范围已存在')
await data_scope_dao.create(db, obj)
@staticmethod
@@ -96,7 +96,7 @@ class DataScopeService:
raise errors.NotFoundError(msg='数据范围不存在')
if data_scope.name != obj.name:
if await data_scope_dao.get_by_name(db, obj.name):
raise errors.ForbiddenError(msg='数据范围已存在')
raise errors.ConflictError(msg='数据范围已存在')
count = await data_scope_dao.update(db, pk, obj)
for role in await data_scope.awaitable_attrs.roles:
for user in await role.awaitable_attrs.users:

View File

@@ -61,7 +61,7 @@ class DeptService:
async with async_db_session.begin() as db:
dept = await dept_dao.get_by_name(db, obj.name)
if dept:
raise errors.ForbiddenError(msg='部门名称已存在')
raise errors.ConflictError(msg='部门名称已存在')
if obj.parent_id:
parent_dept = await dept_dao.get(db, obj.parent_id)
if not parent_dept:
@@ -83,7 +83,7 @@ class DeptService:
raise errors.NotFoundError(msg='部门不存在')
if dept.name != obj.name:
if await dept_dao.get_by_name(db, obj.name):
raise errors.ForbiddenError(msg='部门名称已存在')
raise errors.ConflictError(msg='部门名称已存在')
if obj.parent_id:
parent_dept = await dept_dao.get(db, obj.parent_id)
if not parent_dept:
@@ -104,10 +104,10 @@ class DeptService:
async with async_db_session.begin() as db:
dept = await dept_dao.get_with_relation(db, pk)
if dept.users:
raise errors.ForbiddenError(msg='部门下存在用户,无法删除')
raise errors.ConflictError(msg='部门下存在用户,无法删除')
children = await dept_dao.get_children(db, pk)
if children:
raise errors.ForbiddenError(msg='部门下存在子部门,无法删除')
raise errors.ConflictError(msg='部门下存在子部门,无法删除')
count = await dept_dao.delete(db, pk)
for user in dept.users:
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')

View File

@@ -78,7 +78,7 @@ class MenuService:
async with async_db_session.begin() as db:
title = await menu_dao.get_by_title(db, obj.title)
if title:
raise errors.ForbiddenError(msg='菜单标题已存在')
raise errors.ConflictError(msg='菜单标题已存在')
if obj.parent_id:
parent_menu = await menu_dao.get(db, obj.parent_id)
if not parent_menu:
@@ -100,7 +100,7 @@ class MenuService:
raise errors.NotFoundError(msg='菜单不存在')
if menu.title != obj.title:
if await menu_dao.get_by_title(db, obj.title):
raise errors.ForbiddenError(msg='菜单标题已存在')
raise errors.ConflictError(msg='菜单标题已存在')
if obj.parent_id:
parent_menu = await menu_dao.get(db, obj.parent_id)
if not parent_menu:
@@ -124,7 +124,7 @@ class MenuService:
async with async_db_session.begin() as db:
children = await menu_dao.get_children(db, pk)
if children:
raise errors.ForbiddenError(msg='菜单下存在子菜单,无法删除')
raise errors.ConflictError(msg='菜单下存在子菜单,无法删除')
menu = await menu_dao.get(db, pk)
count = await menu_dao.delete(db, pk)
if menu:

View File

@@ -55,24 +55,24 @@ class PluginService:
contents = await file.read()
file_bytes = io.BytesIO(contents)
if not zipfile.is_zipfile(file_bytes):
raise errors.ForbiddenError(msg='插件压缩包格式非法')
raise errors.RequestError(msg='插件压缩包格式非法')
with zipfile.ZipFile(file_bytes) as zf:
# 校验压缩包
plugin_namelist = zf.namelist()
plugin_name = plugin_namelist[0].split('/')[0]
if not plugin_namelist or plugin_name not in file.filename:
raise errors.ForbiddenError(msg='插件压缩包内容非法')
raise errors.RequestError(msg='插件压缩包内容非法')
if (
len(plugin_namelist) <= 3
or f'{plugin_name}/plugin.toml' not in plugin_namelist
or f'{plugin_name}/README.md' not in plugin_namelist
):
raise errors.ForbiddenError(msg='插件压缩包内缺少必要文件')
raise errors.RequestError(msg='插件压缩包内缺少必要文件')
# 插件是否可安装
full_plugin_path = os.path.join(PLUGIN_DIR, plugin_name)
if os.path.exists(full_plugin_path):
raise errors.ForbiddenError(msg='此插件已安装')
raise errors.ConflictError(msg='此插件已安装')
else:
os.makedirs(full_plugin_path, exist_ok=True)
@@ -99,11 +99,11 @@ class PluginService:
"""
match = is_git_url(repo_url)
if not match:
raise errors.ForbiddenError(msg='Git 仓库地址格式非法')
raise errors.RequestError(msg='Git 仓库地址格式非法')
repo_name = match.group('repo')
plugins = await redis_client.lrange(settings.PLUGIN_REDIS_PREFIX, 0, -1)
if repo_name in plugins:
raise errors.ForbiddenError(msg=f'{repo_name} 插件已安装')
raise errors.ConflictError(msg=f'{repo_name} 插件已安装')
try:
porcelain.clone(repo_url, os.path.join(PLUGIN_DIR, repo_name), checkout=True)
except Exception as e:
@@ -124,11 +124,11 @@ class PluginService:
"""
if type == PluginType.zip:
if not file:
raise errors.ForbiddenError(msg='ZIP 压缩包不能为空')
raise errors.RequestError(msg='ZIP 压缩包不能为空')
await self.install_zip(file=file)
elif type == PluginType.git:
if not repo_url:
raise errors.ForbiddenError(msg='Git 仓库地址不能为空')
raise errors.RequestError(msg='Git 仓库地址不能为空')
await self.install_git(repo_url=repo_url)
@staticmethod
@@ -141,7 +141,7 @@ class PluginService:
"""
plugin_dir = os.path.join(PLUGIN_DIR, plugin)
if not os.path.exists(plugin_dir):
raise errors.ForbiddenError(msg='插件不存在')
raise errors.NotFoundError(msg='插件不存在')
await uninstall_requirements_async(plugin)
bacup_dir = os.path.join(PLUGIN_DIR, f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup')
shutil.move(plugin_dir, bacup_dir)
@@ -159,7 +159,7 @@ class PluginService:
"""
plugin_info = await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:info:{plugin}')
if not plugin_info:
raise errors.ForbiddenError(msg='插件不存在')
raise errors.NotFoundError(msg='插件不存在')
plugin_info = json.loads(plugin_info)
# 更新持久缓存状态
@@ -184,7 +184,7 @@ class PluginService:
"""
plugin_dir = os.path.join(PLUGIN_DIR, plugin)
if not os.path.exists(plugin_dir):
raise errors.ForbiddenError(msg='插件不存在')
raise errors.NotFoundError(msg='插件不存在')
bio = io.BytesIO()
with zipfile.ZipFile(bio, 'w') as zf:

View File

@@ -98,7 +98,7 @@ class RoleService:
async with async_db_session.begin() as db:
role = await role_dao.get_by_name(db, obj.name)
if role:
raise errors.ForbiddenError(msg='角色已存在')
raise errors.ConflictError(msg='角色已存在')
await role_dao.create(db, obj)
@staticmethod
@@ -116,7 +116,7 @@ class RoleService:
raise errors.NotFoundError(msg='角色不存在')
if role.name != obj.name:
if await role_dao.get_by_name(db, obj.name):
raise errors.ForbiddenError(msg='角色已存在')
raise errors.ConflictError(msg='角色已存在')
count = await role_dao.update(db, pk, obj)
for user in await role.awaitable_attrs.users:
await redis_client.delete_prefix(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')

View File

@@ -81,10 +81,10 @@ class UserService:
async with async_db_session.begin() as db:
superuser_verify(request)
if await user_dao.get_by_username(db, obj.username):
raise errors.ForbiddenError(msg='用户名已注册')
raise errors.ConflictError(msg='用户名已注册')
obj.nickname = obj.nickname if obj.nickname else f'#{random.randrange(88888, 99999)}'
if not obj.password:
raise errors.ForbiddenError(msg='密码不允许为空')
raise errors.RequestError(msg='密码不允许为空')
if not await dept_dao.get(db, obj.dept_id):
raise errors.NotFoundError(msg='部门不存在')
for role_id in obj.roles:
@@ -110,7 +110,7 @@ class UserService:
raise errors.ForbiddenError(msg='只能修改自己的信息')
if obj.username != user.username:
if await user_dao.get_by_username(db, obj.username):
raise errors.ForbiddenError(msg='用户名已注册')
raise errors.ConflictError(msg='用户名已注册')
for role_id in obj.roles:
if not await role_dao.get(db, role_id):
raise errors.NotFoundError(msg='角色不存在')
@@ -222,16 +222,17 @@ class UserService:
:param type: 权限类型
:return:
"""
if type == UserPermissionType.superuser:
count = await self.update_superuser(request=request, pk=pk)
elif type == UserPermissionType.staff:
count = await self.update_staff(request=request, pk=pk)
elif type == UserPermissionType.status:
count = await self.update_status(request=request, pk=pk)
elif type == UserPermissionType.multi_login:
count = await self.update_multi_login(request=request, pk=pk)
else:
raise errors.ForbiddenError(msg='权限类型不存在')
match type:
case UserPermissionType.superuser:
count = await self.update_superuser(request=request, pk=pk)
case UserPermissionType.staff:
count = await self.update_staff(request=request, pk=pk)
case UserPermissionType.status:
count = await self.update_status(request=request, pk=pk)
case UserPermissionType.multi_login:
count = await self.update_multi_login(request=request, pk=pk)
case _:
raise errors.RequestError(msg='权限类型不存在')
return count
@staticmethod
@@ -248,9 +249,9 @@ class UserService:
if not user:
raise errors.NotFoundError(msg='用户不存在')
if not password_verify(obj.old_password, user.password):
raise errors.ForbiddenError(msg='原密码错误')
raise errors.RequestError(msg='原密码错误')
if obj.new_password != obj.confirm_password:
raise errors.ForbiddenError(msg='密码输入不一致')
raise errors.RequestError(msg='密码输入不一致')
new_pwd = get_hash_password(obj.new_password, user.salt)
count = await user_dao.reset_password(db, user.id, new_pwd)
key_prefix = [

View File

@@ -39,7 +39,7 @@ class TaskService:
"""获取所有已注册的 Celery 任务列表"""
registered_tasks = await run_in_threadpool(celery_app.control.inspect().registered)
if not registered_tasks:
raise errors.ForbiddenError(msg='Celery 服务未启动')
raise errors.ServerError(msg='Celery 服务未启动')
tasks = list(registered_tasks.values())[0]
return tasks

View File

@@ -98,3 +98,12 @@ class TokenError(HTTPError):
def __init__(self, *, msg: str = 'Not Authenticated', headers: dict[str, Any] | None = None):
super().__init__(code=self.code, msg=msg, headers=headers or {'WWW-Authenticate': 'Bearer'})
class ConflictError(BaseExceptionMixin):
"""资源冲突异常"""
code = StandardResponseCode.HTTP_409
def __init__(self, *, msg: str = 'Conflict', data: Any = None, background: BackgroundTask | None = None):
super().__init__(msg=msg, data=data, background=background)

View File

@@ -6,8 +6,9 @@ from datetime import timedelta
from typing import Any
from uuid import uuid4
from fastapi import Depends, Request
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPBearer
from fastapi.security.http import HTTPAuthorizationCredentials
from fastapi.security.utils import get_authorization_scheme_param
from jose import ExpiredSignatureError, JWTError, jwt
from pwdlib import PasswordHash
@@ -19,14 +20,32 @@ from backend.app.admin.model import User
from backend.app.admin.schema.user import GetUserInfoWithRelationDetail
from backend.common.dataclasses import AccessToken, NewToken, RefreshToken, TokenPayload
from backend.common.exception import errors
from backend.common.exception.errors import TokenError
from backend.core.conf import settings
from backend.database.db import async_db_session
from backend.database.redis import redis_client
from backend.utils.serializers import select_as_dict
from backend.utils.timezone import timezone
class CustomHTTPBearer(HTTPBearer):
"""
自定义 HTTPBearer 认证类
Issues: https://github.com/fastapi/fastapi/issues/10177
"""
async def __call__(self, request: Request) -> HTTPAuthorizationCredentials | None:
try:
return await super().__call__(request)
except HTTPException as e:
if e.status_code == 403:
raise TokenError()
raise e
# JWT authorizes dependency injection
DependsJwtAuth = Depends(HTTPBearer())
DependsJwtAuth = Depends(CustomHTTPBearer())
password_hash = PasswordHash((BcryptHasher(),))

View File

@@ -43,7 +43,7 @@ class GenBusinessService:
async with async_db_session.begin() as db:
business = await gen_business_dao.get_by_name(db, obj.table_name)
if business:
raise errors.ForbiddenError(msg='代码生成业务已存在')
raise errors.ConflictError(msg='代码生成业务已存在')
await gen_business_dao.create(db, obj)
@staticmethod

View File

@@ -76,7 +76,7 @@ class GenModelService:
if obj.name != model.name:
gen_models = await gen_model_dao.get_all_by_business(db, obj.gen_business_id)
if obj.name in [gen_model.name for gen_model in gen_models]:
raise errors.ForbiddenError(msg='模型列名已存在')
raise errors.ConflictError(msg='模型列名已存在')
pd_type = sql_type_to_pydantic(obj.type)
return await gen_model_dao.update(db, pk, obj, pd_type=pd_type)

View File

@@ -55,7 +55,7 @@ class GenService:
business_info = await gen_business_dao.get_by_name(db, obj.table_name)
if business_info:
raise errors.ForbiddenError(msg='已存在相同数据库表业务')
raise errors.ConflictError(msg='已存在相同数据库表业务')
table_name = table_info[0]
new_business = GenBusiness(

View File

@@ -52,10 +52,10 @@ class ConfigService:
"""
async with async_db_session.begin() as db:
if obj.type in settings.CONFIG_BUILT_IN_TYPES:
raise errors.ForbiddenError(msg='非法类型参数')
raise errors.RequestError(msg='非法类型参数')
config = await config_dao.get_by_key(db, obj.key)
if config:
raise errors.ForbiddenError(msg=f'参数配置 {obj.key} 已存在')
raise errors.ConflictError(msg=f'参数配置 {obj.key} 已存在')
await config_dao.create(db, obj)
@staticmethod
@@ -74,7 +74,7 @@ class ConfigService:
if config.key != obj.key:
config = await config_dao.get_by_key(db, obj.key)
if config:
raise errors.ForbiddenError(msg=f'参数配置 {obj.key} 已存在')
raise errors.ConflictError(msg=f'参数配置 {obj.key} 已存在')
count = await config_dao.update(db, pk, obj)
return count

View File

@@ -58,7 +58,7 @@ class DictDataService:
async with async_db_session.begin() as db:
dict_data = await dict_data_dao.get_by_label(db, obj.label)
if dict_data:
raise errors.ForbiddenError(msg='字典数据已存在')
raise errors.ConflictError(msg='字典数据已存在')
dict_type = await dict_type_dao.get(db, obj.type_id)
if not dict_type:
raise errors.NotFoundError(msg='字典类型不存在')
@@ -79,7 +79,7 @@ class DictDataService:
raise errors.NotFoundError(msg='字典数据不存在')
if dict_data.label != obj.label:
if await dict_data_dao.get_by_label(db, obj.label):
raise errors.ForbiddenError(msg='字典数据已存在')
raise errors.ConflictError(msg='字典数据已存在')
dict_type = await dict_type_dao.get(db, obj.type_id)
if not dict_type:
raise errors.NotFoundError(msg='字典类型不存在')

View File

@@ -49,7 +49,7 @@ class DictTypeService:
async with async_db_session.begin() as db:
dict_type = await dict_type_dao.get_by_code(db, obj.code)
if dict_type:
raise errors.ForbiddenError(msg='字典类型已存在')
raise errors.ConflictError(msg='字典类型已存在')
await dict_type_dao.create(db, obj)
@staticmethod
@@ -67,7 +67,7 @@ class DictTypeService:
raise errors.NotFoundError(msg='字典类型不存在')
if dict_type.code != obj.code:
if await dict_type_dao.get_by_code(db, obj.code):
raise errors.ForbiddenError(msg='字典类型已存在')
raise errors.ConflictError(msg='字典类型已存在')
count = await dict_type_dao.update(db, pk, obj)
return count

View File

@@ -331,4 +331,4 @@ class PluginStatusChecker:
log.error(f'插件 {self.plugin} 状态未初始化或丢失,需重启服务自动修复')
raise PluginInjectError(f'插件 {self.plugin} 状态未初始化或丢失,请联系系统管理员')
if not int(plugin_status.get(self.plugin)):
raise errors.ForbiddenError(msg=f'插件 {self.plugin} 未启用,请联系系统管理员')
raise errors.ServerError(msg=f'插件 {self.plugin} 未启用,请联系系统管理员')

View File

@@ -38,18 +38,18 @@ def file_verify(file: UploadFile) -> None:
filename = file.filename
file_ext = filename.split('.')[-1].lower()
if not file_ext:
raise errors.ForbiddenError(msg='未知的文件类型')
raise errors.RequestError(msg='未知的文件类型')
if file_ext == FileType.image:
if file_ext not in settings.UPLOAD_IMAGE_EXT_INCLUDE:
raise errors.ForbiddenError(msg='此图片格式暂不支持')
raise errors.RequestError(msg='此图片格式暂不支持')
if file.size > settings.UPLOAD_IMAGE_SIZE_MAX:
raise errors.ForbiddenError(msg='图片超出最大限制,请重新选择')
raise errors.RequestError(msg='图片超出最大限制,请重新选择')
elif file_ext == FileType.video:
if file_ext not in settings.UPLOAD_VIDEO_EXT_INCLUDE:
raise errors.ForbiddenError(msg='此视频格式暂不支持')
raise errors.RequestError(msg='此视频格式暂不支持')
if file.size > settings.UPLOAD_VIDEO_SIZE_MAX:
raise errors.ForbiddenError(msg='视频超出最大限制,请重新选择')
raise errors.RequestError(msg='视频超出最大限制,请重新选择')
async def upload_file(file: UploadFile) -> str:

View File

@@ -54,9 +54,9 @@ class Snowflake:
:param sequence: 起始序列号
"""
if cluster_id < 0 or cluster_id > SnowflakeConfig.MAX_DATACENTER_ID:
raise errors.ForbiddenError(msg=f'集群编号必须在 0-{SnowflakeConfig.MAX_DATACENTER_ID} 之间')
raise errors.RequestError(msg=f'集群编号必须在 0-{SnowflakeConfig.MAX_DATACENTER_ID} 之间')
if node_id < 0 or node_id > SnowflakeConfig.MAX_WORKER_ID:
raise errors.ForbiddenError(msg=f'节点编号必须在 0-{SnowflakeConfig.MAX_WORKER_ID} 之间')
raise errors.RequestError(msg=f'节点编号必须在 0-{SnowflakeConfig.MAX_WORKER_ID} 之间')
self.node_id = node_id
self.cluster_id = cluster_id