Skip to content
Open

Style #475

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
10 changes: 0 additions & 10 deletions backend/app/api/v1/module_system/role/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
from app.api.v1.module_system.dept.crud import DeptCRUD
from app.core.base_crud import CRUDBase
from app.core.base_schema import AuthSchema
from app.core.exceptions import CustomException

from .model import RoleModel
from .schema import RoleCreateSchema, RoleUpdateSchema
Expand All @@ -28,15 +27,6 @@ async def set_role_menus_crud(self, role_ids: list[int], menu_ids: list[int]) ->
roles = await self.get_list(search={"id": ("in", role_ids)})
menus = [] if not menu_ids else await MenuCRUD(self.auth).get_list(search={"id": ("in", menu_ids)})

from app.api.v1.module_platform.package.service import PackageService

if self.auth.user and not self.auth.user.is_superuser and self.auth.tenant_id:
allowed_menu_ids = await PackageService.get_tenant_available_menu_ids(self.auth, self.auth.tenant_id)
allowed_set = set(allowed_menu_ids)
for menu in menus:
if int(menu.id) not in allowed_set:
raise CustomException(msg=f"菜单[{menu.name}]不在当前租户的功能组内,无法分配")

for obj in roles:
relationship = obj.menus
relationship.clear()
Expand Down
66 changes: 41 additions & 25 deletions backend/app/api/v1/module_system/user/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

from app.api.v1.module_platform.menu.crud import MenuCRUD
from app.api.v1.module_platform.menu.schema import MenuOutSchema
from app.api.v1.module_platform.package.service import PackageService
from app.api.v1.module_platform.tenant.service import TenantService
from app.api.v1.module_system.dept.crud import DeptCRUD
from app.api.v1.module_system.position.crud import PositionCRUD
Expand Down Expand Up @@ -38,6 +37,26 @@ class UserService:
def __init__(self, auth: AuthSchema) -> None:
self.auth = auth

async def _validate_role_ids(self, role_ids: list[int]) -> None:
if not role_ids:
return

roles = await RoleCRUD(self.auth).get_list(search={"id": ("in", role_ids)})
if len(roles) != len(role_ids):
raise CustomException(msg="部分角色不存在")
if not all(role.status == 0 for role in roles):
raise CustomException(msg="部分角色已被禁用")

async def _validate_position_ids(self, position_ids: list[int]) -> None:
if not position_ids:
return

positions = await PositionCRUD(self.auth).get_list(search={"id": ("in", position_ids)})
if len(positions) != len(position_ids):
raise CustomException(msg="部分岗位不存在")
if not all(position.status == 0 for position in positions):
raise CustomException(msg="部分岗位已被禁用")

async def detail(self, id: int) -> UserOutSchema:
user = await UserCRUD(self.auth).get_or_404(id=id)
result = UserOutSchema.model_validate(user)
Expand Down Expand Up @@ -88,12 +107,18 @@ async def create(self, data: UserCreateSchema) -> UserOutSchema:

if data.password:
data.password = PwdUtil.hash_password(password=data.password)
if "role_ids" in data.model_fields_set:
await self._validate_role_ids(data.role_ids or [])
if "position_ids" in data.model_fields_set:
await self._validate_position_ids(data.position_ids or [])
user_dict = data.model_dump(exclude_unset=True, exclude={"role_ids", "position_ids"})
new_user = await UserCRUD(self.auth).create(data=user_dict)
if data.role_ids and len(data.role_ids) > 0:
await UserCRUD(self.auth).set_user_roles(user_ids=[new_user.id], role_ids=data.role_ids)
if data.position_ids and len(data.position_ids) > 0:
await UserCRUD(self.auth).set_user_positions(user_ids=[new_user.id], position_ids=data.position_ids)
if "role_ids" in data.model_fields_set:
await UserCRUD(self.auth).set_user_roles(user_ids=[new_user.id], role_ids=data.role_ids or [])
if "position_ids" in data.model_fields_set:
await UserCRUD(self.auth).set_user_positions(
user_ids=[new_user.id], position_ids=data.position_ids or []
)
return UserOutSchema.model_validate(new_user)

