refactor: 重构项目模块结构与代码细节优化

1.  调整后端模块路由与插件文件结构,迁移部分模块代码至api/v1目录
2.  优化代码注释格式与文档字符串,简化冗余代码
3.  添加监控仪表盘、工单评论等新模块功能
4.  修复前端部分组件交互逻辑与类型定义
5.  清理冗余的初始化数据与废弃配置文件
6.  新增租户注册表单字段与国际化支持
7.  优化HTTP请求拦截器与快捷键功能
This commit is contained in:
zhangtao
2026-07-10 00:18:11 +08:00
parent 349ab7b525
commit 5ff72b086f
323 changed files with 7633 additions and 15306 deletions
@@ -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",
]
+45 -23
View File
@@ -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="目标租户名称")
+301 -239
View File
@@ -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)],
+7 -10
View File
@@ -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)
+18 -23
View File
@@ -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 -2
View File
@@ -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):
+11 -14
View File
@@ -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")
+20 -17
View File
@@ -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
+11 -24
View File
@@ -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_dayssmtp 主机等
平台参数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),
+17 -12
View File
@@ -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):
"""
角色模型
"""角色模型
角色列表只显示当前用户绑定的角色
"""
+12 -18
View File
@@ -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="导入用户成功")
+18 -8
View File
@@ -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"
+32 -75
View File
@@ -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]