mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-21 04:46:26 +00:00
chore: 批量整理代码格式与优化细节
- 修复多处代码缩进、换行不规范问题 - 调整部分配置注释与字符串格式对齐 - 优化导入语句与多行表达式的排版 - 统一枚举、模型字段的注释风格 - 调整部分校验逻辑与提示文案 - 重构部分长函数参数与查询语句格式
This commit is contained in:
@@ -9,12 +9,13 @@ from .log.controller import LogRouter
|
||||
from .menu.controller import MenuRouter
|
||||
from .notice.controller import NoticeRouter
|
||||
from .params.controller import ParamsRouter
|
||||
from .plugin.controller import PluginRouter
|
||||
from .position.controller import PositionRouter
|
||||
from .role.controller import RoleRouter
|
||||
from .tenant.controller import TenantRouter
|
||||
from .user.controller import UserRouter
|
||||
from .tenant.package_controller import PackageRouter
|
||||
from .ticket.controller import TicketRouter
|
||||
from .plugin.controller import PluginRouter
|
||||
from .user.controller import UserRouter
|
||||
|
||||
system_router = APIRouter(prefix="/system")
|
||||
|
||||
@@ -28,6 +29,7 @@ system_router.include_router(ParamsRouter)
|
||||
system_router.include_router(PositionRouter)
|
||||
system_router.include_router(RoleRouter)
|
||||
system_router.include_router(TenantRouter)
|
||||
system_router.include_router(PackageRouter)
|
||||
system_router.include_router(UserRouter)
|
||||
system_router.include_router(TicketRouter)
|
||||
system_router.include_router(PluginRouter)
|
||||
system_router.include_router(PluginRouter)
|
||||
|
||||
@@ -397,9 +397,7 @@ async def oauth_callback_controller(
|
||||
url = oauth_service_error_redirect(await resolve_frontend(), "不支持的 OAuth 渠道")
|
||||
return RedirectResponse(url=url, status_code=302)
|
||||
if not code or not state:
|
||||
url = oauth_service_error_redirect(
|
||||
await resolve_frontend(), "授权被取消或参数不完整"
|
||||
)
|
||||
url = oauth_service_error_redirect(await resolve_frontend(), "授权被取消或参数不完整")
|
||||
return RedirectResponse(url=url, status_code=302)
|
||||
try:
|
||||
token, fe = await complete_oauth_login(
|
||||
|
||||
@@ -52,13 +52,11 @@ def _frontend_error_redirect(frontend_base: str, message: str) -> str:
|
||||
def _frontend_success_redirect(
|
||||
frontend_base: str, access_token: str, refresh_token: str, token_type: str
|
||||
) -> str:
|
||||
q = urlencode(
|
||||
{
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": token_type,
|
||||
}
|
||||
)
|
||||
q = urlencode({
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": token_type,
|
||||
})
|
||||
sep = "&" if "?" in frontend_base else "?"
|
||||
return f"{frontend_base}{sep}{q}"
|
||||
|
||||
@@ -114,7 +112,9 @@ def build_authorize_url(
|
||||
"scope": "snsapi_login",
|
||||
"state": state,
|
||||
}
|
||||
return "https://open.weixin.qq.com/connect/qrconnect?" + urlencode(params) + "#wechat_redirect"
|
||||
return (
|
||||
"https://open.weixin.qq.com/connect/qrconnect?" + urlencode(params) + "#wechat_redirect"
|
||||
)
|
||||
|
||||
if provider == "qq":
|
||||
params = {
|
||||
@@ -150,7 +150,9 @@ async def _http_text(method: str, url: str, **kwargs: Any) -> str:
|
||||
return r.text
|
||||
|
||||
|
||||
async def exchange_github_token(client_id: str, client_secret: str, code: str, redirect_uri: str) -> str:
|
||||
async def exchange_github_token(
|
||||
client_id: str, client_secret: str, code: str, redirect_uri: str
|
||||
) -> str:
|
||||
data = await _http_json(
|
||||
"POST",
|
||||
"https://github.com/login/oauth/access_token",
|
||||
@@ -170,16 +172,16 @@ async def exchange_github_token(client_id: str, client_secret: str, code: str, r
|
||||
return str(token)
|
||||
|
||||
|
||||
async def exchange_gitee_token(client_id: str, client_secret: str, code: str, redirect_uri: str) -> str:
|
||||
qs = urlencode(
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"redirect_uri": redirect_uri,
|
||||
}
|
||||
)
|
||||
async def exchange_gitee_token(
|
||||
client_id: str, client_secret: str, code: str, redirect_uri: str
|
||||
) -> str:
|
||||
qs = urlencode({
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"redirect_uri": redirect_uri,
|
||||
})
|
||||
data = await _http_json("GET", f"https://gitee.com/oauth/token?{qs}")
|
||||
if not isinstance(data, dict):
|
||||
raise CustomException(msg="Gitee token 响应格式错误")
|
||||
@@ -190,14 +192,12 @@ async def exchange_gitee_token(client_id: str, client_secret: str, code: str, re
|
||||
|
||||
|
||||
async def exchange_wechat_token(app_id: str, secret: str, code: str) -> tuple[str, str]:
|
||||
qs = urlencode(
|
||||
{
|
||||
"appid": app_id,
|
||||
"secret": secret,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
}
|
||||
)
|
||||
qs = urlencode({
|
||||
"appid": app_id,
|
||||
"secret": secret,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
})
|
||||
data = await _http_json("GET", f"https://api.weixin.qq.com/sns/oauth2/access_token?{qs}")
|
||||
if not isinstance(data, dict):
|
||||
raise CustomException(msg="微信 token 响应格式错误")
|
||||
@@ -208,16 +208,16 @@ async def exchange_wechat_token(app_id: str, secret: str, code: str) -> tuple[st
|
||||
return str(token), str(openid)
|
||||
|
||||
|
||||
async def exchange_qq_token(client_id: str, client_secret: str, code: str, redirect_uri: str) -> tuple[str, str]:
|
||||
qs = urlencode(
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
}
|
||||
)
|
||||
async def exchange_qq_token(
|
||||
client_id: str, client_secret: str, code: str, redirect_uri: str
|
||||
) -> tuple[str, str]:
|
||||
qs = urlencode({
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
})
|
||||
text = await _http_text("GET", f"https://graph.qq.com/oauth2.0/token?{qs}")
|
||||
parts = dict(p.split("=", 1) for p in text.split("&") if "=" in p)
|
||||
token = parts.get("access_token")
|
||||
@@ -279,13 +279,11 @@ async def fetch_wechat_profile(access_token: str, openid: str) -> tuple[str, str
|
||||
|
||||
|
||||
async def fetch_qq_profile(access_token: str, app_id: str, openid: str) -> tuple[str, str]:
|
||||
qs = urlencode(
|
||||
{
|
||||
"access_token": access_token,
|
||||
"oauth_consumer_key": app_id,
|
||||
"openid": openid,
|
||||
}
|
||||
)
|
||||
qs = urlencode({
|
||||
"access_token": access_token,
|
||||
"oauth_consumer_key": app_id,
|
||||
"openid": openid,
|
||||
})
|
||||
user = await _http_json("GET", f"https://graph.qq.com/user/get_user_info?{qs}")
|
||||
if not isinstance(user, dict):
|
||||
raise CustomException(msg="QQ 用户信息格式错误")
|
||||
@@ -380,9 +378,9 @@ async def complete_oauth_login(
|
||||
if user.status == "1":
|
||||
raise CustomException(msg="用户已被停用")
|
||||
|
||||
user = await UserCRUD(AuthSchema(db=db, user=None, check_data_scope=False)).update_last_login_crud(
|
||||
id=user.id
|
||||
)
|
||||
user = await UserCRUD(
|
||||
AuthSchema(db=db, user=None, check_data_scope=False)
|
||||
).update_last_login_crud(id=user.id)
|
||||
if not user:
|
||||
raise CustomException(msg="用户不存在")
|
||||
|
||||
@@ -411,9 +409,7 @@ async def save_oauth_state(
|
||||
raise CustomException(msg="缓存 OAuth 状态失败")
|
||||
|
||||
|
||||
def oauth_service_frontend_redirect_from_token(
|
||||
frontend_base: str, token: JWTOutSchema
|
||||
) -> str:
|
||||
def oauth_service_frontend_redirect_from_token(frontend_base: str, token: JWTOutSchema) -> str:
|
||||
return _frontend_success_redirect(
|
||||
frontend_base,
|
||||
token.access_token,
|
||||
|
||||
@@ -85,12 +85,14 @@ 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="用户不存在")
|
||||
@@ -99,14 +101,28 @@ 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="用户已被停用")
|
||||
|
||||
# 检查用户的默认租户是否正常
|
||||
from sqlalchemy import select
|
||||
from app.api.v1.module_system.tenant.model import TenantModel
|
||||
tenant_stmt = (
|
||||
select(TenantModel)
|
||||
.where(TenantModel.id == user.tenant_id, TenantModel.status == "0", TenantModel.is_deleted.is_(False))
|
||||
.limit(1)
|
||||
)
|
||||
tenant_result = await auth.db.execute(tenant_stmt)
|
||||
if not tenant_result.scalar_one_or_none():
|
||||
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="用户不存在")
|
||||
@@ -120,7 +136,9 @@ 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")
|
||||
|
||||
@@ -297,7 +315,9 @@ class LoginService:
|
||||
refresh_expires = timedelta(seconds=settings.REFRESH_TOKEN_EXPIRE_MINUTES)
|
||||
now = datetime.now()
|
||||
|
||||
session_info_json = session_info if isinstance(session_info, str) else 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(
|
||||
@@ -400,10 +420,7 @@ class LoginService:
|
||||
)
|
||||
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
|
||||
]
|
||||
return [TenantOptionSchema(id=t.id, name=t.name, code=t.code) for t in tenant_objs]
|
||||
|
||||
# 普通用户通过 sys_user_tenant 关联表查询
|
||||
stmt = (
|
||||
@@ -418,10 +435,7 @@ class LoginService:
|
||||
)
|
||||
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
|
||||
]
|
||||
return [TenantOptionSchema(id=t.id, name=t.name, code=t.code) for t in tenant_objs]
|
||||
|
||||
@classmethod
|
||||
async def select_tenant_service(
|
||||
@@ -525,8 +539,7 @@ class LoginService:
|
||||
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})"
|
||||
f"用户 {auth.user.username}(id={auth.user.id}) 切换到租户 {tenant.name}(id={tenant_id})"
|
||||
)
|
||||
|
||||
return SelectTenantOutSchema(
|
||||
|
||||
@@ -23,12 +23,10 @@ class DeptModel(ModelMixin, TenantMixin):
|
||||
|
||||
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, comment="部门编码"
|
||||
)
|
||||
code: Mapped[str] = mapped_column(String(64), 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="手机")
|
||||
email: Mapped[str | None] = mapped_column(String(64), default=None, comment="邮箱")
|
||||
phone: Mapped[str | None] = mapped_column(String(20), default=None, comment="手机")
|
||||
email: Mapped[str | None] = mapped_column(String(128), default=None, comment="邮箱")
|
||||
|
||||
# 树形结构字段
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
|
||||
@@ -9,53 +9,38 @@ from app.core.validator import DateTimeStr, validate_required_code
|
||||
class DeptCreateSchema(BaseModel):
|
||||
"""部门创建模型"""
|
||||
|
||||
name: str = Field(..., max_length=64, description="部门名称")
|
||||
name: str = Field(..., min_length=1, max_length=64, description="部门名称")
|
||||
order: int = Field(default=1, ge=0, description="显示顺序")
|
||||
code: str = Field(..., max_length=16, description="部门编码")
|
||||
code: str = Field(..., min_length=2, max_length=64, description="部门编码")
|
||||
leader: str | None = Field(default=None, max_length=32, description="部门负责人")
|
||||
phone: str | None = Field(default=None, max_length=11, description="手机")
|
||||
email: str | None = Field(default=None, max_length=64, description="邮箱")
|
||||
phone: str | None = Field(default=None, max_length=20, description="联系电话")
|
||||
email: str | None = Field(default=None, max_length=128, description="邮箱")
|
||||
parent_id: int | None = Field(default=None, ge=0, description="父部门ID")
|
||||
status: str = Field(default="0", description="是否启用(0:启用 1:禁用)")
|
||||
description: str | None = Field(default=None, max_length=255, description="备注说明")
|
||||
status: str = Field(default="0", max_length=1, description="状态(0:正常 1:禁用)")
|
||||
description: str | None = Field(default=None, max_length=255, description="备注")
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, value: str):
|
||||
"""
|
||||
校验并规范化部门名称(去空格、非空)。
|
||||
|
||||
参数:
|
||||
- value (str): 部门名称。
|
||||
|
||||
返回:
|
||||
- str: 规范化后的部门名称。
|
||||
|
||||
异常:
|
||||
- ValueError: 部门名称为空时抛出。
|
||||
"""
|
||||
if not value or len(value.strip()) == 0:
|
||||
"""校验部门名称:不能为空"""
|
||||
if not value or not value.strip():
|
||||
raise ValueError("部门名称不能为空")
|
||||
value = value.replace(" ", "")
|
||||
return value
|
||||
return value.strip()
|
||||
|
||||
@field_validator("code")
|
||||
@classmethod
|
||||
def validate_code(cls, value: str):
|
||||
"""
|
||||
校验部门编码(与角色编码规则一致,见 `validate_required_code`)。
|
||||
|
||||
参数:
|
||||
- value (str): 部门编码。
|
||||
|
||||
返回:
|
||||
- str: 规范化后的部门编码。
|
||||
|
||||
异常:
|
||||
- ValueError: 编码不满足格式要求时抛出。
|
||||
"""
|
||||
"""校验部门编码:字母开头,2-64 位,仅含字母/数字/下划线"""
|
||||
return validate_required_code(value)
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def validate_status(cls, value: str):
|
||||
"""校验状态:仅支持 0(正常)、1(禁用)"""
|
||||
if value not in {"0", "1"}:
|
||||
raise ValueError("状态仅支持 0(正常) 或 1(禁用)")
|
||||
return value
|
||||
|
||||
|
||||
class DeptUpdateSchema(DeptCreateSchema):
|
||||
"""部门更新模型"""
|
||||
|
||||
@@ -91,6 +91,11 @@ class DeptService:
|
||||
obj = await DeptCRUD(auth).get(code=data.code)
|
||||
if obj:
|
||||
raise CustomException(msg="创建失败,编码已存在")
|
||||
|
||||
# 检查租户配额
|
||||
from app.api.v1.module_system.tenant.service import TenantService
|
||||
await TenantService.check_quota_service(auth, auth.tenant_id, "dept")
|
||||
|
||||
dept = await DeptCRUD(auth).create(data=data)
|
||||
return DeptOutSchema.model_validate(dept).model_dump()
|
||||
|
||||
|
||||
@@ -18,9 +18,7 @@ class DictTypeModel(ModelMixin, TenantMixin):
|
||||
__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, comment="字典类型"
|
||||
)
|
||||
dict_type: Mapped[str] = mapped_column(String(255), nullable=False, comment="字典类型")
|
||||
|
||||
# 关系定义
|
||||
dict_data_list: Mapped[list["DictDataModel"]] = relationship(
|
||||
@@ -39,7 +37,10 @@ class DictDataModel(ModelMixin, TenantMixin):
|
||||
"""
|
||||
|
||||
__tablename__: str = "sys_dict_data"
|
||||
__table_args__: dict[str, str] = {"comment": "字典数据表"}
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "dict_type_id", "dict_value", name="uq_dict_data_value"),
|
||||
{"comment": "字典数据表"},
|
||||
)
|
||||
__loader_options__: list[str] = []
|
||||
__platform_data_shared__: bool = True
|
||||
|
||||
|
||||
@@ -20,10 +20,17 @@ class DictTypeCreateSchema(BaseModel):
|
||||
"""
|
||||
|
||||
dict_name: str = Field(..., min_length=1, max_length=64, description="字典名称")
|
||||
dict_type: str = Field(..., min_length=1, max_length=64, description="字典类型")
|
||||
status: str = Field(default="0", description="状态(0正常 1停用)")
|
||||
dict_type: str = Field(..., min_length=1, max_length=255, description="字典类型编码")
|
||||
status: str = Field(default="0", max_length=1, description="状态(0:正常 1:停用)")
|
||||
description: str | None = Field(default=None, max_length=255, description="描述")
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def validate_status(cls, value: str):
|
||||
if value not in {"0", "1"}:
|
||||
raise ValueError("状态仅支持 0(正常) 或 1(停用)")
|
||||
return value
|
||||
|
||||
@field_validator("dict_name")
|
||||
def validate_dict_name(cls, value: str):
|
||||
"""
|
||||
@@ -114,19 +121,26 @@ class DictDataCreateSchema(BaseModel):
|
||||
字典数据表对应pydantic模型
|
||||
"""
|
||||
|
||||
dict_sort: int = Field(..., ge=1, le=999, description="字典排序")
|
||||
dict_label: str = Field(..., max_length=100, description="字典标签")
|
||||
dict_value: str = Field(..., max_length=100, description="字典键值")
|
||||
dict_type: str = Field(..., max_length=100, description="字典类型")
|
||||
dict_type_id: int = Field(..., description="字典类型ID")
|
||||
dict_sort: int = Field(..., ge=1, le=999, description="排序")
|
||||
dict_label: str = Field(..., min_length=1, max_length=255, description="字典标签")
|
||||
dict_value: str = Field(..., min_length=1, max_length=255, description="字典键值")
|
||||
dict_type: str = Field(..., max_length=255, description="字典类型")
|
||||
dict_type_id: int = Field(..., gt=0, description="字典类型ID")
|
||||
css_class: str | None = Field(
|
||||
default=None, max_length=100, description="样式属性(其他样式扩展)"
|
||||
default=None, max_length=255, description="样式属性"
|
||||
)
|
||||
list_class: str | None = Field(default=None, description="表格回显样式")
|
||||
is_default: bool = Field(default=False, description="是否默认(True是 False否)")
|
||||
status: str = Field(default="0", description="状态(0正常 1停用)")
|
||||
list_class: str | None = Field(default=None, max_length=255, description="表格回显样式")
|
||||
is_default: bool = Field(default=False, description="是否默认")
|
||||
status: str = Field(default="0", max_length=1, description="状态(0:正常 1:停用)")
|
||||
description: str | None = Field(default=None, max_length=255, description="描述")
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def validate_status(cls, value: str):
|
||||
if value not in {"0", "1"}:
|
||||
raise ValueError("状态仅支持 0(正常) 或 1(停用)")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_after(self):
|
||||
"""
|
||||
|
||||
@@ -197,7 +197,9 @@ class DictTypeService:
|
||||
search={"dict_type": data.dict_type}
|
||||
)
|
||||
dict_data = [
|
||||
DictDataOutSchema.model_validate(row).model_dump(mode="json") for row in dict_data_list if row
|
||||
DictDataOutSchema.model_validate(row).model_dump(mode="json")
|
||||
for row in dict_data_list
|
||||
if row
|
||||
]
|
||||
|
||||
value = json.dumps(dict_data, ensure_ascii=False)
|
||||
@@ -238,7 +240,9 @@ class DictTypeService:
|
||||
# 如果有字典数据,不能删除
|
||||
raise CustomException(msg="删除失败,该数据字典类型下存在字典数据")
|
||||
# 删除Redis缓存
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{auth.user.tenant_id}:{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}")
|
||||
@@ -404,7 +408,9 @@ class DictDataService:
|
||||
for row in dict_data_list
|
||||
if row
|
||||
]
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{tenant_id}:{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,
|
||||
@@ -419,7 +425,9 @@ class DictDataService:
|
||||
raise CustomException(msg=f"字典数据初始化失败: {e!s}")
|
||||
|
||||
@classmethod
|
||||
async def get_init_dict_service(cls, redis: Redis, dict_type: str, tenant_id: int = 1) -> list[dict]:
|
||||
async def get_init_dict_service(
|
||||
cls, redis: Redis, dict_type: str, tenant_id: int = 1
|
||||
) -> list[dict]:
|
||||
"""
|
||||
从缓存获取字典数据列表信息
|
||||
|
||||
@@ -500,7 +508,9 @@ class DictDataService:
|
||||
search={"dict_type": data.dict_type}
|
||||
)
|
||||
dict_data = [
|
||||
DictDataOutSchema.model_validate(row).model_dump(mode="json") for row in dict_data_list if row
|
||||
DictDataOutSchema.model_validate(row).model_dump(mode="json")
|
||||
for row in dict_data_list
|
||||
if row
|
||||
]
|
||||
|
||||
value = json.dumps(dict_data, ensure_ascii=False)
|
||||
@@ -591,7 +601,9 @@ class DictDataService:
|
||||
search={"dict_type": data.dict_type}
|
||||
)
|
||||
dict_data = [
|
||||
DictDataOutSchema.model_validate(row).model_dump(mode="json") for row in dict_data_list if row
|
||||
DictDataOutSchema.model_validate(row).model_dump(mode="json")
|
||||
for row in dict_data_list
|
||||
if row
|
||||
]
|
||||
|
||||
value = json.dumps(dict_data, ensure_ascii=False)
|
||||
@@ -646,7 +658,9 @@ class DictDataService:
|
||||
# 清除缓存
|
||||
for dict_type in dict_types_to_clear:
|
||||
try:
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{auth.user.tenant_id}:{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:
|
||||
|
||||
@@ -42,11 +42,15 @@ class OperationLogModel(ModelMixin, TenantMixin, UserMixin):
|
||||
type: Mapped[int] = mapped_column(Integer, comment="日志类型(1登录日志 2操作日志)")
|
||||
request_path: Mapped[str] = mapped_column(String(255), comment="请求路径")
|
||||
request_method: Mapped[str] = mapped_column(String(10), comment="请求方式")
|
||||
request_payload: Mapped[str | None] = mapped_column(get_log_text_column_type(), comment="请求体")
|
||||
request_payload: Mapped[str | None] = mapped_column(
|
||||
get_log_text_column_type(), comment="请求体"
|
||||
)
|
||||
request_ip: Mapped[str | None] = mapped_column(String(50), comment="请求IP地址")
|
||||
login_location: Mapped[str | None] = mapped_column(String(255), comment="登录位置")
|
||||
request_os: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="操作系统")
|
||||
request_browser: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="浏览器")
|
||||
response_code: Mapped[int] = mapped_column(Integer, comment="响应状态码")
|
||||
response_json: Mapped[str | None] = mapped_column(get_log_text_column_type(), nullable=True, comment="响应体")
|
||||
response_json: Mapped[str | None] = mapped_column(
|
||||
get_log_text_column_type(), nullable=True, comment="响应体"
|
||||
)
|
||||
process_time: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="处理时间")
|
||||
|
||||
@@ -4,15 +4,15 @@ 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, TenantMixin
|
||||
from app.core.base_model import ModelMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.api.v1.module_system.role.model import RoleModel
|
||||
|
||||
|
||||
class MenuModel(ModelMixin, TenantMixin):
|
||||
class MenuModel(ModelMixin):
|
||||
"""
|
||||
菜单表 - 用于存储系统菜单信息
|
||||
菜单表 - 用于存储系统菜单资源定义
|
||||
|
||||
菜单类型说明:
|
||||
- 1: 目录(一级菜单)
|
||||
|
||||
@@ -11,32 +11,39 @@ from app.core.validator import DateTimeStr, menu_request_validator
|
||||
class MenuCreateSchema(BaseModel):
|
||||
"""菜单创建模型"""
|
||||
|
||||
name: str = Field(..., max_length=50, description="菜单名称")
|
||||
name: str = Field(..., min_length=1, max_length=50, description="菜单名称")
|
||||
type: int = Field(..., ge=1, le=4, description="菜单类型(1:目录 2:菜单 3:按钮 4:外链)")
|
||||
order: int = Field(..., ge=1, description="显示顺序")
|
||||
order: int = Field(..., ge=0, description="显示顺序")
|
||||
permission: str | None = Field(default=None, max_length=100, description="权限标识")
|
||||
icon: str | None = Field(default=None, max_length=100, description="菜单图标")
|
||||
icon: str | None = Field(default=None, max_length=50, description="菜单图标")
|
||||
route_name: str | None = Field(default=None, max_length=100, description="路由名称")
|
||||
route_path: str | None = Field(default=None, max_length=200, description="路由地址")
|
||||
component_path: str | None = Field(default=None, max_length=255, description="组件路径")
|
||||
component_path: str | None = Field(default=None, max_length=200, description="组件路径")
|
||||
redirect: str | None = Field(default=None, max_length=200, description="重定向地址")
|
||||
hidden: bool = Field(default=False, description="是否隐藏(True:是 False:否)")
|
||||
keep_alive: bool = Field(default=True, description="是否缓存(True:是 False:否)")
|
||||
always_show: bool = Field(default=False, description="是否始终显示(True:是 False:否)")
|
||||
hidden: bool = Field(default=False, description="是否隐藏")
|
||||
keep_alive: bool = Field(default=True, description="是否缓存")
|
||||
always_show: bool = Field(default=False, description="是否始终显示")
|
||||
title: str | None = Field(default=None, max_length=50, description="菜单标题")
|
||||
params: list[dict[str, str]] | None = Field(
|
||||
default=None,
|
||||
description="路由参数,格式为[{key: string, value: string}]",
|
||||
)
|
||||
affix: bool = Field(default=False, description="是否固定标签页(True:是 False:否)")
|
||||
affix: bool = Field(default=False, description="是否固定标签页")
|
||||
parent_id: int | None = Field(default=None, ge=1, description="父菜单ID")
|
||||
status: str = Field(default="0", description="是否启用(0:启用 1:禁用)")
|
||||
status: str = Field(default="0", max_length=1, description="状态(0:正常 1:禁用)")
|
||||
description: str | None = Field(default=None, max_length=255, description="描述")
|
||||
client: Literal["pc", "app"] = Field(
|
||||
default="pc",
|
||||
description="终端(pc:管理端桌面 app:移动端)",
|
||||
)
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def _validate_status(cls, v: str) -> str:
|
||||
if v not in {"0", "1"}:
|
||||
raise ValueError("状态仅支持 0(正常) 或 1(禁用)")
|
||||
return v
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _normalize(cls, values):
|
||||
|
||||
@@ -16,17 +16,24 @@ from app.utils.xss_util import sanitize_html
|
||||
class NoticeCreateSchema(BaseModel):
|
||||
"""公告通知创建模型"""
|
||||
|
||||
notice_title: str = Field(..., max_length=50, description="公告标题")
|
||||
notice_type: str = Field(..., description="公告类型(1通知 2公告)")
|
||||
notice_content: str = Field(..., description="公告内容")
|
||||
status: str = Field(default="0", description="是否启用(0:启用 1:禁用)")
|
||||
notice_title: str = Field(..., min_length=1, max_length=64, description="公告标题")
|
||||
notice_type: str = Field(..., max_length=1, description="公告类型(1:通知 2:公告)")
|
||||
notice_content: str = Field(..., max_length=65535, description="公告内容")
|
||||
status: str = Field(default="0", max_length=1, description="状态(0:正常 1:禁用)")
|
||||
description: str | None = Field(default=None, max_length=255, description="描述")
|
||||
|
||||
@field_validator("notice_type")
|
||||
@classmethod
|
||||
def _validate_notice_type(cls, value: str):
|
||||
if value not in {"1", "2"}:
|
||||
raise ValueError("公告类型仅支持 '1'(通知) 或 '2'(公告)")
|
||||
raise ValueError("公告类型仅支持 1(通知) 或 2(公告)")
|
||||
return value
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def _validate_status(cls, value: str):
|
||||
if value not in {"0", "1"}:
|
||||
raise ValueError("状态仅支持 0(正常) 或 1(禁用)")
|
||||
return value
|
||||
|
||||
@field_validator("notice_content")
|
||||
|
||||
@@ -256,7 +256,7 @@ class NoticeService:
|
||||
@classmethod
|
||||
async def get_latest_notices_service(cls, auth: AuthSchema, limit: int = 5) -> list[dict]:
|
||||
"""获取最新 N 条已启用公告"""
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy import desc, select
|
||||
|
||||
from .model import NoticeModel
|
||||
from .schema import NoticeOutSchema
|
||||
@@ -274,7 +274,7 @@ class NoticeService:
|
||||
@classmethod
|
||||
async def get_panel_data_service(cls, auth: AuthSchema) -> dict:
|
||||
"""聚合通知面板数据:通知 + 消息 + 待办"""
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy import desc, select
|
||||
|
||||
# 1. 通知:最新 5 条已启用公告
|
||||
notices = await cls.get_latest_notices_service(auth, limit=5)
|
||||
@@ -284,11 +284,7 @@ class NoticeService:
|
||||
try:
|
||||
from app.api.v1.module_system.log.model import OperationLogModel
|
||||
|
||||
stmt = (
|
||||
select(OperationLogModel)
|
||||
.order_by(desc(OperationLogModel.created_time))
|
||||
.limit(5)
|
||||
)
|
||||
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:
|
||||
@@ -296,7 +292,9 @@ class NoticeService:
|
||||
"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 "",
|
||||
"time": log_entry.created_time.strftime("%Y-%m-%d %H:%M")
|
||||
if log_entry.created_time
|
||||
else "",
|
||||
"type": "system",
|
||||
})
|
||||
except Exception:
|
||||
|
||||
@@ -9,11 +9,11 @@ from app.core.validator import DateTimeStr
|
||||
class ParamsCreateSchema(BaseModel):
|
||||
"""配置创建模型"""
|
||||
|
||||
config_name: str = Field(..., max_length=64, description="参数名称")
|
||||
config_key: str = Field(..., max_length=500, description="参数键名")
|
||||
config_value: str | None = Field(default=None, description="参数键值")
|
||||
config_type: bool = Field(default=False, description="系统内置(True:是 False:否)")
|
||||
status: str = Field(default="0", description="状态(True:正常 False:停用)")
|
||||
config_name: str = Field(..., min_length=1, max_length=64, description="参数名称")
|
||||
config_key: str = Field(..., min_length=1, max_length=500, description="参数键名")
|
||||
config_value: str | None = Field(default=None, max_length=500, description="参数键值")
|
||||
config_type: bool = Field(default=False, description="是否系统内置")
|
||||
status: str = Field(default="0", max_length=1, description="状态(0:正常 1:停用)")
|
||||
description: str | None = Field(default=None, max_length=500, description="描述")
|
||||
|
||||
@field_validator("config_key")
|
||||
@@ -21,9 +21,15 @@ class ParamsCreateSchema(BaseModel):
|
||||
def _validate_config_key(cls, v: str) -> str:
|
||||
v = v.strip().lower()
|
||||
import re
|
||||
|
||||
if not re.match(r"^[a-z][a-z0-9_.-]*$", v):
|
||||
raise ValueError("参数键名必须以小写字母开头,仅包含小写字母/数字/_.-")
|
||||
raise ValueError("参数键名必须以小写字母开头,仅允许小写字母、数字、_ . -")
|
||||
return v
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def _validate_status(cls, v: str) -> str:
|
||||
if v not in {"0", "1"}:
|
||||
raise ValueError("状态仅支持 0(正常) 或 1(停用)")
|
||||
return v
|
||||
|
||||
|
||||
|
||||
@@ -154,7 +154,9 @@ class ParamsService:
|
||||
new_obj_dict = ParamsOutSchema.model_validate(obj).model_dump()
|
||||
|
||||
# 同步redis
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{auth.user.tenant_id}:{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,
|
||||
@@ -200,7 +202,9 @@ class ParamsService:
|
||||
redis_payload = out.model_dump(mode="json")
|
||||
|
||||
# 同步redis
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{auth.user.tenant_id}:{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(
|
||||
@@ -344,7 +348,9 @@ class ParamsService:
|
||||
返回:
|
||||
- list[dict]: 系统配置模型实例字典列表表示
|
||||
"""
|
||||
redis_keys = await RedisCURD(redis).get_keys(f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{tenant_id}:*")
|
||||
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:
|
||||
@@ -356,13 +362,14 @@ 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:
|
||||
|
||||
@@ -8,7 +8,13 @@ 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 .schema import (
|
||||
PluginCreateSchema,
|
||||
PluginInstallSchema,
|
||||
PluginOutSchema,
|
||||
PluginQueryParam,
|
||||
PluginUpdateSchema,
|
||||
)
|
||||
from .service import PluginService
|
||||
|
||||
PluginRouter = APIRouter(route_class=OperationLogRoute, prefix="/plugin", tags=["插件管理"])
|
||||
@@ -16,70 +22,97 @@ 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"]))]):
|
||||
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"]))]):
|
||||
@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"]))]):
|
||||
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"]))]):
|
||||
@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"]))]):
|
||||
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):
|
||||
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"]))]):
|
||||
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"]))]):
|
||||
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"]))]):
|
||||
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"]))]):
|
||||
async def my_plugins(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:plugin:query"]))],
|
||||
):
|
||||
return SuccessResponse(data=await PluginService.my_plugins_service(auth), msg="查询成功")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, validates
|
||||
|
||||
from app.core.base_model import MappedBase, ModelMixin
|
||||
@@ -11,18 +11,30 @@ class PluginModel(ModelMixin):
|
||||
__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)")
|
||||
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="版本号")
|
||||
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数组)")
|
||||
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")
|
||||
@@ -49,10 +61,20 @@ class TenantPluginModel(MappedBase):
|
||||
|
||||
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"
|
||||
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"
|
||||
Integer,
|
||||
ForeignKey("sys_plugin.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="插件ID",
|
||||
)
|
||||
enabled: Mapped[str] = mapped_column(
|
||||
String(1), nullable=False, default="1", comment="启用(1:启用 0:禁用)"
|
||||
)
|
||||
enabled: Mapped[str] = mapped_column(String(1), nullable=False, default="0", comment="启用(0:启用 1:禁用)")
|
||||
installed_time: Mapped[DateTime] = mapped_column(DateTime, nullable=False, comment="安装时间")
|
||||
|
||||
@@ -1,34 +1,79 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
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
|
||||
name: str = Field(..., min_length=1, max_length=100, description="插件名称")
|
||||
code: str = Field(..., min_length=1, max_length=50, description="插件编码(如 module_xxx)")
|
||||
description: str | None = Field(default=None, max_length=255, description="插件描述")
|
||||
version: str = Field(default="1.0.0", max_length=20, description="版本号")
|
||||
author: str | None = Field(default=None, max_length=100, description="作者")
|
||||
icon: str | None = Field(default=None, max_length=500, description="图标URL")
|
||||
category: str = Field(
|
||||
default="tool", max_length=20, description="分类(tool/ai/monitor/business)"
|
||||
)
|
||||
price: int = Field(default=0, ge=0, description="价格(分,0=免费)")
|
||||
menu_path: str | None = Field(default=None, max_length=200, description="菜单路径")
|
||||
permission_prefix: str | None = Field(default=None, max_length=100, description="权限前缀")
|
||||
dependencies: str | None = Field(default=None, description="依赖插件编码(JSON数组)")
|
||||
sort: int = Field(default=0, ge=0, description="排序")
|
||||
|
||||
@field_validator("category")
|
||||
@classmethod
|
||||
def _validate_category(cls, v: str) -> str:
|
||||
allowed = {"tool", "ai", "monitor", "business"}
|
||||
if v not in allowed:
|
||||
raise ValueError(f"插件分类仅支持 tool、ai、monitor、business,当前值: {v}")
|
||||
return v
|
||||
|
||||
@field_validator("version")
|
||||
@classmethod
|
||||
def _validate_version(cls, v: str) -> str:
|
||||
import re
|
||||
if not re.match(r"^\d+\.\d+\.\d+$", v):
|
||||
raise ValueError("版本号格式需为 x.y.z(如 1.0.0)")
|
||||
return v
|
||||
|
||||
@field_validator("code")
|
||||
@classmethod
|
||||
def _validate_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("插件编码不能为空")
|
||||
return v
|
||||
|
||||
|
||||
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
|
||||
name: str | None = Field(default=None, max_length=100, description="插件名称")
|
||||
description: str | None = Field(default=None, max_length=255, description="插件描述")
|
||||
version: str | None = Field(default=None, max_length=20, description="版本号")
|
||||
author: str | None = Field(default=None, max_length=100, description="作者")
|
||||
icon: str | None = Field(default=None, max_length=500, description="图标URL")
|
||||
category: str | None = Field(default=None, max_length=20, description="分类")
|
||||
price: int | None = Field(default=None, ge=0, description="价格(分,0=免费)")
|
||||
menu_path: str | None = Field(default=None, max_length=200, description="菜单路径")
|
||||
permission_prefix: str | None = Field(default=None, max_length=100, description="权限前缀")
|
||||
dependencies: str | None = Field(default=None, description="依赖插件编码(JSON数组)")
|
||||
sort: int | None = Field(default=None, ge=0, description="排序")
|
||||
status: str | None = Field(default=None, max_length=1, description="状态(0:正常 1:禁用)")
|
||||
|
||||
@field_validator("category")
|
||||
@classmethod
|
||||
def _validate_category(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
allowed = {"tool", "ai", "monitor", "business"}
|
||||
if v not in allowed:
|
||||
raise ValueError(f"插件分类仅支持 tool、ai、monitor、business,当前值: {v}")
|
||||
return v
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def _validate_status(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if v not in {"0", "1"}:
|
||||
raise ValueError("状态仅支持 0(正常) 或 1(禁用)")
|
||||
return v
|
||||
|
||||
|
||||
class PluginOutSchema(BaseModel):
|
||||
@@ -51,10 +96,15 @@ class PluginOutSchema(BaseModel):
|
||||
|
||||
|
||||
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)
|
||||
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):
|
||||
|
||||
@@ -12,15 +12,21 @@ from .schema import PluginCreateSchema, PluginOutSchema, PluginQueryParam, Plugi
|
||||
|
||||
|
||||
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:
|
||||
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,
|
||||
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,
|
||||
@@ -55,15 +61,18 @@ class PluginService:
|
||||
# ───── 插件市场 API ─────
|
||||
|
||||
@classmethod
|
||||
async def marketplace_service(cls, auth: AuthSchema, page_no: int, page_size: int,
|
||||
category: str | None = None) -> dict:
|
||||
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,
|
||||
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
|
||||
@@ -88,20 +97,28 @@ class PluginService:
|
||||
if not plugin or plugin.status == "1":
|
||||
raise CustomException(msg="插件不可用")
|
||||
exist = await auth.db.execute(
|
||||
sa.select(TenantPluginModel).where(
|
||||
sa
|
||||
.select(TenantPluginModel)
|
||||
.where(
|
||||
TenantPluginModel.tenant_id == tenant_id,
|
||||
TenantPluginModel.plugin_id == plugin_id,
|
||||
).limit(1)
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if exist.scalar_one_or_none():
|
||||
await auth.db.execute(
|
||||
sa.update(TenantPluginModel).where(
|
||||
sa
|
||||
.update(TenantPluginModel)
|
||||
.where(
|
||||
TenantPluginModel.tenant_id == tenant_id,
|
||||
TenantPluginModel.plugin_id == plugin_id,
|
||||
).values(enabled="0")
|
||||
)
|
||||
.values(enabled="0")
|
||||
)
|
||||
else:
|
||||
tp = TenantPluginModel(tenant_id=tenant_id, plugin_id=plugin_id, enabled="0", installed_time=datetime.now())
|
||||
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}]")
|
||||
@@ -124,10 +141,13 @@ class PluginService:
|
||||
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(
|
||||
sa
|
||||
.select(TenantPluginModel)
|
||||
.where(
|
||||
TenantPluginModel.tenant_id == tenant_id,
|
||||
TenantPluginModel.plugin_id == plugin_id,
|
||||
).limit(1)
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
tp = tp.scalar_one_or_none()
|
||||
if not tp:
|
||||
@@ -142,15 +162,19 @@ class PluginService:
|
||||
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)
|
||||
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 ""
|
||||
d["installed_time"] = (
|
||||
tp.installed_time.strftime("%Y-%m-%d %H:%M") if tp.installed_time else ""
|
||||
)
|
||||
plugins.append(d)
|
||||
return plugins
|
||||
|
||||
@@ -9,9 +9,9 @@ from app.core.validator import DateTimeStr
|
||||
class PositionCreateSchema(BaseModel):
|
||||
"""岗位创建模型"""
|
||||
|
||||
name: str = Field(..., max_length=64, description="岗位名称")
|
||||
order: int = Field(default=1, ge=1, description="显示排序")
|
||||
status: str = Field(default="0", description="是否启用(0:启用 1:禁用)")
|
||||
name: str = Field(..., min_length=1, max_length=64, description="岗位名称")
|
||||
order: int = Field(default=1, ge=0, description="显示排序")
|
||||
status: str = Field(default="0", max_length=1, description="状态(0:正常 1:禁用)")
|
||||
description: str | None = Field(default=None, max_length=255, description="描述")
|
||||
|
||||
@field_validator("name")
|
||||
@@ -22,6 +22,13 @@ class PositionCreateSchema(BaseModel):
|
||||
raise ValueError("岗位名称不能为空")
|
||||
return v
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def _validate_status(cls, v: str) -> str:
|
||||
if v not in {"0", "1"}:
|
||||
raise ValueError("状态仅支持 0(正常) 或 1(禁用)")
|
||||
return v
|
||||
|
||||
|
||||
class PositionUpdateSchema(PositionCreateSchema):
|
||||
"""岗位更新模型"""
|
||||
|
||||
@@ -80,17 +80,14 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]):
|
||||
# 租户菜单约束:只允许分配租户菜单权限内的菜单
|
||||
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:
|
||||
if self.auth.user and not self.auth.user.is_superuser and self.auth.tenant_id:
|
||||
allowed_menu_ids = await TenantService.get_tenant_menu_ids(
|
||||
self.auth, self.auth.user.tenant_id
|
||||
self.auth, self.auth.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}]不在当前租户的功能组内,无法分配"
|
||||
)
|
||||
allowed_set = set(allowed_menu_ids)
|
||||
for menu in menus:
|
||||
if int(menu.id) not in allowed_set:
|
||||
raise CustomException(msg=f"菜单[{menu.name}]不在当前租户的功能组内,无法分配")
|
||||
|
||||
for obj in roles:
|
||||
relationship = obj.menus
|
||||
|
||||
@@ -74,9 +74,7 @@ class RoleModel(ModelMixin, TenantMixin):
|
||||
__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, comment="角色编码"
|
||||
)
|
||||
code: Mapped[str] = mapped_column(String(64), nullable=False, comment="角色编码")
|
||||
order: Mapped[int] = mapped_column(Integer, nullable=False, default=999, comment="显示排序")
|
||||
data_scope: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
|
||||
@@ -21,33 +21,41 @@ from app.core.validator import (
|
||||
class RoleCreateSchema(BaseModel):
|
||||
"""角色创建模型"""
|
||||
|
||||
name: str = Field(..., max_length=64, description="角色名称")
|
||||
code: str = Field(..., max_length=16, description="角色编码")
|
||||
order: int | None = Field(default=1, ge=1, description="显示排序")
|
||||
name: str = Field(..., min_length=1, max_length=64, description="角色名称")
|
||||
code: str = Field(..., min_length=2, max_length=64, description="角色编码")
|
||||
order: int | None = Field(default=1, ge=0, description="显示排序")
|
||||
data_scope: int | None = Field(
|
||||
default=1,
|
||||
ge=1,
|
||||
le=5,
|
||||
description="数据权限范围(1:仅本人 2:本部门 3:本部门及以下 4:全部 5:自定义)",
|
||||
)
|
||||
status: str = Field(default="0", description="是否启用")
|
||||
status: str = Field(default="0", max_length=1, description="状态(0:正常 1:禁用)")
|
||||
description: str | None = Field(default=None, max_length=255, description="描述")
|
||||
|
||||
@field_validator("code")
|
||||
@classmethod
|
||||
def validate_code(cls, value: str):
|
||||
"""
|
||||
校验角色编码(与部门编码规则一致,见 `validate_required_code`)。
|
||||
|
||||
参数:
|
||||
- value (str): 角色编码。
|
||||
|
||||
返回:
|
||||
- str: 校验后的角色编码。
|
||||
|
||||
异常:
|
||||
- ValueError: 不满足编码规则时抛出。
|
||||
"""
|
||||
"""校验角色编码:字母开头,2-64 位,仅含字母/数字/下划线"""
|
||||
return validate_required_code(value)
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def validate_status(cls, value: str):
|
||||
"""校验状态:仅支持 0(正常)、1(禁用)"""
|
||||
if value not in {"0", "1"}:
|
||||
raise ValueError("状态仅支持 0(正常) 或 1(禁用)")
|
||||
return value
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, value: str):
|
||||
"""校验角色名称:不能为空"""
|
||||
v = value.strip()
|
||||
if not v:
|
||||
raise ValueError("角色名称不能为空")
|
||||
return v
|
||||
|
||||
|
||||
class RolePermissionSettingSchema(BaseModel):
|
||||
"""角色权限配置模型"""
|
||||
|
||||
@@ -103,6 +103,11 @@ class RoleService:
|
||||
obj = await RoleCRUD(auth).get(code=data.code)
|
||||
if obj:
|
||||
raise CustomException(msg="创建失败,编码已存在")
|
||||
|
||||
# 检查租户配额
|
||||
from app.api.v1.module_system.tenant.service import TenantService
|
||||
await TenantService.check_quota_service(auth, auth.tenant_id, "role")
|
||||
|
||||
new_role = await RoleCRUD(auth).create(data=data)
|
||||
return RoleOutSchema.model_validate(new_role).model_dump()
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ from .schema import (
|
||||
TenantCreateSchema,
|
||||
TenantMenuSetSchema,
|
||||
TenantOutSchema,
|
||||
TenantQueryParam,
|
||||
TenantQuotaOutSchema,
|
||||
TenantQuotaUpdateSchema,
|
||||
TenantQueryParam,
|
||||
TenantUpdateSchema,
|
||||
TenantUserAddSchema,
|
||||
TenantUserOutSchema,
|
||||
|
||||
@@ -39,8 +39,14 @@ class TenantModel(ModelMixin):
|
||||
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="排序"
|
||||
sort: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序")
|
||||
package_id: Mapped[int | None] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("sys_tenant_package.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
default=None,
|
||||
index=True,
|
||||
comment="关联套餐ID",
|
||||
)
|
||||
start_time: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, nullable=True, default=None, comment="开始时间"
|
||||
@@ -78,9 +84,7 @@ class TenantUserModel(MappedBase):
|
||||
{"comment": "用户租户关联表"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
Integer, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
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"),
|
||||
@@ -130,10 +134,18 @@ class TenantQuotaModel(MappedBase):
|
||||
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="最大部门数")
|
||||
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")
|
||||
|
||||
@@ -186,3 +198,53 @@ class TenantMenuModel(MappedBase):
|
||||
index=True,
|
||||
comment="菜单ID",
|
||||
)
|
||||
|
||||
|
||||
class TenantPackageModel(MappedBase):
|
||||
"""租户套餐表 — 预定义的功能套餐,简化租户授权"""
|
||||
|
||||
__tablename__: str = "sys_tenant_package"
|
||||
__table_args__: dict[str, str] = {"comment": "租户套餐表"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="主键ID")
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, comment="套餐名称")
|
||||
code: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, comment="套餐编码")
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(10), nullable=False, default="0", comment="状态(0:正常 1:禁用)"
|
||||
)
|
||||
sort: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="排序")
|
||||
description: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, default=None, comment="描述"
|
||||
)
|
||||
create_time: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.now, nullable=False, comment="创建时间"
|
||||
)
|
||||
update_time: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.now, onupdate=datetime.now, nullable=False, comment="更新时间"
|
||||
)
|
||||
|
||||
|
||||
class TenantPackageMenuModel(MappedBase):
|
||||
"""套餐-菜单关联表 — 定义套餐包含的菜单资源"""
|
||||
|
||||
__tablename__: str = "sys_tenant_package_menu"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("package_id", "menu_id", name="uq_package_menu"),
|
||||
{"comment": "套餐菜单关联表"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, comment="主键ID")
|
||||
package_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("sys_tenant_package.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",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
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 .package_schema import (
|
||||
TenantPackageCreateSchema,
|
||||
TenantPackageMenuSetSchema,
|
||||
TenantPackageOutSchema,
|
||||
TenantPackageQueryParam,
|
||||
TenantPackageUpdateSchema,
|
||||
)
|
||||
from .package_service import TenantPackageService
|
||||
|
||||
PackageRouter = APIRouter(
|
||||
route_class=OperationLogRoute, prefix="/tenant/package", tags=["租户套餐管理"]
|
||||
)
|
||||
|
||||
|
||||
@PackageRouter.get(
|
||||
"/detail/{id}",
|
||||
summary="获取套餐详情",
|
||||
description="获取套餐详情",
|
||||
response_model=ResponseSchema[TenantPackageOutSchema],
|
||||
)
|
||||
async def get_package_detail_controller(
|
||||
id: Annotated[int, Path(description="套餐ID")],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))],
|
||||
) -> JSONResponse:
|
||||
result_dict = await TenantPackageService.detail_service(id=id, auth=auth)
|
||||
log.info(f"获取套餐详情成功 {id}")
|
||||
return SuccessResponse(data=result_dict, msg="获取套餐详情成功")
|
||||
|
||||
|
||||
@PackageRouter.get(
|
||||
"/list",
|
||||
summary="查询套餐列表",
|
||||
description="查询套餐列表(分页)",
|
||||
response_model=ResponseSchema[dict],
|
||||
)
|
||||
async def get_package_list_controller(
|
||||
page: Annotated[PaginationQueryParam, Depends()],
|
||||
search: Annotated[TenantPackageQueryParam, Depends()],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))],
|
||||
) -> JSONResponse:
|
||||
order_by = [{"sort": "asc"}, {"id": "asc"}]
|
||||
if page.order_by:
|
||||
order_by = page.order_by
|
||||
result_dict = await TenantPackageService.page_service(
|
||||
auth=auth,
|
||||
page_no=page.page_no if page.page_no is not None else 1,
|
||||
page_size=page.page_size if page.page_size is not None else 10,
|
||||
search=search,
|
||||
order_by=order_by,
|
||||
)
|
||||
log.info("查询套餐列表成功")
|
||||
return SuccessResponse(data=result_dict, msg="查询套餐列表成功")
|
||||
|
||||
|
||||
@PackageRouter.post(
|
||||
"/create",
|
||||
summary="创建套餐",
|
||||
description="创建套餐",
|
||||
response_model=ResponseSchema[TenantPackageOutSchema],
|
||||
)
|
||||
async def create_package_controller(
|
||||
data: TenantPackageCreateSchema,
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:create"]))],
|
||||
) -> JSONResponse:
|
||||
result_dict = await TenantPackageService.create_service(auth=auth, data=data)
|
||||
log.info(f"创建套餐成功: {result_dict.get('name')}")
|
||||
return SuccessResponse(data=result_dict, msg="创建套餐成功")
|
||||
|
||||
|
||||
@PackageRouter.put(
|
||||
"/update/{id}",
|
||||
summary="修改套餐",
|
||||
description="修改套餐",
|
||||
response_model=ResponseSchema[TenantPackageOutSchema],
|
||||
)
|
||||
async def update_package_controller(
|
||||
data: TenantPackageUpdateSchema,
|
||||
id: Annotated[int, Path(description="套餐ID")],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:update"]))],
|
||||
) -> JSONResponse:
|
||||
result_dict = await TenantPackageService.update_service(auth=auth, id=id, data=data)
|
||||
log.info(f"修改套餐成功: {result_dict.get('name')}")
|
||||
return SuccessResponse(data=result_dict, msg="修改套餐成功")
|
||||
|
||||
|
||||
@PackageRouter.delete(
|
||||
"/delete",
|
||||
summary="删除套餐",
|
||||
description="删除套餐",
|
||||
)
|
||||
async def delete_package_controller(
|
||||
ids: Annotated[list[int], Body(..., description="ID列表")],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:delete"]))],
|
||||
) -> JSONResponse:
|
||||
await TenantPackageService.delete_service(auth=auth, ids=ids)
|
||||
log.info(f"删除套餐成功: {ids}")
|
||||
return SuccessResponse(msg="删除套餐成功")
|
||||
|
||||
|
||||
@PackageRouter.get(
|
||||
"/{id}/menus",
|
||||
summary="获取套餐菜单权限",
|
||||
description="获取指定套餐包含的菜单ID列表",
|
||||
response_model=ResponseSchema[list[int]],
|
||||
)
|
||||
async def get_package_menus_controller(
|
||||
id: Annotated[int, Path(description="套餐ID")],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:query"]))],
|
||||
) -> JSONResponse:
|
||||
result = await TenantPackageService.get_menus_service(auth=auth, package_id=id)
|
||||
return SuccessResponse(data=result, msg="获取套餐菜单成功")
|
||||
|
||||
|
||||
@PackageRouter.put(
|
||||
"/{id}/menus",
|
||||
summary="设置套餐菜单权限",
|
||||
description="批量设置套餐的菜单权限(先清空再写入)",
|
||||
)
|
||||
async def set_package_menus_controller(
|
||||
id: Annotated[int, Path(description="套餐ID")],
|
||||
data: TenantPackageMenuSetSchema,
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:tenant:update"]))],
|
||||
) -> JSONResponse:
|
||||
await TenantPackageService.set_menus_service(auth=auth, package_id=id, data=data)
|
||||
log.info(f"设置套餐菜单权限成功: package_id={id}, count={len(data.menu_ids)}")
|
||||
return SuccessResponse(msg="设置套餐菜单权限成功")
|
||||
@@ -0,0 +1,64 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from app.api.v1.module_system.auth.schema import AuthSchema
|
||||
from app.core.base_crud import CRUDBase
|
||||
|
||||
from .model import TenantPackageModel
|
||||
from .package_schema import (
|
||||
TenantPackageCreateSchema,
|
||||
TenantPackageOutSchema,
|
||||
TenantPackageUpdateSchema,
|
||||
)
|
||||
|
||||
|
||||
class TenantPackageCRUD(
|
||||
CRUDBase[TenantPackageModel, TenantPackageCreateSchema, TenantPackageUpdateSchema]
|
||||
):
|
||||
"""租户套餐数据层"""
|
||||
|
||||
def __init__(self, auth: AuthSchema) -> None:
|
||||
self.auth = auth
|
||||
super().__init__(model=TenantPackageModel, auth=auth)
|
||||
|
||||
async def get_by_id_crud(
|
||||
self, id: int, preload: list[str | Any] | None = None
|
||||
) -> TenantPackageModel | None:
|
||||
return await self.get(id=id, preload=preload)
|
||||
|
||||
async def get_list_crud(
|
||||
self,
|
||||
search: dict | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
preload: list[str | Any] | None = None,
|
||||
) -> Sequence[TenantPackageModel]:
|
||||
return await self.list(search=search, order_by=order_by, preload=preload)
|
||||
|
||||
async def page_crud(
|
||||
self,
|
||||
offset: int,
|
||||
limit: int,
|
||||
order_by: list[dict[str, str]] | None,
|
||||
search: dict | None = None,
|
||||
out_schema: type[TenantPackageOutSchema] | None = None,
|
||||
preload: list[str | Any] | None = None,
|
||||
) -> dict:
|
||||
return await self.page(
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
order_by=order_by or [{"sort": "asc"}, {"id": "asc"}],
|
||||
search=search or {},
|
||||
out_schema=out_schema or TenantPackageOutSchema,
|
||||
preload=preload or [],
|
||||
)
|
||||
|
||||
async def create_crud(self, data: TenantPackageCreateSchema) -> TenantPackageModel | None:
|
||||
return await self.create(data=data)
|
||||
|
||||
async def update_crud(
|
||||
self, id: int, data: TenantPackageUpdateSchema
|
||||
) -> TenantPackageModel | None:
|
||||
return await self.update(id=id, data=data)
|
||||
|
||||
async def delete_crud(self, ids: list[int]) -> None:
|
||||
await self.delete(ids=ids)
|
||||
@@ -0,0 +1,90 @@
|
||||
from fastapi import Query
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.common.enums import QueueEnum
|
||||
from app.core.base_schema import BaseSchema
|
||||
from app.core.validator import DateTimeStr
|
||||
|
||||
|
||||
class TenantPackageCreateSchema(BaseModel):
|
||||
"""新增租户套餐"""
|
||||
|
||||
name: str = Field(..., max_length=100, description="套餐名称")
|
||||
code: str = Field(..., max_length=100, description="套餐编码")
|
||||
status: str = Field(default="0", description="状态(0:正常 1:禁用)")
|
||||
sort: int = Field(default=0, description="排序")
|
||||
description: str | None = Field(default=None, max_length=255, description="描述")
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _validate_name(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("名称不能为空")
|
||||
return v
|
||||
|
||||
@field_validator("code")
|
||||
@classmethod
|
||||
def _validate_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("编码不能为空")
|
||||
if not v.isalnum():
|
||||
raise ValueError("编码只能包含字母和数字")
|
||||
return v
|
||||
|
||||
|
||||
class TenantPackageUpdateSchema(BaseModel):
|
||||
"""更新租户套餐"""
|
||||
|
||||
name: str | None = Field(default=None, max_length=100, description="套餐名称")
|
||||
code: str | None = Field(default=None, max_length=100, description="套餐编码")
|
||||
status: str | None = Field(default=None, description="状态(0:正常 1:禁用)")
|
||||
sort: int | None = Field(default=None, description="排序")
|
||||
description: str | None = Field(default=None, max_length=255, description="描述")
|
||||
|
||||
@field_validator("code")
|
||||
@classmethod
|
||||
def _validate_code(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
v = v.strip()
|
||||
if not v.isalnum():
|
||||
raise ValueError("编码只能包含字母和数字")
|
||||
return v
|
||||
|
||||
|
||||
class TenantPackageOutSchema(BaseSchema):
|
||||
"""套餐响应"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
code: str
|
||||
status: str
|
||||
sort: int
|
||||
description: str | None
|
||||
create_time: DateTimeStr | None
|
||||
update_time: DateTimeStr | None
|
||||
|
||||
|
||||
class TenantPackageQueryParam:
|
||||
"""套餐查询参数"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str | None = Query(None, description="套餐名称"),
|
||||
code: str | None = Query(None, description="套餐编码"),
|
||||
status: str | None = Query(None, description="状态"),
|
||||
) -> None:
|
||||
if name:
|
||||
self.name = (QueueEnum.like.value, name)
|
||||
if code:
|
||||
self.code = (QueueEnum.like.value, code)
|
||||
if status:
|
||||
self.status = (QueueEnum.eq.value, status)
|
||||
|
||||
|
||||
class TenantPackageMenuSetSchema(BaseModel):
|
||||
"""批量设置套餐菜单权限"""
|
||||
|
||||
menu_ids: list[int] = Field(..., description="菜单ID列表")
|
||||
@@ -0,0 +1,183 @@
|
||||
from app.api.v1.module_system.auth.schema import AuthSchema
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import log
|
||||
|
||||
from .model import TenantPackageMenuModel
|
||||
from .package_crud import TenantPackageCRUD
|
||||
from .package_schema import (
|
||||
TenantPackageCreateSchema,
|
||||
TenantPackageMenuSetSchema,
|
||||
TenantPackageOutSchema,
|
||||
TenantPackageQueryParam,
|
||||
TenantPackageUpdateSchema,
|
||||
)
|
||||
|
||||
|
||||
class TenantPackageService:
|
||||
"""租户套餐模块服务层"""
|
||||
|
||||
@classmethod
|
||||
async def detail_service(cls, auth: AuthSchema, id: int) -> dict:
|
||||
obj = await TenantPackageCRUD(auth).get_by_id_crud(id=id)
|
||||
if not obj:
|
||||
raise CustomException(msg="套餐不存在")
|
||||
return TenantPackageOutSchema.model_validate(obj).model_dump()
|
||||
|
||||
@classmethod
|
||||
async def page_service(
|
||||
cls,
|
||||
auth: AuthSchema,
|
||||
page_no: int,
|
||||
page_size: int,
|
||||
search: TenantPackageQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> dict:
|
||||
return await TenantPackageCRUD(auth).page_crud(
|
||||
offset=(page_no - 1) * page_size,
|
||||
limit=page_size,
|
||||
order_by=order_by or [{"sort": "asc"}, {"id": "asc"}],
|
||||
search=search.__dict__ if search else {},
|
||||
out_schema=TenantPackageOutSchema,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def create_service(cls, auth: AuthSchema, data: TenantPackageCreateSchema) -> dict:
|
||||
if await TenantPackageCRUD(auth).get(name=data.name):
|
||||
raise CustomException(msg="创建失败,套餐名称已存在")
|
||||
if await TenantPackageCRUD(auth).get(code=data.code):
|
||||
raise CustomException(msg="创建失败,套餐编码已存在")
|
||||
|
||||
obj = await TenantPackageCRUD(auth).create_crud(data=data)
|
||||
if not obj:
|
||||
raise CustomException(msg="创建套餐失败")
|
||||
result = TenantPackageOutSchema.model_validate(obj).model_dump()
|
||||
log.info(f"创建套餐成功: {result.get('name')}")
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
async def update_service(
|
||||
cls, auth: AuthSchema, id: int, data: TenantPackageUpdateSchema
|
||||
) -> dict:
|
||||
obj = await TenantPackageCRUD(auth).get_by_id_crud(id=id)
|
||||
if not obj:
|
||||
raise CustomException(msg="套餐不存在")
|
||||
|
||||
if data.name is not None:
|
||||
exist = await TenantPackageCRUD(auth).get(name=data.name)
|
||||
if exist and exist.id != id:
|
||||
raise CustomException(msg="更新失败,名称重复")
|
||||
if data.code is not None:
|
||||
exist = await TenantPackageCRUD(auth).get(code=data.code)
|
||||
if exist and exist.id != id:
|
||||
raise CustomException(msg="更新失败,编码重复")
|
||||
|
||||
updated = await TenantPackageCRUD(auth).update_crud(id=id, data=data)
|
||||
if not updated:
|
||||
raise CustomException(msg="更新失败")
|
||||
return TenantPackageOutSchema.model_validate(updated).model_dump()
|
||||
|
||||
@classmethod
|
||||
async def delete_service(cls, auth: AuthSchema, ids: list[int]) -> None:
|
||||
if not ids:
|
||||
raise CustomException(msg="删除失败,删除对象不能为空")
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from .model import TenantModel
|
||||
|
||||
for pid in ids:
|
||||
stmt = (
|
||||
select(func.count()).select_from(TenantModel).where(TenantModel.package_id == pid)
|
||||
)
|
||||
result = await auth.db.execute(stmt)
|
||||
count = result.scalar()
|
||||
if count and count > 0:
|
||||
raise CustomException(msg=f"套餐 ID={pid} 已被 {count} 个租户使用,无法删除")
|
||||
|
||||
await TenantPackageCRUD(auth).delete_crud(ids=ids)
|
||||
|
||||
@classmethod
|
||||
async def get_menus_service(cls, auth: AuthSchema, package_id: int) -> list[int]:
|
||||
"""获取套餐菜单权限(返回 menu_id 列表)"""
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(TenantPackageMenuModel.menu_id).where(
|
||||
TenantPackageMenuModel.package_id == package_id
|
||||
)
|
||||
result = await auth.db.execute(stmt)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
@classmethod
|
||||
async def set_menus_service(
|
||||
cls, auth: AuthSchema, package_id: int, data: TenantPackageMenuSetSchema
|
||||
) -> None:
|
||||
"""批量设置套餐菜单权限(先清空再写入)"""
|
||||
from sqlalchemy import delete
|
||||
|
||||
await auth.db.execute(
|
||||
delete(TenantPackageMenuModel).where(TenantPackageMenuModel.package_id == package_id)
|
||||
)
|
||||
for menu_id in data.menu_ids:
|
||||
auth.db.add(TenantPackageMenuModel(package_id=package_id, menu_id=menu_id))
|
||||
await auth.db.flush()
|
||||
log.info(f"套餐[{package_id}]菜单权限已设置, count={len(data.menu_ids)}")
|
||||
|
||||
@staticmethod
|
||||
async def get_package_menu_ids(auth: AuthSchema, package_id: int) -> list[int]:
|
||||
"""获取套餐包含的菜单ID列表(供租户权限约束使用)"""
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(TenantPackageMenuModel.menu_id).where(
|
||||
TenantPackageMenuModel.package_id == package_id,
|
||||
)
|
||||
result = await auth.db.execute(stmt)
|
||||
ids = [row[0] for row in result.all()]
|
||||
return ids
|
||||
|
||||
@staticmethod
|
||||
async def get_tenant_available_menu_ids(auth: AuthSchema, tenant_id: int) -> list[int]:
|
||||
"""获取租户的完整可用菜单ID列表(套餐菜单 + 自定义授权菜单)
|
||||
|
||||
合并逻辑:
|
||||
1. 如果租户关联了套餐且套餐状态正常(status=0),取套餐包含的所有菜单
|
||||
2. 如果套餐被禁用(status=1),跳过套餐菜单
|
||||
3. 再取 sys_tenant_menu 中显式授权的菜单
|
||||
4. 返回两者的并集
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from .model import TenantMenuModel, TenantPackageModel, TenantModel
|
||||
|
||||
# 查询租户信息
|
||||
stmt = select(TenantModel).where(TenantModel.id == tenant_id).limit(1)
|
||||
result = await auth.db.execute(stmt)
|
||||
tenant = result.scalar_one_or_none()
|
||||
if not tenant:
|
||||
return []
|
||||
|
||||
all_menu_ids: set[int] = set()
|
||||
|
||||
# 1. 如果有关联套餐且套餐状态正常,获取套餐包含的菜单
|
||||
if tenant.package_id:
|
||||
pkg_stmt = select(TenantPackageModel.status).where(
|
||||
TenantPackageModel.id == tenant.package_id
|
||||
).limit(1)
|
||||
pkg_result = await auth.db.execute(pkg_stmt)
|
||||
pkg_status = pkg_result.scalar_one_or_none()
|
||||
if pkg_status == "0": # 仅正常套餐计入
|
||||
stmt = select(TenantPackageMenuModel.menu_id).where(
|
||||
TenantPackageMenuModel.package_id == tenant.package_id
|
||||
)
|
||||
result = await auth.db.execute(stmt)
|
||||
for row in result.all():
|
||||
all_menu_ids.add(row[0])
|
||||
|
||||
# 2. 获取自定义授权的菜单(sys_tenant_menu)
|
||||
stmt = select(TenantMenuModel.menu_id).where(
|
||||
TenantMenuModel.tenant_id == tenant_id,
|
||||
)
|
||||
result = await auth.db.execute(stmt)
|
||||
for row in result.all():
|
||||
all_menu_ids.add(row[0])
|
||||
|
||||
return list(all_menu_ids)
|
||||
@@ -3,15 +3,15 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
|
||||
|
||||
from app.common.enums import QueueEnum
|
||||
from app.core.base_schema import BaseSchema
|
||||
from app.core.validator import DateTimeStr
|
||||
from app.core.validator import DateTimeStr, email_validator, mobile_validator
|
||||
|
||||
|
||||
class TenantCreateSchema(BaseModel):
|
||||
"""新增租户"""
|
||||
|
||||
name: str = Field(..., max_length=100, description="租户名称")
|
||||
code: str = Field(..., max_length=100, description="租户编码")
|
||||
status: str = Field(default="0", description="状态(0:正常 1:禁用)")
|
||||
name: str = Field(..., min_length=1, max_length=100, description="租户名称")
|
||||
code: str = Field(..., min_length=2, max_length=100, description="租户编码")
|
||||
status: str = Field(default="0", max_length=1, description="状态(0:正常 1:禁用)")
|
||||
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="结束时间")
|
||||
@@ -21,14 +21,15 @@ class TenantCreateSchema(BaseModel):
|
||||
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="排序")
|
||||
sort: int = Field(default=0, ge=0, description="排序")
|
||||
package_id: int | None = Field(default=None, gt=0, description="关联套餐ID")
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _validate_name(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("名称不能为空")
|
||||
raise ValueError("租户名称不能为空")
|
||||
return v
|
||||
|
||||
@field_validator("code")
|
||||
@@ -36,11 +37,30 @@ class TenantCreateSchema(BaseModel):
|
||||
def _validate_code(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("编码不能为空")
|
||||
raise ValueError("租户编码不能为空")
|
||||
if not v.isalnum():
|
||||
raise ValueError("编码只能包含字母和数字")
|
||||
raise ValueError("租户编码仅允许字母和数字")
|
||||
return v
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def _validate_status(cls, v: str) -> str:
|
||||
if v not in {"0", "1"}:
|
||||
raise ValueError("状态仅支持 0(正常) 或 1(禁用)")
|
||||
return v
|
||||
|
||||
@field_validator("contact_phone")
|
||||
@classmethod
|
||||
def _validate_contact_phone(cls, v: str | None) -> str | None:
|
||||
return mobile_validator(v)
|
||||
|
||||
@field_validator("contact_email")
|
||||
@classmethod
|
||||
def _validate_contact_email(cls, v: str | None) -> str | None:
|
||||
if not v:
|
||||
return v
|
||||
return email_validator(v)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_time_range(self):
|
||||
if self.start_time and self.end_time and self.start_time > self.end_time:
|
||||
@@ -53,7 +73,7 @@ class TenantUpdateSchema(BaseModel):
|
||||
|
||||
name: str | None = Field(default=None, max_length=100, description="租户名称")
|
||||
code: str | None = Field(default=None, max_length=100, description="租户编码")
|
||||
status: str | None = Field(default=None, description="状态(0:正常 1:禁用)")
|
||||
status: str | None = Field(default=None, max_length=1, description="状态(0:正常 1:禁用)")
|
||||
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="结束时间")
|
||||
@@ -63,7 +83,8 @@ class TenantUpdateSchema(BaseModel):
|
||||
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="排序")
|
||||
sort: int | None = Field(default=None, ge=0, description="排序")
|
||||
package_id: int | None = Field(default=None, gt=0, description="关联套餐ID")
|
||||
|
||||
@field_validator("code")
|
||||
@classmethod
|
||||
@@ -72,9 +93,30 @@ class TenantUpdateSchema(BaseModel):
|
||||
return v
|
||||
v = v.strip()
|
||||
if not v.isalnum():
|
||||
raise ValueError("编码只能包含字母和数字")
|
||||
raise ValueError("租户编码仅允许字母和数字")
|
||||
return v
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def _validate_status(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if v not in {"0", "1"}:
|
||||
raise ValueError("状态仅支持 0(正常) 或 1(禁用)")
|
||||
return v
|
||||
|
||||
@field_validator("contact_phone")
|
||||
@classmethod
|
||||
def _validate_contact_phone(cls, v: str | None) -> str | None:
|
||||
return mobile_validator(v)
|
||||
|
||||
@field_validator("contact_email")
|
||||
@classmethod
|
||||
def _validate_contact_email(cls, v: str | None) -> str | None:
|
||||
if not v:
|
||||
return v
|
||||
return email_validator(v)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_time_range(self):
|
||||
if self.start_time and self.end_time and self.start_time > self.end_time:
|
||||
@@ -115,9 +157,23 @@ class TenantQueryParam:
|
||||
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:是)")
|
||||
user_id: int = Field(..., gt=0, description="用户ID")
|
||||
role: str = Field(default="member", max_length=20, description="租户内角色(owner/admin/member)")
|
||||
is_default: int = Field(default=0, ge=0, le=1, description="是否默认租户(0:否 1:是)")
|
||||
|
||||
@field_validator("role")
|
||||
@classmethod
|
||||
def _validate_role(cls, v: str) -> str:
|
||||
if v not in {"owner", "admin", "member"}:
|
||||
raise ValueError("租户角色仅支持 owner(拥有者)、admin(管理员)、member(成员)")
|
||||
return v
|
||||
|
||||
@field_validator("is_default")
|
||||
@classmethod
|
||||
def _validate_is_default(cls, v: int) -> int:
|
||||
if v not in {0, 1}:
|
||||
raise ValueError("是否默认仅支持 0(否) 或 1(是)")
|
||||
return v
|
||||
|
||||
|
||||
class TenantUserOutSchema(BaseModel):
|
||||
@@ -137,6 +193,7 @@ class TenantUserOutSchema(BaseModel):
|
||||
|
||||
# ============ P1: 配额管理 ============
|
||||
|
||||
|
||||
class TenantQuotaOutSchema(BaseModel):
|
||||
"""租户配额响应"""
|
||||
|
||||
@@ -161,12 +218,20 @@ class TenantQuotaUpdateSchema(BaseModel):
|
||||
|
||||
# ============ P1: 租户配置 ============
|
||||
|
||||
|
||||
class TenantConfigItem(BaseModel):
|
||||
"""单个配置项"""
|
||||
|
||||
config_key: str = Field(..., description="配置键")
|
||||
config_value: str = Field(..., description="配置值")
|
||||
config_type: str = Field(default="string", description="配置类型")
|
||||
config_key: str = Field(..., min_length=1, max_length=100, description="配置键")
|
||||
config_value: str = Field(..., max_length=65535, description="配置值")
|
||||
config_type: str = Field(default="string", max_length=20, description="配置类型(string/json/int/bool)")
|
||||
|
||||
@field_validator("config_type")
|
||||
@classmethod
|
||||
def _validate_config_type(cls, v: str) -> str:
|
||||
if v not in {"string", "json", "int", "bool"}:
|
||||
raise ValueError("配置类型仅支持 string、json、int、bool")
|
||||
return v
|
||||
|
||||
|
||||
class TenantConfigOutSchema(TenantConfigItem):
|
||||
@@ -180,6 +245,7 @@ class TenantConfigOutSchema(TenantConfigItem):
|
||||
|
||||
# ============ P1: 租户菜单 ============
|
||||
|
||||
|
||||
class TenantMenuSetSchema(BaseModel):
|
||||
"""批量设置租户菜单权限"""
|
||||
|
||||
|
||||
@@ -18,16 +18,22 @@ from app.core.redis_crud import RedisCURD
|
||||
from app.utils.hash_bcrpy_util import PwdUtil
|
||||
|
||||
from .crud import TenantCRUD
|
||||
from .model import TenantConfigModel, TenantMenuModel, TenantModel, TenantQuotaModel, TenantUserModel
|
||||
from .model import (
|
||||
TenantConfigModel,
|
||||
TenantMenuModel,
|
||||
TenantModel,
|
||||
TenantQuotaModel,
|
||||
TenantUserModel,
|
||||
)
|
||||
from .schema import (
|
||||
TenantConfigItem,
|
||||
TenantConfigOutSchema,
|
||||
TenantCreateSchema,
|
||||
TenantMenuSetSchema,
|
||||
TenantOutSchema,
|
||||
TenantQueryParam,
|
||||
TenantQuotaOutSchema,
|
||||
TenantQuotaUpdateSchema,
|
||||
TenantQueryParam,
|
||||
TenantUpdateSchema,
|
||||
TenantUserAddSchema,
|
||||
TenantUserOutSchema,
|
||||
@@ -122,12 +128,19 @@ class TenantService:
|
||||
if not obj:
|
||||
raise CustomException(msg="租户不存在")
|
||||
|
||||
old_package_id = obj.package_id
|
||||
|
||||
if id == 1:
|
||||
if data.code is not None and data.code != obj.code:
|
||||
raise CustomException(msg="系统租户编码不可修改")
|
||||
if data.status is not None and data.status == "1":
|
||||
raise CustomException(msg="系统租户不允许禁用")
|
||||
|
||||
# 套餐变更:仅超管可操作,防止租户管理员自行升级/降级套餐
|
||||
if data.package_id is not None and data.package_id != old_package_id:
|
||||
if not auth.user or not auth.user.is_superuser:
|
||||
raise CustomException(msg="仅平台管理员可变更租户套餐")
|
||||
|
||||
if data.name is not None:
|
||||
exist = await TenantCRUD(auth).get(name=data.name)
|
||||
if exist and exist.id != id:
|
||||
@@ -140,6 +153,34 @@ class TenantService:
|
||||
updated = await TenantCRUD(auth).update_crud(id=id, data=data)
|
||||
if not updated:
|
||||
raise CustomException(msg="更新失败")
|
||||
|
||||
# 套餐变更后:清理角色中不再可用的菜单关联,防止用户看到空白菜单
|
||||
if data.package_id is not None and data.package_id != old_package_id:
|
||||
from sqlalchemy import delete as sa_delete
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.v1.module_system.role.model import RoleMenusModel, RoleModel
|
||||
|
||||
from .package_service import TenantPackageService
|
||||
|
||||
available_ids = await TenantPackageService.get_tenant_available_menu_ids(auth, id)
|
||||
if available_ids:
|
||||
role_ids_stmt = select(RoleModel.id).where(RoleModel.tenant_id == id)
|
||||
result = await auth.db.execute(role_ids_stmt)
|
||||
tenant_role_ids = [row[0] for row in result.all()]
|
||||
if tenant_role_ids:
|
||||
await auth.db.execute(
|
||||
sa_delete(RoleMenusModel).where(
|
||||
RoleMenusModel.role_id.in_(tenant_role_ids),
|
||||
RoleMenusModel.menu_id.notin_(available_ids),
|
||||
)
|
||||
)
|
||||
await auth.db.flush()
|
||||
log.info(
|
||||
f"租户[{id}]套餐变更:已清理角色中不再可用的菜单关联, "
|
||||
f"available_menus={len(available_ids)}, roles_affected={len(tenant_role_ids)}"
|
||||
)
|
||||
|
||||
result = TenantOutSchema.model_validate(updated).model_dump()
|
||||
return result
|
||||
|
||||
@@ -187,9 +228,7 @@ class TenantService:
|
||||
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]:
|
||||
async def get_tenant_users_service(cls, auth: AuthSchema, tenant_id: int) -> list[dict]:
|
||||
"""获取租户下的用户列表"""
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -255,16 +294,17 @@ class TenantService:
|
||||
# 如果设为默认租户,先取消其他默认
|
||||
if data.is_default == 1:
|
||||
await auth.db.execute(
|
||||
sa.update(TenantUserModel)
|
||||
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
|
||||
)
|
||||
select(sa.func.count())
|
||||
.select_from(TenantUserModel)
|
||||
.where(TenantUserModel.user_id == data.user_id)
|
||||
)
|
||||
count = count_result.scalar()
|
||||
if count == 0:
|
||||
@@ -283,9 +323,7 @@ class TenantService:
|
||||
auth.db.add(tu)
|
||||
await auth.db.flush()
|
||||
|
||||
log.info(
|
||||
f"向租户[{tenant.name}]添加用户[{user.username}]成功, role={data.role}"
|
||||
)
|
||||
log.info(f"向租户[{tenant.name}]添加用户[{user.username}]成功, role={data.role}")
|
||||
|
||||
@classmethod
|
||||
async def remove_tenant_user_service(
|
||||
@@ -344,7 +382,9 @@ class TenantService:
|
||||
return TenantQuotaOutSchema.model_validate(quota).model_dump()
|
||||
|
||||
@classmethod
|
||||
async def update_quota_service(cls, auth: AuthSchema, tenant_id: int, data: TenantQuotaUpdateSchema) -> dict:
|
||||
async def update_quota_service(
|
||||
cls, auth: AuthSchema, tenant_id: int, data: TenantQuotaUpdateSchema
|
||||
) -> dict:
|
||||
"""更新租户配额"""
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -363,6 +403,72 @@ class TenantService:
|
||||
log.info(f"租户[{tenant_id}]配额已更新: {update_data}")
|
||||
return TenantQuotaOutSchema.model_validate(quota).model_dump()
|
||||
|
||||
@classmethod
|
||||
async def check_quota_service(
|
||||
cls,
|
||||
auth: AuthSchema,
|
||||
tenant_id: int,
|
||||
resource_type: str, # "user"/"role"/"dept"
|
||||
) -> None:
|
||||
"""检查租户配额是否充足,不足时抛出异常"""
|
||||
from sqlalchemy import func, select
|
||||
|
||||
quota = await cls.get_quota_obj(auth, tenant_id)
|
||||
|
||||
field_map = {
|
||||
"user": "max_users",
|
||||
"role": "max_roles",
|
||||
"dept": "max_depts",
|
||||
}
|
||||
if resource_type not in field_map:
|
||||
return
|
||||
|
||||
max_field = field_map[resource_type]
|
||||
max_limit = getattr(quota, max_field, 0)
|
||||
|
||||
# 根据资源类型动态获取当前数量
|
||||
if resource_type == "user":
|
||||
from app.api.v1.module_system.user.model import UserModel
|
||||
count_stmt = select(func.count()).select_from(UserModel).where(
|
||||
UserModel.tenant_id == tenant_id,
|
||||
UserModel.is_deleted.is_(False),
|
||||
)
|
||||
elif resource_type == "role":
|
||||
from app.api.v1.module_system.role.model import RoleModel
|
||||
count_stmt = select(func.count()).select_from(RoleModel).where(
|
||||
RoleModel.tenant_id == tenant_id,
|
||||
RoleModel.is_deleted.is_(False),
|
||||
)
|
||||
elif resource_type == "dept":
|
||||
from app.api.v1.module_system.dept.model import DeptModel
|
||||
count_stmt = select(func.count()).select_from(DeptModel).where(
|
||||
DeptModel.tenant_id == tenant_id,
|
||||
DeptModel.is_deleted.is_(False),
|
||||
)
|
||||
|
||||
result = await auth.db.execute(count_stmt)
|
||||
current_count = result.scalar() or 0
|
||||
|
||||
if max_limit > 0 and current_count >= max_limit:
|
||||
resource_labels = {"user": "用户", "role": "角色", "dept": "部门"}
|
||||
raise CustomException(
|
||||
msg=f"租户{resource_labels.get(resource_type, resource_type)}数量已达上限({max_limit}),无法继续创建"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_quota_obj(cls, auth: AuthSchema, tenant_id: int) -> TenantQuotaModel:
|
||||
"""获取租户配额对象,不存在则自动初始化"""
|
||||
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 quota
|
||||
|
||||
# ============ P1: 租户配置 ============
|
||||
|
||||
@classmethod
|
||||
@@ -429,13 +535,9 @@ class TenantService:
|
||||
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:
|
||||
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
|
||||
]
|
||||
redis_keys = [f"{RedisInitKeyConfig.TENANT_CONFIG.key}:{tenant_id}:{k}" for k in keys]
|
||||
if redis_keys:
|
||||
await RedisCURD(redis).delete(*redis_keys)
|
||||
|
||||
@@ -489,29 +591,27 @@ class TenantService:
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
@classmethod
|
||||
async def set_menus_service(cls, auth: AuthSchema, tenant_id: int, data: TenantMenuSetSchema) -> None:
|
||||
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)
|
||||
)
|
||||
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
|
||||
async def get_tenant_menu_ids(auth: AuthSchema, tenant_id: int) -> list[int]:
|
||||
"""获取租户可用菜单ID列表(套餐菜单 + 自定义授权菜单的并集)
|
||||
|
||||
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
|
||||
供角色/用户权限约束使用。
|
||||
"""
|
||||
from .package_service import TenantPackageService
|
||||
|
||||
return await TenantPackageService.get_tenant_available_menu_ids(auth, tenant_id)
|
||||
|
||||
# ============ P1: 初始化缓存 ============
|
||||
|
||||
@@ -526,9 +626,10 @@ class TenantService:
|
||||
返回:
|
||||
- None
|
||||
"""
|
||||
from app.core.database import async_db_session
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import async_db_session
|
||||
|
||||
async with async_db_session() as session:
|
||||
async with session.begin():
|
||||
stmt = select(TenantModel)
|
||||
@@ -551,9 +652,7 @@ class TenantService:
|
||||
f"✅ 租户[{tenant.name}](id={tenant.id}) {len(config_list)} 条配置已缓存到 Redis"
|
||||
)
|
||||
else:
|
||||
log.warning(
|
||||
f"⚠️ 租户[{tenant.name}](id={tenant.id}) 无配置数据,跳过缓存"
|
||||
)
|
||||
log.warning(f"⚠️ 租户[{tenant.name}](id={tenant.id}) 无配置数据,跳过缓存")
|
||||
|
||||
# ============ P1: 到期提醒 ============
|
||||
|
||||
@@ -578,9 +677,7 @@ class TenantService:
|
||||
if t.end_time <= now:
|
||||
# 已到期:自动禁用
|
||||
await db.execute(
|
||||
sa.update(TenantModel)
|
||||
.where(TenantModel.id == t.id)
|
||||
.values(status="1")
|
||||
sa.update(TenantModel).where(TenantModel.id == t.id).values(status="1")
|
||||
)
|
||||
log.info(f"租户[{t.name}]已到期,自动禁用")
|
||||
elif t.end_time <= now + timedelta(days=1):
|
||||
|
||||
@@ -9,7 +9,13 @@ 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 .schema import (
|
||||
TicketBatchSchema,
|
||||
TicketCreateSchema,
|
||||
TicketOutSchema,
|
||||
TicketQueryParam,
|
||||
TicketUpdateSchema,
|
||||
)
|
||||
from .service import TicketService
|
||||
|
||||
TicketRouter = APIRouter(route_class=OperationLogRoute, prefix="/ticket", tags=["工单管理"])
|
||||
@@ -21,11 +27,19 @@ async def ticket_list(
|
||||
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)
|
||||
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])
|
||||
@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"]))],
|
||||
@@ -44,7 +58,9 @@ async def ticket_create(
|
||||
return SuccessResponse(data=result, msg="创建成功")
|
||||
|
||||
|
||||
@TicketRouter.put("/update/{id}", summary="更新工单", response_model=ResponseSchema[TicketOutSchema])
|
||||
@TicketRouter.put(
|
||||
"/update/{id}", summary="更新工单", response_model=ResponseSchema[TicketOutSchema]
|
||||
)
|
||||
async def ticket_update(
|
||||
id: Annotated[int, Path()],
|
||||
data: TicketUpdateSchema,
|
||||
|
||||
@@ -17,10 +17,17 @@ class TicketModel(ModelMixin, TenantMixin, UserMixin):
|
||||
__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_content: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True, comment="工单内容(富文本)"
|
||||
)
|
||||
summary: 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:其他)"
|
||||
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:已关闭)"
|
||||
@@ -49,7 +56,7 @@ class TicketModel(ModelMixin, TenantMixin, UserMixin):
|
||||
raise ValueError("工单标题不能为空")
|
||||
return title.strip()
|
||||
|
||||
@validates("content", "ticket_content")
|
||||
@validates("summary", "ticket_content")
|
||||
def validate_content(self, key: str, content: str | None) -> str | None:
|
||||
if content and content.strip():
|
||||
return content.strip()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.base_schema import CommonSchema
|
||||
from app.core.validator import DateTimeStr
|
||||
@@ -7,12 +7,30 @@ from app.core.validator import DateTimeStr
|
||||
class TicketCreateSchema(BaseModel):
|
||||
"""创建工单"""
|
||||
|
||||
title: str = Field(..., max_length=200, description="工单标题")
|
||||
title: str = Field(..., min_length=1, 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)")
|
||||
summary: str | None = Field(default=None, description="工单内容(纯文本摘要)")
|
||||
ticket_type: str = Field(
|
||||
default="suggestion", max_length=20, description="工单类型(suggestion/bug/optimize/other)"
|
||||
)
|
||||
images: str | None = Field(default=None, description="图片URL列表(JSON数组)")
|
||||
description: str | None = Field(default=None, description="工单描述")
|
||||
description: str | None = Field(default=None, max_length=255, description="工单描述")
|
||||
|
||||
@field_validator("ticket_type")
|
||||
@classmethod
|
||||
def _validate_ticket_type(cls, v: str) -> str:
|
||||
allowed = {"suggestion", "bug", "optimize", "other"}
|
||||
if v not in allowed:
|
||||
raise ValueError(f"工单类型仅支持 suggestion、bug、optimize、other,当前值: {v}")
|
||||
return v
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def _validate_title(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("工单标题不能为空")
|
||||
return v
|
||||
|
||||
|
||||
class TicketUpdateSchema(BaseModel):
|
||||
@@ -20,12 +38,33 @@ 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:已关闭)")
|
||||
summary: str | None = Field(default=None, description="工单内容(纯文本摘要)")
|
||||
ticket_type: str | None = Field(default=None, max_length=20, description="工单类型")
|
||||
status: str | None = Field(
|
||||
default=None, max_length=10, 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="工单描述")
|
||||
assigned_id: int | None = Field(default=None, gt=0, description="处理人ID")
|
||||
description: str | None = Field(default=None, max_length=255, description="工单描述")
|
||||
|
||||
@field_validator("ticket_type")
|
||||
@classmethod
|
||||
def _validate_ticket_type(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
allowed = {"suggestion", "bug", "optimize", "other"}
|
||||
if v not in allowed:
|
||||
raise ValueError(f"工单类型仅支持 suggestion、bug、optimize、other,当前值: {v}")
|
||||
return v
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def _validate_status(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if v not in {"0", "1", "2", "3"}:
|
||||
raise ValueError("工单状态仅支持 0(待处理)、1(处理中)、2(已完成)、3(已关闭)")
|
||||
return v
|
||||
|
||||
|
||||
class TicketOutSchema(BaseModel):
|
||||
@@ -36,7 +75,7 @@ class TicketOutSchema(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
ticket_content: str | None = None
|
||||
content: str | None = None
|
||||
summary: str | None = None
|
||||
ticket_type: str
|
||||
status: str
|
||||
images: str | None = None
|
||||
@@ -53,8 +92,15 @@ class TicketOutSchema(BaseModel):
|
||||
class TicketBatchSchema(BaseModel):
|
||||
"""批量更新工单"""
|
||||
|
||||
ids: list[int] = Field(..., description="工单ID列表")
|
||||
status: str = Field(..., description="状态(0:待处理 1:处理中 2:已完成 3:已关闭)")
|
||||
ids: list[int] = Field(..., min_length=1, description="工单ID列表")
|
||||
status: str = Field(..., max_length=10, description="状态(0:待处理 1:处理中 2:已完成 3:已关闭)")
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def _validate_status(cls, v: str) -> str:
|
||||
if v not in {"0", "1", "2", "3"}:
|
||||
raise ValueError("工单状态仅支持 0(待处理)、1(处理中)、2(已完成)、3(已关闭)")
|
||||
return v
|
||||
|
||||
|
||||
class TicketQueryParam:
|
||||
|
||||
@@ -10,17 +10,76 @@ from .schema import (
|
||||
TicketUpdateSchema,
|
||||
)
|
||||
|
||||
_TICKET_STATUS_TRANSITIONS = {
|
||||
"0": {"1", "3"},
|
||||
"1": {"2", "3"},
|
||||
"2": {"3"},
|
||||
"3": {"0"},
|
||||
}
|
||||
|
||||
_TICKET_STATUS_LABELS = {
|
||||
"0": "待处理",
|
||||
"1": "处理中",
|
||||
"2": "已完成",
|
||||
"3": "已关闭",
|
||||
}
|
||||
|
||||
|
||||
class TicketService:
|
||||
"""工单管理服务层"""
|
||||
|
||||
@classmethod
|
||||
def _validate_status_transition(
|
||||
cls,
|
||||
auth: AuthSchema,
|
||||
ticket,
|
||||
new_status: str,
|
||||
) -> None:
|
||||
"""校验工单状态流转是否合法"""
|
||||
old_status = str(ticket.status) if ticket.status is not None else "0"
|
||||
old_label = _TICKET_STATUS_LABELS.get(old_status, old_status)
|
||||
new_label = _TICKET_STATUS_LABELS.get(new_status, new_status)
|
||||
|
||||
if new_status not in _TICKET_STATUS_TRANSITIONS.get(old_status, set()):
|
||||
raise CustomException(
|
||||
msg=f"不允许从“{old_label}”转换为“{new_label}”"
|
||||
)
|
||||
|
||||
is_super = auth.user and auth.user.is_superuser
|
||||
is_creator = auth.user and ticket.created_id == auth.user.id
|
||||
is_assignee = auth.user and ticket.assigned_id == auth.user.id
|
||||
|
||||
if new_status == "0":
|
||||
if not is_super:
|
||||
raise CustomException(msg="仅超管可以重新打开已关闭的工单")
|
||||
elif old_status == "0" and new_status == "1":
|
||||
if not (is_super or is_creator or is_assignee):
|
||||
raise CustomException(msg="仅创建人、处理人或超管可以受理工单")
|
||||
elif old_status == "0" and new_status == "3":
|
||||
if not (is_super or is_creator):
|
||||
raise CustomException(msg="仅创建人或超管可以取消工单")
|
||||
elif old_status == "1" and new_status == "2":
|
||||
if not (is_super or is_assignee):
|
||||
raise CustomException(msg="仅处理人或超管可以将工单标记为已完成")
|
||||
elif old_status == "1" and new_status == "3":
|
||||
if not (is_super or is_creator or is_assignee):
|
||||
raise CustomException(msg="仅创建人、处理人或超管可以关闭工单")
|
||||
elif old_status == "2" and new_status == "3":
|
||||
if not (is_super or is_creator):
|
||||
raise CustomException(msg="仅创建人或超管可以确认关闭工单")
|
||||
|
||||
@classmethod
|
||||
async def page_service(
|
||||
cls, auth: AuthSchema, page_no: int, page_size: int,
|
||||
search: TicketQueryParam | None = None, order_by: list | None = None,
|
||||
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,
|
||||
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,
|
||||
@@ -45,6 +104,10 @@ class TicketService:
|
||||
obj = await TicketCRUD(auth).get_by_id_crud(id=id)
|
||||
if not obj:
|
||||
raise CustomException(msg="工单不存在")
|
||||
|
||||
if data.status is not None:
|
||||
cls._validate_status_transition(auth, obj, data.status)
|
||||
|
||||
updated = await TicketCRUD(auth).update_crud(id=id, data=data)
|
||||
if not updated:
|
||||
raise CustomException(msg="更新失败")
|
||||
@@ -61,4 +124,10 @@ class TicketService:
|
||||
"""批量更新工单状态"""
|
||||
if not data.ids:
|
||||
raise CustomException(msg="请选择要操作的工单")
|
||||
|
||||
for tid in data.ids:
|
||||
obj = await TicketCRUD(auth).get_by_id_crud(id=tid)
|
||||
if not obj:
|
||||
raise CustomException(msg=f"工单[{tid}]不存在")
|
||||
cls._validate_status_transition(auth, obj, data.status)
|
||||
await TicketCRUD(auth).set_crud(ids=data.ids, status=data.status)
|
||||
|
||||
@@ -103,26 +103,29 @@ async def change_current_user_password_controller(
|
||||
return SuccessResponse(data=result_dict, msg="修改密码成功, 请重新登录")
|
||||
|
||||
|
||||
@UserRouter.put(
|
||||
"/reset/password",
|
||||
@UserRouter.post(
|
||||
"/{id}/reset-password",
|
||||
summary="重置密码",
|
||||
description="重置密码",
|
||||
response_model=ResponseSchema[UserOutSchema],
|
||||
)
|
||||
async def reset_password_controller(
|
||||
id: Annotated[int, Path(description="用户ID")],
|
||||
data: ResetPasswordSchema,
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:update"]))],
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
重置密码
|
||||
|
||||
参数:
|
||||
- id (int): 用户ID
|
||||
- data (ResetPasswordSchema): 重置密码模型
|
||||
- auth (AuthSchema): 认证信息模型
|
||||
|
||||
返回:
|
||||
- JSONResponse: 重置密码JSON响应
|
||||
"""
|
||||
data.id = id
|
||||
result_dict = await UserService.reset_user_password_service(data=data, auth=auth)
|
||||
log.info(f"重置密码成功: {result_dict}")
|
||||
return SuccessResponse(data=result_dict, msg="重置密码成功")
|
||||
|
||||
@@ -78,17 +78,11 @@ class UserModel(ModelMixin, TenantMixin, UserMixin):
|
||||
"deleted_by",
|
||||
]
|
||||
|
||||
username: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, comment="用户名/登录账号"
|
||||
)
|
||||
username: Mapped[str] = mapped_column(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, comment="手机号"
|
||||
)
|
||||
email: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, comment="邮箱"
|
||||
)
|
||||
mobile: Mapped[str | None] = mapped_column(String(11), nullable=True, comment="手机号")
|
||||
email: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="邮箱")
|
||||
gender: Mapped[str | None] = mapped_column(
|
||||
String(1), default="2", nullable=True, comment="性别(0:男 1:女 2:未知)"
|
||||
)
|
||||
|
||||
@@ -20,214 +20,218 @@ from app.core.validator import DateTimeStr, email_validator, mobile_validator
|
||||
class CurrentUserUpdateSchema(BaseModel):
|
||||
"""基础用户信息"""
|
||||
|
||||
name: str | None = Field(default=None, description="名称")
|
||||
mobile: str | None = Field(default=None, description="手机号")
|
||||
name: str | None = Field(default=None, max_length=32, description="名称")
|
||||
mobile: str | None = Field(default=None, max_length=11, description="手机号")
|
||||
email: EmailStr | None = Field(default=None, description="邮箱")
|
||||
gender: str | None = Field(default=None, description="性别")
|
||||
avatar: str | None = Field(default=None, description="头像")
|
||||
gender: str | None = Field(default=None, max_length=1, description="性别(0:男 1:女 2:未知)")
|
||||
avatar: str | None = Field(default=None, max_length=255, description="头像")
|
||||
|
||||
@field_validator("mobile")
|
||||
@classmethod
|
||||
def validate_mobile(cls, value: str | None):
|
||||
"""
|
||||
校验手机号格式(委托到 `mobile_validator`)。
|
||||
|
||||
参数:
|
||||
- value (str | None): 手机号。
|
||||
|
||||
返回:
|
||||
- str | None: 校验后的手机号。
|
||||
|
||||
异常:
|
||||
- CustomException: 手机号格式非法时抛出。
|
||||
"""
|
||||
"""校验手机号格式"""
|
||||
return mobile_validator(value)
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def validate_email(cls, value: str | None):
|
||||
"""
|
||||
校验邮箱格式(为空则跳过;否则委托到 `email_validator`)。
|
||||
|
||||
参数:
|
||||
- value (str | None): 邮箱。
|
||||
|
||||
返回:
|
||||
- str | None: 校验后的邮箱。
|
||||
|
||||
异常:
|
||||
- CustomException: 邮箱格式非法时抛出。
|
||||
"""
|
||||
"""校验邮箱格式"""
|
||||
if not value:
|
||||
return value
|
||||
return email_validator(value)
|
||||
|
||||
@field_validator("gender")
|
||||
@classmethod
|
||||
def validate_gender(cls, value: str | None):
|
||||
"""校验性别:仅支持 0(男)、1(女)、2(未知)"""
|
||||
if value and value not in {"0", "1", "2"}:
|
||||
raise ValueError("性别仅支持 0(男)、1(女)、2(未知)")
|
||||
return value
|
||||
|
||||
@field_validator("avatar")
|
||||
@classmethod
|
||||
def validate_avatar(cls, value: str | None):
|
||||
"""
|
||||
校验头像地址为合法的 HTTP/HTTPS URL。
|
||||
|
||||
参数:
|
||||
- value (str | None): 头像 URL。
|
||||
|
||||
返回:
|
||||
- str | None: 校验后的头像 URL。
|
||||
|
||||
异常:
|
||||
- ValueError: 头像 URL 非法时抛出。
|
||||
"""
|
||||
"""校验头像地址为合法的 HTTP/HTTPS URL"""
|
||||
if not value:
|
||||
return value
|
||||
parsed = urlparse(value)
|
||||
if parsed.scheme in ("http", "https") and parsed.netloc:
|
||||
return value
|
||||
raise ValueError("头像地址需为有效的HTTP/HTTPS URL")
|
||||
raise ValueError("头像地址需为有效的 HTTP/HTTPS URL")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_model(self):
|
||||
"""
|
||||
校验基础用户信息的长度约束。
|
||||
|
||||
返回:
|
||||
- CurrentUserUpdateSchema: 校验后的同一实例。
|
||||
|
||||
异常:
|
||||
- ValueError: 字段长度超限时抛出。
|
||||
"""
|
||||
"""校验基础用户信息长度约束"""
|
||||
if self.name and len(self.name) > 32:
|
||||
raise ValueError("名称长度不能超过32个字符")
|
||||
raise ValueError("名称长度不能超过 32 个字符")
|
||||
return self
|
||||
|
||||
|
||||
class UserRegisterSchema(BaseModel):
|
||||
"""注册"""
|
||||
|
||||
name: str | None = Field(default=None, description="名称")
|
||||
mobile: str | None = Field(default=None, description="手机号")
|
||||
username: str = Field(..., description="账号")
|
||||
password: str = Field(..., description="密码哈希值")
|
||||
role_ids: list[int] | None = Field(default=[1], description="角色ID")
|
||||
name: str | None = Field(default=None, max_length=32, description="姓名")
|
||||
mobile: str | None = Field(default=None, max_length=11, description="手机号")
|
||||
username: str = Field(..., min_length=3, max_length=32, description="账号")
|
||||
password: str = Field(..., min_length=6, max_length=128, description="密码")
|
||||
role_ids: list[int] | None = Field(default=[1], description="角色ID列表")
|
||||
created_id: int | None = Field(default=1, description="创建人ID")
|
||||
description: str | None = Field(default=None, max_length=255, description="备注")
|
||||
|
||||
@field_validator("mobile")
|
||||
@classmethod
|
||||
def validate_mobile(cls, value: str | None):
|
||||
"""
|
||||
校验手机号格式(委托到 `mobile_validator`)。
|
||||
|
||||
参数:
|
||||
- value (str | None): 手机号。
|
||||
|
||||
返回:
|
||||
- str | None: 校验后的手机号。
|
||||
|
||||
异常:
|
||||
- CustomException: 手机号格式非法时抛出。
|
||||
"""
|
||||
"""校验手机号格式"""
|
||||
return mobile_validator(value)
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def validate_username(cls, value: str):
|
||||
"""
|
||||
校验并规范化账号:字母开头,长度 3-32,仅含字母/数字/_ . -。
|
||||
|
||||
参数:
|
||||
- value (str): 账号。
|
||||
|
||||
返回:
|
||||
- str: 规范化后的账号。
|
||||
|
||||
异常:
|
||||
- ValueError: 账号为空或不满足格式约束时抛出。
|
||||
"""
|
||||
"""校验账号:字母开头,3-32 位,仅含字母/数字/_ . -"""
|
||||
v = value.strip()
|
||||
if not v:
|
||||
raise ValueError("账号不能为空")
|
||||
# 字母开头,允许字母数字_.-
|
||||
import re
|
||||
|
||||
if not re.match(r"^[A-Za-z][A-Za-z0-9_.-]{2,31}$", v):
|
||||
raise ValueError("账号需字母开头,3-32位,仅含字母/数字/_ . -")
|
||||
raise ValueError("账号需以字母开头,3-32 位,仅允许字母、数字、_ . -")
|
||||
return v
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def validate_password(cls, value: str):
|
||||
"""校验密码:6-128 位"""
|
||||
if len(value) < 6:
|
||||
raise ValueError("密码长度不能少于 6 位")
|
||||
if len(value) > 128:
|
||||
raise ValueError("密码长度不能超过 128 位")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_model(self):
|
||||
"""
|
||||
校验注册信息的长度约束。
|
||||
|
||||
返回:
|
||||
- UserRegisterSchema: 校验后的同一实例。
|
||||
|
||||
异常:
|
||||
- ValueError: 任一字段长度超限时抛出。
|
||||
"""
|
||||
"""校验注册信息长度约束"""
|
||||
if self.name and len(self.name) > 32:
|
||||
raise ValueError("名称长度不能超过32个字符")
|
||||
raise ValueError("姓名长度不能超过 32 个字符")
|
||||
if self.username and len(self.username) > 32:
|
||||
raise ValueError("账号长度不能超过32个字符")
|
||||
raise ValueError("账号长度不能超过 32 个字符")
|
||||
if self.description and len(self.description) > 255:
|
||||
raise ValueError("备注长度不能超过255个字符")
|
||||
if self.password and len(self.password) > 128:
|
||||
raise ValueError("密码长度不能超过128个字符")
|
||||
raise ValueError("备注长度不能超过 255 个字符")
|
||||
return self
|
||||
|
||||
|
||||
class UserForgetPasswordSchema(BaseModel):
|
||||
"""忘记密码"""
|
||||
|
||||
username: str = Field(..., max_length=32, description="用户名")
|
||||
new_password: str = Field(..., max_length=128, description="新密码")
|
||||
mobile: str | None = Field(default=None, description="手机号")
|
||||
username: str = Field(..., min_length=3, max_length=32, description="用户名")
|
||||
new_password: str = Field(..., min_length=6, max_length=128, description="新密码")
|
||||
mobile: str | None = Field(default=None, max_length=11, description="手机号")
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def validate_username(cls, value: str):
|
||||
"""校验账号:字母开头,3-32 位"""
|
||||
v = value.strip()
|
||||
if not v:
|
||||
raise ValueError("账号不能为空")
|
||||
import re
|
||||
if not re.match(r"^[A-Za-z][A-Za-z0-9_.-]{2,31}$", v):
|
||||
raise ValueError("账号需以字母开头,3-32 位,仅允许字母、数字、_ . -")
|
||||
return v
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def validate_new_password(cls, value: str):
|
||||
"""校验密码:6-128 位"""
|
||||
if len(value) < 6:
|
||||
raise ValueError("密码长度不能少于 6 位")
|
||||
if len(value) > 128:
|
||||
raise ValueError("密码长度不能超过 128 位")
|
||||
return value
|
||||
|
||||
@field_validator("mobile")
|
||||
@classmethod
|
||||
def validate_mobile(cls, value: str | None):
|
||||
"""
|
||||
校验手机号格式(委托到 `mobile_validator`)。
|
||||
|
||||
参数:
|
||||
- value (str | None): 手机号。
|
||||
|
||||
返回:
|
||||
- str | None: 校验后的手机号。
|
||||
|
||||
异常:
|
||||
- CustomException: 手机号格式非法时抛出。
|
||||
"""
|
||||
"""校验手机号格式"""
|
||||
return mobile_validator(value)
|
||||
|
||||
|
||||
class UserChangePasswordSchema(BaseModel):
|
||||
"""修改密码"""
|
||||
|
||||
old_password: str = Field(..., max_length=128, description="旧密码")
|
||||
new_password: str = Field(..., max_length=128, description="新密码")
|
||||
old_password: str = Field(..., min_length=6, max_length=128, description="旧密码")
|
||||
new_password: str = Field(..., min_length=6, max_length=128, description="新密码")
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def validate_new_password(cls, value: str):
|
||||
"""校验新密码:6-128 位"""
|
||||
if len(value) < 6:
|
||||
raise ValueError("新密码长度不能少于 6 位")
|
||||
if len(value) > 128:
|
||||
raise ValueError("新密码长度不能超过 128 位")
|
||||
return value
|
||||
|
||||
|
||||
class ResetPasswordSchema(BaseModel):
|
||||
"""重置密码"""
|
||||
|
||||
id: int = Field(..., description="主键ID")
|
||||
id: int = Field(default=0, description="主键ID(已弃用,由路径参数传入)")
|
||||
password: str = Field(..., min_length=6, max_length=128, description="新密码")
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def validate_password(cls, value: str):
|
||||
"""校验新密码:6-128 位"""
|
||||
if len(value) < 6:
|
||||
raise ValueError("新密码长度不能少于 6 位")
|
||||
if len(value) > 128:
|
||||
raise ValueError("新密码长度不能超过 128 位")
|
||||
return value
|
||||
|
||||
|
||||
class UserCreateSchema(CurrentUserUpdateSchema):
|
||||
"""新增"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
username: str | None = Field(default=None, max_length=32, description="用户名")
|
||||
password: str | None = Field(default=None, max_length=128, description="密码哈希值")
|
||||
status: str = Field(default="0", description="是否可用")
|
||||
username: str | None = Field(default=None, min_length=3, max_length=32, description="用户名")
|
||||
password: str | None = Field(default=None, min_length=6, max_length=128, description="密码")
|
||||
status: str = Field(default="0", max_length=1, description="状态(0:正常 1:禁用)")
|
||||
description: str | None = Field(default=None, max_length=255, description="备注")
|
||||
is_superuser: bool | None = Field(default=False, description="是否超管")
|
||||
dept_id: int | None = Field(default=None, description="部门ID")
|
||||
tenant_id: int | None = Field(default=None, description="租户ID,仅平台管理员创建时可指定")
|
||||
role_ids: list[int] | None = Field(default=[], description="角色ID")
|
||||
position_ids: list[int] | None = Field(default=[], description="岗位ID")
|
||||
role_ids: list[int] | None = Field(default=[], description="角色ID列表")
|
||||
position_ids: list[int] | None = Field(default=[], description="岗位ID列表")
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def validate_status(cls, value: str):
|
||||
"""校验状态:仅支持 0(正常)、1(禁用)"""
|
||||
if value not in {"0", "1"}:
|
||||
raise ValueError("状态仅支持 0(正常) 或 1(禁用)")
|
||||
return value
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def validate_username(cls, value: str | None):
|
||||
"""校验账号:字母开头,3-32 位"""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
import re
|
||||
if not re.match(r"^[A-Za-z][A-Za-z0-9_.-]{2,31}$", v):
|
||||
raise ValueError("账号需以字母开头,3-32 位,仅允许字母、数字、_ . -")
|
||||
return v
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def validate_password(cls, value: str | None):
|
||||
"""校验密码:6-128 位"""
|
||||
if value and len(value) < 6:
|
||||
raise ValueError("密码长度不能少于 6 位")
|
||||
if value and len(value) > 128:
|
||||
raise ValueError("密码长度不能超过 128 位")
|
||||
return value
|
||||
|
||||
|
||||
class UserUpdateSchema(UserCreateSchema):
|
||||
|
||||
@@ -143,6 +143,11 @@ class UserService:
|
||||
dept = await DeptCRUD(auth).get_by_id_crud(id=data.dept_id)
|
||||
if not dept:
|
||||
raise CustomException(msg="部门不存在")
|
||||
|
||||
# 检查租户配额
|
||||
from app.api.v1.module_system.tenant.service import TenantService
|
||||
await TenantService.check_quota_service(auth, auth.tenant_id, "user")
|
||||
|
||||
# 创建用户
|
||||
if data.password:
|
||||
data.password = PwdUtil.set_password_hash(password=data.password)
|
||||
@@ -316,15 +321,12 @@ class UserService:
|
||||
}
|
||||
|
||||
# 租户菜单约束:非超管用户只能看到租户菜单权限内的菜单
|
||||
if menu_ids and auth.user.tenant_id:
|
||||
if menu_ids and auth.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
|
||||
allowed_ids = await TenantService.get_tenant_menu_ids(auth, auth.tenant_id)
|
||||
allowed_set = set(allowed_ids)
|
||||
menu_ids = menu_ids & allowed_set
|
||||
|
||||
# 使用树形结构查询,预加载children关系
|
||||
menus = (
|
||||
@@ -587,7 +589,9 @@ class UserService:
|
||||
try:
|
||||
count = count + 1
|
||||
# 数据转换
|
||||
gender = "1" if row["gender"] == "男" else ("2" if row["gender"] == "女" else "1")
|
||||
gender = (
|
||||
"1" if row["gender"] == "男" else ("2" if row["gender"] == "女" else "1")
|
||||
)
|
||||
status = "0" if row["status"] == "正常" else "1"
|
||||
|
||||
# 构建用户数据
|
||||
@@ -627,7 +631,10 @@ class UserService:
|
||||
await UserCRUD(auth).set_user_roles_crud(
|
||||
user_ids=[new_user.id], role_ids=user_create_schema.role_ids
|
||||
)
|
||||
if user_create_schema.position_ids and len(user_create_schema.position_ids) > 0:
|
||||
if (
|
||||
user_create_schema.position_ids
|
||||
and len(user_create_schema.position_ids) > 0
|
||||
):
|
||||
await UserCRUD(auth).set_user_positions_crud(
|
||||
user_ids=[new_user.id], position_ids=user_create_schema.position_ids
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user