mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-21 04:46:26 +00:00
chore: cleanup old frontend code and update project configs
This commit removes the deprecated frontend/web-old directory, updates various project configuration files including tsconfig, vite config, environment variables, and backend data models. It also fixes several API paths, adds tenant awareness to multiple models, and makes minor UI adjustments.
This commit is contained in:
@@ -13,6 +13,8 @@ from .position.controller import PositionRouter
|
||||
from .role.controller import RoleRouter
|
||||
from .tenant.controller import TenantRouter
|
||||
from .user.controller import UserRouter
|
||||
from .ticket.controller import TicketRouter
|
||||
from .plugin.controller import PluginRouter
|
||||
|
||||
system_router = APIRouter(prefix="/system")
|
||||
|
||||
@@ -27,3 +29,5 @@ system_router.include_router(PositionRouter)
|
||||
system_router.include_router(RoleRouter)
|
||||
system_router.include_router(TenantRouter)
|
||||
system_router.include_router(UserRouter)
|
||||
system_router.include_router(TicketRouter)
|
||||
system_router.include_router(PluginRouter)
|
||||
@@ -11,27 +11,32 @@ from app.common.response import ErrorResponse, SuccessResponse
|
||||
from app.config.setting import settings
|
||||
from app.core.dependencies import db_getter, get_current_user, redis_getter
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.redis_crud import RedisCURD
|
||||
from app.core.logger import log
|
||||
from app.core.redis_crud import RedisCURD
|
||||
from app.core.router_class import OperationLogRoute
|
||||
from app.core.security import CustomOAuth2PasswordRequestForm
|
||||
|
||||
from .schema import (
|
||||
AutoLoginTokenSchema,
|
||||
AutoLoginUserSchema,
|
||||
CaptchaOutSchema,
|
||||
JWTOutSchema,
|
||||
LogoutPayloadSchema,
|
||||
RefreshTokenPayloadSchema,
|
||||
)
|
||||
from .oauth_service import (
|
||||
STATE_PREFIX,
|
||||
_callback_url,
|
||||
build_authorize_url,
|
||||
complete_oauth_login,
|
||||
oauth_service_error_redirect,
|
||||
oauth_service_frontend_redirect_from_token,
|
||||
save_oauth_state,
|
||||
_callback_url,
|
||||
)
|
||||
from .schema import (
|
||||
AuthSchema,
|
||||
AutoLoginTokenSchema,
|
||||
AutoLoginUserSchema,
|
||||
CaptchaOutSchema,
|
||||
JWTOutSchema,
|
||||
LoginWithTenantsSchema,
|
||||
LogoutPayloadSchema,
|
||||
RefreshTokenPayloadSchema,
|
||||
SelectTenantOutSchema,
|
||||
SelectTenantSchema,
|
||||
TenantOptionSchema,
|
||||
)
|
||||
from .service import AutoLoginService, CaptchaService, LoginService
|
||||
|
||||
@@ -41,8 +46,8 @@ AuthRouter = APIRouter(route_class=OperationLogRoute, prefix="/auth", tags=["认
|
||||
@AuthRouter.post(
|
||||
"/login",
|
||||
summary="登录",
|
||||
description="登录",
|
||||
response_model=JWTOutSchema,
|
||||
description="登录(返回可选租户列表)",
|
||||
response_model=LoginWithTenantsSchema,
|
||||
)
|
||||
async def login_for_access_token_controller(
|
||||
request: Request,
|
||||
@@ -60,21 +65,21 @@ async def login_for_access_token_controller(
|
||||
- db (AsyncSession): 数据库会话对象
|
||||
|
||||
返回:
|
||||
- JWTOutSchema: 包含访问令牌和刷新令牌的响应模型
|
||||
- LoginWithTenantsSchema: 包含令牌、租户列表和用户信息的响应模型
|
||||
|
||||
异常:
|
||||
- CustomException: 认证失败时抛出异常。
|
||||
"""
|
||||
login_token = await LoginService.authenticate_user_service(
|
||||
login_result = await LoginService.authenticate_user_service(
|
||||
request=request, redis=redis, login_form=login_form, db=db
|
||||
)
|
||||
|
||||
log.info(f"用户{login_form.username}登录成功")
|
||||
|
||||
# 如果是文档请求,则不记录日志:http://localhost:8000/api/v1/docs
|
||||
# 如果是文档请求,则不记录日志
|
||||
if settings.DOCS_URL in request.headers.get("referer", ""):
|
||||
return login_token.model_dump()
|
||||
return SuccessResponse(data=login_token.model_dump(), msg="登录成功")
|
||||
return login_result.model_dump()
|
||||
return SuccessResponse(data=login_result.model_dump(), msg="登录成功")
|
||||
|
||||
|
||||
@AuthRouter.post(
|
||||
@@ -177,18 +182,21 @@ async def logout_controller(
|
||||
response_model=list[AutoLoginUserSchema],
|
||||
)
|
||||
async def get_auto_login_users_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
获取免登录用户列表
|
||||
|
||||
参数:
|
||||
- auth (AuthSchema): 认证信息
|
||||
- db (AsyncSession): 数据库会话对象
|
||||
|
||||
返回:
|
||||
- list[AutoLoginUserSchema]: 免登录用户列表
|
||||
"""
|
||||
users = await AutoLoginService.get_auto_login_users_service(db=db)
|
||||
tenant_id = None if auth.user.is_superuser else auth.user.tenant_id
|
||||
users = await AutoLoginService.get_auto_login_users_service(db=db, tenant_id=tenant_id)
|
||||
return SuccessResponse(data=users, msg="获取成功")
|
||||
|
||||
|
||||
@@ -199,6 +207,7 @@ async def get_auto_login_users_controller(
|
||||
response_model=AutoLoginTokenSchema,
|
||||
)
|
||||
async def get_auto_login_token_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
user_id: int,
|
||||
@@ -207,6 +216,7 @@ async def get_auto_login_token_controller(
|
||||
获取免登录Token
|
||||
|
||||
参数:
|
||||
- auth (AuthSchema): 认证信息
|
||||
- redis (Redis): Redis客户端对象
|
||||
- db (AsyncSession): 数据库会话对象
|
||||
- user_id (int): 用户ID
|
||||
@@ -214,8 +224,9 @@ async def get_auto_login_token_controller(
|
||||
返回:
|
||||
- AutoLoginTokenSchema: 免登录Token和用户信息
|
||||
"""
|
||||
tenant_id = None if auth.user.is_superuser else auth.user.tenant_id
|
||||
result = await AutoLoginService.create_auto_login_token_service(
|
||||
redis=redis, db=db, user_id=user_id
|
||||
redis=redis, db=db, user_id=user_id, tenant_id=tenant_id
|
||||
)
|
||||
return SuccessResponse(data=result, msg="获取成功")
|
||||
|
||||
@@ -251,6 +262,64 @@ async def auto_login_controller(
|
||||
return SuccessResponse(data=login_token.model_dump(), msg="登录成功")
|
||||
|
||||
|
||||
@AuthRouter.post(
|
||||
"/select-tenant",
|
||||
summary="选择租户",
|
||||
description="登录后选择当前操作的租户,签发含租户上下文的新 JWT Token",
|
||||
response_model=SelectTenantOutSchema,
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def select_tenant_controller(
|
||||
request: Request,
|
||||
data: SelectTenantSchema,
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
选择租户
|
||||
|
||||
验证用户是否属于该租户,签发包含 tenant_id 的新 JWT Token。
|
||||
|
||||
参数:
|
||||
- request (Request): FastAPI请求对象
|
||||
- data (SelectTenantSchema): 租户选择请求
|
||||
- auth (AuthSchema): 当前认证信息
|
||||
- redis (Redis): Redis客户端对象
|
||||
|
||||
返回:
|
||||
- SelectTenantOutSchema: 包含新令牌的响应
|
||||
"""
|
||||
result = await LoginService.select_tenant_service(
|
||||
request=request, redis=redis, auth=auth, tenant_id=data.tenant_id
|
||||
)
|
||||
return SuccessResponse(data=result.model_dump(), msg="租户切换成功")
|
||||
|
||||
|
||||
@AuthRouter.get(
|
||||
"/tenants",
|
||||
summary="获取可选租户列表",
|
||||
description="返回当前用户关联的所有可选租户",
|
||||
response_model=list[TenantOptionSchema],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def get_user_tenants_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
获取当前用户的租户列表
|
||||
|
||||
参数:
|
||||
- auth (AuthSchema): 当前认证信息
|
||||
- db (AsyncSession): 数据库会话对象
|
||||
|
||||
返回:
|
||||
- list[TenantOptionSchema]: 租户选项列表
|
||||
"""
|
||||
tenants = await LoginService.get_user_tenants_service(auth=auth, db=db)
|
||||
return SuccessResponse(data=tenants, msg="获取租户列表成功")
|
||||
|
||||
|
||||
@AuthRouter.get(
|
||||
"/oauth/{provider}/login",
|
||||
summary="第三方OAuth跳转",
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from typing import Any, Literal, Tuple
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
import httpx
|
||||
@@ -29,6 +29,7 @@ from app.config.setting import settings
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import log
|
||||
from app.core.redis_crud import RedisCURD
|
||||
|
||||
from .schema import JWTOutSchema
|
||||
from .service import LoginService
|
||||
|
||||
@@ -312,7 +313,7 @@ async def ensure_oauth_user(
|
||||
unique_id: str,
|
||||
display_name: str,
|
||||
) -> UserModel:
|
||||
auth = AuthSchema(db=db, user=None, check_data_scope=False)
|
||||
auth = AuthSchema(db=db, user=None, tenant_id=1, check_data_scope=False)
|
||||
username = _username_for_oauth(provider, unique_id)
|
||||
existing = await UserCRUD(auth).get_by_username_crud(username=username)
|
||||
if existing:
|
||||
@@ -340,7 +341,7 @@ async def complete_oauth_login(
|
||||
provider: OAuthProvider,
|
||||
code: str,
|
||||
state: str,
|
||||
) -> Tuple[JWTOutSchema, str]:
|
||||
) -> tuple[JWTOutSchema, str]:
|
||||
rc = RedisCURD(redis)
|
||||
raw = await rc.get(f"{STATE_PREFIX}{state}")
|
||||
if not raw:
|
||||
|
||||
@@ -14,6 +14,7 @@ class AuthSchema(BaseModel):
|
||||
user: UserModel | None = Field(default=None, description="用户信息")
|
||||
check_data_scope: bool = Field(default=True, description="是否检查数据权限")
|
||||
db: AsyncSession = Field(description="数据库会话")
|
||||
tenant_id: int | None = Field(default=None, description="租户ID,用于用户认证前查询")
|
||||
|
||||
|
||||
class JWTPayloadSchema(BaseModel):
|
||||
@@ -90,3 +91,36 @@ class AutoLoginTokenSchema(BaseModel):
|
||||
|
||||
token: str = Field(..., description="免登录Token")
|
||||
user: AutoLoginUserSchema = Field(..., description="用户信息")
|
||||
|
||||
|
||||
class TenantOptionSchema(BaseModel):
|
||||
"""租户选项(用于登录后选择租户)"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int = Field(..., description="租户ID")
|
||||
name: str = Field(..., description="租户名称")
|
||||
code: str = Field(..., description="租户编码")
|
||||
|
||||
|
||||
class SelectTenantSchema(BaseModel):
|
||||
"""选择租户请求"""
|
||||
|
||||
tenant_id: int = Field(..., gt=0, description="租户ID")
|
||||
|
||||
|
||||
class SelectTenantOutSchema(BaseModel):
|
||||
"""选择租户响应"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
access_token: str = Field(..., description="访问token(含租户上下文)")
|
||||
token_type: str = Field(default="Bearer", description="token类型")
|
||||
expires_in: int = Field(..., gt=0, description="过期时间(秒)")
|
||||
|
||||
|
||||
class LoginWithTenantsSchema(JWTOutSchema):
|
||||
"""登录响应(含租户列表)"""
|
||||
|
||||
tenants: list[TenantOptionSchema] = Field(default_factory=list, description="可选租户列表")
|
||||
user_info: dict = Field(default_factory=dict, description="用户信息")
|
||||
|
||||
@@ -34,8 +34,11 @@ from .schema import (
|
||||
CaptchaOutSchema,
|
||||
JWTOutSchema,
|
||||
JWTPayloadSchema,
|
||||
LoginWithTenantsSchema,
|
||||
LogoutPayloadSchema,
|
||||
RefreshTokenPayloadSchema,
|
||||
SelectTenantOutSchema,
|
||||
TenantOptionSchema,
|
||||
)
|
||||
|
||||
CaptchaKey = NewType("CaptchaKey", str)
|
||||
@@ -52,7 +55,7 @@ class LoginService:
|
||||
redis: Redis,
|
||||
login_form: CustomOAuth2PasswordRequestForm,
|
||||
db: AsyncSession,
|
||||
) -> JWTOutSchema:
|
||||
) -> LoginWithTenantsSchema:
|
||||
"""
|
||||
用户认证
|
||||
|
||||
@@ -62,7 +65,7 @@ class LoginService:
|
||||
- db (AsyncSession): 数据库会话对象
|
||||
|
||||
返回:
|
||||
- JWTOutSchema: 包含访问令牌和刷新令牌的响应模型
|
||||
- LoginWithTenantsSchema: 包含令牌和租户列表的响应模型
|
||||
|
||||
异常:
|
||||
- CustomException: 认证失败时抛出异常。
|
||||
@@ -82,12 +85,12 @@ class LoginService:
|
||||
key=login_form.captcha_key,
|
||||
captcha=login_form.captcha,
|
||||
)
|
||||
log.info(f"[登录计时] 验证码校验: {round((time.time()-_t)*1000,1)}ms"); _t2 = time.time()
|
||||
log.info(f"[登录计时] 验证码校验: {round((time.time() - _t) * 1000, 1)}ms"); _t2 = time.time()
|
||||
|
||||
# 用户认证
|
||||
auth = AuthSchema(db=db)
|
||||
user = await UserCRUD(auth).get_by_username_crud(username=login_form.username)
|
||||
log.info(f"[登录计时] 数据库查询用户: {round((time.time()-_t2)*1000,1)}ms"); _t3 = time.time()
|
||||
log.info(f"[登录计时] 数据库查询用户: {round((time.time() - _t2) * 1000, 1)}ms"); _t3 = time.time()
|
||||
|
||||
if not user:
|
||||
raise CustomException(msg="用户不存在")
|
||||
@@ -96,14 +99,14 @@ class LoginService:
|
||||
plain_password=login_form.password, password_hash=user.password
|
||||
):
|
||||
raise CustomException(msg="账号或密码错误")
|
||||
log.info(f"[登录计时] Bcrypt密码校验: {round((time.time()-_t3)*1000,1)}ms"); _t4 = time.time()
|
||||
log.info(f"[登录计时] Bcrypt密码校验: {round((time.time() - _t3) * 1000, 1)}ms"); _t4 = time.time()
|
||||
|
||||
if user.status == "1":
|
||||
raise CustomException(msg="用户已被停用")
|
||||
|
||||
# 更新最后登录时间
|
||||
user = await UserCRUD(auth).update_last_login_crud(id=user.id)
|
||||
log.info(f"[登录计时] 更新登录时间: {round((time.time()-_t4)*1000,1)}ms"); _t5 = time.time()
|
||||
log.info(f"[登录计时] 更新登录时间: {round((time.time() - _t4) * 1000, 1)}ms"); _t5 = time.time()
|
||||
|
||||
if not user:
|
||||
raise CustomException(msg="用户不存在")
|
||||
@@ -117,11 +120,35 @@ class LoginService:
|
||||
user=user,
|
||||
login_type=login_form.login_type,
|
||||
)
|
||||
log.info(f"[登录计时] 创建Token(含IP解析+Redis写入+在线记录): {round((time.time()-_t5)*1000,1)}ms")
|
||||
log.info(f"[登录计时] 创建Token(含IP解析+Redis写入+在线记录): {round((time.time() - _t5) * 1000, 1)}ms")
|
||||
|
||||
log.info(f"[登录计时] ⭐ 登录总耗时: {round((time.time()-_t)*1000,1)}ms")
|
||||
log.info(f"[登录计时] ⭐ 登录总耗时: {round((time.time() - _t) * 1000, 1)}ms")
|
||||
|
||||
return token
|
||||
# 查询用户关联的租户列表
|
||||
_tt = time.time()
|
||||
tenants = await cls.get_user_tenants_service(
|
||||
auth=AuthSchema(db=db, tenant_id=user.tenant_id, check_data_scope=False),
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
)
|
||||
log.info(f"[登录计时] 查询租户列表: {round((time.time() - _tt) * 1000, 1)}ms")
|
||||
|
||||
user_info = {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"name": user.name,
|
||||
"avatar": user.avatar,
|
||||
"is_super_admin": user.is_superuser,
|
||||
}
|
||||
|
||||
return LoginWithTenantsSchema(
|
||||
access_token=token.access_token,
|
||||
refresh_token=token.refresh_token,
|
||||
expires_in=token.expires_in,
|
||||
token_type=token.token_type,
|
||||
tenants=tenants,
|
||||
user_info=user_info,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def create_token_service(
|
||||
@@ -174,6 +201,8 @@ class LoginService:
|
||||
session_info = OnlineOutSchema(
|
||||
session_id=session_id,
|
||||
user_id=user.id,
|
||||
tenant_id=user.tenant_id,
|
||||
is_super_admin=user.is_superuser,
|
||||
name=user.name,
|
||||
user_name=user.username,
|
||||
ipaddr=request_ip,
|
||||
@@ -268,7 +297,7 @@ class LoginService:
|
||||
refresh_expires = timedelta(seconds=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
|
||||
now = datetime.now()
|
||||
|
||||
session_info_json = json.dumps(session_info)
|
||||
session_info_json = session_info if isinstance(session_info, str) else json.dumps(session_info)
|
||||
|
||||
access_token = create_access_token(
|
||||
payload=JWTPayloadSchema(
|
||||
@@ -336,6 +365,176 @@ class LoginService:
|
||||
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
async def get_user_tenants_service(
|
||||
cls,
|
||||
auth: AuthSchema,
|
||||
db: AsyncSession,
|
||||
user_id: int | None = None,
|
||||
) -> list[TenantOptionSchema]:
|
||||
"""
|
||||
获取用户关联的租户列表
|
||||
|
||||
参数:
|
||||
- auth (AuthSchema): 认证信息对象
|
||||
- db (AsyncSession): 数据库会话对象
|
||||
- user_id (int | None): 用户ID,未传入时从 auth.user 获取
|
||||
|
||||
返回:
|
||||
- list[TenantOptionSchema]: 租户选项列表
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.v1.module_system.tenant.model import TenantModel, TenantUserModel
|
||||
|
||||
uid = user_id or (auth.user.id if auth.user else None)
|
||||
if not uid:
|
||||
return []
|
||||
|
||||
# 超管可以看到所有租户
|
||||
if auth.user and auth.user.is_superuser:
|
||||
stmt = (
|
||||
select(TenantModel)
|
||||
.where(TenantModel.status == "0", TenantModel.is_deleted == 0)
|
||||
.order_by(TenantModel.sort, TenantModel.id)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
tenant_objs = result.scalars().all()
|
||||
return [
|
||||
TenantOptionSchema(id=t.id, name=t.name, code=t.code)
|
||||
for t in tenant_objs
|
||||
]
|
||||
|
||||
# 普通用户通过 sys_user_tenant 关联表查询
|
||||
stmt = (
|
||||
select(TenantModel)
|
||||
.join(TenantUserModel, TenantUserModel.tenant_id == TenantModel.id)
|
||||
.where(
|
||||
TenantUserModel.user_id == uid,
|
||||
TenantModel.status == "0",
|
||||
TenantModel.is_deleted == 0,
|
||||
)
|
||||
.order_by(TenantUserModel.is_default.desc(), TenantModel.sort, TenantModel.id)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
tenant_objs = result.scalars().all()
|
||||
return [
|
||||
TenantOptionSchema(id=t.id, name=t.name, code=t.code)
|
||||
for t in tenant_objs
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def select_tenant_service(
|
||||
cls,
|
||||
request: Request,
|
||||
redis: Redis,
|
||||
auth: AuthSchema,
|
||||
tenant_id: int,
|
||||
) -> SelectTenantOutSchema:
|
||||
"""
|
||||
选择租户:验证用户归属并签发含租户上下文的新 JWT Token
|
||||
|
||||
参数:
|
||||
- request (Request): FastAPI请求对象
|
||||
- redis (Redis): Redis客户端对象
|
||||
- auth (AuthSchema): 当前认证信息
|
||||
- tenant_id (int): 目标租户ID
|
||||
|
||||
返回:
|
||||
- SelectTenantOutSchema: 包含新令牌的响应
|
||||
|
||||
异常:
|
||||
- CustomException: 用户不属于该租户时抛出
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.v1.module_system.tenant.model import TenantModel, TenantUserModel
|
||||
|
||||
if not auth.user:
|
||||
raise CustomException(msg="未认证用户")
|
||||
|
||||
# 超管可以选择任意租户
|
||||
if not auth.user.is_superuser:
|
||||
# 验证用户是否属于该租户
|
||||
exist_stmt = (
|
||||
select(TenantUserModel)
|
||||
.where(
|
||||
TenantUserModel.user_id == auth.user.id,
|
||||
TenantUserModel.tenant_id == tenant_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await auth.db.execute(exist_stmt)
|
||||
if not result.scalar_one_or_none():
|
||||
raise CustomException(msg="您不属于该租户,无法切换")
|
||||
|
||||
# 验证租户是否存在且状态正常
|
||||
tenant_stmt = (
|
||||
select(TenantModel)
|
||||
.where(TenantModel.id == tenant_id, TenantModel.status == "0")
|
||||
.limit(1)
|
||||
)
|
||||
result = await auth.db.execute(tenant_stmt)
|
||||
tenant = result.scalar_one_or_none()
|
||||
if not tenant:
|
||||
raise CustomException(msg="租户不存在或已被禁用")
|
||||
|
||||
# 获取当前会话信息
|
||||
token = request.headers.get("Authorization", "").removeprefix("Bearer ").strip()
|
||||
if not token:
|
||||
raise CustomException(msg="无法获取当前Token")
|
||||
|
||||
from app.core.security import decode_access_token
|
||||
|
||||
payload = decode_access_token(token)
|
||||
session_info = json.loads(payload.sub)
|
||||
session_id = session_info.get("session_id")
|
||||
|
||||
if not session_id:
|
||||
raise CustomException(msg="会话已失效")
|
||||
|
||||
# 更新会话信息中的 tenant_id
|
||||
session_info["tenant_id"] = tenant_id
|
||||
|
||||
# 签发新的 access_token(含新的 tenant_id)
|
||||
from app.core.security import create_access_token
|
||||
|
||||
access_expires = timedelta(seconds=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
now = datetime.now()
|
||||
|
||||
new_access_token = create_access_token(
|
||||
payload=JWTPayloadSchema(
|
||||
sub=json.dumps(session_info),
|
||||
is_refresh=False,
|
||||
exp=now + access_expires,
|
||||
)
|
||||
)
|
||||
|
||||
# 覆盖 Redis 中的 access_token
|
||||
from app.core.redis_crud import RedisCURD
|
||||
|
||||
await RedisCURD(redis).set(
|
||||
key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}",
|
||||
value=new_access_token,
|
||||
expire=int(access_expires.total_seconds()),
|
||||
)
|
||||
|
||||
# 同时更新租户上下文
|
||||
from app.core.tenant import set_current_tenant
|
||||
|
||||
set_current_tenant(tenant_id, auth.user.is_superuser)
|
||||
|
||||
log.info(
|
||||
f"用户 {auth.user.username}(id={auth.user.id}) 切换到租户 "
|
||||
f"{tenant.name}(id={tenant_id})"
|
||||
)
|
||||
|
||||
return SelectTenantOutSchema(
|
||||
access_token=new_access_token,
|
||||
token_type=settings.TOKEN_TYPE,
|
||||
expires_in=int(access_expires.total_seconds()),
|
||||
)
|
||||
|
||||
|
||||
class CaptchaService:
|
||||
"""验证码服务"""
|
||||
@@ -425,12 +624,15 @@ class AutoLoginService:
|
||||
TOKEN_EXPIRE = 300
|
||||
|
||||
@classmethod
|
||||
async def get_auto_login_users_service(cls, db: AsyncSession) -> list[AutoLoginUserSchema]:
|
||||
async def get_auto_login_users_service(
|
||||
cls, db: AsyncSession, tenant_id: int | None = None
|
||||
) -> list[AutoLoginUserSchema]:
|
||||
"""
|
||||
获取免登录用户列表
|
||||
|
||||
参数:
|
||||
- db (AsyncSession): 数据库会话对象
|
||||
- tenant_id (int | None): 租户ID,非超管时必传以限制租户范围
|
||||
|
||||
返回:
|
||||
- list[AutoLoginUserSchema]: 用户列表
|
||||
@@ -439,8 +641,10 @@ class AutoLoginService:
|
||||
|
||||
from app.api.v1.module_system.user.model import UserModel
|
||||
|
||||
# 查询所有启用的用户
|
||||
stmt = select(UserModel).where(UserModel.status == "0").order_by(UserModel.id)
|
||||
stmt = select(UserModel).where(UserModel.status == "0")
|
||||
if tenant_id is not None:
|
||||
stmt = stmt.where(UserModel.tenant_id == tenant_id)
|
||||
stmt = stmt.order_by(UserModel.id)
|
||||
result = await db.execute(stmt)
|
||||
users = result.scalars().all()
|
||||
|
||||
@@ -456,7 +660,11 @@ class AutoLoginService:
|
||||
|
||||
@classmethod
|
||||
async def create_auto_login_token_service(
|
||||
cls, redis: Redis, db: AsyncSession, user_id: int
|
||||
cls,
|
||||
redis: Redis,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
tenant_id: int | None = None,
|
||||
) -> AutoLoginTokenSchema:
|
||||
"""
|
||||
创建免登录Token
|
||||
@@ -466,6 +674,7 @@ class AutoLoginService:
|
||||
- redis (Redis): Redis客户端对象
|
||||
- db (AsyncSession): 数据库会话对象
|
||||
- user_id (int): 用户ID
|
||||
- tenant_id (int | None): 租户ID,非超管时必传以防止跨租户操作
|
||||
|
||||
返回:
|
||||
- AutoLoginTokenSchema: 免登录Token和用户信息
|
||||
@@ -477,8 +686,9 @@ class AutoLoginService:
|
||||
|
||||
from app.api.v1.module_system.user.model import UserModel
|
||||
|
||||
# 查询用户
|
||||
stmt = select(UserModel).where(UserModel.id == user_id)
|
||||
if tenant_id is not None:
|
||||
stmt = stmt.where(UserModel.tenant_id == tenant_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
@@ -498,6 +708,7 @@ class AutoLoginService:
|
||||
token_data = {
|
||||
"user_id": user.id,
|
||||
"username": user.username,
|
||||
"tenant_id": user.tenant_id,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
}
|
||||
await RedisCURD(redis).set(
|
||||
@@ -520,7 +731,12 @@ class AutoLoginService:
|
||||
|
||||
@classmethod
|
||||
async def auto_login_service(
|
||||
cls, request: Request, redis: Redis, db: AsyncSession, token: str
|
||||
cls,
|
||||
request: Request,
|
||||
redis: Redis,
|
||||
db: AsyncSession,
|
||||
token: str,
|
||||
tenant_id: int | None = None,
|
||||
) -> JWTOutSchema:
|
||||
"""
|
||||
免登录
|
||||
@@ -530,6 +746,7 @@ class AutoLoginService:
|
||||
- redis (Redis): Redis客户端对象
|
||||
- db (AsyncSession): 数据库会话对象
|
||||
- token (str): 免登录Token
|
||||
- tenant_id (int | None): 租户ID,非超管时必传以防止跨租户登录
|
||||
|
||||
返回:
|
||||
- JWTOutSchema: JWT令牌信息
|
||||
@@ -541,7 +758,6 @@ class AutoLoginService:
|
||||
|
||||
from app.api.v1.module_system.user.model import UserModel
|
||||
|
||||
# 验证Token
|
||||
token_key = f"{cls.AUTO_LOGIN_PREFIX}{token}"
|
||||
token_data_str = await RedisCURD(redis).get(token_key)
|
||||
|
||||
@@ -550,9 +766,12 @@ class AutoLoginService:
|
||||
|
||||
token_data = json.loads(token_data_str)
|
||||
user_id = token_data.get("user_id")
|
||||
token_tenant_id = token_data.get("tenant_id")
|
||||
|
||||
# 查询用户
|
||||
stmt = select(UserModel).where(UserModel.id == user_id)
|
||||
effective_tenant_id = tenant_id if tenant_id is not None else token_tenant_id
|
||||
if effective_tenant_id is not None:
|
||||
stmt = stmt.where(UserModel.tenant_id == effective_tenant_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String
|
||||
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.common.enums import PermissionFilterStrategy
|
||||
from app.core.base_model import ModelMixin
|
||||
from app.core.base_model import ModelMixin, TenantMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.api.v1.module_system.role.model import RoleModel
|
||||
from app.api.v1.module_system.user.model import UserModel
|
||||
|
||||
|
||||
class DeptModel(ModelMixin):
|
||||
class DeptModel(ModelMixin, TenantMixin):
|
||||
"""
|
||||
部门模型
|
||||
"""
|
||||
|
||||
__tablename__: str = "sys_dept"
|
||||
__table_args__: dict[str, str] = {"comment": "部门表"}
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "code"), {"comment": "部门表"})
|
||||
__loader_options__: list[str] = []
|
||||
__permission_strategy__: PermissionFilterStrategy = PermissionFilterStrategy.DEPT_BASED
|
||||
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False, comment="部门名称")
|
||||
order: Mapped[int] = mapped_column(Integer, nullable=False, default=999, comment="显示排序")
|
||||
code: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, unique=True, comment="部门编码"
|
||||
String(16), nullable=False, comment="部门编码"
|
||||
)
|
||||
leader: Mapped[str | None] = mapped_column(String(32), default=None, comment="部门负责人")
|
||||
phone: Mapped[str | None] = mapped_column(String(11), default=None, comment="手机")
|
||||
|
||||
@@ -521,7 +521,7 @@ async def get_init_dict_data_controller(
|
||||
- CustomException: 根据字典类型获取数据失败时抛出异常。
|
||||
"""
|
||||
dict_data_query_result = await DictDataService.get_init_dict_service(
|
||||
redis=redis, dict_type=dict_type
|
||||
redis=redis, dict_type=dict_type, tenant_id=1
|
||||
)
|
||||
log.info(f"获取初始化字典数据成功:{dict_data_query_result}")
|
||||
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, String
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.base_model import ModelMixin
|
||||
from app.core.base_model import ModelMixin, TenantMixin
|
||||
|
||||
|
||||
class DictTypeModel(ModelMixin):
|
||||
class DictTypeModel(ModelMixin, TenantMixin):
|
||||
"""
|
||||
字典类型表
|
||||
|
||||
__platform_data_shared__ = True 表示 tenant_id=1 的平台字典对
|
||||
所有租户可读,但只有平台管理员可写。
|
||||
"""
|
||||
|
||||
__tablename__: str = "sys_dict_type"
|
||||
__table_args__: dict[str, str] = {"comment": "字典类型表"}
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "dict_type"), {"comment": "字典类型表"})
|
||||
__loader_options__: list[str] = []
|
||||
__platform_data_shared__: bool = True
|
||||
|
||||
dict_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="字典名称")
|
||||
dict_type: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, unique=True, comment="字典类型"
|
||||
String(255), nullable=False, comment="字典类型"
|
||||
)
|
||||
|
||||
# 关系定义
|
||||
@@ -26,14 +30,18 @@ class DictTypeModel(ModelMixin):
|
||||
)
|
||||
|
||||
|
||||
class DictDataModel(ModelMixin):
|
||||
class DictDataModel(ModelMixin, TenantMixin):
|
||||
"""
|
||||
字典数据表
|
||||
|
||||
与 DictTypeModel 相同:tenant_id=1 的平台字典数据对
|
||||
所有租户可读,但只有平台管理员可写。
|
||||
"""
|
||||
|
||||
__tablename__: str = "sys_dict_data"
|
||||
__table_args__: dict[str, str] = {"comment": "字典数据表"}
|
||||
__loader_options__: list[str] = []
|
||||
__platform_data_shared__: bool = True
|
||||
|
||||
dict_sort: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="字典排序")
|
||||
dict_label: Mapped[str] = mapped_column(String(255), nullable=False, comment="字典标签")
|
||||
|
||||
@@ -120,7 +120,7 @@ class DictTypeService:
|
||||
|
||||
new_obj_dict = DictTypeOutSchema.model_validate(obj).model_dump()
|
||||
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{data.dict_type}"
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{auth.user.tenant_id}:{data.dict_type}"
|
||||
|
||||
try:
|
||||
await RedisCURD(redis).set(
|
||||
@@ -190,7 +190,7 @@ class DictTypeService:
|
||||
|
||||
new_obj_dict = DictTypeOutSchema.model_validate(obj).model_dump()
|
||||
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{data.dict_type}"
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{auth.user.tenant_id}:{data.dict_type}"
|
||||
try:
|
||||
# 获取当前字典类型的所有字典数据,确保包含最新状态
|
||||
dict_data_list = await DictDataCRUD(auth).get_obj_list_crud(
|
||||
@@ -238,7 +238,7 @@ class DictTypeService:
|
||||
# 如果有字典数据,不能删除
|
||||
raise CustomException(msg="删除失败,该数据字典类型下存在字典数据")
|
||||
# 删除Redis缓存
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{exist_obj.dict_type}"
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{auth.user.tenant_id}:{exist_obj.dict_type}"
|
||||
try:
|
||||
await RedisCURD(redis).delete(redis_key)
|
||||
log.info(f"删除字典类型成功: {id}")
|
||||
@@ -375,7 +375,7 @@ class DictDataService:
|
||||
@classmethod
|
||||
async def init_dict_service(cls, redis: Redis) -> None:
|
||||
"""
|
||||
应用初始化: 获取所有字典类型对应的字典数据信息并缓存service
|
||||
应用初始化: 获取所有字典类型对应的字典数据信息并按租户缓存。
|
||||
|
||||
参数:
|
||||
- redis (Redis): Redis客户端
|
||||
@@ -386,7 +386,6 @@ class DictDataService:
|
||||
try:
|
||||
async with async_db_session() as session:
|
||||
async with session.begin():
|
||||
# 在初始化过程中,不需要检查数据权限
|
||||
auth = AuthSchema(db=session, check_data_scope=False)
|
||||
obj_list = await DictTypeCRUD(auth).get_obj_list_crud()
|
||||
if not obj_list:
|
||||
@@ -395,17 +394,17 @@ class DictDataService:
|
||||
|
||||
for obj in obj_list:
|
||||
dict_type = obj.dict_type
|
||||
tenant_id = obj.tenant_id
|
||||
try:
|
||||
dict_data_list = await DictDataCRUD(auth).get_obj_list_crud(
|
||||
search={"dict_type": dict_type}
|
||||
search={"dict_type": dict_type, "tenant_id": tenant_id}
|
||||
)
|
||||
dict_data = [
|
||||
DictDataOutSchema.model_validate(row).model_dump(mode="json")
|
||||
for row in dict_data_list
|
||||
if row
|
||||
]
|
||||
# 保存到Redis并设置过期时间
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{dict_type}"
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{tenant_id}:{dict_type}"
|
||||
value = json.dumps(dict_data, ensure_ascii=False)
|
||||
await RedisCURD(redis).set(
|
||||
key=redis_key,
|
||||
@@ -417,26 +416,25 @@ class DictDataService:
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"字典初始化过程发生错误: {e}")
|
||||
# 只在严重错误时抛出异常,允许单个字典加载失败
|
||||
raise CustomException(msg=f"字典数据初始化失败: {e!s}")
|
||||
|
||||
@classmethod
|
||||
async def get_init_dict_service(cls, redis: Redis, dict_type: str) -> list[dict]:
|
||||
async def get_init_dict_service(cls, redis: Redis, dict_type: str, tenant_id: int = 1) -> list[dict]:
|
||||
"""
|
||||
从缓存获取字典数据列表信息service
|
||||
从缓存获取字典数据列表信息
|
||||
|
||||
参数:
|
||||
- redis (Redis): Redis客户端
|
||||
- dict_type (str): 字典类型
|
||||
- tenant_id (int): 租户ID
|
||||
|
||||
返回:
|
||||
- list[dict]: 字典数据列表
|
||||
"""
|
||||
try:
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{dict_type}"
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{tenant_id}:{dict_type}"
|
||||
obj_list_dict = await RedisCURD(redis).get(redis_key)
|
||||
|
||||
# 确保返回数据正确序列化
|
||||
if obj_list_dict:
|
||||
if isinstance(obj_list_dict, str):
|
||||
try:
|
||||
@@ -446,13 +444,12 @@ class DictDataService:
|
||||
elif isinstance(obj_list_dict, list):
|
||||
return obj_list_dict
|
||||
|
||||
# 缓存不存在或格式错误时重新初始化
|
||||
await cls.init_dict_service(redis)
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{tenant_id}:{dict_type}"
|
||||
obj_list_dict = await RedisCURD(redis).get(redis_key)
|
||||
if not obj_list_dict:
|
||||
raise CustomException(msg="数据字典不存在")
|
||||
|
||||
# 再次确保返回数据正确序列化
|
||||
if isinstance(obj_list_dict, str):
|
||||
try:
|
||||
return json.loads(obj_list_dict)
|
||||
@@ -496,7 +493,7 @@ class DictDataService:
|
||||
|
||||
obj = await DictDataCRUD(auth).create_obj_crud(data=data)
|
||||
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{data.dict_type}"
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{auth.user.tenant_id}:{data.dict_type}"
|
||||
try:
|
||||
# 获取当前字典类型的所有字典数据
|
||||
dict_data_list = await DictDataCRUD(auth).get_obj_list_crud(
|
||||
@@ -567,7 +564,7 @@ class DictDataService:
|
||||
if exist_obj.dict_type != data.dict_type:
|
||||
dict_type = await DictTypeCRUD(auth).get(dict_type=exist_obj.dict_type)
|
||||
if dict_type:
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{dict_type.dict_type}"
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{auth.user.tenant_id}:{dict_type.dict_type}"
|
||||
try:
|
||||
dict_data_list = await DictDataCRUD(auth).get_obj_list_crud(
|
||||
search={"dict_type": dict_type.dict_type}
|
||||
@@ -587,7 +584,7 @@ class DictDataService:
|
||||
log.error(f"更新字典数据类型变更时刷新旧缓存失败: {e}")
|
||||
|
||||
obj = await DictDataCRUD(auth).update_obj_crud(id=id, data=data)
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{data.dict_type}"
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{auth.user.tenant_id}:{data.dict_type}"
|
||||
try:
|
||||
# 获取当前字典类型的所有字典数据
|
||||
dict_data_list = await DictDataCRUD(auth).get_obj_list_crud(
|
||||
@@ -649,7 +646,7 @@ class DictDataService:
|
||||
# 清除缓存
|
||||
for dict_type in dict_types_to_clear:
|
||||
try:
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{dict_type}"
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{auth.user.tenant_id}:{dict_type}"
|
||||
await RedisCURD(redis).delete(redis_key)
|
||||
log.info(f"清除字典缓存成功: {dict_type}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -2,7 +2,7 @@ from sqlalchemy import Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.config.setting import settings
|
||||
from app.core.base_model import ModelMixin, UserMixin
|
||||
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
|
||||
|
||||
|
||||
def get_log_text_column_type():
|
||||
@@ -27,7 +27,7 @@ def get_log_text_column_type():
|
||||
return Text
|
||||
|
||||
|
||||
class OperationLogModel(ModelMixin, UserMixin):
|
||||
class OperationLogModel(ModelMixin, TenantMixin, UserMixin):
|
||||
"""
|
||||
系统日志模型
|
||||
日志类型:
|
||||
|
||||
@@ -4,13 +4,13 @@ from sqlalchemy import JSON, Boolean, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.common.enums import PermissionFilterStrategy
|
||||
from app.core.base_model import ModelMixin
|
||||
from app.core.base_model import ModelMixin, TenantMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.api.v1.module_system.role.model import RoleModel
|
||||
|
||||
|
||||
class MenuModel(ModelMixin):
|
||||
class MenuModel(ModelMixin, TenantMixin):
|
||||
"""
|
||||
菜单表 - 用于存储系统菜单信息
|
||||
|
||||
|
||||
@@ -64,8 +64,8 @@ class MenuCreateSchema(BaseModel):
|
||||
if "parent_id" in values and isinstance(values["parent_id"], str):
|
||||
try:
|
||||
values["parent_id"] = int(values["parent_id"].strip())
|
||||
except Exception:
|
||||
pass
|
||||
except (ValueError, TypeError):
|
||||
pass # parent_id 不是有效整数,保留原值
|
||||
# 路由名/路径规范
|
||||
if "route_path" in values and isinstance(values["route_path"], str):
|
||||
rp = values["route_path"]
|
||||
|
||||
@@ -230,3 +230,16 @@ async def get_obj_list_available_controller(
|
||||
result_dict = await NoticeService.get_notice_available_page_service(auth=auth)
|
||||
log.info("查询已启用公告列表成功")
|
||||
return SuccessResponse(data=result_dict, msg="查询已启用公告列表成功")
|
||||
|
||||
|
||||
@NoticeRouter.get(
|
||||
"/panel",
|
||||
summary="通知面板数据(铃铛)",
|
||||
description="返回通知铃铛所需的全部数据:通知、消息、待办",
|
||||
)
|
||||
async def get_notification_panel_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
) -> JSONResponse:
|
||||
"""通知面板聚合接口,返回通知、消息、待办三个列表。"""
|
||||
result = await NoticeService.get_panel_data_service(auth=auth)
|
||||
return SuccessResponse(data=result, msg="获取面板数据成功")
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.base_model import ModelMixin, UserMixin
|
||||
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
|
||||
|
||||
|
||||
class NoticeModel(ModelMixin, UserMixin):
|
||||
class NoticeModel(ModelMixin, TenantMixin, UserMixin):
|
||||
"""
|
||||
通知公告表
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from app.api.v1.module_system.auth.schema import AuthSchema
|
||||
from app.core.base_schema import BatchSetAvailable
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import log
|
||||
from app.utils.excel_util import ExcelUtil
|
||||
|
||||
from .crud import NoticeCRUD
|
||||
@@ -251,3 +252,61 @@ class NoticeService:
|
||||
)
|
||||
|
||||
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
|
||||
|
||||
@classmethod
|
||||
async def get_latest_notices_service(cls, auth: AuthSchema, limit: int = 5) -> list[dict]:
|
||||
"""获取最新 N 条已启用公告"""
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
from .model import NoticeModel
|
||||
from .schema import NoticeOutSchema
|
||||
|
||||
stmt = (
|
||||
select(NoticeModel)
|
||||
.where(NoticeModel.status == "0")
|
||||
.order_by(desc(NoticeModel.created_time))
|
||||
.limit(limit)
|
||||
)
|
||||
result = await auth.db.execute(stmt)
|
||||
notices = result.scalars().all()
|
||||
return [NoticeOutSchema.model_validate(n).model_dump() for n in notices]
|
||||
|
||||
@classmethod
|
||||
async def get_panel_data_service(cls, auth: AuthSchema) -> dict:
|
||||
"""聚合通知面板数据:通知 + 消息 + 待办"""
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
# 1. 通知:最新 5 条已启用公告
|
||||
notices = await cls.get_latest_notices_service(auth, limit=5)
|
||||
|
||||
# 2. 消息:最近的操作日志(作为系统消息)
|
||||
messages = []
|
||||
try:
|
||||
from app.api.v1.module_system.log.model import OperationLogModel
|
||||
|
||||
stmt = (
|
||||
select(OperationLogModel)
|
||||
.order_by(desc(OperationLogModel.created_time))
|
||||
.limit(5)
|
||||
)
|
||||
result = await auth.db.execute(stmt)
|
||||
logs = result.scalars().all()
|
||||
for log_entry in logs:
|
||||
messages.append({
|
||||
"id": log_entry.id,
|
||||
"title": log_entry.oper_param or "系统操作",
|
||||
"content": f"{log_entry.oper_user_name or '系统'} 执行了 {log_entry.title or '操作'}",
|
||||
"time": log_entry.created_time.strftime("%Y-%m-%d %H:%M") if log_entry.created_time else "",
|
||||
"type": "system",
|
||||
})
|
||||
except Exception:
|
||||
log.warning("获取面板消息数据失败(操作日志表可能不存在),已跳过")
|
||||
|
||||
# 3. 待办:暂无数据源,返回空列表
|
||||
pendings: list[dict] = []
|
||||
|
||||
return {
|
||||
"notices": notices,
|
||||
"messages": messages,
|
||||
"pendings": pendings,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Request, UploadFile
|
||||
from fastapi import APIRouter, Body, Depends, Path
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from redis.asyncio.client import Redis
|
||||
|
||||
@@ -242,28 +242,6 @@ async def export_obj_list_controller(
|
||||
)
|
||||
|
||||
|
||||
@ParamsRouter.post(
|
||||
"/upload",
|
||||
summary="上传文件",
|
||||
dependencies=[Depends(AuthPermission(["module_system:param:upload"]))],
|
||||
response_model=ResponseSchema[None],
|
||||
)
|
||||
async def upload_file_controller(file: UploadFile, request: Request) -> JSONResponse:
|
||||
"""
|
||||
上传文件
|
||||
|
||||
参数:
|
||||
- file (UploadFile): 上传的文件对象
|
||||
- request (Request): 请求对象
|
||||
|
||||
返回:
|
||||
- JSONResponse: 包含上传文件结果的 JSON 响应
|
||||
"""
|
||||
result_str = await ParamsService.upload_service(base_url=str(request.base_url), file=file)
|
||||
log.info(f"上传文件: {result_str}")
|
||||
return SuccessResponse(data=result_str, msg="上传文件成功")
|
||||
|
||||
|
||||
@ParamsRouter.get(
|
||||
"/info",
|
||||
summary="获取初始化缓存参数",
|
||||
@@ -282,6 +260,6 @@ async def get_init_obj_controller(
|
||||
返回:
|
||||
- JSONResponse: 获取初始化缓存参数的 JSON 响应
|
||||
"""
|
||||
result_dict = await ParamsService.get_init_config_service(redis=redis)
|
||||
result_dict = await ParamsService.get_init_config_service(redis=redis, tenant_id=1)
|
||||
log.info(f"获取初始化缓存参数成功 {result_dict}")
|
||||
return SuccessResponse(data=result_dict, msg="获取初始化缓存参数成功")
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from sqlalchemy import Boolean, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.base_model import ModelMixin
|
||||
from app.core.base_model import ModelMixin, TenantMixin
|
||||
|
||||
|
||||
class ParamsModel(ModelMixin):
|
||||
class ParamsModel(ModelMixin, TenantMixin):
|
||||
"""
|
||||
参数配置表
|
||||
"""
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import json
|
||||
|
||||
from fastapi import UploadFile
|
||||
from redis.asyncio.client import Redis
|
||||
|
||||
from app.api.v1.module_system.auth.schema import AuthSchema
|
||||
from app.common.enums import RedisInitKeyConfig
|
||||
from app.core.base_schema import UploadResponseSchema
|
||||
from app.core.database import async_db_session
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import log
|
||||
from app.core.redis_crud import RedisCURD
|
||||
from app.utils.excel_util import ExcelUtil
|
||||
from app.utils.upload_util import UploadUtil
|
||||
|
||||
from .crud import ParamsCRUD
|
||||
from .schema import (
|
||||
@@ -157,7 +154,7 @@ class ParamsService:
|
||||
new_obj_dict = ParamsOutSchema.model_validate(obj).model_dump()
|
||||
|
||||
# 同步redis
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{data.config_key}"
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{auth.user.tenant_id}:{data.config_key}"
|
||||
try:
|
||||
result = await RedisCURD(redis).set(
|
||||
key=redis_key,
|
||||
@@ -203,7 +200,7 @@ class ParamsService:
|
||||
redis_payload = out.model_dump(mode="json")
|
||||
|
||||
# 同步redis
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{new_obj.config_key}"
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{auth.user.tenant_id}:{new_obj.config_key}"
|
||||
try:
|
||||
value = json.dumps(redis_payload, ensure_ascii=False)
|
||||
result = await RedisCURD(redis).set(
|
||||
@@ -253,7 +250,7 @@ class ParamsService:
|
||||
exist_obj = await ParamsCRUD(auth).get_obj_by_id_crud(id=id)
|
||||
if not exist_obj:
|
||||
continue
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{exist_obj.config_key}"
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{auth.user.tenant_id}:{exist_obj.config_key}"
|
||||
try:
|
||||
await RedisCURD(redis).delete(redis_key)
|
||||
log.info(f"删除系统配置成功: {id}")
|
||||
@@ -298,31 +295,10 @@ class ParamsService:
|
||||
|
||||
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
|
||||
|
||||
@classmethod
|
||||
async def upload_service(cls, base_url: str, file: UploadFile) -> dict:
|
||||
"""
|
||||
上传文件
|
||||
|
||||
参数:
|
||||
- base_url (str): 基础URL
|
||||
- file (UploadFile): 上传的文件对象
|
||||
|
||||
返回:
|
||||
- dict: 上传文件的响应模型实例字典表示
|
||||
"""
|
||||
filename, filepath, file_url = await UploadUtil.upload_file(file=file, base_url=base_url)
|
||||
|
||||
return UploadResponseSchema(
|
||||
file_path=f"{filepath}",
|
||||
file_name=filename,
|
||||
origin_name=file.filename,
|
||||
file_url=f"{file_url}",
|
||||
).model_dump()
|
||||
|
||||
@classmethod
|
||||
async def init_config_service(cls, redis: Redis) -> None:
|
||||
"""
|
||||
初始化系统配置
|
||||
初始化系统配置并按租户缓存。
|
||||
|
||||
参数:
|
||||
- redis (Redis): Redis 客户端实例
|
||||
@@ -332,15 +308,14 @@ class ParamsService:
|
||||
"""
|
||||
async with async_db_session() as session:
|
||||
async with session.begin():
|
||||
# 在初始化过程中,不需要检查数据权限
|
||||
auth = AuthSchema(db=session, check_data_scope=False)
|
||||
config_obj = await ParamsCRUD(auth).get_obj_list_crud()
|
||||
if not config_obj:
|
||||
raise CustomException(msg="系统配置不存在")
|
||||
try:
|
||||
# 保存到Redis并设置过期时间
|
||||
for config in config_obj:
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{config.config_key}"
|
||||
tenant_id = config.tenant_id
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{tenant_id}:{config.config_key}"
|
||||
out = ParamsOutSchema.model_validate(config)
|
||||
config_obj_dict = out.model_dump()
|
||||
redis_payload = out.model_dump(mode="json")
|
||||
@@ -358,17 +333,18 @@ class ParamsService:
|
||||
raise CustomException(msg="初始化系统配置失败")
|
||||
|
||||
@classmethod
|
||||
async def get_init_config_service(cls, redis: Redis) -> list[dict]:
|
||||
async def get_init_config_service(cls, redis: Redis, tenant_id: int = 1) -> list[dict]:
|
||||
"""
|
||||
获取系统配置
|
||||
|
||||
参数:
|
||||
- redis (Redis): Redis 客户端实例
|
||||
- tenant_id (int): 租户ID
|
||||
|
||||
返回:
|
||||
- list[dict]: 系统配置模型实例字典列表表示
|
||||
"""
|
||||
redis_keys = await RedisCURD(redis).get_keys(f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:*")
|
||||
redis_keys = await RedisCURD(redis).get_keys(f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{tenant_id}:*")
|
||||
redis_configs = await RedisCURD(redis).mget(redis_keys)
|
||||
configs = []
|
||||
for config in redis_configs:
|
||||
@@ -380,6 +356,34 @@ class ParamsService:
|
||||
except Exception as e:
|
||||
log.error(f"解析系统配置数据失败: {e}")
|
||||
continue
|
||||
|
||||
# 如果 Redis 中没有数据,从数据库中加载并缓存
|
||||
if not configs:
|
||||
log.info("Redis 中没有系统配置数据,从数据库中加载")
|
||||
async with async_db_session() as session:
|
||||
async with session.begin():
|
||||
from app.api.v1.module_system.auth.schema import AuthSchema
|
||||
auth = AuthSchema(db=session, check_data_scope=False)
|
||||
config_obj = await ParamsCRUD(auth).get_obj_list_crud()
|
||||
if config_obj:
|
||||
try:
|
||||
for config in config_obj:
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{tenant_id}:{config.config_key}"
|
||||
out = ParamsOutSchema.model_validate(config)
|
||||
config_obj_dict = out.model_dump()
|
||||
redis_payload = out.model_dump(mode="json")
|
||||
value = json.dumps(redis_payload, ensure_ascii=False)
|
||||
result = await RedisCURD(redis).set(
|
||||
key=redis_key,
|
||||
value=value,
|
||||
expire=None,
|
||||
)
|
||||
if not result:
|
||||
log.error(f"❌️ 缓存系统配置失败: {config_obj_dict}")
|
||||
configs.append(config_obj_dict)
|
||||
log.info(f"✅️ 已从数据库加载 {len(configs)} 条系统配置到缓存")
|
||||
except Exception as e:
|
||||
log.error(f"❌️ 加载系统配置失败: {e}")
|
||||
|
||||
return configs
|
||||
|
||||
@@ -396,10 +400,10 @@ class ParamsService:
|
||||
"""
|
||||
# 定义需要获取的配置键
|
||||
config_keys = [
|
||||
f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:demo_enable",
|
||||
f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:ip_white_list",
|
||||
f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:white_api_list_path",
|
||||
f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:ip_black_list",
|
||||
f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:1:demo_enable",
|
||||
f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:1:ip_white_list",
|
||||
f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:1:white_api_list_path",
|
||||
f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:1:ip_black_list",
|
||||
]
|
||||
|
||||
# 批量获取配置
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path
|
||||
|
||||
from app.api.v1.module_system.auth.schema import AuthSchema
|
||||
from app.common.response import ResponseSchema, SuccessResponse
|
||||
from app.core.base_params import PaginationQueryParam
|
||||
from app.core.dependencies import AuthPermission
|
||||
from app.core.router_class import OperationLogRoute
|
||||
|
||||
from .schema import PluginCreateSchema, PluginInstallSchema, PluginOutSchema, PluginQueryParam, PluginUpdateSchema
|
||||
from .service import PluginService
|
||||
|
||||
PluginRouter = APIRouter(route_class=OperationLogRoute, prefix="/plugin", tags=["插件管理"])
|
||||
|
||||
|
||||
# ───── 超管:插件 CRUD ─────
|
||||
|
||||
@PluginRouter.get("/list", summary="插件列表", response_model=ResponseSchema[dict])
|
||||
async def plugin_list(page: Annotated[PaginationQueryParam, Depends()],
|
||||
search: Annotated[PluginQueryParam, Depends()],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:query"]))]):
|
||||
r = await PluginService.page_service(auth, page.page_no, page.page_size, search, page.order_by)
|
||||
return SuccessResponse(data=r, msg="查询成功")
|
||||
|
||||
|
||||
@PluginRouter.get("/detail/{id}", summary="插件详情", response_model=ResponseSchema[PluginOutSchema])
|
||||
async def plugin_detail(id: Annotated[int, Path()],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:query"]))]):
|
||||
return SuccessResponse(data=await PluginService.detail_service(auth, id), msg="查询成功")
|
||||
|
||||
|
||||
@PluginRouter.post("/create", summary="创建插件", response_model=ResponseSchema[PluginOutSchema])
|
||||
async def plugin_create(data: PluginCreateSchema,
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:create"]))]):
|
||||
return SuccessResponse(data=await PluginService.create_service(auth, data), msg="创建成功")
|
||||
|
||||
|
||||
@PluginRouter.put("/update/{id}", summary="更新插件", response_model=ResponseSchema[PluginOutSchema])
|
||||
async def plugin_update(id: Annotated[int, Path()], data: PluginUpdateSchema,
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:update"]))]):
|
||||
return SuccessResponse(data=await PluginService.update_service(auth, id, data), msg="更新成功")
|
||||
|
||||
|
||||
@PluginRouter.delete("/delete", summary="删除插件")
|
||||
async def plugin_delete(ids: Annotated[list[int], Body()],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:delete"]))]):
|
||||
await PluginService.delete_service(auth, ids)
|
||||
return SuccessResponse(msg="删除成功")
|
||||
|
||||
|
||||
# ───── 租户:插件市场 ─────
|
||||
|
||||
@PluginRouter.get("/marketplace", summary="插件市场", response_model=ResponseSchema[dict])
|
||||
async def marketplace(page: Annotated[PaginationQueryParam, Depends()],
|
||||
category: str | None = None,
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:query"]))] = None):
|
||||
r = await PluginService.marketplace_service(auth, page.page_no, page.page_size, category)
|
||||
return SuccessResponse(data=r, msg="查询成功")
|
||||
|
||||
|
||||
@PluginRouter.post("/install", summary="安装插件")
|
||||
async def plugin_install(data: PluginInstallSchema,
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:install"]))]):
|
||||
await PluginService.install_service(auth, data.plugin_id)
|
||||
return SuccessResponse(msg="安装成功")
|
||||
|
||||
|
||||
@PluginRouter.post("/uninstall", summary="卸载插件")
|
||||
async def plugin_uninstall(data: PluginInstallSchema,
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:uninstall"]))]):
|
||||
await PluginService.uninstall_service(auth, data.plugin_id)
|
||||
return SuccessResponse(msg="卸载成功")
|
||||
|
||||
|
||||
@PluginRouter.post("/toggle", summary="启用/禁用插件")
|
||||
async def plugin_toggle(data: PluginInstallSchema,
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:toggle"]))]):
|
||||
await PluginService.toggle_service(auth, data.plugin_id)
|
||||
return SuccessResponse(msg="操作成功")
|
||||
|
||||
|
||||
@PluginRouter.get("/my", summary="我的插件", response_model=ResponseSchema[list[PluginOutSchema]])
|
||||
async def my_plugins(auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:query"]))]):
|
||||
return SuccessResponse(data=await PluginService.my_plugins_service(auth), msg="查询成功")
|
||||
@@ -0,0 +1,11 @@
|
||||
from app.api.v1.module_system.auth.schema import AuthSchema
|
||||
from app.core.base_crud import CRUDBase
|
||||
|
||||
from .model import PluginModel
|
||||
from .schema import PluginCreateSchema, PluginUpdateSchema
|
||||
|
||||
|
||||
class PluginCRUD(CRUDBase[PluginModel, PluginCreateSchema, PluginUpdateSchema]):
|
||||
def __init__(self, auth: AuthSchema) -> None:
|
||||
self.auth = auth
|
||||
super().__init__(model=PluginModel, auth=auth)
|
||||
@@ -0,0 +1,58 @@
|
||||
from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, validates
|
||||
|
||||
from app.core.base_model import MappedBase, ModelMixin
|
||||
|
||||
|
||||
class PluginModel(ModelMixin):
|
||||
"""插件注册表 — 超管维护的插件市场列表"""
|
||||
|
||||
__tablename__: str = "sys_plugin"
|
||||
__table_args__: dict[str, str] = {"comment": "插件注册表"}
|
||||
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, comment="插件名称")
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False, unique=True, comment="插件编码(module_xxx)")
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="插件描述")
|
||||
version: Mapped[str] = mapped_column(String(20), nullable=False, default="1.0.0", comment="版本号")
|
||||
author: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="作者")
|
||||
icon: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="图标URL")
|
||||
category: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="tool", comment="分类(tool/ai/monitor/business)"
|
||||
)
|
||||
price: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="价格(分,0=免费)")
|
||||
menu_path: Mapped[str | None] = mapped_column(String(200), nullable=True, comment="菜单路径(安装后显示)")
|
||||
permission_prefix: Mapped[str | None] = mapped_column(String(100), nullable=True, comment="权限前缀")
|
||||
dependencies: Mapped[str | None] = mapped_column(Text, nullable=True, comment="依赖插件编码(JSON数组)")
|
||||
sort: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序")
|
||||
|
||||
@validates("name")
|
||||
def validate_name(self, key: str, name: str) -> str:
|
||||
if not name or not name.strip():
|
||||
raise ValueError("插件名称不能为空")
|
||||
return name.strip()
|
||||
|
||||
@validates("code")
|
||||
def validate_code(self, key: str, code: str) -> str:
|
||||
if not code or not code.strip():
|
||||
raise ValueError("插件编码不能为空")
|
||||
return code.strip()
|
||||
|
||||
|
||||
class TenantPluginModel(MappedBase):
|
||||
"""租户插件关联表 — 租户已安装的插件"""
|
||||
|
||||
__tablename__: str = "sys_tenant_plugin"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "plugin_id", name="uq_tenant_plugin"),
|
||||
{"comment": "租户插件关联表"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="主键ID")
|
||||
tenant_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("sys_tenant.id", ondelete="CASCADE"), nullable=False, index=True, comment="租户ID"
|
||||
)
|
||||
plugin_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("sys_plugin.id", ondelete="CASCADE"), nullable=False, index=True, comment="插件ID"
|
||||
)
|
||||
enabled: Mapped[str] = mapped_column(String(1), nullable=False, default="0", comment="启用(0:启用 1:禁用)")
|
||||
installed_time: Mapped[DateTime] = mapped_column(DateTime, nullable=False, comment="安装时间")
|
||||
@@ -0,0 +1,61 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class PluginCreateSchema(BaseModel):
|
||||
name: str = Field(..., max_length=100)
|
||||
code: str = Field(..., max_length=50)
|
||||
description: str | None = None
|
||||
version: str = "1.0.0"
|
||||
author: str | None = None
|
||||
icon: str | None = None
|
||||
category: str = "tool"
|
||||
price: int = 0
|
||||
menu_path: str | None = None
|
||||
permission_prefix: str | None = None
|
||||
dependencies: str | None = None
|
||||
sort: int = 0
|
||||
|
||||
|
||||
class PluginUpdateSchema(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
version: str | None = None
|
||||
author: str | None = None
|
||||
icon: str | None = None
|
||||
category: str | None = None
|
||||
price: int | None = None
|
||||
menu_path: str | None = None
|
||||
permission_prefix: str | None = None
|
||||
dependencies: str | None = None
|
||||
sort: int | None = None
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class PluginOutSchema(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
code: str
|
||||
description: str | None = None
|
||||
version: str
|
||||
author: str | None = None
|
||||
icon: str | None = None
|
||||
category: str
|
||||
price: int
|
||||
menu_path: str | None = None
|
||||
permission_prefix: str | None = None
|
||||
dependencies: str | None = None
|
||||
sort: int
|
||||
status: str
|
||||
installed: bool = False # 当前租户是否已安装
|
||||
|
||||
|
||||
class PluginQueryParam:
|
||||
def __init__(self, name: str | None = None, category: str | None = None, status: str | None = None):
|
||||
if name: self.name = ("like", name)
|
||||
if category: self.category = ("eq", category)
|
||||
if status: self.status = ("eq", status)
|
||||
|
||||
|
||||
class PluginInstallSchema(BaseModel):
|
||||
plugin_id: int = Field(..., description="插件ID")
|
||||
@@ -0,0 +1,156 @@
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app.api.v1.module_system.auth.schema import AuthSchema
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import log
|
||||
|
||||
from .crud import PluginCRUD
|
||||
from .model import PluginModel, TenantPluginModel
|
||||
from .schema import PluginCreateSchema, PluginOutSchema, PluginQueryParam, PluginUpdateSchema
|
||||
|
||||
|
||||
class PluginService:
|
||||
|
||||
def __init__(self):
|
||||
raise RuntimeError("Service is stateless, use classmethods")
|
||||
|
||||
@classmethod
|
||||
async def page_service(cls, auth: AuthSchema, page_no: int, page_size: int,
|
||||
search: PluginQueryParam | None = None, order_by: list | None = None) -> dict:
|
||||
return await PluginCRUD(auth).page(
|
||||
offset=(page_no - 1) * page_size, limit=page_size,
|
||||
order_by=order_by or [{"sort": "asc"}],
|
||||
search=search.__dict__ if search else {},
|
||||
out_schema=PluginOutSchema,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def detail_service(cls, auth: AuthSchema, id: int) -> dict:
|
||||
obj = await PluginCRUD(auth).get(id=id)
|
||||
if not obj:
|
||||
raise CustomException(msg="插件不存在")
|
||||
return PluginOutSchema.model_validate(obj).model_dump()
|
||||
|
||||
@classmethod
|
||||
async def create_service(cls, auth: AuthSchema, data: PluginCreateSchema) -> dict:
|
||||
if await PluginCRUD(auth).get(code=data.code):
|
||||
raise CustomException(msg="插件编码已存在")
|
||||
obj = await PluginCRUD(auth).create(data=data)
|
||||
return PluginOutSchema.model_validate(obj).model_dump()
|
||||
|
||||
@classmethod
|
||||
async def update_service(cls, auth: AuthSchema, id: int, data: PluginUpdateSchema) -> dict:
|
||||
obj = await PluginCRUD(auth).get(id=id)
|
||||
if not obj:
|
||||
raise CustomException(msg="插件不存在")
|
||||
updated = await PluginCRUD(auth).update(id=id, data=data)
|
||||
return PluginOutSchema.model_validate(updated).model_dump()
|
||||
|
||||
@classmethod
|
||||
async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None:
|
||||
await PluginCRUD(auth).delete(ids=ids)
|
||||
|
||||
# ───── 插件市场 API ─────
|
||||
|
||||
@classmethod
|
||||
async def marketplace_service(cls, auth: AuthSchema, page_no: int, page_size: int,
|
||||
category: str | None = None) -> dict:
|
||||
search = {}
|
||||
if category:
|
||||
search["category"] = ("eq", category)
|
||||
search["status"] = ("eq", "0")
|
||||
result = await PluginCRUD(auth).page(
|
||||
offset=(page_no - 1) * page_size, limit=page_size,
|
||||
order_by=[{"sort": "asc"}], search=search,
|
||||
out_schema=PluginOutSchema,
|
||||
)
|
||||
tenant_id = getattr(auth, "tenant_id", None) or auth.user.tenant_id
|
||||
if tenant_id and result.get("items"):
|
||||
installed = await auth.db.execute(
|
||||
sa.select(TenantPluginModel.plugin_id).where(
|
||||
TenantPluginModel.tenant_id == tenant_id,
|
||||
TenantPluginModel.enabled == "0",
|
||||
)
|
||||
)
|
||||
installed_ids = {r[0] for r in installed.all()}
|
||||
for item in result["items"]:
|
||||
item["installed"] = item["id"] in installed_ids
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
async def install_service(cls, auth: AuthSchema, plugin_id: int) -> None:
|
||||
tenant_id = getattr(auth, "tenant_id", None) or auth.user.tenant_id
|
||||
if not tenant_id:
|
||||
raise CustomException(msg="无法获取租户信息")
|
||||
plugin = await PluginCRUD(auth).get(id=plugin_id)
|
||||
if not plugin or plugin.status == "1":
|
||||
raise CustomException(msg="插件不可用")
|
||||
exist = await auth.db.execute(
|
||||
sa.select(TenantPluginModel).where(
|
||||
TenantPluginModel.tenant_id == tenant_id,
|
||||
TenantPluginModel.plugin_id == plugin_id,
|
||||
).limit(1)
|
||||
)
|
||||
if exist.scalar_one_or_none():
|
||||
await auth.db.execute(
|
||||
sa.update(TenantPluginModel).where(
|
||||
TenantPluginModel.tenant_id == tenant_id,
|
||||
TenantPluginModel.plugin_id == plugin_id,
|
||||
).values(enabled="0")
|
||||
)
|
||||
else:
|
||||
tp = TenantPluginModel(tenant_id=tenant_id, plugin_id=plugin_id, enabled="0", installed_time=datetime.now())
|
||||
auth.db.add(tp)
|
||||
await auth.db.flush()
|
||||
log.info(f"租户[{tenant_id}]安装插件[{plugin.name}]")
|
||||
|
||||
@classmethod
|
||||
async def uninstall_service(cls, auth: AuthSchema, plugin_id: int) -> None:
|
||||
tenant_id = getattr(auth, "tenant_id", None) or auth.user.tenant_id
|
||||
if not tenant_id:
|
||||
raise CustomException(msg="无法获取租户信息")
|
||||
await auth.db.execute(
|
||||
sa.delete(TenantPluginModel).where(
|
||||
TenantPluginModel.tenant_id == tenant_id,
|
||||
TenantPluginModel.plugin_id == plugin_id,
|
||||
)
|
||||
)
|
||||
await auth.db.flush()
|
||||
log.info(f"租户[{tenant_id}]卸载插件[{plugin_id}]")
|
||||
|
||||
@classmethod
|
||||
async def toggle_service(cls, auth: AuthSchema, plugin_id: int) -> None:
|
||||
tenant_id = getattr(auth, "tenant_id", None) or auth.user.tenant_id
|
||||
tp = await auth.db.execute(
|
||||
sa.select(TenantPluginModel).where(
|
||||
TenantPluginModel.tenant_id == tenant_id,
|
||||
TenantPluginModel.plugin_id == plugin_id,
|
||||
).limit(1)
|
||||
)
|
||||
tp = tp.scalar_one_or_none()
|
||||
if not tp:
|
||||
raise CustomException(msg="未安装该插件")
|
||||
tp.enabled = "1" if tp.enabled == "0" else "0"
|
||||
await auth.db.flush()
|
||||
log.info(f"租户[{tenant_id}]插件[{plugin_id}]状态→{tp.enabled}")
|
||||
|
||||
@classmethod
|
||||
async def my_plugins_service(cls, auth: AuthSchema) -> list[dict]:
|
||||
tenant_id = getattr(auth, "tenant_id", None) or auth.user.tenant_id
|
||||
if not tenant_id:
|
||||
return []
|
||||
result = await auth.db.execute(
|
||||
sa.select(PluginModel, TenantPluginModel).join(
|
||||
TenantPluginModel, TenantPluginModel.plugin_id == PluginModel.id
|
||||
).where(TenantPluginModel.tenant_id == tenant_id).order_by(PluginModel.sort)
|
||||
)
|
||||
plugins = []
|
||||
for p, tp in result.all():
|
||||
d = PluginOutSchema.model_validate(p).model_dump()
|
||||
d["enabled"] = tp.enabled
|
||||
d["installed"] = True
|
||||
d["installed_time"] = tp.installed_time.strftime("%Y-%m-%d %H:%M") if tp.installed_time else ""
|
||||
plugins.append(d)
|
||||
return plugins
|
||||
@@ -3,13 +3,13 @@ from typing import TYPE_CHECKING
|
||||
from sqlalchemy import Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.base_model import ModelMixin, UserMixin
|
||||
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.api.v1.module_system.user.model import UserModel
|
||||
|
||||
|
||||
class PositionModel(ModelMixin, UserMixin):
|
||||
class PositionModel(ModelMixin, TenantMixin, UserMixin):
|
||||
"""
|
||||
岗位模型
|
||||
"""
|
||||
|
||||
@@ -4,6 +4,7 @@ from app.api.v1.module_system.auth.schema import AuthSchema
|
||||
from app.api.v1.module_system.dept.crud import DeptCRUD
|
||||
from app.api.v1.module_system.menu.crud import MenuCRUD
|
||||
from app.core.base_crud import CRUDBase
|
||||
from app.core.exceptions import CustomException
|
||||
|
||||
from .model import RoleModel
|
||||
from .schema import RoleCreateSchema, RoleUpdateSchema
|
||||
@@ -76,6 +77,21 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]):
|
||||
else await MenuCRUD(self.auth).get_list_crud(search={"id": ("in", menu_ids)})
|
||||
)
|
||||
|
||||
# 租户菜单约束:只允许分配租户菜单权限内的菜单
|
||||
from app.api.v1.module_system.tenant.service import TenantService
|
||||
|
||||
allowed_menu_ids = None
|
||||
if self.auth.user and not self.auth.user.is_superuser:
|
||||
allowed_menu_ids = await TenantService.get_tenant_menu_ids(
|
||||
self.auth, self.auth.user.tenant_id
|
||||
)
|
||||
if allowed_menu_ids is not None:
|
||||
for menu in menus:
|
||||
if int(menu.id) not in allowed_menu_ids:
|
||||
raise CustomException(
|
||||
msg=f"菜单[{menu.name}]不在当前租户的功能组内,无法分配"
|
||||
)
|
||||
|
||||
for obj in roles:
|
||||
relationship = obj.menus
|
||||
relationship.clear()
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String
|
||||
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.common.enums import PermissionFilterStrategy
|
||||
from app.core.base_model import MappedBase, ModelMixin
|
||||
from app.core.base_model import MappedBase, ModelMixin, TenantMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.api.v1.module_system.dept.model import DeptModel
|
||||
@@ -61,7 +61,7 @@ class RoleDeptsModel(MappedBase):
|
||||
)
|
||||
|
||||
|
||||
class RoleModel(ModelMixin):
|
||||
class RoleModel(ModelMixin, TenantMixin):
|
||||
"""
|
||||
角色模型
|
||||
|
||||
@@ -69,13 +69,13 @@ class RoleModel(ModelMixin):
|
||||
"""
|
||||
|
||||
__tablename__: str = "sys_role"
|
||||
__table_args__: dict[str, str] = {"comment": "角色表"}
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "code"), {"comment": "角色表"})
|
||||
__loader_options__: list[str] = ["menus", "depts"]
|
||||
__permission_strategy__: PermissionFilterStrategy = PermissionFilterStrategy.USER_ROLE
|
||||
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False, comment="角色名称")
|
||||
code: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, unique=True, comment="角色编码"
|
||||
String(16), nullable=False, comment="角色编码"
|
||||
)
|
||||
order: Mapped[int] = mapped_column(Integer, nullable=False, default=999, comment="显示排序")
|
||||
data_scope: Mapped[int] = mapped_column(
|
||||
|
||||
@@ -2,16 +2,29 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path
|
||||
from fastapi.responses import JSONResponse
|
||||
from redis.asyncio.client import Redis
|
||||
|
||||
from app.api.v1.module_system.auth.schema import AuthSchema
|
||||
from app.common.response import ResponseSchema, SuccessResponse
|
||||
from app.core.base_params import PaginationQueryParam
|
||||
from app.core.base_schema import BatchSetAvailable
|
||||
from app.core.dependencies import AuthPermission
|
||||
from app.core.dependencies import AuthPermission, redis_getter
|
||||
from app.core.logger import log
|
||||
from app.core.router_class import OperationLogRoute
|
||||
|
||||
from .schema import TenantCreateSchema, TenantOutSchema, TenantQueryParam, TenantUpdateSchema
|
||||
from .schema import (
|
||||
TenantConfigItem,
|
||||
TenantConfigOutSchema,
|
||||
TenantCreateSchema,
|
||||
TenantMenuSetSchema,
|
||||
TenantOutSchema,
|
||||
TenantQuotaOutSchema,
|
||||
TenantQuotaUpdateSchema,
|
||||
TenantQueryParam,
|
||||
TenantUpdateSchema,
|
||||
TenantUserAddSchema,
|
||||
TenantUserOutSchema,
|
||||
)
|
||||
from .service import TenantService
|
||||
|
||||
TenantRouter = APIRouter(route_class=OperationLogRoute, prefix="/tenant", tags=["租户管理"])
|
||||
@@ -114,3 +127,177 @@ async def batch_set_available_obj_controller(
|
||||
await TenantService.set_available_service(auth=auth, data=data)
|
||||
log.info(f"批量修改租户状态成功: {data.ids}")
|
||||
return SuccessResponse(msg="批量修改租户状态成功")
|
||||
|
||||
|
||||
@TenantRouter.put(
|
||||
"/status/{id}",
|
||||
summary="启/禁用租户",
|
||||
description="修改单个租户的启用/禁用状态",
|
||||
)
|
||||
async def toggle_tenant_status_controller(
|
||||
id: Annotated[int, Path(description="租户ID")],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:patch"]))],
|
||||
) -> JSONResponse:
|
||||
await TenantService.toggle_status_service(auth=auth, id=id)
|
||||
log.info(f"修改租户状态成功: {id}")
|
||||
return SuccessResponse(msg="修改租户状态成功")
|
||||
|
||||
|
||||
@TenantRouter.get(
|
||||
"/{id}/users",
|
||||
summary="获取租户用户列表",
|
||||
description="获取指定租户下的所有用户",
|
||||
response_model=ResponseSchema[list[TenantUserOutSchema]],
|
||||
)
|
||||
async def get_tenant_users_controller(
|
||||
id: Annotated[int, Path(description="租户ID")],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))],
|
||||
) -> JSONResponse:
|
||||
result = await TenantService.get_tenant_users_service(auth=auth, tenant_id=id)
|
||||
log.info(f"获取租户用户列表成功: tenant_id={id}")
|
||||
return SuccessResponse(data=result, msg="获取租户用户列表成功")
|
||||
|
||||
|
||||
@TenantRouter.post(
|
||||
"/{id}/users",
|
||||
summary="向租户添加用户",
|
||||
description="将指定用户添加到租户中",
|
||||
response_model=ResponseSchema[None],
|
||||
)
|
||||
async def add_tenant_user_controller(
|
||||
id: Annotated[int, Path(description="租户ID")],
|
||||
data: TenantUserAddSchema,
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:create"]))],
|
||||
) -> JSONResponse:
|
||||
await TenantService.add_tenant_user_service(auth=auth, tenant_id=id, data=data)
|
||||
log.info(f"向租户添加用户成功: tenant_id={id}, user_id={data.user_id}")
|
||||
return SuccessResponse(msg="添加用户成功")
|
||||
|
||||
|
||||
@TenantRouter.delete(
|
||||
"/{id}/users/{uid}",
|
||||
summary="从租户移除用户",
|
||||
description="将指定用户从租户中移除",
|
||||
response_model=ResponseSchema[None],
|
||||
)
|
||||
async def remove_tenant_user_controller(
|
||||
id: Annotated[int, Path(description="租户ID")],
|
||||
uid: Annotated[int, Path(description="用户ID")],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:delete"]))],
|
||||
) -> JSONResponse:
|
||||
await TenantService.remove_tenant_user_service(auth=auth, tenant_id=id, user_id=uid)
|
||||
log.info(f"从租户移除用户成功: tenant_id={id}, user_id={uid}")
|
||||
return SuccessResponse(msg="移除用户成功")
|
||||
|
||||
|
||||
# ============ P1: 配额管理 ============
|
||||
|
||||
|
||||
@TenantRouter.get(
|
||||
"/{id}/quota",
|
||||
summary="获取租户配额",
|
||||
description="获取指定租户的资源配额信息",
|
||||
response_model=ResponseSchema[TenantQuotaOutSchema],
|
||||
)
|
||||
async def get_tenant_quota_controller(
|
||||
id: Annotated[int, Path(description="租户ID")],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))],
|
||||
) -> JSONResponse:
|
||||
result = await TenantService.get_quota_service(auth=auth, tenant_id=id)
|
||||
return SuccessResponse(data=result, msg="获取租户配额成功")
|
||||
|
||||
|
||||
@TenantRouter.put(
|
||||
"/{id}/quota",
|
||||
summary="修改租户配额",
|
||||
description="修改指定租户的资源配额(需超级管理员权限)",
|
||||
response_model=ResponseSchema[TenantQuotaOutSchema],
|
||||
)
|
||||
async def update_tenant_quota_controller(
|
||||
id: Annotated[int, Path(description="租户ID")],
|
||||
data: TenantQuotaUpdateSchema,
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:update"]))],
|
||||
) -> JSONResponse:
|
||||
result = await TenantService.update_quota_service(auth=auth, tenant_id=id, data=data)
|
||||
return SuccessResponse(data=result, msg="修改租户配额成功")
|
||||
|
||||
|
||||
# ============ P1: 租户配置 ============
|
||||
|
||||
|
||||
@TenantRouter.get(
|
||||
"/{id}/config",
|
||||
summary="获取租户配置",
|
||||
description="获取指定租户的个性化配置",
|
||||
response_model=ResponseSchema[list[TenantConfigOutSchema]],
|
||||
)
|
||||
async def get_tenant_config_controller(
|
||||
id: Annotated[int, Path(description="租户ID")],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))],
|
||||
) -> JSONResponse:
|
||||
result = await TenantService.get_config_service(auth=auth, tenant_id=id)
|
||||
return SuccessResponse(data=result, msg="获取租户配置成功")
|
||||
|
||||
|
||||
@TenantRouter.get(
|
||||
"/{id}/config/info",
|
||||
summary="获取租户配置(公开-缓存)",
|
||||
description="从 Redis 缓存获取租户个性化配置,无需登录(供登录页等场景使用)",
|
||||
response_model=ResponseSchema[list[TenantConfigOutSchema]],
|
||||
)
|
||||
async def get_tenant_config_info_controller(
|
||||
id: Annotated[int, Path(description="租户ID")],
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
) -> JSONResponse:
|
||||
result = await TenantService.get_config_cache_service(redis=redis, tenant_id=id)
|
||||
return SuccessResponse(data=result, msg="获取租户配置成功")
|
||||
|
||||
|
||||
@TenantRouter.put(
|
||||
"/{id}/config",
|
||||
summary="更新租户配置",
|
||||
description="批量更新租户的个性化配置",
|
||||
response_model=ResponseSchema[list[TenantConfigOutSchema]],
|
||||
)
|
||||
async def update_tenant_config_controller(
|
||||
id: Annotated[int, Path(description="租户ID")],
|
||||
data: Annotated[list[TenantConfigItem], Body(..., description="配置项列表")],
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:update"]))],
|
||||
) -> JSONResponse:
|
||||
result = await TenantService.update_config_service(
|
||||
auth=auth, redis=redis, tenant_id=id, items=data
|
||||
)
|
||||
return SuccessResponse(data=result, msg="更新租户配置成功")
|
||||
|
||||
|
||||
# ============ P1: 租户菜单权限 ============
|
||||
|
||||
|
||||
@TenantRouter.get(
|
||||
"/{id}/menus",
|
||||
summary="获取租户菜单权限",
|
||||
description="获取指定租户有权限访问的菜单ID列表",
|
||||
response_model=ResponseSchema[list[int]],
|
||||
)
|
||||
async def get_tenant_menus_controller(
|
||||
id: Annotated[int, Path(description="租户ID")],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))],
|
||||
) -> JSONResponse:
|
||||
result = await TenantService.get_menus_service(auth=auth, tenant_id=id)
|
||||
return SuccessResponse(data=result, msg="获取租户菜单成功")
|
||||
|
||||
|
||||
@TenantRouter.put(
|
||||
"/{id}/menus",
|
||||
summary="设置租户菜单权限",
|
||||
description="批量设置租户的菜单权限(先清空再写入,需超级管理员权限)",
|
||||
)
|
||||
async def set_tenant_menus_controller(
|
||||
id: Annotated[int, Path(description="租户ID")],
|
||||
data: TenantMenuSetSchema,
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:update"]))],
|
||||
) -> JSONResponse:
|
||||
await TenantService.set_menus_service(auth=auth, tenant_id=id, data=data)
|
||||
log.info(f"设置租户菜单权限成功: tenant_id={id}, count={len(data.menu_ids)}")
|
||||
return SuccessResponse(msg="设置租户菜单权限成功")
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, validates
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, SmallInteger, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||
|
||||
from app.common.enums import PermissionFilterStrategy
|
||||
from app.core.base_model import ModelMixin
|
||||
from app.core.base_model import MappedBase, ModelMixin
|
||||
|
||||
|
||||
class TenantModel(ModelMixin):
|
||||
@@ -21,8 +21,33 @@ class TenantModel(ModelMixin):
|
||||
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, comment="租户名称")
|
||||
code: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, comment="租户编码")
|
||||
start_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None, comment="开始时间")
|
||||
end_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None, comment="结束时间")
|
||||
contact_name: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, default=None, comment="联系人姓名"
|
||||
)
|
||||
contact_phone: Mapped[str | None] = mapped_column(
|
||||
String(20), nullable=True, default=None, comment="联系人电话"
|
||||
)
|
||||
contact_email: Mapped[str | None] = mapped_column(
|
||||
String(128), nullable=True, default=None, comment="联系人邮箱"
|
||||
)
|
||||
address: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, default=None, comment="地址"
|
||||
)
|
||||
domain: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, default=None, comment="域名"
|
||||
)
|
||||
logo_url: Mapped[str | None] = mapped_column(
|
||||
String(500), nullable=True, default=None, comment="Logo URL"
|
||||
)
|
||||
sort: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, comment="排序"
|
||||
)
|
||||
start_time: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, nullable=True, default=None, comment="开始时间"
|
||||
)
|
||||
end_time: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, nullable=True, default=None, comment="结束时间"
|
||||
)
|
||||
|
||||
@validates("name")
|
||||
def validate_name(self, key: str, name: str) -> str:
|
||||
@@ -37,3 +62,127 @@ class TenantModel(ModelMixin):
|
||||
if not code.isalnum():
|
||||
raise ValueError("编码只能包含字母和数字")
|
||||
return code
|
||||
|
||||
|
||||
class TenantUserModel(MappedBase):
|
||||
"""
|
||||
用户-租户关联表
|
||||
|
||||
支持一个用户关联多个租户(如顾问在多个租户间切换)。
|
||||
每个用户有一个默认租户(is_default=1),用于登录后的默认上下文。
|
||||
"""
|
||||
|
||||
__tablename__: str = "sys_user_tenant"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "tenant_id", name="uq_user_tenant"),
|
||||
{"comment": "用户租户关联表"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("sys_user.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="用户ID",
|
||||
)
|
||||
tenant_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("sys_tenant.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="租户ID",
|
||||
)
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="member",
|
||||
comment="租户内角色(owner:拥有者 admin:管理员 member:成员)",
|
||||
)
|
||||
is_default: Mapped[int] = mapped_column(
|
||||
SmallInteger,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="是否默认租户(0:否 1:是)",
|
||||
)
|
||||
create_time: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
default=datetime.now,
|
||||
nullable=False,
|
||||
comment="创建时间",
|
||||
)
|
||||
|
||||
|
||||
class TenantQuotaModel(MappedBase):
|
||||
"""租户配额模型 — 限制租户资源使用上限"""
|
||||
|
||||
__tablename__: str = "sys_tenant_quota"
|
||||
__table_args__: dict[str, str] = {"comment": "租户配额表"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="主键ID")
|
||||
tenant_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("sys_tenant.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
index=True,
|
||||
comment="租户ID",
|
||||
)
|
||||
max_users: Mapped[int] = mapped_column(Integer, nullable=False, default=50, comment="最大用户数")
|
||||
max_roles: Mapped[int] = mapped_column(Integer, nullable=False, default=20, comment="最大角色数")
|
||||
max_storage_mb: Mapped[int] = mapped_column(Integer, nullable=False, default=500, comment="最大存储(MB)")
|
||||
max_depts: Mapped[int] = mapped_column(Integer, nullable=False, default=50, comment="最大部门数")
|
||||
|
||||
tenant: Mapped["TenantModel"] = relationship("TenantModel", lazy="selectin")
|
||||
|
||||
|
||||
class TenantConfigModel(MappedBase):
|
||||
"""租户个性化配置模型 — 键值对存储"""
|
||||
|
||||
__tablename__: str = "sys_tenant_config"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "config_key", name="uq_tenant_config_key"),
|
||||
{"comment": "租户配置表"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="主键ID")
|
||||
tenant_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("sys_tenant.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="租户ID",
|
||||
)
|
||||
config_key: Mapped[str] = mapped_column(String(100), nullable=False, comment="配置键")
|
||||
config_value: Mapped[str | None] = mapped_column(Text, nullable=True, comment="配置值")
|
||||
config_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="string", comment="配置类型(string/json/int/bool)"
|
||||
)
|
||||
|
||||
|
||||
class TenantMenuModel(MappedBase):
|
||||
"""租户菜单权限模型 — 控制租户可见的菜单项"""
|
||||
|
||||
__tablename__: str = "sys_tenant_menu"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "menu_id", name="uq_tenant_menu"),
|
||||
{"comment": "租户菜单权限表"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="主键ID")
|
||||
tenant_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("sys_tenant.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="租户ID",
|
||||
)
|
||||
menu_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("sys_menu.id", ondelete="CASCADE", onupdate="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="菜单ID",
|
||||
)
|
||||
|
||||
@@ -15,6 +15,13 @@ class TenantCreateSchema(BaseModel):
|
||||
description: str | None = Field(default=None, max_length=255, description="描述")
|
||||
start_time: DateTimeStr | None = Field(default=None, description="开始时间")
|
||||
end_time: DateTimeStr | None = Field(default=None, description="结束时间")
|
||||
contact_name: str | None = Field(default=None, max_length=64, description="联系人姓名")
|
||||
contact_phone: str | None = Field(default=None, max_length=20, description="联系人电话")
|
||||
contact_email: str | None = Field(default=None, max_length=128, description="联系人邮箱")
|
||||
address: str | None = Field(default=None, max_length=255, description="地址")
|
||||
domain: str | None = Field(default=None, max_length=255, description="域名")
|
||||
logo_url: str | None = Field(default=None, max_length=500, description="Logo URL")
|
||||
sort: int = Field(default=0, description="排序")
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
@@ -50,6 +57,13 @@ class TenantUpdateSchema(BaseModel):
|
||||
description: str | None = Field(default=None, max_length=255, description="描述")
|
||||
start_time: DateTimeStr | None = Field(default=None, description="开始时间")
|
||||
end_time: DateTimeStr | None = Field(default=None, description="结束时间")
|
||||
contact_name: str | None = Field(default=None, max_length=64, description="联系人姓名")
|
||||
contact_phone: str | None = Field(default=None, max_length=20, description="联系人电话")
|
||||
contact_email: str | None = Field(default=None, max_length=128, description="联系人邮箱")
|
||||
address: str | None = Field(default=None, max_length=255, description="地址")
|
||||
domain: str | None = Field(default=None, max_length=255, description="域名")
|
||||
logo_url: str | None = Field(default=None, max_length=500, description="Logo URL")
|
||||
sort: int | None = Field(default=None, description="排序")
|
||||
|
||||
@field_validator("code")
|
||||
@classmethod
|
||||
@@ -96,3 +110,77 @@ class TenantQueryParam:
|
||||
self.status = (QueueEnum.eq.value, status)
|
||||
if created_time and len(created_time) == 2:
|
||||
self.created_time = (QueueEnum.between.value, (created_time[0], created_time[1]))
|
||||
|
||||
|
||||
class TenantUserAddSchema(BaseModel):
|
||||
"""向租户添加用户"""
|
||||
|
||||
user_id: int = Field(..., description="用户ID")
|
||||
role: str = Field(default="member", description="租户内角色(owner/admin/member)")
|
||||
is_default: int = Field(default=0, description="是否默认租户(0:否 1:是)")
|
||||
|
||||
|
||||
class TenantUserOutSchema(BaseModel):
|
||||
"""租户用户响应"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int = Field(..., description="关联ID")
|
||||
user_id: int = Field(..., description="用户ID")
|
||||
tenant_id: int = Field(..., description="租户ID")
|
||||
role: str = Field(..., description="租户内角色")
|
||||
is_default: int = Field(..., description="是否默认租户")
|
||||
create_time: DateTimeStr | None = Field(default=None, description="创建时间")
|
||||
username: str = Field(default="", description="用户名")
|
||||
name: str = Field(default="", description="用户姓名")
|
||||
|
||||
|
||||
# ============ P1: 配额管理 ============
|
||||
|
||||
class TenantQuotaOutSchema(BaseModel):
|
||||
"""租户配额响应"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
tenant_id: int
|
||||
max_users: int
|
||||
max_roles: int
|
||||
max_storage_mb: int
|
||||
max_depts: int
|
||||
|
||||
|
||||
class TenantQuotaUpdateSchema(BaseModel):
|
||||
"""租户配额更新"""
|
||||
|
||||
max_users: int | None = Field(default=None, ge=0, description="最大用户数")
|
||||
max_roles: int | None = Field(default=None, ge=0, description="最大角色数")
|
||||
max_storage_mb: int | None = Field(default=None, ge=0, description="最大存储(MB)")
|
||||
max_depts: int | None = Field(default=None, ge=0, description="最大部门数")
|
||||
|
||||
|
||||
# ============ P1: 租户配置 ============
|
||||
|
||||
class TenantConfigItem(BaseModel):
|
||||
"""单个配置项"""
|
||||
|
||||
config_key: str = Field(..., description="配置键")
|
||||
config_value: str = Field(..., description="配置值")
|
||||
config_type: str = Field(default="string", description="配置类型")
|
||||
|
||||
|
||||
class TenantConfigOutSchema(TenantConfigItem):
|
||||
"""租户配置响应"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
tenant_id: int
|
||||
|
||||
|
||||
# ============ P1: 租户菜单 ============
|
||||
|
||||
class TenantMenuSetSchema(BaseModel):
|
||||
"""批量设置租户菜单权限"""
|
||||
|
||||
menu_ids: list[int] = Field(..., description="菜单ID列表")
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
import json
|
||||
import random
|
||||
import string
|
||||
|
||||
import sqlalchemy as sa
|
||||
from redis.asyncio.client import Redis
|
||||
|
||||
from app.api.v1.module_system.auth.schema import AuthSchema
|
||||
from app.api.v1.module_system.dept.crud import DeptCRUD
|
||||
from app.api.v1.module_system.position.crud import PositionCRUD
|
||||
from app.api.v1.module_system.role.crud import RoleCRUD
|
||||
from app.api.v1.module_system.user.crud import UserCRUD
|
||||
from app.common.enums import RedisInitKeyConfig
|
||||
from app.core.base_schema import BatchSetAvailable
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import log
|
||||
from app.core.redis_crud import RedisCURD
|
||||
from app.utils.hash_bcrpy_util import PwdUtil
|
||||
|
||||
from .crud import TenantCRUD
|
||||
from .schema import TenantCreateSchema, TenantOutSchema, TenantQueryParam, TenantUpdateSchema
|
||||
from .model import TenantConfigModel, TenantMenuModel, TenantModel, TenantQuotaModel, TenantUserModel
|
||||
from .schema import (
|
||||
TenantConfigItem,
|
||||
TenantConfigOutSchema,
|
||||
TenantCreateSchema,
|
||||
TenantMenuSetSchema,
|
||||
TenantOutSchema,
|
||||
TenantQuotaOutSchema,
|
||||
TenantQuotaUpdateSchema,
|
||||
TenantQueryParam,
|
||||
TenantUpdateSchema,
|
||||
TenantUserAddSchema,
|
||||
TenantUserOutSchema,
|
||||
)
|
||||
|
||||
|
||||
class TenantService:
|
||||
@@ -23,7 +42,8 @@ class TenantService:
|
||||
obj = await TenantCRUD(auth).get_by_id_crud(id=id)
|
||||
if not obj:
|
||||
raise CustomException(msg="租户不存在")
|
||||
return TenantOutSchema.model_validate(obj).model_dump()
|
||||
result = TenantOutSchema.model_validate(obj).model_dump()
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
async def page_service(
|
||||
@@ -87,7 +107,14 @@ class TenantService:
|
||||
)
|
||||
|
||||
await auth.db.refresh(tenant_obj)
|
||||
return TenantOutSchema.model_validate(tenant_obj).model_dump()
|
||||
result = TenantOutSchema.model_validate(tenant_obj).model_dump()
|
||||
|
||||
# P1: 自动初始化租户配额
|
||||
quota = TenantQuotaModel(tenant_id=tenant_obj.id)
|
||||
auth.db.add(quota)
|
||||
await auth.db.flush()
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
async def update_service(cls, auth: AuthSchema, id: int, data: TenantUpdateSchema) -> dict:
|
||||
@@ -113,7 +140,8 @@ class TenantService:
|
||||
updated = await TenantCRUD(auth).update_crud(id=id, data=data)
|
||||
if not updated:
|
||||
raise CustomException(msg="更新失败")
|
||||
return TenantOutSchema.model_validate(updated).model_dump()
|
||||
result = TenantOutSchema.model_validate(updated).model_dump()
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None:
|
||||
@@ -146,3 +174,425 @@ class TenantService:
|
||||
if data.status == "1" and 1 in data.ids:
|
||||
raise CustomException(msg="系统租户不允许禁用")
|
||||
await TenantCRUD(auth).set_available_crud(ids=data.ids, status=data.status)
|
||||
|
||||
@classmethod
|
||||
async def toggle_status_service(cls, auth: AuthSchema, id: int) -> None:
|
||||
"""切换单个租户的启用/禁用状态"""
|
||||
obj = await TenantCRUD(auth).get_by_id_crud(id=id)
|
||||
if not obj:
|
||||
raise CustomException(msg="租户不存在")
|
||||
if id == 1:
|
||||
raise CustomException(msg="系统租户不允许禁用")
|
||||
new_status = "0" if obj.status == "1" else "1"
|
||||
await TenantCRUD(auth).set_available_crud(ids=[id], status=new_status)
|
||||
|
||||
@classmethod
|
||||
async def get_tenant_users_service(
|
||||
cls, auth: AuthSchema, tenant_id: int
|
||||
) -> list[dict]:
|
||||
"""获取租户下的用户列表"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.v1.module_system.user.model import UserModel
|
||||
|
||||
stmt = (
|
||||
select(TenantUserModel, UserModel)
|
||||
.join(UserModel, UserModel.id == TenantUserModel.user_id)
|
||||
.where(TenantUserModel.tenant_id == tenant_id)
|
||||
.order_by(TenantUserModel.is_default.desc(), TenantUserModel.id)
|
||||
)
|
||||
result = await auth.db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
users = []
|
||||
for tu, u in rows:
|
||||
users.append(
|
||||
TenantUserOutSchema(
|
||||
id=tu.id,
|
||||
user_id=tu.user_id,
|
||||
tenant_id=tu.tenant_id,
|
||||
role=tu.role,
|
||||
is_default=tu.is_default,
|
||||
create_time=tu.create_time,
|
||||
username=u.username,
|
||||
name=u.name,
|
||||
).model_dump()
|
||||
)
|
||||
return users
|
||||
|
||||
@classmethod
|
||||
async def add_tenant_user_service(
|
||||
cls, auth: AuthSchema, tenant_id: int, data: TenantUserAddSchema
|
||||
) -> None:
|
||||
"""向租户添加用户"""
|
||||
# 验证租户存在
|
||||
tenant = await TenantCRUD(auth).get_by_id_crud(id=tenant_id)
|
||||
if not tenant:
|
||||
raise CustomException(msg="租户不存在")
|
||||
|
||||
# 验证用户存在
|
||||
from app.api.v1.module_system.user.crud import UserCRUD
|
||||
|
||||
user = await UserCRUD(auth).get_by_id_crud(id=data.user_id)
|
||||
if not user:
|
||||
raise CustomException(msg="用户不存在")
|
||||
|
||||
# 检查是否已关联
|
||||
from sqlalchemy import select
|
||||
|
||||
exist_stmt = (
|
||||
select(TenantUserModel)
|
||||
.where(
|
||||
TenantUserModel.user_id == data.user_id,
|
||||
TenantUserModel.tenant_id == tenant_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await auth.db.execute(exist_stmt)
|
||||
if result.scalar_one_or_none():
|
||||
raise CustomException(msg="该用户已关联此租户")
|
||||
|
||||
# 如果设为默认租户,先取消其他默认
|
||||
if data.is_default == 1:
|
||||
await auth.db.execute(
|
||||
sa.update(TenantUserModel)
|
||||
.where(TenantUserModel.user_id == data.user_id)
|
||||
.values(is_default=0)
|
||||
)
|
||||
elif data.is_default == 0:
|
||||
# 检查是否是该用户的第一个租户关联
|
||||
count_result = await auth.db.execute(
|
||||
select(sa.func.count()).select_from(TenantUserModel).where(
|
||||
TenantUserModel.user_id == data.user_id
|
||||
)
|
||||
)
|
||||
count = count_result.scalar()
|
||||
if count == 0:
|
||||
# 第一个租户自动设为默认
|
||||
data.is_default = 1
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
tu = TenantUserModel(
|
||||
user_id=data.user_id,
|
||||
tenant_id=tenant_id,
|
||||
role=data.role,
|
||||
is_default=data.is_default,
|
||||
create_time=datetime.now(),
|
||||
)
|
||||
auth.db.add(tu)
|
||||
await auth.db.flush()
|
||||
|
||||
log.info(
|
||||
f"向租户[{tenant.name}]添加用户[{user.username}]成功, role={data.role}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def remove_tenant_user_service(
|
||||
cls, auth: AuthSchema, tenant_id: int, user_id: int
|
||||
) -> None:
|
||||
"""从租户移除用户"""
|
||||
from sqlalchemy import select
|
||||
|
||||
# 查找关联记录
|
||||
exist_stmt = (
|
||||
select(TenantUserModel)
|
||||
.where(
|
||||
TenantUserModel.user_id == user_id,
|
||||
TenantUserModel.tenant_id == tenant_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await auth.db.execute(exist_stmt)
|
||||
tu = result.scalar_one_or_none()
|
||||
if not tu:
|
||||
raise CustomException(msg="该用户未关联此租户")
|
||||
|
||||
# 不允许移除租户最后一个 owner
|
||||
if tu.role == "owner":
|
||||
count_result = await auth.db.execute(
|
||||
select(sa.func.count())
|
||||
.select_from(TenantUserModel)
|
||||
.where(
|
||||
TenantUserModel.tenant_id == tenant_id,
|
||||
TenantUserModel.role == "owner",
|
||||
)
|
||||
)
|
||||
owner_count = count_result.scalar()
|
||||
if owner_count <= 1:
|
||||
raise CustomException(msg="租户至少需要保留一个拥有者(owner)")
|
||||
|
||||
await auth.db.delete(tu)
|
||||
await auth.db.flush()
|
||||
|
||||
log.info(f"从租户[{tenant_id}]移除用户[{user_id}]成功")
|
||||
|
||||
# ============ P1: 配额管理 ============
|
||||
|
||||
@classmethod
|
||||
async def get_quota_service(cls, auth: AuthSchema, tenant_id: int) -> dict:
|
||||
"""获取租户配额"""
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(TenantQuotaModel).where(TenantQuotaModel.tenant_id == tenant_id).limit(1)
|
||||
result = await auth.db.execute(stmt)
|
||||
quota = result.scalar_one_or_none()
|
||||
if not quota:
|
||||
quota = TenantQuotaModel(tenant_id=tenant_id)
|
||||
auth.db.add(quota)
|
||||
await auth.db.flush()
|
||||
return TenantQuotaOutSchema.model_validate(quota).model_dump()
|
||||
|
||||
@classmethod
|
||||
async def update_quota_service(cls, auth: AuthSchema, tenant_id: int, data: TenantQuotaUpdateSchema) -> dict:
|
||||
"""更新租户配额"""
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(TenantQuotaModel).where(TenantQuotaModel.tenant_id == tenant_id).limit(1)
|
||||
result = await auth.db.execute(stmt)
|
||||
quota = result.scalar_one_or_none()
|
||||
if not quota:
|
||||
quota = TenantQuotaModel(tenant_id=tenant_id)
|
||||
auth.db.add(quota)
|
||||
await auth.db.flush()
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for k, v in update_data.items():
|
||||
setattr(quota, k, v)
|
||||
await auth.db.flush()
|
||||
log.info(f"租户[{tenant_id}]配额已更新: {update_data}")
|
||||
return TenantQuotaOutSchema.model_validate(quota).model_dump()
|
||||
|
||||
# ============ P1: 租户配置 ============
|
||||
|
||||
@classmethod
|
||||
async def get_config_service(cls, auth: AuthSchema, tenant_id: int) -> list[dict]:
|
||||
"""获取租户所有配置(带 Redis 缓存)"""
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(TenantConfigModel).where(TenantConfigModel.tenant_id == tenant_id)
|
||||
result = await auth.db.execute(stmt)
|
||||
configs = result.scalars().all()
|
||||
return [TenantConfigOutSchema.model_validate(c).model_dump() for c in configs]
|
||||
|
||||
@classmethod
|
||||
async def get_config_cache_service(cls, redis: Redis, tenant_id: int) -> list[dict]:
|
||||
"""
|
||||
从 Redis 缓存获取租户配置,缓存未命中则从 DB 加载并回写缓存
|
||||
|
||||
参数:
|
||||
- redis (Redis): Redis 客户端实例
|
||||
- tenant_id (int): 租户ID
|
||||
|
||||
返回:
|
||||
- list[dict]: 租户配置列表
|
||||
"""
|
||||
redis_keys = await RedisCURD(redis).get_keys(
|
||||
f"{RedisInitKeyConfig.TENANT_CONFIG.key}:{tenant_id}:*"
|
||||
)
|
||||
redis_configs = await RedisCURD(redis).mget(redis_keys)
|
||||
configs = []
|
||||
for config in redis_configs:
|
||||
if not config:
|
||||
continue
|
||||
try:
|
||||
configs.append(json.loads(config))
|
||||
except Exception as e:
|
||||
log.error(f"解析租户配置数据失败: {e}")
|
||||
continue
|
||||
|
||||
if not configs:
|
||||
log.info(f"Redis 中没有租户[{tenant_id}]配置数据,从数据库中加载")
|
||||
from app.core.database import async_db_session
|
||||
|
||||
async with async_db_session() as session:
|
||||
async with session.begin():
|
||||
from app.api.v1.module_system.auth.schema import AuthSchema
|
||||
|
||||
auth = AuthSchema(db=session, check_data_scope=False)
|
||||
configs = await cls.get_config_service(auth, tenant_id)
|
||||
await cls._sync_configs_to_redis(redis, tenant_id, configs)
|
||||
log.info(f"✅ 已从数据库加载 {len(configs)} 条租户配置到缓存")
|
||||
|
||||
return configs
|
||||
|
||||
@classmethod
|
||||
async def _sync_configs_to_redis(
|
||||
cls, redis: Redis, tenant_id: int, configs: list[dict]
|
||||
) -> None:
|
||||
"""将租户配置列表批量写入 Redis 缓存"""
|
||||
for cfg in configs:
|
||||
redis_key = (
|
||||
f"{RedisInitKeyConfig.TENANT_CONFIG.key}:{tenant_id}:{cfg.get('config_key')}"
|
||||
)
|
||||
value = json.dumps(cfg, ensure_ascii=False)
|
||||
await RedisCURD(redis).set(key=redis_key, value=value, expire=None)
|
||||
|
||||
@classmethod
|
||||
async def _del_configs_from_redis(
|
||||
cls, redis: Redis, tenant_id: int, keys: list[str]
|
||||
) -> None:
|
||||
"""删除租户配置的 Redis 缓存"""
|
||||
redis_keys = [
|
||||
f"{RedisInitKeyConfig.TENANT_CONFIG.key}:{tenant_id}:{k}" for k in keys
|
||||
]
|
||||
if redis_keys:
|
||||
await RedisCURD(redis).delete(*redis_keys)
|
||||
|
||||
@classmethod
|
||||
async def update_config_service(
|
||||
cls, auth: AuthSchema, redis: Redis, tenant_id: int, items: list[TenantConfigItem]
|
||||
) -> list[dict]:
|
||||
"""批量更新租户配置(同步 Redis 缓存)"""
|
||||
from sqlalchemy import select
|
||||
|
||||
for item in items:
|
||||
stmt = (
|
||||
select(TenantConfigModel)
|
||||
.where(
|
||||
TenantConfigModel.tenant_id == tenant_id,
|
||||
TenantConfigModel.config_key == item.config_key,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await auth.db.execute(stmt)
|
||||
cfg = result.scalar_one_or_none()
|
||||
if cfg:
|
||||
cfg.config_value = item.config_value
|
||||
if item.config_type:
|
||||
cfg.config_type = item.config_type
|
||||
else:
|
||||
cfg = TenantConfigModel(
|
||||
tenant_id=tenant_id,
|
||||
config_key=item.config_key,
|
||||
config_value=item.config_value,
|
||||
config_type=item.config_type or "string",
|
||||
)
|
||||
auth.db.add(cfg)
|
||||
await auth.db.flush()
|
||||
|
||||
# 刷新 DB 数据并同步到 Redis
|
||||
configs = await cls.get_config_service(auth, tenant_id)
|
||||
await cls._sync_configs_to_redis(redis, tenant_id, configs)
|
||||
log.info(f"租户[{tenant_id}]配置已更新, keys={[i.config_key for i in items]}")
|
||||
return configs
|
||||
|
||||
# ============ P1: 租户菜单 ============
|
||||
|
||||
@classmethod
|
||||
async def get_menus_service(cls, auth: AuthSchema, tenant_id: int) -> list[int]:
|
||||
"""获取租户菜单权限(返回 menu_id 列表)"""
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(TenantMenuModel.menu_id).where(TenantMenuModel.tenant_id == tenant_id)
|
||||
result = await auth.db.execute(stmt)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
@classmethod
|
||||
async def set_menus_service(cls, auth: AuthSchema, tenant_id: int, data: TenantMenuSetSchema) -> None:
|
||||
"""批量设置租户菜单权限(先清空再写入)"""
|
||||
from sqlalchemy import delete
|
||||
|
||||
await auth.db.execute(
|
||||
delete(TenantMenuModel).where(TenantMenuModel.tenant_id == tenant_id)
|
||||
)
|
||||
for menu_id in data.menu_ids:
|
||||
auth.db.add(TenantMenuModel(tenant_id=tenant_id, menu_id=menu_id))
|
||||
await auth.db.flush()
|
||||
log.info(f"租户[{tenant_id}]菜单权限已设置, count={len(data.menu_ids)}")
|
||||
|
||||
@staticmethod
|
||||
async def get_tenant_menu_ids(auth: AuthSchema, tenant_id: int) -> list[int] | None:
|
||||
"""获取租户菜单权限ID列表(供角色/用户权限约束使用)"""
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(TenantMenuModel.menu_id).where(
|
||||
TenantMenuModel.tenant_id == tenant_id,
|
||||
)
|
||||
result = await auth.db.execute(stmt)
|
||||
ids = [row[0] for row in result.all()]
|
||||
return ids if ids else None
|
||||
|
||||
# ============ P1: 初始化缓存 ============
|
||||
|
||||
@classmethod
|
||||
async def init_tenant_config_cache(cls, redis: Redis) -> None:
|
||||
"""
|
||||
初始化所有租户配置到 Redis 缓存(应用启动时调用)。
|
||||
|
||||
参数:
|
||||
- redis (Redis): Redis 客户端实例
|
||||
|
||||
返回:
|
||||
- None
|
||||
"""
|
||||
from app.core.database import async_db_session
|
||||
from sqlalchemy import select
|
||||
|
||||
async with async_db_session() as session:
|
||||
async with session.begin():
|
||||
stmt = select(TenantModel)
|
||||
result = await session.execute(stmt)
|
||||
tenants = result.scalars().all()
|
||||
|
||||
for tenant in tenants:
|
||||
config_stmt = select(TenantConfigModel).where(
|
||||
TenantConfigModel.tenant_id == tenant.id
|
||||
)
|
||||
config_result = await session.execute(config_stmt)
|
||||
configs = config_result.scalars().all()
|
||||
config_list = [
|
||||
TenantConfigOutSchema.model_validate(c).model_dump() for c in configs
|
||||
]
|
||||
|
||||
if config_list:
|
||||
await cls._sync_configs_to_redis(redis, tenant.id, config_list)
|
||||
log.info(
|
||||
f"✅ 租户[{tenant.name}](id={tenant.id}) {len(config_list)} 条配置已缓存到 Redis"
|
||||
)
|
||||
else:
|
||||
log.warning(
|
||||
f"⚠️ 租户[{tenant.name}](id={tenant.id}) 无配置数据,跳过缓存"
|
||||
)
|
||||
|
||||
# ============ P1: 到期提醒 ============
|
||||
|
||||
@staticmethod
|
||||
async def check_tenant_expiry() -> None:
|
||||
"""定时任务:检查租户到期并发送通知 / 自动禁用"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.core.db_session import async_session_factory
|
||||
|
||||
async with async_session_factory() as db:
|
||||
now = datetime.now()
|
||||
# 扫描所有启用的租户
|
||||
stmt = sa.select(TenantModel).where(
|
||||
TenantModel.status == "0",
|
||||
TenantModel.end_time.isnot(None),
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
tenants = result.scalars().all()
|
||||
|
||||
for t in tenants:
|
||||
if t.end_time <= now:
|
||||
# 已到期:自动禁用
|
||||
await db.execute(
|
||||
sa.update(TenantModel)
|
||||
.where(TenantModel.id == t.id)
|
||||
.values(status="1")
|
||||
)
|
||||
log.info(f"租户[{t.name}]已到期,自动禁用")
|
||||
elif t.end_time <= now + timedelta(days=1):
|
||||
TenantService._notify_expiry(t, 1)
|
||||
elif t.end_time <= now + timedelta(days=7):
|
||||
TenantService._notify_expiry(t, 7)
|
||||
elif t.end_time <= now + timedelta(days=30):
|
||||
TenantService._notify_expiry(t, 30)
|
||||
|
||||
await db.commit()
|
||||
|
||||
@staticmethod
|
||||
async def _notify_expiry(tenant: TenantModel, days: int) -> None:
|
||||
"""发送到期提醒通知"""
|
||||
log.info(f"租户[{tenant.name}]将在 {days} 天后到期,联系人: {tenant.contact_email}")
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path
|
||||
|
||||
from app.api.v1.module_system.auth.schema import AuthSchema
|
||||
from app.common.response import ResponseSchema, SuccessResponse
|
||||
from app.core.base_params import PaginationQueryParam
|
||||
from app.core.dependencies import AuthPermission
|
||||
from app.core.logger import log
|
||||
from app.core.router_class import OperationLogRoute
|
||||
|
||||
from .schema import TicketBatchSchema, TicketCreateSchema, TicketOutSchema, TicketQueryParam, TicketUpdateSchema
|
||||
from .service import TicketService
|
||||
|
||||
TicketRouter = APIRouter(route_class=OperationLogRoute, prefix="/ticket", tags=["工单管理"])
|
||||
|
||||
|
||||
@TicketRouter.get("/list", summary="工单列表", response_model=ResponseSchema[dict])
|
||||
async def ticket_list(
|
||||
page: Annotated[PaginationQueryParam, Depends()],
|
||||
search: Annotated[TicketQueryParam, Depends()],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:query"]))],
|
||||
):
|
||||
result = await TicketService.page_service(auth=auth, page_no=page.page_no, page_size=page.page_size, search=search, order_by=page.order_by)
|
||||
return SuccessResponse(data=result, msg="查询成功")
|
||||
|
||||
|
||||
@TicketRouter.get("/detail/{id}", summary="工单详情", response_model=ResponseSchema[TicketOutSchema])
|
||||
async def ticket_detail(
|
||||
id: Annotated[int, Path()],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:query"]))],
|
||||
):
|
||||
result = await TicketService.detail_service(auth=auth, id=id)
|
||||
return SuccessResponse(data=result, msg="查询成功")
|
||||
|
||||
|
||||
@TicketRouter.post("/create", summary="创建工单", response_model=ResponseSchema[TicketOutSchema])
|
||||
async def ticket_create(
|
||||
data: TicketCreateSchema,
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:create"]))],
|
||||
):
|
||||
result = await TicketService.create_service(auth=auth, data=data)
|
||||
log.info(f"创建工单: {data.title}")
|
||||
return SuccessResponse(data=result, msg="创建成功")
|
||||
|
||||
|
||||
@TicketRouter.put("/update/{id}", summary="更新工单", response_model=ResponseSchema[TicketOutSchema])
|
||||
async def ticket_update(
|
||||
id: Annotated[int, Path()],
|
||||
data: TicketUpdateSchema,
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:update"]))],
|
||||
):
|
||||
result = await TicketService.update_service(auth=auth, id=id, data=data)
|
||||
log.info(f"更新工单: {id}")
|
||||
return SuccessResponse(data=result, msg="更新成功")
|
||||
|
||||
|
||||
@TicketRouter.put("/batch", summary="批量更新工单", response_model=ResponseSchema)
|
||||
async def ticket_batch_update(
|
||||
data: TicketBatchSchema,
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:update"]))],
|
||||
):
|
||||
await TicketService.batch_service(auth=auth, data=data)
|
||||
log.info(f"批量更新工单状态: {data.ids} -> {data.status}")
|
||||
return SuccessResponse(msg="批量更新成功")
|
||||
|
||||
|
||||
@TicketRouter.delete("/delete", summary="删除工单")
|
||||
async def ticket_delete(
|
||||
ids: Annotated[list[int], Body()],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:delete"]))],
|
||||
):
|
||||
await TicketService.delete_service(auth=auth, ids=ids)
|
||||
log.info(f"删除工单: {ids}")
|
||||
return SuccessResponse(msg="删除成功")
|
||||
@@ -0,0 +1,51 @@
|
||||
from typing import Any
|
||||
|
||||
from app.api.v1.module_system.auth.schema import AuthSchema
|
||||
from app.core.base_crud import CRUDBase
|
||||
|
||||
from .model import TicketModel
|
||||
from .schema import TicketCreateSchema, TicketUpdateSchema
|
||||
|
||||
|
||||
class TicketCRUD(CRUDBase[TicketModel, TicketCreateSchema, TicketUpdateSchema]):
|
||||
"""工单 CRUD"""
|
||||
|
||||
def __init__(self, auth: AuthSchema) -> None:
|
||||
self.auth = auth
|
||||
super().__init__(model=TicketModel, auth=auth)
|
||||
|
||||
async def page_crud(
|
||||
self,
|
||||
offset: int,
|
||||
limit: int,
|
||||
order_by: list[dict[str, str]] | None,
|
||||
search: dict | None = None,
|
||||
out_schema: type | None = None,
|
||||
preload: list[str | Any] | None = None,
|
||||
) -> dict:
|
||||
from .schema import TicketOutSchema
|
||||
|
||||
return await self.page(
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
order_by=order_by or [{"id": "asc"}],
|
||||
search=search or {},
|
||||
out_schema=out_schema or TicketOutSchema,
|
||||
preload=preload,
|
||||
)
|
||||
|
||||
async def get_by_id_crud(self, id: int) -> TicketModel | None:
|
||||
return await self.get(id=id)
|
||||
|
||||
async def create_crud(self, data: TicketCreateSchema) -> TicketModel | None:
|
||||
return await self.create(data=data)
|
||||
|
||||
async def update_crud(self, id: int, data: TicketUpdateSchema) -> TicketModel | None:
|
||||
return await self.update(id=id, data=data)
|
||||
|
||||
async def delete_crud(self, ids: list[int]) -> None:
|
||||
await self.delete(ids=ids)
|
||||
|
||||
async def set_crud(self, ids: list[int], **kwargs) -> None:
|
||||
"""批量设置工单状态"""
|
||||
await self.set(ids=ids, **kwargs)
|
||||
@@ -0,0 +1,56 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||
|
||||
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.api.v1.module_system.user.model import UserModel
|
||||
|
||||
|
||||
class TicketModel(ModelMixin, TenantMixin, UserMixin):
|
||||
"""工单模型 — 用户提交的建议和反馈"""
|
||||
|
||||
__tablename__: str = "sys_ticket"
|
||||
__table_args__: dict[str, str] = {"comment": "工单表"}
|
||||
__loader_options__: list[str] = ["created_by", "updated_by", "assigned_by"]
|
||||
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False, comment="工单标题")
|
||||
ticket_content: Mapped[str | None] = mapped_column(Text, nullable=True, comment="工单内容(富文本)")
|
||||
content: Mapped[str | None] = mapped_column(Text, nullable=True, comment="工单内容(纯文本摘要)")
|
||||
ticket_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="suggestion", comment="工单类型(suggestion:建议 bug:缺陷 optimize:优化 other:其他)"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(10), nullable=False, default="0", comment="状态(0:待处理 1:处理中 2:已完成 3:已关闭)"
|
||||
)
|
||||
images: Mapped[str | None] = mapped_column(Text, nullable=True, comment="图片URL列表(JSON数组)")
|
||||
reply: Mapped[str | None] = mapped_column(Text, nullable=True, comment="回复内容")
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="工单描述")
|
||||
assigned_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("sys_user.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="处理人ID",
|
||||
)
|
||||
|
||||
# 处理人关联关系
|
||||
assigned_by: Mapped["UserModel | None"] = relationship(
|
||||
"UserModel",
|
||||
foreign_keys=[assigned_id],
|
||||
lazy="selectin",
|
||||
uselist=False,
|
||||
)
|
||||
|
||||
@validates("title")
|
||||
def validate_title(self, key: str, title: str) -> str:
|
||||
if not title or not title.strip():
|
||||
raise ValueError("工单标题不能为空")
|
||||
return title.strip()
|
||||
|
||||
@validates("content", "ticket_content")
|
||||
def validate_content(self, key: str, content: str | None) -> str | None:
|
||||
if content and content.strip():
|
||||
return content.strip()
|
||||
return content
|
||||
@@ -0,0 +1,80 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.core.base_schema import CommonSchema
|
||||
from app.core.validator import DateTimeStr
|
||||
|
||||
|
||||
class TicketCreateSchema(BaseModel):
|
||||
"""创建工单"""
|
||||
|
||||
title: str = Field(..., max_length=200, description="工单标题")
|
||||
ticket_content: str = Field(default="", description="工单内容(富文本)")
|
||||
content: str | None = Field(default=None, description="工单内容(纯文本摘要)")
|
||||
ticket_type: str = Field(default="suggestion", description="工单类型(suggestion/bug/optimize/other)")
|
||||
images: str | None = Field(default=None, description="图片URL列表(JSON数组)")
|
||||
description: str | None = Field(default=None, description="工单描述")
|
||||
|
||||
|
||||
class TicketUpdateSchema(BaseModel):
|
||||
"""更新工单"""
|
||||
|
||||
title: str | None = Field(default=None, max_length=200, description="工单标题")
|
||||
ticket_content: str | None = Field(default=None, description="工单内容(富文本)")
|
||||
content: str | None = Field(default=None, description="工单内容(纯文本摘要)")
|
||||
ticket_type: str | None = Field(default=None, description="工单类型")
|
||||
status: str | None = Field(default=None, description="状态(0:待处理 1:处理中 2:已完成 3:已关闭)")
|
||||
reply: str | None = Field(default=None, description="回复内容")
|
||||
assigned_id: int | None = Field(default=None, description="处理人ID")
|
||||
description: str | None = Field(default=None, description="工单描述")
|
||||
|
||||
|
||||
class TicketOutSchema(BaseModel):
|
||||
"""工单响应"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
title: str
|
||||
ticket_content: str | None = None
|
||||
content: str | None = None
|
||||
ticket_type: str
|
||||
status: str
|
||||
images: str | None = None
|
||||
reply: str | None = None
|
||||
description: str | None = None
|
||||
assigned_id: int | None = None
|
||||
created_time: DateTimeStr | None = None
|
||||
updated_time: DateTimeStr | None = None
|
||||
created_by: CommonSchema | None = None
|
||||
updated_by: CommonSchema | None = None
|
||||
assigned_by: CommonSchema | None = None
|
||||
|
||||
|
||||
class TicketBatchSchema(BaseModel):
|
||||
"""批量更新工单"""
|
||||
|
||||
ids: list[int] = Field(..., description="工单ID列表")
|
||||
status: str = Field(..., description="状态(0:待处理 1:处理中 2:已完成 3:已关闭)")
|
||||
|
||||
|
||||
class TicketQueryParam:
|
||||
"""工单查询参数"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str | None = None,
|
||||
ticket_type: str | None = None,
|
||||
status: str | None = None,
|
||||
created_id: int | None = None,
|
||||
assigned_id: int | None = None,
|
||||
) -> None:
|
||||
if title:
|
||||
self.title = ("like", title)
|
||||
if ticket_type:
|
||||
self.ticket_type = ("eq", ticket_type)
|
||||
if status:
|
||||
self.status = ("eq", status)
|
||||
if created_id:
|
||||
self.created_id = ("eq", created_id)
|
||||
if assigned_id:
|
||||
self.assigned_id = ("eq", assigned_id)
|
||||
@@ -0,0 +1,64 @@
|
||||
from app.api.v1.module_system.auth.schema import AuthSchema
|
||||
from app.core.exceptions import CustomException
|
||||
|
||||
from .crud import TicketCRUD
|
||||
from .schema import (
|
||||
TicketBatchSchema,
|
||||
TicketCreateSchema,
|
||||
TicketOutSchema,
|
||||
TicketQueryParam,
|
||||
TicketUpdateSchema,
|
||||
)
|
||||
|
||||
|
||||
class TicketService:
|
||||
"""工单管理服务层"""
|
||||
|
||||
@classmethod
|
||||
async def page_service(
|
||||
cls, auth: AuthSchema, page_no: int, page_size: int,
|
||||
search: TicketQueryParam | None = None, order_by: list | None = None,
|
||||
) -> dict:
|
||||
return await TicketCRUD(auth).page_crud(
|
||||
offset=(page_no - 1) * page_size, limit=page_size,
|
||||
order_by=order_by or [{"created_time": "desc"}],
|
||||
search=search.__dict__ if search else {},
|
||||
out_schema=TicketOutSchema,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def detail_service(cls, auth: AuthSchema, id: int) -> dict:
|
||||
obj = await TicketCRUD(auth).get_by_id_crud(id=id)
|
||||
if not obj:
|
||||
raise CustomException(msg="工单不存在")
|
||||
return TicketOutSchema.model_validate(obj).model_dump()
|
||||
|
||||
@classmethod
|
||||
async def create_service(cls, auth: AuthSchema, data: TicketCreateSchema) -> dict:
|
||||
obj = await TicketCRUD(auth).create_crud(data=data)
|
||||
if not obj:
|
||||
raise CustomException(msg="创建工单失败")
|
||||
return TicketOutSchema.model_validate(obj).model_dump()
|
||||
|
||||
@classmethod
|
||||
async def update_service(cls, auth: AuthSchema, id: int, data: TicketUpdateSchema) -> dict:
|
||||
obj = await TicketCRUD(auth).get_by_id_crud(id=id)
|
||||
if not obj:
|
||||
raise CustomException(msg="工单不存在")
|
||||
updated = await TicketCRUD(auth).update_crud(id=id, data=data)
|
||||
if not updated:
|
||||
raise CustomException(msg="更新失败")
|
||||
return TicketOutSchema.model_validate(updated).model_dump()
|
||||
|
||||
@classmethod
|
||||
async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None:
|
||||
if not ids:
|
||||
raise CustomException(msg="删除对象不能为空")
|
||||
await TicketCRUD(auth).delete_crud(ids=ids)
|
||||
|
||||
@classmethod
|
||||
async def batch_service(cls, auth: AuthSchema, data: TicketBatchSchema) -> None:
|
||||
"""批量更新工单状态"""
|
||||
if not data.ids:
|
||||
raise CustomException(msg="请选择要操作的工单")
|
||||
await TicketCRUD(auth).set_crud(ids=data.ids, status=data.status)
|
||||
@@ -1,7 +1,7 @@
|
||||
import urllib.parse
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Request, UploadFile
|
||||
from fastapi import APIRouter, Body, Depends, Path, UploadFile
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -53,28 +53,6 @@ async def get_current_user_info_controller(
|
||||
return SuccessResponse(data=result_dict, msg="获取当前用户信息成功")
|
||||
|
||||
|
||||
@UserRouter.post(
|
||||
"/current/avatar/upload",
|
||||
summary="上传当前用户头像",
|
||||
dependencies=[Depends(get_current_user)],
|
||||
response_model=ResponseSchema[UserOutSchema],
|
||||
)
|
||||
async def user_avatar_upload_controller(file: UploadFile, request: Request) -> JSONResponse:
|
||||
"""
|
||||
上传当前用户头像
|
||||
|
||||
参数:
|
||||
- file (UploadFile): 上传的文件
|
||||
- request (Request): 请求对象
|
||||
|
||||
返回:
|
||||
- JSONResponse: 上传头像JSON响应
|
||||
"""
|
||||
result_str = await UserService.upload_avatar_service(base_url=str(request.base_url), file=file)
|
||||
log.info(f"上传头像成功: {result_str}")
|
||||
return SuccessResponse(data=result_str, msg="上传头像成功")
|
||||
|
||||
|
||||
@UserRouter.put(
|
||||
"/current/info/update",
|
||||
summary="更新当前用户基本信息",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.base_model import MappedBase, ModelMixin, TenantMixin, UserMixin
|
||||
@@ -67,7 +67,7 @@ class UserModel(ModelMixin, TenantMixin, UserMixin):
|
||||
"""
|
||||
|
||||
__tablename__: str = "sys_user"
|
||||
__table_args__: dict[str, str] = {"comment": "用户表"}
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "username"), {"comment": "用户表"})
|
||||
__loader_options__: list[str] = [
|
||||
"tenant",
|
||||
"dept",
|
||||
@@ -79,15 +79,15 @@ class UserModel(ModelMixin, TenantMixin, UserMixin):
|
||||
]
|
||||
|
||||
username: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, unique=True, comment="用户名/登录账号"
|
||||
String(64), nullable=False, comment="用户名/登录账号"
|
||||
)
|
||||
password: Mapped[str] = mapped_column(String(255), nullable=False, comment="密码哈希")
|
||||
name: Mapped[str] = mapped_column(String(32), nullable=False, comment="昵称")
|
||||
mobile: Mapped[str | None] = mapped_column(
|
||||
String(11), nullable=True, unique=True, comment="手机号"
|
||||
String(11), nullable=True, comment="手机号"
|
||||
)
|
||||
email: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, unique=True, comment="邮箱"
|
||||
String(64), nullable=True, comment="邮箱"
|
||||
)
|
||||
gender: Mapped[str | None] = mapped_column(
|
||||
String(1), default="2", nullable=True, comment="性别(0:男 1:女 2:未知)"
|
||||
|
||||
@@ -10,13 +10,12 @@ from app.api.v1.module_system.menu.crud import MenuCRUD
|
||||
from app.api.v1.module_system.menu.schema import MenuOutSchema
|
||||
from app.api.v1.module_system.position.crud import PositionCRUD
|
||||
from app.api.v1.module_system.role.crud import RoleCRUD
|
||||
from app.core.base_schema import BatchSetAvailable, UploadResponseSchema
|
||||
from app.core.base_schema import BatchSetAvailable
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import log
|
||||
from app.utils.common_util import traversal_to_tree
|
||||
from app.utils.excel_util import ExcelUtil
|
||||
from app.utils.hash_bcrpy_util import PwdUtil
|
||||
from app.utils.upload_util import UploadUtil
|
||||
|
||||
from .crud import UserCRUD
|
||||
from .schema import (
|
||||
@@ -316,6 +315,17 @@ class UserService:
|
||||
and getattr(menu, "client", "pc") == "pc"
|
||||
}
|
||||
|
||||
# 租户菜单约束:非超管用户只能看到租户菜单权限内的菜单
|
||||
if menu_ids and auth.user.tenant_id:
|
||||
from app.api.v1.module_system.tenant.service import TenantService
|
||||
|
||||
allowed_ids = await TenantService.get_tenant_menu_ids(
|
||||
auth, auth.user.tenant_id
|
||||
)
|
||||
if allowed_ids is not None:
|
||||
allowed_set = set(allowed_ids)
|
||||
menu_ids = menu_ids & allowed_set
|
||||
|
||||
# 使用树形结构查询,预加载children关系
|
||||
menus = (
|
||||
[
|
||||
@@ -386,27 +396,6 @@ class UserService:
|
||||
raise CustomException(msg="超级管理员状态不能修改")
|
||||
await UserCRUD(auth).set_available_crud(ids=data.ids, status=data.status)
|
||||
|
||||
@classmethod
|
||||
async def upload_avatar_service(cls, base_url: str, file: UploadFile) -> dict:
|
||||
"""
|
||||
上传用户头像
|
||||
|
||||
参数:
|
||||
- base_url (str): 基础URL
|
||||
- file (UploadFile): 上传的文件
|
||||
|
||||
返回:
|
||||
- Dict: 上传头像响应字典
|
||||
"""
|
||||
filename, filepath, file_url = await UploadUtil.upload_file(file=file, base_url=base_url)
|
||||
|
||||
return UploadResponseSchema(
|
||||
file_path=f"{filepath}",
|
||||
file_name=filename,
|
||||
origin_name=file.filename,
|
||||
file_url=f"{file_url}",
|
||||
).model_dump()
|
||||
|
||||
@classmethod
|
||||
async def change_user_password_service(
|
||||
cls, auth: AuthSchema, data: UserChangePasswordSchema
|
||||
@@ -569,7 +558,7 @@ class UserService:
|
||||
raise CustomException(msg="导入文件为空")
|
||||
|
||||
# 检查表头是否完整
|
||||
missing_headers = [header for header in header_dict.keys() if header not in df.columns]
|
||||
missing_headers = [header for header in header_dict if header not in df.columns]
|
||||
if missing_headers:
|
||||
raise CustomException(msg=f"导入文件缺少必要的列: {', '.join(missing_headers)}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user