refactor: 移除多租户相关代码,重构为单租户架构

此次提交进行了大规模的架构重构:
1.  移除所有平台租户相关模块和代码,包括租户管理、套餐、订单、发票等功能
2.  将菜单模块从platform迁移到system模块,统一系统功能入口
3.  移除租户隔离相关的模型混入、中间件和配置
4.  简化文件上传、SSE事件总线、定时任务等模块的租户逻辑
5.  重构所有业务schema和模型,移除租户相关字段和关联
6.  清理初始化脚本、模板和常量中的租户相关代码
7.  简化认证和权限控制逻辑,移除数据范围检查相关代码
This commit is contained in:
zhangtao
2026-07-16 23:22:45 +08:00
parent f74b5f277e
commit a222cd9e43
119 changed files with 421 additions and 7145 deletions
+2 -1
View File
@@ -12,7 +12,7 @@ from app.api.v1.module_system.role.controller import RoleRouter
from app.api.v1.module_system.ticket.controller import TicketRouter
from app.api.v1.module_system.user.controller import UserRouter
from app.api.v1.module_system.versions.controller import VersionRouter
from app.api.v1.module_system.menu.controller import MenuRouter
system_router = APIRouter(prefix="/system")
system_router.include_router(AuthRouter)
@@ -27,3 +27,4 @@ system_router.include_router(TicketRouter)
system_router.include_router(UserRouter)
system_router.include_router(VersionRouter)
system_router.include_router(ApiTokenRouter)
system_router.include_router(MenuRouter)
@@ -1,25 +1,16 @@
"""API Token 数据模型
设计要点:
- token 全名:``fastpat_<tenant_code>_<tenant_id_hex>_<48-random>``,明文存 ``token_plain`` 字段
(按业务需求选择明文存储,便于长期使用的 webhook / 集成 token
- ``token_prefix`` 字段存前 12 字符用于列表展示,剩余部分用 ``fp_xxxx****yz`` 形式脱敏
- 支持 ``scopes``、``expires_at``、``rate_limit``、``status`` 等运营字段
- ``last_used_at`` 与 ``used_count`` 提供审计
"""
from datetime import datetime
from sqlalchemy import ForeignKey, Index, Integer, String, Text
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
from app.core.base_model import ModelMixin, UserMixin
class ApiTokenModel(ModelMixin, TenantMixin, UserMixin):
"""租户 API 访问令牌(用于外部系统/集成调用)
class ApiTokenModel(ModelMixin, UserMixin):
"""API 访问令牌(用于外部系统/集成调用)
token 全名格式:
fastpat_<tenant_code>_<tenant_id_hex>_<48-char-base64url-secret>
fastpat_<user_id_hex>_<48-char-base64url-secret>
字段:
- ``token_plain``:明文令牌(创建时一次性返回,后续可读但不推荐直接读)
@@ -32,11 +23,8 @@ class ApiTokenModel(ModelMixin, TenantMixin, UserMixin):
"""
__tablename__: str = "sys_api_token"
__table_args__ = (
Index("idx_tenant_token_prefix", "tenant_id", "token_prefix"),
{"comment": "租户 API 访问令牌"},
)
__loader_options__: list[str] = ["tenant_by", "created_by", "updated_by"]
__table_args__: dict[str, str] = {"comment": "API 访问令牌"}
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by"]
name: Mapped[str] = mapped_column(String(64), nullable=False, comment="令牌名称(业务语义,如:CRM-对账集成)")
token_prefix: Mapped[str] = mapped_column(String(32), nullable=False, index=True, comment="明文 token 前 12 字符(用于展示)")
@@ -55,7 +55,6 @@ class ApiTokenOutSchema(BaseModel):
last_used_at: datetime | None
last_used_ip: str | None
description: str | None
tenant_id: int
created_id: int | None
updated_id: int | None
created_time: datetime | None
@@ -75,7 +74,6 @@ class ApiTokenCreatedSchema(BaseModel):
expires_at: datetime | None
rate_limit: int
status: int
tenant_id: int
created_time: datetime | None
warning: str = Field(
default="请立即保存此 token。关闭此页面后将无法再次完整查看明文,如遗失请重置。",
@@ -9,7 +9,6 @@ from redis.asyncio.client import Redis
from sqlalchemy import update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_platform.tenant.crud import TenantCRUD
from app.api.v1.module_system.user.crud import UserCRUD
from app.core.base_schema import AuthSchema, PageResultSchema
from app.core.database import async_db_session
@@ -34,10 +33,10 @@ _TOKEN_PREFIX_DISPLAY_LEN = 12
_REDIS_RATE_KEY_PREFIX = "api_token:rate:"
def _generate_full_token(tenant_code: str, tenant_id: int) -> str:
"""生成完整 token``fastpat_<tenant_code>_<tenant_id_hex>_<48-base64url>``"""
def _generate_full_token(user_id: int) -> str:
"""生成完整 token``fastpat_<user_id_hex>_<48-base64url>``"""
secret_part = secrets.token_urlsafe(36)
return f"{_TOKEN_PREFIX_HEADER}{tenant_code}_{tenant_id:x}_{secret_part}"
return f"{_TOKEN_PREFIX_HEADER}{user_id:x}_{secret_part}"
def _mask_token(full_token: str) -> str:
@@ -62,7 +61,6 @@ def _to_out_schema(token: ApiTokenModel) -> ApiTokenOutSchema:
last_used_at=token.last_used_at,
last_used_ip=token.last_used_ip,
description=token.description,
tenant_id=token.tenant_id,
created_id=token.created_id,
updated_id=token.updated_id,
created_time=token.created_time,
@@ -92,34 +90,23 @@ def _parse_scopes(scopes_str: str) -> list[str]:
class ApiTokenService:
"""API Token 业务逻辑层"""
MAX_TOKENS_PER_TENANT: int = 50
MAX_TOKENS_PER_USER: int = 50
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
self.auth = auth
self.db = db
# ── 租户隔离检查 ──────────────────────────────────────
def _check_tenant_access(self, token: ApiTokenModel) -> None:
"""非超管只能访问本租户 token"""
if not self.auth.user.is_superuser and token.tenant_id != self.auth.user.tenant_id:
raise CustomException(msg="无权操作其他租户的 token")
# ── 创建 ──────────────────────────────────────────────
async def create(self, data: ApiTokenCreateSchema) -> ApiTokenCreatedSchema:
tenant = await TenantCRUD(self.auth, self.db).get(id=self.auth.user.tenant_id)
if not tenant:
raise CustomException(msg="租户上下文失效,无法创建 token")
existing = await ApiTokenCRUD(self.auth, self.db).get_list(
search={"tenant_id": tenant.id},
search={},
)
active_count = sum(1 for t in existing if t.status == 0 and not t.is_deleted)
if active_count >= self.MAX_TOKENS_PER_TENANT:
raise CustomException(msg=f"该租户 API Token 数量已达上限 ({self.MAX_TOKENS_PER_TENANT}),请先删除或禁用旧 token")
if active_count >= self.MAX_TOKENS_PER_USER:
raise CustomException(msg=f"API Token 数量已达上限 ({self.MAX_TOKENS_PER_USER}),请先删除或禁用旧 token")
full_token = _generate_full_token(tenant_code=tenant.code, tenant_id=tenant.id)
full_token = _generate_full_token(user_id=self.auth.user.id)
token_prefix = full_token[:_TOKEN_PREFIX_DISPLAY_LEN]
scopes_str = ",".join(data.scopes) if data.scopes else "*"
@@ -140,7 +127,7 @@ class ApiTokenService:
if not token_obj:
raise CustomException(msg="创建 token 失败")
logger.info(f"租户[{tenant.id}]新 token 创建成功: id={token_obj.id} name={data.name}")
logger.info(f"新 token 创建成功: id={token_obj.id} name={data.name}")
return ApiTokenCreatedSchema(
id=token_obj.id,
name=token_obj.name,
@@ -150,7 +137,6 @@ class ApiTokenService:
expires_at=token_obj.expires_at,
rate_limit=token_obj.rate_limit,
status=token_obj.status,
tenant_id=token_obj.tenant_id,
created_time=token_obj.created_time,
)
@@ -187,7 +173,6 @@ class ApiTokenService:
async def detail(self, id: int) -> ApiTokenOutSchema:
crud = ApiTokenCRUD(self.auth, self.db)
token = await crud.get_or_404(id=id)
self._check_tenant_access(token)
return _to_out_schema(token)
# ── 状态/重置 ──────────────────────────────────────────
@@ -195,13 +180,8 @@ class ApiTokenService:
async def reset(self, id: int, data: ApiTokenResetSchema) -> ApiTokenCreatedSchema:
crud = ApiTokenCRUD(self.auth, self.db)
token = await crud.get_or_404(id=id)
self._check_tenant_access(token)
tenant = await TenantCRUD(self.auth, self.db).get(id=token.tenant_id)
if not tenant:
raise CustomException(msg="租户不存在")
full_token = _generate_full_token(tenant_code=tenant.code, tenant_id=tenant.id)
full_token = _generate_full_token(user_id=self.auth.user.id)
token_prefix_new = full_token[:_TOKEN_PREFIX_DISPLAY_LEN]
values: dict[str, Any] = {
@@ -222,7 +202,7 @@ class ApiTokenService:
await self.db.flush()
await self.db.refresh(token)
logger.info(f"租户[{tenant.id}] token[{id}] 已重置,新前缀={token_prefix_new}")
logger.info(f"token[{id}] 已重置,新前缀={token_prefix_new}")
return ApiTokenCreatedSchema(
id=token.id,
name=token.name,
@@ -232,7 +212,6 @@ class ApiTokenService:
expires_at=token.expires_at,
rate_limit=token.rate_limit,
status=token.status,
tenant_id=token.tenant_id,
created_time=token.created_time,
)
@@ -241,13 +220,11 @@ class ApiTokenService:
raise CustomException(msg="状态值不合法(0:启用 1:禁用 2:吊销)")
crud = ApiTokenCRUD(self.auth, self.db)
token = await crud.get_or_404(id=id)
self._check_tenant_access(token)
await crud.update(id=id, data={"status": status}) # pyright: ignore[reportArgumentType]
async def delete(self, id: int) -> None:
crud = ApiTokenCRUD(self.auth, self.db)
token = await crud.get_or_404(id=id)
self._check_tenant_access(token)
await crud.delete(ids=[id])
# ── reveal:二次验证后展示明文 ─────────────────────────
@@ -262,7 +239,6 @@ class ApiTokenService:
crud = ApiTokenCRUD(self.auth, self.db)
token = await crud.get_or_404(id=id)
self._check_tenant_access(token)
return ApiTokenRevealOutSchema(token=token.token_plain, name=token.name)
@@ -278,7 +254,7 @@ async def authenticate_api_token(token: str, request_ip: str | None = None, redi
raise CustomException(msg="API Token 格式不合法", code=10401, status_code=401)
async with async_db_session() as db:
crud = ApiTokenCRUD(AuthSchema(check_data_scope=False), db)
crud = ApiTokenCRUD(AuthSchema(), db)
candidate = await crud.get_list(search={"token_plain": ("=", token)})
if not candidate:
raise CustomException(msg="API Token 无效", code=10401, status_code=401)
@@ -4,8 +4,6 @@ from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Body, Depends, Path, Query, Request, status
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi_cache import FastAPICache
from fastapi_cache.decorator import cache
from redis.asyncio.client import Redis
from sqlalchemy.ext.asyncio import AsyncSession
@@ -31,78 +29,26 @@ from .oauth_service import (
)
from .schema import (
CaptchaOutSchema,
EnterPlatformOutSchema,
ImpersonateOutSchema,
ImpersonateSchema,
LoginWithTenantsSchema,
SelectTenantOutSchema,
SelectTenantSchema,
LoginOutSchema,
SliderCompleteOutSchema,
SliderCompleteSchema,
TenantLookupOutSchema,
TenantOptionSchema,
TenantRegisterOutSchema,
TenantRegisterSchema,
)
from .service import (
CaptchaService,
LoginService,
TenantLookupService,
TenantRegisterService,
)
AuthRouter = APIRouter(route_class=OperationLogRoute, prefix="/auth", tags=["认证授权"])
_AUTH_TENANTS_NS = "auth_tenants"
@AuthRouter.get("/tenant/{code}", summary="通过编码查询租户", response_model=ResponseSchema[TenantLookupOutSchema])
async def lookup_tenant_controller(
db: Annotated[AsyncSession, Depends(db_getter)],
code: Annotated[str, Path(description="租户编码")],
) -> JSONResponse:
"""根据租户编码查询租户信息(用于登录页自动加载租户品牌配置)"""
data = await TenantLookupService.lookup_by_code(db=db, code=code)
return SuccessResponse(data=data, msg="查询成功")
@AuthRouter.get("/tenant-by-domain", summary="通过域名查询租户", response_model=ResponseSchema[TenantLookupOutSchema])
async def lookup_tenant_by_domain_controller(
db: Annotated[AsyncSession, Depends(db_getter)],
domain: Annotated[str, Query(description="域名(如 tenant.example.com")],
) -> JSONResponse:
"""根据域名查询租户信息(用于登录页通过访问域名自动识别租户品牌)"""
data = await TenantLookupService.lookup_by_domain(db=db, domain=domain)
return SuccessResponse(data=data, msg="查询成功")
@AuthRouter.get("/tenant-options", summary="获取所有租户选项(登录页下拉选择)", response_model=ResponseSchema[list[TenantOptionSchema]])
async def get_tenant_options_controller(
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
"""获取所有活跃租户下拉选项"""
data = await TenantLookupService.list_options(db=db)
return SuccessResponse(data=data, msg="查询成功")
@AuthRouter.get("/tenant-search", summary="搜索租户(按编码/名称模糊匹配)", response_model=ResponseSchema[list[TenantOptionSchema]])
async def search_tenant_controller(
db: Annotated[AsyncSession, Depends(db_getter)],
q: Annotated[str, Query(description="搜索关键字")],
) -> JSONResponse:
"""模糊搜索租户"""
data = await TenantLookupService.search(db=db, q=q)
return SuccessResponse(data=data, msg="查询成功")
@AuthRouter.post("/login", summary="登录", response_model=LoginWithTenantsSchema)
@AuthRouter.post("/login", summary="登录", response_model=LoginOutSchema)
async def login_for_access_token_controller(
request: Request,
background_tasks: BackgroundTasks,
redis: Annotated[Redis, Depends(redis_getter)],
db: Annotated[AsyncSession, Depends(db_getter)],
login_form: Annotated[CustomOAuth2PasswordRequestForm, Depends()],
) -> JSONResponse | LoginWithTenantsSchema:
) -> JSONResponse | LoginOutSchema:
login_result = await LoginService.authenticate_user(request=request, redis=redis, login_form=login_form, db=db, background_tasks=background_tasks)
logger.info(f"用户{login_form.username}登录成功")
@@ -150,55 +96,6 @@ async def logout_controller(
return ErrorResponse(msg="退出失败")
@AuthRouter.post("/select-tenant", summary="选择租户", response_model=ResponseSchema[SelectTenantOutSchema], dependencies=[Depends(get_current_user)])
async def select_tenant_controller(
request: Request,
auth: Annotated[AuthSchema, Depends(get_current_user)],
redis: Annotated[Redis, Depends(redis_getter)],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[SelectTenantSchema, Body(description="租户选择参数")],
) -> JSONResponse:
result = await LoginService(auth, db).select_tenant(request=request, redis=redis, tenant_id=data.tenant_id)
await FastAPICache.clear(namespace=_AUTH_TENANTS_NS)
return SuccessResponse(data=result, msg="租户切换成功")
@AuthRouter.post("/enter-platform", summary="进入平台管理模式", response_model=ResponseSchema[EnterPlatformOutSchema], dependencies=[Depends(get_current_user)])
async def enter_platform_controller(
request: Request,
auth: Annotated[AuthSchema, Depends(get_current_user)],
redis: Annotated[Redis, Depends(redis_getter)],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
result = await LoginService(auth, db).enter_platform(request=request, redis=redis)
await FastAPICache.clear(namespace=_AUTH_TENANTS_NS)
return SuccessResponse(data=result, msg="已返回平台管理模式")
@AuthRouter.get("/tenants", summary="获取可选租户列表", response_model=ResponseSchema[list[TenantOptionSchema]], dependencies=[Depends(get_current_user)])
@cache(expire=120, namespace=_AUTH_TENANTS_NS)
async def get_user_tenants_controller(
auth: Annotated[AuthSchema, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = LoginService(auth, db)
tenants = await service.get_user_tenants()
return SuccessResponse(data=tenants, msg="获取租户列表成功")
@AuthRouter.post("/impersonate", summary="平台管理员代签入", response_model=ResponseSchema[ImpersonateOutSchema], dependencies=[Depends(get_current_user)])
async def impersonate_controller(
request: Request,
auth: Annotated[AuthSchema, Depends(get_current_user)],
redis: Annotated[Redis, Depends(redis_getter)],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[ImpersonateSchema, Body(description="代签入参数")],
) -> JSONResponse:
result = await LoginService(auth, db).impersonate(request=request, redis=redis, tenant_id=data.tenant_id)
await FastAPICache.clear(namespace=_AUTH_TENANTS_NS)
return SuccessResponse(data=result, msg="代签入成功")
@AuthRouter.get("/oauth/{provider}/login", summary="第三方OAuth跳转")
async def oauth_login_redirect_controller(
request: Request,
@@ -281,19 +178,3 @@ async def oauth_callback_controller(
except CustomException as e:
fe = await resolve_frontend()
return RedirectContentResponse(url=oauth_service_error_redirect(fe, e.msg), status_code=302)
@AuthRouter.post("/tenant/register", status_code=status.HTTP_201_CREATED, summary="租户自助注册", response_model=ResponseSchema[TenantRegisterOutSchema])
async def tenant_register_controller(
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[TenantRegisterSchema, Body(description="租户注册参数")],
) -> JSONResponse:
result = await TenantRegisterService.register(
db=db,
username=data.username,
password=data.password,
email=data.email,
tenant_name=data.tenant_name,
)
logger.info(f"新租户注册: username={data.username} tenant={result.tenant_name}")
return SuccessResponse(data=result, msg=result.message)
@@ -306,7 +306,7 @@ async def ensure_oauth_user(
unique_id: str,
display_name: str,
) -> UserModel:
auth = AuthSchema(check_data_scope=False)
auth = AuthSchema()
username = _username_for_oauth(provider, unique_id)
existing = await UserCRUD(auth, db).get(username=username)
if existing:
@@ -316,7 +316,6 @@ async def ensure_oauth_user(
username=username,
password=secrets.token_urlsafe(24),
name=(display_name or username)[:32],
tenant_id=1, # 系统租户:OAuth 用户未指定业务租户,统一落到 system tenant
role_ids=list(settings.OAUTH_DEFAULT_ROLE_IDS),
)
try:
@@ -384,7 +383,7 @@ async def complete_oauth_login(
if user.status == 1:
raise CustomException(msg="用户已被停用")
user = await UserCRUD(AuthSchema(check_data_scope=False), db).update_last_login(id=user.id)
user = await UserCRUD(AuthSchema(), db).update_last_login(id=user.id)
if not user:
raise CustomException(msg="用户不存在")
@@ -1,6 +1,6 @@
from typing import Any
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from pydantic import BaseModel, ConfigDict, Field
from app.core.base_schema import JWTOutSchema
@@ -15,101 +15,12 @@ class CaptchaOutSchema(BaseModel):
img_base: str = Field(default="", description="Base64编码的验证码图片(滑块模式为空字符串)")
class TenantOptionSchema(BaseModel):
"""租户选项(用于登录后选择租户)"""
class LoginOutSchema(JWTOutSchema):
"""登录响应"""
model_config = ConfigDict(from_attributes=True)
id: int = Field(..., description="租户ID")
name: str = Field(..., description="租户名称")
code: str = Field(..., description="租户编码")
class SelectTenantSchema(BaseModel):
"""选择租户请求"""
tenant_id: int = Field(..., gt=0, description="租户ID")
class SelectTenantOutSchema(BaseModel):
"""选择租户响应"""
model_config = ConfigDict(from_attributes=True)
access_token: str = Field(..., description="访问token(含租户上下文)")
token_type: str = Field(default="Bearer", description="token类型(RFC 6750")
expires_in: int = Field(..., gt=0, description="过期时间(秒)")
class LoginWithTenantsSchema(JWTOutSchema):
"""登录响应(含租户列表)"""
tenants: list[TenantOptionSchema] = Field(default_factory=list, description="可选租户列表")
user_info: dict[str, Any] = Field(default_factory=dict, description="用户信息")
class TenantRegisterSchema(BaseModel):
"""租户自助注册请求"""
username: str = Field(..., min_length=3, max_length=32, description="登录账号")
password: str = Field(..., min_length=6, max_length=128, description="登录密码")
email: EmailStr = Field(..., max_length=128, description="邮箱(用于接收通知)")
tenant_name: str | None = Field(default=None, max_length=100, description="企业/团队名称(可选,默认:{用户名}的租户)")
class TenantRegisterOutSchema(BaseModel):
"""租户自助注册响应"""
user_id: int = Field(..., description="用户ID")
username: str = Field(..., description="账号")
tenant_id: int = Field(..., description="租户ID")
tenant_name: str = Field(..., description="租户名称")
tenant_code: str = Field(..., description="租户编码")
package: str | None = Field(default=None, description="开通套餐")
trial_end: str = Field(..., description="试用到期日")
message: str = Field(default="注册成功", description="提示信息")
class EnterPlatformOutSchema(BaseModel):
"""进入平台管理模式响应"""
model_config = ConfigDict(from_attributes=True)
access_token: str = Field(..., description="访问token(平台上下文)")
token_type: str = Field(default="Bearer", description="token类型(RFC 6750")
expires_in: int = Field(..., gt=0, description="过期时间(秒)")
class TenantLookupOutSchema(BaseModel):
"""通过编码查询租户响应"""
id: int = Field(..., description="租户ID")
name: str = Field(..., description="租户名称")
code: str = Field(..., description="租户编码")
logo_url: str | None = Field(default=None, description="Logo URL")
login_bg: str | None = Field(default=None, description="登录背景地址")
version: str | None = Field(default=None, description="版本号")
class ImpersonateSchema(BaseModel):
"""平台管理员代签入请求"""
tenant_id: int = Field(..., gt=0, description="目标租户ID")
class ImpersonateOutSchema(BaseModel):
"""平台管理员代签入响应"""
model_config = ConfigDict(from_attributes=True)
access_token: str = Field(..., description="访问token(租户上下文)")
refresh_token: str = Field(..., description="刷新token")
token_type: str = Field(default="Bearer", description="token类型")
expires_in: int = Field(..., gt=0, description="过期时间(秒)")
tenant_id: int = Field(..., description="目标租户ID")
tenant_name: str = Field(..., description="目标租户名称")
class SliderCompleteSchema(BaseModel):
"""滑块验证完成请求"""
+13 -471
View File
@@ -7,19 +7,14 @@ from typing import Any, NewType
import ua_parser
from fastapi import BackgroundTasks, Request
from redis.asyncio.client import Redis
from sqlalchemy import func, select
from sqlalchemy import update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_platform.package.model import PackageMenuModel, PackageModel
from app.api.v1.module_platform.tenant.model import TenantModel, TenantUserModel
from app.api.v1.module_system.log.crud import LoginLogCRUD
from app.api.v1.module_system.log.model import LoginLogModel
from app.api.v1.module_system.log.schema import LoginLogCreateSchema
from app.api.v1.module_system.role.model import RoleMenusModel, RoleModel
from app.api.v1.module_system.user.crud import UserCRUD
from app.api.v1.module_system.user.model import UserModel, UserRolesModel
from app.api.v1.module_system.user.schema import UserOutSchema
from app.api.v1.module_system.user.model import UserModel
from app.common.enums import RedisInitKeyConfig
from app.config.setting import settings
from app.core.base_schema import AuthSchema, JWTOutSchema, JWTPayloadSchema
@@ -27,11 +22,7 @@ from app.core.database import async_db_session
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.core.redis_crud import RedisCURD
from app.core.request_context import (
RequestContext,
clear_current_tenant,
set_current_tenant,
)
from app.core.request_context import RequestContext
from app.core.security import (
CustomOAuth2PasswordRequestForm,
create_access_token,
@@ -43,12 +34,7 @@ from app.utils.password_util import PwdUtil
from .schema import (
CaptchaOutSchema,
EnterPlatformOutSchema,
ImpersonateOutSchema,
LoginWithTenantsSchema,
SelectTenantOutSchema,
TenantOptionSchema,
TenantRegisterOutSchema,
LoginOutSchema,
)
CaptchaKey = NewType("CaptchaKey", str)
@@ -67,7 +53,7 @@ async def _write_login_log(
"""写入登录日志;返回日志 ID(用于后台补全归属地)。"""
try:
async with async_db_session() as session, session.begin():
_auth = AuthSchema(check_data_scope=False)
_auth = AuthSchema()
obj = await LoginLogCRUD(_auth, session).create(
data=LoginLogCreateSchema(
username=username,
@@ -110,43 +96,27 @@ class LoginService:
@staticmethod
def _collect_permissions(
user: UserModel,
) -> tuple[list[str], dict[str, int], list[int], list[int], list[int], list[int]]:
"""收集用户角色下的权限菜单、数据范围及角色 ID
遍历用户角色,聚合所有关联菜单的 permission、menu_id、
data_scope、自定义部门 ID 和角色 ID 列表。
) -> tuple[list[str], list[int]]:
"""收集用户角色下的权限菜单 ID
参数:
- user (UserModel): 用户对象
返回:
- tuple[list[str], dict[str, int], list[int], list[int], list[int], list[int]]:
(permissions, permissions_with_menu, menu_ids, data_scopes, custom_dept_ids, role_ids)
- tuple[list[str], list[int]]: (permissions, menu_ids)
"""
permissions: list[str] = []
permissions_with_menu: dict[str, int] = {}
menu_ids: list[int] = []
data_scopes: list[int] = []
custom_dept_ids: list[int] = []
role_ids: list[int] = []
if not user.is_superuser and hasattr(user, "roles"):
for role in user.roles:
if role and role.status == 0:
role_ids.append(role.id)
if hasattr(role, "menus"):
for menu in role.menus:
if menu and menu.status == 0:
menu_ids.append(menu.id)
if menu.permission:
permissions.append(menu.permission)
permissions_with_menu[menu.permission] = menu.id
if hasattr(role, "data_scope"):
data_scopes.append(role.data_scope)
if hasattr(role, "depts") and role.depts:
for dept in role.depts:
if dept:
custom_dept_ids.append(dept.id)
return permissions, permissions_with_menu, menu_ids, data_scopes, custom_dept_ids, role_ids
return permissions, menu_ids
@classmethod
async def authenticate_user(
@@ -156,7 +126,7 @@ class LoginService:
redis: Redis,
login_form: CustomOAuth2PasswordRequestForm,
db: AsyncSession,
) -> LoginWithTenantsSchema:
) -> LoginOutSchema:
"""用户认证"""
ua_result = ua_parser.parse(request.headers.get("user-agent") or "")
request_ip = get_client_ip(request)
@@ -177,7 +147,7 @@ class LoginService:
key=login_form.captcha_key,
)
auth = AuthSchema(check_data_scope=False)
auth = AuthSchema()
user = await UserCRUD(auth, db).get(username=login_form.username)
if not user:
@@ -215,20 +185,6 @@ class LoginService:
)
raise CustomException(msg="用户已被停用")
tenant_stmt = select(TenantModel).where(TenantModel.id == user.tenant_id, TenantModel.status == 0, TenantModel.is_deleted.is_(False)).limit(1)
tenant_result = await db.execute(tenant_stmt)
if not tenant_result.scalar_one_or_none():
await _write_login_log(
username=_login_username,
status=2,
login_ip=request_ip,
login_location=login_location,
request_os=_login_os,
request_browser=_login_browser,
msg="所属租户已被禁用",
)
raise CustomException(msg="所属租户已被禁用,请联系平台管理员")
await UserCRUD(auth, db).update_last_login(id=user.id)
if not user:
@@ -243,9 +199,6 @@ class LoginService:
login_type=login_form.login_type,
)
tenants_auth = AuthSchema(user=UserOutSchema.model_validate(user), check_data_scope=False)
tenants = await LoginService(tenants_auth, db).get_user_tenants(user_id=user.id)
user_info = {
"id": user.id,
"username": user.username,
@@ -267,12 +220,11 @@ class LoginService:
if log_id and login_location == "归属地查询中":
background_tasks.add_task(_async_fill_login_location, redis, log_id, request_ip)
return LoginWithTenantsSchema(
return LoginOutSchema(
access_token=token.access_token,
refresh_token=token.refresh_token,
expires_in=token.expires_in,
token_type=token.token_type,
tenants=tenants,
user_info=user_info,
)
@@ -281,11 +233,7 @@ class LoginService:
user: UserModel,
session_id: str,
permissions: list[str],
permissions_with_menu: dict[str, int],
menu_ids: list[int],
data_scopes: list[int],
custom_dept_ids: list[int],
role_ids: list[int],
request_ip: str,
login_location: str | None,
ua_result: Any,
@@ -297,11 +245,7 @@ class LoginService:
- user (UserModel): 用户对象
- session_id (str): 会话ID
- permissions (list[str]): 权限标识列表
- permissions_with_menu (dict[str, int]): 权限与菜单ID映射
- menu_ids (list[int]): 菜单ID列表
- data_scopes (list[int]): 数据范围列表
- custom_dept_ids (list[int]): 自定义部门ID列表
- role_ids (list[int]): 角色ID列表
- request_ip (str): 请求IP
- login_location (str): 登录地点
- ua_result: User-Agent 解析结果
@@ -310,12 +254,9 @@ class LoginService:
返回:
- dict: 会话信息字典
"""
tenant_status = getattr(user.tenant, "status", 0) if hasattr(user, "tenant") and user.tenant else 0
return {
"session_id": session_id,
"user_id": user.id,
"tenant_id": user.tenant_id if not user.is_superuser else 0,
"tenant_status": tenant_status,
"is_superuser": user.is_superuser,
"user_status": user.status,
"name": user.name,
@@ -326,11 +267,7 @@ class LoginService:
"gender": user.gender,
"avatar": user.avatar,
"permissions": permissions,
"permissions_with_menu": permissions_with_menu,
"menu_ids": menu_ids,
"data_scopes": data_scopes,
"custom_dept_ids": custom_dept_ids,
"role_ids": role_ids,
"ipaddr": request_ip,
"login_location": login_location,
"os": ua_result.os.family if ua_result.os else "Unknown",
@@ -361,17 +298,13 @@ class LoginService:
now = datetime.now()
permissions, permissions_with_menu, menu_ids, data_scopes, custom_dept_ids, role_ids = LoginService._collect_permissions(user)
permissions, menu_ids = LoginService._collect_permissions(user)
session_dict = LoginService._build_session_dict(
user=user,
session_id=session_id,
permissions=permissions,
permissions_with_menu=permissions_with_menu,
menu_ids=menu_ids,
data_scopes=data_scopes,
custom_dept_ids=custom_dept_ids,
role_ids=role_ids,
request_ip=request_ip,
login_location=login_location,
ua_result=ua_result,
@@ -442,7 +375,7 @@ class LoginService:
if not session_id or not user_id:
raise CustomException(msg="非法凭证,无法获取会话编号或用户ID")
auth = AuthSchema(check_data_scope=False)
auth = AuthSchema()
user = await UserCRUD(auth, db).get(id=user_id)
if not user:
raise CustomException(msg="刷新token失败,用户不存在")
@@ -511,210 +444,6 @@ class LoginService:
return True
async def get_user_tenants(
self,
user_id: int | None = None,
) -> list[TenantOptionSchema]:
"""获取用户关联的租户列表"""
from sqlalchemy import select
user = self.auth.user
if not user:
raise CustomException(msg="未认证用户")
uid = user_id or user.id
if not uid:
return []
if user.is_superuser:
stmt = select(TenantModel).where(TenantModel.status == 0, TenantModel.is_deleted.is_(False)).order_by(TenantModel.sort, TenantModel.id)
result = await self.db.execute(stmt)
tenant_objs = result.scalars().all()
return [TenantOptionSchema(id=t.id, name=t.name, code=t.code) for t in tenant_objs]
stmt = (
select(TenantModel)
.join(TenantUserModel, TenantUserModel.tenant_id == TenantModel.id)
.where(
TenantUserModel.user_id == uid,
TenantModel.status == 0,
TenantModel.is_deleted.is_(False),
)
.order_by(TenantUserModel.is_default.desc(), TenantModel.sort, TenantModel.id)
)
result = await self.db.execute(stmt)
tenant_objs = result.scalars().all()
return [TenantOptionSchema(id=t.id, name=t.name, code=t.code) for t in tenant_objs]
async def select_tenant(
self,
request: Request,
redis: Redis,
tenant_id: int,
) -> SelectTenantOutSchema:
"""选择租户:验证用户归属并签发含租户上下文的新 JWT Token"""
user = self.auth.user
if not user:
raise CustomException(msg="未认证用户")
if not user.is_superuser:
exist_stmt = (
select(TenantUserModel)
.where(
TenantUserModel.user_id == user.id,
TenantUserModel.tenant_id == tenant_id,
)
.limit(1)
)
result = await self.db.execute(exist_stmt)
if not result.scalar_one_or_none():
raise CustomException(msg="您不属于该租户,无法切换")
tenant_stmt = select(TenantModel).where(TenantModel.id == tenant_id, TenantModel.status == 0).limit(1)
result = await self.db.execute(tenant_stmt)
tenant = result.scalar_one_or_none()
if not tenant:
raise CustomException(msg="租户不存在或已被禁用")
new_access_token, _new_refresh_token, access_expires = await self._rebuild_tokens(
request, redis, {"tenant_id": tenant_id}
)
set_current_tenant(tenant_id)
logger.info(f"用户 {user.username}(id={user.id}) 切换到租户 {tenant.name}(id={tenant_id})")
return SelectTenantOutSchema(
access_token=new_access_token,
token_type=settings.TOKEN_TYPE,
expires_in=int(access_expires.total_seconds()),
)
async def _rebuild_tokens(
self,
request: Request,
redis: Redis,
session_updates: dict,
) -> tuple[str, str, timedelta]:
"""从请求上下文重建全套令牌(access + refresh + session
提取会话信息,应用更新后写入 Redis,签发新 JWT。
参数:
- request (Request): FastAPI 请求对象
- redis (Redis): Redis 客户端
- session_updates (dict): 需更新到 session_info 的键值对
返回:
- tuple[str, str, timedelta]: (access_token, refresh_token, access_expires)
"""
ctx = getattr(request.state, "ctx", None)
session_id = ctx.session_id if ctx else None
session_info = ctx.session_info if ctx else None
if not session_id or not session_info:
raise CustomException(msg="会话已失效")
session_info.update(session_updates)
refresh_expires = timedelta(seconds=settings.REFRESH_TOKEN_EXPIRE_SECONDS)
await RedisCURD(redis).set(
key=f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}",
value=json.dumps(session_info) if isinstance(session_info, dict) else session_info,
expire=int(refresh_expires.total_seconds()),
)
access_expires = timedelta(seconds=settings.ACCESS_TOKEN_EXPIRE_SECONDS)
now = datetime.now()
new_access_token = create_access_token(
payload=JWTPayloadSchema(
sub=session_id,
is_refresh=False,
exp=now + access_expires,
),
)
await RedisCURD(redis).set(
key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}",
value=new_access_token,
expire=int(access_expires.total_seconds()),
)
new_refresh_token = create_access_token(
payload=JWTPayloadSchema(
sub=session_id,
is_refresh=True,
exp=now + refresh_expires,
),
)
await RedisCURD(redis).set(
key=f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}",
value=new_refresh_token,
expire=int(refresh_expires.total_seconds()),
)
return new_access_token, new_refresh_token, access_expires
async def enter_platform(
self,
request: Request,
redis: Redis,
) -> EnterPlatformOutSchema:
"""进入平台管理模式:清除会话中的 tenant_id,返回平台作用域 JWT"""
user = self.auth.user
if not user:
raise CustomException(msg="未认证用户")
new_access_token, _new_refresh_token, access_expires = await self._rebuild_tokens(
request, redis, {"tenant_id": 0}
)
clear_current_tenant()
logger.info(f"用户 {user.username}(id={user.id}) 返回平台管理模式")
return EnterPlatformOutSchema(
access_token=new_access_token,
token_type=settings.TOKEN_TYPE,
expires_in=int(access_expires.total_seconds()),
)
async def impersonate(
self,
request: Request,
redis: Redis,
tenant_id: int,
) -> ImpersonateOutSchema:
"""平台管理员代签入:以指定租户身份登录(仅超级管理员可用)"""
user = self.auth.user
if not user or not user.is_superuser:
raise CustomException(msg="仅平台管理员可执行代签入")
tenant_stmt = select(TenantModel).where(TenantModel.id == tenant_id, TenantModel.is_deleted.is_(False)).limit(1)
result = await self.db.execute(tenant_stmt)
tenant = result.scalar_one_or_none()
if not tenant:
raise CustomException(msg="租户不存在")
new_access_token, new_refresh_token, access_expires = await self._rebuild_tokens(
request, redis, {"tenant_id": tenant_id, "is_impersonate": True}
)
set_current_tenant(tenant_id)
logger.warning(f"平台管理员 {user.username}(id={user.id}) 代签入租户 {tenant.name}(id={tenant_id})")
return ImpersonateOutSchema(
access_token=new_access_token,
refresh_token=new_refresh_token,
token_type=settings.TOKEN_TYPE,
expires_in=int(access_expires.total_seconds()),
tenant_id=tenant_id,
tenant_name=tenant.name,
)
class CaptchaService:
"""验证码服务 — 滑块拖动模式"""
@@ -781,190 +510,3 @@ class CaptchaService:
await RedisCURD(redis).delete(redis_key)
return True
class TenantRegisterService:
"""PRD §4.5 租户自助注册:一次性创建租户 + 管理员 + owner 角色 + 菜单分配"""
DEFAULT_TRIAL_DAYS: int = settings.TENANT_TRIAL_DAYS
@classmethod
async def register(
cls,
db: AsyncSession,
username: str,
password: str,
email: str,
tenant_name: str | None = None,
) -> TenantRegisterOutSchema:
"""租户自助注册:一次性创建租户 + 管理员 + owner 角色 + 菜单分配"""
from sqlalchemy.exc import IntegrityError
exists_stmt = (
select(func.count())
.select_from(UserModel)
.where(
UserModel.is_deleted.is_(False),
(UserModel.username == username) | (UserModel.email == email),
)
)
cnt = (await db.execute(exists_stmt)).scalar() or 0
if cnt > 0:
raise CustomException(msg="用户名或邮箱已被占用")
pkg_stmt = select(PackageModel).where(PackageModel.status == 0).order_by(PackageModel.id).limit(1)
default_pkg = (await db.execute(pkg_stmt)).scalar_one_or_none()
now = datetime.now()
trial_end = now + timedelta(days=cls.DEFAULT_TRIAL_DAYS)
base = tenant_name or username
code_suffix = base.encode("utf-8").hex()[:6].upper()
tenant_code = f"T{code_suffix}"
tenant = TenantModel(
name=tenant_name or f"{username}的租户",
code=tenant_code,
contact_name=username,
package_id=default_pkg.id if default_pkg else None,
start_time=now,
end_time=trial_end,
status=0,
)
db.add(tenant)
await db.flush()
user = UserModel(
name=username,
username=username,
password=PwdUtil.hash_password(password),
email=email,
tenant_id=tenant.id,
status=0,
)
db.add(user)
await db.flush()
tenant_user = TenantUserModel(
user_id=user.id,
tenant_id=tenant.id,
role="owner",
is_default=1,
)
db.add(tenant_user)
await db.flush()
owner_role = RoleModel(
name="租户管理员",
code="owner",
tenant_id=tenant.id,
order=1,
data_scope=4,
description="自助注册创建的管理员角色",
)
db.add(owner_role)
await db.flush()
user_role = UserRolesModel(user_id=user.id, role_id=owner_role.id)
db.add(user_role)
if default_pkg:
pkg_menu_stmt = select(PackageMenuModel).where(
PackageMenuModel.package_id == default_pkg.id,
)
pkg_menus = (await db.execute(pkg_menu_stmt)).scalars().all()
for pm in pkg_menus:
db.add(RoleMenusModel(role_id=owner_role.id, menu_id=pm.menu_id))
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise CustomException(msg="租户编码或用户名已被占用,请重试")
return TenantRegisterOutSchema(
user_id=user.id,
username=username,
tenant_id=tenant.id,
tenant_name=tenant.name,
tenant_code=tenant_code,
package=default_pkg.name if default_pkg else None,
trial_end=trial_end.strftime("%Y-%m-%d"),
message="注册成功",
)
class TenantLookupService:
"""租户查询服务(登录页根据编码查找租户)"""
@staticmethod
async def lookup_by_code(db: AsyncSession, code: str) -> dict:
stmt = select(TenantModel).where(
TenantModel.code == code,
TenantModel.is_deleted.is_(False),
)
result = (await db.execute(stmt)).scalar_one_or_none()
if not result:
raise CustomException(msg="未找到该租户")
return {
"id": result.id,
"name": result.name,
"code": result.code,
"logo_url": result.logo_url,
"login_bg": result.login_bg,
"version": result.version,
}
@staticmethod
async def lookup_by_domain(db: AsyncSession, domain: str) -> dict:
stmt = select(TenantModel).where(
TenantModel.domain == domain,
TenantModel.is_deleted.is_(False),
)
result = (await db.execute(stmt)).scalar_one_or_none()
if not result:
raise CustomException(msg="未找到该域名对应的租户")
return {
"id": result.id,
"name": result.name,
"code": result.code,
"logo_url": result.logo_url,
"login_bg": result.login_bg,
"version": result.version,
}
@staticmethod
async def list_options(db: AsyncSession) -> list[dict]:
"""获取所有活跃租户选项(登录页下拉选择)"""
stmt = (
select(TenantModel)
.where(TenantModel.is_deleted.is_(False), TenantModel.status == 0)
.order_by(TenantModel.id)
)
results = (await db.execute(stmt)).scalars().all()
return [
{"id": r.id, "name": r.name, "code": r.code}
for r in results
]
@staticmethod
async def search(db: AsyncSession, q: str) -> list[dict]:
"""模糊搜索租户(按编码或名称)"""
pattern = f"%{q}%"
stmt = (
select(TenantModel)
.where(
TenantModel.is_deleted.is_(False),
TenantModel.status == 0,
(TenantModel.code.ilike(pattern) | TenantModel.name.ilike(pattern)),
)
.order_by(TenantModel.id)
.limit(20)
)
results = (await db.execute(stmt)).scalars().all()
return [
{"id": r.id, "name": r.name, "code": r.code}
for r in results
]
+5 -13
View File
@@ -1,31 +1,23 @@
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.common.enums import PermissionFilterStrategy
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
from app.core.base_model import ModelMixin, UserMixin
if TYPE_CHECKING:
from app.api.v1.module_system.role.model import RoleModel
from app.api.v1.module_system.user.model import UserModel
class DeptModel(ModelMixin, TenantMixin, UserMixin):
class DeptModel(ModelMixin, UserMixin):
"""部门模型
"""
__tablename__: str = "sys_dept"
__table_args__ = (UniqueConstraint("tenant_id", "code"), {"comment": "部门表"})
__table_args__: dict[str, str] = {"comment": "部门表"}
__tree_children_attr__: str = "children"
__loader_options__: list[str] = [
"children",
"created_by",
"updated_by",
"deleted_by",
"tenant_by",
]
__permission_strategy__: PermissionFilterStrategy = PermissionFilterStrategy.DEPT_RELATION
__loader_options__: list[str] = ["children", "created_by", "updated_by", "deleted_by"]
name: Mapped[str] = mapped_column(String(64), nullable=False, comment="部门名称")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
@@ -1,6 +1,6 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam, TenantBySchema, UserByQueryParam, UserBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
from app.core.validator import validate_required_code
@@ -44,7 +44,7 @@ class DeptUpdateSchema(DeptCreateSchema):
"""部门更新模型"""
class DeptOutSchema(DeptCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
class DeptOutSchema(DeptCreateSchema, BaseSchema, UserBySchema):
"""部门详情响应模型(不含 children,用于详情和更新)"""
model_config = ConfigDict(from_attributes=True)
@@ -58,7 +58,7 @@ class DeptTreeOutSchema(DeptOutSchema):
children: list["DeptTreeOutSchema"] | None = Field(default=None, description="子部门列表")
class DeptQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class DeptQueryParam(BaseQueryParam, UserByQueryParam):
"""部门管理查询参数"""
name: str | None = Field(None, description="部门名称")
@@ -1,6 +1,5 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_platform.tenant.service import TenantService
from app.core.base_schema import AuthSchema, BatchSetAvailable
from app.core.exceptions import CustomException
from app.utils.common_util import (
@@ -24,7 +23,7 @@ from .schema import (
class DeptService:
"""部门管理服务
提供部门 CRUD、树形结构查询、级联启/禁用、租户配额检查等业务能力。
提供部门 CRUD、树形结构查询、级联启/禁用等业务能力。
"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
@@ -57,12 +56,6 @@ class DeptService:
if obj:
raise CustomException(msg="创建失败,编码已存在")
# 检查租户配额
user = self.auth.user
if not user:
raise CustomException(msg="未登录")
await TenantService(self.auth, self.db).check_quota(user.tenant_id, "dept")
dept = await DeptCRUD(self.auth, self.db).create(data=data)
return DeptOutSchema.model_validate(dept)
@@ -228,6 +228,6 @@ async def get_init_dict_data_controller(
redis: Annotated[Redis, Depends(redis_getter)],
dict_type: Annotated[str, Path(description="字典类型")],
) -> JSONResponse:
dict_data_query_result = await DictDataService.get_init_cache(redis=redis, dict_type=dict_type, tenant_id=1)
dict_data_query_result = await DictDataService.get_init_cache(redis=redis, dict_type=dict_type)
return SuccessResponse(data=dict_data_query_result, msg="获取初始化字典数据成功")
+9 -22
View File
@@ -1,20 +1,15 @@
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
from app.core.base_model import ModelMixin, UserMixin
class DictTypeModel(ModelMixin, TenantMixin, UserMixin):
"""字典类型表
__platform_data_shared__ = True 表示 tenant_id=1 的平台字典对
所有租户可读但只有平台管理员可写
"""
class DictTypeModel(ModelMixin, UserMixin):
"""字典类型表"""
__tablename__: str = "sys_dict_type"
__table_args__ = (UniqueConstraint("tenant_id", "dict_type"), {"comment": "字典类型表"})
__loader_options__: list[str] = ["dict_data_list", "created_by", "updated_by", "deleted_by", "tenant_by"]
__platform_data_shared__: bool = True
__table_args__: dict[str, str] = {"comment": "字典类型表"}
__loader_options__: list[str] = ["dict_data_list", "created_by", "updated_by", "deleted_by"]
dict_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="字典名称")
dict_type: Mapped[str] = mapped_column(String(255), nullable=False, index=True, comment="字典类型")
@@ -27,20 +22,12 @@ class DictTypeModel(ModelMixin, TenantMixin, UserMixin):
)
class DictDataModel(ModelMixin, TenantMixin, UserMixin):
"""字典数据表
DictTypeModel 相同tenant_id=1 的平台字典数据对
所有租户可读但只有平台管理员可写
"""
class DictDataModel(ModelMixin, UserMixin):
"""字典数据表"""
__tablename__: str = "sys_dict_data"
__table_args__ = (
UniqueConstraint("tenant_id", "dict_type_id", "dict_value", name="uq_dict_data_value"),
{"comment": "字典数据表"},
)
__loader_options__: list[str] = ["dict_type_obj", "created_by", "updated_by", "deleted_by", "tenant_by"]
__platform_data_shared__: bool = True
__table_args__: dict[str, str] = {"comment": "字典数据表"}
__loader_options__: list[str] = ["dict_type_obj", "created_by", "updated_by", "deleted_by"]
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
@@ -8,7 +8,7 @@ from pydantic import (
model_validator,
)
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam, TenantBySchema, UserByQueryParam, UserBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
class DictTypeCreateSchema(BaseModel):
@@ -71,13 +71,13 @@ class DictTypeUpdateSchema(DictTypeCreateSchema):
"""字典类型更新模型"""
class DictTypeOutSchema(DictTypeCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
class DictTypeOutSchema(DictTypeCreateSchema, BaseSchema, UserBySchema):
"""字典类型响应模型"""
model_config = ConfigDict(from_attributes=True)
class DictTypeQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class DictTypeQueryParam(BaseQueryParam, UserByQueryParam):
"""字典类型查询参数"""
dict_name: str | None = Field(default=None, description="字典名称", max_length=100)
@@ -138,13 +138,13 @@ class DictDataUpdateSchema(DictDataCreateSchema):
"""字典数据更新模型"""
class DictDataOutSchema(DictDataCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
class DictDataOutSchema(DictDataCreateSchema, BaseSchema, UserBySchema):
"""字典数据响应模型"""
model_config = ConfigDict(from_attributes=True)
class DictDataQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class DictDataQueryParam(BaseQueryParam, UserByQueryParam):
"""字典数据查询参数"""
dict_label: str | None = Field(default=None, description="字典标签", max_length=100)
@@ -113,7 +113,7 @@ class DictTypeService:
new_obj_dict = DictTypeOutSchema.model_validate(obj)
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{self.auth.user.tenant_id}:{data.dict_type}"
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:1:{data.dict_type}"
try:
await RedisCURD(redis).set(
@@ -166,7 +166,7 @@ class DictTypeService:
new_obj_dict = DictTypeOutSchema.model_validate(obj)
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{self.auth.user.tenant_id}:{data.dict_type}"
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:1:{data.dict_type}"
try:
# 获取当前字典类型的所有字典数据,确保包含最新状态
dict_data_list = await DictDataCRUD(self.auth, self.db).get_list(search={"dict_type": data.dict_type})
@@ -214,7 +214,7 @@ class DictTypeService:
# 验证通过后统一删除 Redis 缓存
existing_dict_types = {obj.dict_type for obj in existing if obj.id in ids}
for dt in existing_dict_types:
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{self.auth.user.tenant_id}:{dt}"
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:1:{dt}"
try:
await RedisCURD(redis).delete(redis_key)
logger.info(f"删除字典类型缓存: {dt}")
@@ -343,7 +343,7 @@ class DictDataService:
"""
try:
async with async_db_session() as session, session.begin():
init_auth = AuthSchema(check_data_scope=False)
init_auth = AuthSchema()
obj_list = await DictTypeCRUD(init_auth, session).get_list()
if not obj_list:
logger.warning("未找到任何字典类型数据")
@@ -351,11 +351,10 @@ class DictDataService:
for obj in obj_list:
dict_type = obj.dict_type
tenant_id = obj.tenant_id
try:
dict_data_list = await DictDataCRUD(init_auth, session).get_list(search={"dict_type": dict_type, "tenant_id": tenant_id})
dict_data_list = await DictDataCRUD(init_auth, session).get_list(search={"dict_type": dict_type})
dict_data = [DictDataOutSchema.model_validate(row).model_dump(mode="json") 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}:1:{dict_type}"
value = json.dumps(dict_data, ensure_ascii=False)
await RedisCURD(redis).set(
key=redis_key,
@@ -370,13 +369,12 @@ class DictDataService:
raise CustomException(msg="字典数据初始化失败") from e
@staticmethod
async def get_init_cache(redis: Redis, dict_type: str, tenant_id: int = 1) -> list[dict]:
async def get_init_cache(redis: Redis, dict_type: str) -> list[dict]:
"""从缓存获取字典数据列表信息(无 auth)。
参数:
- redis (Redis): Redis客户端
- dict_type (str): 字典类型
- tenant_id (int): 租户ID
返回:
- list[dict]: 字典数据列表
@@ -394,7 +392,7 @@ class DictDataService:
return None
try:
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{tenant_id}:{dict_type}"
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:1:{dict_type}"
obj_list_dict = await RedisCURD(redis).get(redis_key)
result = _parse(obj_list_dict)
@@ -425,7 +423,7 @@ class DictDataService:
- redis (Redis): Redis 客户端
- dict_type (str): 字典类型
"""
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{self.auth.user.tenant_id}:{dict_type}"
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:1:{dict_type}"
dict_data_list = await DictDataCRUD(self.auth, self.db).get_list(search={"dict_type": dict_type})
dict_data = [DictDataOutSchema.model_validate(row).model_dump(mode="json") for row in dict_data_list if row]
value = json.dumps(dict_data, ensure_ascii=False)
@@ -2,7 +2,7 @@ from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.config.setting import settings
from app.core.base_model import ModelMixin, TenantMixin
from app.core.base_model import ModelMixin
def get_log_text_column_type():
@@ -20,14 +20,13 @@ def get_log_text_column_type():
return Text
class LoginLogModel(ModelMixin, TenantMixin):
class LoginLogModel(ModelMixin):
"""登录日志模型
"""
__tablename__: str = "sys_login_log"
__table_args__: dict[str, str] = {"comment": "登录日志表"}
__loader_options__: list[str] = ["tenant_by"]
status: Mapped[int] = mapped_column(Integer, default=1, comment="登录状态(1成功 2失败)", index=True)
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
username: Mapped[str] = mapped_column(String(64), nullable=False, comment="用户名")
@@ -38,14 +37,14 @@ class LoginLogModel(ModelMixin, TenantMixin):
msg: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="提示消息")
class OperationLogModel(ModelMixin, TenantMixin):
class OperationLogModel(ModelMixin):
"""操作日志模型
"""
__tablename__: str = "sys_operation_log"
__table_args__: dict[str, str] = {"comment": "操作日志表"}
__loader_options__: list[str] = ["tenant_by"]
username: Mapped[str] = mapped_column(String(64), nullable=False, comment="操作人用户名")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="操作状态(0:成功 1:失败)", index=True)
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
request_path: Mapped[str] = mapped_column(String(255), comment="请求路径")
@@ -1,7 +1,7 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.common.enums import QueueEnum
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam, TenantBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema
ALLOWED_REQUEST_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
@@ -35,7 +35,7 @@ class LoginLogCreateSchema(BaseModel):
return v
class LoginLogOutSchema(LoginLogCreateSchema, BaseSchema, TenantBySchema):
class LoginLogOutSchema(LoginLogCreateSchema, BaseSchema):
"""登录日志响应"""
model_config = ConfigDict(from_attributes=True)
@@ -45,7 +45,7 @@ class LoginLogDetailOutSchema(LoginLogOutSchema):
"""登录日志详情响应"""
class LoginLogQueryParam(BaseQueryParam, TenantByQueryParam):
class LoginLogQueryParam(BaseQueryParam):
"""登录日志查询参数"""
username: str | tuple[str, str] | None = Field(None, max_length=64, description="用户名")
@@ -60,7 +60,7 @@ class LoginLogQueryParam(BaseQueryParam, TenantByQueryParam):
return self
class OperationLogQueryParam(BaseQueryParam, TenantByQueryParam):
class OperationLogQueryParam(BaseQueryParam):
"""操作日志查询参数"""
request_path: str | None = Field(None, description="请求路径")
@@ -70,11 +70,12 @@ class OperationLogQueryParam(BaseQueryParam, TenantByQueryParam):
request_ip: str | None = Field(None, description="请求IP")
class OperationLogOutSchema(BaseSchema, TenantBySchema):
class OperationLogOutSchema(BaseSchema):
"""操作日志响应模型"""
model_config = ConfigDict(from_attributes=True)
username: str = Field(..., description="操作人用户名")
status: int | None = Field(default=None, description="状态(0:启动 1:停用)")
description: str | None = Field(default=None, description="描述")
request_path: str = Field(..., description="请求路径")
@@ -92,6 +93,7 @@ class OperationLogDetailOutSchema(OperationLogOutSchema):
class OperationLogCreateSchema(BaseModel):
username: str = Field(..., min_length=1, max_length=64, description="操作人用户名")
request_path: str = Field(..., min_length=1, max_length=255, description="请求路径")
request_method: str = Field(..., description="请求方式")
request_payload: str | None = Field(None, description="请求体")
@@ -0,0 +1,86 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
from fastapi.responses import JSONResponse
from fastapi_cache import FastAPICache
from fastapi_cache.decorator import cache
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.response import ResponseSchema, SuccessResponse
from app.core.base_schema import AuthSchema, BatchSetAvailable
from app.core.dependencies import AuthPermission, db_getter
from app.core.router_class import OperationLogRoute
from .schema import MenuCreateSchema, MenuOutSchema, MenuQueryParam, MenuUpdateSchema
from .service import MenuService
MenuRouter = APIRouter(route_class=OperationLogRoute, prefix="/menu", tags=["菜单管理"])
_MENU_NS = "menu"
@MenuRouter.get("/tree", summary="查询菜单树", response_model=ResponseSchema[list[MenuOutSchema]])
@cache(expire=300, namespace=_MENU_NS)
async def get_menu_tree_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:menu:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
search: Annotated[MenuQueryParam, Query()],
) -> JSONResponse:
order_by = [{"order": "asc"}]
result_dict_tree = await MenuService(auth, db).tree(search=search, order_by=order_by)
return SuccessResponse(data=result_dict_tree, msg="查询菜单树成功")
@MenuRouter.get("/detail/{id}", summary="查询菜单详情", response_model=ResponseSchema[MenuOutSchema])
async def get_obj_detail_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:menu:detail"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="菜单ID", ge=1)],
) -> JSONResponse:
result_dict = await MenuService(auth, db).detail(id=id)
return SuccessResponse(data=result_dict, msg="查询菜单详情成功")
@MenuRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建菜单", response_model=ResponseSchema[MenuOutSchema])
async def create_obj_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:menu:create"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[MenuCreateSchema, Body(description="菜单创建参数")],
) -> JSONResponse:
result_dict = await MenuService(auth, db).create(data=data)
await FastAPICache.clear(namespace=_MENU_NS)
return SuccessResponse(data=result_dict, msg="创建菜单成功")
@MenuRouter.put("/update/{id}", summary="修改菜单", response_model=ResponseSchema[MenuOutSchema])
async def update_obj_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:menu:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="菜单ID", ge=1)],
data: Annotated[MenuUpdateSchema, Body(description="菜单修改参数")],
) -> JSONResponse:
result_dict = await MenuService(auth, db).update(id=id, data=data)
await FastAPICache.clear(namespace=_MENU_NS)
return SuccessResponse(data=result_dict, msg="修改菜单成功")
@MenuRouter.delete("/delete", summary="删除菜单", response_model=ResponseSchema[None])
async def delete_obj_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:menu:delete"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
ids: Annotated[list[int], Body(description="菜单ID列表")],
) -> JSONResponse:
await MenuService(auth, db).delete(ids=ids)
await FastAPICache.clear(namespace=_MENU_NS)
return SuccessResponse(msg="删除菜单成功")
@MenuRouter.patch("/status/batch", summary="批量修改菜单状态", response_model=ResponseSchema[None])
async def batch_set_available_obj_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:menu:patch"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
) -> JSONResponse:
await MenuService(auth, db).set_available(data=data)
await FastAPICache.clear(namespace=_MENU_NS)
return SuccessResponse(msg="批量修改菜单状态成功")
@@ -0,0 +1,14 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_crud import CRUDBase
from app.core.base_schema import AuthSchema
from .model import MenuModel
from .schema import MenuCreateSchema, MenuUpdateSchema
class MenuCRUD(CRUDBase[MenuModel, MenuCreateSchema, MenuUpdateSchema]):
"""菜单模块数据层"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
super().__init__(model=MenuModel, auth=auth, db=db)
@@ -0,0 +1,54 @@
from typing import TYPE_CHECKING
from sqlalchemy import JSON, Boolean, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.base_model import ModelMixin
if TYPE_CHECKING:
from app.api.v1.module_system.role.model import RoleModel
class MenuModel(ModelMixin):
"""菜单表 - 用于存储系统菜单资源定义
菜单类型说明:
- 1: 目录(一级菜单)
- 2: 菜单(二级菜单)
- 3: 按钮/权限(页面内按钮权限)
- 4: 外部链接
"""
__tablename__: str = "platform_menu"
__table_args__: dict[str, str] = {"comment": "平台菜单表"}
__tree_children_attr__: str = "children"
__loader_options__: list[str] = ["roles", "children"]
name: Mapped[str] = mapped_column(String(50), nullable=False, comment="菜单名称")
type: Mapped[int] = mapped_column(Integer, nullable=False, default=2, comment="菜单类型(1:目录 2:菜单 3:按钮 4:链接)")
order: Mapped[int] = mapped_column(Integer, nullable=False, default=999, comment="显示排序")
permission: Mapped[str | None] = mapped_column(String(100), comment="权限标识(如:module_system:user:query)")
icon: Mapped[str | None] = mapped_column(String(50), comment="菜单图标")
route_name: Mapped[str | None] = mapped_column(String(100), comment="路由名称")
route_path: Mapped[str | None] = mapped_column(String(200), comment="路由路径")
component_path: Mapped[str | None] = mapped_column(String(200), comment="组件路径")
redirect: Mapped[str | None] = mapped_column(String(200), comment="重定向地址")
hidden: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, comment="是否隐藏(True:隐藏 False:显示)")
keep_alive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, comment="是否缓存(True:是 False:否)")
always_show: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, comment="是否始终显示(True:是 False:否)")
title: Mapped[str | None] = mapped_column(String(50), comment="菜单标题")
params: Mapped[list[dict[str, str]] | None] = mapped_column(JSON, comment="路由参数(JSON对象)")
affix: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, comment="是否固定标签页(True:是 False:否)")
link: Mapped[str | None] = mapped_column(String(500), comment="外链地址(仅type=4)")
is_iframe: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, comment="是否嵌入iframe(True:是 False:否)")
is_hide_tab: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, comment="是否隐藏标签页(True:是 False:否)")
active_path: Mapped[str | None] = mapped_column(String(200), comment="激活菜单路径(用于高亮父级)")
show_badge: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, comment="是否显示红点角标(True:是 False:否)")
show_text_badge: Mapped[str | None] = mapped_column(String(20), comment="文字角标内容")
scope: Mapped[str] = mapped_column(String(20), nullable=False, default="web", server_default="web", comment="菜单可见范围(web:管理端 desktop app:移动端)")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("platform_menu.id", ondelete="SET NULL"), default=None, index=True, comment="父菜单ID")
parent: Mapped["MenuModel | None"] = relationship(back_populates="children", remote_side="MenuModel.id", foreign_keys="MenuModel.parent_id", uselist=False)
children: Mapped[list["MenuModel"] | None] = relationship(back_populates="parent", foreign_keys="MenuModel.parent_id", order_by="MenuModel.order", lazy="selectin")
roles: Mapped[list["RoleModel"]] = relationship(secondary="sys_role_menus", back_populates="menus", lazy="selectin")
@@ -0,0 +1,206 @@
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.core.base_schema import BaseQueryParam, BaseSchema
from app.core.validator import menu_request_validator
class MenuCreateSchema(BaseModel):
"""菜单创建模型"""
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=0, description="显示顺序")
permission: 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=200, description="组件路径")
redirect: str | None = Field(default=None, max_length=200, description="重定向地址")
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="是否固定标签页")
parent_id: int | None = Field(default=None, ge=1, description="父菜单ID")
status: int = Field(default=0, ge=0, le=1, description="状态(0:启动 1:停用)")
description: str | None = Field(default=None, max_length=255, description="描述")
link: str | None = Field(default=None, max_length=500, description="外链地址(仅type=4)")
is_iframe: bool = Field(default=False, description="是否嵌入iframe")
is_hide_tab: bool = Field(default=False, description="是否隐藏标签页")
active_path: str | None = Field(default=None, max_length=200, description="激活菜单路径")
show_badge: bool = Field(default=False, description="是否显示红点角标")
show_text_badge: str | None = Field(default=None, max_length=20, description="文字角标内容")
scope: Literal["web", "app"] = Field(
default="web",
description="菜单可见范围(web:管理端 desktop app:移动端)",
)
@field_validator("status")
@classmethod
def _validate_status(cls, v: int) -> int:
if v not in {0, 1}:
raise ValueError("状态仅支持 0(正常) 或 1(禁用)")
return v
@model_validator(mode="before")
@classmethod
def _normalize(cls, values):
if isinstance(values, dict):
for k in [
"name",
"icon",
"permission",
"route_name",
"route_path",
"component_path",
"redirect",
"title",
"description",
"link",
"active_path",
"show_text_badge",
]:
if k in values and isinstance(values[k], str):
stripped = values[k].strip()
values[k] = stripped or None
if "parent_id" in values and isinstance(values["parent_id"], str):
try:
values["parent_id"] = int(values["parent_id"].strip())
except (ValueError, TypeError):
pass
if "component_path" in values and isinstance(values["component_path"], str):
cp = values["component_path"]
if cp and cp.startswith("/"):
raise ValueError("组件路径不能以 / 开头")
return values
@model_validator(mode="after")
def validate_fields(self):
"""统一校验菜单请求字段(委托到 `menu_request_validator`)。
返回:
- MenuCreateSchema: 校验后的同一实例
异常:
- CustomException: 字段不满足菜单类型约束时抛出
"""
return menu_request_validator(self)
class MenuUpdateSchema(BaseModel):
"""菜单更新模型 — 所有字段可选"""
name: str | None = Field(default=None, min_length=1, max_length=50, description="菜单名称")
type: int | None = Field(default=None, ge=1, le=4, description="菜单类型(1:目录 2:菜单 3:按钮 4:外链)")
order: int | None = Field(default=None, ge=0, description="显示顺序")
permission: 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=200, description="组件路径")
redirect: str | None = Field(default=None, max_length=200, description="重定向地址")
hidden: bool | None = Field(default=None, description="是否隐藏")
keep_alive: bool | None = Field(default=None, description="是否缓存")
always_show: bool | None = Field(default=None, description="是否始终显示")
title: str | None = Field(default=None, max_length=50, description="菜单标题")
params: list[dict[str, str]] | None = Field(default=None, description="路由参数")
affix: bool | None = Field(default=None, description="是否固定标签页")
parent_id: int | None = Field(default=None, ge=1, description="父菜单ID")
status: int | None = Field(default=None, ge=0, le=1, description="状态(0:启动 1:停用)")
description: str | None = Field(default=None, max_length=255, description="描述")
link: str | None = Field(default=None, max_length=500, description="外链地址(仅type=4)")
is_iframe: bool | None = Field(default=None, description="是否嵌入iframe")
is_hide_tab: bool | None = Field(default=None, description="是否隐藏标签页")
active_path: str | None = Field(default=None, max_length=200, description="激活菜单路径")
show_badge: bool | None = Field(default=None, description="是否显示红点角标")
show_text_badge: str | None = Field(default=None, max_length=20, description="文字角标内容")
scope: Literal["platform"] | None = Field(
default=None,
description="菜单可见范围",
)
parent_name: str | None = Field(default=None, max_length=50, description="父菜单名称")
@field_validator("status")
@classmethod
def _validate_status(cls, v: int | None) -> int | None:
if v is None:
return v
if v not in {0, 1}:
raise ValueError("状态仅支持 0(正常) 或 1(禁用)")
return v
@model_validator(mode="before")
@classmethod
def _normalize(cls, values):
if isinstance(values, dict):
for k in [
"name",
"icon",
"permission",
"route_name",
"route_path",
"component_path",
"redirect",
"title",
"description",
"link",
"active_path",
"show_text_badge",
]:
if k in values and isinstance(values[k], str):
stripped = values[k].strip()
values[k] = stripped or None
if "parent_id" in values and isinstance(values["parent_id"], str):
try:
values["parent_id"] = int(values["parent_id"].strip())
except (ValueError, TypeError):
pass
if "component_path" in values and isinstance(values["component_path"], str) and values["component_path"]:
if values["component_path"].startswith("/"):
raise ValueError("组件路径不能以 / 开头")
return values
@model_validator(mode="after")
def validate_fields(self):
if self.type is None:
return self
return menu_request_validator(self)
class MenuOutSchema(MenuCreateSchema, BaseSchema):
"""菜单详情响应模型(不含 children,用于详情和更新)"""
model_config = ConfigDict(from_attributes=True)
parent_name: str | None = Field(default=None, max_length=50, description="父菜单名称")
class MenuTreeOutSchema(MenuOutSchema):
"""菜单树形响应模型(含 children,用于树形列表)"""
children: list["MenuTreeOutSchema"] | None = Field(default=None, description="子菜单列表")
class MenuQueryParam(BaseQueryParam):
"""菜单管理查询参数(菜单为平台级资源,无用户归属)"""
name: str | None = Field(None, description="菜单名称")
route_path: str | None = Field(None, description="路由地址")
component_path: str | None = Field(None, description="组件路径")
type: int | None = Field(None, description="菜单类型(1:目录 2:菜单 3:按钮 4:外链)")
permission: str | None = Field(None, description="权限标识")
description: str | None = Field(None, description="描述")
status: int | None = Field(None, description="是否启用")
scope: str | None = Field(
None,
description="菜单范围过滤(web:管理端 desktop app:移动端)",
json_schema_extra={"q": "eq"},
)
@@ -0,0 +1,164 @@
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.base_schema import AuthSchema, BatchSetAvailable
from app.core.exceptions import CustomException
from app.utils.common_util import (
get_child_id_map,
get_child_recursion,
get_parent_id_map,
get_parent_recursion,
search_to_dict,
traversal_to_tree,
)
from .crud import MenuCRUD
from .schema import (
MenuCreateSchema,
MenuOutSchema,
MenuQueryParam,
MenuTreeOutSchema,
MenuUpdateSchema,
)
class MenuService:
"""菜单管理服务(查询操作租户可见,写操作仅超级管理员可操作)"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
self.auth = auth
self.db = db
async def _validate_parent_child_type(self, parent_id: int | None, child_type: int | None) -> None:
if parent_id is None:
if child_type is None:
return
if child_type not in (1, 2, 4):
raise CustomException(msg="顶级菜单仅允许目录、菜单或外链类型")
return
parent = await MenuCRUD(self.auth, self.db).get(id=parent_id)
if not parent:
raise CustomException(msg="父级菜单不存在")
pt = parent.type
if pt == 1:
if child_type not in (1, 2, 4):
raise CustomException(msg="目录下仅允许新增目录、菜单或外链")
elif pt == 2:
if child_type != 3:
raise CustomException(msg="菜单下仅允许新增按钮")
else:
raise CustomException(msg="菜单或链接类型下不允许新增子菜单")
async def _validate_parent_child_scope(self, parent_id: int | None, scope: str | None) -> None:
if parent_id is None or scope is None:
return
parent = await MenuCRUD(self.auth, self.db).get(id=parent_id)
if not parent:
return
p_scope = getattr(parent, "scope", None) or "web"
if p_scope != scope:
raise CustomException(msg="子菜单可见范围须与父菜单一致")
async def detail(self, id: int) -> MenuOutSchema:
menu = await MenuCRUD(self.auth, self.db).get(id=id, preload=["roles"])
if not menu:
raise CustomException(msg="菜单不存在")
menu_out = MenuOutSchema.model_validate(menu)
if menu.parent_id:
parent = await MenuCRUD(self.auth, self.db).get(id=menu.parent_id)
if parent:
menu_out.parent_name = parent.name
return menu_out
async def tree(
self,
search: MenuQueryParam | None = None,
order_by: list[dict] | None = None,
) -> list[dict]:
# 递归预加载所有层级 children(避免 Pydantic 校验时异步懒加载失败)
from .model import MenuModel
_loader = selectinload(MenuModel.children)
for _ in range(10):
_loader = _loader.selectinload(MenuModel.children)
menu_list = await MenuCRUD(self.auth, self.db).tree_list(search=search_to_dict(search), order_by=order_by, preload=[_loader])
menu_dict_list = [MenuTreeOutSchema.model_validate(menu).model_dump() for menu in menu_list]
return traversal_to_tree(menu_dict_list)
async def create(self, data: MenuCreateSchema) -> MenuOutSchema:
search: dict[str, Any] = {}
if data.title is not None:
search["title"] = data.title
if data.parent_id is not None:
search["parent_id"] = data.parent_id
menu = await MenuCRUD(self.auth, self.db).get(**search)
if menu:
raise CustomException(msg="创建失败,该菜单已存在")
await self._validate_parent_child_type(data.parent_id, data.type)
await self._validate_parent_child_scope(data.parent_id, data.scope)
new_menu = await MenuCRUD(self.auth, self.db).create(data=data)
return MenuOutSchema.model_validate(new_menu)
async def update(self, id: int, data: MenuUpdateSchema) -> MenuOutSchema:
_ = await MenuCRUD(self.auth, self.db).get_or_404(id=id, msg="更新失败,该菜单不存在")
await self._validate_parent_child_type(data.parent_id, data.type)
await self._validate_parent_child_scope(data.parent_id, data.scope)
if data.title is not None:
search: dict[str, Any] = {"title": data.title}
if data.parent_id is not None:
search["parent_id"] = data.parent_id
exist_menu = await MenuCRUD(self.auth, self.db).get(**search)
if exist_menu and exist_menu.id != id:
raise CustomException(msg="更新失败,菜单标题重复")
if data.parent_id:
parent_menu = await MenuCRUD(self.auth, self.db).get(id=data.parent_id)
if not parent_menu:
raise CustomException(msg="更新失败,父级菜单不存在")
new_menu = await MenuCRUD(self.auth, self.db).update(id=id, data=data)
if data.status is not None:
await self.set_available(data=BatchSetAvailable(ids=[id], status=data.status))
menu_out = MenuOutSchema.model_validate(new_menu)
if menu_out.parent_id:
parent = await MenuCRUD(self.auth, self.db).get(id=menu_out.parent_id)
if parent:
menu_out.parent_name = parent.name
return menu_out
async def delete(self, ids: list[int]) -> None:
if not ids:
raise CustomException(msg="删除失败,删除对象不能为空")
all_menus = await MenuCRUD(self.auth, self.db).get_list()
child_id_map = get_child_id_map(model_list=all_menus)
delete_ids_set = set()
for mid in ids:
all_descendants = get_child_recursion(id=mid, id_map=child_id_map)
delete_ids_set.update(all_descendants)
delete_ids = list(delete_ids_set)
await MenuCRUD(self.auth, self.db).delete(ids=delete_ids)
async def set_available(self, data: BatchSetAvailable) -> None:
menu_list = await MenuCRUD(self.auth, self.db).get_list()
total_ids = []
if data.status == 0:
id_map = get_parent_id_map(model_list=menu_list)
for menu_id in data.ids:
enable_ids = get_parent_recursion(id=menu_id, id_map=id_map)
total_ids.extend(enable_ids)
else:
id_map = get_child_id_map(model_list=menu_list)
for menu_id in data.ids:
disable_ids = get_child_recursion(id=menu_id, id_map=id_map)
total_ids.extend(disable_ids)
await MenuCRUD(self.auth, self.db).set(ids=total_ids, status=data.status)
@@ -1,51 +1,18 @@
from datetime import datetime
from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.base_model import MappedBase, ModelMixin, TenantMixin, UserMixin
from app.core.base_model import ModelMixin, UserMixin
class NoticeModel(ModelMixin, TenantMixin, UserMixin):
"""通知公告表
__platform_data_shared__ = True 表示 tenant_id=1 的平台公告对
所有租户可读但只有平台管理员可写
"""
class NoticeModel(ModelMixin, UserMixin):
"""通知公告表"""
__tablename__: str = "sys_notice"
__table_args__: dict[str, str] = {"comment": "通知公告表"}
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by", "tenant_by"]
__platform_data_shared__: bool = True
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by"]
notice_title: Mapped[str] = mapped_column(String(64), nullable=False, comment="公告标题")
notice_type: Mapped[str] = mapped_column(String(1), nullable=False, comment="公告类型(1通知 2公告)")
notice_content: Mapped[str | None] = mapped_column(Text, nullable=True, comment="公告内容")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:草稿 1:已发布 2:已归档)", index=True)
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
class NoticeReadModel(MappedBase):
"""通知已读记录表 — 记录用户对公告的已读状态。
设计说明:
- 不继承 TenantMixin该表按 user_id 隔离租户上下文由所属 notice 间接确定
- (user_id, notice_id) 唯一约束 未建立记录即代表未读
- 仅用于标记已读时间不做其他业务用途
- 不继承 ModelMixin使用 (user_id, notice_id) 复合主键无需自增 id
避免 SQLite 不支持复合主键列 autoincrement 的问题
"""
__tablename__: str = "sys_notice_read"
__table_args__: tuple = (
UniqueConstraint("user_id", "notice_id", name="uq_user_notice_read"),
{"comment": "通知已读记录表"},
)
__loader_options__: list[str] = ["notice"]
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("sys_user.id", ondelete="CASCADE"), primary_key=True, comment="用户ID")
notice_id: Mapped[int] = mapped_column(Integer, ForeignKey("sys_notice.id", ondelete="CASCADE"), primary_key=True, comment="通知ID")
read_time: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.now, comment="已读时间")
# 关联
notice: Mapped[NoticeModel] = relationship("NoticeModel", lazy="selectin")
@@ -6,7 +6,7 @@ from pydantic import (
model_validator,
)
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam, TenantBySchema, UserByQueryParam, UserBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
from app.utils.xss_util import sanitize_html
@@ -16,7 +16,7 @@ class NoticeCreateSchema(BaseModel):
notice_title: str = Field(..., min_length=1, max_length=64, description="公告标题")
notice_type: str = Field(..., max_length=1, description="公告类型(1:通知 2:公告)")
notice_content: str | None = Field(default=None, max_length=65535, description="公告内容")
status: int = Field(default=0, ge=0, le=1, description="状态(0:启动 1:停用)")
status: int = Field(default=0, ge=0, le=2, description="状态(0:草稿 1:已发布 2:已归档)")
description: str | None = Field(default=None, max_length=255, description="描述")
@field_validator("notice_type")
@@ -29,8 +29,8 @@ class NoticeCreateSchema(BaseModel):
@field_validator("status")
@classmethod
def _validate_status(cls, value: int):
if value not in {0, 1}:
raise ValueError("状态仅支持 0(正常) 或 1(禁用)")
if value not in {0, 1, 2}:
raise ValueError("状态仅支持 0(草稿) 1(已发布) 2(已归档)")
return value
@field_validator("notice_content")
@@ -53,15 +53,15 @@ class NoticeUpdateSchema(NoticeCreateSchema):
"""公告通知更新模型"""
class NoticeOutSchema(NoticeCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
class NoticeOutSchema(NoticeCreateSchema, BaseSchema, UserBySchema):
"""公告通知响应模型"""
model_config = ConfigDict(from_attributes=True)
class NoticeQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class NoticeQueryParam(BaseQueryParam, UserByQueryParam):
"""公告通知查询参数"""
notice_title: str | None = Field(None, description="公告标题")
notice_type: str | None = Field(None, description="公告类型", json_schema_extra={"q": "eq"})
status: int | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)")
status: int | None = Field(None, ge=0, le=2, description="状态(0:草稿 1:已发布 2:已归档)")
@@ -109,5 +109,5 @@ async def export_param_list_controller(
async def get_init_config_controller(
redis: Annotated[Redis, Depends(redis_getter)],
) -> JSONResponse:
result_dict = await ParamsService.get_init_cache(redis=redis, tenant_id=1)
result_dict = await ParamsService.get_init_cache(redis=redis)
return SuccessResponse(data=result_dict, msg="获取初始化缓存参数成功")
@@ -1,25 +1,15 @@
from sqlalchemy import Boolean, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
from app.core.base_model import ModelMixin, UserMixin
class ParamsModel(ModelMixin, TenantMixin, UserMixin):
"""系统参数表
用于存储全局系统配置 retention_dayssmtp 主机等
平台参数tenant_id=1对所有租户共享租户级参数仅本租户可见
"""
class ParamsModel(ModelMixin, UserMixin):
"""系统参数表"""
__tablename__: str = "sys_param"
__table_args__: dict[str, str] = {"comment": "系统参数表"}
__loader_options__: list[str] = [
"created_by",
"updated_by",
"deleted_by",
"tenant_by",
]
__platform_data_shared__: bool = True
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by"]
config_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="参数名称")
config_key: Mapped[str] = mapped_column(String(500), nullable=False, comment="参数键名")
@@ -2,7 +2,7 @@ import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam, TenantBySchema, UserByQueryParam, UserBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
class ParamsCreateSchema(BaseModel):
@@ -39,14 +39,14 @@ class ParamsUpdateSchema(ParamsCreateSchema):
"""
class ParamsOutSchema(ParamsCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
class ParamsOutSchema(ParamsCreateSchema, BaseSchema, UserBySchema):
"""参数响应模型
"""
model_config = ConfigDict(from_attributes=True)
class ParamsQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class ParamsQueryParam(BaseQueryParam, UserByQueryParam):
"""参数管理查询参数
"""
@@ -9,7 +9,7 @@ from app.core.base_schema import AuthSchema, PageResultSchema
from app.core.database import async_db_session
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.core.middlewares import invalidate_middleware_config_cache
from app.core.redis_crud import RedisCURD
from app.utils.common_util import search_to_dict
from app.utils.excel_util import ExcelUtil
@@ -126,7 +126,7 @@ class ParamsService:
user = self.auth.user
if not user:
raise CustomException(msg="未登录")
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{user.tenant_id}:{data.config_key}"
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:1:{data.config_key}"
try:
redis_payload = out.model_dump(mode="json")
value = json.dumps(redis_payload, ensure_ascii=False)
@@ -169,7 +169,7 @@ class ParamsService:
user = self.auth.user
if not user:
raise CustomException(msg="未登录")
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{user.tenant_id}:{new_obj.config_key}"
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:1:{new_obj.config_key}"
try:
value = json.dumps(redis_payload, ensure_ascii=False)
result = await RedisCURD(redis).set(
@@ -184,9 +184,6 @@ class ParamsService:
logger.error(f"更新系统配置失败: {e}")
raise CustomException(msg="同步配置到缓存失败") from e
# 失效中间件内存缓存,让下次请求重新加载
invalidate_middleware_config_cache(user.tenant_id)
return out
async def delete(self, redis: Redis, ids: list[int]) -> None:
@@ -218,16 +215,13 @@ class ParamsService:
if not user:
raise CustomException(msg="未登录")
for obj in objs:
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{user.tenant_id}:{obj.config_key}"
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:1:{obj.config_key}"
try:
await RedisCURD(redis).delete(redis_key)
except Exception as e:
logger.error(f"删除系统配置失败: {e}")
raise CustomException(msg="同步删除缓存失败") from e
# 失效中间件内存缓存
invalidate_middleware_config_cache(user.tenant_id)
async def batch_set_status(self, redis: Redis, ids: list[int], status: int) -> None:
"""批量设置系统参数状态
@@ -242,17 +236,16 @@ class ParamsService:
if not ids:
raise CustomException(msg="请选择要操作的数据")
# 先查参数列表获取 config_key 和 tenant_id
# 先查参数列表获取 config_key
params = await ParamsCRUD(self.auth, self.db).get_list(search={"id": ("in", list(ids))})
await ParamsCRUD(self.auth, self.db).set(ids=ids, status=status)
# 同步删除对应 Redis 缓存
for param in params:
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{param.tenant_id}:{param.config_key}"
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:1:{param.config_key}"
try:
await RedisCURD(redis).delete(redis_key)
except Exception as e:
logger.error(f"同步删除系统配置缓存失败: {e}")
invalidate_middleware_config_cache(None)
@staticmethod
def export(data_list: list[dict]) -> bytes:
@@ -288,7 +281,7 @@ class ParamsService:
@staticmethod
async def _load_all_configs_from_db() -> Sequence[object]:
async with async_db_session() as session, session.begin():
init_auth = AuthSchema(check_data_scope=False)
init_auth = AuthSchema()
return await ParamsCRUD(init_auth, session).get_list()
@staticmethod
@@ -296,7 +289,7 @@ class ParamsService:
"""将 DB 配置写入 Redis,返回对应的 dict 列表。"""
configs: list[dict] = []
for config in config_obj:
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{config.tenant_id}:{config.config_key}"
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:1:{config.config_key}"
out = ParamsOutSchema.model_validate(config)
payload = out.model_dump(mode="json")
try:
@@ -319,9 +312,9 @@ class ParamsService:
raise CustomException(msg="初始化系统参数到 Redis 失败") from e
@staticmethod
async def get_init_cache(redis: Redis, tenant_id: int = 1) -> list[dict]:
async def get_init_cache(redis: Redis) -> list[dict]:
"""从 Redis 读取系统配置;为空时自动回源 DB。"""
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}:1:*")
redis_configs = await RedisCURD(redis).mget(redis_keys)
configs = []
for raw in redis_configs:
@@ -3,24 +3,18 @@ from typing import TYPE_CHECKING
from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
from app.core.base_model import ModelMixin, UserMixin
if TYPE_CHECKING:
from app.api.v1.module_system.user.model import UserModel
class PositionModel(ModelMixin, TenantMixin, UserMixin):
class PositionModel(ModelMixin, UserMixin):
"""岗位模型"""
__tablename__: str = "sys_position"
__table_args__: dict[str, str] = {"comment": "岗位表"}
__loader_options__: list[str] = [
"users",
"created_by",
"updated_by",
"deleted_by",
"tenant_by",
]
__loader_options__: list[str] = ["users", "created_by", "updated_by", "deleted_by"]
name: Mapped[str] = mapped_column(String(64), nullable=False, comment="岗位名称")
code: Mapped[str] = mapped_column(String(64), nullable=False, comment="岗位编码")
@@ -1,6 +1,6 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam, TenantBySchema, UserByQueryParam, UserBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
class PositionCreateSchema(BaseModel):
@@ -40,13 +40,13 @@ class PositionUpdateSchema(PositionCreateSchema):
"""岗位更新模型"""
class PositionOutSchema(PositionCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
class PositionOutSchema(PositionCreateSchema, BaseSchema, UserBySchema):
"""岗位信息响应模型"""
model_config = ConfigDict(from_attributes=True)
class PositionQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class PositionQueryParam(BaseQueryParam, UserByQueryParam):
"""岗位管理查询参数"""
name: str | None = Field(None, description="岗位名称")
+4 -62
View File
@@ -2,13 +2,11 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_platform.menu.crud import MenuCRUD
from app.api.v1.module_platform.package.service import PackageService
from app.api.v1.module_system.menu.crud import MenuCRUD
from app.api.v1.module_system.dept.crud import DeptCRUD
from app.core.base_crud import CRUDBase
from app.core.base_schema import AuthSchema
from app.core.exceptions import CustomException
from app.core.logger import logger as _lg
from .model import RoleModel
from .schema import RoleCreateSchema, RoleUpdateSchema
@@ -46,55 +44,13 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]):
missing = sorted(set(menu_ids) - {m.id for m in menus})
raise CustomException(msg=f"菜单不存在: {missing}")
# 非超管:按"调用方的租户套餐"校验;同时校验所有目标角色是否都在调用方租户内,
# 防止超管以外的人通过传入其他租户角色 ID 跨租户授权菜单。
user = self.auth.user
if user and user.tenant_id:
user_allowed = set[int](
await PackageService(self.auth, self.db).get_tenant_available_menu_ids(user.tenant_id)
)
# 调用方可见的角色必须在自己的租户内(防跨租户角色 IDOR)
for obj in roles:
if getattr(obj, "tenant_id", None) and obj.tenant_id != user.tenant_id:
if user.is_superuser:
continue # 超管放行(含 system tenant=1
raise CustomException(msg=f"无权操作跨租户角色: {obj.name}")
for menu in menus:
if int(menu.id) not in user_allowed:
if user.is_superuser:
continue
raise CustomException(msg=f"菜单[{menu.name}]不在当前租户的功能组内,无法分配")
# 超管给跨租户角色授权菜单时,也需校验菜单至少在目标租户套餐内
# —— 这一行为按业务灵活控制:默认通过,但记日志
if user.is_superuser:
cross_tenant_roles = [
r for r in roles if getattr(r, "tenant_id", None) and r.tenant_id != user.tenant_id
]
if cross_tenant_roles and menus:
_lg.info(
"超管跨租户授权菜单:roles={} menus={}",
[r.id for r in cross_tenant_roles],
[m.id for m in menus],
)
for obj in roles:
obj.menus.clear()
obj.menus.extend(menus)
await self.db.flush()
async def set_role_depts_crud(self, role_ids: list[int], dept_ids: list[int]) -> None:
"""设置角色的部门权限(含存在性校验 + 跨租户隔离
参数:
- role_ids (list[int]): 角色ID列表
- dept_ids (list[int]): 部门ID列表
返回:
- None
"""
"""设置角色的部门权限(含存在性校验)"""
if not role_ids:
raise CustomException(msg="角色ID列表不能为空")
@@ -108,16 +64,6 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]):
missing = sorted(set(dept_ids) - {d.id for d in depts})
raise CustomException(msg=f"部门不存在: {missing}")
# 跨租户隔离:非超管不能操作其他租户的角色
user = self.auth.user
if user and not user.is_superuser:
for obj in roles:
if getattr(obj, "tenant_id", None) and obj.tenant_id != user.tenant_id:
raise CustomException(msg=f"无权操作跨租户角色: {obj.name}")
for obj in depts:
if getattr(obj, "tenant_id", None) and obj.tenant_id != user.tenant_id:
raise CustomException(msg=f"无权操作跨租户部门: {obj.name}")
for obj in roles:
relationship = obj.depts
relationship.clear()
@@ -125,10 +71,6 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]):
await self.db.flush()
async def get_options(self) -> list[dict[str, Any]]:
"""获取角色下拉选项,返回 [{value, label}](自动按当前用户租户过滤)"""
search: dict[str, Any] = {"status": 0}
user = self.auth.user
if user and user.tenant_id and not user.is_superuser:
search["tenant_id"] = user.tenant_id
items = await self.get_list(search=search)
"""获取角色下拉选项,返回 [{value, label}](自动按状态过滤)"""
items = await self.get_list(search={"status": 0})
return [{"value": item.id, "label": item.name} for item in items]
+8 -20
View File
@@ -1,13 +1,12 @@
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.common.enums import PermissionFilterStrategy
from app.core.base_model import MappedBase, ModelMixin, TenantMixin, UserMixin
from app.core.base_model import MappedBase, ModelMixin, UserMixin
if TYPE_CHECKING:
from app.api.v1.module_platform.menu.model import MenuModel
from app.api.v1.module_system.menu.model import MenuModel
from app.api.v1.module_system.dept.model import DeptModel
from app.api.v1.module_system.user.model import UserModel
@@ -59,26 +58,15 @@ class RoleDeptsModel(MappedBase):
)
class RoleModel(ModelMixin, TenantMixin, UserMixin):
"""角色模型
角色列表只显示当前用户绑定的角色
"""
class RoleModel(ModelMixin, UserMixin):
"""角色模型"""
__tablename__: str = "sys_role"
__table_args__ = (UniqueConstraint("tenant_id", "code"), {"comment": "角色表"})
__loader_options__: list[str] = [
"menus",
"depts",
"created_by",
"updated_by",
"deleted_by",
"tenant_by",
]
__permission_strategy__: PermissionFilterStrategy = PermissionFilterStrategy.USER_BINDING
__table_args__: dict[str, str] = {"comment": "角色表"}
__loader_options__: list[str] = ["menus", "depts", "created_by", "updated_by", "deleted_by"]
name: Mapped[str] = mapped_column(String(64), nullable=False, comment="角色名称")
code: Mapped[str] = mapped_column(String(64), nullable=False, comment="角色编码")
code: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, comment="角色编码")
order: Mapped[int] = mapped_column(Integer, nullable=False, default=999, comment="显示排序")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
@@ -6,9 +6,9 @@ from pydantic import (
model_validator,
)
from app.api.v1.module_platform.menu.schema import MenuOutSchema
from app.api.v1.module_system.menu.schema import MenuOutSchema
from app.api.v1.module_system.dept.schema import DeptOutSchema
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam, TenantBySchema, UserByQueryParam, UserBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
from app.core.validator import (
role_permission_request_validator,
validate_required_code,
@@ -84,7 +84,7 @@ class RoleUpdateSchema(RoleCreateSchema):
"""
class RoleOutSchema(RoleCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
class RoleOutSchema(RoleCreateSchema, BaseSchema, UserBySchema):
"""角色信息响应模型
"""
@@ -94,7 +94,7 @@ class RoleOutSchema(RoleCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
depts: list[DeptOutSchema] = Field(default_factory=list, description="角色部门列表")
class RoleQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class RoleQueryParam(BaseQueryParam, UserByQueryParam):
"""角色管理查询参数
"""
@@ -2,7 +2,6 @@ from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_platform.tenant.service import TenantService
from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema
from app.core.exceptions import CustomException
from app.utils.common_util import search_to_dict
@@ -105,9 +104,6 @@ class RoleService:
if obj:
raise CustomException(msg="创建失败,编码已存在")
# 检查租户配额
await TenantService(self.auth, self.db).check_quota(self.auth.user.tenant_id, "role")
new_role = await RoleCRUD(self.auth, self.db).create(data=data)
return RoleOutSchema.model_validate(new_role)
@@ -1,3 +0,0 @@
from .controller import TicketRouter
__all__ = ["TicketRouter"]
@@ -1,104 +0,0 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.response import ResponseSchema, SuccessResponse
from app.core.base_schema import AuthSchema, PageResultSchema, PaginationQueryParam
from app.core.dependencies import AuthPermission, db_getter
from app.core.router_class import OperationLogRoute
from .schema import TicketBatchSchema, TicketCommentCreateSchema, TicketCommentOutSchema, TicketCreateSchema, TicketOutSchema, TicketQueryParam, TicketUpdateSchema
from .service import TicketCommentService, TicketService
TicketRouter = APIRouter(route_class=OperationLogRoute, prefix="/ticket", tags=["工单管理"])
@TicketRouter.get("/list", summary="工单列表", response_model=ResponseSchema[PageResultSchema[TicketOutSchema]])
async def ticket_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[TicketQueryParam, Query()],
) -> JSONResponse:
result = await TicketService(auth, db).page(
page_no=page.page_no,
page_size=page.page_size,
search=search,
order_by=page.order_by,
)
return SuccessResponse(data=result, msg="查询成功")
@TicketRouter.get("/detail/{id}", summary="获取工单详情", response_model=ResponseSchema[TicketOutSchema])
async def ticket_detail_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:detail"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="工单ID")],
) -> JSONResponse:
result = await TicketService(auth, db).detail(id=id)
return SuccessResponse(data=result, msg="查询成功")
@TicketRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建工单", response_model=ResponseSchema[TicketOutSchema])
async def ticket_create_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:create"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[TicketCreateSchema, Body(description="工单创建参数")],
) -> JSONResponse:
result = await TicketService(auth, db).create(data=data)
return SuccessResponse(data=result, msg="创建成功")
@TicketRouter.put("/update/{id}", summary="更新工单", response_model=ResponseSchema[TicketOutSchema])
async def ticket_update_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="工单ID", ge=1)],
data: Annotated[TicketUpdateSchema, Body(description="工单更新参数")],
) -> JSONResponse:
result = await TicketService(auth, db).update(id=id, data=data)
return SuccessResponse(data=result, msg="更新成功")
@TicketRouter.put("/batch", summary="批量更新工单", response_model=ResponseSchema)
async def ticket_batch_update_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[TicketBatchSchema, Body(description="工单批量更新参数")],
) -> JSONResponse:
await TicketService(auth, db).batch(data=data)
return SuccessResponse(msg="批量操作成功")
@TicketRouter.delete("/delete", summary="删除工单", response_model=ResponseSchema[None])
async def ticket_delete_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:delete"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
ids: Annotated[list[int], Body(description="工单ID列表")],
) -> JSONResponse:
await TicketService(auth, db).delete(ids=ids)
return SuccessResponse(msg="删除成功")
@TicketRouter.get("/{ticket_id}/comments", summary="工单评论列表", response_model=ResponseSchema[PageResultSchema[TicketCommentOutSchema]])
async def ticket_comment_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:detail"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
ticket_id: Annotated[int, Path(description="工单ID")],
page: Annotated[PaginationQueryParam, Depends()],
) -> JSONResponse:
result = await TicketCommentService(auth, db).page(ticket_id=ticket_id, page_no=page.page_no, page_size=page.page_size)
return SuccessResponse(data=result, msg="查询成功")
@TicketRouter.post("/{ticket_id}/comments", status_code=status.HTTP_201_CREATED, summary="创建评论", response_model=ResponseSchema[TicketCommentOutSchema])
async def ticket_comment_create_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:detail"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
ticket_id: Annotated[int, Path(description="工单ID")],
data: Annotated[TicketCommentCreateSchema, Body(description="评论内容")],
) -> JSONResponse:
result = await TicketCommentService(auth, db).create(ticket_id=ticket_id, data=data)
return SuccessResponse(data=result, msg="评论成功")
@@ -1,23 +0,0 @@
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_crud import CRUDBase
from app.core.base_schema import AuthSchema
from .model import TicketCommentModel, TicketModel
from .schema import TicketCommentCreateSchema, TicketCreateSchema, TicketUpdateSchema
class TicketCRUD(CRUDBase[TicketModel, TicketCreateSchema, TicketUpdateSchema]):
"""工单 CRUD"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
super().__init__(model=TicketModel, auth=auth, db=db)
class TicketCommentCRUD(CRUDBase[TicketCommentModel, TicketCommentCreateSchema, Any]):
"""工单评论 CRUD"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
super().__init__(model=TicketCommentModel, auth=auth, db=db)
@@ -1,63 +0,0 @@
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
if TYPE_CHECKING:
from app.api.v1.module_system.user.model import UserModel
class TicketModel(ModelMixin, TenantMixin, UserMixin):
"""工单模型 — 用户提交的建议和反馈
status: 0=待处理 1=处理中 2=已完成 3=已关闭
"""
__tablename__: str = "sys_ticket"
__table_args__: dict[str, str] = {"comment": "工单表"}
__loader_options__: list[str] = [
"created_by",
"updated_by",
"deleted_by",
"assigned_by",
"tenant_by",
]
title: Mapped[str] = mapped_column(String(200), nullable=False, comment="工单标题")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:待处理 1:处理中 2:已完成 3:已关闭)", index=True)
description: Mapped[str | None] = mapped_column(Text, default=None, 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:其他)")
images: Mapped[str | None] = mapped_column(Text, nullable=True, comment="图片URL列表(JSON数组)")
reply: Mapped[str | None] = mapped_column(Text, nullable=True, comment="回复内容")
assigned_id: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id", ondelete="SET NULL", onupdate="CASCADE"), nullable=True, index=True, comment="处理人ID")
assigned_by: Mapped["UserModel | None"] = relationship("UserModel", foreign_keys=[assigned_id], lazy="selectin", uselist=False)
@validates("title")
def validate_title(self, key: str, title: str) -> str:
if not title or not title.strip():
raise ValueError("工单标题不能为空")
return title.strip()
@validates("summary", "ticket_content")
def validate_content(self, key: str, content: str | None) -> str | None:
if content and content.strip():
return content.strip()
return content
class TicketCommentModel(ModelMixin, UserMixin):
"""工单评论模型"""
__tablename__: str = "sys_ticket_comment"
__table_args__: dict[str, str] = {"comment": "工单评论表"}
__loader_options__: list[str] = [
"created_by",
"updated_by",
"deleted_by",
]
ticket_id: Mapped[int] = mapped_column(ForeignKey("sys_ticket.id", ondelete="CASCADE"), nullable=False, index=True, comment="工单ID")
content: Mapped[str] = mapped_column(Text, nullable=False, comment="评论内容(富文本)")
@@ -1,105 +0,0 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.common.enums import TicketTypeEnum
from app.core.base_schema import (
BaseQueryParam,
BaseSchema,
CommonSchema,
TenantByQueryParam,
TenantBySchema,
UserByQueryParam,
UserBySchema,
)
class TicketCreateSchema(BaseModel):
"""创建工单"""
title: str = Field(..., min_length=1, max_length=200, description="工单标题")
ticket_content: str = Field(default="", description="工单内容(富文本)")
summary: str | None = Field(default=None, description="工单内容(纯文本摘要)")
ticket_type: TicketTypeEnum = Field(default=TicketTypeEnum.SUGGESTION, description="工单类型(suggestion/bug/optimize/other)")
images: str | None = Field(default=None, description="图片URL列表(JSON数组)")
description: str | None = Field(default=None, max_length=255, description="工单描述")
@field_validator("title")
@classmethod
def _validate_title(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("工单标题不能为空")
return v
class TicketUpdateSchema(BaseModel):
"""更新工单"""
title: str | None = Field(default=None, max_length=200, description="工单标题")
ticket_content: str | None = Field(default=None, description="工单内容(富文本)")
summary: str | None = Field(default=None, description="工单内容(纯文本摘要)")
ticket_type: TicketTypeEnum | None = Field(default=None, description="工单类型")
status: int | None = Field(default=None, ge=0, le=3, description="状态(0:待处理 1:处理中 2:已完成 3:已关闭)")
reply: 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("status")
@classmethod
def _validate_status(cls, v: int | None) -> int | 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(BaseSchema, UserBySchema, TenantBySchema):
"""工单响应"""
model_config = ConfigDict(from_attributes=True)
title: str = Field(..., description="工单标题")
ticket_content: str | None = Field(default=None, description="工单内容")
summary: str | None = Field(default=None, description="摘要")
ticket_type: TicketTypeEnum = Field(..., description="工单类型")
status: int = Field(..., description="状态(0:待处理 1:处理中 2:已完成 3:已关闭)")
images: str | None = Field(default=None, description="图片")
reply: str | None = Field(default=None, description="回复内容")
assigned_id: int | None = Field(default=None, description="指派人ID")
assigned_by: CommonSchema | None = Field(default=None, description="指派人")
class TicketBatchSchema(BaseModel):
"""批量更新工单"""
ids: list[int] = Field(..., min_length=1, description="工单ID列表")
status: int = Field(..., ge=0, le=3, description="状态(0:待处理 1:处理中 2:已完成 3:已关闭)")
@field_validator("status")
@classmethod
def _validate_status(cls, v: int) -> int:
if v not in {0, 1, 2, 3}:
raise ValueError("工单状态仅支持 0(待处理)、1(处理中)、2(已完成)、3(已关闭)")
return v
class TicketQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
"""工单查询参数"""
title: str | None = Field(None, description="工单标题")
ticket_type: str | None = Field(None, description="工单类型", json_schema_extra={"q": "eq"})
assigned_id: int | None = Field(None, description="处理人ID")
status: int | None = Field(None, ge=0, le=3, description="状态(0:待处理 1:处理中 2:已完成 3:已关闭)")
class TicketCommentCreateSchema(BaseModel):
"""创建评论"""
content: str = Field(..., min_length=1, description="评论内容")
class TicketCommentOutSchema(BaseSchema, UserBySchema):
"""评论响应"""
model_config = ConfigDict(from_attributes=True)
ticket_id: int
content: str
created_by_name: str | None = None
@@ -1,182 +0,0 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_system.user.model import UserModel
from app.core.base_schema import AuthSchema, PageResultSchema
from app.core.event_bus import EventBus
from app.core.exceptions import CustomException
from app.utils.common_util import search_to_dict
from .crud import TicketCommentCRUD, TicketCRUD
from .schema import (
TicketBatchSchema,
TicketCommentCreateSchema,
TicketCommentOutSchema,
TicketCreateSchema,
TicketOutSchema,
TicketQueryParam,
TicketUpdateSchema,
)
_TICKET_STATUS_TRANSITIONS = {
0: {1, 3},
1: {2, 3},
2: {3},
3: {0},
}
_TICKET_STATUS_LABELS = {
0: "待处理",
1: "处理中",
2: "已完成",
3: "已关闭",
}
class TicketService:
"""工单管理服务"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
self.auth = auth
self.db = db
def _validate_status_transition(self, ticket, new_status: int) -> None:
old_status = ticket.status if ticket.status is not None else 0
old_label = _TICKET_STATUS_LABELS.get(old_status, str(old_status))
new_label = _TICKET_STATUS_LABELS.get(new_status, str(new_status))
if new_status not in _TICKET_STATUS_TRANSITIONS.get(old_status, set()):
raise CustomException(msg=f"不允许从{old_label}转换为{new_label}")
user = self.auth.user
is_super = user.is_superuser if user else False
is_creator = user and user.id and ticket.created_id == user.id
is_assignee = user and user.id and ticket.assigned_id == 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="仅创建人或超管可以确认关闭工单")
async def page(
self,
page_no: int,
page_size: int,
search: TicketQueryParam | None = None,
order_by: list | None = None,
) -> PageResultSchema[TicketOutSchema]:
return await TicketCRUD(self.auth, self.db).page(
offset=(page_no - 1) * page_size,
limit=page_size,
order_by=order_by or [{"created_time": "desc"}],
search=search_to_dict(search),
out_schema=TicketOutSchema,
)
async def detail(self, id: int) -> TicketOutSchema:
obj = await TicketCRUD(self.auth, self.db).get_or_404(id=id)
return TicketOutSchema.model_validate(obj)
async def create(self, data: TicketCreateSchema) -> TicketOutSchema:
obj = await TicketCRUD(self.auth, self.db).create(data=data)
if not obj:
raise CustomException(msg="创建工单失败")
return TicketOutSchema.model_validate(obj)
async def update(self, id: int, data: TicketUpdateSchema) -> TicketOutSchema:
obj = await TicketCRUD(self.auth, self.db).get_or_404(id=id, msg="工单不存在")
if data.status is not None:
self._validate_status_transition(obj, data.status)
if data.assigned_id is not None:
user_stmt = select(UserModel).where(
UserModel.id == data.assigned_id,
UserModel.is_deleted.is_(False),
)
user_result = await self.db.execute(user_stmt)
assigned_user = user_result.scalar_one_or_none()
if not assigned_user:
raise CustomException(msg="指定的处理人不存在")
if assigned_user.tenant_id != obj.tenant_id:
raise CustomException(msg="处理人必须与工单属于同一租户")
updated = await TicketCRUD(self.auth, self.db).update(id=id, data=data)
if not updated:
raise CustomException(msg="工单不存在")
# 有回复内容时 SSE 推送通知给工单创建者
if data.reply and obj.created_id:
await EventBus.publish(
obj.created_id,
{
"type": "ticket_reply",
"ticket_id": obj.id,
"title": obj.title,
"ticket_type": obj.ticket_type,
},
)
return TicketOutSchema.model_validate(updated)
async def delete(self, ids: list[int]) -> None:
if not ids:
raise CustomException(msg="删除对象不能为空")
await TicketCRUD(self.auth, self.db).delete(ids=ids)
async def batch(self, data: TicketBatchSchema) -> None:
if not data.ids:
raise CustomException(msg="请选择要操作的工单")
tickets = await TicketCRUD(self.auth, self.db).get_list(search={"id": ("in", data.ids)})
ticket_map = {t.id: t for t in tickets}
for tid in data.ids:
obj = ticket_map.get(tid)
if not obj:
raise CustomException(msg=f"工单[{tid}]不存在")
self._validate_status_transition(obj, data.status)
await TicketCRUD(self.auth, self.db).set(ids=data.ids, status=data.status)
class TicketCommentService:
"""工单评论服务"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
self.auth = auth
self.db = db
async def page(self, ticket_id: int, page_no: int, page_size: int) -> PageResultSchema[TicketCommentOutSchema]:
return await TicketCommentCRUD(self.auth, self.db).page(
offset=(page_no - 1) * page_size,
limit=page_size,
order_by=[{"created_time": "desc"}],
search={"ticket_id": ("eq", ticket_id)},
out_schema=TicketCommentOutSchema,
)
async def create(self, ticket_id: int, data: TicketCommentCreateSchema) -> TicketCommentOutSchema:
# 验证工单存在
await TicketCRUD(self.auth, self.db).get_or_404(id=ticket_id, msg="工单不存在")
create_data = data.model_dump() | {"ticket_id": ticket_id}
obj = await TicketCommentCRUD(self.auth, self.db).create(data=create_data) # type: ignore[arg-type]
if not obj:
raise CustomException(msg="评论失败")
return TicketCommentOutSchema.model_validate(obj)
async def delete(self, comment_id: int) -> None:
await TicketCommentCRUD(self.auth, self.db).get_or_404(id=comment_id, msg="评论不存在")
await TicketCommentCRUD(self.auth, self.db).delete(ids=[comment_id])
@@ -87,7 +87,7 @@ async def forget_password_controller(
key=data.captcha_key,
)
auth = AuthSchema(check_data_scope=False)
auth = AuthSchema()
user_forget_password_result = await UserService(auth, db).forget_password(data=data)
logger.info(f"{data.username} 重置密码成功")
return SuccessResponse(data=user_forget_password_result, msg="重置密码成功")
+5 -45
View File
@@ -6,13 +6,9 @@ from app.api.v1.module_system.position.crud import PositionCRUD
from app.api.v1.module_system.role.crud import RoleCRUD
from app.core.base_crud import CRUDBase
from app.core.base_schema import AuthSchema
from app.core.exceptions import CustomException
from .model import UserModel
from .schema import (
UserCreateSchema,
UserUpdateSchema,
)
from .schema import UserCreateSchema, UserUpdateSchema
class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]):
@@ -30,7 +26,7 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]):
await self.set([id], last_login=datetime.now())
async def set_user_roles(self, user_ids: list[int], role_ids: list[int]) -> None:
"""批量设置用户角色(带租户隔离验证)
"""批量设置用户角色
参数:
- user_ids (list[int]): 用户ID列表
@@ -40,15 +36,7 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]):
- None
"""
user_objs = await self.get_list(search={"id": ("in", user_ids)})
if role_ids:
role_objs = await RoleCRUD(self.auth, self.db).get_list(search={"id": ("in", role_ids)})
auth_user = self.auth.user
if auth_user and not auth_user.is_superuser:
for role in role_objs:
if role.tenant_id != auth_user.tenant_id:
raise CustomException(msg=f"角色 {role.name} 不属于当前租户")
else:
role_objs = []
role_objs = [] if not role_ids else await RoleCRUD(self.auth, self.db).get_list(search={"id": ("in", role_ids)})
for obj in user_objs:
relationship = obj.roles
@@ -57,7 +45,7 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]):
await self.db.flush()
async def set_user_positions(self, user_ids: list[int], position_ids: list[int]) -> None:
"""批量设置用户岗位(带租户隔离验证)
"""批量设置用户岗位
参数:
- user_ids (list[int]): 用户ID列表
@@ -67,15 +55,7 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]):
- None
"""
user_objs = await self.get_list(search={"id": ("in", user_ids)})
if position_ids:
position_objs = await PositionCRUD(self.auth, self.db).get_list(search={"id": ("in", position_ids)})
auth_user = self.auth.user
if auth_user and not auth_user.is_superuser:
for position in position_objs:
if position.tenant_id != auth_user.tenant_id:
raise CustomException(msg=f"岗位 {position.name} 不属于当前租户")
else:
position_objs = []
position_objs = [] if not position_ids else await PositionCRUD(self.auth, self.db).get_list(search={"id": ("in", position_ids)})
for obj in user_objs:
relationship = obj.positions
@@ -98,23 +78,3 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]):
async def forget_password(self, id: int, password_hash: str) -> UserModel:
"""重置密码(与 change_password 逻辑相同)"""
return await self.change_password(id=id, password_hash=password_hash)
async def bump_token_version(self, user_id: int) -> None:
"""递增指定用户的 token_version 字段,使所有现有 JWT 立即失效。
配合 invalidate_user_sessionsservice 层调用可在用户改密/重置/禁用时
同时清掉 Redis 中的活跃会话
参数:
- user_id (int): 用户ID
"""
from sqlalchemy import update as sa_update
from .model import UserModel
await self.db.execute(
sa_update(UserModel)
.where(UserModel.id == user_id)
.values(token_version=UserModel.token_version + 1)
)
await self.db.flush()
+6 -14
View File
@@ -1,13 +1,12 @@
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.base_model import MappedBase, ModelMixin, TenantMixin, UserMixin
from app.core.base_model import MappedBase, ModelMixin, UserMixin
if TYPE_CHECKING:
from app.api.v1.module_platform.tenant.model import TenantModel
from app.api.v1.module_system.dept.model import DeptModel
from app.api.v1.module_system.position.model import PositionModel
from app.api.v1.module_system.role.model import RoleModel
@@ -59,15 +58,15 @@ class UserPositionsModel(MappedBase):
)
class UserModel(ModelMixin, TenantMixin, UserMixin):
class UserModel(ModelMixin, UserMixin):
"""用户模型
"""
__tablename__: str = "sys_user"
__table_args__ = (UniqueConstraint("tenant_id", "username"), {"comment": "用户表"})
__loader_options__: list[str] = ["dept", "roles", "positions", "created_by", "updated_by", "deleted_by", "tenant_by"]
__table_args__: dict[str, str] = {"comment": "用户表"}
__loader_options__: list[str] = ["dept", "roles", "positions", "created_by", "updated_by", "deleted_by"]
username: Mapped[str] = mapped_column(String(64), nullable=False, comment="用户名/登录账号")
username: Mapped[str] = mapped_column(String(64), unique=True, 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="手机号")
@@ -82,15 +81,8 @@ class UserModel(ModelMixin, TenantMixin, UserMixin):
qq_login: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="QQ登录")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
token_version: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="令牌版本号:每次改密/重置/禁用递增,使旧 JWT 立即失效")
dept_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("sys_dept.id", ondelete="SET NULL", onupdate="CASCADE"), nullable=True, index=True, comment="部门ID")
tenant: Mapped["TenantModel | None"] = relationship(
"TenantModel",
foreign_keys="UserModel.tenant_id",
lazy="selectin",
viewonly=True,
)
dept: Mapped["DeptModel | None"] = relationship(back_populates="users", foreign_keys=[dept_id], lazy="selectin")
roles: Mapped[list["RoleModel"]] = relationship(secondary="sys_user_roles", back_populates="users", lazy="selectin")
positions: Mapped[list["PositionModel"]] = relationship(secondary="sys_user_positions", back_populates="users", lazy="selectin")
@@ -9,9 +9,9 @@ from pydantic import (
model_validator,
)
from app.api.v1.module_platform.menu.schema import MenuTreeOutSchema
from app.api.v1.module_system.menu.schema import MenuTreeOutSchema
from app.api.v1.module_system.role.schema import RoleOutSchema
from app.core.base_schema import BaseQueryParam, BaseSchema, CommonSchema, CoreUserSchema, TenantByQueryParam, TenantBySchema, UserByQueryParam, UserBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema, CommonSchema, CoreUserSchema, UserByQueryParam, UserBySchema
from app.core.validator import email_validator, mobile_validator
@@ -68,7 +68,6 @@ class CurrentUserUpdateSchema(BaseModel):
class UserForgetPasswordSchema(BaseModel):
"""忘记密码"""
tenant_name: str = Field(..., min_length=1, max_length=32, 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="手机号")
@@ -149,7 +148,6 @@ class UserCreateSchema(CurrentUserUpdateSchema):
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列表")
@@ -220,13 +218,12 @@ class UserUpdateSchema(CurrentUserUpdateSchema):
return v
class UserOutSchema(CoreUserSchema, BaseSchema, UserBySchema, TenantBySchema):
class UserOutSchema(CoreUserSchema, BaseSchema, UserBySchema):
"""响应"""
model_config = ConfigDict(arbitrary_types_allowed=True, from_attributes=True)
id: int = Field(default=0, description="主键ID")
tenant_id: int = Field(default=0, description="租户ID")
username: str | None = Field(default=None, max_length=32, description="用户名")
name: str | None = Field(default=None, max_length=32, description="名称")
mobile: str | None = Field(default=None, max_length=11, description="手机号")
@@ -247,17 +244,15 @@ class UserOutSchema(CoreUserSchema, BaseSchema, UserBySchema, TenantBySchema):
positions: list[CommonSchema] | None = Field(default=[], description="岗位")
roles: list[RoleOutSchema] | None = Field(default=[], description="角色")
menus: list[MenuTreeOutSchema] | None = Field(default=[], description="菜单")
is_impersonate: bool = Field(default=False, description="是否为平台管理员代签入")
is_superuser: bool = Field(default=False, description="是否超管")
class UserQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
class UserQueryParam(BaseQueryParam, UserByQueryParam):
"""用户管理查询参数(继承标准 Mixin)
支持
- 时间范围BaseQueryParam
- 创建人/更新人筛选UserByQueryParam
- 租户筛选TenantByQueryParam
- 业务字段用户名名称手机号邮箱部门状态
"""
@@ -3,15 +3,12 @@ from typing import Any
from fastapi import UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_platform.menu.crud import MenuCRUD
from app.api.v1.module_platform.menu.schema import MenuOutSchema, MenuTreeOutSchema
from app.api.v1.module_platform.package.service import PackageService
from app.api.v1.module_platform.tenant.model import TenantModel
from app.api.v1.module_platform.tenant.service import TenantService
from app.api.v1.module_system.menu.crud import MenuCRUD
from app.api.v1.module_system.menu.schema import MenuOutSchema, MenuTreeOutSchema
from app.api.v1.module_system.dept.crud import DeptCRUD
from app.api.v1.module_system.position.crud import PositionCRUD
from app.api.v1.module_system.role.crud import RoleCRUD
from app.core.base_schema import AuthSchema, BatchSetAvailable, CommonSchema, PageResultSchema
from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.utils.common_util import search_to_dict, traversal_to_tree
@@ -84,8 +81,6 @@ class UserService:
if not dept:
raise CustomException(msg="该数据不存在")
await TenantService(self.auth, self.db).check_quota(self.auth.user.tenant_id, "user")
if data.password:
data.password = PwdUtil.hash_password(password=data.password)
new_user = await UserCRUD(self.auth, self.db).create(data=data)
@@ -196,12 +191,12 @@ class UserService:
if not self.auth.user.id:
raise CustomException(msg="该数据不存在")
user = await UserCRUD(self.auth, self.db).get(id=self.auth.user.id)
if user is None:
raise CustomException(msg="该数据不存在")
user_dict = UserOutSchema.model_validate(user)
if user and user.dept:
if user.dept:
user_dict.dept_name = user.dept.name
if user and user.tenant_by:
user_dict.tenant_by = CommonSchema(id=user.tenant_by.id, name=user.tenant_by.name, status=user.tenant_by.status)
user_dict.is_impersonate = self.auth.is_impersonate
user_dict.is_superuser = user.is_superuser
_pc_only = {"client": "pc"}
if self.auth.user.is_superuser:
@@ -212,12 +207,6 @@ class UserService:
menus_raw = [MenuOutSchema.model_validate(menu) for menu in menu_all]
else:
menu_ids = set(self.auth.menu_ids)
if menu_ids and self.auth.user.tenant_id:
allowed_ids = await PackageService(self.auth, self.db).get_tenant_available_menu_ids(self.auth.user.tenant_id)
allowed_set = set(allowed_ids)
menu_ids = menu_ids & allowed_set
menus_raw = (
[
MenuOutSchema.model_validate(menu)
@@ -230,8 +219,6 @@ class UserService:
else []
)
for menu in menus_raw:
menu.scope = None
menu_tree = [MenuTreeOutSchema(**item) for item in traversal_to_tree([menu.model_dump(mode="json") for menu in menus_raw])]
user_dict.menus = menu_tree
return user_dict
@@ -262,10 +249,6 @@ class UserService:
if user.is_superuser:
raise CustomException(msg="超级管理员状态不能修改")
await UserCRUD(self.auth, self.db).set(ids=data.ids, status=data.status)
# 停用的用户立即让旧 token 失效
if data.status == 1:
for user in users:
await self._invalidate_user_sessions(user_id=user.id)
async def change_password(self, data: UserChangePasswordSchema) -> UserOutSchema:
if not self.auth.user.id:
@@ -281,8 +264,6 @@ class UserService:
new_password_hash = PwdUtil.hash_password(password=data.new_password)
new_user = await UserCRUD(self.auth, self.db).change_password(id=user.id, password_hash=new_password_hash)
# 改密后立即让旧 token 失效:递增 token_version + 清掉该用户的所有 Redis session
await self._invalidate_user_sessions(user_id=user.id)
return UserOutSchema.model_validate(new_user)
async def reset_password(self, data: ResetPasswordSchema) -> UserOutSchema:
@@ -298,56 +279,22 @@ class UserService:
new_password_hash = PwdUtil.hash_password(password=data.password)
new_user = await UserCRUD(self.auth, self.db).change_password(id=data.id, password_hash=new_password_hash)
# 重置密码后立即让旧 token 失效
await self._invalidate_user_sessions(user_id=user.id)
return UserOutSchema.model_validate(new_user)
async def _invalidate_user_sessions(self, user_id: int) -> None:
"""使指定用户的所有活跃 session 立即失效。
递增 ``UserModel.token_version``JWT 中携带的旧 token_version DB 不匹配 401
Redis 中存储的 key 格式为 ``user_session:<session_id>``session_id = UUID
``user_id`` 无直接映射关系因此无法按 user_id 精确清理孤立 session 数据
``token_version`` 已确保旧 JWT 无法通过校验安全无虞
"""
await UserCRUD(self.auth, self.db).bump_token_version(user_id=user_id)
async def forget_password(self, data: UserForgetPasswordSchema) -> UserOutSchema:
from sqlalchemy import select
# 根据租户名称查租户
tenant_stmt = (
select(TenantModel)
.where(
TenantModel.name == data.tenant_name,
TenantModel.status == 0,
TenantModel.is_deleted.is_(False),
)
.limit(1)
)
result = await self.db.execute(tenant_stmt)
tenant = result.scalar_one_or_none()
if not tenant:
raise CustomException(msg="租户不存在")
# 在租户范围内查找用户
user = await UserCRUD(self.auth, self.db).get(username=data.username, tenant_id=tenant.id)
# 直接按用户名查找用户
user = await UserCRUD(self.auth, self.db).get(username=data.username)
if not user:
raise CustomException(msg="该数据不存在")
if user.status == 1:
raise CustomException(msg="用户已停用")
if user.is_superuser:
raise CustomException(msg="超级管理员密码不能重置")
if data.mobile and user.mobile != data.mobile:
raise CustomException(msg="手机号不匹配")
new_password_hash = PwdUtil.hash_password(password=data.new_password)
new_user = await UserCRUD(self.auth, self.db).forget_password(id=user.id, password_hash=new_password_hash)
# 忘记密码后使旧 token 失效
await self._invalidate_user_sessions(user_id=user.id)
return UserOutSchema.model_validate(new_user)
async def batch_import(self, file: UploadFile, update_support: bool = False) -> str:
@@ -437,8 +384,6 @@ class UserService:
dept = await DeptCRUD(self.auth, self.db).get(id=dept_id)
if not dept:
return 0, f"{row_num}行: 部门ID {dept_id} 不存在"
if not self.auth.user.is_superuser and dept.tenant_id != self.auth.user.tenant_id:
return 0, f"{row_num}行: 部门ID {dept_id} 不属于当前租户"
user_data = {
"username": username,
@@ -37,7 +37,7 @@ async def get_version_list_controller(
async def get_published_versions_controller(
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
auth = AuthSchema(check_data_scope=False)
auth = AuthSchema()
service = VersionService(auth, db)
result = await service.get_published()
return SuccessResponse(data=result, msg="查询成功")
@@ -9,17 +9,13 @@ class VersionModel(ModelMixin, UserMixin):
__tablename__: str = "sys_version"
__table_args__: dict[str, str] = {"comment": "版本管理表"}
__loader_options__: list[str] = [
"created_by",
"updated_by",
"deleted_by",
]
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by"]
version: Mapped[str] = mapped_column(String(50), nullable=False, comment="版本号")
title: Mapped[str] = mapped_column(String(200), nullable=False, comment="版本标题")
date: Mapped[str] = mapped_column(String(50), nullable=False, comment="发布日期")
content: Mapped[str | None] = mapped_column(Text, nullable=True, default=None, comment="版本富文本内容")
description: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None, comment="备注")
sort: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="排序")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态: 0=草稿,1=已发布,2=已回滚")
description: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None, comment="备注")
require_re_login: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, comment="是否需要重新登录")