async def update(self, id: int, data: UserUpdateSchema) -> UserOutSchema:
Expand Down Expand Up @@ -122,23 +147,19 @@ async def update(self, id: int, data: UserUpdateSchema) -> UserOutSchema:
if dept.status == 1:
raise CustomException(msg="部门已被禁用")

new_user = await UserCRUD(self.auth).update(id=id, data=data)
if "role_ids" in data.model_fields_set:
await self._validate_role_ids(data.role_ids or [])
if "position_ids" in data.model_fields_set:
await self._validate_position_ids(data.position_ids or [])

update_data = data.model_dump(exclude_unset=True, exclude={"role_ids", "position_ids"})
new_user = await UserCRUD(self.auth).update(id=id, data=update_data)

if data.role_ids and len(data.role_ids) > 0:
roles = await RoleCRUD(self.auth).get_list(search={"id": ("in", data.role_ids)})
if len(roles) != len(data.role_ids):
raise CustomException(msg="更新失败,部分角色不存在")
if not all(role.status == 0 for role in roles):
raise CustomException(msg="更新失败,部分角色已被禁用")
await UserCRUD(self.auth).set_user_roles(user_ids=[id], role_ids=data.role_ids)
if "role_ids" in data.model_fields_set:
await UserCRUD(self.auth).set_user_roles(user_ids=[id], role_ids=data.role_ids or [])

if data.position_ids and len(data.position_ids) > 0:
positions = await PositionCRUD(self.auth).get_list(search={"id": ("in", data.position_ids)})
if len(positions) != len(data.position_ids):
raise CustomException(msg="更新失败,部分岗位不存在")
if not all(position.status == 0 for position in positions):
raise CustomException(msg="更新失败,部分岗位已被禁用")
await UserCRUD(self.auth).set_user_positions(user_ids=[id], position_ids=data.position_ids)
if "position_ids" in data.model_fields_set:
await UserCRUD(self.auth).set_user_positions(user_ids=[id], position_ids=data.position_ids or [])

return UserOutSchema.model_validate(new_user)

Expand Down Expand Up @@ -180,11 +201,6 @@ async def current_info(self) -> UserOutSchema:
else:
menu_ids = {menu.id for role in self.auth.user.roles or [] for menu in role.menus if menu.status == 0 and getattr(menu, "client", "pc") == "pc"}

if menu_ids and self.auth.tenant_id:
allowed_ids = await PackageService(self.auth).get_tenant_available_menu_ids(self.auth.tenant_id)
allowed_set = set(allowed_ids)
menu_ids = menu_ids & allowed_set

