Skip to content

Optimize api with semantic HTTP status codes #681

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/app/admin/service/auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ async def login(
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}')
Expand Down Expand Up @@ -122,7 +122,7 @@ async def login(
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(
Expand Down
4 changes: 2 additions & 2 deletions backend/app/admin/service/data_rule_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ async def create(*, obj: CreateDataRuleParam) -> None:
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
Expand All @@ -105,7 +105,7 @@ async def update(*, pk: int, obj: UpdateDataRuleParam) -> int:
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

Expand Down
4 changes: 2 additions & 2 deletions backend/app/admin/service/data_scope_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ async def create(*, obj: CreateDataScopeParam) -> None:
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
Expand All @@ -96,7 +96,7 @@ async def update(*, pk: int, obj: UpdateDataScopeParam) -> int:
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:
Expand Down
8 changes: 4 additions & 4 deletions backend/app/admin/service/dept_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ async def create(*, obj: CreateDeptParam) -> None:
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:
Expand All @@ -83,7 +83,7 @@ async def update(*, pk: int, obj: UpdateDeptParam) -> int:
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:
Expand All @@ -104,10 +104,10 @@ async def delete(*, pk: int) -> int:
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}')
Expand Down
6 changes: 3 additions & 3 deletions backend/app/admin/service/menu_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ async def create(*, obj: CreateMenuParam) -> None:
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:
Expand All @@ -100,7 +100,7 @@ async def update(*, pk: int, obj: UpdateMenuParam) -> int:
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:
Expand All @@ -124,7 +124,7 @@ async def delete(*, pk: int) -> int:
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:
Expand Down
22 changes: 11 additions & 11 deletions backend/app/admin/service/plugin_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,24 +55,24 @@ async def install_zip(*, file: UploadFile) -> None:
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)

Expand All @@ -99,11 +99,11 @@ async def install_git(*, repo_url: str):
"""
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:
Expand All @@ -124,11 +124,11 @@ async def install(self, *, type: PluginType, file: UploadFile | None = None, rep
"""
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
Expand All @@ -141,7 +141,7 @@ async def uninstall(*, plugin: str):
"""
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)
Expand All @@ -159,7 +159,7 @@ async def update_status(*, plugin: str):
"""
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)

# 更新持久缓存状态
Expand All @@ -184,7 +184,7 @@ async def build(*, plugin: str) -> io.BytesIO:
"""
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:
Expand Down
4 changes: 2 additions & 2 deletions backend/app/admin/service/role_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ async def create(*, obj: CreateRoleParam) -> None:
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
Expand All @@ -116,7 +116,7 @@ async def update(*, pk: int, obj: UpdateRoleParam) -> int:
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}')
Expand Down
31 changes: 16 additions & 15 deletions backend/app/admin/service/user_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,10 @@ async def create(*, request: Request, obj: AddUserParam) -> None:
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:
Expand All @@ -110,7 +110,7 @@ async def update(*, request: Request, pk: int, obj: UpdateUserParam) -> int:
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='角色不存在')
Expand Down Expand Up @@ -222,16 +222,17 @@ async def update_permission(self, *, request: Request, pk: int, type: UserPermis
: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
Expand All @@ -248,9 +249,9 @@ async def reset_pwd(*, pk: int, obj: ResetPasswordParam) -> int:
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 = [
Expand Down
2 changes: 1 addition & 1 deletion backend/app/task/service/task_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async def get_all() -> list[str]:
"""获取所有已注册的 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

Expand Down
9 changes: 9 additions & 0 deletions backend/common/exception/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
23 changes: 21 additions & 2 deletions backend/common/security/jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
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
Expand All @@ -19,14 +20,32 @@
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(),))

Expand Down
2 changes: 1 addition & 1 deletion backend/plugin/code_generator/service/business_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ async def create(*, obj: CreateGenBusinessParam) -> None:
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
Expand Down
2 changes: 1 addition & 1 deletion backend/plugin/code_generator/service/column_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ async def update(*, pk: int, obj: UpdateGenModelParam) -> int:
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)
Expand Down
2 changes: 1 addition & 1 deletion backend/plugin/code_generator/service/gen_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ async def import_business_and_model(*, obj: ImportParam) -> None:

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(
Expand Down
Loading