chore: 批量整理代码格式与优化细节

- 修复多处代码缩进、换行不规范问题
- 调整部分配置注释与字符串格式对齐
- 优化导入语句与多行表达式的排版
- 统一枚举、模型字段的注释风格
- 调整部分校验逻辑与提示文案
- 重构部分长函数参数与查询语句格式
This commit is contained in:
zhangtao
2026-06-02 08:19:10 +08:00
parent c0733d9861
commit 290728d9d2
98 changed files with 4884 additions and 1764 deletions
+5 -3
View File
@@ -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(
+18 -33
View File
@@ -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
+25 -11
View File
@@ -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,
+24 -16
View File
@@ -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:未知)"
)
+124 -120
View File
@@ -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
)