menus = (
[
MenuOutSchema.model_validate(menu)
Expand Down
34 changes: 14 additions & 20 deletions backend/app/core/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from app.core.request_context import RequestContext
from app.core.request_context import get_current_tenant_id as _get_ctx_tenant_id
from app.core.security import OAuth2Schema, decode_access_token
from app.core.user_access import get_default_user_permissions

# 套餐菜单权限缓存: {tenant_id: (timestamp, [menu_ids])}
_package_menu_cache: dict[int, tuple[float, list[int]]] = {}
Expand Down Expand Up @@ -163,9 +164,9 @@ async def _load_user_from_db(db: AsyncSession, username: str):

# 过滤不可用的角色和职位(在会话内完成,确保关联数据已加载)
if hasattr(user, "roles"):
user.roles = [role for role in user.roles if role and role.status]
user.roles = [role for role in user.roles if role and role.status == 0]
if hasattr(user, "positions"):
user.positions = [pos for pos in user.positions if pos and pos.status]
user.positions = [pos for pos in user.positions if pos and pos.status == 0]

return user

Expand Down Expand Up @@ -276,25 +277,16 @@ async def _authenticate(
auth.user = user
return auth

async def _get_cached_tenant_menu_ids(auth: AuthSchema, tenant_id: int) -> list[int]:
"""获取租户可用菜单 ID,带 60s 进程级缓存

套餐菜单变更频率极低,缓存可大幅减少 AuthPermission 的 DB 查询次数。

参数:
auth: 认证信息
tenant_id: 租户 ID

返回:
可用菜单 ID 列表
"""
async def _get_cached_tenant_menu_ids(auth: AuthSchema, tenant_id: int) -> list[int]:
"""获取租户可用菜单 ID,带 60s 进程级缓存。"""
cached = _package_menu_cache.get(tenant_id)
if cached and time.time() - cached[0] < 60:
return cached[1]

from app.api.v1.module_platform.package.service import PackageService

result = await PackageService.get_tenant_available_menu_ids(auth, tenant_id)
result = await PackageService(auth).get_tenant_available_menu_ids(tenant_id)
_package_menu_cache[tenant_id] = (time.time(), result)
return result

Expand Down Expand Up @@ -355,15 +347,17 @@ async def __call__(self, auth: AuthSchema = Depends(get_current_user)) -> AuthSc
if menu.status == 0 and menu.permission:
role_perms[menu.permission] = menu.id

if not role_perms:
raise CustomException(msg="无权限操作", code=10403, status_code=403)

# 租户用户:权限必须受套餐菜单约束(带 60s 进程级缓存)
role_permissions: set[str]
if auth.tenant_id:
allowed_ids = set(await _get_cached_tenant_menu_ids(auth, auth.tenant_id))
user_permissions = {p for p, mid in role_perms.items() if mid in allowed_ids}
role_permissions = {p for p, mid in role_perms.items() if mid in allowed_ids}
else:
user_permissions = set(role_perms.keys())
role_permissions = set(role_perms.keys())

user_permissions = role_permissions | get_default_user_permissions(auth.user)

if not user_permissions:
raise CustomException(msg="无权限操作", code=10403, status_code=403)

# 权限验证 - 满足任一权限即可
if not any(perm in user_permissions for perm in self.permissions):
Expand Down
20 changes: 6 additions & 14 deletions backend/app/core/permission.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ def __init__(self, model: Any, auth: AuthSchema) -> None:
self.auth = auth
self.conditions: list[ColumnElement] = [] # 权限条件列表

def __has_admin_role(self) -> bool:
roles = getattr(self.auth.user, "roles", []) or []
return any(getattr(role, "code", None) in {"ADMIN", "SUPER_ADMIN"} and getattr(role, "status", 0) == 0 for role in roles)

async def filter_query(self, query: Any) -> Any:
"""
按数据权限为 SQLAlchemy 查询追加 WHERE 条件。
Expand Down Expand Up @@ -63,8 +67,8 @@ async def __permission_condition(self) -> ColumnElement | None:
if not self.auth.check_data_scope:
return None

# 超级管理员可以查看所有数据
if self.auth.user.is_superuser:
# 平台超管和业务管理员可以查看管理后台数据;普通用户继续按角色数据范围过滤。
if self.auth.user.is_superuser or self.__has_admin_role():
return None

# 获取模型的权限过滤策略
Expand Down Expand Up @@ -102,18 +106,6 @@ async def __filter_by_menu_auth(self) -> ColumnElement | None:
if hasattr(role, "menus") and role.menus:
menu_ids.update(menu.id for menu in role.menus if menu.status == 0)

# 租户用户:菜单列表也受套餐约束(请求级缓存避免重复 DB 查询)
if self.auth.tenant_id and menu_ids:
cache_attr = "_cached_package_menu_ids"
cached = getattr(self.auth, cache_attr, None)
if cached is None:
from app.api.v1.module_platform.package.service import PackageService
allowed_ids = set(await PackageService.get_tenant_available_menu_ids(self.auth, self.auth.tenant_id))
object.__setattr__(self.auth, cache_attr, allowed_ids)
else:
allowed_ids = cached
menu_ids = menu_ids & allowed_ids

if menu_ids:
id_attr = getattr(self.model, "id", None)
if id_attr is not None:
Expand Down
30 changes: 30 additions & 0 deletions backend/app/core/user_access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from typing import Any

DEFAULT_USER_ROLE_CODES = {"USER"}
DEFAULT_USER_PERMISSIONS = {
"module_ai:chat:query",
"module_ai:chat:detail",
"module_ai:chat:create",
"module_ai:chat:update",
"module_ai:chat:delete",
"module_ai:chat:ws",
}


def _has_enabled_role_code(user: Any, codes: set[str]) -> bool:
"""Return True when the user has at least one enabled role code in codes."""
roles = getattr(user, "roles", None) or []
normalized_codes = {code.upper() for code in codes}
for role in roles:
if getattr(role, "status", 1) != 0:
continue
code = (getattr(role, "code", "") or "").strip().upper()
if code in normalized_codes:
return True
return False


def get_default_user_permissions(user: Any) -> set[str]:
if _has_enabled_role_code(user, DEFAULT_USER_ROLE_CODES):
return set(DEFAULT_USER_PERMISSIONS)
return set()
53 changes: 50 additions & 3 deletions backend/app/plugin/module_ai/chat/controller.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
from typing import Annotated, Any

from fastapi import APIRouter, Depends, Path
from fastapi import APIRouter, Depends, Path, Request, UploadFile
from fastapi.responses import JSONResponse
from redis.asyncio import Redis

from app.config.setting import settings
from app.common.response import ResponseSchema, SuccessResponse
from app.core.base_params import PaginationQueryParam
from app.core.base_schema import AuthSchema
from app.core.base_schema import AuthSchema, UploadResponseSchema
from app.core.dependencies import AuthPermission, redis_getter
from app.core.router_class import OperationLogRoute
from app.core.exceptions import CustomException
from app.api.v1.module_common.file.service import FileService

from .schema import (
AiChatRequestSchema,
Expand All @@ -25,6 +28,47 @@
ChatRouter = APIRouter(route_class=OperationLogRoute, prefix="/chat", tags=["AI管理", "AI对话"])


@ChatRouter.post(
"/upload",
summary="上传聊天附件",
response_model=ResponseSchema[UploadResponseSchema],
)
async def upload_chat_file_controller(
request: Request,
file: UploadFile,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_ai:chat:query"]))],
) -> JSONResponse:
chat_allowed_extensions = {
".txt",
".md",
".markdown",
".csv",
".json",
".pdf",
".doc",
".docx",
".jpg",
".jpeg",
".png",
".gif",
}
original_allowed_extensions = list(settings.ALLOWED_EXTENSIONS)

try:
settings.ALLOWED_EXTENSIONS = sorted(set(original_allowed_extensions) | chat_allowed_extensions)
result = await FileService.upload_service(
base_url=str(request.base_url),
file=file,
upload_type="file",
)
except CustomException:
raise
finally:
settings.ALLOWED_EXTENSIONS = original_allowed_extensions

return SuccessResponse(data=result, msg="附件上传成功")


@ChatRouter.get(
"/detail/{session_id}",
summary="获取会话详情",
Expand Down Expand Up @@ -109,12 +153,15 @@ async def delete_session_controller(
)
async def ai_chat_controller(
data: AiChatRequestSchema,
redis: Annotated[Redis, Depends(redis_getter)],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_ai:chat:query"]))],
) -> JSONResponse:
service = ChatService(auth)
model_config = await AiModelConfigService(auth, redis).get_active()
result = await service.chat_non_stream(
message=data.message,
session_id=data.session_id,
model_config=model_config,
)
return SuccessResponse(
data=AiChatResponseSchema(
Expand Down Expand Up @@ -200,7 +247,7 @@ async def delete_model_config_controller(
async def activate_model_config_controller(
config_id: Annotated[str, Path(description="配置项 ID;传 __default__ 使用系统默认")],
redis: Annotated[Redis, Depends(redis_getter)],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_ai:chat:update"]))],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_ai:chat:query"]))],
) -> JSONResponse:
service = AiModelConfigService(auth, redis)
await service.set_active(config_id)
Expand Down
Loading