feat(system): 完成后端系统管理(审计/角色/操作日志/日志查询)
- 操作日志审计机制:audit 上下文(成功与失败都记、独立提交、操作人标量 固化、commit 后查询前置约定),用户/角色全部写操作接入;request_data 按键名的结构化脱敏 + UTF-8 字节边界截断 - 角色管理:CRUD 与启停;admin 禁删禁停、有绑定禁删(FOR UPDATE 锁行)、 编码创建后不可改、停用角色不可再分配 - 日志查询:操作/登录两端点,列表与详情两段式(详情含已脱敏请求参数); 时间参数统一转本地 naive(修混用时区 500 与偏移丢弃) - 用户管理:补齐 reset_password 操作人、编辑空串归一 NULL、并发唯一键兜底 400 - 测试 301 例全绿(单元 223 / 集成 78) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
e22c4bcdc9
commit
c83287819a
32 files changed
+3158
-137
No files matched your search
@@ -1,8 +1,10 @@
|
||||
"""登录日志数据访问。"""
|
||||
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import LoginLog
|
||||
from app.repositories.query_utils import LogFilters, apply_log_filters
|
||||
|
||||
# user_agent 列宽,超长需截断否则插入报错
|
||||
_USER_AGENT_MAX_LEN = 255
|
||||
@@ -36,3 +38,40 @@ def add(
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def list_logs(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
filters: LogFilters,
|
||||
ip: str | None = None,
|
||||
) -> list[LoginLog]:
|
||||
"""分页查询登录日志。
|
||||
|
||||
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
||||
不确定,翻页会重复或遗漏(与用户列表同一理由)。
|
||||
"""
|
||||
stmt = _apply_filters(select(LoginLog), filters=filters, ip=ip)
|
||||
stmt = stmt.order_by(LoginLog.id.desc()).offset(offset).limit(limit)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_logs(
|
||||
session: Session,
|
||||
*,
|
||||
filters: LogFilters,
|
||||
ip: str | None = None,
|
||||
) -> int:
|
||||
"""统计符合条件的登录日志总数,供分页使用。"""
|
||||
stmt = _apply_filters(select(func.count(LoginLog.id)), filters=filters, ip=ip)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, *, filters: LogFilters, ip: str | None) -> Select:
|
||||
"""应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。"""
|
||||
stmt = apply_log_filters(stmt, LoginLog, filters)
|
||||
if ip:
|
||||
stmt = stmt.where(LoginLog.ip == ip)
|
||||
return stmt
|
||||
@@ -0,0 +1,200 @@
|
||||
"""操作日志数据访问。
|
||||
|
||||
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||||
|
||||
写入前处理(截断 / 脱敏 / 序列化)集中在本层,与 `login_log_repository`
|
||||
的做法一致:模型定义的长度常量在这里落实,调用方只需传业务值。
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.orm import Session, defer
|
||||
|
||||
from app.core.logging import scrub
|
||||
from app.models import OperationLog
|
||||
from app.models.operation_log import (
|
||||
ACTION_MAX_LEN,
|
||||
ERROR_MESSAGE_MAX_LEN,
|
||||
IP_MAX_LEN,
|
||||
METHOD_MAX_LEN,
|
||||
MODULE_MAX_LEN,
|
||||
PATH_MAX_LEN,
|
||||
REQUEST_DATA_MAX_BYTES,
|
||||
SUMMARY_MAX_LEN,
|
||||
USER_AGENT_MAX_LEN,
|
||||
USERNAME_MAX_LEN,
|
||||
)
|
||||
from app.repositories.query_utils import LogFilters, apply_log_filters
|
||||
|
||||
# 键名(不区分大小写)含以下子串的字段,其值一律整体抹除。
|
||||
# 口径与 core.logging.scrub 的键名正则一致,但改为结构化处理 ——
|
||||
# 文本正则有三种逃逸形态:值以空白开头时整体匹配失败(完全不脱敏)、
|
||||
# 值含空格/逗号时只抹首段、值含引号时既残留又破坏 JSON 结构。
|
||||
_SENSITIVE_KEY_PARTS = ("password", "passwd", "pwd", "secret", "token", "app_key", "access_key")
|
||||
|
||||
_MASK = "***"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OperationLogEntry:
|
||||
"""一条待写入的操作日志(全字段 DTO)。
|
||||
|
||||
替代 14 个平铺参数:字段若在 audit() / _safe_write() / add() 三处
|
||||
重复展开,增删字段时任何一侧漏改都只表现为审计数据不完整、不报错
|
||||
(success 默认 0 的语义使漏传尤其隐蔽)。
|
||||
"""
|
||||
|
||||
user_id: int | None
|
||||
username: str
|
||||
module: str
|
||||
action: str
|
||||
summary: str
|
||||
success: bool
|
||||
method: str | None = None
|
||||
path: str | None = None
|
||||
ip: str | None = None
|
||||
user_agent: str | None = None
|
||||
request_data: dict | None = None
|
||||
error_message: str | None = None
|
||||
duration_ms: int | None = None
|
||||
|
||||
|
||||
def add(session: Session, entry: OperationLogEntry) -> None:
|
||||
"""写入一条操作日志。只 flush 不 commit。
|
||||
|
||||
所有文本列按模型定义的长度常量截断 —— 超长在 MySQL 严格模式下抛 1406,
|
||||
而「审计失败不影响业务」的约定会把该异常吞掉,结果是整条日志**静默丢失**,
|
||||
事后极难排查。截断是唯一防线,必须在此层无条件执行。
|
||||
"""
|
||||
session.add(
|
||||
OperationLog(
|
||||
user_id=entry.user_id,
|
||||
username=entry.username[:USERNAME_MAX_LEN],
|
||||
module=entry.module[:MODULE_MAX_LEN],
|
||||
action=entry.action[:ACTION_MAX_LEN],
|
||||
summary=entry.summary[:SUMMARY_MAX_LEN],
|
||||
method=_clip(entry.method, METHOD_MAX_LEN),
|
||||
path=_clip(entry.path, PATH_MAX_LEN),
|
||||
ip=_clip(entry.ip, IP_MAX_LEN),
|
||||
user_agent=_clip(entry.user_agent, USER_AGENT_MAX_LEN),
|
||||
request_data=_serialize_request_data(entry.request_data),
|
||||
success=1 if entry.success else 0,
|
||||
error_message=_clip(entry.error_message, ERROR_MESSAGE_MAX_LEN),
|
||||
duration_ms=entry.duration_ms,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def get_by_id(session: Session, log_id: int) -> OperationLog | None:
|
||||
"""按主键查询单条日志(详情端点用,含 request_data)。不存在返回 None。"""
|
||||
return session.get(OperationLog, log_id)
|
||||
|
||||
|
||||
def list_logs(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
filters: LogFilters,
|
||||
module: str | None = None,
|
||||
action: str | None = None,
|
||||
) -> list[OperationLog]:
|
||||
"""分页查询操作日志。
|
||||
|
||||
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
||||
不确定,翻页会重复或遗漏(与用户列表同一理由)。
|
||||
|
||||
**排除 request_data 列**(TEXT,单行最大 60000 字节):列表页默认不
|
||||
展示它,全列加载会让翻页负担随日志表增长线性放大;详情走 `get_by_id`
|
||||
按需读取。
|
||||
"""
|
||||
stmt = _apply_filters(
|
||||
select(OperationLog).options(defer(OperationLog.request_data)),
|
||||
filters=filters,
|
||||
module=module,
|
||||
action=action,
|
||||
)
|
||||
stmt = stmt.order_by(OperationLog.id.desc()).offset(offset).limit(limit)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_logs(
|
||||
session: Session,
|
||||
*,
|
||||
filters: LogFilters,
|
||||
module: str | None = None,
|
||||
action: str | None = None,
|
||||
) -> int:
|
||||
"""统计符合条件的日志总数,供分页使用。"""
|
||||
stmt = _apply_filters(
|
||||
select(func.count(OperationLog.id)), filters=filters, module=module, action=action
|
||||
)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: Select,
|
||||
*,
|
||||
filters: LogFilters,
|
||||
module: str | None,
|
||||
action: str | None,
|
||||
) -> Select:
|
||||
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
|
||||
stmt = apply_log_filters(stmt, OperationLog, filters)
|
||||
if module:
|
||||
stmt = stmt.where(OperationLog.module == module)
|
||||
if action:
|
||||
stmt = stmt.where(OperationLog.action == action)
|
||||
return stmt
|
||||
|
||||
|
||||
def _clip(text: str | None, limit: int) -> str | None:
|
||||
"""按字符数截断(长度常量与数据库列宽均按字符定义)。"""
|
||||
return text[:limit] if text else None
|
||||
|
||||
|
||||
def _is_sensitive_key(key: object) -> bool:
|
||||
name = str(key).lower()
|
||||
return any(part in name for part in _SENSITIVE_KEY_PARTS)
|
||||
|
||||
|
||||
def _sanitize_sensitive(value: object) -> object:
|
||||
"""递归抹除请求参数中的敏感字段值(dict 按键名整体替换、list 逐项递归)。"""
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _MASK if _is_sensitive_key(key) else _sanitize_sensitive(item)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_sanitize_sensitive(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _serialize_request_data(data: dict | None) -> str | None:
|
||||
"""请求参数 → JSON 文本:两段脱敏 → 字节边界截断。
|
||||
|
||||
脱敏分两层,各司其职:
|
||||
|
||||
- **结构化抹除**(`_sanitize_sensitive`):按键名整体置 ``***``,
|
||||
不依赖文本模式匹配 —— 密码含空格、引号、前导空白等形态都不受影响
|
||||
- `core.logging.scrub` 作文本兜底:覆盖手机号、Bearer 等无法按键名
|
||||
判断的值
|
||||
|
||||
截断必须在 **UTF-8 字节边界** 进行:request_data 列是 TEXT(65535 字节
|
||||
上限),而 request_data 上限 60000 字节也是按字节定的;若按字符切片,
|
||||
中文场景下字符数与字节数相差 3 倍同样可能超限;先编码再按字节切片
|
||||
则可能把多字节字符切成两半产生非法序列。故顺序为:编码 → 字节切片 →
|
||||
宽容解码丢弃残片。encode 用 ``errors="replace"`` 兜底孤立代理字符 ——
|
||||
请求 JSON 里的 ``\\ud800`` 转义会解析为孤立代理,直接 encode 会抛
|
||||
UnicodeEncodeError,被审计兜底逻辑吞掉后该请求的记录**静默丢失**。
|
||||
"""
|
||||
if not data:
|
||||
return None
|
||||
text = scrub(json.dumps(_sanitize_sensitive(data), ensure_ascii=False, default=str))
|
||||
encoded = text.encode("utf-8", errors="replace")
|
||||
if len(encoded) <= REQUEST_DATA_MAX_BYTES:
|
||||
return text
|
||||
return encoded[:REQUEST_DATA_MAX_BYTES].decode("utf-8", errors="ignore")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""repositories 层公共查询工具。
|
||||
|
||||
集中两类易错点,避免各 repository 各写一份、口径漂移:
|
||||
|
||||
- **模糊匹配的 LIKE 通配符转义**:直接 ``f"%{value}%"`` 拼接时,输入中的
|
||||
下划线会作为单字符通配执行(本系统用户名普遍含下划线,误配是常态而非
|
||||
理论),百分号则命中全表并触发全表扫描
|
||||
- **两类日志共有的筛选条件**:用户名/结果/时间范围在操作日志与登录日志
|
||||
之间逐字重复,抽为同一实现后「列表与计数」「操作与登录」不可能对不上
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Select
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LogFilters:
|
||||
"""两类日志(操作/登录)共有的筛选条件。"""
|
||||
|
||||
username: str | None = None
|
||||
success: int | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
|
||||
|
||||
def fuzzy(stmt: Select, column: Any, value: str) -> Select:
|
||||
"""模糊匹配(含通配符自动转义)。
|
||||
|
||||
``contains(autoescape=True)`` 会转义输入中的 ``%``/``_`` 并附加 ``ESCAPE``
|
||||
子句,把用户输入当字面量匹配。
|
||||
"""
|
||||
return stmt.where(column.contains(value, autoescape=True))
|
||||
|
||||
|
||||
def apply_log_filters(stmt: Select, model: Any, filters: LogFilters) -> Select:
|
||||
"""应用两类日志共有的筛选条件。
|
||||
|
||||
``model`` 传 ``OperationLog`` / ``LoginLog`` 的模型类(两者字段同名同型)。
|
||||
|
||||
列表与计数共用本函数,避免「列表按条件过滤而总数不按条件过滤」——
|
||||
漏改一侧时表现为翻到最后一页是空页或页码虚增,且没有任何报错。
|
||||
"""
|
||||
if filters.username:
|
||||
stmt = fuzzy(stmt, model.username, filters.username)
|
||||
if filters.success is not None:
|
||||
stmt = stmt.where(model.success == filters.success)
|
||||
if filters.start_time:
|
||||
stmt = stmt.where(model.create_time >= filters.start_time)
|
||||
if filters.end_time:
|
||||
stmt = stmt.where(model.create_time <= filters.end_time)
|
||||
return stmt
|
||||
@@ -0,0 +1,123 @@
|
||||
"""角色数据访问。
|
||||
|
||||
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||||
|
||||
`get_all_roles` 与 `get_roles_by_codes` 原位于 `user_repository`,
|
||||
因角色管理成为独立模块而迁入本文件。
|
||||
"""
|
||||
|
||||
from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Role, UserRole
|
||||
|
||||
# ==================== 读取 ====================
|
||||
|
||||
|
||||
def get_by_id(session: Session, role_id: int, *, for_update: bool = False) -> Role | None:
|
||||
"""按主键查询角色。不存在返回 None。
|
||||
|
||||
``for_update=True`` 走 ``SELECT ... FOR UPDATE`` 锁定角色行 —— 删除前的
|
||||
「有用户绑定禁删」校验必须用它:校验与 DELETE 之间若发生并发绑定,
|
||||
`user_roles` 的 CASCADE 会把新绑定一并静默剥离。锁行后,并发绑定的
|
||||
外键检查会等待,删除得以基于最新状态做出(对方失败而非数据被吞)。
|
||||
"""
|
||||
if for_update:
|
||||
stmt = select(Role).where(Role.id == role_id).with_for_update()
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
return session.get(Role, role_id)
|
||||
|
||||
|
||||
def get_by_code(session: Session, role_code: str) -> Role | None:
|
||||
"""按编码查询角色。不存在返回 None。"""
|
||||
return session.execute(select(Role).where(Role.role_code == role_code)).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_roles_by_codes(session: Session, role_codes: list[str]) -> list[Role]:
|
||||
"""按编码批量取角色。用于校验前端传来的角色是否存在。"""
|
||||
if not role_codes:
|
||||
return []
|
||||
stmt = select(Role).where(Role.role_code.in_(role_codes))
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def get_all_roles(session: Session) -> list[Role]:
|
||||
"""取全部启用角色,供前端下拉选择。"""
|
||||
stmt = select(Role).where(Role.status == 1).order_by(Role.sort_order, Role.id)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def list_roles(
|
||||
session: Session,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
keyword: str | None = None,
|
||||
status: int | None = None,
|
||||
) -> list[Role]:
|
||||
"""分页查询角色(管理列表:含停用角色)。
|
||||
|
||||
排序与下拉口径一致(sort_order, id),保持同一套相对次序。
|
||||
"""
|
||||
stmt = _apply_filters(select(Role), keyword=keyword, status=status)
|
||||
stmt = stmt.order_by(Role.sort_order, Role.id).offset(offset).limit(limit)
|
||||
return list(session.execute(stmt).scalars())
|
||||
|
||||
|
||||
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
|
||||
"""统计符合条件的角色总数,供分页使用。"""
|
||||
stmt = _apply_filters(select(func.count(Role.id)), keyword=keyword, status=status)
|
||||
return int(session.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||
|
||||
关键词匹配走 `contains(autoescape=True)`(转义 LIKE 通配符)——
|
||||
角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。
|
||||
"""
|
||||
if keyword:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Role.role_code.contains(keyword, autoescape=True),
|
||||
Role.role_name.contains(keyword, autoescape=True),
|
||||
)
|
||||
)
|
||||
if status is not None:
|
||||
stmt = stmt.where(Role.status == status)
|
||||
return stmt
|
||||
|
||||
|
||||
def count_users_by_role(session: Session, role_ids: list[int]) -> dict[int, int]:
|
||||
"""批量统计各角色关联的用户数。
|
||||
|
||||
一次 GROUP BY 取回全部计数,而非在列表循环里逐行查询(N+1)。
|
||||
"""
|
||||
if not role_ids:
|
||||
return {}
|
||||
stmt = (
|
||||
select(UserRole.role_id, func.count())
|
||||
.where(UserRole.role_id.in_(role_ids))
|
||||
.group_by(UserRole.role_id)
|
||||
)
|
||||
return {int(role_id): int(count) for role_id, count in session.execute(stmt)}
|
||||
|
||||
|
||||
# ==================== 写入 ====================
|
||||
|
||||
|
||||
def create(session: Session, role: Role) -> Role:
|
||||
"""新增角色。只 flush 以取得自增 id。"""
|
||||
session.add(role)
|
||||
session.flush()
|
||||
return role
|
||||
|
||||
|
||||
def delete(session: Session, role: Role) -> None:
|
||||
"""删除角色。
|
||||
|
||||
调用方(service)必须先校验该角色无用户绑定 —— `user_roles` 外键是
|
||||
CASCADE,直接删会静默剥离绑定用户的权限。
|
||||
"""
|
||||
session.delete(role)
|
||||
session.flush()
|
||||
@@ -1,4 +1,4 @@
|
||||
"""用户与角色数据访问。
|
||||
"""用户数据访问(角色相关的读取与维护见 `role_repository`)。
|
||||
|
||||
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||||
"""
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.models import Role, User, UserRole
|
||||
from app.repositories.query_utils import fuzzy
|
||||
|
||||
|
||||
def _base_query() -> Select[tuple[User]]:
|
||||
@@ -95,11 +96,15 @@ def _apply_filters(
|
||||
real_name: str | None,
|
||||
status: int | None,
|
||||
) -> Select:
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
|
||||
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||
|
||||
模糊匹配统一走 `query_utils.fuzzy`(转义 LIKE 通配符)—— 用户名普遍
|
||||
含下划线,直接拼接会让 `_` 当单字符通配执行、误配无关账号。
|
||||
"""
|
||||
if username:
|
||||
stmt = stmt.where(User.username.like(f"%{username}%"))
|
||||
stmt = fuzzy(stmt, User.username, username)
|
||||
if real_name:
|
||||
stmt = stmt.where(User.real_name.like(f"%{real_name}%"))
|
||||
stmt = fuzzy(stmt, User.real_name, real_name)
|
||||
if status is not None:
|
||||
stmt = stmt.where(User.status == status)
|
||||
return stmt
|
||||
@@ -127,15 +132,7 @@ def delete(session: Session, user: User) -> None:
|
||||
session.flush()
|
||||
|
||||
|
||||
# ==================== 角色 ====================
|
||||
|
||||
|
||||
def get_roles_by_codes(session: Session, role_codes: list[str]) -> list[Role]:
|
||||
"""按编码批量取角色。用于校验前端传来的角色是否存在。"""
|
||||
if not role_codes:
|
||||
return []
|
||||
stmt = select(Role).where(Role.role_code.in_(role_codes))
|
||||
return list(session.execute(stmt).scalars())
|
||||
# ==================== 角色关联 ====================
|
||||
|
||||
|
||||
def replace_roles(session: Session, user: User, roles: list[Role]) -> None:
|
||||
@@ -144,18 +141,18 @@ def replace_roles(session: Session, user: User, roles: list[Role]) -> None:
|
||||
采用「先清后建」而非逐条比对:角色数量极少(本期仅两个),
|
||||
差异计算的复杂度换不来收益,且整体替换的语义更直白。
|
||||
|
||||
**clear 之后必须立刻 flush 一次**:SQLAlchemy 在同一事务的 flush 中
|
||||
**先处理 INSERT 再处理 DELETE**,若中间不落库,重插与旧集合有交集的
|
||||
(user_id, role_id)(编辑用户时保留既有角色就会发生)会撞
|
||||
`uk_user_roles_user_id_role_id` 唯一键,报 1062。
|
||||
|
||||
**必须经 `user.user_roles` 集合追加,不能用 `session.add(UserRole(...))`**:
|
||||
后者只把对象放进 session,不会同步到关系的已加载集合,于是同一事务内
|
||||
再读 `user.user_roles` 仍是空的 —— 表现为「保存成功,但接口返回的角色
|
||||
列表是 []」。经集合操作时 `user_id` 由关系自动填充,集合也立即反映新状态。
|
||||
"""
|
||||
user.user_roles.clear()
|
||||
session.flush()
|
||||
for role in roles:
|
||||
user.user_roles.append(UserRole(role_id=role.id))
|
||||
session.flush()
|
||||
|
||||
|
||||
def get_all_roles(session: Session) -> list[Role]:
|
||||
"""取全部启用角色,供前端下拉选择。"""
|
||||
stmt = select(Role).where(Role.status == 1).order_by(Role.sort_order, Role.id)
|
||||
return list(session.execute(stmt).scalars())
|
||||
Reference in new issue
Block a user