mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-21 04:46:26 +00:00
refactor: 移除多租户相关代码,重构为单租户架构
此次提交进行了大规模的架构重构: 1. 移除所有平台租户相关模块和代码,包括租户管理、套餐、订单、发票等功能 2. 将菜单模块从platform迁移到system模块,统一系统功能入口 3. 移除租户隔离相关的模型混入、中间件和配置 4. 简化文件上传、SSE事件总线、定时任务等模块的租户逻辑 5. 重构所有业务schema和模型,移除租户相关字段和关联 6. 清理初始化脚本、模板和常量中的租户相关代码 7. 简化认证和权限控制逻辑,移除数据范围检查相关代码
This commit is contained in:
@@ -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):
|
||||
"""滑块验证完成请求"""
|
||||
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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="获取初始化字典数据成功")
|
||||
|
||||
@@ -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_days、smtp 主机等)。
|
||||
平台参数(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="岗位名称")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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="重置密码成功")
|
||||
|
||||
@@ -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_sessions(service 层调用)可在用户改密/重置/禁用时
|
||||
同时清掉 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()
|
||||
|
||||
@@ -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="是否需要重新登录")
|
||||
|
||||
Reference in New Issue
Block a user