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:
zhangtao
2026-05-27 01:12:03 +08:00
parent eb18d85327
commit 53d2a9d13e
581 changed files with 14751 additions and 55305 deletions
@@ -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="用户信息")
+237 -18
View File
@@ -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}")
+14 -6
View File
@@ -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)}")