mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-21 04:46:26 +00:00
refactor: 重构项目模块结构与代码细节优化
1. 调整后端模块路由与插件文件结构,迁移部分模块代码至api/v1目录 2. 优化代码注释格式与文档字符串,简化冗余代码 3. 添加监控仪表盘、工单评论等新模块功能 4. 修复前端部分组件交互逻辑与类型定义 5. 清理冗余的初始化数据与废弃配置文件 6. 新增租户注册表单字段与国际化支持 7. 优化HTTP请求拦截器与快捷键功能
This commit is contained in:
@@ -10,6 +10,7 @@ from app.api.v1.module_system.position.controller import PositionRouter
|
||||
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
|
||||
|
||||
system_router = APIRouter(prefix="/system")
|
||||
|
||||
@@ -23,3 +24,4 @@ system_router.include_router(PositionRouter)
|
||||
system_router.include_router(RoleRouter)
|
||||
system_router.include_router(TicketRouter)
|
||||
system_router.include_router(UserRouter)
|
||||
system_router.include_router(VersionRouter)
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
import secrets
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Body, Depends, Path, Query, Request
|
||||
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
|
||||
@@ -14,8 +14,6 @@ from app.config.setting import settings
|
||||
from app.core.base_schema import (
|
||||
AuthSchema,
|
||||
JWTOutSchema,
|
||||
LogoutPayloadSchema,
|
||||
RefreshTokenPayloadSchema,
|
||||
)
|
||||
from app.core.dependencies import db_getter, get_current_user, redis_getter
|
||||
from app.core.exceptions import CustomException
|
||||
@@ -26,6 +24,7 @@ from app.core.security import CustomOAuth2PasswordRequestForm
|
||||
|
||||
from .oauth_service import (
|
||||
STATE_PREFIX,
|
||||
OAuthProvider,
|
||||
_callback_url,
|
||||
build_authorize_url,
|
||||
complete_oauth_login,
|
||||
@@ -34,20 +33,22 @@ from .oauth_service import (
|
||||
save_oauth_state,
|
||||
)
|
||||
from .schema import (
|
||||
AutoLoginTokenSchema,
|
||||
AutoLoginUserSchema,
|
||||
CaptchaOutSchema,
|
||||
EnterPlatformOutSchema,
|
||||
ImpersonateOutSchema,
|
||||
ImpersonateSchema,
|
||||
LoginWithTenantsSchema,
|
||||
SelectTenantOutSchema,
|
||||
SelectTenantSchema,
|
||||
TenantLookupOutSchema,
|
||||
TenantOptionSchema,
|
||||
TenantRegisterOutSchema,
|
||||
TenantRegisterSchema,
|
||||
)
|
||||
from .service import (
|
||||
AutoLoginService,
|
||||
CaptchaService,
|
||||
LoginService,
|
||||
TenantLookupService,
|
||||
TenantRegisterService,
|
||||
)
|
||||
|
||||
@@ -56,6 +57,26 @@ 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.post("/login", summary="登录", response_model=LoginWithTenantsSchema)
|
||||
async def login_for_access_token_controller(
|
||||
request: Request,
|
||||
@@ -63,10 +84,8 @@ async def login_for_access_token_controller(
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
login_form: Annotated[CustomOAuth2PasswordRequestForm, Depends()],
|
||||
) -> JSONResponse | dict:
|
||||
login_result = await LoginService.authenticate_user(
|
||||
request=request, redis=redis, login_form=login_form, db=db, background_tasks=background_tasks
|
||||
)
|
||||
) -> JSONResponse | LoginWithTenantsSchema:
|
||||
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}登录成功")
|
||||
|
||||
@@ -79,7 +98,7 @@ async def login_for_access_token_controller(
|
||||
async def get_new_token_controller(
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
payload: Annotated[RefreshTokenPayloadSchema, Body(description="刷新token参数")],
|
||||
payload: Annotated[str, Body(description="刷新token参数")],
|
||||
) -> JSONResponse:
|
||||
new_token = await LoginService.refresh_token(db=db, redis=redis, refresh_token=payload)
|
||||
return SuccessResponse(data=new_token, msg="刷新成功")
|
||||
@@ -96,7 +115,7 @@ async def get_captcha_for_login_controller(
|
||||
@AuthRouter.post("/logout", summary="退出登录", response_model=ResponseSchema[None], dependencies=[Depends(get_current_user)])
|
||||
async def logout_controller(
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
payload: Annotated[LogoutPayloadSchema, Body(description="退出登录参数")],
|
||||
payload: Annotated[str, Body(description="退出登录参数")],
|
||||
) -> JSONResponse:
|
||||
if await LoginService.logout(redis=redis, token=payload):
|
||||
logger.info("退出成功")
|
||||
@@ -104,40 +123,6 @@ async def logout_controller(
|
||||
return ErrorResponse(msg="退出失败")
|
||||
|
||||
|
||||
@AuthRouter.get("/auto-login/users", summary="获取免登录用户列表", response_model=ResponseSchema[list[AutoLoginUserSchema]])
|
||||
async def get_auto_login_users_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
tenant_id = None if auth.user.is_superuser else auth.user.tenant_id
|
||||
users = await AutoLoginService.get_auto_login_users(db=db, tenant_id=tenant_id)
|
||||
return SuccessResponse(data=users, msg="获取成功")
|
||||
|
||||
|
||||
@AuthRouter.post("/auto-login/token", summary="获取免登录Token", response_model=ResponseSchema[AutoLoginTokenSchema])
|
||||
async def get_auto_login_token_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
user_id: Annotated[int, Body(description="用户ID")],
|
||||
) -> JSONResponse:
|
||||
tenant_id = None if auth.user.is_superuser else auth.user.tenant_id
|
||||
result = await AutoLoginService.create_auto_login_token(redis=redis, db=db, user_id=user_id, tenant_id=tenant_id)
|
||||
return SuccessResponse(data=result, msg="获取成功")
|
||||
|
||||
|
||||
@AuthRouter.post("/auto-login", summary="免登录", response_model=ResponseSchema[JWTOutSchema])
|
||||
async def auto_login_controller(
|
||||
request: Request,
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
token: Annotated[str, Body(description="免登录Token")],
|
||||
) -> JSONResponse:
|
||||
login_token = await AutoLoginService.auto_login(request=request, redis=redis, db=db, token=token)
|
||||
logger.info("用户免登录成功")
|
||||
return SuccessResponse(data=login_token, msg="登录成功")
|
||||
|
||||
|
||||
@AuthRouter.post("/select-tenant", summary="选择租户", response_model=ResponseSchema[SelectTenantOutSchema], dependencies=[Depends(get_current_user)])
|
||||
async def select_tenant_controller(
|
||||
request: Request,
|
||||
@@ -150,6 +135,17 @@ async def select_tenant_controller(
|
||||
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)],
|
||||
) -> JSONResponse:
|
||||
result = await LoginService(auth).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(
|
||||
@@ -160,11 +156,23 @@ async def get_user_tenants_controller(
|
||||
return SuccessResponse(data=tenants, msg="获取租户列表成功")
|
||||
|
||||
|
||||
@AuthRouter.get("/oauth/{provider}/login", summary="第三方OAuth跳转", response_model=RedirectContentResponse[None])
|
||||
@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)],
|
||||
data: Annotated[ImpersonateSchema, Body(description="代签入参数")],
|
||||
) -> JSONResponse:
|
||||
result = await LoginService(auth).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,
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
provider: Annotated[str, Path(description="wechat | qq | github | gitee")],
|
||||
provider: Annotated[OAuthProvider, Path(description="wechat | qq | github | gitee")],
|
||||
redirect_uri: Annotated[str | None, Query(description="OAuth 完成后浏览器回到的前端登录页完整 URL")] = None,
|
||||
) -> RedirectResponse:
|
||||
allowed = {"wechat", "qq", "github", "gitee"}
|
||||
@@ -197,12 +205,12 @@ async def oauth_login_redirect_controller(
|
||||
)
|
||||
|
||||
|
||||
@AuthRouter.get("/oauth/{provider}/callback", summary="第三方OAuth回调", include_in_schema=False, response_model=RedirectContentResponse[None])
|
||||
@AuthRouter.get("/oauth/{provider}/callback", summary="第三方OAuth回调", include_in_schema=False)
|
||||
async def oauth_callback_controller(
|
||||
request: Request,
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
provider: Annotated[str, Path(description="wechat | qq | github | gitee")],
|
||||
provider: Annotated[OAuthProvider, Path(description="wechat | qq | github | gitee")],
|
||||
code: Annotated[str | None, Query(description="OAuth 授权码")] = None,
|
||||
state: Annotated[str | None, Query(description="OAuth 状态参数")] = None,
|
||||
) -> RedirectResponse:
|
||||
@@ -244,7 +252,7 @@ async def oauth_callback_controller(
|
||||
return RedirectContentResponse(url=oauth_service_error_redirect(fe, e.msg), status_code=302)
|
||||
|
||||
|
||||
@AuthRouter.post("/tenant/register", summary="租户自助注册", response_model=ResponseSchema[TenantRegisterOutSchema])
|
||||
@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="租户注册参数")],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""
|
||||
第三方 OAuth2 登录(微信开放平台扫码、QQ、GitHub、Gitee)。
|
||||
"""第三方 OAuth2 登录(微信开放平台扫码、QQ、GitHub、Gitee)。
|
||||
|
||||
各平台需在开放平台登记「授权回调域 / redirect_uri」为:
|
||||
{API}/system/auth/oauth/{provider}/callback
|
||||
@@ -20,7 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.module_system.user.crud import UserCRUD
|
||||
from app.api.v1.module_system.user.model import UserModel
|
||||
from app.api.v1.module_system.user.schema import UserRegisterSchema
|
||||
from app.api.v1.module_system.user.schema import UserCreateSchema
|
||||
from app.api.v1.module_system.user.service import UserService
|
||||
from app.config.setting import settings
|
||||
from app.core.base_schema import AuthSchema, JWTOutSchema
|
||||
@@ -33,7 +32,6 @@ from .service import LoginService
|
||||
OAuthProvider = Literal["wechat", "qq", "github", "gitee"]
|
||||
|
||||
STATE_PREFIX = "oauth_state:"
|
||||
STATE_TTL_SECONDS = 600
|
||||
|
||||
|
||||
def _callback_url(request: Request, provider: OAuthProvider) -> str:
|
||||
@@ -52,7 +50,7 @@ def _frontend_success_redirect(frontend_base: str, access_token: str, refresh_to
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": token_type,
|
||||
}
|
||||
},
|
||||
)
|
||||
sep = "&" if "?" in frontend_base else "?"
|
||||
return f"{frontend_base}{sep}{q}"
|
||||
@@ -173,7 +171,7 @@ async def exchange_gitee_token(client_id: str, client_secret: str, code: str, re
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"redirect_uri": redirect_uri,
|
||||
}
|
||||
},
|
||||
)
|
||||
data = await _http_json("GET", f"https://gitee.com/oauth/token?{qs}")
|
||||
if not isinstance(data, dict):
|
||||
@@ -191,7 +189,7 @@ async def exchange_wechat_token(app_id: str, secret: str, code: str) -> tuple[st
|
||||
"secret": secret,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
}
|
||||
},
|
||||
)
|
||||
data = await _http_json("GET", f"https://api.weixin.qq.com/sns/oauth2/access_token?{qs}")
|
||||
if not isinstance(data, dict):
|
||||
@@ -211,7 +209,7 @@ async def exchange_qq_token(client_id: str, client_secret: str, code: str, redir
|
||||
"client_secret": client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
}
|
||||
},
|
||||
)
|
||||
text = await _http_text("GET", f"https://graph.qq.com/oauth2.0/token?{qs}")
|
||||
parts = dict(p.split("=", 1) for p in text.split("&") if "=" in p)
|
||||
@@ -279,7 +277,7 @@ async def fetch_qq_profile(access_token: str, app_id: str, openid: str) -> tuple
|
||||
"access_token": access_token,
|
||||
"oauth_consumer_key": app_id,
|
||||
"openid": openid,
|
||||
}
|
||||
},
|
||||
)
|
||||
user = await _http_json("GET", f"https://graph.qq.com/user/get_user_info?{qs}")
|
||||
if not isinstance(user, dict):
|
||||
@@ -308,20 +306,20 @@ async def ensure_oauth_user(
|
||||
unique_id: str,
|
||||
display_name: str,
|
||||
) -> UserModel:
|
||||
auth = AuthSchema(db=db, user=None, tenant_id=1, check_data_scope=False)
|
||||
auth = AuthSchema.anonymous(db=db)
|
||||
username = _username_for_oauth(provider, unique_id)
|
||||
existing = await UserCRUD(auth).get(username=username)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
reg = UserRegisterSchema(
|
||||
reg = UserCreateSchema(
|
||||
username=username,
|
||||
password=secrets.token_urlsafe(24),
|
||||
name=(display_name or username)[:32],
|
||||
role_ids=list(settings.OAUTH_DEFAULT_ROLE_IDS),
|
||||
)
|
||||
try:
|
||||
await UserService(auth).register(data=reg)
|
||||
await UserService(auth).create(data=reg)
|
||||
except Exception:
|
||||
# 并发创建可能触发唯一约束冲突,回退到再次查询
|
||||
existing = await UserCRUD(auth).get(username=username)
|
||||
@@ -379,17 +377,19 @@ async def complete_oauth_login(
|
||||
raise CustomException(msg="不支持的 OAuth 渠道")
|
||||
|
||||
user = await ensure_oauth_user(db=db, provider=provider, unique_id=uid, display_name=name)
|
||||
if user.status == 1:
|
||||
raise CustomException(msg="用户已被停用")
|
||||
try:
|
||||
if user.status == 1:
|
||||
raise CustomException(msg="用户已被停用")
|
||||
|
||||
user = await UserCRUD(AuthSchema(db=db, user=None, tenant_id=1, check_data_scope=False)).update_last_login_crud(id=user.id)
|
||||
if not user:
|
||||
raise CustomException(msg="用户不存在")
|
||||
user = await UserCRUD(AuthSchema.anonymous(db=db)).update_last_login(id=user.id)
|
||||
if not user:
|
||||
raise CustomException(msg="用户不存在")
|
||||
|
||||
login_type = f"oauth_{provider}"
|
||||
token = await LoginService.create_token(request=request, redis=redis, user=user, login_type=login_type)
|
||||
await rc.delete(f"{STATE_PREFIX}{state}")
|
||||
return token, frontend
|
||||
login_type = f"oauth_{provider}"
|
||||
token = await LoginService.create_token(request=request, redis=redis, user=user, login_type=login_type)
|
||||
return token, frontend
|
||||
finally:
|
||||
await rc.delete(f"{STATE_PREFIX}{state}")
|
||||
|
||||
|
||||
async def save_oauth_state(
|
||||
@@ -403,7 +403,7 @@ async def save_oauth_state(
|
||||
ok = await rc.set(
|
||||
f"{STATE_PREFIX}{state}",
|
||||
json.dumps({"provider": provider, "frontend_redirect": frontend_redirect}),
|
||||
expire=STATE_TTL_SECONDS,
|
||||
expire=settings.OAUTH_STATE_TTL,
|
||||
)
|
||||
if not ok:
|
||||
raise CustomException(msg="缓存 OAuth 状态失败")
|
||||
@@ -423,12 +423,12 @@ def oauth_service_error_redirect(frontend_base: str, message: str) -> str:
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OAuthProvider",
|
||||
"STATE_PREFIX",
|
||||
"OAuthProvider",
|
||||
"_callback_url",
|
||||
"build_authorize_url",
|
||||
"complete_oauth_login",
|
||||
"save_oauth_state",
|
||||
"_callback_url",
|
||||
"oauth_service_frontend_redirect_from_token",
|
||||
"oauth_service_error_redirect",
|
||||
"oauth_service_frontend_redirect_from_token",
|
||||
"save_oauth_state",
|
||||
]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
from app.core.base_schema import JWTOutSchema
|
||||
|
||||
@@ -13,26 +15,6 @@ class CaptchaOutSchema(BaseModel):
|
||||
img_base: str = Field(..., min_length=1, description="Base64编码的验证码图片")
|
||||
|
||||
|
||||
class AutoLoginUserSchema(BaseModel):
|
||||
"""免登录用户信息模型"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int = Field(..., description="用户ID")
|
||||
username: str = Field(..., description="用户名")
|
||||
name: str = Field(..., description="用户姓名")
|
||||
avatar: str | None = Field(default=None, description="头像")
|
||||
|
||||
|
||||
class AutoLoginTokenSchema(BaseModel):
|
||||
"""免登录Token响应模型"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
token: str = Field(..., description="免登录Token")
|
||||
user: AutoLoginUserSchema = Field(..., description="用户信息")
|
||||
|
||||
|
||||
class TenantOptionSchema(BaseModel):
|
||||
"""租户选项(用于登录后选择租户)"""
|
||||
|
||||
@@ -63,7 +45,7 @@ class LoginWithTenantsSchema(JWTOutSchema):
|
||||
"""登录响应(含租户列表)"""
|
||||
|
||||
tenants: list[TenantOptionSchema] = Field(default_factory=list, description="可选租户列表")
|
||||
user_info: dict = Field(default_factory=dict, description="用户信息")
|
||||
user_info: dict[str, Any] = Field(default_factory=dict, description="用户信息")
|
||||
|
||||
|
||||
class TenantRegisterSchema(BaseModel):
|
||||
@@ -71,7 +53,7 @@ class TenantRegisterSchema(BaseModel):
|
||||
|
||||
username: str = Field(..., min_length=3, max_length=32, description="登录账号")
|
||||
password: str = Field(..., min_length=6, max_length=128, description="登录密码")
|
||||
email: str = Field(..., max_length=128, description="邮箱(用于接收通知)")
|
||||
email: EmailStr = Field(..., max_length=128, description="邮箱(用于接收通知)")
|
||||
tenant_name: str | None = Field(default=None, max_length=100, description="企业/团队名称(可选,默认:{用户名}的租户)")
|
||||
|
||||
|
||||
@@ -86,3 +68,43 @@ class TenantRegisterOutSchema(BaseModel):
|
||||
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="目标租户名称")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
@@ -9,7 +8,6 @@ from fastapi import BackgroundTasks, Request
|
||||
from redis.asyncio.client import Redis
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.module_monitor.online.schema import OnlineOutSchema
|
||||
from app.api.v1.module_system.user.crud import UserCRUD
|
||||
from app.api.v1.module_system.user.model import UserModel
|
||||
from app.common.enums import RedisInitKeyConfig
|
||||
@@ -18,8 +16,6 @@ from app.core.base_schema import (
|
||||
AuthSchema,
|
||||
JWTOutSchema,
|
||||
JWTPayloadSchema,
|
||||
LogoutPayloadSchema,
|
||||
RefreshTokenPayloadSchema,
|
||||
)
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import logger
|
||||
@@ -35,9 +31,9 @@ from app.utils.hash_bcrpy_util import PwdUtil
|
||||
from app.utils.ip_local_util import IpLocalUtil, get_client_ip
|
||||
|
||||
from .schema import (
|
||||
AutoLoginTokenSchema,
|
||||
AutoLoginUserSchema,
|
||||
CaptchaOutSchema,
|
||||
EnterPlatformOutSchema,
|
||||
ImpersonateOutSchema,
|
||||
LoginWithTenantsSchema,
|
||||
SelectTenantOutSchema,
|
||||
TenantOptionSchema,
|
||||
@@ -60,14 +56,13 @@ async def _write_login_log(
|
||||
"""写入登录日志;返回日志 ID(用于后台补全归属地)。"""
|
||||
from app.api.v1.module_system.log.crud import LoginLogCRUD
|
||||
from app.api.v1.module_system.log.schema import LoginLogCreateSchema
|
||||
from app.core.base_schema import AuthSchema
|
||||
from app.core.database import async_db_session
|
||||
|
||||
try:
|
||||
async with async_db_session() as session:
|
||||
async with session.begin():
|
||||
_auth = AuthSchema(db=session, check_data_scope=False)
|
||||
obj = await LoginLogCRUD(_auth).create(data=LoginLogCreateSchema(
|
||||
async with async_db_session() as session, session.begin():
|
||||
_auth = AuthSchema.anonymous(db=session)
|
||||
obj = await LoginLogCRUD(_auth).create(
|
||||
data=LoginLogCreateSchema(
|
||||
username=username,
|
||||
status=status,
|
||||
login_ip=login_ip,
|
||||
@@ -75,20 +70,20 @@ async def _write_login_log(
|
||||
request_os=request_os,
|
||||
request_browser=request_browser,
|
||||
msg=msg,
|
||||
))
|
||||
return obj.id if obj else None
|
||||
),
|
||||
)
|
||||
return obj.id if obj else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _async_fill_login_location(
|
||||
redis, login_log_id: int, ip: str | None
|
||||
) -> None:
|
||||
async def _async_fill_login_location(redis, login_log_id: int, ip: str | None) -> None:
|
||||
"""后台异步补全登录日志的归属地。"""
|
||||
if not ip:
|
||||
return
|
||||
try:
|
||||
location = await IpLocalUtil.resolve_location_async(redis, ip)
|
||||
logger.info(f"异步解析IP归属地结果: ip={ip}, log_id={login_log_id}, location={location}")
|
||||
if location == "归属地查询中" or not location:
|
||||
return
|
||||
from sqlalchemy import update as sa_update
|
||||
@@ -96,27 +91,17 @@ async def _async_fill_login_location(
|
||||
from app.api.v1.module_system.log.model import LoginLogModel
|
||||
from app.core.database import async_db_session
|
||||
|
||||
async with async_db_session() as session:
|
||||
async with session.begin():
|
||||
await session.execute(
|
||||
sa_update(LoginLogModel)
|
||||
.where(LoginLogModel.id == login_log_id)
|
||||
.values(login_location=location)
|
||||
)
|
||||
async with async_db_session() as session, session.begin():
|
||||
await session.execute(sa_update(LoginLogModel).where(LoginLogModel.id == login_log_id).values(login_location=location))
|
||||
logger.info(f"登录日志归属地已更新: log_id={login_log_id}, location={location}")
|
||||
except Exception as e:
|
||||
from app.core.logger import logger
|
||||
logger.warning(f"异步补全登录归属地失败: {e}")
|
||||
|
||||
|
||||
def _resolve_request_ip(request: Request) -> str | None:
|
||||
"""从请求中解析客户端真实 IP。"""
|
||||
return get_client_ip(request)
|
||||
|
||||
|
||||
class LoginService:
|
||||
"""登录认证服务"""
|
||||
|
||||
def __init__(self, auth: AuthSchema | None = None) -> None:
|
||||
def __init__(self, auth: AuthSchema) -> None:
|
||||
self.auth = auth
|
||||
|
||||
@classmethod
|
||||
@@ -129,8 +114,8 @@ class LoginService:
|
||||
db: AsyncSession,
|
||||
) -> LoginWithTenantsSchema:
|
||||
"""用户认证"""
|
||||
ua_result = ua_parser.parse(request.headers.get("user-agent"))
|
||||
request_ip = _resolve_request_ip(request)
|
||||
ua_result = ua_parser.parse(request.headers.get("user-agent") or "")
|
||||
request_ip = get_client_ip(request)
|
||||
login_location = await IpLocalUtil.resolve_location_for_log(redis, request_ip)
|
||||
_login_os = ua_result.os.family if ua_result.os else "Unknown"
|
||||
_login_browser = ua_result.user_agent.family if ua_result.user_agent else "Unknown"
|
||||
@@ -148,7 +133,7 @@ class LoginService:
|
||||
captcha=login_form.captcha,
|
||||
)
|
||||
|
||||
auth = AuthSchema(db=db, check_data_scope=False)
|
||||
auth = AuthSchema.anonymous(db=db)
|
||||
user = await UserCRUD(auth).get(username=login_form.username)
|
||||
|
||||
if not user:
|
||||
@@ -218,7 +203,7 @@ class LoginService:
|
||||
login_type=login_form.login_type,
|
||||
)
|
||||
|
||||
tenants_auth = AuthSchema(db=db, user=user, tenant_id=user.tenant_id, check_data_scope=False)
|
||||
tenants_auth = AuthSchema(db=db, user=user, check_data_scope=False)
|
||||
tenants = await LoginService(tenants_auth).get_user_tenants(user_id=user.id)
|
||||
|
||||
user_info = {
|
||||
@@ -255,8 +240,8 @@ class LoginService:
|
||||
async def create_token(cls, request: Request, redis: Redis, user: UserModel, login_type: str) -> JWTOutSchema:
|
||||
"""创建访问令牌和刷新令牌"""
|
||||
session_id = str(uuid.uuid4())
|
||||
ua_result = ua_parser.parse(request.headers.get("user-agent"))
|
||||
request_ip = _resolve_request_ip(request)
|
||||
ua_result = ua_parser.parse(request.headers.get("user-agent") or "")
|
||||
request_ip = get_client_ip(request)
|
||||
|
||||
login_location = await IpLocalUtil.resolve_location_for_log(redis, request_ip)
|
||||
|
||||
@@ -277,20 +262,56 @@ class LoginService:
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
session_info = OnlineOutSchema(
|
||||
session_id=session_id,
|
||||
user_id=user.id,
|
||||
tenant_id=user.tenant_id,
|
||||
is_superuser=user.is_superuser,
|
||||
name=user.name,
|
||||
user_name=user.username,
|
||||
ipaddr=request_ip,
|
||||
login_location=login_location,
|
||||
os=ua_result.os.family if ua_result.os else "Unknown",
|
||||
browser=ua_result.user_agent.family if ua_result.user_agent else "Unknown",
|
||||
login_time=user.last_login,
|
||||
login_type=login_type,
|
||||
).model_dump_json()
|
||||
tenant_status = getattr(user.tenant, "status", 0) if hasattr(user, "tenant") and user.tenant else 0
|
||||
|
||||
permissions = []
|
||||
permissions_with_menu = {}
|
||||
menu_ids = []
|
||||
data_scopes = []
|
||||
custom_dept_ids = []
|
||||
if not user.is_superuser and hasattr(user, "roles"):
|
||||
for role in user.roles:
|
||||
if role and role.status == 0 and 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)
|
||||
|
||||
session_dict = {
|
||||
"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,
|
||||
"user_name": user.username,
|
||||
"dept_id": user.dept_id,
|
||||
"mobile": user.mobile,
|
||||
"email": user.email,
|
||||
"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,
|
||||
"ipaddr": request_ip,
|
||||
"login_location": login_location,
|
||||
"os": ua_result.os.family if ua_result.os else "Unknown",
|
||||
"browser": ua_result.user_agent.family if ua_result.user_agent else "Unknown",
|
||||
"login_time": user.last_login,
|
||||
"login_type": login_type,
|
||||
}
|
||||
session_info = json.dumps(session_dict, default=str)
|
||||
|
||||
# 会话信息存 Redis(完整 JSON),JWT sub 仅含 session_id
|
||||
await RedisCURD(redis).set(
|
||||
@@ -304,14 +325,14 @@ class LoginService:
|
||||
sub=session_id,
|
||||
is_refresh=False,
|
||||
exp=now + access_expires,
|
||||
)
|
||||
),
|
||||
)
|
||||
refresh_token = create_access_token(
|
||||
payload=JWTPayloadSchema(
|
||||
sub=session_id,
|
||||
is_refresh=True,
|
||||
exp=now + refresh_expires,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
await RedisCURD(redis).set(
|
||||
@@ -338,17 +359,15 @@ class LoginService:
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
redis: Redis,
|
||||
refresh_token: RefreshTokenPayloadSchema,
|
||||
refresh_token: str,
|
||||
) -> JWTOutSchema:
|
||||
"""刷新访问令牌"""
|
||||
token_payload: JWTPayloadSchema = decode_access_token(token=refresh_token.refresh_token)
|
||||
token_payload: JWTPayloadSchema = decode_access_token(token=refresh_token)
|
||||
if not token_payload.is_refresh:
|
||||
raise CustomException(msg="非法凭证,请传入刷新令牌")
|
||||
|
||||
session_id = token_payload.sub
|
||||
session_info = await RedisCURD(redis).get(
|
||||
f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}"
|
||||
)
|
||||
session_info = await RedisCURD(redis).get(f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}")
|
||||
if not session_info:
|
||||
raise CustomException(msg="会话已过期,请重新登录")
|
||||
|
||||
@@ -357,7 +376,7 @@ class LoginService:
|
||||
if not session_id or not user_id:
|
||||
raise CustomException(msg="非法凭证,无法获取会话编号或用户ID")
|
||||
|
||||
auth = AuthSchema(db=db, check_data_scope=False)
|
||||
auth = AuthSchema.anonymous(db=db)
|
||||
user = await UserCRUD(auth).get(id=user_id)
|
||||
if not user:
|
||||
raise CustomException(msg="刷新token失败,用户不存在")
|
||||
@@ -379,7 +398,7 @@ class LoginService:
|
||||
sub=session_id,
|
||||
is_refresh=False,
|
||||
exp=now + access_expires,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
refresh_token_new = create_access_token(
|
||||
@@ -387,7 +406,7 @@ class LoginService:
|
||||
sub=session_id,
|
||||
is_refresh=True,
|
||||
exp=now + refresh_expires,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
await RedisCURD(redis).set(
|
||||
@@ -410,9 +429,9 @@ class LoginService:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def logout(redis: Redis, token: LogoutPayloadSchema) -> bool:
|
||||
async def logout(redis: Redis, token: str) -> bool:
|
||||
"""退出登录"""
|
||||
payload: JWTPayloadSchema = decode_access_token(token=token.token)
|
||||
payload: JWTPayloadSchema = decode_access_token(token=token)
|
||||
session_id = payload.sub
|
||||
|
||||
if not session_id:
|
||||
@@ -435,11 +454,15 @@ class LoginService:
|
||||
|
||||
from app.api.v1.module_platform.tenant.model import TenantModel, TenantUserModel
|
||||
|
||||
uid = user_id or (self.auth.user.id if self.auth.user else None)
|
||||
user = self.auth.user
|
||||
if not user:
|
||||
raise CustomException(msg="未认证用户")
|
||||
|
||||
uid = user_id or user.id
|
||||
if not uid:
|
||||
return []
|
||||
|
||||
if self.auth.user and self.auth.user.is_superuser:
|
||||
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.auth.db.execute(stmt)
|
||||
tenant_objs = result.scalars().all()
|
||||
@@ -470,14 +493,15 @@ class LoginService:
|
||||
|
||||
from app.api.v1.module_platform.tenant.model import TenantModel, TenantUserModel
|
||||
|
||||
if not self.auth.user:
|
||||
user = self.auth.user
|
||||
if not user:
|
||||
raise CustomException(msg="未认证用户")
|
||||
|
||||
if not self.auth.user.is_superuser:
|
||||
if not user.is_superuser:
|
||||
exist_stmt = (
|
||||
select(TenantUserModel)
|
||||
.where(
|
||||
TenantUserModel.user_id == self.auth.user.id,
|
||||
TenantUserModel.user_id == user.id,
|
||||
TenantUserModel.tenant_id == tenant_id,
|
||||
)
|
||||
.limit(1)
|
||||
@@ -519,7 +543,7 @@ class LoginService:
|
||||
sub=session_id,
|
||||
is_refresh=False,
|
||||
exp=now + access_expires,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
await RedisCURD(redis).set(
|
||||
@@ -533,7 +557,7 @@ class LoginService:
|
||||
sub=session_id,
|
||||
is_refresh=True,
|
||||
exp=now + refresh_expires,
|
||||
)
|
||||
),
|
||||
)
|
||||
await RedisCURD(redis).set(
|
||||
key=f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}",
|
||||
@@ -545,7 +569,7 @@ class LoginService:
|
||||
|
||||
set_current_tenant(tenant_id)
|
||||
|
||||
logger.info(f"用户 {self.auth.user.username}(id={self.auth.user.id}) 切换到租户 {tenant.name}(id={tenant_id})")
|
||||
logger.info(f"用户 {user.username}(id={user.id}) 切换到租户 {tenant.name}(id={tenant_id})")
|
||||
|
||||
return SelectTenantOutSchema(
|
||||
access_token=new_access_token,
|
||||
@@ -553,6 +577,157 @@ class LoginService:
|
||||
expires_in=int(access_expires.total_seconds()),
|
||||
)
|
||||
|
||||
async def enter_platform(
|
||||
self,
|
||||
request: Request,
|
||||
redis: Redis,
|
||||
) -> EnterPlatformOutSchema:
|
||||
"""进入平台管理模式:清除会话中的 tenant_id,返回平台作用域 JWT"""
|
||||
user = self.auth.user
|
||||
if not user:
|
||||
raise CustomException(msg="未认证用户")
|
||||
|
||||
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["tenant_id"] = 0
|
||||
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()),
|
||||
)
|
||||
|
||||
from app.core.request_context import clear_current_tenant
|
||||
|
||||
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:
|
||||
"""平台管理员代签入:以指定租户身份登录(仅超级管理员可用)"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.v1.module_platform.tenant.model import TenantModel
|
||||
|
||||
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.auth.db.execute(tenant_stmt)
|
||||
tenant = result.scalar_one_or_none()
|
||||
if not tenant:
|
||||
raise CustomException(msg="租户不存在")
|
||||
|
||||
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["tenant_id"] = tenant_id
|
||||
session_info["is_impersonate"] = True
|
||||
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()),
|
||||
)
|
||||
|
||||
from app.core.request_context import set_current_tenant
|
||||
|
||||
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:
|
||||
"""验证码服务"""
|
||||
@@ -597,143 +772,10 @@ class CaptchaService:
|
||||
return True
|
||||
|
||||
|
||||
class AutoLoginService:
|
||||
"""免登录服务"""
|
||||
|
||||
AUTO_LOGIN_PREFIX = "fastapiadmin:auto_login:"
|
||||
TOKEN_EXPIRE = 300
|
||||
|
||||
@classmethod
|
||||
async def get_auto_login_users(cls, db: AsyncSession, tenant_id: int | None = None) -> list[AutoLoginUserSchema]:
|
||||
"""获取免登录用户列表"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.v1.module_system.user.model import UserModel
|
||||
|
||||
stmt = select(UserModel).where(UserModel.status == 0)
|
||||
if tenant_id is not None:
|
||||
stmt = stmt.where(UserModel.tenant_id == tenant_id)
|
||||
stmt = stmt.order_by(UserModel.id)
|
||||
result = await db.execute(stmt)
|
||||
users = result.scalars().all()
|
||||
|
||||
return [
|
||||
AutoLoginUserSchema(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
name=user.name,
|
||||
avatar=user.avatar,
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def create_auto_login_token(
|
||||
cls,
|
||||
redis: Redis,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
tenant_id: int | None = None,
|
||||
) -> AutoLoginTokenSchema:
|
||||
"""创建免登录Token"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.v1.module_system.user.model import UserModel
|
||||
|
||||
stmt = select(UserModel).where(UserModel.id == user_id)
|
||||
if tenant_id is not None:
|
||||
stmt = stmt.where(UserModel.tenant_id == tenant_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise CustomException(msg="用户不存在")
|
||||
|
||||
if user.status == 1:
|
||||
raise CustomException(msg="用户已被停用")
|
||||
|
||||
import uuid
|
||||
|
||||
token = str(uuid.uuid4())
|
||||
token_key = f"{cls.AUTO_LOGIN_PREFIX}{token}"
|
||||
|
||||
token_data = {
|
||||
"user_id": user.id,
|
||||
"username": user.username,
|
||||
"tenant_id": user.tenant_id,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
}
|
||||
await RedisCURD(redis).set(
|
||||
key=token_key,
|
||||
value=json.dumps(token_data),
|
||||
expire=cls.TOKEN_EXPIRE,
|
||||
)
|
||||
|
||||
logger.info(f"创建免登录Token成功,用户:{user.username}")
|
||||
|
||||
return AutoLoginTokenSchema(
|
||||
token=token,
|
||||
user=AutoLoginUserSchema(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
name=user.name,
|
||||
avatar=user.avatar,
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def auto_login(
|
||||
cls,
|
||||
request: Request,
|
||||
redis: Redis,
|
||||
db: AsyncSession,
|
||||
token: str,
|
||||
tenant_id: int | None = None,
|
||||
) -> JWTOutSchema:
|
||||
"""免登录"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.v1.module_system.user.model import UserModel
|
||||
|
||||
token_key = f"{cls.AUTO_LOGIN_PREFIX}{token}"
|
||||
token_data_str = await RedisCURD(redis).get(token_key)
|
||||
|
||||
if not token_data_str:
|
||||
raise CustomException(msg="免登录Token已过期或无效")
|
||||
|
||||
if isinstance(token_data_str, bytes):
|
||||
token_data_str = token_data_str.decode("utf-8")
|
||||
|
||||
token_data = json.loads(token_data_str)
|
||||
user_id = token_data.get("user_id")
|
||||
token_tenant_id = token_data.get("tenant_id")
|
||||
|
||||
stmt = select(UserModel).where(UserModel.id == user_id)
|
||||
effective_tenant_id = tenant_id if tenant_id is not None else token_tenant_id
|
||||
if effective_tenant_id is not None:
|
||||
stmt = stmt.where(UserModel.tenant_id == effective_tenant_id)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise CustomException(msg="用户不存在")
|
||||
|
||||
if user.status == 1:
|
||||
raise CustomException(msg="用户已被停用")
|
||||
|
||||
await RedisCURD(redis).delete(token_key)
|
||||
|
||||
jwt_token = await LoginService.create_token(request=request, redis=redis, user=user, login_type="PC端")
|
||||
|
||||
logger.info(f"用户{user.username}免登录成功")
|
||||
|
||||
return jwt_token
|
||||
|
||||
|
||||
class TenantRegisterService:
|
||||
"""PRD §4.5 租户自助注册:一次性创建租户 + 管理员 + owner 角色 + 菜单分配"""
|
||||
|
||||
DEFAULT_TRIAL_DAYS = 7
|
||||
DEFAULT_TRIAL_DAYS: int = settings.TENANT_TRIAL_DAYS
|
||||
|
||||
@classmethod
|
||||
async def register(
|
||||
@@ -749,7 +791,7 @@ class TenantRegisterService:
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.api.v1.module_platform.package.model import PackageMenuModel, PackageModel
|
||||
from app.api.v1.module_platform.tenant.model import TenantModel
|
||||
from app.api.v1.module_platform.tenant.model import TenantModel, TenantUserModel
|
||||
from app.api.v1.module_system.role.model import RoleMenusModel, RoleModel
|
||||
from app.api.v1.module_system.user.model import UserModel, UserRolesModel
|
||||
|
||||
@@ -788,6 +830,7 @@ class TenantRegisterService:
|
||||
await db.flush()
|
||||
|
||||
user = UserModel(
|
||||
name=username,
|
||||
username=username,
|
||||
password=PwdUtil.hash_password(password),
|
||||
email=email,
|
||||
@@ -797,6 +840,15 @@ class TenantRegisterService:
|
||||
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",
|
||||
@@ -825,11 +877,6 @@ class TenantRegisterService:
|
||||
await db.rollback()
|
||||
raise CustomException(msg="租户编码或用户名已被占用,请重试")
|
||||
|
||||
try:
|
||||
await cls._send_welcome_email(email, username, tenant.name, trial_end)
|
||||
except Exception:
|
||||
logger.warning(f"注册欢迎邮件发送失败: {email}")
|
||||
|
||||
return TenantRegisterOutSchema(
|
||||
user_id=user.id,
|
||||
username=username,
|
||||
@@ -841,37 +888,52 @@ class TenantRegisterService:
|
||||
message="注册成功",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _send_welcome_email(cls, to_email: str, username: str, tenant_name: str, trial_end: datetime) -> None:
|
||||
"""发送欢迎邮件(不阻塞注册流程)。"""
|
||||
from app.api.v1.module_platform.email.crud import EmailConfigCRUD
|
||||
from app.core.base_schema import AuthSchema
|
||||
from app.core.database import async_db_session
|
||||
from app.utils.email_util import render_template_file, send_email
|
||||
|
||||
async with async_db_session() as _db:
|
||||
cfg = await EmailConfigCRUD(AuthSchema(db=_db, check_data_scope=False)).get_active_default()
|
||||
class TenantLookupService:
|
||||
"""租户查询服务(登录页根据编码查找租户)"""
|
||||
|
||||
if not cfg:
|
||||
logger.info("无可用 SMTP 配置,跳过欢迎邮件")
|
||||
return
|
||||
@staticmethod
|
||||
async def lookup_by_code(db: AsyncSession, code: str) -> dict:
|
||||
from sqlalchemy import select
|
||||
|
||||
html_body = render_template_file("emails/welcome.jinja2", {
|
||||
"tenant_name": tenant_name,
|
||||
"username": username,
|
||||
"trial_end": trial_end.strftime("%Y-%m-%d"),
|
||||
})
|
||||
from app.api.v1.module_platform.tenant.model import TenantModel
|
||||
|
||||
await send_email(
|
||||
smtp_host=cfg.smtp_host,
|
||||
smtp_port=cfg.smtp_port,
|
||||
smtp_user=cfg.smtp_user,
|
||||
smtp_password=cfg.smtp_password,
|
||||
use_tls=cfg.use_tls,
|
||||
from_name=cfg.from_name,
|
||||
to_email=to_email,
|
||||
to_name=username,
|
||||
subject=f"欢迎加入 {tenant_name}!",
|
||||
body_html=html_body,
|
||||
stmt = select(TenantModel).where(
|
||||
TenantModel.code == code,
|
||||
TenantModel.is_deleted.is_(False),
|
||||
)
|
||||
logger.info(f"欢迎邮件已发送至 {to_email}")
|
||||
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:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.v1.module_platform.tenant.model import TenantModel
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query
|
||||
from fastapi import APIRouter, Body, Path, Query, Security, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi_cache import FastAPICache
|
||||
from fastapi_cache.decorator import cache
|
||||
@@ -17,55 +17,61 @@ DeptRouter = APIRouter(route_class=OperationLogRoute, prefix="/dept", tags=["部
|
||||
|
||||
_DEPT_NS = "dept"
|
||||
|
||||
|
||||
@DeptRouter.get("/tree", summary="查询部门树", response_model=ResponseSchema[list[DeptOutSchema]])
|
||||
@cache(expire=300, namespace=_DEPT_NS)
|
||||
async def get_dept_tree_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dept:query"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dept:query"]))],
|
||||
search: Annotated[DeptQueryParam, Query(description="部门查询参数")],
|
||||
) -> JSONResponse:
|
||||
order_by = [{"order": "asc"}]
|
||||
result_dict_tree = await DeptService(auth).tree(search=search, order_by=order_by)
|
||||
return SuccessResponse(data=result_dict_tree, msg="查询部门树成功")
|
||||
|
||||
|
||||
@DeptRouter.get("/detail/{id}", summary="查询部门详情", response_model=ResponseSchema[DeptOutSchema])
|
||||
async def get_obj_detail_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dept:detail"]))],
|
||||
id: Annotated[int, Path(description="部门ID")],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dept:detail"]))],
|
||||
id: Annotated[int, Path(description="部门ID", ge=1)],
|
||||
) -> JSONResponse:
|
||||
result_dict = await DeptService(auth).detail(id=id)
|
||||
return SuccessResponse(data=result_dict, msg="查询部门详情成功")
|
||||
|
||||
@DeptRouter.post("/create", summary="创建部门", response_model=ResponseSchema[DeptOutSchema])
|
||||
|
||||
@DeptRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建部门", response_model=ResponseSchema[DeptOutSchema])
|
||||
async def create_obj_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dept:create"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dept:create"]))],
|
||||
data: Annotated[DeptCreateSchema, Body(description="部门创建参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await DeptService(auth).create(data=data)
|
||||
await FastAPICache.clear(namespace=_DEPT_NS)
|
||||
return SuccessResponse(data=result_dict, msg="创建部门成功")
|
||||
|
||||
|
||||
@DeptRouter.put("/update/{id}", summary="修改部门", response_model=ResponseSchema[DeptOutSchema])
|
||||
async def update_obj_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dept:update"]))],
|
||||
id: Annotated[int, Path(description="部门ID")],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dept:update"]))],
|
||||
id: Annotated[int, Path(description="部门ID", ge=1)],
|
||||
data: Annotated[DeptUpdateSchema, Body(description="部门修改参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await DeptService(auth).update(id=id, data=data)
|
||||
await FastAPICache.clear(namespace=_DEPT_NS)
|
||||
return SuccessResponse(data=result_dict, msg="修改部门成功")
|
||||
|
||||
|
||||
@DeptRouter.delete("/delete", summary="删除部门", response_model=ResponseSchema[None])
|
||||
async def delete_obj_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dept:delete"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dept:delete"]))],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
) -> JSONResponse:
|
||||
await DeptService(auth).delete(ids=ids)
|
||||
await FastAPICache.clear(namespace=_DEPT_NS)
|
||||
return SuccessResponse(msg="删除部门成功")
|
||||
|
||||
|
||||
@DeptRouter.patch("/status/batch", summary="批量修改部门状态", response_model=ResponseSchema[None])
|
||||
async def batch_set_available_obj_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dept:patch"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dept:patch"]))],
|
||||
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
|
||||
) -> JSONResponse:
|
||||
await DeptService(auth).batch_set_available(data=data)
|
||||
|
||||
@@ -12,8 +12,7 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class DeptModel(ModelMixin, TenantMixin, UserMixin):
|
||||
"""
|
||||
部门模型
|
||||
"""部门模型
|
||||
"""
|
||||
|
||||
__tablename__: str = "sys_dept"
|
||||
|
||||
@@ -62,12 +62,12 @@ class DeptTreeOutSchema(DeptOutSchema):
|
||||
class DeptQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
|
||||
"""部门管理查询参数"""
|
||||
|
||||
name: str | None = Field(None, description="部门名称")
|
||||
status: int | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)")
|
||||
name: str | tuple[str, str] | None = Field(None, description="部门名称")
|
||||
status: int | tuple[str, int] | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query_params(self) -> "DeptQueryParam":
|
||||
if self.name:
|
||||
if isinstance(self.name, str):
|
||||
self.name = (QueueEnum.like.value, self.name)
|
||||
if isinstance(self.status, int):
|
||||
self.status = (QueueEnum.eq.value, self.status)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
from app.core.base_schema import AuthSchema, BatchSetAvailable
|
||||
from app.core.exceptions import CustomException
|
||||
from app.utils.common_util import (
|
||||
@@ -19,8 +18,7 @@ from .schema import (
|
||||
|
||||
|
||||
class DeptService:
|
||||
"""
|
||||
部门管理服务
|
||||
"""部门管理服务
|
||||
|
||||
提供部门 CRUD、树形结构查询、级联启/禁用、租户配额检查等业务能力。
|
||||
"""
|
||||
@@ -57,7 +55,10 @@ class DeptService:
|
||||
# 检查租户配额
|
||||
from app.api.v1.module_platform.tenant.service import TenantService
|
||||
|
||||
await TenantService(self.auth).check_quota(self.auth.tenant_id, "dept")
|
||||
user = self.auth.user
|
||||
if not user:
|
||||
raise CustomException(msg="未登录")
|
||||
await TenantService(self.auth).check_quota(user.tenant_id, "dept")
|
||||
|
||||
dept = await DeptCRUD(self.auth).create(data=data)
|
||||
return DeptOutSchema.model_validate(dept)
|
||||
@@ -90,7 +91,7 @@ class DeptService:
|
||||
child_id_map = get_child_id_map(model_list=all_depts)
|
||||
|
||||
for pid in ids:
|
||||
if pid in child_id_map and child_id_map[pid]:
|
||||
if child_id_map.get(pid):
|
||||
raise CustomException(msg="存在子部门,不允许删除父部门")
|
||||
|
||||
await DeptCRUD(self.auth).delete(ids=ids)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi_cache import FastAPICache
|
||||
from fastapi_cache.decorator import cache
|
||||
@@ -28,17 +28,19 @@ DictRouter = APIRouter(route_class=OperationLogRoute, prefix="/dict", tags=["字
|
||||
|
||||
_DICT_TYPE_NS = "dict_type"
|
||||
|
||||
|
||||
@DictRouter.get("/type/detail/{id}", summary="获取字典类型详情", response_model=ResponseSchema[DictTypeOutSchema])
|
||||
async def get_type_detail_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:detail"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dict_type:detail"]))],
|
||||
id: Annotated[int, Path(description="字典类型ID", ge=1)],
|
||||
) -> JSONResponse:
|
||||
result_dict = await DictTypeService(auth).detail(id=id)
|
||||
return SuccessResponse(data=result_dict, msg="获取字典类型详情成功")
|
||||
|
||||
|
||||
@DictRouter.get("/type/list", summary="查询字典类型", response_model=ResponseSchema[PageResultSchema[DictTypeOutSchema]])
|
||||
async def get_type_list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:query"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dict_type:query"]))],
|
||||
page: Annotated[PaginationQueryParam, Query(description="分页查询参数")],
|
||||
search: Annotated[DictTypeQueryParam, Query(description="字典类型查询参数")],
|
||||
) -> JSONResponse:
|
||||
@@ -50,28 +52,31 @@ async def get_type_list_controller(
|
||||
)
|
||||
return SuccessResponse(data=result_dict, msg="查询字典类型列表成功")
|
||||
|
||||
|
||||
@DictRouter.get("/type/optionselect", summary="获取全部字典类型", response_model=ResponseSchema[list[DictTypeOutSchema]])
|
||||
@cache(expire=300, namespace=_DICT_TYPE_NS)
|
||||
async def get_type_optionselect_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:query"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dict_type:query"]))],
|
||||
) -> JSONResponse:
|
||||
result_dict_list = await DictTypeService(auth).get_list()
|
||||
return SuccessResponse(data=result_dict_list, msg="获取字典类型列表成功")
|
||||
|
||||
@DictRouter.post("/type/create", summary="创建字典类型", response_model=ResponseSchema[DictTypeOutSchema])
|
||||
|
||||
@DictRouter.post("/type/create", status_code=status.HTTP_201_CREATED, summary="创建字典类型", response_model=ResponseSchema[DictTypeOutSchema])
|
||||
async def create_type_controller(
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:create"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dict_type:create"]))],
|
||||
data: Annotated[DictTypeCreateSchema, Body(description="字典类型创建参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await DictTypeService(auth).create(redis=redis, data=data)
|
||||
await FastAPICache.clear(namespace=_DICT_TYPE_NS)
|
||||
return SuccessResponse(data=result_dict, msg="创建字典类型成功")
|
||||
|
||||
|
||||
@DictRouter.put("/type/update/{id}", summary="修改字典类型", response_model=ResponseSchema[DictTypeOutSchema])
|
||||
async def update_type_controller(
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:update"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dict_type:update"]))],
|
||||
id: Annotated[int, Path(description="字典类型ID", ge=1)],
|
||||
data: Annotated[DictTypeUpdateSchema, Body(description="字典类型修改参数")],
|
||||
) -> JSONResponse:
|
||||
@@ -79,30 +84,33 @@ async def update_type_controller(
|
||||
await FastAPICache.clear(namespace=_DICT_TYPE_NS)
|
||||
return SuccessResponse(data=result_dict, msg="修改字典类型成功")
|
||||
|
||||
|
||||
@DictRouter.delete("/type/delete", summary="删除字典类型", response_model=ResponseSchema[None])
|
||||
async def delete_type_controller(
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:delete"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dict_type:delete"]))],
|
||||
ids: Annotated[list[int], Body(description="字典类型ID列表")],
|
||||
) -> JSONResponse:
|
||||
await DictTypeService(auth).delete(redis=redis, ids=ids)
|
||||
await FastAPICache.clear(namespace=_DICT_TYPE_NS)
|
||||
return SuccessResponse(msg="删除字典类型成功")
|
||||
|
||||
|
||||
@DictRouter.patch("/type/status/batch", summary="批量修改字典类型状态", response_model=ResponseSchema[None])
|
||||
async def batch_set_available_dict_type_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:patch"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dict_type:patch"]))],
|
||||
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
|
||||
) -> JSONResponse:
|
||||
await DictTypeService(auth).set_available(data=data)
|
||||
await FastAPICache.clear(namespace=_DICT_TYPE_NS)
|
||||
return SuccessResponse(msg="批量修改字典类型状态成功")
|
||||
|
||||
|
||||
@DictRouter.post("/type/export", summary="导出字典类型")
|
||||
async def export_type_list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_type:export"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dict_type:export"]))],
|
||||
search: Annotated[DictTypeQueryParam, Query(description="字典类型查询参数")],
|
||||
) -> StreamingResponse[bytes]:
|
||||
) -> StreamingResponse:
|
||||
# 获取全量数据并转为dict列表
|
||||
result_dict_list = await DictTypeService(auth).get_list(search=search)
|
||||
export_data = [item.model_dump() for item in result_dict_list]
|
||||
@@ -114,17 +122,19 @@ async def export_type_list_controller(
|
||||
headers={"Content-Disposition": "attachment; filename=dict_type.xlsx"},
|
||||
)
|
||||
|
||||
|
||||
@DictRouter.get("/data/detail/{id}", summary="获取字典数据详情", response_model=ResponseSchema[DictDataOutSchema])
|
||||
async def get_data_detail_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_data:detail"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dict_data:detail"]))],
|
||||
id: Annotated[int, Path(description="字典数据ID", ge=1)],
|
||||
) -> JSONResponse:
|
||||
result_dict = await DictDataService(auth).detail(id=id)
|
||||
return SuccessResponse(data=result_dict, msg="获取字典数据详情成功")
|
||||
|
||||
|
||||
@DictRouter.get("/data/list", summary="查询字典数据", response_model=ResponseSchema[PageResultSchema[DictDataOutSchema]])
|
||||
async def get_data_list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_data:query"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dict_data:query"]))],
|
||||
page: Annotated[PaginationQueryParam, Query(description="分页参数")],
|
||||
search: Annotated[DictDataQueryParam, Query(description="字典数据查询参数")],
|
||||
) -> JSONResponse:
|
||||
@@ -139,48 +149,53 @@ async def get_data_list_controller(
|
||||
)
|
||||
return SuccessResponse(data=result_dict, msg="查询字典数据列表成功")
|
||||
|
||||
@DictRouter.post("/data/create", summary="创建字典数据", response_model=ResponseSchema[DictDataOutSchema])
|
||||
|
||||
@DictRouter.post("/data/create", status_code=status.HTTP_201_CREATED, summary="创建字典数据", response_model=ResponseSchema[DictDataOutSchema])
|
||||
async def create_data_controller(
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_data:create"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dict_data:create"]))],
|
||||
data: Annotated[DictDataCreateSchema, Body(description="字典数据创建参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await DictDataService(auth).create(redis=redis, data=data)
|
||||
return SuccessResponse(data=result_dict, msg="创建字典数据成功")
|
||||
|
||||
|
||||
@DictRouter.put("/data/update/{id}", summary="修改字典数据", response_model=ResponseSchema[DictDataOutSchema])
|
||||
async def update_data_controller(
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_data:update"]))],
|
||||
id: Annotated[int, Path(description="字典数据ID")],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dict_data:update"]))],
|
||||
id: Annotated[int, Path(description="字典数据ID", ge=1)],
|
||||
data: Annotated[DictDataUpdateSchema, Body(description="字典数据修改参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await DictDataService(auth).update(redis=redis, id=id, data=data)
|
||||
return SuccessResponse(data=result_dict, msg="修改字典数据成功")
|
||||
|
||||
|
||||
@DictRouter.delete("/data/delete", summary="删除字典数据", response_model=ResponseSchema[None])
|
||||
async def delete_data_controller(
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_data:delete"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dict_data:delete"]))],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
) -> JSONResponse:
|
||||
await DictDataService(auth).delete(redis=redis, ids=ids)
|
||||
return SuccessResponse(msg="删除字典数据成功")
|
||||
|
||||
|
||||
@DictRouter.patch("/data/status/batch", summary="批量修改字典数据状态", response_model=ResponseSchema[None])
|
||||
async def batch_set_available_dict_data_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_data:patch"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dict_data:patch"]))],
|
||||
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
|
||||
) -> JSONResponse:
|
||||
await DictDataService(auth).set_available(data=data)
|
||||
return SuccessResponse(msg="批量修改字典数据状态成功")
|
||||
|
||||
@DictRouter.post("/data/export", summary="导出字典数据", response_model=StreamResponse[bytes])
|
||||
|
||||
@DictRouter.post("/data/export", summary="导出字典数据")
|
||||
async def export_data_list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:dict_data:export"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dict_data:export"]))],
|
||||
page: Annotated[PaginationQueryParam, Query(description="分页参数")],
|
||||
search: Annotated[DictDataQueryParam, Query(description="字典数据查询参数")],
|
||||
) -> StreamingResponse[bytes]:
|
||||
) -> StreamingResponse:
|
||||
result_dict_list = await DictDataService(auth).get_list(search=search, order_by=page.order_by)
|
||||
export_data = [item.model_dump() for item in result_dict_list]
|
||||
export_result = DictDataService.export(data_list=export_data)
|
||||
@@ -191,6 +206,7 @@ async def export_data_list_controller(
|
||||
headers={"Content-Disposition": "attachment; filename=dice_data.xlsx"},
|
||||
)
|
||||
|
||||
|
||||
@DictRouter.get("/data/info/{dict_type}", summary="根据字典类型获取数据", response_model=ResponseSchema[list[DictDataOutSchema]])
|
||||
async def get_init_dict_data_controller(
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from app.api.v1.module_system.dict.model import DictDataModel, DictTypeModel
|
||||
from app.api.v1.module_system.dict.schema import (
|
||||
@@ -15,8 +16,7 @@ class DictTypeCRUD(CRUDBase[DictTypeModel, DictTypeCreateSchema, DictTypeUpdateS
|
||||
"""数据字典类型数据层"""
|
||||
|
||||
def __init__(self, auth: AuthSchema) -> None:
|
||||
"""
|
||||
初始化数据字典类型数据层。
|
||||
"""初始化数据字典类型数据层。
|
||||
|
||||
参数:
|
||||
- auth (AuthSchema): 认证信息模型(含 DB 会话等上下文)。
|
||||
@@ -31,8 +31,7 @@ class DictDataCRUD(CRUDBase[DictDataModel, DictDataCreateSchema, DictDataUpdateS
|
||||
"""数据字典数据层"""
|
||||
|
||||
def __init__(self, auth: AuthSchema) -> None:
|
||||
"""
|
||||
初始化数据字典项数据层。
|
||||
"""初始化数据字典项数据层。
|
||||
|
||||
参数:
|
||||
- auth (AuthSchema): 认证信息模型(含 DB 会话等上下文)。
|
||||
@@ -43,8 +42,7 @@ class DictDataCRUD(CRUDBase[DictDataModel, DictDataCreateSchema, DictDataUpdateS
|
||||
super().__init__(model=DictDataModel, auth=auth)
|
||||
|
||||
async def batch_delete(self, ids: list[int], exclude_system: bool = True) -> int:
|
||||
"""
|
||||
批量删除数据字典数据
|
||||
"""批量删除数据字典数据
|
||||
|
||||
参数:
|
||||
- ids (list[int]): 数据字典数据ID列表
|
||||
@@ -58,7 +56,7 @@ class DictDataCRUD(CRUDBase[DictDataModel, DictDataCreateSchema, DictDataUpdateS
|
||||
search={
|
||||
"id__in": ids,
|
||||
"remark__contains": "系统默认",
|
||||
}
|
||||
},
|
||||
)
|
||||
system_ids = [item.id for item in system_data]
|
||||
ids = [id for id in ids if id not in system_ids]
|
||||
@@ -68,8 +66,7 @@ class DictDataCRUD(CRUDBase[DictDataModel, DictDataCreateSchema, DictDataUpdateS
|
||||
return len(ids)
|
||||
|
||||
async def get_list_by_dict_type(self, dict_type: str, status: int | None = 0) -> Sequence[DictDataModel]:
|
||||
"""
|
||||
根据字典类型获取字典数据列表
|
||||
"""根据字典类型获取字典数据列表
|
||||
|
||||
参数:
|
||||
- dict_type (str): 字典类型
|
||||
@@ -78,7 +75,7 @@ class DictDataCRUD(CRUDBase[DictDataModel, DictDataCreateSchema, DictDataUpdateS
|
||||
返回:
|
||||
- Sequence[DictDataModel]: 数据字典数据模型序列
|
||||
"""
|
||||
search = {"dict_type": dict_type}
|
||||
search: dict[str, Any] = {"dict_type": dict_type}
|
||||
if status is not None:
|
||||
search["status"] = status
|
||||
return await self.get_list(search=search, order_by=[{"id": "asc"}])
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
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
|
||||
from app.core.base_model import ModelMixin, TenantMixin, UserMixin
|
||||
|
||||
|
||||
class DictTypeModel(ModelMixin, TenantMixin):
|
||||
"""
|
||||
字典类型表
|
||||
class DictTypeModel(ModelMixin, TenantMixin, UserMixin):
|
||||
"""字典类型表
|
||||
|
||||
__platform_data_shared__ = True 表示 tenant_id=1 的平台字典对
|
||||
所有租户可读,但只有平台管理员可写。
|
||||
@@ -14,7 +13,7 @@ class DictTypeModel(ModelMixin, TenantMixin):
|
||||
|
||||
__tablename__: str = "sys_dict_type"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "dict_type"), {"comment": "字典类型表"})
|
||||
__loader_options__: list[str] = ["dict_data_list"]
|
||||
__loader_options__: list[str] = ["dict_data_list", "created_by", "updated_by", "deleted_by", "tenant_by"]
|
||||
__platform_data_shared__: bool = True
|
||||
|
||||
dict_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="字典名称")
|
||||
@@ -28,9 +27,8 @@ class DictTypeModel(ModelMixin, TenantMixin):
|
||||
)
|
||||
|
||||
|
||||
class DictDataModel(ModelMixin, TenantMixin):
|
||||
"""
|
||||
字典数据表
|
||||
class DictDataModel(ModelMixin, TenantMixin, UserMixin):
|
||||
"""字典数据表
|
||||
|
||||
与 DictTypeModel 相同:tenant_id=1 的平台字典数据对
|
||||
所有租户可读,但只有平台管理员可写。
|
||||
@@ -41,7 +39,7 @@ class DictDataModel(ModelMixin, TenantMixin):
|
||||
UniqueConstraint("tenant_id", "dict_type_id", "dict_value", name="uq_dict_data_value"),
|
||||
{"comment": "字典数据表"},
|
||||
)
|
||||
__loader_options__: list[str] = ["dict_type_obj"]
|
||||
__loader_options__: list[str] = ["dict_type_obj", "created_by", "updated_by", "deleted_by", "tenant_by"]
|
||||
__platform_data_shared__: bool = True
|
||||
|
||||
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
|
||||
|
||||
@@ -13,8 +13,7 @@ from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam,
|
||||
|
||||
|
||||
class DictTypeCreateSchema(BaseModel):
|
||||
"""
|
||||
字典类型表对应pydantic模型
|
||||
"""字典类型表对应pydantic模型
|
||||
"""
|
||||
|
||||
dict_name: str = Field(..., min_length=1, max_length=64, description="字典名称")
|
||||
@@ -32,8 +31,7 @@ class DictTypeCreateSchema(BaseModel):
|
||||
@field_validator("dict_name")
|
||||
@classmethod
|
||||
def validate_dict_name(cls, value: str):
|
||||
"""
|
||||
校验字典名称为非空字符串。
|
||||
"""校验字典名称为非空字符串。
|
||||
|
||||
参数:
|
||||
- value (str): 字典名称。
|
||||
@@ -51,8 +49,7 @@ class DictTypeCreateSchema(BaseModel):
|
||||
@field_validator("dict_type")
|
||||
@classmethod
|
||||
def validate_dict_type(cls, value: str):
|
||||
"""
|
||||
校验字典类型:小写字母开头,仅包含小写字母/数字/下划线。
|
||||
"""校验字典类型:小写字母开头,仅包含小写字母/数字/下划线。
|
||||
|
||||
参数:
|
||||
- value (str): 字典类型。
|
||||
@@ -84,15 +81,15 @@ class DictTypeOutSchema(DictTypeCreateSchema, BaseSchema, UserBySchema, TenantBy
|
||||
class DictTypeQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
|
||||
"""字典类型查询参数"""
|
||||
|
||||
dict_name: str | None = Field(default=None, description="字典名称", max_length=100)
|
||||
dict_type: str | None = Field(default=None, description="字典类型", max_length=100)
|
||||
status: int | None = Field(default=None, ge=0, le=1, description="状态(0:启动 1:停用)")
|
||||
dict_name: str | tuple[str, str] | None = Field(default=None, description="字典名称", max_length=100)
|
||||
dict_type: str | tuple[str, str] | None = Field(default=None, description="字典类型", max_length=100)
|
||||
status: int | tuple[str, int] | None = Field(default=None, ge=0, le=1, description="状态(0:启动 1:停用)")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query_params(self) -> "DictTypeQueryParam":
|
||||
if self.dict_name:
|
||||
if isinstance(self.dict_name, str):
|
||||
self.dict_name = (QueueEnum.like.value, self.dict_name)
|
||||
if self.dict_type:
|
||||
if isinstance(self.dict_type, str):
|
||||
self.dict_type = (QueueEnum.eq.value, self.dict_type)
|
||||
if isinstance(self.status, int):
|
||||
self.status = (QueueEnum.eq.value, self.status)
|
||||
@@ -100,8 +97,7 @@ class DictTypeQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
|
||||
|
||||
|
||||
class DictDataCreateSchema(BaseModel):
|
||||
"""
|
||||
字典数据表对应pydantic模型
|
||||
"""字典数据表对应pydantic模型
|
||||
"""
|
||||
|
||||
dict_sort: int = Field(..., ge=1, le=999, description="排序")
|
||||
@@ -124,8 +120,7 @@ class DictDataCreateSchema(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_after(self):
|
||||
"""
|
||||
校验并规范化字典数据字段(标签/键值/类型/类型ID)。
|
||||
"""校验并规范化字典数据字段(标签/键值/类型/类型ID)。
|
||||
|
||||
返回:
|
||||
- DictDataCreateSchema: 校验与去空格后的同一实例。
|
||||
@@ -163,19 +158,19 @@ class DictDataOutSchema(DictDataCreateSchema, BaseSchema, UserBySchema, TenantBy
|
||||
class DictDataQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
|
||||
"""字典数据查询参数"""
|
||||
|
||||
dict_label: str | None = Field(default=None, description="字典标签", max_length=100)
|
||||
dict_type: str | None = Field(default=None, description="字典类型", max_length=100)
|
||||
dict_type_id: int | None = Field(default=None, description="字典类型ID")
|
||||
status: int | None = Field(default=None, ge=0, le=1, description="状态(0:启动 1:停用)")
|
||||
dict_label: str | tuple[str, str] | None = Field(default=None, description="字典标签", max_length=100)
|
||||
dict_type: str | tuple[str, str] | None = Field(default=None, description="字典类型", max_length=100)
|
||||
dict_type_id: int | tuple[str, int] | None = Field(default=None, description="字典类型ID")
|
||||
status: int | tuple[str, int] | None = Field(default=None, ge=0, le=1, description="状态(0:启动 1:停用)")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query_params(self) -> "DictDataQueryParam":
|
||||
if self.dict_label:
|
||||
if isinstance(self.dict_label, str):
|
||||
self.dict_label = (QueueEnum.like.value, self.dict_label)
|
||||
if self.dict_type:
|
||||
if isinstance(self.dict_type, str):
|
||||
self.dict_type = (QueueEnum.eq.value, self.dict_type)
|
||||
if isinstance(self.dict_type_id, int):
|
||||
self.dict_type_id = (QueueEnum.eq.value, self.dict_type_id)
|
||||
if isinstance(self.status, int):
|
||||
self.status = (QueueEnum.eq.value, self.status)
|
||||
if self.dict_type_id is not None:
|
||||
self.dict_type_id = (QueueEnum.eq.value, self.dict_type_id)
|
||||
return self
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
from redis.asyncio.client import Redis
|
||||
|
||||
from app.common.enums import RedisInitKeyConfig
|
||||
from app.core.base_schema import AuthSchema, BatchSetAvailable
|
||||
from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema
|
||||
from app.core.database import async_db_session
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import logger
|
||||
@@ -24,8 +24,7 @@ from .schema import (
|
||||
|
||||
|
||||
class DictTypeService:
|
||||
"""
|
||||
字典类型管理服务
|
||||
"""字典类型管理服务
|
||||
|
||||
设计:实例方法承载「当前用户上下文 (auth)」,``redis`` 仍是方法参数
|
||||
(因为不是每个端点都用到)。调用方写法由 ``XxxService.method_service(auth=...)``
|
||||
@@ -36,8 +35,7 @@ class DictTypeService:
|
||||
self.auth = auth
|
||||
|
||||
async def detail(self, id: int) -> DictTypeOutSchema:
|
||||
"""
|
||||
获取数据字典类型详情
|
||||
"""获取数据字典类型详情
|
||||
|
||||
参数:
|
||||
- id (int): 数据字典类型ID
|
||||
@@ -45,15 +43,15 @@ class DictTypeService:
|
||||
返回:
|
||||
- DictTypeOutSchema: 字典类型响应模型
|
||||
"""
|
||||
return await DictTypeCRUD(self.auth).get_or_404(id=id, out_schema=DictTypeOutSchema)
|
||||
obj = await DictTypeCRUD(self.auth).get_or_404(id=id)
|
||||
return DictTypeOutSchema.model_validate(obj)
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
search: DictTypeQueryParam | None = None,
|
||||
order_by: list[dict] | None = None,
|
||||
) -> list[DictTypeOutSchema]:
|
||||
"""
|
||||
获取数据字典类型列表
|
||||
"""获取数据字典类型列表
|
||||
|
||||
参数:
|
||||
- search (DictTypeQueryParam | None): 搜索条件模型
|
||||
@@ -71,9 +69,8 @@ class DictTypeService:
|
||||
page_size: int,
|
||||
search: DictTypeQueryParam | None = None,
|
||||
order_by: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
分页查询字典类型(数据库 OFFSET/LIMIT)。
|
||||
) -> PageResultSchema[DictTypeOutSchema]:
|
||||
"""分页查询字典类型(数据库 OFFSET/LIMIT)。
|
||||
|
||||
参数:
|
||||
- page_no (int): 页码(从 1 开始)
|
||||
@@ -82,7 +79,7 @@ class DictTypeService:
|
||||
- order_by (list[dict] | None): 排序字段列表
|
||||
|
||||
返回:
|
||||
- dict: 分页结果(结构由 ``CRUD.page`` 返回约定)
|
||||
- PageResultSchema[DictTypeOutSchema]: 分页结果
|
||||
"""
|
||||
offset = (page_no - 1) * page_size
|
||||
return await DictTypeCRUD(self.auth).page(
|
||||
@@ -94,8 +91,7 @@ class DictTypeService:
|
||||
)
|
||||
|
||||
async def create(self, redis: Redis, data: DictTypeCreateSchema) -> DictTypeOutSchema:
|
||||
"""
|
||||
创建数据字典类型
|
||||
"""创建数据字典类型
|
||||
|
||||
参数:
|
||||
- redis (Redis): Redis客户端
|
||||
@@ -132,8 +128,7 @@ class DictTypeService:
|
||||
id: int,
|
||||
data: DictTypeUpdateSchema,
|
||||
) -> DictTypeOutSchema:
|
||||
"""
|
||||
更新数据字典类型
|
||||
"""更新数据字典类型
|
||||
|
||||
参数:
|
||||
- redis (Redis): Redis客户端
|
||||
@@ -191,8 +186,7 @@ class DictTypeService:
|
||||
return new_obj_dict
|
||||
|
||||
async def delete(self, redis: Redis, ids: list[int]) -> None:
|
||||
"""
|
||||
删除数据字典类型
|
||||
"""删除数据字典类型
|
||||
|
||||
参数:
|
||||
- redis (Redis): Redis客户端
|
||||
@@ -225,8 +219,7 @@ class DictTypeService:
|
||||
await DictTypeCRUD(self.auth).delete(ids=ids)
|
||||
|
||||
async def set_available(self, data: BatchSetAvailable) -> None:
|
||||
"""
|
||||
设置数据字典类型状态
|
||||
"""设置数据字典类型状态
|
||||
|
||||
参数:
|
||||
- data (BatchSetAvailable): 批量设置状态模型
|
||||
@@ -238,8 +231,7 @@ class DictTypeService:
|
||||
|
||||
@staticmethod
|
||||
def export(data_list: list[dict]) -> bytes:
|
||||
"""
|
||||
导出数据字典类型列表(无状态工具方法)
|
||||
"""导出数据字典类型列表(无状态工具方法)
|
||||
|
||||
参数:
|
||||
- data_list (list[dict]): 数据字典类型列表
|
||||
@@ -269,8 +261,7 @@ class DictTypeService:
|
||||
|
||||
|
||||
class DictDataService:
|
||||
"""
|
||||
字典数据管理服务
|
||||
"""字典数据管理服务
|
||||
|
||||
设计同 DictTypeService:实例方法 + ``__init__(auth)``。
|
||||
"""
|
||||
@@ -279,8 +270,7 @@ class DictDataService:
|
||||
self.auth = auth
|
||||
|
||||
async def detail(self, id: int) -> DictDataOutSchema:
|
||||
"""
|
||||
获取数据字典数据详情
|
||||
"""获取数据字典数据详情
|
||||
|
||||
参数:
|
||||
- id (int): 数据字典数据ID
|
||||
@@ -288,15 +278,15 @@ class DictDataService:
|
||||
返回:
|
||||
- DictDataOutSchema: 字典数据响应模型
|
||||
"""
|
||||
return await DictDataCRUD(self.auth).get_or_404(id=id, out_schema=DictDataOutSchema)
|
||||
obj = await DictDataCRUD(self.auth).get_or_404(id=id)
|
||||
return DictDataOutSchema.model_validate(obj)
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
search: DictDataQueryParam | None = None,
|
||||
order_by: list[dict] | None = None,
|
||||
) -> list[DictDataOutSchema]:
|
||||
"""
|
||||
获取数据字典数据列表
|
||||
"""获取数据字典数据列表
|
||||
|
||||
参数:
|
||||
- search (DictDataQueryParam | None): 搜索条件模型
|
||||
@@ -314,9 +304,8 @@ class DictDataService:
|
||||
page_size: int,
|
||||
search: DictDataQueryParam | None = None,
|
||||
order_by: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
分页查询字典数据(数据库 OFFSET/LIMIT)。
|
||||
) -> PageResultSchema[DictDataOutSchema]:
|
||||
"""分页查询字典数据(数据库 OFFSET/LIMIT)。
|
||||
|
||||
参数:
|
||||
- page_no (int): 页码(从 1 开始)
|
||||
@@ -325,7 +314,7 @@ class DictDataService:
|
||||
- order_by (list[dict] | None): 排序字段列表
|
||||
|
||||
返回:
|
||||
- dict: 分页结果(结构由 ``CRUD.page`` 返回约定)
|
||||
- PageResultSchema[DictDataOutSchema]: 分页结果
|
||||
"""
|
||||
offset = (page_no - 1) * page_size
|
||||
return await DictDataCRUD(self.auth).page(
|
||||
@@ -338,8 +327,7 @@ class DictDataService:
|
||||
|
||||
@staticmethod
|
||||
async def init_cache(redis: Redis) -> None:
|
||||
"""
|
||||
应用初始化: 获取所有字典类型对应的字典数据信息并按租户缓存(无 auth)。
|
||||
"""应用初始化: 获取所有字典类型对应的字典数据信息并按租户缓存(无 auth)。
|
||||
|
||||
参数:
|
||||
- redis (Redis): Redis客户端
|
||||
@@ -348,35 +336,28 @@ class DictDataService:
|
||||
- None
|
||||
"""
|
||||
try:
|
||||
async with async_db_session() as session:
|
||||
async with session.begin():
|
||||
init_auth = AuthSchema(db=session, check_data_scope=False)
|
||||
obj_list = await DictTypeCRUD(init_auth).get_list()
|
||||
if not obj_list:
|
||||
logger.warning("未找到任何字典类型数据")
|
||||
return
|
||||
async with async_db_session() as session, session.begin():
|
||||
init_auth = AuthSchema.anonymous(db=session)
|
||||
obj_list = await DictTypeCRUD(init_auth).get_list()
|
||||
if not obj_list:
|
||||
logger.warning("未找到任何字典类型数据")
|
||||
return
|
||||
|
||||
for obj in obj_list:
|
||||
dict_type = obj.dict_type
|
||||
tenant_id = obj.tenant_id
|
||||
try:
|
||||
dict_data_list = await DictDataCRUD(init_auth).get_list(
|
||||
search={"dict_type": dict_type, "tenant_id": tenant_id}
|
||||
)
|
||||
dict_data = [
|
||||
DictDataOutSchema.model_validate(row).model_dump(mode="json")
|
||||
for row in dict_data_list
|
||||
if row
|
||||
]
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{tenant_id}:{dict_type}"
|
||||
value = json.dumps(dict_data, ensure_ascii=False)
|
||||
await RedisCURD(redis).set(
|
||||
key=redis_key,
|
||||
value=value,
|
||||
expire=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 初始化字典数据失败 [{dict_type}]: {e}")
|
||||
for obj in obj_list:
|
||||
dict_type = obj.dict_type
|
||||
tenant_id = obj.tenant_id
|
||||
try:
|
||||
dict_data_list = await DictDataCRUD(init_auth).get_list(search={"dict_type": dict_type, "tenant_id": tenant_id})
|
||||
dict_data = [DictDataOutSchema.model_validate(row).model_dump(mode="json") for row in dict_data_list if row]
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_DICT.key}:{tenant_id}:{dict_type}"
|
||||
value = json.dumps(dict_data, ensure_ascii=False)
|
||||
await RedisCURD(redis).set(
|
||||
key=redis_key,
|
||||
value=value,
|
||||
expire=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 初始化字典数据失败 [{dict_type}]: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"字典初始化过程发生错误: {e}")
|
||||
@@ -384,8 +365,7 @@ class DictDataService:
|
||||
|
||||
@staticmethod
|
||||
async def get_init_cache(redis: Redis, dict_type: str, tenant_id: int = 1) -> list[dict]:
|
||||
"""
|
||||
从缓存获取字典数据列表信息(无 auth)。
|
||||
"""从缓存获取字典数据列表信息(无 auth)。
|
||||
|
||||
参数:
|
||||
- redis (Redis): Redis客户端
|
||||
@@ -427,8 +407,7 @@ class DictDataService:
|
||||
raise CustomException(msg="获取字典数据失败") from e
|
||||
|
||||
async def create(self, redis: Redis, data: DictDataCreateSchema) -> DictDataOutSchema:
|
||||
"""
|
||||
创建数据字典数据
|
||||
"""创建数据字典数据
|
||||
|
||||
参数:
|
||||
- redis (Redis): Redis客户端
|
||||
@@ -474,8 +453,7 @@ class DictDataService:
|
||||
id: int,
|
||||
data: DictDataUpdateSchema,
|
||||
) -> DictDataOutSchema:
|
||||
"""
|
||||
更新数据字典数据
|
||||
"""更新数据字典数据
|
||||
|
||||
参数:
|
||||
- redis (Redis): Redis客户端
|
||||
@@ -538,8 +516,7 @@ class DictDataService:
|
||||
return DictDataOutSchema.model_validate(obj)
|
||||
|
||||
async def delete(self, redis: Redis, ids: list[int]) -> None:
|
||||
"""
|
||||
删除数据字典数据
|
||||
"""删除数据字典数据
|
||||
|
||||
参数:
|
||||
- redis (Redis): Redis客户端
|
||||
@@ -575,8 +552,7 @@ class DictDataService:
|
||||
await DictDataCRUD(self.auth).delete(ids=ids)
|
||||
|
||||
async def set_available(self, data: BatchSetAvailable) -> None:
|
||||
"""
|
||||
设置数据字典数据状态
|
||||
"""设置数据字典数据状态
|
||||
|
||||
参数:
|
||||
- data (BatchSetAvailable): 批量设置状态模型
|
||||
@@ -588,8 +564,7 @@ class DictDataService:
|
||||
|
||||
@staticmethod
|
||||
def export(data_list: list[dict]) -> bytes:
|
||||
"""
|
||||
导出数据字典数据列表(无状态工具方法)
|
||||
"""导出数据字典数据列表(无状态工具方法)
|
||||
|
||||
参数:
|
||||
- data_list (list[dict]): 数据字典数据列表
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query, Security
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.common.response import ResponseSchema, SuccessResponse
|
||||
@@ -9,11 +9,9 @@ from app.core.dependencies import AuthPermission, get_current_user
|
||||
from app.core.router_class import OperationLogRoute
|
||||
|
||||
from .schema import (
|
||||
LoginLogCreateSchema,
|
||||
LoginLogDetailOutSchema,
|
||||
LoginLogOutSchema,
|
||||
LoginLogQueryParam,
|
||||
OperationLogCreateSchema,
|
||||
OperationLogDetailOutSchema,
|
||||
OperationLogOutSchema,
|
||||
OperationLogQueryParam,
|
||||
@@ -25,8 +23,8 @@ LogRouter = APIRouter(route_class=OperationLogRoute, prefix="/log", tags=["日
|
||||
|
||||
@LogRouter.get("/login/detail/{id}", summary="获取登录日志详情", response_model=ResponseSchema[LoginLogDetailOutSchema])
|
||||
async def get_log_detail_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:login_log:query"]))],
|
||||
id: Annotated[int, Path(description="登录日志ID")],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:login_log:query"]))],
|
||||
id: Annotated[int, Path(description="登录日志ID", ge=1)],
|
||||
) -> JSONResponse:
|
||||
result_dict = await LoginLogService(auth).detail(id=id)
|
||||
return SuccessResponse(data=result_dict, msg="获取登录日志详情成功")
|
||||
@@ -34,7 +32,7 @@ async def get_log_detail_controller(
|
||||
|
||||
@LogRouter.get("/login/list", summary="查询登录日志列表", response_model=ResponseSchema[PageResultSchema[LoginLogOutSchema]])
|
||||
async def get_log_list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:login_log:query"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:login_log:query"]))],
|
||||
page: Annotated[PaginationQueryParam, Query(description="分页参数")],
|
||||
search: Annotated[LoginLogQueryParam, Query(description="登录日志查询参数")],
|
||||
) -> JSONResponse:
|
||||
@@ -47,25 +45,16 @@ async def get_log_list_controller(
|
||||
return SuccessResponse(data=result_dict, msg="查询登录日志列表成功")
|
||||
|
||||
|
||||
@LogRouter.post("/login/create", summary="创建登录日志", response_model=ResponseSchema[LoginLogDetailOutSchema])
|
||||
async def create_log_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
data: Annotated[LoginLogCreateSchema, Body(description="登录日志创建参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await LoginLogService(auth).create(data=data)
|
||||
return SuccessResponse(data=result_dict, msg="创建登录日志成功")
|
||||
|
||||
|
||||
@LogRouter.delete("/login/delete", summary="删除登录日志", response_model=ResponseSchema)
|
||||
async def delete_log_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:login_log:delete"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:login_log:delete"]))],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
) -> JSONResponse:
|
||||
await LoginLogService(auth).delete(ids=ids)
|
||||
return SuccessResponse(msg="删除登录日志成功")
|
||||
|
||||
|
||||
@LogRouter.get("/operation/detail/{id}", summary="获取操作日志详情", response_model=ResponseSchema[OperationLogDetailOutSchema], dependencies=[Depends(AuthPermission(["module_system:log:query"]))])
|
||||
@LogRouter.get("/operation/detail/{id}", summary="获取操作日志详情", response_model=ResponseSchema[OperationLogDetailOutSchema], dependencies=[Security(AuthPermission(["module_system:log:query"]))])
|
||||
async def get_operation_log_detail_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
id: Annotated[int, Path(description="操作日志ID", gt=0)],
|
||||
@@ -74,7 +63,9 @@ async def get_operation_log_detail_controller(
|
||||
return SuccessResponse(data=result_dict, msg="获取操作日志详情成功")
|
||||
|
||||
|
||||
@LogRouter.get("/operation/list", summary="获取操作日志列表", response_model=ResponseSchema[PageResultSchema[OperationLogOutSchema]], dependencies=[Depends(AuthPermission(["module_system:log:query"]))])
|
||||
@LogRouter.get(
|
||||
"/operation/list", summary="获取操作日志列表", response_model=ResponseSchema[PageResultSchema[OperationLogOutSchema]], dependencies=[Security(AuthPermission(["module_system:log:query"]))],
|
||||
)
|
||||
async def get_operation_log_list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
page: Annotated[PaginationQueryParam, Query(description="分页参数")],
|
||||
@@ -89,16 +80,7 @@ async def get_operation_log_list_controller(
|
||||
return SuccessResponse(data=result_dict, msg="查询操作日志列表成功")
|
||||
|
||||
|
||||
@LogRouter.post("/operation/create", summary="创建操作日志", response_model=ResponseSchema[OperationLogDetailOutSchema])
|
||||
async def create_operation_log_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
data: Annotated[OperationLogCreateSchema, Body(description="操作日志创建参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await OperationLogService(auth).create(data=data)
|
||||
return SuccessResponse(data=result_dict, msg="创建操作日志成功")
|
||||
|
||||
|
||||
@LogRouter.delete("/operation/delete", summary="删除操作日志", response_model=ResponseSchema, dependencies=[Depends(AuthPermission(["module_system:log:delete"]))])
|
||||
@LogRouter.delete("/operation/delete", summary="删除操作日志", response_model=ResponseSchema, dependencies=[Security(AuthPermission(["module_system:log:delete"]))])
|
||||
async def delete_operation_log_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
|
||||
@@ -2,7 +2,7 @@ from app.core.base_crud import CRUDBase
|
||||
from app.core.base_schema import AuthSchema
|
||||
|
||||
from .model import LoginLogModel, OperationLogModel
|
||||
from .schema import LoginLogCreateSchema
|
||||
from .schema import LoginLogCreateSchema, OperationLogCreateSchema
|
||||
|
||||
|
||||
class LoginLogCRUD(CRUDBase[LoginLogModel, LoginLogCreateSchema, None]):
|
||||
@@ -12,7 +12,7 @@ class LoginLogCRUD(CRUDBase[LoginLogModel, LoginLogCreateSchema, None]):
|
||||
super().__init__(model=LoginLogModel, auth=auth)
|
||||
|
||||
|
||||
class OperationLogCRUD(CRUDBase[OperationLogModel, None, None]):
|
||||
class OperationLogCRUD(CRUDBase[OperationLogModel, OperationLogCreateSchema, None]):
|
||||
"""操作日志 CRUD"""
|
||||
|
||||
def __init__(self, auth: AuthSchema):
|
||||
|
||||
@@ -2,34 +2,31 @@ 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, UserMixin
|
||||
from app.core.base_model import ModelMixin, TenantMixin
|
||||
|
||||
|
||||
def get_log_text_column_type():
|
||||
"""
|
||||
根据数据库类型选择适合存储大文本的列类型。
|
||||
"""根据数据库类型选择适合存储大文本的列类型。
|
||||
"""
|
||||
db_type = settings.DATABASE_TYPE
|
||||
if db_type == "mysql":
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
|
||||
return LONGTEXT
|
||||
elif db_type == "postgres":
|
||||
if db_type == "postgres":
|
||||
from sqlalchemy.dialects.postgresql import TEXT
|
||||
|
||||
return TEXT
|
||||
else:
|
||||
return Text
|
||||
return Text
|
||||
|
||||
|
||||
class LoginLogModel(ModelMixin, TenantMixin, UserMixin):
|
||||
"""
|
||||
登录日志模型
|
||||
class LoginLogModel(ModelMixin, TenantMixin):
|
||||
"""登录日志模型
|
||||
"""
|
||||
|
||||
__tablename__: str = "sys_login_log"
|
||||
__table_args__: dict[str, str] = {"comment": "登录日志表"}
|
||||
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by", "tenant_by"]
|
||||
__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="备注")
|
||||
@@ -41,14 +38,13 @@ class LoginLogModel(ModelMixin, TenantMixin, UserMixin):
|
||||
msg: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="提示消息")
|
||||
|
||||
|
||||
class OperationLogModel(ModelMixin, TenantMixin, UserMixin):
|
||||
"""
|
||||
操作日志模型
|
||||
class OperationLogModel(ModelMixin, TenantMixin):
|
||||
"""操作日志模型
|
||||
"""
|
||||
|
||||
__tablename__: str = "sys_operation_log"
|
||||
__table_args__: dict[str, str] = {"comment": "操作日志表"}
|
||||
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by", "tenant_by"]
|
||||
__loader_options__: list[str] = ["tenant_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="备注")
|
||||
@@ -58,3 +54,4 @@ class OperationLogModel(ModelMixin, TenantMixin, UserMixin):
|
||||
response_code: Mapped[int] = mapped_column(Integer, comment="响应状态码")
|
||||
response_json: Mapped[str | None] = mapped_column(get_log_text_column_type(), nullable=True, comment="响应体")
|
||||
process_time: Mapped[str | None] = mapped_column(String(20), nullable=True, comment="处理时间")
|
||||
request_ip: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True, comment="请求IP")
|
||||
|
||||
@@ -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, UserByQueryParam, UserBySchema
|
||||
from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam, TenantBySchema
|
||||
|
||||
ALLOWED_REQUEST_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
|
||||
|
||||
@@ -35,7 +35,7 @@ class LoginLogCreateSchema(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
class LoginLogOutSchema(LoginLogCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
|
||||
class LoginLogOutSchema(LoginLogCreateSchema, BaseSchema, TenantBySchema):
|
||||
"""登录日志响应"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -45,43 +45,46 @@ class LoginLogDetailOutSchema(LoginLogOutSchema):
|
||||
"""登录日志详情响应"""
|
||||
|
||||
|
||||
class LoginLogQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
|
||||
class LoginLogQueryParam(BaseQueryParam, TenantByQueryParam):
|
||||
"""登录日志查询参数"""
|
||||
|
||||
username: str | None = Field(None, max_length=64, description="用户名")
|
||||
status: int | None = Field(None, description="登录状态(1:成功 2:失败)")
|
||||
username: str | tuple[str, str] | None = Field(None, max_length=64, description="用户名")
|
||||
status: int | tuple[str, int] | None = Field(None, description="登录状态(1:成功 2:失败)")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query_params(self) -> "LoginLogQueryParam":
|
||||
if self.username:
|
||||
if isinstance(self.username, str):
|
||||
self.username = (QueueEnum.like.value, self.username)
|
||||
if isinstance(self.status, int):
|
||||
self.status = (QueueEnum.eq.value, self.status)
|
||||
return self
|
||||
|
||||
|
||||
class OperationLogQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
|
||||
class OperationLogQueryParam(BaseQueryParam, TenantByQueryParam):
|
||||
"""操作日志查询参数"""
|
||||
|
||||
request_path: str | None = Field(None, description="请求路径")
|
||||
request_method: str | None = Field(None, description="请求方式")
|
||||
username: str | None = Field(None, description="用户名")
|
||||
status: int | None = Field(None, ge=0, le=1, description="状态(0:成功 1:失败)")
|
||||
request_path: str | tuple[str, str] | None = Field(None, description="请求路径")
|
||||
request_method: str | tuple[str, str] | None = Field(None, description="请求方式")
|
||||
username: str | tuple[str, str] | None = Field(None, description="用户名")
|
||||
status: int | tuple[str, int] | None = Field(None, ge=0, le=1, description="状态(0:成功 1:失败)")
|
||||
request_ip: str | tuple[str, str] | None = Field(None, description="请求IP")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query_params(self) -> "OperationLogQueryParam":
|
||||
if self.request_path:
|
||||
if isinstance(self.request_path, str):
|
||||
self.request_path = (QueueEnum.like.value, self.request_path)
|
||||
if self.request_method:
|
||||
if isinstance(self.request_method, str):
|
||||
self.request_method = (QueueEnum.eq.value, self.request_method)
|
||||
if self.username:
|
||||
if isinstance(self.username, str):
|
||||
self.username = (QueueEnum.like.value, self.username)
|
||||
if isinstance(self.status, int):
|
||||
self.status = (QueueEnum.eq.value, self.status)
|
||||
if isinstance(self.request_ip, str):
|
||||
self.request_ip = (QueueEnum.eq.value, self.request_ip)
|
||||
return self
|
||||
|
||||
|
||||
class OperationLogOutSchema(BaseSchema, UserBySchema, TenantBySchema):
|
||||
class OperationLogOutSchema(BaseSchema, TenantBySchema):
|
||||
"""操作日志响应模型"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -92,6 +95,7 @@ class OperationLogOutSchema(BaseSchema, UserBySchema, TenantBySchema):
|
||||
request_method: str = Field(..., description="请求方式")
|
||||
response_code: int = Field(..., description="响应状态码")
|
||||
process_time: str | None = Field(default=None, description="处理时间")
|
||||
request_ip: str | None = Field(default=None, description="请求IP")
|
||||
|
||||
|
||||
class OperationLogDetailOutSchema(OperationLogOutSchema):
|
||||
@@ -108,9 +112,8 @@ class OperationLogCreateSchema(BaseModel):
|
||||
response_code: int = Field(200, ge=100, le=599, description="响应状态码")
|
||||
response_json: str | None = Field(None, description="响应体")
|
||||
process_time: str | None = Field(None, max_length=20, description="处理时间")
|
||||
created_id: int | None = Field(None, description="创建人ID")
|
||||
updated_id: int | None = Field(None, description="更新人ID")
|
||||
description: str | None = Field(None, description="备注")
|
||||
request_ip: str | None = Field(None, max_length=50, description="请求IP")
|
||||
|
||||
@field_validator("request_method")
|
||||
@classmethod
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
|
||||
from app.core.base_schema import AuthSchema
|
||||
from app.core.base_schema import AuthSchema, PageResultSchema
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import logger
|
||||
|
||||
from .crud import LoginLogCRUD, OperationLogCRUD
|
||||
from .schema import (
|
||||
LoginLogCreateSchema,
|
||||
LoginLogDetailOutSchema,
|
||||
LoginLogOutSchema,
|
||||
LoginLogQueryParam,
|
||||
OperationLogCreateSchema,
|
||||
OperationLogDetailOutSchema,
|
||||
OperationLogOutSchema,
|
||||
OperationLogQueryParam,
|
||||
@@ -23,7 +20,8 @@ class LoginLogService:
|
||||
self.auth = auth
|
||||
|
||||
async def detail(self, id: int) -> LoginLogDetailOutSchema:
|
||||
return await LoginLogCRUD(self.auth).get_or_404(id=id, out_schema=LoginLogDetailOutSchema)
|
||||
obj = await LoginLogCRUD(self.auth).get_or_404(id=id)
|
||||
return LoginLogDetailOutSchema.model_validate(obj)
|
||||
|
||||
async def page(
|
||||
self,
|
||||
@@ -31,7 +29,7 @@ class LoginLogService:
|
||||
page_size: int,
|
||||
search: LoginLogQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> dict:
|
||||
) -> PageResultSchema[LoginLogOutSchema]:
|
||||
return await LoginLogCRUD(self.auth).page(
|
||||
offset=(page_no - 1) * page_size,
|
||||
limit=page_size,
|
||||
@@ -40,12 +38,6 @@ class LoginLogService:
|
||||
out_schema=LoginLogOutSchema,
|
||||
)
|
||||
|
||||
async def create(self, data: LoginLogCreateSchema) -> LoginLogDetailOutSchema:
|
||||
obj = await LoginLogCRUD(self.auth).create(data=data)
|
||||
if not obj:
|
||||
raise CustomException(msg="创建失败")
|
||||
return LoginLogDetailOutSchema.model_validate(obj)
|
||||
|
||||
async def delete(self, ids: list[int]) -> None:
|
||||
if len(ids) < 1:
|
||||
raise CustomException(msg="删除失败,删除对象不能为空")
|
||||
@@ -66,8 +58,9 @@ class OperationLogService:
|
||||
self.auth = auth
|
||||
|
||||
@staticmethod
|
||||
async def cleanup_operation_log() -> None:
|
||||
async def cleanup_operation_log() -> bool:
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
@@ -90,29 +83,22 @@ class OperationLogService:
|
||||
cutoff = datetime.now() - timedelta(days=retention_days)
|
||||
async with async_db_session() as session:
|
||||
op_stmt = delete(OperationLogModel).where(OperationLogModel.created_time < cutoff)
|
||||
op_result = await session.execute(op_stmt)
|
||||
op_result: Any = await session.execute(op_stmt)
|
||||
|
||||
login_stmt = delete(LoginLogModel).where(LoginLogModel.created_time < cutoff)
|
||||
login_result = await session.execute(login_stmt)
|
||||
login_result: Any = await session.execute(login_stmt)
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"操作日志清理完成: 操作日志 {op_result.rowcount} 条, 登录日志 {login_result.rowcount} 条")
|
||||
return True
|
||||
|
||||
async def create(self, data: OperationLogCreateSchema) -> OperationLogDetailOutSchema:
|
||||
crud = OperationLogCRUD(self.auth)
|
||||
obj = await crud.create(data=data)
|
||||
if not obj:
|
||||
raise CustomException(msg="创建失败")
|
||||
return OperationLogDetailOutSchema.model_validate(obj)
|
||||
|
||||
async def page(
|
||||
self,
|
||||
page_no: int,
|
||||
page_size: int,
|
||||
search: OperationLogQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> dict:
|
||||
) -> PageResultSchema[OperationLogOutSchema]:
|
||||
crud = OperationLogCRUD(self.auth)
|
||||
return await crud.page(
|
||||
offset=(page_no - 1) * page_size,
|
||||
@@ -124,7 +110,8 @@ class OperationLogService:
|
||||
|
||||
async def detail(self, id: int) -> OperationLogDetailOutSchema:
|
||||
crud = OperationLogCRUD(self.auth)
|
||||
return await crud.get_or_404(id=id, out_schema=OperationLogDetailOutSchema)
|
||||
obj = await crud.get_or_404(id=id)
|
||||
return OperationLogDetailOutSchema.model_validate(obj)
|
||||
|
||||
async def delete(self, ids: list[int]) -> None:
|
||||
if len(ids) < 1:
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
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 app.common.response import ResponseSchema, StreamResponse, SuccessResponse
|
||||
from app.common.response import ResponseSchema, SuccessResponse
|
||||
from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema, PaginationQueryParam
|
||||
from app.core.dependencies import AuthPermission, get_current_user
|
||||
from app.core.logger import logger
|
||||
from app.core.router_class import OperationLogRoute
|
||||
from app.utils.common_util import bytes2file_response
|
||||
|
||||
from .schema import NoticeCreateSchema, NoticeOutSchema, NoticeQueryParam, NoticeUpdateSchema, PanelDataOut
|
||||
from .schema import NoticeCreateSchema, NoticeOutSchema, NoticeQueryParam, NoticeUpdateSchema
|
||||
from .service import NoticeService
|
||||
|
||||
NoticeRouter = APIRouter(route_class=OperationLogRoute, prefix="/notice", tags=["公告通知"])
|
||||
|
||||
_NOTICE_NS = "notice"
|
||||
|
||||
|
||||
@NoticeRouter.get("/detail/{id}", summary="获取公告详情", response_model=ResponseSchema[NoticeOutSchema])
|
||||
async def get_notice_detail_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:detail"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:notice:detail"]))],
|
||||
id: Annotated[int, Path(description="公告ID")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await NoticeService(auth).detail(id=id)
|
||||
return SuccessResponse(data=result_dict, msg="获取公告详情成功")
|
||||
|
||||
|
||||
@NoticeRouter.get("/list", summary="查询公告", response_model=ResponseSchema[PageResultSchema[NoticeOutSchema]])
|
||||
async def get_notice_list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:query"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:notice:query"]))],
|
||||
page: Annotated[PaginationQueryParam, Query(description="分页参数")],
|
||||
search: Annotated[NoticeQueryParam, Query(description="公告查询参数")],
|
||||
) -> JSONResponse:
|
||||
@@ -41,57 +41,47 @@ async def get_notice_list_controller(
|
||||
)
|
||||
return SuccessResponse(data=result_dict, msg="查询公告列表成功")
|
||||
|
||||
@NoticeRouter.post("/create", summary="创建公告", response_model=ResponseSchema[NoticeOutSchema])
|
||||
|
||||
@NoticeRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建公告", response_model=ResponseSchema[NoticeOutSchema])
|
||||
async def create_notice_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:create"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:notice:create"]))],
|
||||
data: Annotated[NoticeCreateSchema, Body(description="公告创建参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await NoticeService(auth).create(data=data)
|
||||
await FastAPICache.clear(namespace=_NOTICE_NS)
|
||||
return SuccessResponse(data=result_dict, msg="创建公告成功")
|
||||
|
||||
|
||||
@NoticeRouter.put("/update/{id}", summary="修改公告", response_model=ResponseSchema[NoticeOutSchema])
|
||||
async def update_notice_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:update"]))],
|
||||
id: Annotated[int, Path(description="公告ID")],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:notice:update"]))],
|
||||
id: Annotated[int, Path(description="公告ID", ge=1)],
|
||||
data: Annotated[NoticeUpdateSchema, Body(description="公告修改参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await NoticeService(auth).update(id=id, data=data)
|
||||
await FastAPICache.clear(namespace=_NOTICE_NS)
|
||||
return SuccessResponse(data=result_dict, msg="修改公告成功")
|
||||
|
||||
|
||||
@NoticeRouter.delete("/delete", summary="删除公告", response_model=ResponseSchema[None])
|
||||
async def delete_notice_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:delete"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:notice:delete"]))],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
) -> JSONResponse:
|
||||
await NoticeService(auth).delete(ids=ids)
|
||||
await FastAPICache.clear(namespace=_NOTICE_NS)
|
||||
return SuccessResponse(msg="删除公告成功")
|
||||
|
||||
|
||||
@NoticeRouter.patch("/status/batch", summary="批量修改公告状态", response_model=ResponseSchema[None])
|
||||
async def batch_set_available_notice_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:patch"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:notice:patch"]))],
|
||||
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
|
||||
) -> JSONResponse:
|
||||
await NoticeService(auth).set_available(data=data)
|
||||
await FastAPICache.clear(namespace=_NOTICE_NS)
|
||||
return SuccessResponse(msg="批量修改公告状态成功")
|
||||
|
||||
@NoticeRouter.post("/export", summary="导出公告")
|
||||
async def export_notice_list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:notice:export"]))],
|
||||
search: Annotated[NoticeQueryParam, Query(description="公告查询参数")],
|
||||
) -> StreamingResponse[bytes]:
|
||||
result_dict_list = await NoticeService(auth).get_list(search=search)
|
||||
export_data = [item.model_dump() for item in result_dict_list]
|
||||
export_result = NoticeService.export(notice_list=export_data)
|
||||
|
||||
return StreamResponse(
|
||||
data=bytes2file_response(export_result),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": "attachment; filename=notice.xlsx"},
|
||||
)
|
||||
|
||||
@NoticeRouter.get("/available", summary="获取全局启用公告", response_model=ResponseSchema[list[NoticeOutSchema]])
|
||||
@cache(expire=120, namespace=_NOTICE_NS)
|
||||
@@ -100,42 +90,3 @@ async def get_notice_list_available_controller(
|
||||
) -> JSONResponse:
|
||||
result_dict = await NoticeService(auth).available_page()
|
||||
return SuccessResponse(data=result_dict, msg="查询已启用公告列表成功")
|
||||
|
||||
@NoticeRouter.get("/panel", summary="通知面板数据(铃铛)", response_model=ResponseSchema[PanelDataOut])
|
||||
@cache(expire=30, namespace=_NOTICE_NS)
|
||||
async def get_notification_panel_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
) -> JSONResponse:
|
||||
"""通知面板聚合接口,返回通知、消息、待办三个列表。"""
|
||||
result = await NoticeService(auth).panel_data()
|
||||
return SuccessResponse(data=result, msg="获取面板数据成功")
|
||||
|
||||
@NoticeRouter.post("/read/{id}", summary="标记已读", response_model=ResponseSchema[None])
|
||||
async def mark_read_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
id: Annotated[int, Path(description="通知ID")],
|
||||
) -> JSONResponse:
|
||||
"""标记已读。通过 `sys_notice_read` 表记录已读时间。"""
|
||||
await NoticeService(auth).mark_read(notice_id=id)
|
||||
await FastAPICache.clear(namespace=_NOTICE_NS)
|
||||
logger.info(f"用户[{auth.user.id}]标记通知[{id}]已读")
|
||||
return SuccessResponse(msg="标记已读成功")
|
||||
|
||||
@NoticeRouter.post("/read-all", summary="全部已读", response_model=ResponseSchema[int])
|
||||
async def mark_all_read_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
) -> JSONResponse:
|
||||
"""全部标记已读。返回本次操作标记的数量。"""
|
||||
count = await NoticeService(auth).mark_all_read()
|
||||
await FastAPICache.clear(namespace=_NOTICE_NS)
|
||||
logger.info(f"用户[{auth.user.id}]全部已读, 数量={count}")
|
||||
return SuccessResponse(data=count, msg=f"全部标记已读成功,共标记 {count} 条")
|
||||
|
||||
@NoticeRouter.get("/unread-count", summary="获取未读数量", response_model=ResponseSchema[int])
|
||||
@cache(expire=15, namespace=_NOTICE_NS)
|
||||
async def get_unread_count_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
) -> JSONResponse:
|
||||
"""获取未读通知数量。通过 LEFT JOIN 统计未读数。"""
|
||||
count = await NoticeService(auth).get_unread_count()
|
||||
return SuccessResponse(data=count, msg="获取未读数量成功")
|
||||
|
||||
@@ -7,13 +7,16 @@ from app.core.base_model import MappedBase, ModelMixin, TenantMixin, UserMixin
|
||||
|
||||
|
||||
class NoticeModel(ModelMixin, TenantMixin, UserMixin):
|
||||
"""
|
||||
通知公告表
|
||||
"""通知公告表
|
||||
|
||||
__platform_data_shared__ = True 表示 tenant_id=1 的平台公告对
|
||||
所有租户可读,但只有平台管理员可写。
|
||||
"""
|
||||
|
||||
__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
|
||||
|
||||
notice_title: Mapped[str] = mapped_column(String(64), nullable=False, comment="公告标题")
|
||||
notice_type: Mapped[str] = mapped_column(String(1), nullable=False, comment="公告类型(1通知 2公告)")
|
||||
@@ -23,8 +26,7 @@ class NoticeModel(ModelMixin, TenantMixin, UserMixin):
|
||||
|
||||
|
||||
class NoticeReadModel(MappedBase):
|
||||
"""
|
||||
通知已读记录表 — 记录用户对公告的已读状态。
|
||||
"""通知已读记录表 — 记录用户对公告的已读状态。
|
||||
|
||||
设计说明:
|
||||
- 不继承 TenantMixin:该表按 user_id 隔离,租户上下文由所属 notice 间接确定
|
||||
|
||||
@@ -63,34 +63,16 @@ class NoticeOutSchema(NoticeCreateSchema, BaseSchema, UserBySchema, TenantBySche
|
||||
class NoticeQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
|
||||
"""公告通知查询参数"""
|
||||
|
||||
notice_title: str | None = Field(None, description="公告标题")
|
||||
notice_type: str | None = Field(None, description="公告类型")
|
||||
status: int | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)")
|
||||
notice_title: str | tuple[str, str] | None = Field(None, description="公告标题")
|
||||
notice_type: str | tuple[str, str] | None = Field(None, description="公告类型")
|
||||
status: int | tuple[str, int] | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query_params(self) -> "NoticeQueryParam":
|
||||
if self.notice_title:
|
||||
if isinstance(self.notice_title, str):
|
||||
self.notice_title = (QueueEnum.like.value, self.notice_title)
|
||||
if self.notice_type:
|
||||
if isinstance(self.notice_type, str):
|
||||
self.notice_type = (QueueEnum.eq.value, self.notice_type)
|
||||
if isinstance(self.status, int):
|
||||
self.status = (QueueEnum.eq.value, self.status)
|
||||
return self
|
||||
|
||||
|
||||
class PanelMessageItem(BaseModel):
|
||||
"""面板-消息项"""
|
||||
|
||||
id: int = Field(..., description="消息ID")
|
||||
title: str = Field(..., description="标题")
|
||||
content: str = Field(..., description="内容")
|
||||
time: str = Field(..., description="时间")
|
||||
type: str = Field(..., description="类型")
|
||||
|
||||
|
||||
class PanelDataOut(BaseModel):
|
||||
"""通知面板聚合数据"""
|
||||
|
||||
notices: list[NoticeOutSchema] = Field(default_factory=list, description="通知列表")
|
||||
messages: list[PanelMessageItem] = Field(default_factory=list, description="消息列表")
|
||||
pendings: list[dict] = Field(default_factory=list, description="待办列表")
|
||||
|
||||
@@ -1,32 +1,23 @@
|
||||
from app.core.base_schema import AuthSchema, BatchSetAvailable
|
||||
from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import logger
|
||||
from app.utils.excel_util import ExcelUtil
|
||||
|
||||
from .crud import NoticeCRUD
|
||||
from .model import NoticeModel, NoticeReadModel
|
||||
from .schema import (
|
||||
NoticeCreateSchema,
|
||||
NoticeOutSchema,
|
||||
NoticeQueryParam,
|
||||
NoticeUpdateSchema,
|
||||
PanelDataOut,
|
||||
PanelMessageItem,
|
||||
)
|
||||
from .schema import NoticeCreateSchema, NoticeOutSchema, NoticeQueryParam, NoticeUpdateSchema
|
||||
|
||||
|
||||
class NoticeService:
|
||||
"""
|
||||
公告管理服务
|
||||
"""公告管理服务
|
||||
|
||||
提供公告 CRUD、状态切换、已启用公告分页查询、消息面板、Excel 导出等业务能力。
|
||||
提供公告 CRUD、状态切换、已启用公告分页查询、Excel 导出等业务能力。
|
||||
"""
|
||||
|
||||
def __init__(self, auth: AuthSchema) -> None:
|
||||
self.auth = auth
|
||||
|
||||
async def detail(self, id: int) -> NoticeOutSchema:
|
||||
return await NoticeCRUD(self.auth).get_or_404(id=id, out_schema=NoticeOutSchema)
|
||||
obj = await NoticeCRUD(self.auth).get_or_404(id=id)
|
||||
return NoticeOutSchema.model_validate(obj)
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
@@ -42,7 +33,7 @@ class NoticeService:
|
||||
page_size: int,
|
||||
search: NoticeQueryParam | None = None,
|
||||
order_by: list[dict] | None = None,
|
||||
) -> dict:
|
||||
) -> PageResultSchema[NoticeOutSchema]:
|
||||
offset = (page_no - 1) * page_size
|
||||
return await NoticeCRUD(self.auth).page(
|
||||
offset=offset,
|
||||
@@ -52,12 +43,12 @@ class NoticeService:
|
||||
out_schema=NoticeOutSchema,
|
||||
)
|
||||
|
||||
async def available_page(self) -> dict:
|
||||
async def available_page(self) -> PageResultSchema[NoticeOutSchema]:
|
||||
return await NoticeCRUD(self.auth).page(
|
||||
offset=0,
|
||||
limit=10,
|
||||
order_by=[{"id": "asc"}],
|
||||
search={"status": 0},
|
||||
search={"status": ("eq", 0)},
|
||||
out_schema=NoticeOutSchema,
|
||||
)
|
||||
|
||||
@@ -108,108 +99,3 @@ class NoticeService:
|
||||
item["status"] = "启用" if item.get("status") == 0 else "停用"
|
||||
item["notice_type"] = "通知" if item.get("notice_type") == "1" else "公告"
|
||||
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
|
||||
|
||||
async def latest(self, limit: int = 5) -> list[NoticeOutSchema]:
|
||||
from sqlalchemy import desc, select
|
||||
|
||||
stmt = select(NoticeModel).where(NoticeModel.status == 0).order_by(desc(NoticeModel.created_time)).limit(limit)
|
||||
result = await self.auth.db.execute(stmt)
|
||||
notices = result.scalars().all()
|
||||
return [NoticeOutSchema.model_validate(n) for n in notices]
|
||||
|
||||
async def mark_read(self, notice_id: int) -> None:
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
notice = await NoticeCRUD(self.auth).get(id=notice_id)
|
||||
if not notice:
|
||||
raise CustomException(msg="该公告不存在")
|
||||
|
||||
exist_stmt = select(NoticeReadModel).where(
|
||||
NoticeReadModel.user_id == self.auth.user.id,
|
||||
NoticeReadModel.notice_id == notice_id,
|
||||
)
|
||||
result = await self.auth.db.execute(exist_stmt)
|
||||
if result.scalar_one_or_none():
|
||||
return
|
||||
|
||||
read_record = NoticeReadModel(
|
||||
user_id=self.auth.user.id,
|
||||
notice_id=notice_id,
|
||||
read_time=datetime.now(),
|
||||
)
|
||||
self.auth.db.add(read_record)
|
||||
await self.auth.db.flush()
|
||||
|
||||
async def mark_all_read(self) -> int:
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import insert, select
|
||||
|
||||
read_ids_stmt = select(NoticeReadModel.notice_id).where(NoticeReadModel.user_id == self.auth.user.id)
|
||||
read_ids_result = await self.auth.db.execute(read_ids_stmt)
|
||||
read_ids = {row[0] for row in read_ids_result.fetchall()}
|
||||
|
||||
notices_stmt = select(NoticeModel.id).where(NoticeModel.status == 0)
|
||||
notices_result = await self.auth.db.execute(notices_stmt)
|
||||
all_ids = {row[0] for row in notices_result.fetchall()}
|
||||
|
||||
unread_ids = all_ids - read_ids
|
||||
if not unread_ids:
|
||||
return 0
|
||||
|
||||
now = datetime.now()
|
||||
if unread_ids:
|
||||
await self.auth.db.execute(
|
||||
insert(NoticeReadModel),
|
||||
[{"user_id": self.auth.user.id, "notice_id": nid, "read_time": now} for nid in unread_ids],
|
||||
)
|
||||
await self.auth.db.flush()
|
||||
return len(unread_ids)
|
||||
|
||||
async def get_unread_count(self) -> int:
|
||||
from sqlalchemy import func, select
|
||||
|
||||
total_stmt = select(func.count()).select_from(NoticeModel).where(NoticeModel.status == 0)
|
||||
total_result = await self.auth.db.execute(total_stmt)
|
||||
total_count = total_result.scalar() or 0
|
||||
|
||||
read_stmt = select(func.count()).select_from(NoticeReadModel).where(NoticeReadModel.user_id == self.auth.user.id)
|
||||
read_result = await self.auth.db.execute(read_stmt)
|
||||
read_count = read_result.scalar() or 0
|
||||
|
||||
return max(0, total_count - read_count)
|
||||
|
||||
async def panel_data(self) -> PanelDataOut:
|
||||
from sqlalchemy import desc, select
|
||||
|
||||
notices = await self.latest(limit=5)
|
||||
|
||||
messages = []
|
||||
try:
|
||||
from app.api.v1.module_system.log.model import OperationLogModel
|
||||
|
||||
stmt = select(OperationLogModel).order_by(desc(OperationLogModel.created_time)).limit(5)
|
||||
result = await self.auth.db.execute(stmt)
|
||||
logs = result.scalars().all()
|
||||
for log_entry in logs:
|
||||
messages.append(
|
||||
PanelMessageItem(
|
||||
id=log_entry.id,
|
||||
title=log_entry.request_path or "系统操作",
|
||||
content=f"{log_entry.request_method} {log_entry.request_path}",
|
||||
time=log_entry.created_time.strftime("%Y-%m-%d %H:%M") if log_entry.created_time else "",
|
||||
type="system",
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"获取面板消息数据失败(操作日志表可能不存在),已跳过: {e}")
|
||||
|
||||
pendings: list[dict] = []
|
||||
|
||||
return PanelDataOut(
|
||||
notices=notices,
|
||||
messages=messages,
|
||||
pendings=pendings,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from redis.asyncio.client import Redis
|
||||
|
||||
@@ -18,31 +18,16 @@ ParamsRouter = APIRouter(route_class=OperationLogRoute, prefix="/param", tags=["
|
||||
|
||||
@ParamsRouter.get("/detail/{id}", summary="获取参数详情", response_model=ResponseSchema[ParamsOutSchema])
|
||||
async def get_param_detail_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:detail"]))],
|
||||
id: Annotated[int, Path(description="参数ID")],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:param:detail"]))],
|
||||
id: Annotated[int, Path(description="参数ID", ge=1)],
|
||||
) -> JSONResponse:
|
||||
result_dict = await ParamsService(auth).detail(id=id)
|
||||
return SuccessResponse(data=result_dict, msg="获取参数详情成功")
|
||||
|
||||
@ParamsRouter.get("/key/{config_key}", summary="根据配置键获取参数详情", response_model=ResponseSchema[ParamsOutSchema])
|
||||
async def get_param_by_key_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:query"]))],
|
||||
config_key: Annotated[str, Path(description="配置键")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await ParamsService(auth).get_by_key(config_key=config_key)
|
||||
return SuccessResponse(data=result_dict, msg="根据配置键获取参数详情成功")
|
||||
|
||||
@ParamsRouter.get("/value/{config_key}", summary="根据配置键获取参数值", response_model=ResponseSchema[ParamsOutSchema])
|
||||
async def get_config_value_by_key_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:query"]))],
|
||||
config_key: Annotated[str, Path(description="配置键")],
|
||||
) -> JSONResponse:
|
||||
result_value = await ParamsService(auth).get_config_value_by_key(config_key=config_key)
|
||||
return SuccessResponse(data=result_value, msg="根据配置键获取参数值成功")
|
||||
|
||||
@ParamsRouter.get("/list", summary="获取参数列表", response_model=ResponseSchema[PageResultSchema[ParamsOutSchema]])
|
||||
async def get_param_list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:query"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:param:query"]))],
|
||||
page: Annotated[PaginationQueryParam, Query(description="分页参数")],
|
||||
search: Annotated[ParamsQueryParam, Query(description="参数查询参数")],
|
||||
) -> JSONResponse:
|
||||
@@ -54,47 +39,52 @@ async def get_param_list_controller(
|
||||
)
|
||||
return SuccessResponse(data=result_dict, msg="查询参数列表成功")
|
||||
|
||||
@ParamsRouter.post("/create", summary="创建参数", response_model=ResponseSchema[ParamsOutSchema])
|
||||
|
||||
@ParamsRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建参数", response_model=ResponseSchema[ParamsOutSchema])
|
||||
async def create_param_controller(
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:create"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:param:create"]))],
|
||||
data: Annotated[ParamsCreateSchema, Body(description="参数创建参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await ParamsService(auth).create(redis=redis, data=data)
|
||||
return SuccessResponse(data=result_dict, msg="创建参数成功")
|
||||
|
||||
|
||||
@ParamsRouter.put("/update/{id}", summary="修改参数", response_model=ResponseSchema[ParamsOutSchema])
|
||||
async def update_param_controller(
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:update"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:param:update"]))],
|
||||
id: Annotated[int, Path(description="参数ID")],
|
||||
data: Annotated[ParamsUpdateSchema, Body(description="参数修改参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await ParamsService(auth).update(redis=redis, id=id, data=data)
|
||||
return SuccessResponse(data=result_dict, msg="更新参数成功")
|
||||
|
||||
|
||||
@ParamsRouter.delete("/delete", summary="删除参数", response_model=ResponseSchema[ParamsOutSchema])
|
||||
async def delete_param_controller(
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:delete"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:param:delete"]))],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
) -> JSONResponse:
|
||||
await ParamsService(auth).delete(redis=redis, ids=ids)
|
||||
return SuccessResponse(msg="删除参数成功")
|
||||
|
||||
|
||||
@ParamsRouter.patch("/status/batch", summary="批量设置参数状态", response_model=ResponseSchema)
|
||||
async def batch_set_status_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:patch"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:param:patch"]))],
|
||||
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
|
||||
) -> JSONResponse:
|
||||
await ParamsService(auth).batch_set_status(ids=data.ids, status=data.status)
|
||||
return SuccessResponse(msg="批量设置参数状态成功")
|
||||
|
||||
|
||||
@ParamsRouter.get("/export", summary="导出参数")
|
||||
async def export_param_list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:param:export"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:param:export"]))],
|
||||
search: Annotated[ParamsQueryParam, Query(description="参数查询参数")],
|
||||
) -> StreamingResponse[bytes]:
|
||||
) -> StreamingResponse:
|
||||
result_dict_list = await ParamsService(auth).get_list(search=search)
|
||||
export_data = [item.model_dump() for item in result_dict_list]
|
||||
export_result = ParamsService.export(data_list=export_data)
|
||||
@@ -105,7 +95,8 @@ async def export_param_list_controller(
|
||||
headers={"Content-Disposition": "attachment; filename=params.xlsx"},
|
||||
)
|
||||
|
||||
@ParamsRouter.get( "/info", summary="获取初始化缓存参数", response_model=ResponseSchema[list[ParamsOutSchema]])
|
||||
|
||||
@ParamsRouter.get("/info", summary="获取初始化缓存参数", response_model=ResponseSchema[list[ParamsOutSchema]])
|
||||
async def get_init_config_controller(
|
||||
redis: Annotated[Redis, Depends(redis_getter)],
|
||||
) -> JSONResponse:
|
||||
|
||||
@@ -9,8 +9,7 @@ class ParamsCRUD(CRUDBase[ParamsModel, ParamsCreateSchema, ParamsUpdateSchema]):
|
||||
"""配置管理数据层"""
|
||||
|
||||
def __init__(self, auth: AuthSchema) -> None:
|
||||
"""
|
||||
初始化系统参数配置数据层。
|
||||
"""初始化系统参数配置数据层。
|
||||
|
||||
参数:
|
||||
- auth (AuthSchema): 认证信息模型(含 DB 会话等上下文)。
|
||||
|
||||
@@ -5,8 +5,7 @@ from app.core.base_model import ModelMixin, TenantMixin, UserMixin
|
||||
|
||||
|
||||
class ParamsModel(ModelMixin, TenantMixin, UserMixin):
|
||||
"""
|
||||
系统参数表
|
||||
"""系统参数表
|
||||
|
||||
用于存储全局系统配置(如 retention_days、smtp 主机等)。
|
||||
平台参数(tenant_id=1)对所有租户共享;租户级参数仅本租户可见。
|
||||
@@ -20,6 +19,7 @@ class ParamsModel(ModelMixin, TenantMixin, UserMixin):
|
||||
"deleted_by",
|
||||
"tenant_by",
|
||||
]
|
||||
__platform_data_shared__: bool = True
|
||||
|
||||
config_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="参数名称")
|
||||
config_key: Mapped[str] = mapped_column(String(500), nullable=False, comment="参数键名")
|
||||
|
||||
@@ -7,8 +7,7 @@ from app.core.base_schema import BaseQueryParam, BaseSchema, TenantByQueryParam,
|
||||
|
||||
|
||||
class ParamsCreateSchema(BaseModel):
|
||||
"""
|
||||
参数创建模型
|
||||
"""参数创建模型
|
||||
"""
|
||||
|
||||
config_name: str = Field(..., min_length=1, max_length=64, description="参数名称")
|
||||
@@ -37,42 +36,33 @@ class ParamsCreateSchema(BaseModel):
|
||||
|
||||
|
||||
class ParamsUpdateSchema(ParamsCreateSchema):
|
||||
"""
|
||||
参数更新模型
|
||||
"""参数更新模型
|
||||
"""
|
||||
|
||||
|
||||
class ParamsOutSchema(ParamsCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
|
||||
"""
|
||||
参数响应模型
|
||||
"""参数响应模型
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ParamsQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
|
||||
"""
|
||||
参数管理查询参数
|
||||
|
||||
支持:
|
||||
- 时间范围(BaseQueryParam)
|
||||
- 创建人/更新人筛选(UserByQueryParam)
|
||||
- 租户筛选(TenantByQueryParam)
|
||||
- 业务字段:参数名称、参数键名、是否系统内置
|
||||
"""参数管理查询参数
|
||||
"""
|
||||
|
||||
config_name: str | None = Field(None, description="参数名称")
|
||||
config_key: str | None = Field(None, description="参数键名")
|
||||
config_type: bool | None = Field(None, description="是否系统内置(True:是 False:否)")
|
||||
status: int | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)")
|
||||
config_name: str | tuple[str, str] | None = Field(None, description="参数名称")
|
||||
config_key: str | tuple[str, str] | None = Field(None, description="参数键名")
|
||||
config_type: bool | tuple[str, bool] | None = Field(None, description="是否系统内置(True:是 False:否)")
|
||||
status: int | tuple[str, int] | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query_params(self) -> "ParamsQueryParam":
|
||||
if self.config_name:
|
||||
if isinstance(self.config_name, str):
|
||||
self.config_name = (QueueEnum.like.value, self.config_name)
|
||||
if self.config_key:
|
||||
if isinstance(self.config_key, str):
|
||||
self.config_key = (QueueEnum.like.value, self.config_key)
|
||||
if self.config_type is not None:
|
||||
if isinstance(self.config_type, bool):
|
||||
self.config_type = (QueueEnum.eq.value, self.config_type)
|
||||
if isinstance(self.status, int):
|
||||
self.status = (QueueEnum.eq.value, self.status)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
|
||||
from redis.asyncio.client import Redis
|
||||
|
||||
from app.common.enums import RedisInitKeyConfig
|
||||
from app.core.base_schema import AuthSchema
|
||||
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
|
||||
@@ -30,7 +31,7 @@ MIDDLEWARE_CONFIG_KEYS: tuple[str, ...] = (
|
||||
|
||||
# 内存缓存(按租户隔离)
|
||||
_MID_CONFIG_TTL: float = 60.0
|
||||
_mid_config_cache: dict[int, dict] = {}
|
||||
_mid_config_cache: dict[int, tuple[float, dict]] = {}
|
||||
|
||||
|
||||
def _parse_bool(value: object) -> bool:
|
||||
@@ -98,15 +99,14 @@ def _parse_value(key: str, value: object) -> object:
|
||||
if value is None:
|
||||
return 90
|
||||
try:
|
||||
return int(value)
|
||||
return int(str(value))
|
||||
except (TypeError, ValueError):
|
||||
return 90
|
||||
return value
|
||||
|
||||
|
||||
class ParamsService:
|
||||
"""
|
||||
参数管理服务
|
||||
"""参数管理服务
|
||||
|
||||
设计:实例方法承载「当前用户上下文 (auth)」,``redis`` 仍是方法参数
|
||||
(因为不是每个端点都用到)。调用方写法由
|
||||
@@ -117,8 +117,7 @@ class ParamsService:
|
||||
self.auth = auth
|
||||
|
||||
async def detail(self, id: int) -> ParamsOutSchema:
|
||||
"""
|
||||
获取参数详情
|
||||
"""获取参数详情
|
||||
|
||||
参数:
|
||||
- id (int): 参数ID
|
||||
@@ -126,11 +125,11 @@ class ParamsService:
|
||||
返回:
|
||||
- ParamsOutSchema: 参数响应模型
|
||||
"""
|
||||
return await ParamsCRUD(self.auth).get_or_404(id=id, out_schema=ParamsOutSchema)
|
||||
obj = await ParamsCRUD(self.auth).get_or_404(id=id)
|
||||
return ParamsOutSchema.model_validate(obj)
|
||||
|
||||
async def get_by_key(self, config_key: str) -> ParamsOutSchema:
|
||||
"""
|
||||
根据配置键获取参数详情
|
||||
"""根据配置键获取参数详情
|
||||
|
||||
参数:
|
||||
- config_key (str): 参数键名
|
||||
@@ -143,28 +142,12 @@ class ParamsService:
|
||||
raise CustomException(msg="该数据不存在")
|
||||
return ParamsOutSchema.model_validate(obj)
|
||||
|
||||
async def get_config_value_by_key(self, config_key: str) -> str | None:
|
||||
"""
|
||||
根据配置键获取参数值
|
||||
|
||||
参数:
|
||||
- config_key (str): 参数键名
|
||||
|
||||
返回:
|
||||
- str | None: 参数键值字符串或 None
|
||||
"""
|
||||
obj = await ParamsCRUD(self.auth).get(config_key=config_key)
|
||||
if not obj:
|
||||
raise CustomException(msg="该数据不存在")
|
||||
return obj.config_value
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
search: ParamsQueryParam | None = None,
|
||||
order_by: list[dict] | None = None,
|
||||
) -> list[ParamsOutSchema]:
|
||||
"""
|
||||
获取配置管理型列表
|
||||
"""获取配置管理型列表
|
||||
|
||||
参数:
|
||||
- search (ParamsQueryParam | None): 查询参数对象
|
||||
@@ -182,9 +165,8 @@ class ParamsService:
|
||||
page_size: int,
|
||||
search: ParamsQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
分页查询系统参数(数据库 OFFSET/LIMIT)。
|
||||
) -> PageResultSchema[ParamsOutSchema]:
|
||||
"""分页查询系统参数(数据库 OFFSET/LIMIT)。
|
||||
|
||||
参数:
|
||||
- page_no (int): 页码(从 1 开始)
|
||||
@@ -193,7 +175,7 @@ class ParamsService:
|
||||
- order_by (list[dict[str, str]] | None): 排序字段列表
|
||||
|
||||
返回:
|
||||
- dict: 分页结果(结构由 ``CRUD.page`` 返回约定)
|
||||
- PageResultSchema[ParamsOutSchema]: 分页结果
|
||||
"""
|
||||
offset = (page_no - 1) * page_size
|
||||
return await ParamsCRUD(self.auth).page(
|
||||
@@ -205,8 +187,7 @@ class ParamsService:
|
||||
)
|
||||
|
||||
async def create(self, redis: Redis, data: ParamsCreateSchema) -> ParamsOutSchema:
|
||||
"""
|
||||
创建配置管理型
|
||||
"""创建配置管理型
|
||||
|
||||
参数:
|
||||
- redis (Redis): Redis 客户端实例
|
||||
@@ -223,7 +204,10 @@ class ParamsService:
|
||||
out = ParamsOutSchema.model_validate(obj)
|
||||
|
||||
# 同步redis
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{self.auth.user.tenant_id}:{data.config_key}"
|
||||
user = self.auth.user
|
||||
if not user:
|
||||
raise CustomException(msg="未登录")
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{user.tenant_id}:{data.config_key}"
|
||||
try:
|
||||
redis_payload = out.model_dump(mode="json")
|
||||
value = json.dumps(redis_payload, ensure_ascii=False)
|
||||
@@ -242,8 +226,7 @@ class ParamsService:
|
||||
return out
|
||||
|
||||
async def update(self, redis: Redis, id: int, data: ParamsUpdateSchema) -> ParamsOutSchema:
|
||||
"""
|
||||
更新参数
|
||||
"""更新参数
|
||||
|
||||
参数:
|
||||
- redis (Redis): Redis 客户端实例
|
||||
@@ -264,7 +247,10 @@ class ParamsService:
|
||||
redis_payload = out.model_dump(mode="json")
|
||||
|
||||
# 同步redis
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{self.auth.user.tenant_id}:{new_obj.config_key}"
|
||||
user = self.auth.user
|
||||
if not user:
|
||||
raise CustomException(msg="未登录")
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{user.tenant_id}:{new_obj.config_key}"
|
||||
try:
|
||||
value = json.dumps(redis_payload, ensure_ascii=False)
|
||||
result = await RedisCURD(redis).set(
|
||||
@@ -280,13 +266,12 @@ class ParamsService:
|
||||
raise CustomException(msg="同步配置到缓存失败") from e
|
||||
|
||||
# 失效中间件内存缓存,让下次请求重新加载
|
||||
_invalidate_mid_config_cache(self.auth.user.tenant_id)
|
||||
_invalidate_mid_config_cache(user.tenant_id)
|
||||
|
||||
return out
|
||||
|
||||
async def delete(self, redis: Redis, ids: list[int]) -> None:
|
||||
"""
|
||||
删除配置管理型
|
||||
"""删除配置管理型
|
||||
|
||||
参数:
|
||||
- redis (Redis): Redis 客户端实例
|
||||
@@ -310,8 +295,11 @@ class ParamsService:
|
||||
await ParamsCRUD(self.auth).delete(ids=ids)
|
||||
|
||||
# 同步删除Redis缓存(使用删除前已获取的对象信息)
|
||||
user = self.auth.user
|
||||
if not user:
|
||||
raise CustomException(msg="未登录")
|
||||
for obj in objs:
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{self.auth.user.tenant_id}:{obj.config_key}"
|
||||
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{user.tenant_id}:{obj.config_key}"
|
||||
try:
|
||||
await RedisCURD(redis).delete(redis_key)
|
||||
except Exception as e:
|
||||
@@ -319,11 +307,10 @@ class ParamsService:
|
||||
raise CustomException(msg="同步删除缓存失败") from e
|
||||
|
||||
# 失效中间件内存缓存
|
||||
_invalidate_mid_config_cache(self.auth.user.tenant_id)
|
||||
_invalidate_mid_config_cache(user.tenant_id)
|
||||
|
||||
async def batch_set_status(self, ids: list[int], status: int) -> None:
|
||||
"""
|
||||
批量设置系统参数状态
|
||||
"""批量设置系统参数状态
|
||||
|
||||
参数:
|
||||
- ids (list[int]): 系统参数ID列表
|
||||
@@ -339,8 +326,7 @@ class ParamsService:
|
||||
|
||||
@staticmethod
|
||||
def export(data_list: list[dict]) -> bytes:
|
||||
"""
|
||||
导出参数列表(无状态工具方法)
|
||||
"""导出参数列表(无状态工具方法)
|
||||
|
||||
参数:
|
||||
- data_list (list[dict]): 参数字典列表
|
||||
@@ -370,14 +356,13 @@ class ParamsService:
|
||||
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
|
||||
|
||||
@staticmethod
|
||||
async def _load_all_configs_from_db() -> list:
|
||||
async with async_db_session() as session:
|
||||
async with session.begin():
|
||||
init_auth = AuthSchema(db=session, check_data_scope=False)
|
||||
return await ParamsCRUD(init_auth).get_list()
|
||||
async def _load_all_configs_from_db() -> Sequence[object]:
|
||||
async with async_db_session() as session, session.begin():
|
||||
init_auth = AuthSchema.anonymous(db=session)
|
||||
return await ParamsCRUD(init_auth).get_list()
|
||||
|
||||
@staticmethod
|
||||
async def _sync_configs_to_redis(redis: Redis, config_obj: list) -> list[dict]:
|
||||
async def _sync_configs_to_redis(redis: Redis, config_obj: Sequence) -> list[dict]:
|
||||
"""将 DB 配置写入 Redis,返回对应的 dict 列表。"""
|
||||
configs: list[dict] = []
|
||||
for config in config_obj:
|
||||
@@ -421,8 +406,7 @@ class ParamsService:
|
||||
|
||||
@staticmethod
|
||||
async def get_system_config_for_middleware(redis: Redis, tenant_id: int = 1) -> dict:
|
||||
"""
|
||||
获取中间件 / 调度器所需的系统配置(带 60 秒内存缓存,按租户隔离)。
|
||||
"""获取中间件 / 调度器所需的系统配置(带 60 秒内存缓存,按租户隔离)。
|
||||
|
||||
参数:
|
||||
- redis (Redis): Redis 客户端实例
|
||||
@@ -445,10 +429,7 @@ class ParamsService:
|
||||
|
||||
停用(status=1)的配置视为未配置,使用默认值。
|
||||
"""
|
||||
config_keys = [
|
||||
f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{tenant_id}:{key}"
|
||||
for key in MIDDLEWARE_CONFIG_KEYS
|
||||
]
|
||||
config_keys = [f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{tenant_id}:{key}" for key in MIDDLEWARE_CONFIG_KEYS]
|
||||
config_values = await RedisCURD(redis).mget(config_keys)
|
||||
|
||||
result: dict[str, object] = {}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# 见 docs/PLUGIN_ARCHITECTURE.md
|
||||
|
||||
name = "platform"
|
||||
title = "平台"
|
||||
version = "1.0.0"
|
||||
description = "平台功能;路由由 module_platform/**/controller 动态注册。"
|
||||
optional = true
|
||||
tags = ["platform"]
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query
|
||||
from fastapi import APIRouter, Body, Path, Query, Security, status
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi_cache import FastAPICache
|
||||
from fastapi_cache.decorator import cache
|
||||
@@ -18,10 +18,11 @@ PositionRouter = APIRouter(route_class=OperationLogRoute, prefix="/position", ta
|
||||
|
||||
_POS_NS = "position"
|
||||
|
||||
|
||||
@PositionRouter.get("/list", summary="查询岗位", response_model=ResponseSchema[PageResultSchema[PositionOutSchema]])
|
||||
@cache(expire=300, namespace=_POS_NS)
|
||||
async def get_obj_list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:position:query"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:position:query"]))],
|
||||
page: Annotated[PaginationQueryParam, Query(description="分页参数")],
|
||||
search: Annotated[PositionQueryParam, Query(description="岗位查询参数")],
|
||||
) -> JSONResponse:
|
||||
@@ -36,58 +37,72 @@ async def get_obj_list_controller(
|
||||
)
|
||||
return SuccessResponse(data=result_dict, msg="查询岗位列表成功")
|
||||
|
||||
|
||||
@PositionRouter.get("/detail/{id}", summary="查询岗位详情", response_model=ResponseSchema[PositionOutSchema])
|
||||
async def get_obj_detail_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:position:detail"]))],
|
||||
id: Annotated[int, Path(description="岗位ID")],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:position:detail"]))],
|
||||
id: Annotated[int, Path(description="岗位ID", ge=1)],
|
||||
) -> JSONResponse:
|
||||
result_dict = await PositionService(auth).detail(id=id)
|
||||
return SuccessResponse(data=result_dict, msg="获取岗位详情成功")
|
||||
|
||||
@PositionRouter.post("/create", summary="创建岗位", response_model=ResponseSchema[PositionOutSchema])
|
||||
|
||||
@PositionRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建岗位", response_model=ResponseSchema[PositionOutSchema])
|
||||
async def create_obj_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:position:create"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:position:create"]))],
|
||||
data: Annotated[PositionCreateSchema, Body(description="岗位创建参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await PositionService(auth).create(data=data)
|
||||
await FastAPICache.clear(namespace=_POS_NS)
|
||||
return SuccessResponse(data=result_dict, msg="创建岗位成功")
|
||||
|
||||
|
||||
@PositionRouter.put("/update/{id}", summary="修改岗位", response_model=ResponseSchema[PositionOutSchema])
|
||||
async def update_obj_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:position:update"]))],
|
||||
id: Annotated[int, Path(description="岗位ID")],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:position:update"]))],
|
||||
id: Annotated[int, Path(description="岗位ID", ge=1)],
|
||||
data: Annotated[PositionUpdateSchema, Body(description="岗位修改参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await PositionService(auth).update(id=id, data=data)
|
||||
await FastAPICache.clear(namespace=_POS_NS)
|
||||
return SuccessResponse(data=result_dict, msg="修改岗位成功")
|
||||
|
||||
|
||||
@PositionRouter.delete("/delete", summary="删除岗位", response_model=ResponseSchema[None])
|
||||
async def delete_obj_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:position:delete"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:position:delete"]))],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
) -> JSONResponse:
|
||||
await PositionService(auth).delete(ids=ids)
|
||||
await FastAPICache.clear(namespace=_POS_NS)
|
||||
return SuccessResponse(msg="删除岗位成功")
|
||||
|
||||
|
||||
@PositionRouter.patch("/status/batch", summary="批量修改岗位状态", response_model=ResponseSchema[None])
|
||||
async def batch_set_available_obj_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:position:patch"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:position:patch"]))],
|
||||
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
|
||||
) -> JSONResponse:
|
||||
await PositionService(auth).set_available(data=data)
|
||||
await FastAPICache.clear(namespace=_POS_NS)
|
||||
return SuccessResponse(msg="批量修改岗位状态成功")
|
||||
|
||||
|
||||
@PositionRouter.get("/options", summary="获取岗位下拉选项", response_model=ResponseSchema[list[dict[str, int | str]]])
|
||||
async def get_position_options_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:position:query"]))],
|
||||
) -> JSONResponse:
|
||||
options = await PositionService(auth).get_options()
|
||||
return SuccessResponse(data=options, msg="获取岗位选项成功")
|
||||
|
||||
|
||||
@PositionRouter.get("/export", summary="导出岗位")
|
||||
async def export_obj_list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:position:export"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:position:export"]))],
|
||||
search: Annotated[PositionQueryParam, Query(description="岗位查询参数")],
|
||||
) -> StreamingResponse[bytes]:
|
||||
) -> StreamingResponse:
|
||||
position_query_result = await PositionService(auth).get_list(search=search)
|
||||
position_export_result = PositionService.export_list(position_list=position_query_result)
|
||||
position_export_result = PositionService.export_list(position_list=[item.model_dump() for item in position_query_result])
|
||||
|
||||
return StreamResponse(
|
||||
data=bytes2file_response(position_export_result),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
from app.core.base_crud import CRUDBase
|
||||
from app.core.base_schema import AuthSchema
|
||||
|
||||
@@ -10,3 +12,8 @@ class PositionCRUD(CRUDBase[PositionModel, PositionCreateSchema, PositionUpdateS
|
||||
|
||||
def __init__(self, auth: AuthSchema) -> None:
|
||||
super().__init__(model=PositionModel, auth=auth)
|
||||
|
||||
async def get_options(self) -> list[dict[str, Any]]:
|
||||
"""获取岗位下拉选项,返回 [{value, label}]"""
|
||||
items = await self.get_list(search={"status": 0})
|
||||
return [{"value": item.id, "label": item.name} for item in items]
|
||||
|
||||
@@ -10,9 +10,7 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class PositionModel(ModelMixin, TenantMixin, UserMixin):
|
||||
"""
|
||||
岗位模型
|
||||
"""
|
||||
"""岗位模型"""
|
||||
|
||||
__tablename__: str = "sys_position"
|
||||
__table_args__: dict[str, str] = {"comment": "岗位表"}
|
||||
|
||||
@@ -50,12 +50,12 @@ class PositionOutSchema(PositionCreateSchema, BaseSchema, UserBySchema, TenantBy
|
||||
class PositionQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
|
||||
"""岗位管理查询参数"""
|
||||
|
||||
name: str | None = Field(None, description="岗位名称")
|
||||
status: int | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)")
|
||||
name: str | tuple[str, str] | None = Field(None, description="岗位名称")
|
||||
status: int | tuple[str, int] | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query_params(self) -> "PositionQueryParam":
|
||||
if self.name:
|
||||
if isinstance(self.name, str):
|
||||
self.name = (QueueEnum.like.value, self.name)
|
||||
if isinstance(self.status, int):
|
||||
self.status = (QueueEnum.eq.value, self.status)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from app.core.base_schema import AuthSchema, BatchSetAvailable
|
||||
from typing import Any
|
||||
|
||||
from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema
|
||||
from app.core.exceptions import CustomException
|
||||
from app.utils.excel_util import ExcelUtil
|
||||
|
||||
@@ -12,8 +14,7 @@ from .schema import (
|
||||
|
||||
|
||||
class PositionService:
|
||||
"""
|
||||
岗位管理服务
|
||||
"""岗位管理服务
|
||||
|
||||
提供岗位 CRUD、批量启/禁用、Excel 导出等业务能力。
|
||||
"""
|
||||
@@ -22,7 +23,12 @@ class PositionService:
|
||||
self.auth = auth
|
||||
|
||||
async def detail(self, id: int) -> PositionOutSchema:
|
||||
return await PositionCRUD(self.auth).get_or_404(id=id, out_schema=PositionOutSchema)
|
||||
obj = await PositionCRUD(self.auth).get_or_404(id=id)
|
||||
return PositionOutSchema.model_validate(obj)
|
||||
|
||||
async def get_options(self) -> list[dict[str, Any]]:
|
||||
"""获取岗位下拉选项,委托给 PositionCRUD"""
|
||||
return await PositionCRUD(self.auth).get_options()
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
@@ -38,7 +44,7 @@ class PositionService:
|
||||
page_size: int,
|
||||
search: PositionQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> dict:
|
||||
) -> PageResultSchema[PositionOutSchema]:
|
||||
offset = (page_no - 1) * page_size
|
||||
return await PositionCRUD(self.auth).page(
|
||||
offset=offset,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query
|
||||
from fastapi import APIRouter, Body, Path, Query, Security, status
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi_cache import FastAPICache
|
||||
from fastapi_cache.decorator import cache
|
||||
@@ -18,10 +18,11 @@ RoleRouter = APIRouter(route_class=OperationLogRoute, prefix="/role", tags=["角
|
||||
|
||||
_ROLE_NS = "role"
|
||||
|
||||
|
||||
@RoleRouter.get("/list", summary="查询角色", response_model=ResponseSchema[PageResultSchema[RoleOutSchema]])
|
||||
@cache(expire=300, namespace=_ROLE_NS)
|
||||
async def get_role_list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:query"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:role:query"]))],
|
||||
page: Annotated[PaginationQueryParam, Query(description="分页参数")],
|
||||
search: Annotated[RoleQueryParam, Query(description="角色查询参数")],
|
||||
) -> JSONResponse:
|
||||
@@ -36,67 +37,82 @@ async def get_role_list_controller(
|
||||
)
|
||||
return SuccessResponse(data=result_dict, msg="查询角色成功")
|
||||
|
||||
|
||||
@RoleRouter.get("/detail/{id}", summary="查询角色详情", response_model=ResponseSchema[RoleOutSchema])
|
||||
async def get_role_detail_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:detail"]))],
|
||||
id: Annotated[int, Path(description="角色ID")],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:role:detail"]))],
|
||||
id: Annotated[int, Path(description="角色ID", ge=1)],
|
||||
) -> JSONResponse:
|
||||
result_dict = await RoleService(auth).detail(id=id)
|
||||
return SuccessResponse(data=result_dict, msg="获取角色详情成功")
|
||||
|
||||
@RoleRouter.post("/create", summary="创建角色", response_model=ResponseSchema[RoleOutSchema])
|
||||
|
||||
@RoleRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建角色", response_model=ResponseSchema[RoleOutSchema])
|
||||
async def create_role_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:create"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:role:create"]))],
|
||||
data: Annotated[RoleCreateSchema, Body(description="角色创建参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await RoleService(auth).create(data=data)
|
||||
await FastAPICache.clear(namespace=_ROLE_NS)
|
||||
return SuccessResponse(data=result_dict, msg="创建角色成功")
|
||||
|
||||
|
||||
@RoleRouter.put("/update/{id}", summary="修改角色", response_model=ResponseSchema[RoleOutSchema])
|
||||
async def update_role_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:update"]))],
|
||||
id: Annotated[int, Path(description="角色ID")],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:role:update"]))],
|
||||
id: Annotated[int, Path(description="角色ID", ge=1)],
|
||||
data: Annotated[RoleUpdateSchema, Body(description="角色修改参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await RoleService(auth).update(id=id, data=data)
|
||||
await FastAPICache.clear(namespace=_ROLE_NS)
|
||||
return SuccessResponse(data=result_dict, msg="修改角色成功")
|
||||
|
||||
|
||||
@RoleRouter.delete("/delete", summary="删除角色", response_model=ResponseSchema[None])
|
||||
async def delete_role_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:delete"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:role:delete"]))],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
) -> JSONResponse:
|
||||
await RoleService(auth).delete(ids=ids)
|
||||
await FastAPICache.clear(namespace=_ROLE_NS)
|
||||
return SuccessResponse(msg="删除角色成功")
|
||||
|
||||
|
||||
@RoleRouter.patch("/status/batch", summary="批量修改角色状态", response_model=ResponseSchema[None])
|
||||
async def batch_set_available_role_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:patch"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:role:patch"]))],
|
||||
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
|
||||
) -> JSONResponse:
|
||||
await RoleService(auth).set_available(data=data)
|
||||
await FastAPICache.clear(namespace=_ROLE_NS)
|
||||
return SuccessResponse(msg="批量修改角色状态成功")
|
||||
|
||||
|
||||
@RoleRouter.put("/permission", summary="角色授权", response_model=ResponseSchema[None])
|
||||
async def set_role_permission_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:permission"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:role:permission"]))],
|
||||
data: Annotated[RolePermissionSettingSchema, Body(description="角色授权参数")],
|
||||
) -> JSONResponse:
|
||||
await RoleService(auth).set_permission(data=data)
|
||||
await FastAPICache.clear(namespace=_ROLE_NS)
|
||||
return SuccessResponse(msg="授权角色成功")
|
||||
|
||||
|
||||
@RoleRouter.get("/options", summary="获取角色下拉选项", response_model=ResponseSchema[list[dict[str, int | str]]])
|
||||
async def get_role_options_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:role:query"]))],
|
||||
) -> JSONResponse:
|
||||
options = await RoleService(auth).get_options()
|
||||
return SuccessResponse(data=options, msg="获取角色选项成功")
|
||||
|
||||
|
||||
@RoleRouter.get("/export", summary="导出角色")
|
||||
async def export_role_list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:role:export"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:role:export"]))],
|
||||
search: Annotated[RoleQueryParam, Query(description="角色查询参数")],
|
||||
) -> StreamingResponse[bytes]:
|
||||
) -> StreamingResponse:
|
||||
role_query_result = await RoleService(auth).get_list(search=search)
|
||||
role_export_result = RoleService.export_list(role_list=role_query_result)
|
||||
role_export_result = RoleService.export_list(role_list=[item.model_dump() for item in role_query_result])
|
||||
|
||||
return StreamResponse(
|
||||
data=bytes2file_response(role_export_result),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
from app.api.v1.module_platform.menu.crud import MenuCRUD
|
||||
from app.api.v1.module_system.dept.crud import DeptCRUD
|
||||
from app.core.base_crud import CRUDBase
|
||||
@@ -15,8 +17,7 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]):
|
||||
super().__init__(model=RoleModel, auth=auth)
|
||||
|
||||
async def set_role_menus_crud(self, role_ids: list[int], menu_ids: list[int]) -> None:
|
||||
"""
|
||||
设置角色的菜单权限
|
||||
"""设置角色的菜单权限
|
||||
|
||||
参数:
|
||||
- role_ids (list[int]): 角色ID列表
|
||||
@@ -25,27 +26,26 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]):
|
||||
返回:
|
||||
- None
|
||||
"""
|
||||
from app.api.v1.module_platform.package.service import PackageService
|
||||
|
||||
roles = await self.get_list(search={"id": ("in", role_ids)})
|
||||
menus = [] if not menu_ids else await MenuCRUD(self.auth).get_list(search={"id": ("in", menu_ids)})
|
||||
|
||||
from app.api.v1.module_platform.package.service import PackageService
|
||||
|
||||
if self.auth.user and not self.auth.user.is_superuser and self.auth.tenant_id:
|
||||
allowed_menu_ids = await PackageService.get_tenant_available_menu_ids(self.auth, self.auth.tenant_id)
|
||||
allowed_set = set(allowed_menu_ids)
|
||||
# 非超管需校验菜单在租户套餐范围内
|
||||
user = self.auth.user
|
||||
if user and not user.is_superuser and user.tenant_id:
|
||||
allowed_set = set[int](await PackageService(self.auth).get_tenant_available_menu_ids(user.tenant_id))
|
||||
for menu in menus:
|
||||
if int(menu.id) not in allowed_set:
|
||||
raise CustomException(msg=f"菜单[{menu.name}]不在当前租户的功能组内,无法分配")
|
||||
|
||||
for obj in roles:
|
||||
relationship = obj.menus
|
||||
relationship.clear()
|
||||
relationship.extend(menus)
|
||||
obj.menus.clear()
|
||||
obj.menus.extend(menus)
|
||||
await self.auth.db.flush()
|
||||
|
||||
async def set_role_depts_crud(self, role_ids: list[int], dept_ids: list[int]) -> None:
|
||||
"""
|
||||
设置角色的部门权限
|
||||
"""设置角色的部门权限
|
||||
|
||||
参数:
|
||||
- role_ids (list[int]): 角色ID列表
|
||||
@@ -62,3 +62,8 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]):
|
||||
relationship.clear()
|
||||
relationship.extend(depts)
|
||||
await self.auth.db.flush()
|
||||
|
||||
async def get_options(self) -> list[dict[str, Any]]:
|
||||
"""获取角色下拉选项,返回 [{value, label}]"""
|
||||
items = await self.get_list(search={"status": 0})
|
||||
return [{"value": item.id, "label": item.name} for item in items]
|
||||
|
||||
@@ -13,8 +13,7 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class RoleMenusModel(MappedBase):
|
||||
"""
|
||||
角色菜单关联表
|
||||
"""角色菜单关联表
|
||||
|
||||
定义角色与菜单的多对多关系,用于权限控制
|
||||
"""
|
||||
@@ -37,8 +36,7 @@ class RoleMenusModel(MappedBase):
|
||||
|
||||
|
||||
class RoleDeptsModel(MappedBase):
|
||||
"""
|
||||
角色部门关联表
|
||||
"""角色部门关联表
|
||||
|
||||
定义角色与部门的多对多关系,用于数据权限控制
|
||||
仅当角色的data_scope=5(自定义数据权限)时使用此表
|
||||
@@ -62,8 +60,7 @@ class RoleDeptsModel(MappedBase):
|
||||
|
||||
|
||||
class RoleModel(ModelMixin, TenantMixin, UserMixin):
|
||||
"""
|
||||
角色模型
|
||||
"""角色模型
|
||||
|
||||
角色列表只显示当前用户绑定的角色
|
||||
"""
|
||||
|
||||
@@ -17,8 +17,7 @@ from app.core.validator import (
|
||||
|
||||
|
||||
class RoleCreateSchema(BaseModel):
|
||||
"""
|
||||
角色创建模型
|
||||
"""角色创建模型
|
||||
"""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=64, description="角色名称")
|
||||
@@ -58,8 +57,7 @@ class RoleCreateSchema(BaseModel):
|
||||
|
||||
|
||||
class RolePermissionSettingSchema(BaseModel):
|
||||
"""
|
||||
角色权限配置模型
|
||||
"""角色权限配置模型
|
||||
"""
|
||||
|
||||
data_scope: int = Field(
|
||||
@@ -74,8 +72,7 @@ class RolePermissionSettingSchema(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_fields(self):
|
||||
"""
|
||||
校验角色权限配置字段(数据范围与关联 ID 等)。
|
||||
"""校验角色权限配置字段(数据范围与关联 ID 等)。
|
||||
|
||||
返回:
|
||||
- RolePermissionSettingSchema: 通过 `role_permission_request_validator` 校验后的同一实例。
|
||||
@@ -84,14 +81,12 @@ class RolePermissionSettingSchema(BaseModel):
|
||||
|
||||
|
||||
class RoleUpdateSchema(RoleCreateSchema):
|
||||
"""
|
||||
角色更新模型
|
||||
"""角色更新模型
|
||||
"""
|
||||
|
||||
|
||||
class RoleOutSchema(RoleCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
|
||||
"""
|
||||
角色信息响应模型
|
||||
"""角色信息响应模型
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -101,20 +96,19 @@ class RoleOutSchema(RoleCreateSchema, BaseSchema, UserBySchema, TenantBySchema):
|
||||
|
||||
|
||||
class RoleQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
|
||||
"""
|
||||
角色管理查询参数
|
||||
"""角色管理查询参数
|
||||
"""
|
||||
|
||||
name: str | None = Field(None, description="角色名称")
|
||||
code: str | None = Field(None, description="角色编码")
|
||||
status: int | None = Field(None, description="状态(0:启动 1:停用)")
|
||||
name: str | tuple[str, str] | None = Field(None, description="角色名称")
|
||||
code: str | tuple[str, str] | None = Field(None, description="角色编码")
|
||||
status: int | tuple[str, int] | None = Field(None, description="状态(0:启动 1:停用)")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query_params(self) -> "RoleQueryParam":
|
||||
if self.name:
|
||||
if isinstance(self.name, str):
|
||||
self.name = (QueueEnum.like.value, self.name)
|
||||
if self.code:
|
||||
if isinstance(self.code, str):
|
||||
self.code = (QueueEnum.like.value, self.code)
|
||||
if self.status is not None:
|
||||
if isinstance(self.status, int):
|
||||
self.status = (QueueEnum.eq.value, self.status)
|
||||
return self
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Any
|
||||
|
||||
from app.core.base_schema import AuthSchema, BatchSetAvailable
|
||||
from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema
|
||||
from app.core.exceptions import CustomException
|
||||
from app.utils.excel_util import ExcelUtil
|
||||
|
||||
@@ -15,8 +15,7 @@ from .schema import (
|
||||
|
||||
|
||||
class RoleService:
|
||||
"""
|
||||
角色管理服务
|
||||
"""角色管理服务
|
||||
|
||||
提供角色 CRUD、权限配置、数据权限范围设置、批量启/禁用、Excel 导出等业务能力。
|
||||
"""
|
||||
@@ -25,8 +24,7 @@ class RoleService:
|
||||
self.auth = auth
|
||||
|
||||
async def detail(self, id: int) -> RoleOutSchema:
|
||||
"""
|
||||
获取角色详情
|
||||
"""获取角色详情
|
||||
|
||||
参数:
|
||||
- id (int): 角色ID
|
||||
@@ -34,15 +32,19 @@ class RoleService:
|
||||
返回:
|
||||
- RoleOutSchema: 角色详情响应模型
|
||||
"""
|
||||
return await RoleCRUD(self.auth).get_or_404(id=id, out_schema=RoleOutSchema)
|
||||
obj = await RoleCRUD(self.auth).get_or_404(id=id)
|
||||
return RoleOutSchema.model_validate(obj)
|
||||
|
||||
async def get_options(self) -> list[dict[str, Any]]:
|
||||
"""获取角色下拉选项,委托给 RoleCRUD"""
|
||||
return await RoleCRUD(self.auth).get_options()
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
search: RoleQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> list[RoleOutSchema]:
|
||||
"""
|
||||
获取角色列表
|
||||
"""获取角色列表
|
||||
|
||||
参数:
|
||||
- search (RoleQueryParam | None): 查询参数模型
|
||||
@@ -60,9 +62,8 @@ class RoleService:
|
||||
page_size: int,
|
||||
search: RoleQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
分页查询角色(数据库 OFFSET/LIMIT)。
|
||||
) -> PageResultSchema[RoleOutSchema]:
|
||||
"""分页查询角色(数据库 OFFSET/LIMIT)。
|
||||
|
||||
参数:
|
||||
- page_no (int): 页码(从 1 开始)
|
||||
@@ -102,14 +103,13 @@ class RoleService:
|
||||
raise CustomException(msg="创建失败,编码已存在")
|
||||
|
||||
# 检查租户配额
|
||||
await TenantService(self.auth).check_quota(self.auth.tenant_id, "role")
|
||||
await TenantService(self.auth).check_quota(self.auth.user.tenant_id, "role")
|
||||
|
||||
new_role = await RoleCRUD(self.auth).create(data=data)
|
||||
return RoleOutSchema.model_validate(new_role)
|
||||
|
||||
async def update(self, id: int, data: RoleUpdateSchema) -> RoleOutSchema:
|
||||
"""
|
||||
更新角色
|
||||
"""更新角色
|
||||
|
||||
参数:
|
||||
- id (int): 角色ID
|
||||
@@ -129,8 +129,7 @@ class RoleService:
|
||||
return RoleOutSchema.model_validate(updated_role)
|
||||
|
||||
async def delete(self, ids: list[int]) -> None:
|
||||
"""
|
||||
删除角色
|
||||
"""删除角色
|
||||
|
||||
参数:
|
||||
- ids (list[int]): 角色ID列表
|
||||
@@ -149,8 +148,7 @@ class RoleService:
|
||||
await RoleCRUD(self.auth).delete(ids=ids)
|
||||
|
||||
async def set_permission(self, data: RolePermissionSettingSchema) -> None:
|
||||
"""
|
||||
设置角色权限
|
||||
"""设置角色权限
|
||||
|
||||
参数:
|
||||
- data (RolePermissionSettingSchema): 角色权限设置模型
|
||||
@@ -171,8 +169,7 @@ class RoleService:
|
||||
await RoleCRUD(self.auth).set_role_depts_crud(role_ids=data.role_ids, dept_ids=[])
|
||||
|
||||
async def set_available(self, data: BatchSetAvailable) -> None:
|
||||
"""
|
||||
设置角色可用状态
|
||||
"""设置角色可用状态
|
||||
|
||||
参数:
|
||||
- data (BatchSetAvailable): 批量设置可用状态模型
|
||||
@@ -189,8 +186,7 @@ class RoleService:
|
||||
|
||||
@staticmethod
|
||||
def export_list(role_list: list[dict[str, Any]]) -> bytes:
|
||||
"""
|
||||
导出角色列表
|
||||
"""导出角色列表
|
||||
|
||||
参数:
|
||||
- role_list (list[dict[str, Any]]): 角色详情字典列表
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Path, Query
|
||||
from fastapi import APIRouter, Body, Path, Query, Security, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.common.response import ResponseSchema, SuccessResponse
|
||||
@@ -8,14 +8,15 @@ from app.core.base_schema import AuthSchema, PageResultSchema, PaginationQueryPa
|
||||
from app.core.dependencies import AuthPermission
|
||||
from app.core.router_class import OperationLogRoute
|
||||
|
||||
from .schema import TicketBatchSchema, TicketCreateSchema, TicketOutSchema, TicketQueryParam, TicketUpdateSchema
|
||||
from .service import TicketService
|
||||
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, Depends(AuthPermission(["module_system:ticket:list"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:query"]))],
|
||||
page: Annotated[PaginationQueryParam, Query(description="分页参数")],
|
||||
search: Annotated[TicketQueryParam, Query(description="工单查询参数")],
|
||||
) -> JSONResponse:
|
||||
@@ -27,43 +28,68 @@ async def ticket_list_controller(
|
||||
)
|
||||
return SuccessResponse(data=result, msg="查询成功")
|
||||
|
||||
|
||||
@TicketRouter.get("/detail/{id}", summary="获取工单详情", response_model=ResponseSchema[TicketOutSchema])
|
||||
async def ticket_detail_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:detail"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:detail"]))],
|
||||
id: Annotated[int, Path(description="工单ID")],
|
||||
) -> JSONResponse:
|
||||
result = await TicketService(auth).detail(id=id)
|
||||
return SuccessResponse(data=result, msg="查询成功")
|
||||
|
||||
@TicketRouter.post("/create", summary="创建工单", response_model=ResponseSchema[TicketOutSchema])
|
||||
|
||||
@TicketRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建工单", response_model=ResponseSchema[TicketOutSchema])
|
||||
async def ticket_create_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:create"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:create"]))],
|
||||
data: Annotated[TicketCreateSchema, Body(description="工单创建参数")],
|
||||
) -> JSONResponse:
|
||||
result = await TicketService(auth).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, Depends(AuthPermission(["module_system:ticket:update"]))],
|
||||
id: Annotated[int, Path(description="工单ID")],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:update"]))],
|
||||
id: Annotated[int, Path(description="工单ID", ge=1)],
|
||||
data: Annotated[TicketUpdateSchema, Body(description="工单更新参数")],
|
||||
) -> JSONResponse:
|
||||
result = await TicketService(auth).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, Depends(AuthPermission(["module_system:ticket:update"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:update"]))],
|
||||
data: Annotated[TicketBatchSchema, Body(description="工单批量更新参数")],
|
||||
) -> JSONResponse:
|
||||
await TicketService(auth).batch(data=data)
|
||||
return SuccessResponse(msg="批量操作成功")
|
||||
|
||||
|
||||
@TicketRouter.delete("/delete", summary="删除工单", response_model=ResponseSchema[None])
|
||||
async def ticket_delete_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:ticket:delete"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:delete"]))],
|
||||
ids: Annotated[list[int], Body(description="工单ID列表")],
|
||||
) -> JSONResponse:
|
||||
await TicketService(auth).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"]))],
|
||||
ticket_id: Annotated[int, Path(description="工单ID")],
|
||||
page: Annotated[PaginationQueryParam, Query(description="分页参数")],
|
||||
) -> JSONResponse:
|
||||
result = await TicketCommentService(auth).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"]))],
|
||||
ticket_id: Annotated[int, Path(description="工单ID")],
|
||||
data: Annotated[TicketCommentCreateSchema, Body(description="评论内容")],
|
||||
) -> JSONResponse:
|
||||
result = await TicketCommentService(auth).create(ticket_id=ticket_id, data=data)
|
||||
return SuccessResponse(data=result, msg="评论成功")
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from typing import Any
|
||||
|
||||
from app.core.base_crud import CRUDBase
|
||||
from app.core.base_schema import AuthSchema
|
||||
|
||||
from .model import TicketModel
|
||||
from .schema import TicketCreateSchema, TicketUpdateSchema
|
||||
from .model import TicketCommentModel, TicketModel
|
||||
from .schema import TicketCommentCreateSchema, TicketCreateSchema, TicketUpdateSchema
|
||||
|
||||
|
||||
class TicketCRUD(CRUDBase[TicketModel, TicketCreateSchema, TicketUpdateSchema]):
|
||||
@@ -10,3 +12,10 @@ class TicketCRUD(CRUDBase[TicketModel, TicketCreateSchema, TicketUpdateSchema]):
|
||||
|
||||
def __init__(self, auth: AuthSchema) -> None:
|
||||
super().__init__(model=TicketModel, auth=auth)
|
||||
|
||||
|
||||
class TicketCommentCRUD(CRUDBase[TicketCommentModel, TicketCommentCreateSchema, Any]):
|
||||
"""工单评论 CRUD"""
|
||||
|
||||
def __init__(self, auth: AuthSchema) -> None:
|
||||
super().__init__(model=TicketCommentModel, auth=auth)
|
||||
|
||||
@@ -47,3 +47,17 @@ class TicketModel(ModelMixin, TenantMixin, UserMixin):
|
||||
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,7 +1,15 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.common.enums import QueueEnum, TicketTypeEnum
|
||||
from app.core.base_schema import BaseQueryParam, BaseSchema, CommonSchema, TenantByQueryParam, TenantBySchema, UserByQueryParam, UserBySchema
|
||||
from app.core.base_schema import (
|
||||
BaseQueryParam,
|
||||
BaseSchema,
|
||||
CommonSchema,
|
||||
TenantByQueryParam,
|
||||
TenantBySchema,
|
||||
UserByQueryParam,
|
||||
UserBySchema,
|
||||
)
|
||||
|
||||
|
||||
class TicketCreateSchema(BaseModel):
|
||||
@@ -78,19 +86,32 @@ class TicketBatchSchema(BaseModel):
|
||||
class TicketQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
|
||||
"""工单查询参数"""
|
||||
|
||||
title: str | None = Field(None, description="工单标题")
|
||||
ticket_type: str | None = Field(None, description="工单类型")
|
||||
assigned_id: int | None = Field(None, description="处理人ID")
|
||||
status: int | None = Field(None, ge=0, le=3, description="状态(0:待处理 1:处理中 2:已完成 3:已关闭)")
|
||||
title: str | tuple[str, str] | None = Field(None, description="工单标题")
|
||||
ticket_type: str | tuple[str, str] | None = Field(None, description="工单类型")
|
||||
assigned_id: int | tuple[str, int] | None = Field(None, description="处理人ID")
|
||||
status: int | tuple[str, int] | None = Field(None, ge=0, le=3, description="状态(0:待处理 1:处理中 2:已完成 3:已关闭)")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query_params(self) -> "TicketQueryParam":
|
||||
if self.title:
|
||||
if isinstance(self.title, str):
|
||||
self.title = (QueueEnum.like.value, self.title)
|
||||
if self.ticket_type:
|
||||
self.ticket_type = (QueueEnum.eq.value, self.ticket_type)
|
||||
if self.assigned_id:
|
||||
if isinstance(self.ticket_type, str):
|
||||
self.ticket_type = (QueueEnum.like.value, self.ticket_type)
|
||||
if isinstance(self.assigned_id, int):
|
||||
self.assigned_id = (QueueEnum.eq.value, self.assigned_id)
|
||||
if isinstance(self.status, int):
|
||||
self.status = (QueueEnum.eq.value, self.status)
|
||||
return self
|
||||
|
||||
|
||||
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,13 +1,14 @@
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.v1.module_system.user.model import UserModel
|
||||
from app.core.base_schema import AuthSchema
|
||||
from app.core.base_schema import AuthSchema, PageResultSchema
|
||||
from app.core.exceptions import CustomException
|
||||
|
||||
from .crud import TicketCRUD
|
||||
from .crud import TicketCommentCRUD, TicketCRUD
|
||||
from .schema import (
|
||||
TicketBatchSchema,
|
||||
TicketCommentCreateSchema,
|
||||
TicketCommentOutSchema,
|
||||
TicketCreateSchema,
|
||||
TicketOutSchema,
|
||||
TicketQueryParam,
|
||||
@@ -43,9 +44,10 @@ class TicketService:
|
||||
if new_status not in _TICKET_STATUS_TRANSITIONS.get(old_status, set()):
|
||||
raise CustomException(msg=f"不允许从{old_label}转换为{new_label}")
|
||||
|
||||
is_super = self.auth.user and self.auth.user.is_superuser
|
||||
is_creator = self.auth.user and ticket.created_id == self.auth.user.id
|
||||
is_assignee = self.auth.user and ticket.assigned_id == self.auth.user.id
|
||||
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:
|
||||
@@ -72,7 +74,7 @@ class TicketService:
|
||||
page_size: int,
|
||||
search: TicketQueryParam | None = None,
|
||||
order_by: list | None = None,
|
||||
) -> dict:
|
||||
) -> PageResultSchema[TicketOutSchema]:
|
||||
return await TicketCRUD(self.auth).page(
|
||||
offset=(page_no - 1) * page_size,
|
||||
limit=page_size,
|
||||
@@ -82,7 +84,8 @@ class TicketService:
|
||||
)
|
||||
|
||||
async def detail(self, id: int) -> TicketOutSchema:
|
||||
return await TicketCRUD(self.auth).get_or_404(id=id, out_schema=TicketOutSchema)
|
||||
obj = await TicketCRUD(self.auth).get_or_404(id=id)
|
||||
return TicketOutSchema.model_validate(obj)
|
||||
|
||||
async def create(self, data: TicketCreateSchema) -> TicketOutSchema:
|
||||
obj = await TicketCRUD(self.auth).create(data=data)
|
||||
@@ -111,6 +114,21 @@ class TicketService:
|
||||
updated = await TicketCRUD(self.auth).update(id=id, data=data)
|
||||
if not updated:
|
||||
raise CustomException(msg="工单不存在")
|
||||
|
||||
# 有回复内容时 SSE 推送通知给工单创建者
|
||||
if data.reply and obj.created_id:
|
||||
from app.core.event_bus import EventBus
|
||||
|
||||
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:
|
||||
@@ -122,11 +140,40 @@ class TicketService:
|
||||
if not data.ids:
|
||||
raise CustomException(msg="请选择要操作的工单")
|
||||
|
||||
tickets = await TicketCRUD(self.auth).get_by_ids_crud(ids=data.ids)
|
||||
tickets = await TicketCRUD(self.auth).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).set_crud(ids=data.ids, status=data.status)
|
||||
await TicketCRUD(self.auth).set(ids=data.ids, status=data.status)
|
||||
|
||||
|
||||
class TicketCommentService:
|
||||
"""工单评论服务"""
|
||||
|
||||
def __init__(self, auth: AuthSchema) -> None:
|
||||
self.auth = auth
|
||||
|
||||
async def page(self, ticket_id: int, page_no: int, page_size: int) -> PageResultSchema[TicketCommentOutSchema]:
|
||||
return await TicketCommentCRUD(self.auth).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).get_or_404(id=ticket_id, msg="工单不存在")
|
||||
create_data = data.model_dump() | {"ticket_id": ticket_id}
|
||||
obj = await TicketCommentCRUD(self.auth).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).get_or_404(id=comment_id, msg="评论不存在")
|
||||
await TicketCommentCRUD(self.auth).delete(ids=[comment_id])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import urllib.parse
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, File, Path, Query, UploadFile
|
||||
from fastapi import APIRouter, Body, Depends, File, Path, Query, Security, UploadFile, status
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -20,13 +20,13 @@ from .schema import (
|
||||
UserForgetPasswordSchema,
|
||||
UserOutSchema,
|
||||
UserQueryParam,
|
||||
UserRegisterSchema,
|
||||
UserUpdateSchema,
|
||||
)
|
||||
from .service import UserService
|
||||
|
||||
UserRouter = APIRouter(route_class=OperationLogRoute, prefix="/user", tags=["用户管理"])
|
||||
|
||||
|
||||
@UserRouter.get("/current/info", summary="查询当前用户信息", response_model=ResponseSchema[UserOutSchema])
|
||||
async def get_current_user_info_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
@@ -34,7 +34,8 @@ async def get_current_user_info_controller(
|
||||
user_dict = await UserService(auth).current_info()
|
||||
return SuccessResponse(data=user_dict, msg="获取当前用户信息成功")
|
||||
|
||||
@UserRouter.put("/current/info/update",summary="更新当前用户基本信息",response_model=ResponseSchema[UserOutSchema])
|
||||
|
||||
@UserRouter.put("/current/info/update", summary="更新当前用户基本信息", response_model=ResponseSchema[UserOutSchema])
|
||||
async def update_current_user_info_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
data: Annotated[CurrentUserUpdateSchema, Body(description="更新用户基本信息参数")],
|
||||
@@ -42,6 +43,7 @@ async def update_current_user_info_controller(
|
||||
result_dict = await UserService(auth).update_current_info(data=data)
|
||||
return SuccessResponse(data=result_dict, msg="更新当前用户基本信息成功")
|
||||
|
||||
|
||||
@UserRouter.put("/password/change", summary="修改当前用户密码", response_model=ResponseSchema[UserOutSchema])
|
||||
async def change_current_user_password_controller(
|
||||
auth: Annotated[AuthSchema, Depends(get_current_user)],
|
||||
@@ -50,39 +52,32 @@ async def change_current_user_password_controller(
|
||||
result_dict = await UserService(auth).change_password(data=data)
|
||||
return SuccessResponse(data=result_dict, msg="修改密码成功, 请重新登录")
|
||||
|
||||
|
||||
@UserRouter.put("/password/reset/{id}", summary="重置用户密码", response_model=ResponseSchema[UserOutSchema])
|
||||
async def reset_password_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:update"]))],
|
||||
id: Annotated[int, Path(description="用户ID")],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:user:update"]))],
|
||||
id: Annotated[int, Path(description="用户ID", ge=1)],
|
||||
data: Annotated[ResetPasswordSchema, Body(description="重置用户密码参数")],
|
||||
) -> JSONResponse:
|
||||
data.id = id
|
||||
result_dict = await UserService(auth).reset_password(data=data)
|
||||
return SuccessResponse(data=result_dict, msg="重置密码成功")
|
||||
|
||||
@UserRouter.post("/register", summary="注册用户", response_model=ResponseSchema[UserOutSchema])
|
||||
async def register_user_controller(
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
data: Annotated[UserRegisterSchema, Body(description="注册用户参数")],
|
||||
) -> JSONResponse:
|
||||
auth = AuthSchema(db=db, check_data_scope=False)
|
||||
user_register_result = await UserService(auth).register(data=data)
|
||||
logger.info(f"{data.username} 注册用户成功: {user_register_result}")
|
||||
return SuccessResponse(data=user_register_result, msg="注册用户成功")
|
||||
|
||||
@UserRouter.post("/password/forget", summary="忘记密码", response_model=ResponseSchema[UserOutSchema])
|
||||
async def forget_password_controller(
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
data: Annotated[UserForgetPasswordSchema, Body(description="忘记密码参数")],
|
||||
) -> JSONResponse:
|
||||
auth = AuthSchema(db=db, check_data_scope=False)
|
||||
auth = AuthSchema.anonymous(db=db)
|
||||
user_forget_password_result = await UserService(auth).forget_password(data=data)
|
||||
logger.info(f"{data.username} 重置密码成功: {user_forget_password_result}")
|
||||
logger.info(f"{data.username} 重置密码成功")
|
||||
return SuccessResponse(data=user_forget_password_result, msg="重置密码成功")
|
||||
|
||||
|
||||
@UserRouter.get("/list", summary="查询用户", response_model=ResponseSchema[PageResultSchema[UserOutSchema]])
|
||||
async def get_user_list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:query"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:user:query"]))],
|
||||
page: Annotated[PaginationQueryParam, Query(description="分页参数")],
|
||||
search: Annotated[UserQueryParam, Query(description="用户查询参数")],
|
||||
) -> JSONResponse:
|
||||
@@ -94,49 +89,55 @@ async def get_user_list_controller(
|
||||
)
|
||||
return SuccessResponse(data=result_dict, msg="查询用户成功")
|
||||
|
||||
|
||||
@UserRouter.get("/detail/{id}", summary="查询用户详情", response_model=ResponseSchema[UserOutSchema])
|
||||
async def get_user_detail_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:detail"]))],
|
||||
id: Annotated[int, Path(description="用户ID")],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:user:detail"]))],
|
||||
id: Annotated[int, Path(description="用户ID", ge=1)],
|
||||
) -> JSONResponse:
|
||||
result_dict = await UserService(auth).detail(id=id)
|
||||
return SuccessResponse(data=result_dict, msg="获取用户详情成功")
|
||||
|
||||
@UserRouter.post("/create", summary="创建用户", response_model=ResponseSchema[UserOutSchema])
|
||||
|
||||
@UserRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建用户", response_model=ResponseSchema[UserOutSchema])
|
||||
async def create_user_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:create"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:user:create"]))],
|
||||
data: Annotated[UserCreateSchema, Body(description="创建用户参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await UserService(auth).create(data=data)
|
||||
return SuccessResponse(data=result_dict, msg="创建用户成功")
|
||||
|
||||
|
||||
@UserRouter.put("/update/{id}", summary="修改用户", response_model=ResponseSchema[UserOutSchema])
|
||||
async def update_user_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:update"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:user:update"]))],
|
||||
id: Annotated[int, Path(description="用户ID")],
|
||||
data: Annotated[UserUpdateSchema, Body(description="修改用户参数")],
|
||||
) -> JSONResponse:
|
||||
result_dict = await UserService(auth).update(id=id, data=data)
|
||||
return SuccessResponse(data=result_dict, msg="修改用户成功")
|
||||
|
||||
|
||||
@UserRouter.delete("/delete", summary="删除用户", response_model=ResponseSchema[None])
|
||||
async def delete_user_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:delete"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:user:delete"]))],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
) -> JSONResponse:
|
||||
await UserService(auth).delete(ids=ids)
|
||||
return SuccessResponse(msg="删除用户成功")
|
||||
|
||||
|
||||
@UserRouter.patch("/status/batch", summary="批量修改用户状态", response_model=ResponseSchema[None])
|
||||
async def batch_set_available_user_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:patch"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:user:patch"]))],
|
||||
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
|
||||
) -> JSONResponse:
|
||||
await UserService(auth).set_available(data=data)
|
||||
return SuccessResponse(msg="批量修改用户状态成功")
|
||||
|
||||
@UserRouter.get("/import/template", summary="获取用户导入模板", dependencies=[Depends(AuthPermission(["module_system:user:download"]))])
|
||||
async def export_user_import_template_controller() -> StreamingResponse[bytes]:
|
||||
|
||||
@UserRouter.get("/import/template", summary="获取用户导入模板", dependencies=[Security(AuthPermission(["module_system:user:download"]))])
|
||||
async def export_user_import_template_controller() -> StreamingResponse:
|
||||
user_import_template_result = UserService.get_import_template()
|
||||
|
||||
return StreamResponse(
|
||||
@@ -148,14 +149,15 @@ async def export_user_import_template_controller() -> StreamingResponse[bytes]:
|
||||
},
|
||||
)
|
||||
|
||||
@UserRouter.get("/export", summary="导出用户", response_model=StreamResponse[bytes])
|
||||
|
||||
@UserRouter.get("/export", summary="导出用户")
|
||||
async def export_user_list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:export"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:user:export"]))],
|
||||
page: Annotated[PaginationQueryParam, Query(description="分页参数")],
|
||||
search: Annotated[UserQueryParam, Query(description="用户查询参数")],
|
||||
) -> StreamingResponse[bytes]:
|
||||
) -> StreamingResponse:
|
||||
user_list = await UserService(auth).get_list(search=search, order_by=page.order_by)
|
||||
user_export_result = UserService.export_list(user_list=user_list)
|
||||
user_export_result = UserService.export_list(user_list=[item.model_dump() for item in user_list])
|
||||
|
||||
return StreamResponse(
|
||||
data=bytes2file_response(user_export_result),
|
||||
@@ -163,10 +165,11 @@ async def export_user_list_controller(
|
||||
headers={"Content-Disposition": "attachment; filename=user.xlsx"},
|
||||
)
|
||||
|
||||
|
||||
@UserRouter.post("/import/data", summary="导入用户", response_model=ResponseSchema[None])
|
||||
async def import_user_list_controller(
|
||||
file: Annotated[UploadFile, File(description="用户导入文件")],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_system:user:import"]))],
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:user:import"]))],
|
||||
) -> JSONResponse:
|
||||
batch_import_result = await UserService(auth).batch_import(file=file, update_support=True)
|
||||
return SuccessResponse(data=batch_import_result, msg="导入用户成功")
|
||||
|
||||
@@ -19,8 +19,7 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]):
|
||||
super().__init__(model=UserModel, auth=auth)
|
||||
|
||||
async def update_last_login(self, id: int) -> None:
|
||||
"""
|
||||
更新用户最后登录时间
|
||||
"""更新用户最后登录时间
|
||||
|
||||
参数:
|
||||
- id (int): 用户ID
|
||||
@@ -28,8 +27,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列表
|
||||
@@ -38,9 +36,16 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]):
|
||||
返回:
|
||||
- None
|
||||
"""
|
||||
from app.core.exceptions import CustomException
|
||||
|
||||
user_objs = await self.get_list(search={"id": ("in", user_ids)})
|
||||
if role_ids:
|
||||
role_objs = await RoleCRUD(self.auth).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 = []
|
||||
|
||||
@@ -51,8 +56,7 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]):
|
||||
await self.auth.db.flush()
|
||||
|
||||
async def set_user_positions(self, user_ids: list[int], position_ids: list[int]) -> None:
|
||||
"""
|
||||
批量设置用户岗位
|
||||
"""批量设置用户岗位(带租户隔离验证)
|
||||
|
||||
参数:
|
||||
- user_ids (list[int]): 用户ID列表
|
||||
@@ -61,9 +65,16 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]):
|
||||
返回:
|
||||
- None
|
||||
"""
|
||||
from app.core.exceptions import CustomException
|
||||
|
||||
user_objs = await self.get_list(search={"id": ("in", user_ids)})
|
||||
if position_ids:
|
||||
position_objs = await PositionCRUD(self.auth).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 = []
|
||||
|
||||
@@ -74,8 +85,7 @@ class UserCRUD(CRUDBase[UserModel, UserCreateSchema, UserUpdateSchema]):
|
||||
await self.auth.db.flush()
|
||||
|
||||
async def change_password(self, id: int, password_hash: str) -> UserModel:
|
||||
"""
|
||||
修改用户密码
|
||||
"""修改用户密码
|
||||
|
||||
参数:
|
||||
- id (int): 用户ID
|
||||
|
||||
@@ -14,8 +14,7 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class UserRolesModel(MappedBase):
|
||||
"""
|
||||
用户角色关联表
|
||||
"""用户角色关联表
|
||||
|
||||
定义用户与角色的多对多关系
|
||||
"""
|
||||
@@ -38,8 +37,7 @@ class UserRolesModel(MappedBase):
|
||||
|
||||
|
||||
class UserPositionsModel(MappedBase):
|
||||
"""
|
||||
用户岗位关联表
|
||||
"""用户岗位关联表
|
||||
|
||||
定义用户与岗位的多对多关系
|
||||
"""
|
||||
@@ -62,8 +60,7 @@ class UserPositionsModel(MappedBase):
|
||||
|
||||
|
||||
class UserModel(ModelMixin, TenantMixin, UserMixin):
|
||||
"""
|
||||
用户模型
|
||||
"""用户模型
|
||||
"""
|
||||
|
||||
__tablename__: str = "sys_user"
|
||||
|
||||
@@ -66,61 +66,10 @@ class CurrentUserUpdateSchema(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class UserRegisterSchema(BaseModel):
|
||||
"""注册"""
|
||||
|
||||
name: str | None = Field(default=None, max_length=32, description="姓名")
|
||||
mobile: str | None = Field(default=None, max_length=11, description="手机号")
|
||||
username: str = Field(..., min_length=3, max_length=32, description="账号")
|
||||
password: str = Field(..., min_length=6, max_length=128, description="密码")
|
||||
role_ids: list[int] | None = Field(default=[1], description="角色ID列表")
|
||||
created_id: int | None = Field(default=1, description="创建人ID")
|
||||
description: str | None = Field(default=None, max_length=255, description="备注")
|
||||
|
||||
@field_validator("mobile")
|
||||
@classmethod
|
||||
def validate_mobile(cls, value: str | None):
|
||||
"""校验手机号格式"""
|
||||
return mobile_validator(value)
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def validate_username(cls, value: str):
|
||||
"""校验账号:字母开头,3-32 位,仅含字母/数字/_ . -"""
|
||||
v = value.strip()
|
||||
if not v:
|
||||
raise ValueError("账号不能为空")
|
||||
import re
|
||||
|
||||
if not re.match(r"^[A-Za-z][A-Za-z0-9_.-]{2,31}$", v):
|
||||
raise ValueError("账号需以字母开头,3-32 位,仅允许字母、数字、_ . -")
|
||||
return v
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def validate_password(cls, value: str):
|
||||
"""校验密码:6-128 位"""
|
||||
if len(value) < 6:
|
||||
raise ValueError("密码长度不能少于 6 位")
|
||||
if len(value) > 128:
|
||||
raise ValueError("密码长度不能超过 128 位")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_model(self):
|
||||
"""校验注册信息长度约束"""
|
||||
if self.name and len(self.name) > 32:
|
||||
raise ValueError("姓名长度不能超过 32 个字符")
|
||||
if self.username and len(self.username) > 32:
|
||||
raise ValueError("账号长度不能超过 32 个字符")
|
||||
if self.description and len(self.description) > 255:
|
||||
raise ValueError("备注长度不能超过 255 个字符")
|
||||
return self
|
||||
|
||||
|
||||
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="手机号")
|
||||
@@ -190,8 +139,7 @@ class ResetPasswordSchema(BaseModel):
|
||||
|
||||
|
||||
class UserCreateSchema(CurrentUserUpdateSchema):
|
||||
"""
|
||||
新增用户
|
||||
"""新增用户
|
||||
"""
|
||||
|
||||
username: str | None = Field(default=None, max_length=32, description="用户名")
|
||||
@@ -242,6 +190,7 @@ class UserUpdateSchema(CurrentUserUpdateSchema):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
username: str | None = Field(default=None, max_length=32, description="用户名")
|
||||
password: str | None = Field(default=None, min_length=6, max_length=128, description="密码")
|
||||
status: int | None = Field(default=None, ge=0, le=1, description="状态(0:启动 1:停用)")
|
||||
description: str | None = Field(default=None, max_length=255, description="备注")
|
||||
dept_id: int | None = Field(default=None, description="部门ID")
|
||||
@@ -270,18 +219,22 @@ class UserUpdateSchema(CurrentUserUpdateSchema):
|
||||
return v
|
||||
|
||||
|
||||
class UserOutSchema(UserUpdateSchema, BaseSchema, UserBySchema, TenantBySchema):
|
||||
class UserOutSchema(BaseSchema, UserBySchema, TenantBySchema):
|
||||
"""响应"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True, from_attributes=True)
|
||||
|
||||
username: str | None = Field(default=None, max_length=32, description="用户名")
|
||||
|
||||
tenant_id: int | None = Field(
|
||||
default=None,
|
||||
exclude=True,
|
||||
description="创建入参使用;列表/详情出参见 tenant",
|
||||
)
|
||||
name: str | None = Field(default=None, max_length=32, description="名称")
|
||||
mobile: str | None = Field(default=None, max_length=11, description="手机号")
|
||||
email: EmailStr | None = Field(default=None, description="邮箱")
|
||||
gender: str | None = Field(default=None, max_length=1, description="性别(0:男 1:女 2:未知)")
|
||||
avatar: str | None = Field(default=None, max_length=255, description="头像")
|
||||
status: int | None = Field(default=0, ge=0, le=1, description="状态(0:启动 1:停用)")
|
||||
description: str | None = Field(default=None, max_length=255, description="备注")
|
||||
dept_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列表")
|
||||
gitee_login: str | None = Field(default=None, max_length=32, description="Gitee登录")
|
||||
github_login: str | None = Field(default=None, max_length=32, description="Github登录")
|
||||
wx_login: str | None = Field(default=None, max_length=32, description="微信登录")
|
||||
@@ -291,11 +244,11 @@ class UserOutSchema(UserUpdateSchema, BaseSchema, UserBySchema, TenantBySchema):
|
||||
positions: list[CommonSchema] | None = Field(default=[], description="岗位")
|
||||
roles: list[RoleOutSchema] | None = Field(default=[], description="角色")
|
||||
menus: list[MenuOutSchema] | None = Field(default=[], description="菜单")
|
||||
is_impersonate: bool = Field(default=False, description="是否为平台管理员代签入")
|
||||
|
||||
|
||||
class UserQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
|
||||
"""
|
||||
用户管理查询参数(继承标准 Mixin)
|
||||
"""用户管理查询参数(继承标准 Mixin)
|
||||
|
||||
支持:
|
||||
- 时间范围(BaseQueryParam)
|
||||
@@ -304,29 +257,33 @@ class UserQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam):
|
||||
- 业务字段:用户名、名称、手机号、邮箱、部门、状态
|
||||
"""
|
||||
|
||||
username: str | None = Field(None, description="用户名")
|
||||
name: str | None = Field(None, description="名称")
|
||||
mobile: str | None = Field(None, description="手机号", pattern=r"^1[3-9]\d{9}$")
|
||||
email: str | None = Field(
|
||||
username: str | tuple[str, str] | None = Field(None, description="用户名")
|
||||
name: str | tuple[str, str] | None = Field(None, description="名称")
|
||||
mobile: str | tuple[str, str] | None = Field(None, description="手机号", pattern=r"^1[3-9]\d{9}$")
|
||||
email: str | tuple[str, str] | None = Field(
|
||||
None,
|
||||
description="邮箱",
|
||||
pattern=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$",
|
||||
)
|
||||
dept_id: int | None = Field(None, description="部门ID")
|
||||
status: int | None = Field(None, description="是否可用")
|
||||
dept_id: int | tuple[str, int] | None = Field(None, description="部门ID")
|
||||
status: int | tuple[str, int] | None = Field(None, description="是否可用")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query_params(self) -> "UserQueryParam":
|
||||
if self.username:
|
||||
if isinstance(self.username, str):
|
||||
self.username = (QueueEnum.like.value, self.username)
|
||||
if self.name:
|
||||
if isinstance(self.name, str):
|
||||
self.name = (QueueEnum.like.value, self.name)
|
||||
if self.mobile:
|
||||
if isinstance(self.mobile, str):
|
||||
self.mobile = (QueueEnum.like.value, self.mobile)
|
||||
if self.email:
|
||||
if isinstance(self.email, str):
|
||||
self.email = (QueueEnum.like.value, self.email)
|
||||
if self.dept_id:
|
||||
if isinstance(self.dept_id, int):
|
||||
self.dept_id = (QueueEnum.eq.value, self.dept_id)
|
||||
if self.status is not None:
|
||||
if isinstance(self.status, int):
|
||||
self.status = (QueueEnum.eq.value, self.status)
|
||||
return self
|
||||
|
||||
|
||||
UserBySchema.model_rebuild()
|
||||
TenantBySchema.model_rebuild()
|
||||
|
||||
@@ -5,7 +5,7 @@ from fastapi import UploadFile
|
||||
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
|
||||
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 traversal_to_tree
|
||||
@@ -21,7 +21,6 @@ from .schema import (
|
||||
UserForgetPasswordSchema,
|
||||
UserOutSchema,
|
||||
UserQueryParam,
|
||||
UserRegisterSchema,
|
||||
UserUpdateSchema,
|
||||
)
|
||||
|
||||
@@ -54,7 +53,7 @@ class UserService:
|
||||
page_size: int,
|
||||
search: UserQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> dict:
|
||||
) -> PageResultSchema[UserOutSchema]:
|
||||
offset = (page_no - 1) * page_size
|
||||
return await UserCRUD(self.auth).page(
|
||||
offset=offset,
|
||||
@@ -80,12 +79,11 @@ class UserService:
|
||||
if not dept:
|
||||
raise CustomException(msg="该数据不存在")
|
||||
|
||||
await TenantService(self.auth).check_quota(self.auth.tenant_id, "user")
|
||||
await TenantService(self.auth).check_quota(self.auth.user.tenant_id, "user")
|
||||
|
||||
if data.password:
|
||||
data.password = PwdUtil.hash_password(password=data.password)
|
||||
user_dict = data.model_dump(exclude_unset=True, exclude={"role_ids", "position_ids"})
|
||||
new_user = await UserCRUD(self.auth).create(data=user_dict)
|
||||
new_user = await UserCRUD(self.auth).create(data=data)
|
||||
if data.role_ids and len(data.role_ids) > 0:
|
||||
await UserCRUD(self.auth).set_user_roles(user_ids=[new_user.id], role_ids=data.role_ids)
|
||||
if data.position_ids and len(data.position_ids) > 0:
|
||||
@@ -151,7 +149,7 @@ class UserService:
|
||||
raise CustomException(msg="超级管理员不能删除")
|
||||
if user.status == 0:
|
||||
raise CustomException(msg="用户已启用,不能删除")
|
||||
if self.auth.user and self.auth.user.id == uid:
|
||||
if self.auth.user.id == uid:
|
||||
raise CustomException(msg="不能删除当前登陆用户")
|
||||
|
||||
await UserCRUD(self.auth).set_user_roles(user_ids=ids, role_ids=[])
|
||||
@@ -162,26 +160,31 @@ class UserService:
|
||||
from app.api.v1.module_platform.menu.crud import MenuCRUD
|
||||
from app.api.v1.module_platform.menu.schema import MenuOutSchema
|
||||
from app.api.v1.module_platform.package.service import PackageService
|
||||
from app.core.base_schema import CommonSchema
|
||||
|
||||
if not self.auth.user or not self.auth.user.id:
|
||||
if not self.auth.user.id:
|
||||
raise CustomException(msg="该数据不存在")
|
||||
user = await UserCRUD(self.auth).get(id=self.auth.user.id)
|
||||
user_dict = UserOutSchema.model_validate(user)
|
||||
if user and 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.session_info.get("is_impersonate", False) if self.auth.session_info else False
|
||||
|
||||
_pc_only = {"client": "pc"}
|
||||
if self.auth.user and self.auth.user.is_superuser:
|
||||
if self.auth.user.is_superuser:
|
||||
scope_filter = {"scope": "tenant"} if self.auth.user.tenant_id else {"scope": "platform"}
|
||||
menu_all = await MenuCRUD(self.auth).tree_list(
|
||||
search={"type": ("in", [1, 2, 3, 4]), "status": 0, **_pc_only},
|
||||
search={"type": ("in", [1, 2, 3, 4]), "status": 0, **_pc_only, **scope_filter},
|
||||
order_by=[{"order": "asc"}],
|
||||
)
|
||||
menus = [MenuOutSchema.model_validate(menu) for menu in menu_all]
|
||||
else:
|
||||
menu_ids = {menu.id for role in self.auth.user.roles or [] for menu in role.menus if menu.status == 0 and getattr(menu, "client", "pc") == "pc"}
|
||||
menu_ids = set(self.auth.session_info.get("menu_ids", [])) if self.auth.session_info else set()
|
||||
|
||||
if menu_ids and self.auth.tenant_id:
|
||||
allowed_ids = await PackageService(self.auth).get_tenant_available_menu_ids(self.auth.tenant_id)
|
||||
if menu_ids and self.auth.user.tenant_id:
|
||||
allowed_ids = await PackageService(self.auth).get_tenant_available_menu_ids(self.auth.user.tenant_id)
|
||||
allowed_set = set(allowed_ids)
|
||||
menu_ids = menu_ids & allowed_set
|
||||
|
||||
@@ -196,11 +199,12 @@ class UserService:
|
||||
if menu_ids
|
||||
else []
|
||||
)
|
||||
user_dict.menus = traversal_to_tree([menu.model_dump() for menu in menus])
|
||||
menus = traversal_to_tree([menu.model_dump() for menu in menus])
|
||||
user_dict.menus = None
|
||||
return user_dict
|
||||
|
||||
async def update_current_info(self, data: CurrentUserUpdateSchema) -> UserOutSchema:
|
||||
if not self.auth.user or not self.auth.user.id:
|
||||
if not self.auth.user.id:
|
||||
raise CustomException(msg="该数据不存在")
|
||||
user = await UserCRUD(self.auth).get(id=self.auth.user.id)
|
||||
if not user:
|
||||
@@ -220,14 +224,14 @@ class UserService:
|
||||
return UserOutSchema.model_validate(new_user)
|
||||
|
||||
async def set_available(self, data: BatchSetAvailable) -> None:
|
||||
for mid in data.ids:
|
||||
user = await UserCRUD(self.auth).get_or_404(id=mid)
|
||||
users = await UserCRUD(self.auth).get_list(search={"id": ("in", list(data.ids))})
|
||||
for user in users:
|
||||
if user.is_superuser:
|
||||
raise CustomException(msg="超级管理员状态不能修改")
|
||||
await UserCRUD(self.auth).set(ids=data.ids, status=data.status)
|
||||
|
||||
async def change_password(self, data: UserChangePasswordSchema) -> UserOutSchema:
|
||||
if not self.auth.user or not self.auth.user.id:
|
||||
if not self.auth.user.id:
|
||||
raise CustomException(msg="该数据不存在")
|
||||
if not data.old_password or not data.new_password:
|
||||
raise CustomException(msg="密码不能为空")
|
||||
@@ -257,25 +261,28 @@ class UserService:
|
||||
new_user = await UserCRUD(self.auth).change_password(id=data.id, password_hash=new_password_hash)
|
||||
return UserOutSchema.model_validate(new_user)
|
||||
|
||||
async def register(self, data: UserRegisterSchema) -> UserOutSchema:
|
||||
username_ok = await UserCRUD(self.auth).get(username=data.username)
|
||||
if username_ok:
|
||||
raise CustomException(msg="该数据已存在")
|
||||
|
||||
data.password = PwdUtil.hash_password(password=data.password)
|
||||
data.name = data.username
|
||||
create_dict = data.model_dump(exclude_unset=True, exclude={"role_ids", "position_ids"})
|
||||
|
||||
if self.auth.user and self.auth.user.id:
|
||||
create_dict["created_id"] = self.auth.user.id
|
||||
|
||||
result = await UserCRUD(self.auth).create(data=create_dict)
|
||||
if data.role_ids:
|
||||
await UserCRUD(self.auth).set_user_roles(user_ids=[result.id], role_ids=data.role_ids)
|
||||
return UserOutSchema.model_validate(result)
|
||||
|
||||
async def forget_password(self, data: UserForgetPasswordSchema) -> UserOutSchema:
|
||||
user = await UserCRUD(self.auth).get(username=data.username)
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.v1.module_platform.tenant.model import TenantModel
|
||||
|
||||
# 根据租户名称查租户
|
||||
tenant_stmt = (
|
||||
select(TenantModel)
|
||||
.where(
|
||||
TenantModel.name == data.tenant_name,
|
||||
TenantModel.status == 0,
|
||||
TenantModel.is_deleted.is_(False),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await self.auth.db.execute(tenant_stmt)
|
||||
tenant = result.scalar_one_or_none()
|
||||
if not tenant:
|
||||
raise CustomException(msg="租户不存在")
|
||||
|
||||
# 在租户范围内查找用户
|
||||
user = await UserCRUD(self.auth).get(username=data.username, tenant_id=tenant.id)
|
||||
if not user:
|
||||
raise CustomException(msg="该数据不存在")
|
||||
if user.status == 1:
|
||||
@@ -343,6 +350,15 @@ class UserService:
|
||||
error_msgs.append(f"第{i}行: 昵称不能为空")
|
||||
continue
|
||||
|
||||
dept_id = int(row["dept_id"])
|
||||
dept = await DeptCRUD(self.auth).get(id=dept_id)
|
||||
if not dept:
|
||||
error_msgs.append(f"第{i}行: 部门ID {dept_id} 不存在")
|
||||
continue
|
||||
if not self.auth.user.is_superuser and dept.tenant_id != self.auth.user.tenant_id:
|
||||
error_msgs.append(f"第{i}行: 部门ID {dept_id} 不属于当前租户")
|
||||
continue
|
||||
|
||||
user_data = {
|
||||
"username": username,
|
||||
"name": name,
|
||||
@@ -350,7 +366,7 @@ class UserService:
|
||||
"mobile": str(row["mobile"]).strip() if row.get("mobile") is not None else None,
|
||||
"gender": str(row["gender"]).strip() if row.get("gender") is not None else "1",
|
||||
"status": 0 if str(row["status"]).strip() == "正常" else 1,
|
||||
"dept_id": int(row["dept_id"]),
|
||||
"dept_id": dept_id,
|
||||
"password": PwdUtil.hash_password(password="123456"),
|
||||
}
|
||||
|
||||
@@ -367,8 +383,7 @@ class UserService:
|
||||
error_msgs.append(f"第{i}行: 用户 {user_data['username']} 已存在")
|
||||
else:
|
||||
user_create_schema = UserCreateSchema(**user_data)
|
||||
user_create_data = user_create_schema.model_dump(exclude_unset=True, exclude={"role_ids", "position_ids"})
|
||||
new_user = await UserCRUD(self.auth).create(data=user_create_data)
|
||||
new_user = await UserCRUD(self.auth).create(data=user_create_schema)
|
||||
if user_create_schema.role_ids and len(user_create_schema.role_ids) > 0:
|
||||
await UserCRUD(self.auth).set_user_roles(user_ids=[new_user.id], role_ids=user_create_schema.role_ids)
|
||||
if user_create_schema.position_ids and len(user_create_schema.position_ids) > 0:
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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 (
|
||||
VersionCreateSchema,
|
||||
VersionOutSchema,
|
||||
VersionQueryParam,
|
||||
VersionStatusSchema,
|
||||
VersionUpdateSchema,
|
||||
)
|
||||
from .service import VersionService
|
||||
|
||||
VersionRouter = APIRouter(route_class=OperationLogRoute, prefix="/versions", tags=["版本管理"])
|
||||
|
||||
|
||||
@VersionRouter.get("/list", summary="分页查询版本", response_model=ResponseSchema[PageResultSchema[VersionOutSchema]])
|
||||
async def get_version_list_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:version:query"]))],
|
||||
page: Annotated[PaginationQueryParam, Query(description="分页参数")],
|
||||
search: Annotated[VersionQueryParam, Query(description="查询参数")],
|
||||
) -> JSONResponse:
|
||||
service = VersionService(auth)
|
||||
result = await service.page(page_no=page.page_no, page_size=page.page_size, search=search)
|
||||
return SuccessResponse(data=result, msg="查询版本列表成功")
|
||||
|
||||
|
||||
@VersionRouter.get("/published", summary="已发布版本列表", response_model=ResponseSchema[list[VersionOutSchema]])
|
||||
async def get_published_versions_controller(
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
auth = AuthSchema.anonymous(db=db)
|
||||
service = VersionService(auth)
|
||||
result = await service.get_published()
|
||||
return SuccessResponse(data=result, msg="查询成功")
|
||||
|
||||
|
||||
@VersionRouter.get("/detail/{id}", summary="获取版本详情", response_model=ResponseSchema[VersionOutSchema])
|
||||
async def get_version_detail_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:version:detail"]))],
|
||||
id: Annotated[int, Path(description="版本ID")],
|
||||
) -> JSONResponse:
|
||||
service = VersionService(auth)
|
||||
result = await service.detail(id=id)
|
||||
return SuccessResponse(data=result, msg="获取版本详情成功")
|
||||
|
||||
|
||||
@VersionRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建版本", response_model=ResponseSchema[VersionOutSchema])
|
||||
async def create_version_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:version:create"]))],
|
||||
data: Annotated[VersionCreateSchema, Body(description="创建参数")],
|
||||
) -> JSONResponse:
|
||||
service = VersionService(auth)
|
||||
result = await service.create(data=data)
|
||||
return SuccessResponse(data=result, msg="创建版本成功")
|
||||
|
||||
|
||||
@VersionRouter.put("/update/{id}", summary="修改版本", response_model=ResponseSchema[VersionOutSchema])
|
||||
async def update_version_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:version:update"]))],
|
||||
id: Annotated[int, Path(description="版本ID")],
|
||||
data: Annotated[VersionUpdateSchema, Body(description="修改参数")],
|
||||
) -> JSONResponse:
|
||||
service = VersionService(auth)
|
||||
result = await service.update(id=id, data=data)
|
||||
return SuccessResponse(data=result, msg="修改版本成功")
|
||||
|
||||
|
||||
@VersionRouter.delete("/delete", summary="删除版本", response_model=ResponseSchema[None])
|
||||
async def delete_version_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:version:delete"]))],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
) -> JSONResponse:
|
||||
service = VersionService(auth)
|
||||
await service.delete(ids=ids)
|
||||
return SuccessResponse(msg="删除版本成功")
|
||||
|
||||
|
||||
@VersionRouter.put("/{id}/status", summary="变更版本状态", response_model=ResponseSchema[VersionOutSchema])
|
||||
async def set_version_status_controller(
|
||||
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:version:update"]))],
|
||||
id: Annotated[int, Path(description="版本ID")],
|
||||
data: Annotated[VersionStatusSchema, Body(description="状态参数")],
|
||||
) -> JSONResponse:
|
||||
service = VersionService(auth)
|
||||
result = await service.set_status(id=id, data=data)
|
||||
return SuccessResponse(data=result, msg="状态变更成功")
|
||||
@@ -0,0 +1,23 @@
|
||||
from app.core.base_crud import CRUDBase
|
||||
from app.core.base_schema import AuthSchema
|
||||
from app.core.exceptions import CustomException
|
||||
|
||||
from .model import VersionModel
|
||||
from .schema import VersionCreateSchema, VersionUpdateSchema
|
||||
|
||||
|
||||
class VersionCRUD(CRUDBase[VersionModel, VersionCreateSchema, VersionUpdateSchema]):
|
||||
"""版本数据层"""
|
||||
|
||||
def __init__(self, auth: AuthSchema) -> None:
|
||||
super().__init__(model=VersionModel, auth=auth)
|
||||
|
||||
async def set_status(self, id: int, status: int) -> VersionModel:
|
||||
"""更新版本状态"""
|
||||
obj = await self.get(id=id)
|
||||
if not obj:
|
||||
raise CustomException(msg="版本不存在")
|
||||
obj.status = status
|
||||
await self.db.flush()
|
||||
await self.db.refresh(obj)
|
||||
return obj
|
||||
@@ -0,0 +1,25 @@
|
||||
from sqlalchemy import Boolean, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.base_model import ModelMixin, UserMixin
|
||||
|
||||
|
||||
class VersionModel(ModelMixin, UserMixin):
|
||||
"""版本管理(平台级)"""
|
||||
|
||||
__tablename__: str = "sys_version"
|
||||
__table_args__: dict[str, str] = {"comment": "版本管理表"}
|
||||
__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=已回滚")
|
||||
require_re_login: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, comment="是否需要重新登录")
|
||||
@@ -0,0 +1,49 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.base_schema import BaseQueryParam
|
||||
|
||||
|
||||
class VersionCreateSchema(BaseModel):
|
||||
"""版本创建模型"""
|
||||
|
||||
version: str = Field(..., description="版本号")
|
||||
title: str = Field(..., description="更新标题")
|
||||
date: str = Field(..., description="发布日期")
|
||||
content: str | None = Field(default=None, description="更新内容(富文本HTML)")
|
||||
description: str | None = Field(default=None, description="备注")
|
||||
sort: int = Field(default=0, description="排序")
|
||||
status: int = Field(default=0, description="状态: 0=草稿,1=已发布,2=已回滚")
|
||||
require_re_login: bool = Field(default=False, description="是否需要重新登录")
|
||||
|
||||
|
||||
class VersionUpdateSchema(VersionCreateSchema):
|
||||
"""版本更新模型"""
|
||||
|
||||
|
||||
class VersionOutSchema(VersionCreateSchema):
|
||||
"""版本响应模型"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int = Field(description="主键ID")
|
||||
created_time: str | None = Field(default=None, description="创建时间")
|
||||
updated_time: str | None = Field(default=None, description="更新时间")
|
||||
|
||||
|
||||
class VersionStatusSchema(BaseModel):
|
||||
"""版本状态更新模型"""
|
||||
|
||||
status: int = Field(..., description="状态: 0=草稿,1=已发布,2=已回滚")
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def validate_status(cls, v: int) -> int:
|
||||
if v not in (0, 1, 2):
|
||||
raise ValueError("status must be 0, 1, or 2")
|
||||
return v
|
||||
|
||||
|
||||
class VersionQueryParam(BaseQueryParam):
|
||||
"""版本查询参数"""
|
||||
|
||||
status: int | None = Field(default=None, description="状态: 0=草稿,1=已发布,2=已回滚")
|
||||
@@ -0,0 +1,72 @@
|
||||
from app.core.base_schema import AuthSchema, PageResultSchema
|
||||
from app.core.exceptions import CustomException
|
||||
|
||||
from .crud import VersionCRUD
|
||||
from .schema import (
|
||||
VersionCreateSchema,
|
||||
VersionOutSchema,
|
||||
VersionQueryParam,
|
||||
VersionStatusSchema,
|
||||
VersionUpdateSchema,
|
||||
)
|
||||
|
||||
|
||||
class VersionService:
|
||||
"""版本管理模块服务层"""
|
||||
|
||||
def __init__(self, auth: AuthSchema) -> None:
|
||||
self.auth = auth
|
||||
|
||||
async def detail(self, id: int) -> VersionOutSchema:
|
||||
obj = await VersionCRUD(self.auth).get(id=id)
|
||||
if not obj:
|
||||
raise CustomException(msg="该数据不存在")
|
||||
return VersionOutSchema.model_validate(obj)
|
||||
|
||||
async def page(
|
||||
self,
|
||||
page_no: int,
|
||||
page_size: int,
|
||||
search: VersionQueryParam | None = None,
|
||||
order_by: list[dict[str, str]] | None = None,
|
||||
) -> PageResultSchema[VersionOutSchema]:
|
||||
offset = (page_no - 1) * page_size
|
||||
return await VersionCRUD(self.auth).page(
|
||||
offset=offset,
|
||||
limit=page_size,
|
||||
order_by=order_by or [{"sort": "asc"}, {"id": "desc"}],
|
||||
search=vars(search) if search else {},
|
||||
out_schema=VersionOutSchema,
|
||||
)
|
||||
|
||||
async def create(self, data: VersionCreateSchema) -> VersionOutSchema:
|
||||
obj = await VersionCRUD(self.auth).create(data=data)
|
||||
return VersionOutSchema.model_validate(obj)
|
||||
|
||||
async def update(self, id: int, data: VersionUpdateSchema) -> VersionOutSchema:
|
||||
obj = await VersionCRUD(self.auth).get(id=id)
|
||||
if not obj:
|
||||
raise CustomException(msg="更新失败,该数据不存在")
|
||||
obj = await VersionCRUD(self.auth).update(id=id, data=data)
|
||||
return VersionOutSchema.model_validate(obj)
|
||||
|
||||
async def delete(self, ids: list[int]) -> None:
|
||||
if len(ids) < 1:
|
||||
raise CustomException(msg="删除失败,删除对象不能为空")
|
||||
objs = await VersionCRUD(self.auth).get_list(search={"id": ("in", ids)})
|
||||
obj_map = {o.id: o for o in objs}
|
||||
for id_ in ids:
|
||||
if id_ not in obj_map:
|
||||
raise CustomException(msg="删除失败,该数据不存在")
|
||||
await VersionCRUD(self.auth).delete(ids=ids)
|
||||
|
||||
async def set_status(self, id: int, data: VersionStatusSchema) -> VersionOutSchema:
|
||||
obj = await VersionCRUD(self.auth).set_status(id=id, status=data.status)
|
||||
return VersionOutSchema.model_validate(obj)
|
||||
|
||||
async def get_published(self) -> list[VersionOutSchema]:
|
||||
objs = await VersionCRUD(self.auth).get_list(
|
||||
search={"status": ("eq", 1)},
|
||||
order_by=[{"sort": "asc"}],
|
||||
)
|
||||
return [VersionOutSchema.model_validate(obj) for obj in objs]
|
||||
Reference in New Issue
Block a user