chore: 清理冗余代码与配置,优化项目结构

1.  删除无用文件与废弃代码:移除locale枚举、element-plus插件、sse路由、api token模块等
2.  简化类型导入与依赖:移除大量未使用的类型导入,统一echarts导入方式
3.  优化配置与样式:调整gitignore、样式引入顺序,新增列表动画样式
4.  修复接口与模型:修正接口返回类型、查询参数配置,更新部门模型字段
5.  优化性能与体验:添加图片懒加载,优化加载逻辑与表格渲染
6.  调整环境配置:新增并更新开发/生产环境配置文件
This commit is contained in:
zhangtao
2026-07-23 20:49:52 +08:00
parent 096e2216d6
commit 0ce31936aa
360 changed files with 8072 additions and 22122 deletions
@@ -1,6 +1,5 @@
from fastapi import APIRouter
from app.api.v1.module_system.api_token.controller import ApiTokenRouter
from app.api.v1.module_system.auth.controller import AuthRouter
from app.api.v1.module_system.dept.controller import DeptRouter
from app.api.v1.module_system.dict.controller import DictRouter
@@ -27,5 +26,4 @@ system_router.include_router(RoleRouter)
system_router.include_router(TicketRouter)
system_router.include_router(UserRouter)
system_router.include_router(VersionRouter)
system_router.include_router(ApiTokenRouter)
system_router.include_router(MenuRouter)
@@ -1,5 +0,0 @@
"""API Token 模块:租户级访问令牌(用于外部系统/API 集成调用)。
模块路径:``module_system.api_token``,通过 module_system/__init__.py 注册路由。
外部 API 端点通过 ``api_external_router`` 注册到 ``/external`` 路径下。
"""
@@ -1,107 +0,0 @@
"""API Token ControllerCRUD + reveal 二次验证
"""
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, Query, Security
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 (
ApiTokenCreatedSchema,
ApiTokenCreateSchema,
ApiTokenOutSchema,
ApiTokenQueryParam,
ApiTokenResetSchema,
ApiTokenRevealOutSchema,
ApiTokenRevealSchema,
)
from .service import ApiTokenService
ApiTokenRouter = APIRouter(route_class=OperationLogRoute, prefix="/api_token", tags=["系统-API令牌"])
@ApiTokenRouter.post("/create", summary="创建 API Token", response_model=ResponseSchema[ApiTokenCreatedSchema])
async def create_token_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:api_token:create"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[ApiTokenCreateSchema, Body(description="创建参数")],
) -> JSONResponse:
"""创建后会完整返回明文 token,请立即保存。"""
result = await ApiTokenService(auth, db).create(data=data)
return SuccessResponse(data=result, msg="创建 token 成功")
@ApiTokenRouter.get("/list", summary="查询 token 列表", response_model=ResponseSchema[PageResultSchema[ApiTokenOutSchema]])
async def get_token_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:api_token:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[ApiTokenQueryParam, Query()],
) -> JSONResponse:
result = await ApiTokenService(auth, db).page(
page_no=page.page_no,
page_size=page.page_size,
search=search,
order_by=page.order_by,
)
return SuccessResponse(data=result, msg="查询成功")
@ApiTokenRouter.get("/detail/{id}", summary="token 详情", response_model=ResponseSchema[ApiTokenOutSchema])
async def get_token_detail_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:api_token:detail"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="token ID", ge=1)],
) -> JSONResponse:
result = await ApiTokenService(auth, db).detail(id=id)
return SuccessResponse(data=result, msg="查询成功")
@ApiTokenRouter.post("/{id}/reset", summary="重置 token(重新生成 secret", response_model=ResponseSchema[ApiTokenCreatedSchema])
async def reset_token_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:api_token:reset"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="token ID", ge=1)],
data: Annotated[ApiTokenResetSchema, Body(description="可选项")],
) -> JSONResponse:
"""重置后会再次返回完整明文(仅此一次)。"""
result = await ApiTokenService(auth, db).reset(id=id, data=data)
return SuccessResponse(data=result, msg="重置 token 成功")
@ApiTokenRouter.patch("/{id}/status", summary="启用/禁用 token", response_model=ResponseSchema[None])
async def set_token_status_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:api_token:patch"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="token ID", ge=1)],
status: Annotated[int, Body(description="状态", ge=0, le=2)],
) -> JSONResponse:
await ApiTokenService(auth, db).set_status(id=id, status=status)
return SuccessResponse(msg="状态修改成功")
@ApiTokenRouter.delete("/{id}", summary="删除 token", response_model=ResponseSchema[None])
async def delete_token_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:api_token:delete"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="token ID", ge=1)],
) -> JSONResponse:
await ApiTokenService(auth, db).delete(id=id)
return SuccessResponse(msg="删除成功")
@ApiTokenRouter.post("/{id}/reveal", summary="查看 token 明文(需二次验证)", response_model=ResponseSchema[ApiTokenRevealOutSchema])
async def reveal_token_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:api_token:reveal"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="token ID", ge=1)],
data: Annotated[ApiTokenRevealSchema, Body(description="需输入当前用户密码")],
) -> JSONResponse:
"""高权限端点:会返回完整明文,需要二次密码验证。"""
result = await ApiTokenService(auth, db).reveal(id=id, data=data)
return SuccessResponse(data=result, msg="reveal 成功")
@@ -1,14 +0,0 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_crud import CRUDBase
from app.core.base_schema import AuthSchema
from .model import ApiTokenModel
from .schema import ApiTokenCreateSchema
class ApiTokenCRUD(CRUDBase[ApiTokenModel, ApiTokenCreateSchema, ApiTokenCreateSchema]):
"""平台 API Token CRUD 基础实现"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
super().__init__(auth=auth, model=ApiTokenModel, db=db)
@@ -1,40 +0,0 @@
from datetime import datetime
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
class ApiTokenModel(ModelMixin, UserMixin):
"""API 访问令牌(用于外部系统/集成调用)
token 全名格式:
fastpat_<user_id_hex>_<48-char-base64url-secret>
字段:
- ``token_plain``:明文令牌(创建时一次性返回,后续可读但不推荐直接读)
- ``token_prefix``:用于列表展示的前 12 字符
- ``scopes``JSON 数组(``["order:read", "user:write"]``),控制 API 可访问范围
- ``expires_at``:过期时间(空=永久)
- ``rate_limit``:每小时请求配额(默认 1000)
- ``status``0=启用 1=禁用 2=吊销
- ``last_used_at/used_count/last_used_ip``:调用审计
"""
__tablename__: str = "sys_api_token"
__table_args__: dict[str, str] = {"comment": "API 访问令牌"}
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by"]
name: Mapped[str] = mapped_column(String(64), nullable=False, comment="令牌名称(业务语义,如:CRM-对账集成)")
token_prefix: Mapped[str] = mapped_column(String(32), nullable=False, index=True, comment="明文 token 前 12 字符(用于展示)")
token_plain: Mapped[str] = mapped_column(Text, nullable=False, comment="明文 token(自管理,按需用于外部集成)")
owner_user_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("sys_user.id", ondelete="SET NULL", onupdate="CASCADE"), nullable=True, index=True, comment="所属用户ID(创建者/操作者)")
scopes: Mapped[str] = mapped_column(String(255), nullable=False, default="*", comment="可用 scope(逗号或 JSON 数组字符串)")
expires_at: Mapped[datetime | None] = mapped_column(nullable=True, comment="过期时间(NULL=永不过期)")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启用 1:禁用 2:吊销)", index=True)
rate_limit: Mapped[int] = mapped_column(Integer, default=1000, nullable=False, comment="每小时请求上限")
used_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="累计调用次数")
last_used_at: Mapped[datetime | None] = mapped_column(nullable=True, comment="最近一次调用时间")
last_used_ip: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="最近一次调用 IP")
description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="备注")
@@ -1,89 +0,0 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.core.base_schema import PaginationQueryParam
class ApiTokenCreateSchema(BaseModel):
"""创建 API Token"""
name: str = Field(..., min_length=2, max_length=64, description="令牌业务名称")
scopes: list[str] = Field(default_factory=lambda: ["*"], description="可用 scope``*`` 表示全部")
expires_at: datetime | None = Field(default=None, description="过期时间(NULL=永不过期)")
rate_limit: int = Field(default=1000, ge=1, le=1_000_000, description="每小时请求上限")
description: str | None = Field(default=None, max_length=512, description="备注")
class ApiTokenResetSchema(BaseModel):
"""重置(重新生成 secret)— 沿用同名 token,仅替换 secret 段"""
name: str | None = Field(default=None, max_length=64, description="新名称(不传则保持原值)")
scopes: list[str] | None = Field(default=None, description="新 scope(不传则保持原值)")
expires_at: datetime | None = Field(default=None, description="新过期时间")
rate_limit: int | None = Field(default=None, ge=1, le=1_000_000, description="新配额(NULL 保持原值)")
class ApiTokenQueryParam(PaginationQueryParam):
"""列表查询条件"""
name: str | None = Field(default=None, description="名称模糊匹配")
status: int | None = Field(default=None, description="状态精确匹配")
class ApiTokenRevealSchema(BaseModel):
"""查看明文 — 需当前用户密码二次验证"""
password: str = Field(..., min_length=6, max_length=128, description="当前用户登录密码")
class ApiTokenOutSchema(BaseModel):
"""列表/详情输出:脱敏(不含 token_plain"""
model_config = ConfigDict(from_attributes=True)
id: int
name: str
token_prefix: str = Field(..., description="明文 token 前 12 字符,用于识别")
token_mask: str = Field(..., description="脱敏展示,例如 ``fastpat_xxxx****yz3w``")
owner_user_id: int | None
scopes: str
status: int
rate_limit: int
expires_at: datetime | None
used_count: int
last_used_at: datetime | None
last_used_ip: str | None
description: str | None
created_id: int | None
updated_id: int | None
created_time: datetime | None
updated_time: datetime | None
class ApiTokenCreatedSchema(BaseModel):
"""创建/重置响应:唯一含明文 token 的输出"""
model_config = ConfigDict(from_attributes=True)
id: int
name: str
token: str = Field(..., description="完整明文 token(仅此一次返回,请妥善保管)")
token_prefix: str
scopes: list[str]
expires_at: datetime | None
rate_limit: int
status: int
created_time: datetime | None
warning: str = Field(
default="请立即保存此 token。关闭此页面后将无法再次完整查看明文,如遗失请重置。",
description="安全提示",
)
class ApiTokenRevealOutSchema(BaseModel):
"""reveal 响应:含完整明文 + 警告"""
token: str
name: str
warning: str = "此为完整明文,仅高权限场景下返回,请勿写入日志/代码/对话。"
@@ -1,298 +0,0 @@
from __future__ import annotations
import json
import secrets
from datetime import datetime
from typing import Any
from redis.asyncio.client import Redis
from sqlalchemy import update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_system.user.crud import UserCRUD
from app.core.base_schema import AuthSchema, PageResultSchema
from app.core.database import async_db_session
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.utils.password_util import PwdUtil
from .crud import ApiTokenCRUD
from .model import ApiTokenModel
from .schema import (
ApiTokenCreatedSchema,
ApiTokenCreateSchema,
ApiTokenOutSchema,
ApiTokenQueryParam,
ApiTokenResetSchema,
ApiTokenRevealOutSchema,
ApiTokenRevealSchema,
)
_TOKEN_PREFIX_HEADER = "fastpat_"
_TOKEN_PREFIX_DISPLAY_LEN = 12
_REDIS_RATE_KEY_PREFIX = "api_token:rate:"
def _generate_full_token(user_id: int) -> str:
"""生成完整 token``fastpat_<user_id_hex>_<48-base64url>``"""
secret_part = secrets.token_urlsafe(36)
return f"{_TOKEN_PREFIX_HEADER}{user_id:x}_{secret_part}"
def _mask_token(full_token: str) -> str:
"""脱敏展示:保留头部 + ``****`` + 尾部 4 字符"""
if len(full_token) <= 16:
return "****"
return f"{full_token[:14]}****{full_token[-4:]}"
def _to_out_schema(token: ApiTokenModel) -> ApiTokenOutSchema:
return ApiTokenOutSchema(
id=token.id,
name=token.name,
token_prefix=token.token_prefix,
token_mask=_mask_token(token.token_plain),
owner_user_id=token.owner_user_id,
scopes=token.scopes,
status=token.status,
rate_limit=token.rate_limit,
expires_at=token.expires_at,
used_count=token.used_count,
last_used_at=token.last_used_at,
last_used_ip=token.last_used_ip,
description=token.description,
created_id=token.created_id,
updated_id=token.updated_id,
created_time=token.created_time,
updated_time=token.updated_time,
)
def _parse_scopes(scopes_str: str) -> list[str]:
if not scopes_str:
return []
if scopes_str == "*":
return ["*"]
try:
loaded = json.loads(scopes_str)
if isinstance(loaded, list):
return loaded
except (json.JSONDecodeError, ValueError):
pass
return [s.strip() for s in scopes_str.split(",") if s.strip()]
# ──────────────────────────────────────────────────────────
# Service
# ──────────────────────────────────────────────────────────
class ApiTokenService:
"""API Token 业务逻辑层"""
MAX_TOKENS_PER_USER: int = 50
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
self.auth = auth
self.db = db
# ── 创建 ──────────────────────────────────────────────
async def create(self, data: ApiTokenCreateSchema) -> ApiTokenCreatedSchema:
existing = await ApiTokenCRUD(self.auth, self.db).get_list(
search={},
)
active_count = sum(1 for t in existing if t.status == 0 and not t.is_deleted)
if active_count >= self.MAX_TOKENS_PER_USER:
raise CustomException(msg=f"API Token 数量已达上限 ({self.MAX_TOKENS_PER_USER}),请先删除或禁用旧 token")
full_token = _generate_full_token(user_id=self.auth.user.id)
token_prefix = full_token[:_TOKEN_PREFIX_DISPLAY_LEN]
scopes_str = ",".join(data.scopes) if data.scopes else "*"
crud = ApiTokenCRUD(self.auth, self.db)
token_obj = await crud.create(
data={ # pyright: ignore[reportArgumentType]
"name": data.name,
"token_prefix": token_prefix,
"token_plain": full_token,
"owner_user_id": self.auth.user.id,
"scopes": scopes_str,
"expires_at": data.expires_at,
"status": 0,
"rate_limit": data.rate_limit,
"description": data.description,
},
)
if not token_obj:
raise CustomException(msg="创建 token 失败")
logger.info(f"新 token 创建成功: id={token_obj.id} name={data.name}")
return ApiTokenCreatedSchema(
id=token_obj.id,
name=token_obj.name,
token=full_token,
token_prefix=token_prefix,
scopes=data.scopes,
expires_at=token_obj.expires_at,
rate_limit=token_obj.rate_limit,
status=token_obj.status,
created_time=token_obj.created_time,
)
# ── 查询 ──────────────────────────────────────────────
async def page(
self,
page_no: int,
page_size: int,
search: ApiTokenQueryParam,
order_by: list[dict[str, str]] | None = None,
) -> PageResultSchema[ApiTokenOutSchema]:
crud = ApiTokenCRUD(self.auth, self.db)
search_dict: dict[str, Any] = {}
if search.name:
search_dict["name"] = ("like", f"%{search.name}%")
if search.status is not None:
search_dict["status"] = search.status
result = await crud.page(
offset=(page_no - 1) * page_size,
limit=page_size,
search=search_dict,
order_by=order_by or [{"id": "asc"}],
)
items_out = [_to_out_schema(row) for row in result.items]
return PageResultSchema[ApiTokenOutSchema](
page_no=result.page_no,
page_size=result.page_size,
total=result.total,
has_next=result.has_next,
items=items_out,
)
async def detail(self, id: int) -> ApiTokenOutSchema:
crud = ApiTokenCRUD(self.auth, self.db)
token = await crud.get_or_404(id=id)
return _to_out_schema(token)
# ── 状态/重置 ──────────────────────────────────────────
async def reset(self, id: int, data: ApiTokenResetSchema) -> ApiTokenCreatedSchema:
crud = ApiTokenCRUD(self.auth, self.db)
token = await crud.get_or_404(id=id)
full_token = _generate_full_token(user_id=self.auth.user.id)
token_prefix_new = full_token[:_TOKEN_PREFIX_DISPLAY_LEN]
values: dict[str, Any] = {
"token_prefix": token_prefix_new,
"token_plain": full_token,
"used_count": 0,
}
if data.name is not None:
values["name"] = data.name
if data.scopes is not None:
values["scopes"] = ",".join(data.scopes)
if data.expires_at is not None:
values["expires_at"] = data.expires_at
if data.rate_limit is not None:
values["rate_limit"] = data.rate_limit
await self.db.execute(sa_update(ApiTokenModel).where(ApiTokenModel.id == id).values(**values))
await self.db.flush()
await self.db.refresh(token)
logger.info(f"token[{id}] 已重置,新前缀={token_prefix_new}")
return ApiTokenCreatedSchema(
id=token.id,
name=token.name,
token=full_token,
token_prefix=token_prefix_new,
scopes=_parse_scopes(token.scopes),
expires_at=token.expires_at,
rate_limit=token.rate_limit,
status=token.status,
created_time=token.created_time,
)
async def set_status(self, id: int, status: int) -> None:
if status not in (0, 1, 2):
raise CustomException(msg="状态值不合法(0:启用 1:禁用 2:吊销)")
crud = ApiTokenCRUD(self.auth, self.db)
await crud.get_or_404(id=id)
await crud.update(id=id, data={"status": status}) # pyright: ignore[reportArgumentType]
async def delete(self, id: int) -> None:
crud = ApiTokenCRUD(self.auth, self.db)
await crud.get_or_404(id=id)
await crud.delete(ids=[id])
# ── reveal:二次验证后展示明文 ─────────────────────────
async def reveal(self, id: int, data: ApiTokenRevealSchema) -> ApiTokenRevealOutSchema:
user_row = await UserCRUD(self.auth, self.db).get(id=self.auth.user.id)
if not user_row:
raise CustomException(msg="用户不存在")
if not PwdUtil.verify_password(plain_password=data.password, password_hash=user_row.password):
logger.warning(f"reveal 二次验证失败: user_id={self.auth.user.id}")
raise CustomException(msg="密码错误,无法 reveal 明文")
crud = ApiTokenCRUD(self.auth, self.db)
token = await crud.get_or_404(id=id)
return ApiTokenRevealOutSchema(token=token.token_plain, name=token.name)
# ──────────────────────────────────────────────────────────
# 外部 API Bearer 验证(公开接口)
# ──────────────────────────────────────────────────────────
async def authenticate_api_token(token: str, request_ip: str | None = None, redis: Redis | None = None) -> ApiTokenModel:
"""外部 API 鉴权:从 Authorization Bearer 中解析 fastpat token,记录调用次数。"""
if not token or not token.startswith(_TOKEN_PREFIX_HEADER):
raise CustomException(msg="API Token 格式不合法", code=10401, status_code=401)
async with async_db_session() as db:
crud = ApiTokenCRUD(AuthSchema(), db)
candidate = await crud.get_list(search={"token_plain": ("=", token)})
if not candidate:
raise CustomException(msg="API Token 无效", code=10401, status_code=401)
token_row = candidate[0]
if token_row.status != 0:
raise CustomException(msg="API Token 已禁用或吊销", code=10401, status_code=401)
if token_row.expires_at is not None and token_row.expires_at < datetime.now():
raise CustomException(msg="API Token 已过期", code=10401, status_code=401)
if token_row.is_deleted:
raise CustomException(msg="API Token 已删除", code=10401, status_code=401)
# 限流(每小时)
if redis is not None:
try:
key = f"{_REDIS_RATE_KEY_PREFIX}{token_row.id}:{datetime.now().strftime('%Y%m%d%H')}"
current = await redis.incr(key)
if current == 1:
await redis.expire(key, 3600)
if current > token_row.rate_limit:
raise CustomException(
msg=f"API Token 限流:本小时已调用 {current} 次,上限 {token_row.rate_limit}",
code=10429,
status_code=429,
)
except CustomException:
raise
except Exception as e:
logger.warning(f"API Token 限流检查失败(继续放行): {e!s}")
await db.execute(
sa_update(ApiTokenModel)
.where(ApiTokenModel.id == token_row.id)
.values(
used_count=ApiTokenModel.used_count + 1,
last_used_at=datetime.now(),
last_used_ip=request_ip,
),
)
await db.commit()
return token_row
@@ -1,3 +1,22 @@
"""
认证控制器 — TODO: 限流粒度细化
---------------------------------
当前登录(/login)和 OAuth 端点(/oauth/*)共享应用的通用限流配置,
缺少独立的、更严格的限流策略。建议为以下端点配置独立的 RateLimiter
1. /auth/login — 密码登录
- 建议: 按 IP + 用户名组合限流,如 5次/分钟/IP + 10次/15分钟/用户
- 原因: 暴力破解防护
2. /auth/oauth/* — 第三方 OAuth 登录/回调
- 建议: 按 IP 限流,如 10次/分钟/IP
- 原因: OAuth 流程可能触发多次重定向,频率稍高于登录
3. /auth/captcha/* — 验证码获取/校验
- 建议: 按 IP 限流,如 3次/分钟/IP
- 原因: 防止验证码遍历
"""
import json
import secrets
from typing import Annotated
@@ -36,6 +36,12 @@ STATE_PREFIX = "oauth_state:"
def _callback_url(request: Request, provider: OAuthProvider) -> str:
root = str(request.base_url).rstrip("/")
# 域名白名单校验:防止 Host 头注入攻击重定向到恶意域名
allowed_hosts = settings.OAUTH_ALLOWED_HOSTS
if allowed_hosts and allowed_hosts != ["*"]:
host = request.url.hostname
if host is None or not any(host == allowed_host or host.endswith("." + allowed_host) for allowed_host in allowed_hosts):
raise CustomException(msg="非法的 OAuth 回调域名")
return f"{root}/system/auth/oauth/{provider}/callback"
@@ -146,7 +146,7 @@ class LoginService:
)
auth = AuthSchema()
user = await UserCRUD(auth, db).get(username=login_form.username)
user = await UserCRUD(auth, db).get(username=login_form.username, preload=["roles", "roles.menus"])
if not user:
await _write_login_log(
@@ -1,9 +1,7 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
from fastapi import APIRouter, Body, Depends, Path, Security, status
from fastapi.responses import JSONResponse
from fastapi_cache import FastAPICache
from fastapi_cache.decorator import cache
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.response import ResponseSchema, SuccessResponse
@@ -16,15 +14,12 @@ from .service import DeptService
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, Security(AuthPermission(["module_system:dept:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
search: Annotated[DeptQueryParam, Query()],
search: Annotated[DeptQueryParam, Depends()],
) -> JSONResponse:
order_by = [{"order": "asc"}]
result_dict_tree = await DeptService(auth, db).tree(search=search, order_by=order_by)
@@ -48,7 +43,6 @@ async def create_obj_controller(
data: Annotated[DeptCreateSchema, Body(description="部门创建参数")],
) -> JSONResponse:
result_dict = await DeptService(auth, db).create(data=data)
await FastAPICache.clear(namespace=_DEPT_NS)
return SuccessResponse(data=result_dict, msg="创建部门成功")
@@ -60,7 +54,6 @@ async def update_obj_controller(
data: Annotated[DeptUpdateSchema, Body(description="部门修改参数")],
) -> JSONResponse:
result_dict = await DeptService(auth, db).update(id=id, data=data)
await FastAPICache.clear(namespace=_DEPT_NS)
return SuccessResponse(data=result_dict, msg="修改部门成功")
@@ -71,7 +64,6 @@ async def delete_obj_controller(
ids: Annotated[list[int], Body(description="ID列表")],
) -> JSONResponse:
await DeptService(auth, db).delete(ids=ids)
await FastAPICache.clear(namespace=_DEPT_NS)
return SuccessResponse(msg="删除部门成功")
@@ -82,5 +74,4 @@ async def batch_set_available_obj_controller(
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
) -> JSONResponse:
await DeptService(auth, db).batch_set_available(data=data)
await FastAPICache.clear(namespace=_DEPT_NS)
return SuccessResponse(msg="批量修改部门状态成功")
+7 -17
View File
@@ -11,22 +11,16 @@ if TYPE_CHECKING:
class DeptModel(ModelMixin, UserMixin):
"""部门模型
"""
"""部门模型"""
__tablename__: str = "sys_dept"
__table_args__: dict[str, str] = {"comment": "部门表"}
__tree_children_attr__: str = "children"
__loader_options__: list[str] = ["children", "created_by", "updated_by", "deleted_by"]
name: Mapped[str] = mapped_column(String(64), nullable=False, comment="部门名称")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
name: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="部门名称")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)")
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
order: Mapped[int] = mapped_column(Integer, nullable=False, default=999, comment="显示排序")
order: Mapped[int] = mapped_column(Integer, nullable=False, default=999, index=True, comment="显示排序")
code: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, comment="部门编码")
leader: Mapped[str | None] = mapped_column(String(32), default=None, comment="部门负责人")
phone: Mapped[str | None] = mapped_column(String(20), default=None, comment="手机")
email: Mapped[str | None] = mapped_column(String(128), default=None, comment="邮箱")
parent_id: Mapped[int | None] = mapped_column(
Integer,
ForeignKey("sys_dept.id", ondelete="SET NULL", onupdate="CASCADE"),
@@ -40,10 +34,6 @@ class DeptModel(ModelMixin, UserMixin):
foreign_keys=[parent_id],
uselist=False,
)
children: Mapped[list["DeptModel"]] = relationship(back_populates="parent", foreign_keys=[parent_id], lazy="selectin")
roles: Mapped[list["RoleModel"]] = relationship(secondary="sys_role_depts", back_populates="depts", lazy="selectin")
users: Mapped[list["UserModel"]] = relationship(
back_populates="dept",
foreign_keys="UserModel.dept_id",
lazy="selectin",
)
children: Mapped[list["DeptModel"]] = relationship(back_populates="parent", foreign_keys=[parent_id])
roles: Mapped[list["RoleModel"]] = relationship(secondary="sys_role_depts", back_populates="depts")
users: Mapped[list["UserModel"]] = relationship(back_populates="dept", foreign_keys="UserModel.dept_id")
@@ -10,10 +10,7 @@ class DeptCreateSchema(BaseModel):
name: str = Field(..., min_length=1, max_length=64, description="部门名称")
order: int = Field(default=1, ge=0, description="显示顺序")
code: str = Field(..., min_length=2, max_length=64, description="部门编码")
leader: str | None = Field(default=None, max_length=32, description="部门负责人")
phone: str | None = Field(default=None, max_length=20, description="联系电话")
email: str | None = Field(default=None, max_length=128, description="邮箱")
parent_id: int | None = Field(default=None, ge=0, description="父部门ID")
parent_id: int | None = Field(default=None, ge=1, description="部门ID")
status: int = Field(default=0, ge=0, le=1, description="状态(0:启动 1:停用)")
description: str | None = Field(default=None, max_length=255, description="备注")
@@ -61,5 +58,5 @@ class DeptTreeOutSchema(DeptOutSchema):
class DeptQueryParam(BaseQueryParam, UserByQueryParam):
"""部门管理查询参数"""
name: str | None = Field(None, description="部门名称")
status: int | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)")
name: str | None = Field(None, description="部门名称", json_schema_extra={"q": "like"})
status: int | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)", json_schema_extra={"q": "eq"})
@@ -8,6 +8,7 @@ from app.utils.common_util import (
get_parent_id_map,
get_parent_recursion,
search_to_dict,
traversal_to_tree,
)
from .crud import DeptCRUD
@@ -15,7 +16,6 @@ from .schema import (
DeptCreateSchema,
DeptOutSchema,
DeptQueryParam,
DeptTreeOutSchema,
DeptUpdateSchema,
)
@@ -44,9 +44,9 @@ class DeptService:
search: DeptQueryParam | None = None,
order_by: list[dict] | None = None,
) -> list[dict]:
dept_list = await DeptCRUD(self.auth, self.db).tree_list(search=search_to_dict(search), order_by=order_by)
dept_dict_list = [DeptTreeOutSchema.model_validate(dept).model_dump() for dept in dept_list]
return [d for d in dept_dict_list if d.get("parent_id") is None]
dept_list = await DeptCRUD(self.auth, self.db).get_list(search=search_to_dict(search), order_by=order_by)
dept_dict_list = [DeptOutSchema.model_validate(dept).model_dump() for dept in dept_list]
return traversal_to_tree(dept_dict_list)
async def create(self, data: DeptCreateSchema) -> DeptOutSchema:
dept = await DeptCRUD(self.auth, self.db).get(name=data.name)
@@ -57,10 +57,10 @@ class DeptService:
raise CustomException(msg="创建失败,编码已存在")
dept = await DeptCRUD(self.auth, self.db).create(data=data)
return DeptOutSchema.model_validate(dept)
return await self.detail(id=dept.id)
async def update(self, id: int, data: DeptUpdateSchema) -> DeptOutSchema:
dept = await DeptCRUD(self.auth, self.db).get_or_404(id=id, msg="更新失败,该数据不存在")
await DeptCRUD(self.auth, self.db).get_or_404(id=id, msg="更新失败,该数据不存在")
exist_dept = await DeptCRUD(self.auth, self.db).get(name=data.name)
if exist_dept and exist_dept.id != id:
raise CustomException(msg="更新失败,名称已存在")
@@ -68,13 +68,8 @@ class DeptService:
if exist_code and exist_code.id != id:
raise CustomException(msg="更新失败,编码已存在")
dept = await DeptCRUD(self.auth, self.db).update(id=id, data=data)
dept_out = DeptOutSchema.model_validate(dept)
if dept_out.parent_id:
parent = await DeptCRUD(self.auth, self.db).get(id=dept_out.parent_id)
if parent:
dept_out.parent_name = parent.name
return dept_out
await DeptCRUD(self.auth, self.db).update(id=id, data=data)
return await self.detail(id=id)
async def delete(self, ids: list[int]) -> None:
if not ids:
@@ -1,17 +1,14 @@
from typing import Annotated
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
from fastapi import APIRouter, Body, Depends, Path, Security, status
from fastapi.responses import JSONResponse
from redis.asyncio.client import Redis
from sqlalchemy.ext.asyncio import AsyncSession
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, db_getter, redis_getter
from app.core.router_class import OperationLogRoute
from app.utils.common_util import bytes2file_response
from .schema import (
DictDataCreateSchema,
@@ -27,8 +24,6 @@ from .service import DictDataService, DictTypeService
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(
@@ -44,7 +39,7 @@ async def get_type_detail_controller(
async def get_type_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dict_type:query"]))],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[DictTypeQueryParam, Query()],
search: Annotated[DictTypeQueryParam, Depends()],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
result_dict = await DictTypeService(auth, db).page(
@@ -57,7 +52,6 @@ async def get_type_list_controller(
@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, Security(AuthPermission(["module_system:dict_type:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
@@ -74,7 +68,6 @@ async def create_type_controller(
data: Annotated[DictTypeCreateSchema, Body(description="字典类型创建参数")],
) -> JSONResponse:
result_dict = await DictTypeService(auth, db).create(redis=redis, data=data)
await FastAPICache.clear(namespace=_DICT_TYPE_NS)
return SuccessResponse(data=result_dict, msg="创建字典类型成功")
@@ -87,7 +80,6 @@ async def update_type_controller(
data: Annotated[DictTypeUpdateSchema, Body(description="字典类型修改参数")],
) -> JSONResponse:
result_dict = await DictTypeService(auth, db).update(redis=redis, id=id, data=data)
await FastAPICache.clear(namespace=_DICT_TYPE_NS)
return SuccessResponse(data=result_dict, msg="修改字典类型成功")
@@ -99,7 +91,6 @@ async def delete_type_controller(
ids: Annotated[list[int], Body(description="字典类型ID列表")],
) -> JSONResponse:
await DictTypeService(auth, db).delete(redis=redis, ids=ids)
await FastAPICache.clear(namespace=_DICT_TYPE_NS)
return SuccessResponse(msg="删除字典类型成功")
@@ -110,28 +101,9 @@ async def batch_set_available_dict_type_controller(
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
await DictTypeService(auth, db).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, Security(AuthPermission(["module_system:dict_type:export"]))],
search: Annotated[DictTypeQueryParam, Body()],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> StreamingResponse:
# 获取全量数据并转为dict列表
result_dict_list = await DictTypeService(auth, db).get_list(search=search)
export_data = [item.model_dump() for item in result_dict_list]
export_result = DictTypeService.export(data_list=export_data)
return StreamResponse(
data=bytes2file_response(export_result),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
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, Security(AuthPermission(["module_system:dict_data:detail"]))],
@@ -146,7 +118,7 @@ async def get_data_detail_controller(
async def get_data_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dict_data:query"]))],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[DictDataQueryParam, Query()],
search: Annotated[DictDataQueryParam, Depends()],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
order_by = [{"order": "asc"}]
@@ -205,24 +177,6 @@ async def batch_set_available_dict_data_controller(
return SuccessResponse(msg="批量修改字典数据状态成功")
@DictRouter.post("/data/export", summary="导出字典数据")
async def export_data_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:dict_data:export"]))],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[DictDataQueryParam, Body()],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> StreamingResponse:
result_dict_list = await DictDataService(auth, db).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)
return StreamResponse(
data=bytes2file_response(export_result),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
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)],
+8 -14
View File
@@ -1,35 +1,29 @@
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.base_model import ModelMixin, UserMixin
from app.core.base_model import ModelMixin
class DictTypeModel(ModelMixin, UserMixin):
class DictTypeModel(ModelMixin):
"""字典类型表"""
__tablename__: str = "sys_dict_type"
__table_args__: dict[str, str] = {"comment": "字典类型表"}
__loader_options__: list[str] = ["dict_data_list", "created_by", "updated_by", "deleted_by"]
dict_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="字典名称")
dict_type: Mapped[str] = mapped_column(String(255), nullable=False, index=True, comment="字典类型")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
dict_name: Mapped[str] = mapped_column(String(100), nullable=False, index=True, comment="字典名称")
dict_type: Mapped[str] = mapped_column(String(255), nullable=False, index=True, unique=True, comment="字典类型")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)")
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
dict_data_list: Mapped[list["DictDataModel"]] = relationship(
"DictDataModel",
back_populates="dict_type_obj",
cascade="all, delete-orphan",
)
dict_data_list: Mapped[list["DictDataModel"]] = relationship("DictDataModel", back_populates="dict_type_obj")
class DictDataModel(ModelMixin, UserMixin):
class DictDataModel(ModelMixin):
"""字典数据表"""
__tablename__: str = "sys_dict_data"
__table_args__: dict[str, str] = {"comment": "字典数据表"}
__loader_options__: list[str] = ["dict_type_obj", "created_by", "updated_by", "deleted_by"]
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, index=True, comment="状态(0:启动 1:停用)")
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
dict_sort: Mapped[int] = mapped_column(Integer, nullable=False, default=0, comment="字典排序")
dict_label: Mapped[str] = mapped_column(String(255), nullable=False, comment="字典标签")
+11 -11
View File
@@ -8,7 +8,7 @@ from pydantic import (
model_validator,
)
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
from app.core.base_schema import BaseQueryParam, BaseSchema
class DictTypeCreateSchema(BaseModel):
@@ -71,18 +71,18 @@ class DictTypeUpdateSchema(DictTypeCreateSchema):
"""字典类型更新模型"""
class DictTypeOutSchema(DictTypeCreateSchema, BaseSchema, UserBySchema):
class DictTypeOutSchema(DictTypeCreateSchema, BaseSchema):
"""字典类型响应模型"""
model_config = ConfigDict(from_attributes=True)
class DictTypeQueryParam(BaseQueryParam, UserByQueryParam):
class DictTypeQueryParam(BaseQueryParam):
"""字典类型查询参数"""
dict_name: str | None = Field(default=None, description="字典名称", max_length=100)
dict_name: str | None = Field(default=None, description="字典名称", max_length=100, json_schema_extra={"q": "like"})
dict_type: str | None = Field(default=None, description="字典类型", max_length=100, json_schema_extra={"q": "eq"})
status: int | None = Field(default=None, ge=0, le=1, description="状态(0:启动 1:停用)")
status: int | None = Field(default=None, ge=0, le=1, description="状态(0:启动 1:停用)", json_schema_extra={"q": "eq"})
class DictDataCreateSchema(BaseModel):
@@ -138,16 +138,16 @@ class DictDataUpdateSchema(DictDataCreateSchema):
"""字典数据更新模型"""
class DictDataOutSchema(DictDataCreateSchema, BaseSchema, UserBySchema):
class DictDataOutSchema(DictDataCreateSchema, BaseSchema):
"""字典数据响应模型"""
model_config = ConfigDict(from_attributes=True)
class DictDataQueryParam(BaseQueryParam, UserByQueryParam):
class DictDataQueryParam(BaseQueryParam):
"""字典数据查询参数"""
dict_label: str | None = Field(default=None, description="字典标签", max_length=100)
dict_type: str | None = Field(default=None, description="字典类型", max_length=100, json_schema_extra={"q": "eq"})
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 | None = Field(default=None, description="字典标签", max_length=255, json_schema_extra={"q": "like"})
dict_type: str | None = Field(default=None, description="字典类型", max_length=255, json_schema_extra={"q": "eq"})
dict_type_id: int | None = Field(default=None, description="字典类型ID", json_schema_extra={"q": "eq"})
status: int | None = Field(default=None, ge=0, le=1, description="状态(0:启动 1:停用)", json_schema_extra={"q": "eq"})
@@ -13,7 +13,6 @@ from app.core.exceptions import CustomException
from app.core.logger import logger
from app.core.redis_crud import RedisCURD
from app.utils.common_util import search_to_dict
from app.utils.excel_util import ExcelUtil
from .crud import DictDataCRUD, DictTypeCRUD
from .schema import (
@@ -234,37 +233,6 @@ class DictTypeService:
"""
await DictTypeCRUD(self.auth, self.db).set(ids=data.ids, status=data.status)
@staticmethod
def export(data_list: list[dict]) -> bytes:
"""导出数据字典类型列表(无状态工具方法)
参数:
- data_list (list[dict]): 数据字典类型列表
返回:
- bytes: Excel文件字节流
"""
mapping_dict = {
"id": "编号",
"dict_name": "字典名称",
"dict_type": "字典类型",
"status": "状态",
"description": "备注",
"created_time": "创建时间",
"updated_time": "更新时间",
"created_id": "创建者ID",
"updated_id": "更新者ID",
}
# 复制数据并转换状态
data = data_list.copy()
for item in data:
# 处理状态
item["status"] = "启用" if item.get("status") == 0 else "停用"
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
class DictDataService:
"""字典数据管理服务
@@ -553,38 +521,3 @@ class DictDataService:
- None
"""
await DictDataCRUD(self.auth, self.db).set(ids=data.ids, status=data.status)
@staticmethod
def export(data_list: list[dict]) -> bytes:
"""导出数据字典数据列表(无状态工具方法)
参数:
- data_list (list[dict]): 数据字典数据列表
返回:
- bytes: Excel文件字节流
"""
mapping_dict = {
"id": "编号",
"dict_type": "字典类型",
"dict_label": "字典标签",
"dict_value": "字典键值",
"dict_sort": "字典排序",
"status": "状态",
"description": "备注",
"created_time": "创建时间",
"updated_time": "更新时间",
"created_id": "创建者ID",
"updated_id": "更新者ID",
}
# 复制数据并转换状态
data = data_list.copy()
for item in data:
item["status"] = "启用" if item.get("status") == 0 else "停用"
if item.get("is_default") is True:
item["is_default"] = ""
else:
item["is_default"] = ""
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
@@ -1,6 +1,6 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, Query, Security
from fastapi import APIRouter, Body, Depends, Path, Security
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
@@ -38,7 +38,7 @@ async def get_log_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:login_log:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[LoginLogQueryParam, Query()],
search: Annotated[LoginLogQueryParam, Depends()],
) -> JSONResponse:
result_dict = await LoginLogService(auth, db).page(
page_no=page.page_no,
@@ -76,7 +76,7 @@ async def get_operation_log_list_controller(
auth: Annotated[AuthSchema, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(db_getter)],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[OperationLogQueryParam, Query()],
search: Annotated[OperationLogQueryParam, Depends()],
) -> JSONResponse:
result_dict = await OperationLogService(auth, db).page(
page_no=page.page_no,
@@ -27,9 +27,9 @@ class LoginLogModel(ModelMixin):
__tablename__: str = "sys_login_log"
__table_args__: dict[str, str] = {"comment": "登录日志表"}
status: Mapped[int] = mapped_column(Integer, default=1, comment="登录状态(1成功 2失败)", index=True)
status: Mapped[int] = mapped_column(Integer, default=1, index=True, comment="登录状态(1成功 2失败)")
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
username: Mapped[str] = mapped_column(String(64), nullable=False, comment="用户名")
username: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="用户名")
login_location: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="登录位置")
login_ip: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="登录IP地址")
request_os: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="操作系统")
@@ -38,16 +38,15 @@ class LoginLogModel(ModelMixin):
class OperationLogModel(ModelMixin):
"""操作日志模型
"""
"""操作日志模型"""
__tablename__: str = "sys_operation_log"
__table_args__: dict[str, str] = {"comment": "操作日志表"}
username: Mapped[str] = mapped_column(String(64), nullable=False, comment="操作人用户名")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="操作状态(0:成功 1:失败)", index=True)
username: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="操作人用户名")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="操作状态(0:成功 1:失败)")
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
request_path: Mapped[str] = mapped_column(String(255), comment="请求路径")
request_path: Mapped[str] = mapped_column(String(255), index=True, comment="请求路径")
request_method: Mapped[str] = mapped_column(String(10), comment="请求方式")
request_payload: Mapped[str | None] = mapped_column(get_log_text_column_type(), comment="请求体")
response_code: Mapped[int] = mapped_column(Integer, comment="响应状态码")
+7 -16
View File
@@ -1,6 +1,5 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.common.enums import QueueEnum
from app.core.base_schema import BaseQueryParam, BaseSchema
ALLOWED_REQUEST_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
@@ -48,26 +47,18 @@ class LoginLogDetailOutSchema(LoginLogOutSchema):
class LoginLogQueryParam(BaseQueryParam):
"""登录日志查询参数"""
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 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
username: str | None = Field(None, max_length=64, description="用户名", json_schema_extra={"q": "like"})
status: int | None = Field(None, description="登录状态(1:成功 2:失败)", json_schema_extra={"q": "eq"})
class OperationLogQueryParam(BaseQueryParam):
"""操作日志查询参数"""
request_path: str | None = Field(None, description="请求路径")
request_path: str | None = Field(None, description="请求路径", json_schema_extra={"q": "like"})
request_method: str | None = Field(None, description="请求方式", json_schema_extra={"q": "eq"})
username: str | None = Field(None, description="用户名")
status: int | None = Field(None, ge=0, le=1, description="状态(0:成功 1:失败)")
request_ip: str | None = Field(None, description="请求IP")
username: str | None = Field(None, description="用户名", json_schema_extra={"q": "like"})
status: int | None = Field(None, ge=0, le=1, description="状态(0:成功 1:失败)", json_schema_extra={"q": "eq"})
request_ip: str | None = Field(None, description="请求IP", json_schema_extra={"q": "eq"})
class OperationLogOutSchema(BaseSchema):
@@ -1,9 +1,7 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
from fastapi import APIRouter, Body, Depends, Path, Security, status
from fastapi.responses import JSONResponse
from fastapi_cache import FastAPICache
from fastapi_cache.decorator import cache
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.response import ResponseSchema, SuccessResponse
@@ -16,15 +14,12 @@ from .service import MenuService
MenuRouter = APIRouter(route_class=OperationLogRoute, prefix="/menu", tags=["菜单管理"])
_MENU_NS = "menu"
@MenuRouter.get("/tree", summary="查询菜单树", response_model=ResponseSchema[list[MenuOutSchema]])
@cache(expire=300, namespace=_MENU_NS)
async def get_menu_tree_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:menu:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
search: Annotated[MenuQueryParam, Query()],
search: Annotated[MenuQueryParam, Depends()],
) -> JSONResponse:
order_by = [{"order": "asc"}]
result_dict_tree = await MenuService(auth, db).tree(search=search, order_by=order_by)
@@ -48,7 +43,6 @@ async def create_obj_controller(
data: Annotated[MenuCreateSchema, Body(description="菜单创建参数")],
) -> JSONResponse:
result_dict = await MenuService(auth, db).create(data=data)
await FastAPICache.clear(namespace=_MENU_NS)
return SuccessResponse(data=result_dict, msg="创建菜单成功")
@@ -60,7 +54,6 @@ async def update_obj_controller(
data: Annotated[MenuUpdateSchema, Body(description="菜单修改参数")],
) -> JSONResponse:
result_dict = await MenuService(auth, db).update(id=id, data=data)
await FastAPICache.clear(namespace=_MENU_NS)
return SuccessResponse(data=result_dict, msg="修改菜单成功")
@@ -71,7 +64,6 @@ async def delete_obj_controller(
ids: Annotated[list[int], Body(description="菜单ID列表")],
) -> JSONResponse:
await MenuService(auth, db).delete(ids=ids)
await FastAPICache.clear(namespace=_MENU_NS)
return SuccessResponse(msg="删除菜单成功")
@@ -82,5 +74,4 @@ async def batch_set_available_obj_controller(
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
) -> JSONResponse:
await MenuService(auth, db).set_available(data=data)
await FastAPICache.clear(namespace=_MENU_NS)
return SuccessResponse(msg="批量修改菜单状态成功")
@@ -21,16 +21,14 @@ class MenuModel(ModelMixin):
__tablename__: str = "sys_menu"
__table_args__: dict[str, str] = {"comment": "系统菜单表"}
__tree_children_attr__: str = "children"
__loader_options__: list[str] = ["roles", "children"]
name: Mapped[str] = mapped_column(String(50), nullable=False, comment="菜单名称")
name: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="菜单名称")
type: Mapped[int] = mapped_column(Integer, nullable=False, default=2, comment="菜单类型(1:目录 2:菜单 3:按钮 4:链接)")
order: Mapped[int] = mapped_column(Integer, nullable=False, default=999, comment="显示排序")
order: Mapped[int] = mapped_column(Integer, nullable=False, default=999, index=True, comment="显示排序")
permission: Mapped[str | None] = mapped_column(String(100), comment="权限标识(如:module_system:user:query)")
icon: Mapped[str | None] = mapped_column(String(50), comment="菜单图标")
route_name: Mapped[str | None] = mapped_column(String(100), comment="路由名称")
route_path: Mapped[str | None] = mapped_column(String(200), comment="路由路径")
route_path: Mapped[str | None] = mapped_column(String(200), index=True, comment="路由路径")
component_path: Mapped[str | None] = mapped_column(String(200), comment="组件路径")
redirect: Mapped[str | None] = mapped_column(String(200), comment="重定向地址")
hidden: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, comment="是否隐藏(True:隐藏 False:显示)")
@@ -46,9 +44,9 @@ class MenuModel(ModelMixin):
show_badge: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, comment="是否显示红点角标(True:是 False:否)")
show_text_badge: Mapped[str | None] = mapped_column(String(20), comment="文字角标内容")
scope: Mapped[str] = mapped_column(String(20), nullable=False, default="web", server_default="web", comment="菜单可见范围(web:管理端 desktop app:移动端)")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)")
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("sys_menu.id", ondelete="SET NULL"), default=None, index=True, comment="父菜单ID")
parent: Mapped["MenuModel | None"] = relationship(back_populates="children", remote_side="MenuModel.id", foreign_keys="MenuModel.parent_id", uselist=False)
children: Mapped[list["MenuModel"] | None] = relationship(back_populates="parent", foreign_keys="MenuModel.parent_id", order_by="MenuModel.order", lazy="selectin")
roles: Mapped[list["RoleModel"]] = relationship(secondary="sys_role_menus", back_populates="menus", lazy="selectin")
children: Mapped[list["MenuModel"] | None] = relationship(back_populates="parent", foreign_keys="MenuModel.parent_id", order_by="MenuModel.order")
roles: Mapped[list["RoleModel"]] = relationship(secondary="sys_role_menus", back_populates="menus")
@@ -120,9 +120,9 @@ class MenuUpdateSchema(BaseModel):
active_path: str | None = Field(default=None, max_length=200, description="激活菜单路径")
show_badge: bool | None = Field(default=None, description="是否显示红点角标")
show_text_badge: str | None = Field(default=None, max_length=20, description="文字角标内容")
scope: Literal["platform"] | None = Field(
scope: Literal["web", "app"] | None = Field(
default=None,
description="菜单可见范围",
description="菜单可见范围(web:管理端 app:移动端)",
)
parent_name: str | None = Field(default=None, max_length=50, description="父菜单名称")
@@ -190,13 +190,13 @@ class MenuTreeOutSchema(MenuOutSchema):
class MenuQueryParam(BaseQueryParam):
"""菜单管理查询参数(菜单为平台级资源,无用户归属)"""
name: str | None = Field(None, description="菜单名称")
route_path: str | None = Field(None, description="路由地址")
component_path: str | None = Field(None, description="组件路径")
type: int | None = Field(None, description="菜单类型(1:目录 2:菜单 3:按钮 4:外链)")
permission: str | None = Field(None, description="权限标识")
description: str | None = Field(None, description="描述")
status: int | None = Field(None, description="是否启用")
name: str | None = Field(None, description="菜单名称", json_schema_extra={"q": "like"})
route_path: str | None = Field(None, description="路由地址", json_schema_extra={"q": "like"})
component_path: str | None = Field(None, description="组件路径", json_schema_extra={"q": "like"})
type: int | None = Field(None, description="菜单类型(1:目录 2:菜单 3:按钮 4:外链)", json_schema_extra={"q": "eq"})
permission: str | None = Field(None, description="权限标识", json_schema_extra={"q": "eq"})
description: str | None = Field(None, description="描述", json_schema_extra={"q": "like"})
status: int | None = Field(None, description="是否启用", json_schema_extra={"q": "eq"})
scope: str | None = Field(
None,
description="菜单范围过滤(web:管理端 desktop app:移动端)",
@@ -1,7 +1,6 @@
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.base_schema import AuthSchema, BatchSetAvailable
from app.core.exceptions import CustomException
@@ -19,7 +18,6 @@ from .schema import (
MenuCreateSchema,
MenuOutSchema,
MenuQueryParam,
MenuTreeOutSchema,
MenuUpdateSchema,
)
@@ -77,13 +75,8 @@ class MenuService:
search: MenuQueryParam | None = None,
order_by: list[dict] | None = None,
) -> list[dict]:
# 递归预加载所有层级 children(避免 Pydantic 校验时异步懒加载失败)
from .model import MenuModel
_loader = selectinload(MenuModel.children)
for _ in range(10):
_loader = _loader.selectinload(MenuModel.children)
menu_list = await MenuCRUD(self.auth, self.db).tree_list(search=search_to_dict(search), order_by=order_by, preload=[_loader])
menu_dict_list = [MenuTreeOutSchema.model_validate(menu).model_dump() for menu in menu_list]
menu_list = await MenuCRUD(self.auth, self.db).get_list(search=search_to_dict(search), order_by=order_by)
menu_dict_list = [MenuOutSchema.model_validate(menu).model_dump() for menu in menu_list]
return traversal_to_tree(menu_dict_list)
async def create(self, data: MenuCreateSchema) -> MenuOutSchema:
@@ -1,9 +1,7 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
from fastapi import APIRouter, Body, Depends, Path, Security, status
from fastapi.responses import JSONResponse
from fastapi_cache import FastAPICache
from fastapi_cache.decorator import cache
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.response import ResponseSchema, SuccessResponse
@@ -16,8 +14,6 @@ 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(
@@ -34,7 +30,7 @@ async def get_notice_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:notice:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[NoticeQueryParam, Query()],
search: Annotated[NoticeQueryParam, Depends()],
) -> JSONResponse:
result_dict = await NoticeService(auth, db).page(
page_no=page.page_no,
@@ -52,7 +48,6 @@ async def create_notice_controller(
data: Annotated[NoticeCreateSchema, Body(description="公告创建参数")],
) -> JSONResponse:
result_dict = await NoticeService(auth, db).create(data=data)
await FastAPICache.clear(namespace=_NOTICE_NS)
return SuccessResponse(data=result_dict, msg="创建公告成功")
@@ -64,7 +59,6 @@ async def update_notice_controller(
data: Annotated[NoticeUpdateSchema, Body(description="公告修改参数")],
) -> JSONResponse:
result_dict = await NoticeService(auth, db).update(id=id, data=data)
await FastAPICache.clear(namespace=_NOTICE_NS)
return SuccessResponse(data=result_dict, msg="修改公告成功")
@@ -75,7 +69,6 @@ async def delete_notice_controller(
ids: Annotated[list[int], Body(description="ID列表")],
) -> JSONResponse:
await NoticeService(auth, db).delete(ids=ids)
await FastAPICache.clear(namespace=_NOTICE_NS)
return SuccessResponse(msg="删除公告成功")
@@ -86,12 +79,10 @@ async def batch_set_available_notice_controller(
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
) -> JSONResponse:
await NoticeService(auth, db).set_available(data=data)
await FastAPICache.clear(namespace=_NOTICE_NS)
return SuccessResponse(msg="批量修改公告状态成功")
@NoticeRouter.get("/available", summary="获取全局启用公告", response_model=ResponseSchema[list[NoticeOutSchema]])
@cache(expire=120, namespace=_NOTICE_NS)
async def get_notice_list_available_controller(
auth: Annotated[AuthSchema, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(db_getter)],
@@ -9,10 +9,9 @@ class NoticeModel(ModelMixin, UserMixin):
__tablename__: str = "sys_notice"
__table_args__: dict[str, str] = {"comment": "通知公告表"}
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by"]
notice_title: Mapped[str] = mapped_column(String(64), nullable=False, comment="公告标题")
notice_type: Mapped[str] = mapped_column(String(1), nullable=False, comment="公告类型(1通知 2公告)")
notice_title: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="公告标题")
notice_type: Mapped[str] = mapped_column(String(1), nullable=False, index=True, comment="公告类型(1通知 2公告)")
notice_content: Mapped[str | None] = mapped_column(Text, nullable=True, comment="公告内容")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:草稿 1:已发布 2:已归档)", index=True)
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:草稿 1:已发布 2:已归档)")
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
@@ -62,6 +62,6 @@ class NoticeOutSchema(NoticeCreateSchema, BaseSchema, UserBySchema):
class NoticeQueryParam(BaseQueryParam, UserByQueryParam):
"""公告通知查询参数"""
notice_title: str | None = Field(None, description="公告标题")
notice_title: str | None = Field(None, description="公告标题", json_schema_extra={"q": "like"})
notice_type: str | None = Field(None, description="公告类型", json_schema_extra={"q": "eq"})
status: int | None = Field(None, ge=0, le=2, description="状态(0:草稿 1:已发布 2:已归档)")
status: int | None = Field(None, ge=0, le=2, description="状态(0:草稿 1:已发布 2:已归档)", json_schema_extra={"q": "eq"})
@@ -98,7 +98,7 @@ class NoticeService:
if notice:
raise CustomException(msg="创建失败,该数据已存在")
notice_obj = await NoticeCRUD(self.auth, self.db).create(data=data)
return NoticeOutSchema.model_validate(notice_obj)
return await self.detail(id=notice_obj.id)
async def update(self, id: int, data: NoticeUpdateSchema) -> NoticeOutSchema:
"""更新公告
@@ -114,8 +114,8 @@ class NoticeService:
exist_notice = await NoticeCRUD(self.auth, self.db).get(notice_title=data.notice_title)
if exist_notice and exist_notice.id != id:
raise CustomException(msg="更新失败,标题已存在")
notice_obj = await NoticeCRUD(self.auth, self.db).update(id=id, data=data)
return NoticeOutSchema.model_validate(notice_obj)
await NoticeCRUD(self.auth, self.db).update(id=id, data=data)
return await self.detail(id=id)
async def delete(self, ids: list[int]) -> None:
"""删除公告
@@ -1,59 +1,21 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi import APIRouter, Body, Depends, Path, Security
from fastapi.responses import JSONResponse
from redis.asyncio.client import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.response import ResponseSchema, StreamResponse, SuccessResponse
from app.core.base_schema import AuthSchema, BatchSetAvailable, PageResultSchema, PaginationQueryParam
from app.common.response import ResponseSchema, SuccessResponse
from app.core.base_schema import AuthSchema
from app.core.dependencies import AuthPermission, db_getter, redis_getter
from app.core.router_class import OperationLogRoute
from app.utils.common_util import bytes2file_response
from .schema import ParamsCreateSchema, ParamsOutSchema, ParamsQueryParam, ParamsUpdateSchema
from .schema import ParamsOutSchema, ParamsUpdateSchema
from .service import ParamsService
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, Security(AuthPermission(["module_system:param:detail"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="参数ID", ge=1)],
) -> JSONResponse:
result_dict = await ParamsService(auth, db).detail(id=id)
return SuccessResponse(data=result_dict, msg="获取参数详情成功")
@ParamsRouter.get("/list", summary="获取参数列表", response_model=ResponseSchema[PageResultSchema[ParamsOutSchema]])
async def get_param_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:param:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[ParamsQueryParam, Query()],
) -> JSONResponse:
result_dict = await ParamsService(auth, db).page(
page_no=page.page_no,
page_size=page.page_size,
search=search,
order_by=page.order_by,
)
return SuccessResponse(data=result_dict, msg="查询参数列表成功")
@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, Security(AuthPermission(["module_system:param:create"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[ParamsCreateSchema, Body(description="参数创建参数")],
) -> JSONResponse:
result_dict = await ParamsService(auth, db).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)],
@@ -66,45 +28,6 @@ async def update_param_controller(
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, Security(AuthPermission(["module_system:param:delete"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
ids: Annotated[list[int], Body(description="ID列表")],
) -> JSONResponse:
await ParamsService(auth, db).delete(redis=redis, ids=ids)
return SuccessResponse(msg="删除参数成功")
@ParamsRouter.patch("/status/batch", summary="批量设置参数状态", response_model=ResponseSchema)
async def batch_set_status_controller(
redis: Annotated[Redis, Depends(redis_getter)],
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:param:patch"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
) -> JSONResponse:
await ParamsService(auth, db).batch_set_status(redis=redis, ids=data.ids, status=data.status)
return SuccessResponse(msg="批量设置参数状态成功")
@ParamsRouter.post("/export", summary="导出参数")
async def export_param_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:param:export"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
search: Annotated[ParamsQueryParam, Body()],
) -> StreamingResponse:
result_dict_list = await ParamsService(auth, db).get_list(search=search)
export_data = [item.model_dump() for item in result_dict_list]
export_result = ParamsService.export(data_list=export_data)
return StreamResponse(
data=bytes2file_response(export_result),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=params.xlsx"},
)
@ParamsRouter.get("/info", summary="获取初始化缓存参数", response_model=ResponseSchema[list[ParamsOutSchema]])
async def get_init_config_controller(
redis: Annotated[Redis, Depends(redis_getter)],
@@ -4,10 +4,10 @@ from app.core.base_crud import CRUDBase
from app.core.base_schema import AuthSchema
from .model import ParamsModel
from .schema import ParamsCreateSchema, ParamsUpdateSchema
from .schema import ParamsUpdateSchema
class ParamsCRUD(CRUDBase[ParamsModel, ParamsCreateSchema, ParamsUpdateSchema]):
class ParamsCRUD(CRUDBase[ParamsModel, ParamsUpdateSchema, ParamsUpdateSchema]):
"""配置管理数据层"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
@@ -1,19 +1,18 @@
from sqlalchemy import Boolean, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import ModelMixin, UserMixin
from app.core.base_model import ModelMixin
class ParamsModel(ModelMixin, UserMixin):
class ParamsModel(ModelMixin):
"""系统参数表"""
__tablename__: str = "sys_param"
__table_args__: dict[str, str] = {"comment": "系统参数表"}
__loader_options__: list[str] = ["created_by", "updated_by", "deleted_by"]
config_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="参数名称")
config_key: Mapped[str] = mapped_column(String(500), nullable=False, comment="参数键名")
config_name: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="参数名称")
config_key: Mapped[str] = mapped_column(String(500), nullable=False, index=True, comment="参数键名")
config_value: Mapped[str | None] = mapped_column(Text, comment="参数键值")
config_type: Mapped[bool] = mapped_column(Boolean, default=False, nullable=True, comment="系统内置(True:是 False:否)", index=True)
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)")
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
@@ -2,11 +2,11 @@ import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
from app.core.base_schema import BaseSchema
class ParamsCreateSchema(BaseModel):
"""参数创建模型
class ParamsBaseSchema(BaseModel):
"""参数基础字段
"""
config_name: str = Field(..., min_length=1, max_length=64, description="参数名称")
@@ -34,23 +34,13 @@ class ParamsCreateSchema(BaseModel):
return v
class ParamsUpdateSchema(ParamsCreateSchema):
class ParamsUpdateSchema(ParamsBaseSchema):
"""参数更新模型
"""
class ParamsOutSchema(ParamsCreateSchema, BaseSchema, UserBySchema):
class ParamsOutSchema(ParamsBaseSchema, BaseSchema):
"""参数响应模型
"""
model_config = ConfigDict(from_attributes=True)
class ParamsQueryParam(BaseQueryParam, UserByQueryParam):
"""参数管理查询参数
"""
config_name: str | None = Field(None, description="参数名称")
config_key: str | None = Field(None, description="参数键名", json_schema_extra={"q": "eq"})
config_type: bool | None = Field(None, description="是否系统内置(True:是 False:否)")
status: int | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)")
@@ -5,144 +5,23 @@ from redis.asyncio.client import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.enums import RedisInitKeyConfig
from app.core.base_schema import AuthSchema, PageResultSchema
from app.core.base_schema import AuthSchema
from app.core.database import async_db_session
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.core.redis_crud import RedisCURD
from app.utils.common_util import search_to_dict
from app.utils.excel_util import ExcelUtil
from .crud import ParamsCRUD
from .schema import (
ParamsCreateSchema,
ParamsOutSchema,
ParamsQueryParam,
ParamsUpdateSchema,
)
from .schema import ParamsOutSchema, ParamsUpdateSchema
class ParamsService:
"""参数管理服务
设计实例方法承载当前用户上下文 (auth)``redis`` 仍是方法参数
因为不是每个端点都用到调用方写法由
``ParamsService.method_service(auth=...)`` 改为 ``ParamsService(auth).method(...)``
"""
"""参数管理服务"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
self.auth = auth
self.db = db
async def detail(self, id: int) -> ParamsOutSchema:
"""获取参数详情
参数:
- id (int): 参数ID
返回:
- ParamsOutSchema: 参数响应模型
"""
obj = await ParamsCRUD(self.auth, self.db).get_or_404(id=id)
return ParamsOutSchema.model_validate(obj)
async def get_by_key(self, config_key: str) -> ParamsOutSchema:
"""根据配置键获取参数详情
参数:
- config_key (str): 参数键名
返回:
- ParamsOutSchema: 参数响应模型
"""
obj = await ParamsCRUD(self.auth, self.db).get(config_key=config_key)
if not obj:
raise CustomException(msg="该数据不存在")
return ParamsOutSchema.model_validate(obj)
async def get_list(
self,
search: ParamsQueryParam | None = None,
order_by: list[dict] | None = None,
) -> list[ParamsOutSchema]:
"""获取配置管理型列表
参数:
- search (ParamsQueryParam | None): 查询参数对象
- order_by (list[dict] | None): 排序参数列表
返回:
- list[ParamsOutSchema]: 参数响应模型列表
"""
obj_list = await ParamsCRUD(self.auth, self.db).get_list(search=search_to_dict(search), order_by=order_by)
return [ParamsOutSchema.model_validate(obj) for obj in obj_list]
async def page(
self,
page_no: int,
page_size: int,
search: ParamsQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> PageResultSchema[ParamsOutSchema]:
"""分页查询系统参数(数据库 OFFSET/LIMIT)。
参数:
- page_no (int): 页码 1 开始
- page_size (int): 每页条数
- search (ParamsQueryParam | None): 查询条件
- order_by (list[dict[str, str]] | None): 排序字段列表
返回:
- PageResultSchema[ParamsOutSchema]: 分页结果
"""
offset = (page_no - 1) * page_size
return await ParamsCRUD(self.auth, self.db).page(
offset=offset,
limit=page_size,
order_by=order_by or [{"id": "asc"}],
search=search_to_dict(search),
out_schema=ParamsOutSchema,
)
async def create(self, redis: Redis, data: ParamsCreateSchema) -> ParamsOutSchema:
"""创建配置管理型
参数:
- redis (Redis): Redis 客户端实例
- data (ParamsCreateSchema): 配置管理型创建模型
返回:
- ParamsOutSchema: 新创建的参数响应模型
"""
exist_obj = await ParamsCRUD(self.auth, self.db).get(config_key=data.config_key)
if exist_obj:
raise CustomException(msg="创建失败,该数据已存在")
obj = await ParamsCRUD(self.auth, self.db).create(data=data)
out = ParamsOutSchema.model_validate(obj)
# 同步redis
user = self.auth.user
if not user:
raise CustomException(msg="未登录")
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{data.config_key}"
try:
redis_payload = out.model_dump(mode="json")
value = json.dumps(redis_payload, ensure_ascii=False)
result = await RedisCURD(redis).set(
key=redis_key,
value=value,
expire=None,
)
if not result:
logger.error(f"同步配置到缓存失败: {out}")
raise CustomException(msg="同步配置到缓存失败")
except Exception as e:
logger.error(f"创建字典类型失败: {e}")
raise CustomException(msg="同步配置到缓存失败") from e
return out
async def update(self, redis: Redis, id: int, data: ParamsUpdateSchema) -> ParamsOutSchema:
"""更新参数
@@ -185,98 +64,6 @@ class ParamsService:
return out
async def delete(self, redis: Redis, ids: list[int]) -> None:
"""删除配置管理型
参数:
- redis (Redis): Redis 客户端实例
- ids (list[int]): 配置管理型ID列表
返回:
- None
"""
if not ids:
raise CustomException(msg="删除失败,删除对象不能为空")
# 批量校验参数存在性
objs = await ParamsCRUD(self.auth, self.db).get_list(search={"id": ("in", ids)})
obj_map = {o.id: o for o in objs}
for pid in ids:
obj = obj_map.get(pid)
if not obj:
raise CustomException(msg="删除失败,该数据不存在")
if obj.config_type:
raise CustomException(msg=f"{obj.config_name} 删除失败,系统初始化配置不可以删除")
await ParamsCRUD(self.auth, self.db).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}:{obj.config_key}"
try:
await RedisCURD(redis).delete(redis_key)
except Exception as e:
logger.error(f"删除系统配置失败: {e}")
raise CustomException(msg="同步删除缓存失败") from e
async def batch_set_status(self, redis: Redis, ids: list[int], status: int) -> None:
"""批量设置系统参数状态
参数:
- redis: Redis 客户端用于同步缓存
- ids (list[int]): 系统参数ID列表
- status (int): 状态值
返回:
- None
"""
if not ids:
raise CustomException(msg="请选择要操作的数据")
# 先查参数列表获取 config_key
params = await ParamsCRUD(self.auth, self.db).get_list(search={"id": ("in", list(ids))})
await ParamsCRUD(self.auth, self.db).set(ids=ids, status=status)
# 同步删除对应 Redis 缓存
for param in params:
redis_key = f"{RedisInitKeyConfig.SYSTEM_CONFIG.key}:{param.config_key}"
try:
await RedisCURD(redis).delete(redis_key)
except Exception as e:
logger.error(f"同步删除系统配置缓存失败: {e}")
@staticmethod
def export(data_list: list[dict]) -> bytes:
"""导出参数列表(无状态工具方法)
参数:
- data_list (list[dict]): 参数字典列表
返回:
- bytes: Excel 文件字节流
"""
mapping_dict = {
"id": "编号",
"config_name": "参数名称",
"config_key": "参数键名",
"config_value": "参数键值",
"config_type": "系统内置((True:是 False:否))",
"description": "备注",
"created_time": "创建时间",
"updated_time": "更新时间",
"created_id": "创建者ID",
"updated_id": "更新者ID",
}
# 复制数据并转换状态
data = data_list.copy()
for item in data:
# 处理状态
item["config_type"] = "" if item.get("config_type") else ""
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
@staticmethod
async def _load_all_configs_from_db() -> Sequence[object]:
async with async_db_session() as session, session.begin():
@@ -1,9 +1,7 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
from fastapi import APIRouter, Body, Depends, Path, Security, status
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi_cache import FastAPICache
from fastapi_cache.decorator import cache
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.response import ResponseSchema, StreamResponse, SuccessResponse
@@ -17,16 +15,13 @@ from .service import PositionService
PositionRouter = APIRouter(route_class=OperationLogRoute, prefix="/position", tags=["岗位管理"])
_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, Security(AuthPermission(["module_system:position:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[PositionQueryParam, Query()],
search: Annotated[PositionQueryParam, Depends()],
) -> JSONResponse:
result_dict = await PositionService(auth, db).page(
page_no=page.page_no,
@@ -54,7 +49,6 @@ async def create_obj_controller(
data: Annotated[PositionCreateSchema, Body(description="岗位创建参数")],
) -> JSONResponse:
result_dict = await PositionService(auth, db).create(data=data)
await FastAPICache.clear(namespace=_POS_NS)
return SuccessResponse(data=result_dict, msg="创建岗位成功")
@@ -66,7 +60,6 @@ async def update_obj_controller(
data: Annotated[PositionUpdateSchema, Body(description="岗位修改参数")],
) -> JSONResponse:
result_dict = await PositionService(auth, db).update(id=id, data=data)
await FastAPICache.clear(namespace=_POS_NS)
return SuccessResponse(data=result_dict, msg="修改岗位成功")
@@ -77,7 +70,6 @@ async def delete_obj_controller(
ids: Annotated[list[int], Body(description="ID列表")],
) -> JSONResponse:
await PositionService(auth, db).delete(ids=ids)
await FastAPICache.clear(namespace=_POS_NS)
return SuccessResponse(msg="删除岗位成功")
@@ -88,7 +80,6 @@ async def batch_set_available_obj_controller(
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
) -> JSONResponse:
await PositionService(auth, db).set_available(data=data)
await FastAPICache.clear(namespace=_POS_NS)
return SuccessResponse(msg="批量修改岗位状态成功")
@@ -14,17 +14,12 @@ class PositionModel(ModelMixin, UserMixin):
__tablename__: str = "sys_position"
__table_args__: dict[str, str] = {"comment": "岗位表"}
__loader_options__: list[str] = ["users", "created_by", "updated_by", "deleted_by"]
name: Mapped[str] = mapped_column(String(64), nullable=False, comment="岗位名称")
name: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="岗位名称")
code: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, comment="岗位编码")
order: Mapped[int] = mapped_column(Integer, nullable=False, default=1, comment="显示排序")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)")
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
# 关联关系
users: Mapped[list["UserModel"]] = relationship(
secondary="sys_user_positions",
back_populates="positions",
lazy="selectin",
)
users: Mapped[list["UserModel"]] = relationship(secondary="sys_user_positions", back_populates="positions")
@@ -49,5 +49,5 @@ class PositionOutSchema(PositionCreateSchema, BaseSchema, UserBySchema):
class PositionQueryParam(BaseQueryParam, UserByQueryParam):
"""岗位管理查询参数"""
name: str | None = Field(None, description="岗位名称")
status: int | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)")
name: str | None = Field(None, description="岗位名称", json_schema_extra={"q": "like"})
status: int | None = Field(None, ge=0, le=1, description="状态(0:启动 1:停用)", json_schema_extra={"q": "eq"})
@@ -63,15 +63,15 @@ class PositionService:
if position:
raise CustomException(msg="创建失败,该数据已存在")
new_position = await PositionCRUD(self.auth, self.db).create(data=data)
return PositionOutSchema.model_validate(new_position)
return await self.detail(id=new_position.id)
async def update(self, id: int, data: PositionUpdateSchema) -> PositionOutSchema:
_ = await PositionCRUD(self.auth, self.db).get_or_404(id=id, msg="更新失败,该数据不存在")
exist_position = await PositionCRUD(self.auth, self.db).get(name=data.name)
if exist_position and exist_position.id != id:
raise CustomException(msg="更新失败,名称已存在")
updated_position = await PositionCRUD(self.auth, self.db).update(id=id, data=data)
return PositionOutSchema.model_validate(updated_position)
await PositionCRUD(self.auth, self.db).update(id=id, data=data)
return await self.detail(id=id)
async def delete(self, ids: list[int]) -> None:
if not ids:
@@ -1,9 +1,7 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
from fastapi import APIRouter, Body, Depends, Path, Security, status
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi_cache import FastAPICache
from fastapi_cache.decorator import cache
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.response import ResponseSchema, StreamResponse, SuccessResponse
@@ -17,16 +15,13 @@ from .service import RoleService
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, Security(AuthPermission(["module_system:role:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[RoleQueryParam, Query()],
search: Annotated[RoleQueryParam, Depends()],
) -> JSONResponse:
result_dict = await RoleService(auth, db).page(
page_no=page.page_no,
@@ -54,7 +49,6 @@ async def create_role_controller(
data: Annotated[RoleCreateSchema, Body(description="角色创建参数")],
) -> JSONResponse:
result_dict = await RoleService(auth, db).create(data=data)
await FastAPICache.clear(namespace=_ROLE_NS)
return SuccessResponse(data=result_dict, msg="创建角色成功")
@@ -66,7 +60,6 @@ async def update_role_controller(
data: Annotated[RoleUpdateSchema, Body(description="角色修改参数")],
) -> JSONResponse:
result_dict = await RoleService(auth, db).update(id=id, data=data)
await FastAPICache.clear(namespace=_ROLE_NS)
return SuccessResponse(data=result_dict, msg="修改角色成功")
@@ -77,7 +70,6 @@ async def delete_role_controller(
ids: Annotated[list[int], Body(description="ID列表")],
) -> JSONResponse:
await RoleService(auth, db).delete(ids=ids)
await FastAPICache.clear(namespace=_ROLE_NS)
return SuccessResponse(msg="删除角色成功")
@@ -88,7 +80,6 @@ async def batch_set_available_role_controller(
data: Annotated[BatchSetAvailable, Body(description="状态设置")],
) -> JSONResponse:
await RoleService(auth, db).set_available(data=data)
await FastAPICache.clear(namespace=_ROLE_NS)
return SuccessResponse(msg="批量修改角色状态成功")
@@ -99,7 +90,6 @@ async def set_role_permission_controller(
data: Annotated[RolePermissionSettingSchema, Body(description="角色授权参数")],
) -> JSONResponse:
await RoleService(auth, db).set_permission(data=data)
await FastAPICache.clear(namespace=_ROLE_NS)
return SuccessResponse(msg="授权角色成功")
@@ -31,15 +31,13 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]):
if not role_ids:
raise CustomException(msg="角色ID列表不能为空")
roles = await self.get_list(search={"id": ("in", role_ids)})
# 校验:传入的 role_ids 必须全部存在(否则容易被 IDOR silent no-op
roles = await self.get_list(search={"id": ("in", role_ids)}, preload=["menus"])
if len(roles) != len(set(role_ids)):
missing = sorted(set(role_ids) - {r.id for r in roles})
raise CustomException(msg=f"角色不存在: {missing}")
menus = [] if not menu_ids else await MenuCRUD(self.auth, self.db).get_list(search={"id": ("in", menu_ids)})
# 校验:传入的所有菜单必须存在
if menu_ids and len(menus) != len(set(menu_ids)):
missing = sorted(set(menu_ids) - {m.id for m in menus})
raise CustomException(msg=f"菜单不存在: {missing}")
@@ -54,7 +52,7 @@ class RoleCRUD(CRUDBase[RoleModel, RoleCreateSchema, RoleUpdateSchema]):
if not role_ids:
raise CustomException(msg="角色ID列表不能为空")
roles = await self.get_list(search={"id": ("in", role_ids)})
roles = await self.get_list(search={"id": ("in", role_ids)}, preload=["depts"])
if len(roles) != len(set(role_ids)):
missing = sorted(set(role_ids) - {r.id for r in roles})
raise CustomException(msg=f"角色不存在: {missing}")
+5 -11
View File
@@ -63,20 +63,14 @@ class RoleModel(ModelMixin, UserMixin):
__tablename__: str = "sys_role"
__table_args__: dict[str, str] = {"comment": "角色表"}
__loader_options__: list[str] = ["menus", "depts", "created_by", "updated_by", "deleted_by"]
name: Mapped[str] = mapped_column(String(64), nullable=False, comment="角色名称")
name: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="角色名称")
code: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, comment="角色编码")
order: Mapped[int] = mapped_column(Integer, nullable=False, default=999, comment="显示排序")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)")
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
data_scope: Mapped[int] = mapped_column(Integer, default=1, nullable=False, comment="数据权限范围(1:仅本人 2:本部门 3:本部门及以下 4:全部 5:自定义)")
menus: Mapped[list["MenuModel"]] = relationship(
secondary="sys_role_menus",
back_populates="roles",
lazy="selectin",
order_by="MenuModel.order",
)
depts: Mapped[list["DeptModel"]] = relationship(secondary="sys_role_depts", back_populates="roles", lazy="selectin")
users: Mapped[list["UserModel"]] = relationship(secondary="sys_user_roles", back_populates="roles", lazy="selectin")
menus: Mapped[list["MenuModel"]] = relationship(secondary="sys_role_menus", back_populates="roles", order_by="MenuModel.order")
depts: Mapped[list["DeptModel"]] = relationship(secondary="sys_role_depts", back_populates="roles")
users: Mapped[list["UserModel"]] = relationship(secondary="sys_user_roles", back_populates="roles")
@@ -98,6 +98,6 @@ class RoleQueryParam(BaseQueryParam, UserByQueryParam):
"""角色管理查询参数
"""
name: str | None = Field(None, description="角色名称")
name: str | None = Field(None, description="角色名称", json_schema_extra={"q": "like"})
code: str | None = Field(None, description="角色编码", json_schema_extra={"q": "eq"})
status: int | None = Field(None, description="状态(0:启动 1:停用)")
status: int | None = Field(None, description="状态(0:启动 1:停用)", json_schema_extra={"q": "eq"})
@@ -16,6 +16,8 @@ from .schema import (
RoleUpdateSchema,
)
_ROLE_PRELOAD = ["menus", "depts"]
class RoleService:
"""角色管理服务
@@ -36,7 +38,7 @@ class RoleService:
返回:
- RoleOutSchema: 角色详情响应模型
"""
obj = await RoleCRUD(self.auth, self.db).get_or_404(id=id)
obj = await RoleCRUD(self.auth, self.db).get_or_404(id=id, preload=_ROLE_PRELOAD)
return RoleOutSchema.model_validate(obj)
async def get_options(self) -> list[dict[str, Any]]:
@@ -57,7 +59,7 @@ class RoleService:
返回:
- list[RoleOutSchema]: 角色响应模型列表
"""
role_list = await RoleCRUD(self.auth, self.db).get_list(search=search_to_dict(search), order_by=order_by)
role_list = await RoleCRUD(self.auth, self.db).get_list(search=search_to_dict(search), order_by=order_by, preload=_ROLE_PRELOAD)
return [RoleOutSchema.model_validate(role) for role in role_list]
async def page(
@@ -85,6 +87,7 @@ class RoleService:
order_by=order_by or [{"id": "asc"}],
search=search_to_dict(search),
out_schema=RoleOutSchema,
preload=_ROLE_PRELOAD,
)
async def create(self, data: RoleCreateSchema) -> RoleOutSchema:
@@ -105,7 +108,7 @@ class RoleService:
raise CustomException(msg="创建失败,编码已存在")
new_role = await RoleCRUD(self.auth, self.db).create(data=data)
return RoleOutSchema.model_validate(new_role)
return await self.detail(id=new_role.id)
async def update(self, id: int, data: RoleUpdateSchema) -> RoleOutSchema:
"""更新角色
@@ -124,8 +127,8 @@ class RoleService:
exist_code = await RoleCRUD(self.auth, self.db).get(code=data.code)
if exist_code and exist_code.id != id:
raise CustomException(msg="更新失败,角色编码已存在")
updated_role = await RoleCRUD(self.auth, self.db).update(id=id, data=data)
return RoleOutSchema.model_validate(updated_role)
await RoleCRUD(self.auth, self.db).update(id=id, data=data)
return await self.detail(id=id)
async def delete(self, ids: list[int]) -> None:
"""删除角色
@@ -1,6 +1,6 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
from fastapi import APIRouter, Body, Depends, Path, Security, status
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
@@ -21,7 +21,7 @@ async def ticket_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:ticket:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[TicketQueryParam, Query()],
search: Annotated[TicketQueryParam, Depends()],
) -> JSONResponse:
result = await TicketService(auth, db).page(
page_no=page.page_no,
@@ -16,15 +16,9 @@ class TicketModel(ModelMixin, UserMixin):
__tablename__: str = "sys_ticket"
__table_args__: dict[str, str] = {"comment": "工单表"}
__loader_options__: list[str] = [
"created_by",
"updated_by",
"deleted_by",
"assigned_by",
]
title: Mapped[str] = mapped_column(String(200), nullable=False, comment="工单标题")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:待处理 1:处理中 2:已完成 3:已关闭)", index=True)
title: Mapped[str] = mapped_column(String(200), nullable=False, index=True, comment="标题")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:待处理 1:处理中 2:已完成 3:已关闭)")
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
ticket_content: Mapped[str | None] = mapped_column(Text, nullable=True, comment="工单内容(富文本)")
summary: Mapped[str | None] = mapped_column(Text, nullable=True, comment="工单内容(纯文本摘要)")
@@ -33,7 +27,7 @@ class TicketModel(ModelMixin, UserMixin):
reply: Mapped[str | None] = mapped_column(Text, nullable=True, comment="回复内容")
assigned_id: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id", ondelete="SET NULL", onupdate="CASCADE"), nullable=True, index=True, comment="处理人ID")
assigned_by: Mapped["UserModel | None"] = relationship("UserModel", foreign_keys=[assigned_id], lazy="selectin", uselist=False)
assigned_by: Mapped["UserModel | None"] = relationship("UserModel", foreign_keys=[assigned_id], uselist=False)
@validates("title")
def validate_title(self, key: str, title: str) -> str:
@@ -52,11 +46,6 @@ 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="评论内容(富文本)")
@@ -84,10 +84,10 @@ class TicketBatchSchema(BaseModel):
class TicketQueryParam(BaseQueryParam, UserByQueryParam):
"""工单查询参数"""
title: str | None = Field(None, description="工单标题")
title: str | None = Field(None, description="工单标题", json_schema_extra={"q": "like"})
ticket_type: str | None = Field(None, description="工单类型", json_schema_extra={"q": "eq"})
assigned_id: int | None = Field(None, description="处理人ID")
status: int | None = Field(None, ge=0, le=3, description="状态(0:待处理 1:处理中 2:已完成 3:已关闭)")
assigned_id: int | None = Field(None, description="处理人ID", json_schema_extra={"q": "eq"})
status: int | None = Field(None, ge=0, le=3, description="状态(0:待处理 1:处理中 2:已完成 3:已关闭)", json_schema_extra={"q": "eq"})
class TicketCommentCreateSchema(BaseModel):
@@ -5,7 +5,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_system.user.model import UserModel
from app.core.base_schema import AuthSchema, PageResultSchema
from app.core.event_bus import EventBus
from app.core.exceptions import CustomException
from app.utils.common_util import search_to_dict
from app.utils.excel_util import ExcelUtil
@@ -36,6 +35,9 @@ _TICKET_STATUS_LABELS = {
}
_TICKET_PRELOAD = ["assigned_by"]
class TicketService:
"""工单管理服务"""
@@ -88,17 +90,18 @@ class TicketService:
order_by=order_by or [{"created_time": "desc"}],
search=search_to_dict(search),
out_schema=TicketOutSchema,
preload=_TICKET_PRELOAD,
)
async def detail(self, id: int) -> TicketOutSchema:
obj = await TicketCRUD(self.auth, self.db).get_or_404(id=id)
obj = await TicketCRUD(self.auth, self.db).get_or_404(id=id, preload=_TICKET_PRELOAD)
return TicketOutSchema.model_validate(obj)
async def create(self, data: TicketCreateSchema) -> TicketOutSchema:
obj = await TicketCRUD(self.auth, self.db).create(data=data)
if not obj:
raise CustomException(msg="创建工单失败")
return TicketOutSchema.model_validate(obj)
return await self.detail(id=obj.id)
async def update(self, id: int, data: TicketUpdateSchema) -> TicketOutSchema:
obj = await TicketCRUD(self.auth, self.db).get_or_404(id=id, msg="工单不存在")
@@ -120,19 +123,7 @@ class TicketService:
if not updated:
raise CustomException(msg="工单不存在")
# 有回复内容时 SSE 推送通知给工单创建者
if data.reply and obj.created_id:
await EventBus.publish(
obj.created_id,
{
"type": "ticket_reply",
"ticket_id": obj.id,
"title": obj.title,
"ticket_type": obj.ticket_type,
},
)
return TicketOutSchema.model_validate(updated)
return await self.detail(id=updated.id)
async def delete(self, ids: list[int]) -> None:
if not ids:
@@ -160,6 +151,7 @@ class TicketService:
obj_list = await TicketCRUD(self.auth, self.db).get_list(
search=search_to_dict(search),
order_by=order_by or [{"created_time": "desc"}],
preload=_TICKET_PRELOAD,
)
return [TicketOutSchema.model_validate(obj) for obj in obj_list]
@@ -1,7 +1,7 @@
import urllib.parse
from typing import Annotated
from fastapi import APIRouter, Body, Depends, File, Path, Query, Security, UploadFile, status
from fastapi import APIRouter, Body, Depends, File, Path, Security, UploadFile, status
from fastapi.responses import JSONResponse, StreamingResponse
from redis.asyncio.client import Redis
from sqlalchemy.ext.asyncio import AsyncSession
@@ -120,7 +120,7 @@ async def get_user_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:user:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[UserQueryParam, Query()],
search: Annotated[UserQueryParam, Depends()],
) -> JSONResponse:
result_dict = await UserService(auth, db).page(
page_no=page.page_no,
+8 -26
View File
@@ -37,41 +37,23 @@ 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列表
- role_ids (list[int]): 角色ID列表
返回:
- None
"""
user_objs = await self.get_list(search={"id": ("in", user_ids)})
"""批量设置用户角色"""
user_objs = await self.get_list(search={"id": ("in", user_ids)}, preload=["roles"])
role_objs = [] if not role_ids else await RoleCRUD(self.auth, self.db).get_list(search={"id": ("in", role_ids)})
for obj in user_objs:
relationship = obj.roles
relationship.clear()
relationship.extend(role_objs)
obj.roles.clear()
obj.roles.extend(role_objs)
await self.db.flush()
async def set_user_positions(self, user_ids: list[int], position_ids: list[int]) -> None:
"""批量设置用户岗位
参数:
- user_ids (list[int]): 用户ID列表
- position_ids (list[int]): 岗位ID列表
返回:
- None
"""
user_objs = await self.get_list(search={"id": ("in", user_ids)})
"""批量设置用户岗位"""
user_objs = await self.get_list(search={"id": ("in", user_ids)}, preload=["positions"])
position_objs = [] if not position_ids else await PositionCRUD(self.auth, self.db).get_list(search={"id": ("in", position_ids)})
for obj in user_objs:
relationship = obj.positions
relationship.clear()
relationship.extend(position_objs)
obj.positions.clear()
obj.positions.extend(position_objs)
await self.db.flush()
async def change_password(self, id: int, password_hash: str) -> UserModel:
+9 -10
View File
@@ -64,13 +64,12 @@ class UserModel(ModelMixin, UserMixin):
__tablename__: str = "sys_user"
__table_args__: dict[str, str] = {"comment": "用户表"}
__loader_options__: list[str] = ["dept", "roles", "positions", "created_by", "updated_by", "deleted_by"]
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, comment="用户名/登录账号")
password: Mapped[str] = mapped_column(String(255), nullable=False, comment="密码哈希")
name: Mapped[str] = mapped_column(String(32), nullable=False, comment="昵称")
mobile: Mapped[str | None] = mapped_column(String(11), nullable=True, comment="手机号")
email: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="邮箱")
mobile: Mapped[str | None] = mapped_column(String(11), nullable=True, index=True, comment="手机号")
email: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True, comment="邮箱")
gender: Mapped[str | None] = mapped_column(String(1), default="2", nullable=True, comment="性别(0:男 1:女 2:未知)")
avatar: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="头像URL地址")
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, comment="是否超管")
@@ -79,13 +78,13 @@ class UserModel(ModelMixin, UserMixin):
github_login: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="Github登录")
wx_login: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="微信登录")
qq_login: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="QQ登录")
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)", index=True)
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启动 1:停用)")
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
dept_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("sys_dept.id", ondelete="SET NULL", onupdate="CASCADE"), nullable=True, index=True, comment="部门ID")
dept: Mapped["DeptModel | None"] = relationship(back_populates="users", foreign_keys=[dept_id], lazy="selectin")
roles: Mapped[list["RoleModel"]] = relationship(secondary="sys_user_roles", back_populates="users", lazy="selectin")
positions: Mapped[list["PositionModel"]] = relationship(secondary="sys_user_positions", back_populates="users", lazy="selectin")
created_by: Mapped["UserModel | None"] = relationship("UserModel", foreign_keys="UserModel.created_id", remote_side="UserModel.id", lazy="selectin", uselist=False, viewonly=True)
updated_by: Mapped["UserModel | None"] = relationship("UserModel", foreign_keys="UserModel.updated_id", remote_side="UserModel.id", lazy="selectin", uselist=False, viewonly=True)
deleted_by: Mapped["UserModel | None"] = relationship("UserModel", foreign_keys="UserModel.deleted_id", remote_side="UserModel.id", lazy="selectin", uselist=False, viewonly=True)
dept: Mapped["DeptModel | None"] = relationship(back_populates="users", foreign_keys=[dept_id])
roles: Mapped[list["RoleModel"]] = relationship(secondary="sys_user_roles", back_populates="users")
positions: Mapped[list["PositionModel"]] = relationship(secondary="sys_user_positions", back_populates="users")
created_by: Mapped["UserModel | None"] = relationship("UserModel", foreign_keys="UserModel.created_id", remote_side="UserModel.id", uselist=False, viewonly=True)
updated_by: Mapped["UserModel | None"] = relationship("UserModel", foreign_keys="UserModel.updated_id", remote_side="UserModel.id", uselist=False, viewonly=True)
deleted_by: Mapped["UserModel | None"] = relationship("UserModel", foreign_keys="UserModel.deleted_id", remote_side="UserModel.id", uselist=False, viewonly=True)
@@ -1,3 +1,4 @@
import re
from urllib.parse import urlparse
from pydantic import (
@@ -12,7 +13,7 @@ from pydantic import (
from app.api.v1.module_system.menu.schema import MenuTreeOutSchema
from app.api.v1.module_system.role.schema import RoleOutSchema
from app.core.base_schema import BaseQueryParam, BaseSchema, CommonSchema, CoreUserSchema, UserByQueryParam, UserBySchema
from app.core.validator import email_validator, mobile_validator
from app.core.validator import DateTimeStr, email_validator, mobile_validator
class CurrentUserUpdateSchema(BaseModel):
@@ -81,7 +82,6 @@ class UserForgetPasswordSchema(BaseModel):
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 位,仅允许字母、数字、_ . -")
@@ -166,7 +166,6 @@ class UserCreateSchema(CurrentUserUpdateSchema):
if not value:
return value
v = value.strip()
import re
if not re.match(r"^[A-Za-z][A-Za-z0-9_.-]{1,31}$", v):
raise ValueError("账号需以字母开头,2-32 位,仅允许字母、数字、_ . -")
@@ -200,7 +199,6 @@ class UserRegisterSchema(BaseModel):
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 位,仅允许字母、数字、_ . -")
@@ -245,7 +243,6 @@ class UserUpdateSchema(CurrentUserUpdateSchema):
if not value:
return value
v = value.strip()
import re
if not re.match(r"^[A-Za-z][A-Za-z0-9_.-]{1,31}$", v):
raise ValueError("账号需以字母开头,2-32 位,仅允许字母、数字、_ . -")
@@ -279,6 +276,7 @@ class UserOutSchema(CoreUserSchema, BaseSchema, UserBySchema):
roles: list[RoleOutSchema] | None = Field(default=[], description="角色")
menus: list[MenuTreeOutSchema] | None = Field(default=[], description="菜单")
is_superuser: bool = Field(default=False, description="是否超管")
last_login: DateTimeStr | None = Field(default=None, description="最后登录时间")
class UserQueryParam(BaseQueryParam, UserByQueryParam):
@@ -290,13 +288,14 @@ class UserQueryParam(BaseQueryParam, UserByQueryParam):
- 业务字段用户名名称手机号邮箱部门状态
"""
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}$")
username: str | None = Field(None, description="用户名", json_schema_extra={"q": "like"})
name: str | None = Field(None, description="名称", json_schema_extra={"q": "like"})
mobile: str | None = Field(None, description="手机号", pattern=r"^1[3-9]\d{9}$", json_schema_extra={"q": "eq"})
email: str | None = Field(
None,
description="邮箱",
pattern=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$",
json_schema_extra={"q": "eq"},
)
dept_id: int | None = Field(None, description="部门ID")
status: int | None = Field(None, description="是否可用")
dept_id: int | None = Field(None, description="部门ID", json_schema_extra={"q": "eq"})
status: int | None = Field(None, description="是否可用", json_schema_extra={"q": "eq"})
+96 -132
View File
@@ -28,6 +28,8 @@ from .schema import (
UserUpdateSchema,
)
_USER_PRELOAD = ["dept", "positions", "roles.menus", "roles.depts"]
class UserService:
"""用户管理服务"""
@@ -37,11 +39,10 @@ class UserService:
self.db = db
async def detail(self, id: int) -> UserOutSchema:
user = await UserCRUD(self.auth, self.db).get_or_404(id=id)
user = await UserCRUD(self.auth, self.db).get_or_404(id=id, preload=_USER_PRELOAD)
result = UserOutSchema.model_validate(user)
if user.dept_id:
dept = await DeptCRUD(self.auth, self.db).get(id=user.dept_id)
result.dept_name = dept.name if dept else None
if user.dept:
result.dept_name = user.dept.name
return result
async def get_list(
@@ -49,8 +50,12 @@ class UserService:
search: UserQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> list[UserOutSchema]:
user_list = await UserCRUD(self.auth, self.db).get_list(search=search_to_dict(search), order_by=order_by)
return [UserOutSchema.model_validate(user) for user in user_list]
user_list = await UserCRUD(self.auth, self.db).get_list(search=search_to_dict(search), order_by=order_by, preload=_USER_PRELOAD)
result = [UserOutSchema.model_validate(user) for user in user_list]
for user, item in zip(user_list, result, strict=True):
if user.dept:
item.dept_name = user.dept.name
return result
async def page(
self,
@@ -66,98 +71,71 @@ class UserService:
order_by=order_by or [{"id": "asc"}],
search=search_to_dict(search),
out_schema=UserOutSchema,
preload=_USER_PRELOAD,
)
async def create(self, data: UserCreateSchema) -> UserOutSchema:
if not data.username:
raise CustomException(msg="用户名不能为空")
if data.is_superuser:
raise CustomException(msg="不允许创建超级管理员")
user = await UserCRUD(self.auth, self.db).get(username=data.username)
if user:
if await UserCRUD(self.auth, self.db).get(username=data.username):
raise CustomException(msg="已存在相同用户名称的账号")
if data.dept_id and not await DeptCRUD(self.auth, self.db).get(id=data.dept_id):
raise CustomException(msg="该数据不存在")
if data.password:
data.password = PwdUtil.hash_password(password=data.password)
create_data = data.model_dump(exclude_none=True, exclude={"role_ids", "position_ids"})
new_user = await UserCRUD(self.auth, self.db).create(data=create_data)
if data.role_ids:
await UserCRUD(self.auth, self.db).set_user_roles(user_ids=[new_user.id], role_ids=data.role_ids)
if data.position_ids:
await UserCRUD(self.auth, self.db).set_user_positions(user_ids=[new_user.id], position_ids=data.position_ids)
return await self.detail(id=new_user.id)
async def update(self, id: int, data: UserUpdateSchema) -> UserOutSchema:
if data.username:
if exist_user := await UserCRUD(self.auth, self.db).get(username=data.username):
if exist_user.id != id:
raise CustomException(msg="更新失败,账号已存在")
if data.mobile:
if exist_mobile := await UserCRUD(self.auth, self.db).get(mobile=data.mobile):
if exist_mobile.id != id:
raise CustomException(msg="该数据已存在")
if data.email:
if exist_email := await UserCRUD(self.auth, self.db).get(email=data.email):
if exist_email.id != id:
raise CustomException(msg="该数据已存在")
if data.dept_id:
dept = await DeptCRUD(self.auth, self.db).get(id=data.dept_id)
if not dept:
raise CustomException(msg="该数据不存在")
if dept.status == 1:
raise CustomException(msg="部门已被禁用")
if data.password:
data.password = PwdUtil.hash_password(password=data.password)
new_user = await UserCRUD(self.auth, self.db).create(data=data)
if data.role_ids and len(data.role_ids) > 0:
await UserCRUD(self.auth, self.db).set_user_roles(user_ids=[new_user.id], role_ids=data.role_ids)
if data.position_ids and len(data.position_ids) > 0:
await UserCRUD(self.auth, self.db).set_user_positions(user_ids=[new_user.id], position_ids=data.position_ids)
return UserOutSchema.model_validate(new_user)
update_data = data.model_dump(exclude_unset=True, exclude_none=True, exclude={"role_ids", "position_ids"})
await UserCRUD(self.auth, self.db).update(id=id, data=update_data)
async def update(self, id: int, data: UserUpdateSchema) -> UserOutSchema:
if not data.username:
raise CustomException(msg="账号不能为空")
if data.role_ids:
roles = await RoleCRUD(self.auth, self.db).get_list(search={"id": ("in", data.role_ids)})
if len(roles) != len(data.role_ids):
raise CustomException(msg="更新失败,部分角色不存在")
if not all(role.status == 0 for role in roles):
raise CustomException(msg="更新失败,部分角色已被禁用")
await UserCRUD(self.auth, self.db).set_user_roles(user_ids=[id], role_ids=data.role_ids)
user = await UserCRUD(self.auth, self.db).get_or_404(id=id)
if user.is_superuser:
raise CustomException(msg="超级管理员不允许修改")
if data.position_ids:
positions = await PositionCRUD(self.auth, self.db).get_list(search={"id": ("in", data.position_ids)})
if len(positions) != len(data.position_ids):
raise CustomException(msg="更新失败,部分岗位不存在")
if not all(position.status == 0 for position in positions):
raise CustomException(msg="更新失败,部分岗位已被禁用")
await UserCRUD(self.auth, self.db).set_user_positions(user_ids=[id], position_ids=data.position_ids)
await self._validate_unique_username(data.username, exclude_id=id)
await self._validate_mobile_email_unique(data, exclude_id=id)
await self._validate_dept_active(data.dept_id)
new_user = await UserCRUD(self.auth, self.db).update(id=id, data=data)
await self._assign_user_roles(id, data.role_ids)
await self._assign_user_positions(id, data.position_ids)
return UserOutSchema.model_validate(new_user)
async def _validate_unique_username(self, username: str, exclude_id: int) -> None:
"""校验用户名唯一性"""
exist_user = await UserCRUD(self.auth, self.db).get(username=username)
if exist_user and exist_user.id != exclude_id:
raise CustomException(msg="更新失败,账号已存在")
async def _validate_mobile_email_unique(self, data: UserUpdateSchema, exclude_id: int) -> None:
"""校验手机号和邮箱唯一性"""
if data.mobile:
exist_mobile_user = await UserCRUD(self.auth, self.db).get(mobile=data.mobile)
if exist_mobile_user and exist_mobile_user.id != exclude_id:
raise CustomException(msg="该数据已存在")
if data.email:
exist_email_user = await UserCRUD(self.auth, self.db).get(email=data.email)
if exist_email_user and exist_email_user.id != exclude_id:
raise CustomException(msg="该数据已存在")
async def _validate_dept_active(self, dept_id: int | None) -> None:
"""校验部门存在且已启用"""
if not dept_id:
return
dept = await DeptCRUD(self.auth, self.db).get(id=dept_id)
if not dept:
raise CustomException(msg="该数据不存在")
if dept.status == 1:
raise CustomException(msg="部门已被禁用")
async def _assign_user_roles(self, user_id: int, role_ids: list[int] | None) -> None:
"""校验并分配用户角色"""
if not role_ids or len(role_ids) < 1:
return
roles = await RoleCRUD(self.auth, self.db).get_list(search={"id": ("in", role_ids)})
if len(roles) != len(role_ids):
raise CustomException(msg="更新失败,部分角色不存在")
if not all(role.status == 0 for role in roles):
raise CustomException(msg="更新失败,部分角色已被禁用")
await UserCRUD(self.auth, self.db).set_user_roles(user_ids=[user_id], role_ids=role_ids)
async def _assign_user_positions(self, user_id: int, position_ids: list[int] | None) -> None:
"""校验并分配用户岗位"""
if not position_ids or len(position_ids) < 1:
return
positions = await PositionCRUD(self.auth, self.db).get_list(search={"id": ("in", position_ids)})
if len(positions) != len(position_ids):
raise CustomException(msg="更新失败,部分岗位不存在")
if not all(position.status == 0 for position in positions):
raise CustomException(msg="更新失败,部分岗位已被禁用")
await UserCRUD(self.auth, self.db).set_user_positions(user_ids=[user_id], position_ids=position_ids)
return await self.detail(id=id)
async def delete(self, ids: list[int]) -> None:
if not ids:
@@ -173,25 +151,21 @@ class UserService:
if user.is_superuser:
errors.append(f"用户[{uid}]是超级管理员,不能删除")
continue
if user.status == 0:
errors.append(f"用户[{uid}]已启用,不能删除")
continue
if self.auth.user.id == uid:
errors.append("不能删除当前登陆用户")
continue
if errors:
raise CustomException(msg="; ".join(errors))
if not user_map:
raise CustomException(msg="删除对象不存在")
await UserCRUD(self.auth, self.db).set_user_roles(user_ids=ids, role_ids=[])
await UserCRUD(self.auth, self.db).set_user_positions(user_ids=ids, position_ids=[])
await UserCRUD(self.auth, self.db).delete(ids=ids)
async def current_info(self) -> UserOutSchema:
if not self.auth.user.id:
user_id = self.auth.user.id
if not user_id:
raise CustomException(msg="该数据不存在")
user = await UserCRUD(self.auth, self.db).get(id=self.auth.user.id)
user = await UserCRUD(self.auth, self.db).get(id=user_id, preload=_USER_PRELOAD)
if user is None:
raise CustomException(msg="该数据不存在")
user_dict = UserOutSchema.model_validate(user)
@@ -199,9 +173,9 @@ class UserService:
user_dict.dept_name = user.dept.name
user_dict.is_superuser = user.is_superuser
_pc_only = {"client": "pc"}
_pc_only = {"scope": "web"}
if self.auth.user.is_superuser:
menu_all = await MenuCRUD(self.auth, self.db).tree_list(
menu_all = await MenuCRUD(self.auth, self.db).get_list(
search={"type": ("in", [1, 2, 3, 4]), "status": 0, **_pc_only},
order_by=[{"order": "asc"}],
)
@@ -211,7 +185,7 @@ class UserService:
menus_raw = (
[
MenuOutSchema.model_validate(menu)
for menu in await MenuCRUD(self.auth, self.db).tree_list(
for menu in await MenuCRUD(self.auth, self.db).get_list(
search={"id": ("in", list(menu_ids)), **_pc_only},
order_by=[{"order": "asc"}],
)
@@ -225,24 +199,22 @@ class UserService:
return user_dict
async def update_current_info(self, data: CurrentUserUpdateSchema) -> UserOutSchema:
if not self.auth.user.id:
user_id = self.auth.user.id
if not user_id:
raise CustomException(msg="该数据不存在")
user = await UserCRUD(self.auth, self.db).get(id=self.auth.user.id)
if not user:
raise CustomException(msg="该数据不存在")
if user.is_superuser:
raise CustomException(msg="超级管理员不能修改个人信息")
if data.mobile:
exist_mobile_user = await UserCRUD(self.auth, self.db).get(mobile=data.mobile)
if exist_mobile_user and exist_mobile_user.id != self.auth.user.id:
raise CustomException(msg="该数据已存在")
if exist_mobile := await UserCRUD(self.auth, self.db).get(mobile=data.mobile):
if exist_mobile.id != user_id:
raise CustomException(msg="该数据已存在")
if data.email:
exist_email_user = await UserCRUD(self.auth, self.db).get(email=data.email)
if exist_email_user and exist_email_user.id != self.auth.user.id:
raise CustomException(msg="该数据已存在")
if exist_email := await UserCRUD(self.auth, self.db).get(email=data.email):
if exist_email.id != user_id:
raise CustomException(msg="该数据已存在")
user_update_data = UserUpdateSchema(**data.model_dump())
new_user = await UserCRUD(self.auth, self.db).update(id=self.auth.user.id, data=user_update_data)
return UserOutSchema.model_validate(new_user)
await UserCRUD(self.auth, self.db).update(id=user_id, data=user_update_data)
return await self.detail(id=user_id)
async def set_available(self, data: BatchSetAvailable) -> None:
users = await UserCRUD(self.auth, self.db).get_list(search={"id": ("in", data.ids)})
@@ -252,41 +224,29 @@ class UserService:
await UserCRUD(self.auth, self.db).set(ids=data.ids, status=data.status)
async def change_password(self, data: UserChangePasswordSchema) -> UserOutSchema:
if not self.auth.user.id:
user_id = self.auth.user.id
if not user_id:
raise CustomException(msg="该数据不存在")
if not data.old_password or not data.new_password:
raise CustomException(msg="密码不能为空")
user = await UserCRUD(self.auth, self.db).get(id=self.auth.user.id)
if not user:
raise CustomException(msg="该数据不存在")
user = await UserCRUD(self.auth, self.db).get_or_404(id=user_id)
if not PwdUtil.verify_password(plain_password=data.old_password, password_hash=user.password):
raise CustomException(msg="原密码输入错误")
new_password_hash = PwdUtil.hash_password(password=data.new_password)
new_user = await UserCRUD(self.auth, self.db).change_password(id=user.id, password_hash=new_password_hash)
return UserOutSchema.model_validate(new_user)
await UserCRUD(self.auth, self.db).change_password(id=user_id, password_hash=new_password_hash)
return await self.detail(id=user_id)
async def reset_password(self, data: ResetPasswordSchema) -> UserOutSchema:
if not data.password:
raise CustomException(msg="密码不能为空")
user = await UserCRUD(self.auth, self.db).get(id=data.id)
if not user:
raise CustomException(msg="该数据不存在")
user = await UserCRUD(self.auth, self.db).get_or_404(id=data.id)
if user.is_superuser:
raise CustomException(msg="超级管理员密码不能重置")
new_password_hash = PwdUtil.hash_password(password=data.password)
new_user = await UserCRUD(self.auth, self.db).change_password(id=data.id, password_hash=new_password_hash)
return UserOutSchema.model_validate(new_user)
await UserCRUD(self.auth, self.db).change_password(id=data.id, password_hash=new_password_hash)
return await self.detail(id=data.id)
async def forget_password(self, data: UserForgetPasswordSchema) -> UserOutSchema:
# 直接按用户名查找用户
user = await UserCRUD(self.auth, self.db).get(username=data.username)
if not user:
raise CustomException(msg="该数据不存在")
user = await UserCRUD(self.auth, self.db).get_or_404(username=data.username)
if user.status == 1:
raise CustomException(msg="用户已停用")
if user.is_superuser:
@@ -295,8 +255,8 @@ class UserService:
raise CustomException(msg="手机号不匹配")
new_password_hash = PwdUtil.hash_password(password=data.new_password)
new_user = await UserCRUD(self.auth, self.db).forget_password(id=user.id, password_hash=new_password_hash)
return UserOutSchema.model_validate(new_user)
await UserCRUD(self.auth, self.db).change_password(id=user.id, password_hash=new_password_hash)
return await self.detail(id=user.id)
async def register(self, data: UserRegisterSchema) -> UserOutSchema:
"""用户注册"""
@@ -312,8 +272,10 @@ class UserService:
status=0,
)
new_user = await UserCRUD(self.auth, self.db).create_obj_crud(data=create_data)
if not new_user:
raise CustomException(msg="注册失败")
logger.info(f"新用户注册成功: {data.username}")
return UserOutSchema.model_validate(new_user)
return await self.detail(id=new_user.id)
async def batch_import(self, file: UploadFile, update_support: bool = False) -> str:
header_dict = {
@@ -426,7 +388,9 @@ class UserService:
return 0, f"{row_num}行: 用户 {user_data['username']} 已存在"
else:
user_create_schema = UserCreateSchema(**user_data)
new_user = await UserCRUD(self.auth, self.db).create(data=user_create_schema)
new_user = await UserCRUD(self.auth, self.db).create(
data=user_create_schema.model_dump(exclude_none=True, exclude={"role_ids", "position_ids"}) # type: ignore[arg-type]
)
if user_create_schema.role_ids and len(user_create_schema.role_ids) > 0:
await UserCRUD(self.auth, self.db).set_user_roles(
user_ids=[new_user.id], role_ids=user_create_schema.role_ids
@@ -1,6 +1,6 @@
from typing import Annotated
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
from fastapi import APIRouter, Body, Depends, Path, Security, status
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
@@ -26,7 +26,7 @@ async def get_version_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_system:version:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[VersionQueryParam, Query()],
search: Annotated[VersionQueryParam, Depends()],
) -> JSONResponse:
service = VersionService(auth, db)
result = await service.page(page_no=page.page_no, page_size=page.page_size, search=search)
@@ -9,9 +9,8 @@ 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="版本号")
version: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, 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="版本富文本内容")
@@ -47,4 +47,4 @@ class VersionStatusSchema(BaseModel):
class VersionQueryParam(BaseQueryParam):
"""版本查询参数"""
status: int | None = Field(default=None, description="状态: 0=草稿,1=已发布,2=已回滚")
status: int | None = Field(default=None, description="状态: 0=草稿,1=已发布,2=已回滚", json_schema_extra={"q": "eq"})