Author SHA1 Message Date
liuwenxinandClaude Code c7212cd93f chore: 修正 gitignore 并同步项目文档
- .gitignore:logs/ 收窄为 /backend/logs/ —— 原规则(无前导斜杠)匹配任意
  层级同名目录,曾把 frontend/src/views/system/logs 整个吞出版本库
- 同步根/frontend README(进度、e2e 运行方式与环境变量)
- 收录业务确认问题清单(完整版)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-10 16:31:45 +08:00
liuwenxinandClaude Code 8355e0cc7d feat(system): 完成前端系统管理(布局框架、三个管理页面、e2e)
- BasicLayout:侧边栏/顶栏/面包屑/移动端抽屉;菜单从路由表派生;
  路由守卫新增 requiresAdmin 层(菜单隐藏 + 直连 URL 弹回)
- 用户/角色/日志三个页面:分页请求序号防竞态、确认动作流抽 useConfirmAction、
  时间范围配 default-time、日志详情按需懒加载并缓存
- 空 catch 统一改 handledError 显式声明;词表/选项常量集中(与后端对齐),
  「停用」文案全站统一为「禁用」
- 品牌色提升为全局 token,Element Plus 主色指向赭金
- e2e:公共工具抽 common.py;系统管理 13 项(含详情懒加载脱敏断言),
  管理员密码改 E2E_ADMIN_PASSWORD 环境变量;login_flow 8 项回归通过

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-10 16:28:53 +08:00
liuwenxinandClaude Code c83287819a 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>
2026-10-10 16:28:04 +08:00
liuwenxinandClaude Code e22c4bcdc9 feat(system): 实现用户管理接口
需求 2.5.1 用户账号管理,全部要求管理员权限(require_admin 依赖):

- 接口:列表/详情/新增/编辑/删除/重置密码/启停/角色下拉,
  角色下拉放 /users/meta/roles 以避开 /{user_id} 的路由匹配冲突
- 删除、禁用、改角色三处自我保护:不允许对当前登录账号执行,
  避免管理员误操作后立刻失去全部管理权限且无法自行恢复
- 重置密码与禁用均提升 token_version,使已签发令牌立即失效,
  不依赖 Redis 黑名单
- 密码按字节数校验(bcrypt 5.0 硬上限 72 字节,中文一字 3 字节)
- 用户列表按 id 倒序:create_time 为秒级精度,同秒数据分页结果不确定
- 修复 replace_roles 未同步关系集合的缺陷:改用 user.user_roles
  集合追加,此前 session.add(UserRole(...)) 会表现为保存成功但返回空角色
- update_user 的自我保护原先依赖返回 None 的占位函数,恒不生效,
  改为显式传入 operator_id

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-10 10:44:13 +08:00
liuwenxinandClaude Code ad5fe4a411 feat(system): 新增操作日志表与 V2 迁移
需求 2.5.1 要求记录用户管理操作,先落表结构:

- operation_logs 表:冗余 username 保证账号删除后仍可追溯,
  user_id 外键 ON DELETE SET NULL,success 默认 '0' 与 login_logs 口径一致
- 截断常量(USERNAME/ERROR_MESSAGE/REQUEST_DATA)集中定义,供写入方引用
- 修复既有 V1 迁移与迁移模板的 downgrade:索引是外键唯一支撑索引,
  先 drop_index 会报 errno 1553,改为只 drop_table
- 同步 db/migration/V2 与 init-dev.sql,迁移版本号更新为 a45a8087831b

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-10 10:44:05 +08:00
68 changed files with 6821 additions and 209 deletions

No files matched your search

+6 -1
View File
@@ -26,7 +26,10 @@ out/
# 日志
*.log
logs/
# 后端运行日志目录(core/logging.py 创建)。
# 不要写成裸 `logs/` —— 那会匹配任意层级的同名目录,曾把
# frontend/src/views/system/logs 整个吞掉(文件进不了版本库)
/backend/logs/
# IDE
.idea/
@@ -54,3 +57,5 @@ frontend/e2e/*.png
# 备份
backup/
.claude
CLAUDE.md
+3 -2
View File
@@ -2,7 +2,7 @@
营销人员绩效工资核算系统。后端 Python/FastAPI,前端 Vue 3,数据存 MySQL,会话与缓存在 Redis。
> 当前进度:登录鉴权链路已跑通(验证码 / JWT / 限流 / 登出黑名单 / 登录日志)。业务模块(基础数据、业务数据、绩效核算、报表)待需求澄清后开发。
> 当前进度:登录鉴权与系统管理(用户/角色/操作日志/日志查询)已完成,前端具备应用布局框架。业务模块(基础数据、业务数据、绩效核算、报表)待需求澄清后开发。
## 快速开始
@@ -81,7 +81,8 @@ pnpm type-check # 类型检查
pnpm build # 类型检查 + 生产构建
# 浏览器端到端(需容器、后端、前端都在运行)
python e2e/login_flow.py
python e2e/login_flow.py # 登录链路 8 项
python e2e/system_management.py # 系统管理 13 项
```
## 目录结构
+36 -1
View File
@@ -1,11 +1,12 @@
# 后端
> AI摘要:衍水集团营销人员工资管理系统的后端服务。FastAPI + SQLAlchemy(同步)+ MySQL + Redis,Python 3.12 由 uv 锁定。六层结构 `api / services / repositories / models / schemas / core`。当前实现登录鉴权与健康检查,业务模块待需求澄清后开发。
> AI摘要:衍水集团营销人员工资管理系统的后端服务。FastAPI + SQLAlchemy(同步)+ MySQL + Redis,Python 3.12 由 uv 锁定。六层结构 `api / services / repositories / models / schemas / core`。当前实现登录鉴权与系统管理(用户/角色/操作日志/日志查询),业务模块待需求澄清后开发。
## 职责
- 提供 REST 接口(统一前缀 `/api`)
- 登录鉴权:图形验证码、JWT、登录限流、登出黑名单、登录日志
- 系统管理(需求 2.5.1):用户管理、角色管理、操作日志(自动审计)、操作与登录日志查询
- 后续承担:基础数据、业务数据、绩效核算、报表统计、T+ 数据同步
## 技术栈与版本
@@ -53,6 +54,24 @@ app/
| POST | `/api/v1/auth/logout` | 是 | 令牌入黑名单,立即失效 |
| GET | `/api/v1/auth/me` | 是 | 当前用户与角色 |
### 系统管理接口(需求 2.5.1,全部要求管理员权限)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET / POST | `/api/v1/system/users` | 用户列表(分页/筛选)/ 新增用户 |
| GET / PUT / DELETE | `/api/v1/system/users/{id}` | 用户详情 / 编辑(含角色分配)/ 删除(物理删除) |
| PUT | `/api/v1/system/users/{id}/password` | 重置密码(提升令牌版本,旧令牌立即失效) |
| PUT | `/api/v1/system/users/{id}/status` | 启用 / 禁用(禁用同样提升令牌版本) |
| GET | `/api/v1/system/users/meta/roles` | 角色下拉选项(仅启用角色) |
| GET / POST | `/api/v1/system/roles` | 角色列表(含停用与绑定用户数)/ 新增角色 |
| PUT / DELETE | `/api/v1/system/roles/{id}` | 编辑(不含编码)/ 删除(内置与有绑定的角色受保护) |
| PUT | `/api/v1/system/roles/{id}/status` | 启用 / 禁用(内置 admin 禁停) |
| GET | `/api/v1/system/operation-logs` | 操作日志查询(操作人/模块/操作/结果/时间范围;**不含 request_data** 大字段) |
| GET | `/api/v1/system/operation-logs/{id}` | 操作日志详情(含已脱敏的请求参数,展开行懒加载) |
| GET | `/api/v1/system/login-logs` | 登录日志查询(用户名/IP/结果/时间范围) |
全部写操作自动写入操作日志(成功与失败都记,机制见下)。
统一响应 `{code, message, data}`,分页为 `list/total/pageNum/pageSize`。
状态码:400 参数错误 / 401 未登录 / 403 无权限 / 404 不存在 / 500 服务端异常。
@@ -118,6 +137,22 @@ Redis 黑名单只负责「用户主动登出」。
bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是静默截断)。
中文一个字符占 3 字节,24 个汉字即到顶——`max_length=20` 这类字符数校验拦不住。
### 操作日志:审计上下文 + 独立提交
写操作统一经 `services/audit_service.py` 的 `audit()` 上下文管理器留痕(用法见 `api/v1/system/users.py`):
- 调用形态为 DTO:`audit(session, operator=..., record=AuditRecord(module, action, summary, request_data), meta=...)`;落库条目为 `OperationLogEntry`
- 正常退出记成功;抛异常**先 `session.rollback()` 再**记失败(success=0 + 中文错误原因),随后原异常继续传播
- 日志**独立 commit**,整体兜底不外抛 —— 方向是「审计可丢,业务不可断」;反向理解:业务失败回滚不会带走失败记录,而那正是审计最需要的
- **操作人标量在进入上下文时固化**:失败路径先 rollback 会使 ORM 对象过期,之后再读 id/username 会触发额外查询(账号并发被删时 ObjectDeletedError 还会被兜底吞掉)
- 请求元信息(ip / user-agent / method / path)经路由层的 `RequestMetaDep` 传入 service
- **commit 之后的任何查询/ORM 读取都要前置**(提到 commit 前或用已固化的局部变量):业务已提交却因后置查询抛错会被记成「失败」,产生与事实相反的审计
- 文本列按 `models/operation_log.py` 的长度常量截断;`request_data` 先经**结构化脱敏**(递归按键名整值抹除 password/token 等,不依赖文本模式匹配)再经 `core.logging.scrub` 文本兜底,最后按 **UTF-8 字节边界**截断(encode 带 `errors="replace"` 兜底孤立代理字符)—— 超长/编码异常都会在 MySQL 或 encode 处抛出,被兜底吞掉后表现为「日志莫名消失」
- **日志列表与详情两段式**:`operation-logs` 列表排除 `request_data`(TEXT 大字段,随列表返回会让翻页负担随表增长),展开详情走 `GET /operation-logs/{id}` 懒加载
- 模块/操作词表在 `core/constants.py`(`MODULE_*` / `ACTION_*`),前端 `utils/constants.ts` 与其对齐
**角色管理保护规则**(后端是唯一防线,前端按钮禁用只是体验):admin 角色禁删禁停;有用户绑定的角色禁删(`user_roles` 外键是 CASCADE;删除前走 `SELECT ... FOR UPDATE` 锁行,堵住并发绑定窗口);`role_code` 创建后不可改;**停用角色不可再分配**(`_resolve_roles` 校验 status,停用只影响新分配、不影响既有授权)。
## 常见问题
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
+12 -6
View File
@@ -5,7 +5,7 @@ Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
@@ -13,16 +13,22 @@ ${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
down_revision: str | Sequence[str] | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
"""应用迁移。"""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
"""回滚迁移。
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
"""
${downgrades if downgrades else "pass"}
@@ -337,17 +337,19 @@ def upgrade() -> None:
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("idx_user_roles_role_id", table_name="user_roles")
"""回滚迁移。
只保留 drop_table —— DROP TABLE 会连带删除索引与外键。
原 autogenerate 输出里逐个 drop_index 会失败:idx_user_roles_role_id 与
idx_role_permissions_permission_id 分别是外键的唯一支撑索引,
MySQL 拒绝单独删除(errno 1553),回滚会中断在半途。
删除顺序遵循外键依赖:先子表(user_roles / role_permissions / login_logs),
再父表(users / roles / permissions)。
"""
op.drop_table("user_roles")
op.drop_index("idx_role_permissions_permission_id", table_name="role_permissions")
op.drop_table("role_permissions")
op.drop_index("idx_login_logs_username", table_name="login_logs")
op.drop_index("idx_login_logs_create_time", table_name="login_logs")
op.drop_table("login_logs")
op.drop_index(op.f("idx_users_phone"), table_name="users")
op.drop_table("users")
op.drop_table("roles")
op.drop_table("permissions")
# ### end Alembic commands ###
@@ -0,0 +1,109 @@
"""新增操作日志表
Revision ID: a45a8087831b
Revises: 6165691326d4
Create Date: 2026-10-10 10:06:17.435697
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "a45a8087831b"
down_revision: str | Sequence[str] | None = "6165691326d4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""应用迁移。"""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"operation_logs",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"user_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="操作人ID(账号删除后置空)",
),
sa.Column(
"username",
sa.String(length=64),
nullable=False,
comment="操作人用户名(冗余存储便于追溯)",
),
sa.Column("module", sa.String(length=64), nullable=False, comment="所属模块,如 用户管理"),
sa.Column(
"action", sa.String(length=32), nullable=False, comment="操作类型:新增/修改/删除/导出"
),
sa.Column(
"summary", sa.String(length=255), nullable=False, comment="操作摘要(中文,供人阅读)"
),
sa.Column("method", sa.String(length=8), nullable=True, comment="HTTP 方法"),
sa.Column("path", sa.String(length=255), nullable=True, comment="请求路径"),
sa.Column("ip", sa.String(length=45), nullable=True, comment="客户端IP(45位兼容IPv6)"),
sa.Column(
"user_agent",
sa.String(length=255),
nullable=True,
comment="客户端User-Agent(截断存储)",
),
sa.Column("request_data", sa.Text(), nullable=True, comment="请求参数(已脱敏)"),
sa.Column(
"success",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="是否成功:1成功 0失败",
),
sa.Column(
"error_message",
sa.String(length=500),
nullable=True,
comment="失败原因(写入方须截断)",
),
sa.Column("duration_ms", sa.Integer(), nullable=True, comment="处理耗时(毫秒)"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["user_id"], ["users.id"], name=op.f("fk_operation_logs_user_id"), ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_operation_logs")),
comment="操作日志表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
"idx_operation_logs_create_time", "operation_logs", ["create_time"], unique=False
)
op.create_index("idx_operation_logs_module", "operation_logs", ["module"], unique=False)
op.create_index("idx_operation_logs_user_id", "operation_logs", ["user_id"], unique=False)
op.create_index("idx_operation_logs_username", "operation_logs", ["username"], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""回滚迁移。
**注意(autogenerate 不会替你处理)**:生成的 `op.drop_index` 与
`op.drop_table` 顺序需人工检查 —— 若某索引是外键的唯一支撑索引,
单独 drop_index 会报 MySQL 1553(needed in a foreign key constraint)。
**整表回滚应只保留 `op.drop_table`**,DROP TABLE 会连带删除索引与外键。
"""
# 整表回滚只保留 drop_table —— DROP TABLE 会连带删除索引与外键。
# 逐个 drop_index 会失败:idx_operation_logs_user_id 是外键
# fk_operation_logs_user_id 的唯一支撑索引,MySQL 拒绝删除(errno 1553)。
op.drop_table("operation_logs")
+44 -2
View File
@@ -2,16 +2,18 @@
from typing import Annotated
from fastapi import Depends
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.core.constants import ROLE_ADMIN
from app.core.db import get_db
from app.core.exceptions import AuthError
from app.core.exceptions import AuthError, ForbiddenError
from app.core.security import decode_token
from app.models import User
from app.repositories import user_repository
from app.services import token_blacklist
from app.services.audit_service import RequestMeta
# auto_error=False:令牌缺失时由我们自己抛 AuthError,
# 才能返回统一响应信封,而不是 FastAPI 默认的 {"detail": "Not authenticated"}
@@ -21,6 +23,29 @@ BearerCredentials = Annotated[HTTPAuthorizationCredentials | None, Depends(_bear
DbSession = Annotated[Session, Depends(get_db)]
def client_ip(request: Request) -> str | None:
"""取客户端 IP。
当前取直连地址。生产环境经 Nginx 反代后直连地址会变成代理地址,
届时需改为读取 `X-Forwarded-For` 的**第一个**值 —— 且必须确保该头
由网关覆写,否则客户端可以伪造。此改动留到部署阶段一并处理。
"""
return request.client.host if request.client else None
def get_request_meta(request: Request) -> RequestMeta:
"""提取 HTTP 请求元信息,供操作日志审计使用。"""
return RequestMeta(
ip=client_ip(request),
user_agent=request.headers.get("user-agent"),
method=request.method,
path=request.url.path,
)
RequestMetaDep = Annotated[RequestMeta, Depends(get_request_meta)]
def get_raw_token(credentials: BearerCredentials) -> str:
"""取原始令牌字符串。登出时需要它来解析 jti。"""
if credentials is None or not credentials.credentials:
@@ -71,3 +96,20 @@ def _is_digits(value: object) -> bool:
令牌内容不可信,直接 int() 会因非法输入抛 ValueError 而不是 401。
"""
return isinstance(value, str) and value.isdigit()
def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> User:
"""要求当前用户具备管理员角色。
需求 2.5.1 只定义了「系统管理员(全部权限)」与「普通用户(业务操作)」两个
角色,故权限判定用**角色粗粒度**即可,不必引入权限码表 —— `permissions`
与 `role_permissions` 表已建好,等业务方给出权限清单后再启用。
:raises ForbiddenError: 已登录但不具备管理员角色(403)
"""
if ROLE_ADMIN not in user_repository.get_role_codes(current_user):
raise ForbiddenError("需要管理员权限")
return current_user
AdminUser = Annotated[User, Depends(require_admin)]
+12
View File
@@ -8,6 +8,9 @@ from fastapi import APIRouter
from app.api import health
from app.api.v1 import auth
from app.api.v1.system import logs as system_logs
from app.api.v1.system import roles as system_roles
from app.api.v1.system import users as system_users
api_router = APIRouter()
@@ -17,4 +20,13 @@ api_router.include_router(health.router)
# 业务接口:/api/v1/*
v1_router = APIRouter(prefix="/v1")
v1_router.include_router(auth.router)
# 系统管理(需求 2.5.1):/api/v1/system/*
system_router = APIRouter(prefix="/system")
system_router.include_router(system_users.router)
system_router.include_router(system_roles.router)
system_router.include_router(system_logs.operation_router)
system_router.include_router(system_logs.login_router)
v1_router.include_router(system_router)
api_router.include_router(v1_router)
+2 -12
View File
@@ -4,7 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Request
from app.api.deps import DbSession, get_current_user, get_raw_token
from app.api.deps import DbSession, client_ip, get_current_user, get_raw_token
from app.core.response import ApiResponse, ok
from app.models import User
from app.repositories.user_repository import get_role_codes
@@ -14,16 +14,6 @@ from app.services import auth_service, captcha_service
router = APIRouter(prefix="/auth", tags=["认证"])
def _client_ip(request: Request) -> str | None:
"""取客户端 IP。
当前取直连地址。生产环境经 Nginx 反代后直连地址会变成代理地址,
届时需改为读取 `X-Forwarded-For` 的**第一个**值 —— 且必须确保该头
由网关覆写,否则客户端可以伪造。此改动留到部署阶段一并处理。
"""
return request.client.host if request.client else None
@router.get("/captcha", response_model=ApiResponse[CaptchaOut], summary="获取图形验证码")
def get_captcha() -> ApiResponse[CaptchaOut]:
"""签发图形验证码。无需认证。
@@ -52,7 +42,7 @@ def login(payload: LoginIn, request: Request, session: DbSession) -> ApiResponse
password=payload.password,
captcha_id=payload.captcha_id,
captcha_code=payload.captcha_code,
ip=_client_ip(request),
ip=client_ip(request),
user_agent=request.headers.get("user-agent"),
)
return ok(result, message="登录成功")
+7
View File
@@ -0,0 +1,7 @@
"""系统管理路由(需求 2.5.1)。
含用户管理、角色管理、日志查询(操作日志 + 登录日志)四部分,全部要求管理员权限。
与 `api/v1/auth.py` 的分工:`auth` 是登录链路(未认证即可访问的验证码与登录,
以及任何登录用户都能访问的 `/me` 与登出);本包是**管理功能**。
"""
+67
View File
@@ -0,0 +1,67 @@
"""日志查询接口(需求 2.5.1:支持日志查询)。
操作日志与登录日志是两个独立端点:两表字段与筛选项不同(前者含模块/操作/
请求详情,后者含失败原因),合并会让 `action` 等参数产生二义;前端正好是
两个 tab。操作日志另有单条详情(列表为性能排除 `request_data`)。
均要求管理员权限;只读查询不写审计(读操作记日志只会淹没审计表)。
"""
from typing import Annotated
from fastapi import APIRouter, Path, Query
from app.api.deps import AdminUser, DbSession
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.log import (
LoginLogOut,
LoginLogQuery,
OperationLogDetailOut,
OperationLogOut,
OperationLogQuery,
)
from app.services import log_service
operation_router = APIRouter(prefix="/operation-logs", tags=["系统管理 · 日志"])
login_router = APIRouter(prefix="/login-logs", tags=["系统管理 · 日志"])
@operation_router.get(
"", response_model=ApiResponse[PageResult[OperationLogOut]], summary="操作日志"
)
def list_operation_logs(
session: DbSession,
_: AdminUser,
query: Annotated[OperationLogQuery, Query()],
) -> ApiResponse[PageResult[OperationLogOut]]:
"""分页查询操作日志,支持按操作人/模块/操作类型/结果/时间范围筛选。
按 id 倒序(时间倒序的稳定实现)。列表**不含** `request_data`(大字段),
展开详情请调 `GET /operation-logs/{log_id}`。
"""
return ok(log_service.list_operation_logs(session, query))
@operation_router.get(
"/{log_id}", response_model=ApiResponse[OperationLogDetailOut], summary="操作日志详情"
)
def get_operation_log(
session: DbSession,
_: AdminUser,
log_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[OperationLogDetailOut]:
"""查询单条操作日志详情(含已脱敏的请求参数)。
前端展开日志行时按 id 按需加载 —— 避免列表翻页携带大字段。
"""
return ok(log_service.get_operation_log(session, log_id))
@login_router.get("", response_model=ApiResponse[PageResult[LoginLogOut]], summary="登录日志")
def list_login_logs(
session: DbSession,
_: AdminUser,
query: Annotated[LoginLogQuery, Query()],
) -> ApiResponse[PageResult[LoginLogOut]]:
"""分页查询登录日志,支持按用户名/IP/结果/时间范围筛选。"""
return ok(log_service.list_login_logs(session, query))
+100
View File
@@ -0,0 +1,100 @@
"""角色管理接口(自主完善模块:需求 2.5.1 仅定义 admin / user 两个角色)。
保护规则(admin 禁删禁停、有绑定的角色禁删、role_code 不可改)见
`role_service` 模块 docstring。前端会禁用相应按钮,但**后端校验是唯一防线**。
全部要求管理员权限;写操作经 `RequestMetaDep` 带入请求元信息由 service
层写入操作日志。
"""
from typing import Annotated
from fastapi import APIRouter, Path, Query
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.role import RoleCreateIn, RoleManageOut, RoleQuery, RoleStatusIn, RoleUpdateIn
from app.services import role_service
router = APIRouter(prefix="/roles", tags=["系统管理 · 角色"])
@router.get("", response_model=ApiResponse[PageResult[RoleManageOut]], summary="角色列表")
def list_roles(
session: DbSession,
_: AdminUser,
query: Annotated[RoleQuery, Query()],
) -> ApiResponse[PageResult[RoleManageOut]]:
"""分页查询角色。
与用户表单的角色下拉口径不同:**本列表含停用角色**(下拉只给启用项)。
每行附带 `user_count`(绑定用户数),供前端提示删除保护。
"""
return ok(role_service.list_roles(session, query))
@router.post("", response_model=ApiResponse[RoleManageOut], summary="新增角色")
def create_role(
session: DbSession,
current_user: AdminUser,
payload: RoleCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[RoleManageOut]:
"""新增角色。编码全局唯一、创建后不可修改;新角色默认启用。"""
return ok(
role_service.create_role(session, payload, operator=current_user, meta=meta),
message="角色已创建",
)
@router.put("/{role_id}", response_model=ApiResponse[RoleManageOut], summary="编辑角色")
def update_role(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
payload: RoleUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[RoleManageOut]:
"""编辑角色名称/说明/排序。
**不含 role_code** —— 编码是授权判定(`ROLE_ADMIN` 常量)的锚点。
"""
return ok(
role_service.update_role(session, role_id, payload, operator=current_user, meta=meta),
message="已保存",
)
@router.put("/{role_id}/status", response_model=ApiResponse[RoleManageOut], summary="启用/禁用角色")
def set_status(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
payload: RoleStatusIn,
meta: RequestMetaDep,
) -> ApiResponse[RoleManageOut]:
"""启用或禁用角色。
停用**不解除**用户绑定、也不影响既有授权(授权判定只看编码、不看状态);
内置管理员角色不可停用。
"""
result = role_service.set_status(
session, role_id, payload.status, operator=current_user, meta=meta
)
return ok(result, message="已启用" if payload.status == 1 else "已禁用")
@router.delete("/{role_id}", response_model=ApiResponse[None], summary="删除角色")
def delete_role(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除角色。
内置管理员角色不可删除;仍有关联用户的角色不可删除(提示先解除绑定)——
`user_roles` 外键是 CASCADE,后端拦截是防止静默剥离权限的唯一防线。
"""
role_service.delete_role(session, role_id, operator=current_user, meta=meta)
return ok(message="角色已删除")
+146
View File
@@ -0,0 +1,146 @@
"""用户管理接口(需求 2.5.1:用户账号管理)。
全部要求管理员权限 —— 按需求「系统管理员(全部权限)、普通用户(业务操作)」,
用户管理属管理员专属。
删除、禁用、改角色三处都有**自我保护**:不允许对当前登录账号执行,
否则管理员误操作会立刻失去全部管理权限且无法自行恢复。
全部写操作经 `RequestMetaDep` 带入请求元信息后由 service 层写入操作日志
(成功与失败都记),操作人为当前登录管理员。
"""
from typing import Annotated
from fastapi import APIRouter, Path, Query
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.role import RoleOut
from app.schemas.user import (
PasswordResetIn,
UserCreateIn,
UserOut,
UserQuery,
UserStatusIn,
UserUpdateIn,
)
from app.services import user_service
router = APIRouter(prefix="/users", tags=["系统管理 · 用户"])
@router.get("", response_model=ApiResponse[PageResult[UserOut]], summary="用户列表")
def list_users(
session: DbSession,
_: AdminUser,
query: Annotated[UserQuery, Query()],
) -> ApiResponse[PageResult[UserOut]]:
"""分页查询用户,支持按用户名/姓名模糊、状态与角色筛选。"""
return ok(user_service.list_users(session, query))
@router.get("/{user_id}", response_model=ApiResponse[UserOut], summary="用户详情")
def get_user(
session: DbSession,
_: AdminUser,
user_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[UserOut]:
"""查询用户详情。"""
return ok(user_service.get_user(session, user_id))
@router.post("", response_model=ApiResponse[UserOut], summary="新增用户")
def create_user(
session: DbSession,
current_user: AdminUser,
payload: UserCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[UserOut]:
"""新增用户。用户名须唯一,至少指定一个角色。"""
return ok(
user_service.create_user(session, payload, operator=current_user, meta=meta),
message="用户已创建",
)
@router.put("/{user_id}", response_model=ApiResponse[UserOut], summary="编辑用户")
def update_user(
session: DbSession,
current_user: AdminUser,
user_id: Annotated[int, Path(ge=1)],
payload: UserUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[UserOut]:
"""编辑用户的基本信息与角色。
**不含用户名与密码**:用户名是审计追溯的锚点;密码走专门的重置接口。
"""
return ok(
user_service.update_user(session, user_id, payload, operator=current_user, meta=meta),
message="已保存",
)
@router.delete("/{user_id}", response_model=ApiResponse[None], summary="删除用户")
def delete_user(
session: DbSession,
current_user: AdminUser,
user_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除用户。
物理删除。历史操作日志通过 `user_id` 置空 + 冗余用户名保留可追溯性。
业务上更推荐用「禁用」代替删除 —— 禁用保留完整审计线索。
"""
user_service.delete_user(session, user_id, operator=current_user, meta=meta)
return ok(message="用户已删除")
@router.put("/{user_id}/password", response_model=ApiResponse[None], summary="重置密码")
def reset_password(
session: DbSession,
current_user: AdminUser,
user_id: Annotated[int, Path(ge=1)],
payload: PasswordResetIn,
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""重置用户密码。
会同时提升该用户的令牌版本,使其**已签发的令牌立即失效** ——
否则改密后旧令牌在有效期内仍可用。
"""
user_service.reset_password(
session, user_id, payload.new_password, operator=current_user, meta=meta
)
return ok(message="密码已重置,该用户需重新登录")
@router.put("/{user_id}/status", response_model=ApiResponse[UserOut], summary="启用/禁用用户")
def set_status(
session: DbSession,
current_user: AdminUser,
user_id: Annotated[int, Path(ge=1)],
payload: UserStatusIn,
meta: RequestMetaDep,
) -> ApiResponse[UserOut]:
"""启用或禁用用户。
禁用会提升令牌版本使已签发令牌立即失效,否则被禁用者仍可凭旧令牌
继续访问直到过期(最长 2 小时)。
"""
result = user_service.set_status(
session, user_id, payload.status, operator=current_user, meta=meta
)
return ok(result, message="已启用" if payload.status == 1 else "已禁用")
@router.get("/meta/roles", response_model=ApiResponse[list[RoleOut]], summary="角色下拉选项")
def list_roles(session: DbSession, _: AdminUser) -> ApiResponse[list[RoleOut]]:
"""取全部启用角色,供用户表单的角色选择使用。
路径放在 `/users/meta/roles` 而非 `/roles`,是为了避免与
`/{user_id}` 的路由匹配冲突(`roles` 会被当成 user_id 解析而报 422)。
"""
return ok(user_service.list_roles(session))
+12
View File
@@ -36,3 +36,15 @@ PASSWORD_MAX_BYTES = 72
# 剔除易混淆字符 0/O/1/I/l,降低人工识别错误率
CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
CAPTCHA_LENGTH = 4
# ===== 操作日志词表(operation_logs 的 module / action 取值)=====
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致
MODULE_USER = "用户管理"
MODULE_ROLE = "角色管理"
ACTION_CREATE = "新增"
ACTION_UPDATE = "修改"
ACTION_DELETE = "删除"
ACTION_RESET_PASSWORD = "重置密码"
ACTION_ENABLE = "启用"
ACTION_DISABLE = "禁用"
+2
View File
@@ -11,12 +11,14 @@
from app.models.base import Base, TimestampMixin
from app.models.login_log import LoginLog
from app.models.operation_log import OperationLog
from app.models.role import Permission, Role, RolePermission
from app.models.user import User, UserRole
__all__ = [
"Base",
"LoginLog",
"OperationLog",
"Permission",
"Role",
"RolePermission",
+99
View File
@@ -0,0 +1,99 @@
"""操作日志模型。
需求 2.5.1 要求「记录用户登录、操作行为及计提参数/系数表变更,支持日志查询」。
登录行为已在 `login_logs` 中单独记录(登录失败也要留痕,且不依赖认证上下文);
本表记录**已认证用户的操作行为**,两者分工明确、互不重叠。
不继承 TimestampMixin:日志是追加写、不可变的,没有"更新时间"语义。
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, mysql_table_args
# 各文本列的上限。**写入方必须按此截断**,否则 MySQL 严格模式抛 1406
# (Data too long),而按"审计失败不得影响业务"的约定该异常会被吞掉 ——
# 结果是整条日志静默丢失。截断值集中定义于此,供 repository 层引用。
USERNAME_MAX_LEN = 64
MODULE_MAX_LEN = 64
ACTION_MAX_LEN = 32
SUMMARY_MAX_LEN = 255
METHOD_MAX_LEN = 8
PATH_MAX_LEN = 255
IP_MAX_LEN = 45
USER_AGENT_MAX_LEN = 255
ERROR_MESSAGE_MAX_LEN = 500
# request_data 是 MySQL TEXT(上限 65535 字节)。脱敏后的 JSON 可能超限
# (如批量操作提交数千个 ID),超限同样会抛 1406,故写入方需按字节截断。
REQUEST_DATA_MAX_BYTES = 60000
class OperationLog(Base):
"""操作日志表。
记录原则与 `login_logs` 一致:
- **请求参数必须脱敏后入库**(复用 `core.logging.scrub`),日志是最容易泄漏
密码与令牌的地方
- 用户名冗余存储而非只存 user_id —— 账号被删除后仍能追溯操作者,
故 `username` 单独建索引(与 `login_logs` 保持一致)
- 写入失败不得影响业务(审计可丢,业务不可断)
"""
__tablename__ = "operation_logs"
__table_args__ = mysql_table_args(
"操作日志表",
Index("idx_operation_logs_user_id", "user_id"),
# 账号删除后 user_id 被置空,只能按用户名追溯,故该列必须有索引,
# 否则日志表增长后查询退化为全表扫描
Index("idx_operation_logs_username", "username"),
Index("idx_operation_logs_module", "module"),
Index("idx_operation_logs_create_time", "create_time"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
user_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="操作人ID(账号删除后置空)",
)
username: Mapped[str] = mapped_column(
String(USERNAME_MAX_LEN), nullable=False, comment="操作人用户名(冗余存储便于追溯)"
)
module: Mapped[str] = mapped_column(
String(MODULE_MAX_LEN), nullable=False, comment="所属模块,如 用户管理"
)
action: Mapped[str] = mapped_column(
String(ACTION_MAX_LEN), nullable=False, comment="操作类型:新增/修改/删除/导出"
)
summary: Mapped[str] = mapped_column(
String(SUMMARY_MAX_LEN), nullable=False, comment="操作摘要(中文,供人阅读)"
)
method: Mapped[str | None] = mapped_column(String(METHOD_MAX_LEN), comment="HTTP 方法")
path: Mapped[str | None] = mapped_column(String(PATH_MAX_LEN), comment="请求路径")
ip: Mapped[str | None] = mapped_column(String(IP_MAX_LEN), comment="客户端IP(45位兼容IPv6)")
user_agent: Mapped[str | None] = mapped_column(
String(USER_AGENT_MAX_LEN), comment="客户端User-Agent(截断存储)"
)
# 请求参数,**入库前已脱敏**;长文本故用 TEXT
request_data: Mapped[str | None] = mapped_column(Text, comment="请求参数(已脱敏)")
# 默认 0 而非 1:与 login_logs 保持一致,且方向更保守 ——
# 写入方漏传时记为「失败」,管理员会去核查;反之漏传记为「成功」则是
# 审计漏报,且无声无息。写入方应显式传值(见 repository 层)。
success: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="0", comment="是否成功:1成功 0失败"
)
error_message: Mapped[str | None] = mapped_column(
String(ERROR_MESSAGE_MAX_LEN), comment="失败原因(写入方须截断)"
)
duration_ms: Mapped[int | None] = mapped_column(Integer, comment="处理耗时(毫秒)")
create_time: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
)
@@ -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")
+54
View File
@@ -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
+123
View File
@@ -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()
+126 -4
View File
@@ -1,14 +1,23 @@
"""用户与角色数据访问。
"""用户数据访问(角色相关的读取与维护见 `role_repository`)。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session, selectinload
from app.models import User
from app.models import Role, User, UserRole
from app.repositories.query_utils import fuzzy
def _base_query() -> Select[tuple[User]]:
"""用户查询基座。
预加载角色关联,避免列表渲染时逐行查角色(N+1)。
"""
return select(User).options(selectinload(User.user_roles).selectinload(UserRole.role))
def get_by_username(session: Session, username: str) -> User | None:
@@ -34,3 +43,116 @@ def touch_last_login(session: Session, user: User, moment: datetime) -> None:
"""更新最后登录时间。只 flush,提交由 service 决定。"""
user.last_login_time = moment
session.flush()
# ==================== 列表查询 ====================
def list_users(
session: Session,
*,
offset: int,
limit: int,
username: str | None = None,
real_name: str | None = None,
status: int | None = None,
role_code: str | None = None,
) -> list[User]:
"""分页查询用户列表。
**按 id 倒序而非 create_time**:create_time 是秒级精度,批量导入的用户
可能落在同一秒,按它排序的分页结果不确定 —— 翻页时会出现重复或遗漏。
id 是自增主键,天然唯一且与创建顺序一致。
"""
stmt = _base_query()
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
if role_code:
# 按角色筛选需 join 关联表;角色是字典数据,数量极少,join 代价可忽略
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
stmt = stmt.order_by(User.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).scalars().unique())
def count_users(
session: Session,
*,
username: str | None = None,
real_name: str | None = None,
status: int | None = None,
role_code: str | None = None,
) -> int:
"""统计符合条件的用户总数,供分页使用。"""
stmt = select(func.count(User.id))
stmt = _apply_filters(stmt, username=username, real_name=real_name, status=status)
if role_code:
stmt = stmt.join(UserRole).join(Role).where(Role.role_code == role_code)
return int(session.execute(stmt).scalar_one())
def _apply_filters(
stmt: Select,
*,
username: str | None,
real_name: str | None,
status: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
模糊匹配统一走 `query_utils.fuzzy`(转义 LIKE 通配符)—— 用户名普遍
含下划线,直接拼接会让 `_` 当单字符通配执行、误配无关账号。
"""
if username:
stmt = fuzzy(stmt, User.username, username)
if real_name:
stmt = fuzzy(stmt, User.real_name, real_name)
if status is not None:
stmt = stmt.where(User.status == status)
return stmt
# ==================== 写入 ====================
def create(session: Session, user: User) -> User:
"""新增用户。只 flush 以取得自增 id。"""
session.add(user)
session.flush()
return user
def delete(session: Session, user: User) -> None:
"""物理删除用户。
本项目对用户采用「删除即禁用」的**业务**约定(见模型注释),
但保留物理删除能力供测试清理与误建账号的彻底移除使用。
`user_roles` 与 `operation_logs` 的外键分别配置了 CASCADE 与 SET NULL,
故不会留下悬挂引用。
"""
session.delete(user)
session.flush()
# ==================== 角色关联 ====================
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()
+105
View File
@@ -0,0 +1,105 @@
"""日志查询的请求/响应模型。
操作日志与登录日志字段不同(前者含模块/操作/请求详情,后者含失败原因),
故各自定义 Query 与 Out,仅共用「用户名 + 结果 + 时间范围」的筛选基类。
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.core.response import PageQuery
class LogQueryBase(PageQuery):
"""日志公共查询条件:模糊用户名 + 结果 + 时间范围。"""
username: str | None = Field(default=None, max_length=64, description="用户名,模糊匹配")
success: int | None = Field(default=None, ge=0, le=1, description="1成功 0失败")
start_time: datetime | None = Field(default=None, description="起始时间(含)")
end_time: datetime | None = Field(default=None, description="结束时间(含)")
@field_validator("start_time", "end_time")
@classmethod
def _to_local_naive(cls, value: datetime | None) -> datetime | None:
"""统一为本地时区的 naive 时间。
库列存本地时间(容器 TZ=Asia/Shanghai),而 API 接受 ISO 8601
(可带 ``Z`` 等时区后缀)。不归一时有两种故障:aware 与 naive
混用比较抛 TypeError(穿透成 500);aware 直接透传给驱动会
**静默丢弃偏移**(查询窗口整体偏 8 小时且无任何提示)。
aware 先转系统本地时区、再摘除 tzinfo 是唯一正确姿势;
naive 视为本地时间原样保留。
"""
if value is not None and value.tzinfo is not None:
return value.astimezone().replace(tzinfo=None)
return value
@model_validator(mode="after")
def _check_time_range(self) -> "LogQueryBase":
if self.start_time and self.end_time and self.start_time > self.end_time:
raise ValueError("起始时间不能晚于结束时间")
return self
class OperationLogQuery(LogQueryBase):
"""操作日志查询条件。"""
module: str | None = Field(default=None, max_length=64, description="所属模块,精确匹配")
action: str | None = Field(default=None, max_length=32, description="操作类型,精确匹配")
class OperationLogOut(BaseModel):
"""操作日志列表项。
**不含 request_data**(TEXT,单行最大 60000 字节):列表页默认不展示它,
随列表返回会让翻页负担随日志表增长线性放大;展开详情走
`GET /operation-logs/{id}`(`OperationLogDetailOut`),前端按需加载。
"""
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int | None = None
username: str
module: str
action: str
summary: str
method: str | None = None
path: str | None = None
ip: str | None = None
user_agent: str | None = None
success: int = 0
error_message: str | None = None
duration_ms: int | None = None
create_time: datetime | None = None
class OperationLogDetailOut(OperationLogOut):
"""操作日志详情:列表字段 + 请求参数。
`request_data` 入库时已经结构化 + 文本两层脱敏,可直接展示。
"""
request_data: str | None = None
class LoginLogQuery(LogQueryBase):
"""登录日志查询条件。"""
ip: str | None = Field(default=None, max_length=45, description="客户端IP,精确匹配")
class LoginLogOut(BaseModel):
"""登录日志响应。"""
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int | None = None
username: str
ip: str | None = None
user_agent: str | None = None
success: int = 0
fail_reason: str | None = None
create_time: datetime | None = None
+80
View File
@@ -0,0 +1,80 @@
"""角色管理的请求/响应模型。
`RoleOut` 原位于 `schemas/user.py`,因角色管理成为独立模块而迁入本文件 ——
用户表单的角色下拉与角色管理列表共用同一响应模型。
"""
import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.response import PageQuery
# 角色编码规则:小写字母开头,后接小写字母/数字/下划线,长度 2–32(与列宽一致)。
# 不允许大写与中文 —— 编码会出现在授权判断(`ROLE_ADMIN` 常量按编码比对)与
# 操作日志里,保持单一风格避免大小写/全角混用造成对照困难
ROLE_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,31}$")
class RoleOut(BaseModel):
"""角色基础响应(用户表单下拉与角色列表共用)。"""
model_config = ConfigDict(from_attributes=True)
id: int
role_code: str
role_name: str
description: str | None = None
sort_order: int = 0
status: int = 1
class RoleManageOut(RoleOut):
"""角色管理列表项:额外携带绑定用户数,供前端提示删除保护的原因。"""
user_count: int = 0
class RoleCreateIn(BaseModel):
"""新增角色。"""
model_config = ConfigDict(str_strip_whitespace=True)
role_code: str = Field(min_length=2, max_length=32, description="角色编码,创建后不可修改")
role_name: str = Field(min_length=1, max_length=64, description="角色名称")
description: str | None = Field(default=None, max_length=255)
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
@field_validator("role_code")
@classmethod
def _check_code(cls, value: str) -> str:
if not ROLE_CODE_PATTERN.match(value):
raise ValueError("角色编码须以小写字母开头,仅可含小写字母、数字与下划线")
return value
class RoleUpdateIn(BaseModel):
"""编辑角色。
**不含 role_code** —— 编码是授权判定的锚点,修改会让既有授权语义静默改变。
前端表单全量提交,故为全量替换语义。
"""
model_config = ConfigDict(str_strip_whitespace=True)
role_name: str = Field(min_length=1, max_length=64, description="角色名称")
description: str | None = Field(default=None, max_length=255)
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
class RoleStatusIn(BaseModel):
"""启用/禁用角色。"""
status: int = Field(ge=0, le=1, description="1启用 0禁用")
class RoleQuery(PageQuery):
"""角色列表查询条件。"""
keyword: str | None = Field(default=None, max_length=64, description="编码或名称,模糊匹配")
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")
+113
View File
@@ -0,0 +1,113 @@
"""用户管理的请求/响应模型(角色相关模型见 `role.py`)。
与 `auth.py` 的分工:`auth.py` 管登录链路(验证码、登录、当前用户),
本模块管**用户管理**(增删改查、重置密码、启停、角色分配)。
字段名驼峰(规范约定)与蛇形属性之间用 alias 映射,与响应模型保持同一套做法。
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES
from app.core.response import PageQuery
def _validate_password_bytes(value: str) -> str:
"""按**字节数**校验密码长度。
必须在这里拦下:bcrypt 5.0 起对超过 72 字节的密码直接抛 ValueError,
而中文一个字符占 3 字节 —— 按字符数校验(如 max_length=20)根本拦不住。
与 `auth.py` 的登录校验共用同一规则,避免两处口径不一致。
"""
length = len(value.encode("utf-8"))
if length < PASSWORD_MIN_BYTES:
raise ValueError(f"密码不足 {PASSWORD_MIN_BYTES} 字节")
if length > PASSWORD_MAX_BYTES:
raise ValueError(f"密码超过 {PASSWORD_MAX_BYTES} 字节上限")
return value
class UserOut(BaseModel):
"""用户列表/详情响应。
不含 password —— 密码哈希绝不外泄,这是硬约束而非疏漏。
"""
model_config = ConfigDict(from_attributes=True)
id: int
username: str
real_name: str | None = None
phone: str | None = None
email: str | None = None
status: int = Field(description="状态:1启用 0禁用")
roles: list[str] = Field(default_factory=list, description="角色编码列表")
last_login_time: datetime | None = None
remark: str | None = None
create_time: datetime | None = None
class UserCreateIn(BaseModel):
"""新增用户。"""
model_config = ConfigDict(str_strip_whitespace=True)
username: str = Field(min_length=2, max_length=64, description="登录用户名,须唯一")
password: str = Field(description="初始密码")
real_name: str | None = Field(default=None, max_length=64)
phone: str | None = Field(default=None, max_length=20)
email: str | None = Field(default=None, max_length=128)
roles: list[str] = Field(default_factory=list, description="角色编码列表,至少一个")
remark: str | None = Field(default=None, max_length=255)
@field_validator("password")
@classmethod
def _check_password(cls, value: str) -> str:
return _validate_password_bytes(value)
class UserUpdateIn(BaseModel):
"""编辑用户。
**不含 username** —— 用户名是登录凭据与审计追溯的锚点,改名会让历史
操作日志与登录日志对不上。确需改名应走专门的流程(本期不提供)。
"""
model_config = ConfigDict(str_strip_whitespace=True)
real_name: str | None = Field(default=None, max_length=64)
phone: str | None = Field(default=None, max_length=20)
email: str | None = Field(default=None, max_length=128)
roles: list[str] | None = Field(default=None, description="传 null 表示不改角色")
remark: str | None = Field(default=None, max_length=255)
class PasswordResetIn(BaseModel):
"""重置密码(管理员操作,不需要原密码)。"""
new_password: str = Field(description="新密码")
@field_validator("new_password")
@classmethod
def _check_password(cls, value: str) -> str:
return _validate_password_bytes(value)
class UserStatusIn(BaseModel):
"""启用/禁用用户。"""
status: int = Field(ge=0, le=1, description="1启用 0禁用")
class UserQuery(PageQuery):
"""用户列表查询条件。
继承分页基类,额外支持按用户名/姓名模糊、按状态与角色筛选。
"""
username: str | None = Field(default=None, max_length=64, description="用户名,模糊匹配")
real_name: str | None = Field(default=None, max_length=64, description="姓名,模糊匹配")
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")
role_code: str | None = Field(default=None, max_length=32, description="角色编码筛选")
+173
View File
@@ -0,0 +1,173 @@
"""操作日志审计服务。
需求 2.5.1 要求记录用户的操作行为。与登录行为的分工:登录在 `login_logs`
单独记录(失败也要留痕且不依赖认证上下文),本模块记录**已认证用户的管理类
操作**。
设计要点:
- **成功与失败都留痕** —— 失败的操作(如试图删除不存在的用户)同样是审计
对象,模型里专门有 `success` / `error_message` 两列承载它
- **审计失败不影响业务** —— 落库过程整体兜底,失败只记应用日志、绝不外抛
(与 `auth_service._record` 同一原则:"审计可丢,业务不可断")
- **独立提交** —— 日志单独 commit,不与业务同事务。同事务时业务失败回滚会
连带丢掉最该留痕的失败记录
用法(上下文管理器)::
target = user_repository.get_by_id(session, user_id) # 只读预取,供摘要
with audit(session, operator=current_user,
record=AuditRecord(MODULE_USER, ACTION_DELETE,
f"删除用户 {target.username}"),
meta=meta):
...业务操作与 session.commit()...
return out
约定:
- 业务代码在上下文内自行 commit(事务边界仍在 service 层)。**commit 之后
的任何数据库查询、ORM 属性读取都必须移出上下文**(提到 commit 之前或
用已固化的局部变量)—— 业务已提交却因后置查询抛错被记成「失败」,会
产生与事实相反的审计记录
- 上下文内不得吞掉异常后正常退出——吞掉后会被记为成功
- 摘要需要"目标实体名"时,应在进入上下文**之前**只读预取,
这样目标不存在(NotFound 失败)时也能留下可追溯的记录
- 不支持嵌套(在 audit 内再调用会自行 commit 的服务)——进入侦测到
未提交变更会记 warning
"""
import logging
import time
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.core.exceptions import AppException
from app.models import User
from app.repositories import operation_log_repository
from app.repositories.operation_log_repository import OperationLogEntry
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class RequestMeta:
"""HTTP 请求元信息。由路由层依赖 `RequestMetaDep` 提取后传入 service。"""
ip: str | None = None
user_agent: str | None = None
method: str | None = None
path: str | None = None
@dataclass(frozen=True, slots=True)
class AuditRecord:
"""审计的业务侧内容(模块 / 操作 / 摘要 / 请求参数)。"""
module: str
action: str
summary: str
request_data: dict | None = None
@contextmanager
def audit(
session: Session,
*,
operator: User,
record: AuditRecord,
meta: RequestMeta | None = None,
) -> Iterator[None]:
"""审计上下文:正常退出记成功;抛异常记失败并**原样重抛**。"""
started = time.perf_counter()
meta = meta or RequestMeta()
# 固化操作人为普通标量,之后不再触碰 ORM 对象 —— 失败路径先 rollback
# 会使其全部过期,再读 id/username 将触发额外 SELECT;若账号恰被并发
# 删除,ObjectDeletedError 还会被下面的兜底吞掉、失败审计静默丢失
base: dict = {
"user_id": operator.id,
"username": operator.username,
"module": record.module,
"action": record.action,
"summary": record.summary,
"method": meta.method,
"path": meta.path,
"ip": meta.ip,
"user_agent": meta.user_agent,
"request_data": record.request_data,
}
# 进入审计时应无未提交变更(业务写操作在上下文内才发生)。出现变更
# 意味着调用方可能嵌套了会自行 commit 的服务:失败路径的回滚将无法
# 撤销已提交的部分,审计与数据状态会不一致
if session.new or session.dirty or session.deleted:
logger.warning(
"进入审计上下文时存在未提交的变更 | module=%s | action=%s",
record.module,
record.action,
)
try:
yield
except Exception as exc:
# 先回滚再写日志:业务异常可能源于 IntegrityError 等数据库错误,
# 不回滚的话 session 处于「必须回滚」状态,写日志会抛
# PendingRollbackError —— 最该留下的失败记录反而被丢掉
_rollback_quietly(session)
_safe_write(
session,
entry=OperationLogEntry(
**base,
success=False,
error_message=_error_text(exc),
duration_ms=_elapsed_ms(started),
),
)
raise
else:
_safe_write(
session,
entry=OperationLogEntry(**base, success=True, duration_ms=_elapsed_ms(started)),
)
def _safe_write(session: Session, *, entry: OperationLogEntry) -> None:
"""落库并独立提交。整体兜底:任何失败只记应用日志,绝不外抛。"""
try:
operation_log_repository.add(session, entry)
session.commit()
except Exception:
logger.warning(
"操作日志写入失败(不影响业务) | module=%s | action=%s | summary=%s",
entry.module,
entry.action,
entry.summary,
exc_info=True,
)
_rollback_quietly(session)
def _rollback_quietly(session: Session) -> None:
try:
session.rollback()
except Exception:
logger.warning("会话回滚失败", exc_info=True)
def _error_text(exc: Exception) -> str:
"""失败原因文案。
业务异常取其面向用户的消息;未预期异常返回通用文案 —— 完整堆栈由全局
异常处理器记入应用日志,审计表不落内部细节。
"""
if isinstance(exc, AppException):
return exc.message
return "服务端异常,请稍后重试"
def _elapsed_ms(started: float) -> int:
"""度量业务处理耗时(不含响应序列化)。"""
return max(0, int((time.perf_counter() - started) * 1000))
+82
View File
@@ -0,0 +1,82 @@
"""日志查询编排。
操作日志与登录日志分别提供分页查询(两表字段与筛选项不同),操作日志另有
单条详情(列表为性能排除的 `request_data` 走详情按需加载)。
纯只读操作,不写审计 —— 读操作记日志只会淹没审计表。
"""
from sqlalchemy.orm import Session
from app.core.exceptions import NotFoundError
from app.core.response import PageResult
from app.repositories import login_log_repository, operation_log_repository
from app.repositories.query_utils import LogFilters
from app.schemas.log import (
LoginLogOut,
LoginLogQuery,
OperationLogDetailOut,
OperationLogOut,
OperationLogQuery,
)
def list_operation_logs(session: Session, query: OperationLogQuery) -> PageResult[OperationLogOut]:
"""分页查询操作日志(列表不含 request_data,详情走 `get_operation_log`)。"""
filters = _filters_from(query)
logs = operation_log_repository.list_logs(
session,
offset=query.offset,
limit=query.page_size,
filters=filters,
module=query.module,
action=query.action,
)
total = operation_log_repository.count_logs(
session, filters=filters, module=query.module, action=query.action
)
return PageResult[OperationLogOut](
records=[OperationLogOut.model_validate(log) for log in logs],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_operation_log(session: Session, log_id: int) -> OperationLogDetailOut:
"""查询单条操作日志详情(含已脱敏的 request_data)。
:raises NotFoundError: 日志不存在
"""
log = operation_log_repository.get_by_id(session, log_id)
if log is None:
raise NotFoundError("日志不存在")
return OperationLogDetailOut.model_validate(log)
def list_login_logs(session: Session, query: LoginLogQuery) -> PageResult[LoginLogOut]:
"""分页查询登录日志。"""
filters = _filters_from(query)
logs = login_log_repository.list_logs(
session,
offset=query.offset,
limit=query.page_size,
filters=filters,
ip=query.ip,
)
total = login_log_repository.count_logs(session, filters=filters, ip=query.ip)
return PageResult[LoginLogOut](
records=[LoginLogOut.model_validate(log) for log in logs],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def _filters_from(query: OperationLogQuery | LoginLogQuery) -> LogFilters:
"""两类 Query 的公共筛选字段 → LogFilters(口径与 repository 共用一个实现)。"""
return LogFilters(
username=query.username,
success=query.success,
start_time=query.start_time,
end_time=query.end_time,
)
+253
View File
@@ -0,0 +1,253 @@
"""角色管理业务编排。
需求 2.5.1 只定义 admin / user 两个角色,本模块属用户确认的**自主完善**
(轻量版:不含权限点分配 —— `permissions` / `role_permissions` 表继续闲置,
待业务方给出权限清单后再启用)。
保护规则(都是系统自举与授权安全的最低防线,不只是界面体验):
- `admin` 角色**禁删禁停** —— `cli` 建管理员与 `require_admin` 判定都依赖
该编码存在,删除会使系统无法自举
- **有用户绑定的角色禁删** —— `user_roles` 外键是 CASCADE,直接删除会静默
剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE`
锁行,堵住「校验与删除之间并发绑定」的窗口
- `role_code` 创建后不可改 —— 它是授权判定(`ROLE_ADMIN` 常量按编码比对)的锚点
- 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态),
但**不可再分配给用户**(`user_service._resolve_roles` 拦截)
"""
import logging
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_CREATE,
ACTION_DELETE,
ACTION_DISABLE,
ACTION_ENABLE,
ACTION_UPDATE,
MODULE_ROLE,
ROLE_ADMIN,
)
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.models import Role, User
from app.repositories import role_repository
from app.schemas.role import RoleCreateIn, RoleManageOut, RoleQuery, RoleUpdateIn
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__)
def _to_out(role: Role, user_count: int) -> RoleManageOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
return RoleManageOut(
id=role.id,
role_code=role.role_code,
role_name=role.role_name,
description=role.description,
sort_order=role.sort_order,
status=role.status,
user_count=user_count,
)
def _user_count(session: Session, role_id: int) -> int:
"""单个角色的绑定用户数(复用批量统计,保持口径一致)。"""
return role_repository.count_users_by_role(session, [role_id]).get(role_id, 0)
def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]:
"""分页查询角色。
与用户表单下拉(`/users/meta/roles`)口径不同:**管理列表含停用角色**,
下拉只给启用项。
"""
roles = role_repository.list_roles(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
status=query.status,
)
total = role_repository.count_roles(session, keyword=query.keyword, status=query.status)
counts = role_repository.count_users_by_role(session, [role.id for role in roles])
return PageResult[RoleManageOut](
records=[_to_out(role, counts.get(role.id, 0)) for role in roles],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def create_role(
session: Session,
payload: RoleCreateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> RoleManageOut:
"""新增角色。编码唯一、创建后不可修改,新角色固定启用。
:raises BadRequestError: 角色编码已存在(含并发提交的兜底)
"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_ROLE,
action=ACTION_CREATE,
summary=f"新增角色 {payload.role_name}({payload.role_code})",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if role_repository.get_by_code(session, payload.role_code) is not None:
raise BadRequestError(f"角色编码 {payload.role_code!r} 已存在")
role = Role(
role_code=payload.role_code,
role_name=payload.role_name,
description=payload.description,
sort_order=payload.sort_order,
status=1,
)
try:
role_repository.create(session, role)
out = _to_out(role, 0)
session.commit()
except IntegrityError as exc:
# 先查后写不是原子操作:并发提交同一编码会撞 uk_roles_role_code。
# 回滚并把唯一键冲突翻译为可读的 400,而非 500
session.rollback()
raise BadRequestError(f"角色编码 {payload.role_code!r} 已存在") from exc
logger.info("新增角色 | role_code=%s", payload.role_code)
return out
def update_role(
session: Session,
role_id: int,
payload: RoleUpdateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> RoleManageOut:
"""编辑角色名称/说明/排序(全量替换,不含 role_code)。
:raises NotFoundError: 角色不存在
"""
role = role_repository.get_by_id(session, role_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_ROLE,
action=ACTION_UPDATE,
summary=f"修改角色 {role.role_name if role else f'#{role_id}'}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if role is None:
raise NotFoundError("角色不存在")
role.role_name = payload.role_name
role.description = payload.description
role.sort_order = payload.sort_order
# 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明)
out = _to_out(role, _user_count(session, role_id))
session.commit()
logger.info("修改角色 | role_id=%s", role_id)
return out
def set_status(
session: Session,
role_id: int,
status: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> RoleManageOut:
"""启用或禁用角色。
停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态),
但该角色不可再分配给用户(`user_service._resolve_roles` 拦截);
内置管理员角色不可停用。
:raises NotFoundError: 角色不存在
:raises ForbiddenError: 试图停用内置管理员角色
"""
role = role_repository.get_by_id(session, role_id)
verb = "启用" if status == 1 else "禁用"
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_ROLE,
action=ACTION_ENABLE if status == 1 else ACTION_DISABLE,
summary=f"{verb}角色 {role.role_name if role else f'#{role_id}'}",
request_data={"status": status},
),
meta=meta,
):
if role is None:
raise NotFoundError("角色不存在")
if role.role_code == ROLE_ADMIN and status == 0:
raise ForbiddenError("内置管理员角色不可停用")
role.status = status
out = _to_out(role, _user_count(session, role_id))
session.commit()
logger.info("变更角色状态 | role_id=%s | status=%s", role_id, status)
return out
def delete_role(
session: Session,
role_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""删除角色。
内置管理员角色不可删除;仍有关联用户的角色不可删除(提示先解除绑定)。
查询走 `FOR UPDATE` 锁行:校验与删除之间若发生并发绑定,CASCADE 会把
新绑定一并静默剥离 —— 锁行使并发绑定失败(可见)而非数据被吞(不可见)。
:raises NotFoundError: 角色不存在
:raises ForbiddenError: 试图删除内置管理员角色
:raises BadRequestError: 角色下仍有绑定的用户
"""
role = role_repository.get_by_id(session, role_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_ROLE,
action=ACTION_DELETE,
summary=f"删除角色 {role.role_name if role else f'#{role_id}'}",
),
meta=meta,
):
if role is None:
raise NotFoundError("角色不存在")
if role.role_code == ROLE_ADMIN:
raise ForbiddenError("内置管理员角色不可删除")
# user_roles 外键是 CASCADE:不拦截的话,删除会静默剥离绑定用户的权限
user_count = _user_count(session, role_id)
if user_count > 0:
raise BadRequestError(f"该角色下仍有 {user_count} 个用户,请先解除绑定")
role_code = role.role_code # 固化:对象随删除失效
role_repository.delete(session, role)
session.commit()
logger.info("删除角色 | role_code=%s", role_code)
+360
View File
@@ -0,0 +1,360 @@
"""用户管理业务编排。
**事务边界在本层**:每个公开方法自行决定何时 commit。
repository 只 flush,事务由这里控制。
设计取舍:
- 删除采用**物理删除**,与「删除即禁用」并不矛盾 —— 后者是对业务方的建议
(停用账号应走禁用而非删除,以保留审计线索),但需求 2.5.1 明确列了
「增删改查」,故保留删除能力;`operation_logs` 通过 SET NULL + 冗余 username
保证删除后仍可追溯
- 不允许删除或禁用**自己** —— 管理员误操作会立刻失去全部管理权限,
且没有其他账号可以救回来
- 全部写操作经 `audit_service.audit()` 留痕,**成功与失败都记**。摘要需要
「目标用户名」,故对目标的只读预取放在审计上下文**之前** —— 这样目标
不存在(NotFound 失败)时也能留下可读的记录
- **响应对象在 commit 之前构造**(`_to_out` 读的是内存中的已加载状态):
commit 后再触碰 ORM 对象会因过期而触发额外查询,该查询一旦抛错,
「已提交成功」的操作会被审计记成失败
"""
import logging
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_CREATE,
ACTION_DELETE,
ACTION_DISABLE,
ACTION_ENABLE,
ACTION_RESET_PASSWORD,
ACTION_UPDATE,
MODULE_USER,
ROLE_ADMIN,
)
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.core.security import hash_password
from app.models import User
from app.repositories import role_repository, user_repository
from app.schemas.role import RoleOut
from app.schemas.user import UserCreateIn, UserOut, UserQuery, UserUpdateIn
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__)
def _to_out(user: User) -> UserOut:
"""ORM 对象转响应模型。
禁止把 ORM 对象直接当 response_model —— 表结构变更会直接泄漏到接口契约上。
"""
return UserOut(
id=user.id,
username=user.username,
real_name=user.real_name,
phone=user.phone,
email=user.email,
status=user.status,
roles=user_repository.get_role_codes(user),
last_login_time=user.last_login_time,
remark=user.remark,
create_time=user.create_time,
)
def list_users(session: Session, query: UserQuery) -> PageResult[UserOut]:
"""分页查询用户列表。"""
users = user_repository.list_users(
session,
offset=query.offset,
limit=query.page_size,
username=query.username,
real_name=query.real_name,
status=query.status,
role_code=query.role_code,
)
total = user_repository.count_users(
session,
username=query.username,
real_name=query.real_name,
status=query.status,
role_code=query.role_code,
)
return PageResult[UserOut](
records=[_to_out(user) for user in users],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_user(session: Session, user_id: int) -> UserOut:
"""查询用户详情。
:raises NotFoundError: 用户不存在
"""
user = user_repository.get_by_id(session, user_id)
if user is None:
raise NotFoundError("用户不存在")
return _to_out(user)
def create_user(
session: Session,
payload: UserCreateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> UserOut:
"""新增用户。
:raises BadRequestError: 用户名已存在,或角色编码无效/已停用
"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_USER,
action=ACTION_CREATE,
summary=f"新增用户 {payload.username}",
# password 会随字典进入审计管道,由 repository 层的结构化脱敏抹除其值
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if user_repository.get_by_username(session, payload.username) is not None:
raise BadRequestError(f"用户名 {payload.username!r} 已存在")
roles = _resolve_roles(session, payload.roles)
user = User(
username=payload.username,
password=hash_password(payload.password),
real_name=payload.real_name,
phone=payload.phone,
email=payload.email,
status=1,
remark=payload.remark,
)
try:
user_repository.create(session, user)
user_repository.replace_roles(session, user, roles)
out = _to_out(user)
session.commit()
except IntegrityError as exc:
# 先查后写不是原子操作:并发提交同一用户名会撞 uk_users_username。
# 回滚并把唯一键冲突翻译为可读的 400,而非 500
session.rollback()
raise BadRequestError(f"用户名 {payload.username!r} 已存在") from exc
logger.info("新增用户 | username=%s | roles=%s", payload.username, payload.roles)
return out
def update_user(
session: Session,
user_id: int,
payload: UserUpdateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> UserOut:
"""编辑用户的基本信息与角色。
**不含用户名与密码**:用户名是审计追溯的锚点;密码走专门的重置接口,
以免"改资料"顺手改掉密码这类越权操作。
空串字段(`''`)归一为 None:编辑表单会把未修改的字段以空串提交,
若原样写入会把库中的 NULL 污染成空串(两类空值语义漂移且不可逆);
归一后「清空输入框」= 写回 NULL,语义正确。
:raises NotFoundError: 用户不存在
:raises BadRequestError: 角色编码无效或已停用
"""
user = user_repository.get_by_id(session, user_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_USER,
action=ACTION_UPDATE,
summary=f"修改用户 {user.username if user else f'#{user_id}'}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if user is None:
raise NotFoundError("用户不存在")
if payload.real_name is not None:
user.real_name = payload.real_name or None
if payload.phone is not None:
user.phone = payload.phone or None
if payload.email is not None:
user.email = payload.email or None
if payload.remark is not None:
user.remark = payload.remark or None
# 不允许把自己降级掉管理角色,否则改完立刻失去管理权限
if payload.roles is not None and user_id == operator.id and ROLE_ADMIN not in payload.roles:
raise ForbiddenError("不能移除自己的管理员角色")
session.flush()
if payload.roles is not None:
roles = _resolve_roles(session, payload.roles)
user_repository.replace_roles(session, user, roles)
out = _to_out(user)
session.commit()
logger.info("编辑用户 | user_id=%s | roles=%s", user_id, payload.roles)
return out
def reset_password(
session: Session,
user_id: int,
new_password: str,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""重置用户密码。
同时把 `token_version` 加一,使该用户**已签发的令牌立即失效** ——
改密后旧令牌仍可用是常见疏漏,这里与「离职即失效」复用同一机制。
:raises NotFoundError: 用户不存在
"""
user = user_repository.get_by_id(session, user_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_USER,
action=ACTION_RESET_PASSWORD,
summary=f"重置用户 {user.username if user else f'#{user_id}'} 的密码",
# 明文进审计管道,由 repository 层的结构化脱敏整体抹除
request_data={"new_password": new_password},
),
meta=meta,
):
if user is None:
raise NotFoundError("用户不存在")
user.password = hash_password(new_password)
user.token_version += 1
session.commit()
logger.info("重置密码 | user_id=%s(令牌版本已提升)", user_id)
def set_status(
session: Session,
user_id: int,
status: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> UserOut:
"""启用或禁用用户。
禁用时同样提升 `token_version`,使已签发令牌立即失效 ——
否则被禁用者仍可凭旧令牌访问直到过期(最长 2 小时)。
:raises NotFoundError: 用户不存在
:raises ForbiddenError: 试图禁用自己
"""
user = user_repository.get_by_id(session, user_id)
verb = "启用" if status == 1 else "禁用"
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_USER,
action=ACTION_ENABLE if status == 1 else ACTION_DISABLE,
summary=f"{verb}用户 {user.username if user else f'#{user_id}'}",
request_data={"status": status},
),
meta=meta,
):
if user is None:
raise NotFoundError("用户不存在")
if status == 0 and user_id == operator.id:
raise ForbiddenError("不能禁用自己")
user.status = status
if status == 0:
user.token_version += 1
out = _to_out(user)
session.commit()
logger.info("变更用户状态 | user_id=%s | status=%s", user_id, status)
return out
def delete_user(
session: Session,
user_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""删除用户。
物理删除。历史操作日志通过 `user_id` 置空 + 冗余 `username` 保留可追溯性。
:raises NotFoundError: 用户不存在
:raises ForbiddenError: 试图删除自己
"""
user = user_repository.get_by_id(session, user_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_USER,
action=ACTION_DELETE,
summary=f"删除用户 {user.username if user else f'#{user_id}'}",
),
meta=meta,
):
if user is None:
raise NotFoundError("用户不存在")
if user_id == operator.id:
raise ForbiddenError("不能删除自己")
username = user.username # 固化:对象随删除失效
user_repository.delete(session, user)
session.commit()
logger.info("删除用户 | username=%s", username)
def list_roles(session: Session) -> list[RoleOut]:
"""取全部启用角色,供用户表单的角色选择使用。"""
return [RoleOut.model_validate(role) for role in role_repository.get_all_roles(session)]
def _resolve_roles(session: Session, role_codes: list[str]) -> list:
"""把角色编码解析为角色对象,校验全部存在且**未停用**。
停用角色不出现在表单下拉中;接口层若不拦截,直接调 API(或使用停用前
加载的旧选项)仍可绑定,与「停用后不可再分配」的语义矛盾。已绑定停用
角色的用户需先转为启用角色再保存。
:raises BadRequestError: 存在无效编码,或角色已停用
"""
if not role_codes:
raise BadRequestError("至少需要指定一个角色")
roles = role_repository.get_roles_by_codes(session, role_codes)
if len(roles) != len(set(role_codes)):
valid = {role.role_code for role in roles}
invalid = sorted(set(role_codes) - valid)
raise BadRequestError(f"角色编码无效:{invalid}")
disabled = sorted(role.role_code for role in roles if role.status != 1)
if disabled:
raise BadRequestError(f"角色已停用,不可分配:{disabled}")
return list(roles)
+27 -3
View File
@@ -1,6 +1,6 @@
# 后端测试
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 184 个用例全绿(单元 155 / 集成 29)。
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 301 个用例全绿(单元 223 / 集成 78)。
## 一、测试约定
@@ -57,6 +57,23 @@ uv run pytest tests/integration -q # 仅集成
| `unit/test_auth_schemas.py` | 密码**按字节**校验 | 9 | 24 个汉字 = 72 字节正好、25 个被拒 —— 按字符数校验(如 `max_length=20`)根本拦不住,会让登录接口 500 |
| `integration/test_auth_flow.py` | 整条链路**串联正确性** | 16 | 验证码→登录→持令牌访问→登出失效;验证码重放被拒;失败原因如实落库且**日志表无任何密码字段**;账号不存在与密码错误文案完全相同(防枚举);禁用账号与 `token_version` 提升均使旧令牌立即失效 |
### 系统管理 · 用户与角色管理
| 测试文件 | 守什么 | 用例数 | 关键覆盖点 |
| --- | --- | :---: | --- |
| `unit/test_audit_service.py` | 审计上下文的语义 | 16 | 正常退出记成功、抛异常记失败;**失败时先 rollback 再写日志**且原异常原样重抛(顺序颠倒时数据库异常会让失败记录丢成 PendingRollbackError);**操作人标量在进入时固化**(失败路径不再触碰会过期的 ORM 对象);进入时脏会话告警(防嵌套半提交);审计自身失败不外抛、不替换业务异常 |
| `unit/test_audit_serialization.py` | 入库前截断与脱敏不丢数据、不泄密 | 16 | 各列按模型常量截断;60KB 请求参数按 **UTF-8 字节边界**截断后是原串的干净前缀(中文不切半);**结构化抹除**(按敏感键名整值替换):前导空白/空格/逗号/引号密码全被抹除且 JSON 结构完好、嵌套 dict/list 与键名大小写、非敏感键不误伤;手机号仍由 scrub 文本兜底;孤立代理字符不再抛错丢审计 |
| `unit/test_role_schemas.py` | 角色编码格式红线 | 16 | `^[a-z][a-z0-9_]{1,31}$` 全边界(大写/中文/数字或下划线开头/单字符/超长被拒);编辑模型**不接受 role_code**(多余字段忽略);状态只能 0/1 |
| `integration/test_user_management.py` | 用户 CRUD 串联正确性 + **审计留痕** | 23 | 分页以 `pageNum/pageSize` 生效(钉死别名契约,改蛇形会静默失效);自删/自禁/移除自己 admin 角色 403;禁用与重置密码使旧令牌立即失效;**每个写操作(含失败的)在 operation_logs 有对应记录**、request_data 全文扫不到明文密码;账号删除后审计行 `user_id` 置空而 username 保留;**编辑时保留既有角色不撞唯一键**(1062 回归);**编辑未修改字段不把 NULL 污染成空串**;**含下划线用户名不误配**(LIKE 转义回归) |
| `integration/test_role_management.py` | 角色保护规则(系统自举与授权安全的最低防线) | 13 | admin 角色禁删禁停 403;有绑定用户的角色禁删 400 且消息含用户数、解绑后可删;编码创建后不可改;**停用角色不可再分配**(接口层拦截);停用后不在 `/users/meta/roles` 但仍在管理列表;`user_count` 正确;普通用户 403 |
### 系统管理 · 日志查询
| 测试文件 | 守什么 | 用例数 | 关键覆盖点 |
| --- | --- | :---: | --- |
| `unit/test_log_schemas.py` | 时间范围校验与时区归一 | 13 | 起始晚于结束拒绝(两类日志共用基类,放过去则 SQL 条件互斥、查询恒空且无提示);**带时区输入转本地 naive**(Z 后缀 → 东八区 08:00;混用 aware/naive 不再抛 TypeError 穿透 500);边界相等通过;仅传一端不校验 |
| `integration/test_log_query.py` | 写入与查询的**口径一致** | 13 | 写操作产生的审计可按 username/module/action/success/时间范围检索到,摘要含目标用户名;失败记录带中文 `error_message`;**列表不含 request_data、详情端点返回已脱敏参数**(两段式,含 404/403);起始晚于结束 400;登录日志直插造数后可按 ip 精确筛选;两类日志普通用户 403 |
### 应用骨架与交付产物
| 测试文件 | 守什么 | 用例数 | 关键覆盖点 |
@@ -71,6 +88,8 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-09 | 全部 | 120 | ✅ 通过(4.99s) | 阶段 1 骨架 + 阶段 2 数据库完成后首次全量运行 |
| 2026-10-09 | 全部 | 119 | ✅ 通过(5.01s) | ORM 由异步改回同步后回归;净减 1 例(`sync_database_url` 独立配置已合并) |
| 2026-10-09 | 全部 | 184 | ✅ 通过(25.46s) | **登录鉴权模块完成**后统一编写并执行该模块测试套件(新增 65 例)。集成用例需容器在跑 |
| 2026-10-10 | 全部 | 282 | ✅ 通过(103.81s) | **系统管理模块(用户/角色/日志)完成**后统一编写该模块套件(新增 91 例:单元 48 / 集成 43)。含操作日志审计机制专项;**e2e 发现并修复 `replace_roles` 唯一键缺陷**(见四、缺陷表);集成用例需容器在跑 |
| 2026-10-10 | 全部 | 301 | ✅ 通过(87.40s) | **code-review 缺陷修复后全量回归**(新增/改造 19 例)。密码结构化脱敏、时区归一、日志详情两段式各有专项;集成套件因密码哈希模块级预计算提速(63s→42s) |
## 四、测试发现并修复的缺陷
@@ -85,14 +104,19 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-09 | 验证码沿用 `captcha` 库默认渲染 | 随机彩色字符 + 重噪点,与界面视觉体系冲突;该库最后更新于 2025-03,内部还引用了 Pillow 的旧别名 | 改为自绘(`core/captcha_image.py`):配色与界面统一,难度由逐字旋转、抖动、干扰曲线与噪点保证;仅沿用其内置字体文件 |
| 2026-10-09 | 噪点用 `ellipse` 绘制 | 3×3 包围盒画出的椭圆恰好填充中心与四个边中点,形状就是「+」,形成规则的十字纹理——既显眼又容易被整体滤波抹掉,起不到干扰作用 | 改用单像素点与 2×2 方块 |
| 2026-10-09 | `tests/unit/test_jwt.py` 两处赋值了未使用的 `settings`(ruff F841) | 变量未被引用,属无效代码 | 删除赋值;该文件的 `get_settings` 导入仍被其他用例使用 |
| 2026-10-10 | `replace_roles` 先清后建但中间未落库 | SQLAlchemy 在同一 flush 中**先处理 INSERT 再处理 DELETE**,编辑用户保留既有角色时重插同 `(user_id, role_id)` 撞唯一键(1062)——编辑用户接口 500。**由前端 e2e「编辑用户」步骤暴露**:集成测试当时只覆盖了「换掉全部角色」(无交集)的路径 | `clear()` 后立即 `session.flush()` 把删除落库,再追加新集合;补集成回归用例(编辑时保留既有角色) |
| 2026-10-10 | 审计脱敏依赖文本正则 | 三种逃逸形态:密码**以空白开头时完全不脱敏**、含空格/逗号只抹首段、含引号既残留明文又破坏 JSON——明文/残段写入 `operation_logs.request_data` 且日志页可直接展开查看(**由 code-review 发现并逐条实证复现**;既有测试所用密码均不含空格,恰好掩盖该路径) | 改为按键名的**结构化抹除**(递归 dict/list 整值替换 `***`),scrub 降级为文本兜底;补前导空白/空格/引号/嵌套/大小写五类边界用例 |
| 2026-10-10 | `.gitignore` 的 `logs/` 规则吞掉日志查询页目录 | 规则无前导斜杠、匹配任意层级:`frontend/src/views/system/logs/` 整体被忽略,文件**进不了版本库**——干净克隆构建失败(本地因文件已在磁盘上而一切"正常",隐蔽性极强) | 收窄为 `/backend/logs/`(该规则本意即后端运行日志);以 `git check-ignore` 实证不再命中 |
| 2026-10-10 | 时间筛选未做时区归一 | 混用 aware/naive 抛 TypeError 穿透成 **500**;带时区输入被驱动静默丢弃偏移(查询窗口偏 8 小时且无提示) | `LogQueryBase` 加 field_validator:aware → `astimezone()` 本地 naive、naive 原样;补 4 例专项 |
| 2026-10-10 | 审计时序两处缺陷 | ① 失败路径 rollback 后读 operator 触发额外查询,账号并发被删时 ObjectDeletedError 被兜底吞掉、失败审计静默丢失;② 成功/失败两分支 8 个实参重复展开,且 role/user service 在 commit 之后仍做查询与响应构造——后置查询一旦抛错,「已提交成功」会被记成失败 | operator 标量进入时固化;审计链 DTO 化(`audit` 4 参 / `_safe_write` 2 参 / `add` 2 参);service 的 commit 后查询与响应构造全部前置 |
## 五、待补用例
| 模块 | 状态 | 说明 |
| --- | --- | --- |
| 系统管理 · 用户与角色管理 | 待编写 | 需求 2.5.1 的用户增删改查、密码重置、启用/禁用、角色分配 — 该模块功能尚未实现 |
| 系统管理 · 操作日志 | 待编写 | 除登录日志外的操作审计(当前仅登录日志落库) |
| 基础数据管理 | 未开始 | 依赖 T+ 对接方式确认 |
| 业务数据管理 | 未开始 | 依赖需求澄清(回款天数口径等 10 项) |
| 绩效工资核算 | 未开始 | 依赖需求澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率) |
| 报表统计 | 未开始 | 依赖报表口径确认 |
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
+169
View File
@@ -0,0 +1,169 @@
"""系统管理集成测试共用夹具(需要 MySQL 与 Redis)。
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
数据卫生约定:
- 测试账号/角色统一 ``t_`` 前缀;工厂夹具清理自己创建的对象,另有
autouse 兜底按前缀全量清理(用例经**接口**创建的数据不在工厂名下,
失败中断时会残留并影响后续用例的 total 断言)
- 操作日志的清理同时按 username 与 user_id —— 账号被删除后其 ``user_id``
会被 SET NULL,只能靠冗余的 username 追溯,清理逻辑必须覆盖两种状态
"""
import uuid
import pytest
from sqlalchemy import delete, select
from app.core.constants import ROLE_ADMIN, ROLE_USER
from app.core.db import get_session_factory
from app.core.security import create_access_token, hash_password
from app.models import OperationLog, Role, User, UserRole
TEST_PASSWORD = "TestPwd@2026"
# 模块级预计算一次:bcrypt(rounds=12)单次约 0.2–0.4 秒,逐用例重复哈希
# 会让整个集成套件多花 20 秒以上;这些测试账号全部走直签 JWT,
# 哈希值不被任何断言消费,复用一个哈希与每次重算等价
_TEST_PASSWORD_HASH = hash_password(TEST_PASSWORD)
def _purge_test_data() -> None:
"""按 `t_` 前缀清理测试残留(先日志后账号;角色先断关联再删)。
``t\\_%`` 中的下划线做了转义 —— 不转义时 `_` 是 LIKE 的单字符通配,
会把 tn... 这类无关账号一并卷入。
"""
factory = get_session_factory()
with factory() as session:
users = list(
session.execute(select(User.id, User.username).where(User.username.like("t\\_%"))).all()
)
ids = [row[0] for row in users]
names = [row[1] for row in users]
if ids:
session.execute(delete(OperationLog).where(OperationLog.user_id.in_(ids)))
if names:
session.execute(delete(OperationLog).where(OperationLog.username.in_(names)))
session.execute(delete(User).where(User.id.in_(ids)))
role_ids = list(
session.execute(select(Role.id).where(Role.role_code.like("t\\_%"))).scalars()
)
if role_ids:
session.execute(delete(UserRole).where(UserRole.role_id.in_(role_ids)))
session.execute(delete(Role).where(Role.id.in_(role_ids)))
session.commit()
@pytest.fixture(autouse=True)
def _purge_after_each_test():
"""每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。"""
yield
_purge_test_data()
@pytest.fixture
def make_user():
"""独立测试账号工厂:``make_user(["admin"])`` → dict(id/username/password)。
不复用真实 admin —— 测试会改密码、提 token_version、删除账号,
污染真实账号会让后续手工验证产生困惑。
"""
def _factory(role_codes: list[str] | None = None, username: str | None = None) -> dict:
codes = role_codes if role_codes is not None else [ROLE_USER]
name = username or f"t_{uuid.uuid4().hex[:8]}"
factory = get_session_factory()
with factory() as session:
roles = list(session.execute(select(Role).where(Role.role_code.in_(codes))).scalars())
assert len(roles) == len(codes), f"角色不存在:{codes}"
user = User(
username=name,
password=_TEST_PASSWORD_HASH,
real_name="测试账号",
status=1,
)
user.user_roles = [UserRole(role_id=role.id) for role in roles]
session.add(user)
session.commit()
return {"id": user.id, "username": name, "password": TEST_PASSWORD}
return _factory
@pytest.fixture
def auth_headers():
"""令牌直签工厂:``auth_headers(user)`` → Authorization 请求头。
绕开验证码与登录接口 —— 令牌只承载身份,授权判定以数据库中的角色为准
(`require_admin` 每次查库),直签不影响测试有效性,省去清理 Redis
限流键与黑名单。令牌版本变化(如重置密码、禁用)后旧令牌会失效,
再次调用本工厂可换新令牌。
"""
def _factory(user: dict) -> dict[str, str]:
factory = get_session_factory()
with factory() as session:
db_user = session.get(User, user["id"])
assert db_user is not None, "账号已被删除,无法签发令牌"
roles = [link.role.role_code for link in db_user.user_roles if link.role]
token, _, _ = create_access_token(
user_id=db_user.id,
token_version=db_user.token_version,
roles=roles,
)
return {"Authorization": f"Bearer {token}"}
return _factory
@pytest.fixture
def make_role():
"""独立测试角色工厂(ORM 直建,用后删除)。
清理时先删除 user_roles 关联(用例失败时可能残留绑定),再删角色。
"""
created: list[int] = []
def _factory(**overrides: object) -> dict:
code = str(overrides.pop("role_code", f"t_role_{uuid.uuid4().hex[:6]}"))
name = str(overrides.pop("role_name", "测试角色"))
factory = get_session_factory()
with factory() as session:
role = Role(
role_code=code,
role_name=name,
description=overrides.pop("description", None),
sort_order=overrides.pop("sort_order", 99),
status=overrides.pop("status", 1),
**overrides,
)
session.add(role)
session.commit()
created.append(role.id)
return {"id": role.id, "role_code": code, "role_name": name}
yield _factory
factory = get_session_factory()
with factory() as session:
session.execute(delete(UserRole).where(UserRole.role_id.in_(created)))
session.execute(delete(Role).where(Role.id.in_(created)))
session.commit()
@pytest.fixture
def admin(make_user, auth_headers) -> dict:
"""管理员账号(含请求头)。"""
user = make_user([ROLE_ADMIN])
user["headers"] = auth_headers(user)
return user
@pytest.fixture
def normal(make_user, auth_headers) -> dict:
"""普通用户账号(含请求头)。"""
user = make_user([ROLE_USER])
user["headers"] = auth_headers(user)
return user
+198
View File
@@ -0,0 +1,198 @@
"""日志查询接口集成测试(需要 MySQL 与 Redis)。
守的是:两类日志的筛选、分页、权限,以及**写入与查询的口径一致** ——
写操作产生的审计记录必须能被查询接口按同样口径检索到(否则"有写入
没查询"或"查询条件对不上",日志功能形同虚设)。
登录日志造数直接 ORM 插入,不经过登录接口:绕开验证码依赖,且能精确
控制 success/fail_reason/ip 组合。
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
"""
import uuid
from datetime import datetime, timedelta
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete
from app.core.db import get_session_factory
from app.models import LoginLog
pytestmark = pytest.mark.integration
_OPERATION_LOGS = "/api/v1/system/operation-logs"
_LOGIN_LOGS = "/api/v1/system/login-logs"
_USERS = "/api/v1/system/users"
def _make_audit(client: TestClient, admin: dict, action: str = "删除") -> None:
"""触发一条审计记录(失败操作最省事、不留业务数据)。"""
resp = client.delete(f"{_USERS}/999999999", headers=admin["headers"])
assert resp.status_code == 404
assert action == "删除"
class TestOperationLogQuery:
"""操作日志查询。"""
def test_写操作后可按模块与操作筛选(self, client: TestClient, admin: dict, make_user) -> None:
target = make_user()
resp = client.delete(f"{_USERS}/{target['id']}", headers=admin["headers"])
assert resp.status_code == 200
resp = client.get(
_OPERATION_LOGS,
params={"username": admin["username"], "module": "用户管理", "action": "删除"},
headers=admin["headers"],
)
data = resp.json()["data"]
assert data["total"] >= 1
row = data["list"][0]
assert row["success"] == 1
assert target["username"] in row["summary"]
assert row["module"] == "用户管理"
def test_按结果筛选出失败记录(self, client: TestClient, admin: dict) -> None:
_make_audit(client, admin)
resp = client.get(
_OPERATION_LOGS,
params={"username": admin["username"], "success": 0},
headers=admin["headers"],
)
data = resp.json()["data"]
assert data["total"] >= 1
assert data["list"][0]["error_message"]
def test_时间范围筛选(self, client: TestClient, admin: dict) -> None:
_make_audit(client, admin)
now = datetime.now()
fmt = "%Y-%m-%d %H:%M:%S"
resp = client.get(
_OPERATION_LOGS,
params={
"username": admin["username"],
"start_time": (now - timedelta(minutes=5)).strftime(fmt),
"end_time": (now + timedelta(minutes=5)).strftime(fmt),
},
headers=admin["headers"],
)
assert resp.json()["data"]["total"] >= 1
# 已过去的时间窗(排除测试刚产生的记录)
resp = client.get(
_OPERATION_LOGS,
params={
"username": admin["username"],
"start_time": "2020-01-01 00:00:00",
"end_time": "2020-01-02 00:00:00",
},
headers=admin["headers"],
)
assert resp.json()["data"]["total"] == 0
def test_起始晚于结束返回400(self, client: TestClient, admin: dict) -> None:
resp = client.get(
_OPERATION_LOGS,
params={"start_time": "2026-10-02 00:00:00", "end_time": "2026-10-01 00:00:00"},
headers=admin["headers"],
)
assert resp.status_code == 400
def test_列表响应不含请求参数字段(self, client: TestClient, admin: dict) -> None:
"""列表为性能排除 request_data(TEXT 大字段),防止翻页负担随日志表增长。"""
resp = client.get(_OPERATION_LOGS, headers=admin["headers"])
rows = resp.json()["data"]["list"]
if rows:
assert "request_data" not in rows[0]
def test_详情端点返回已脱敏的请求参数(self, client: TestClient, admin: dict, make_user) -> None:
"""列表 → 详情 的两段式:详情含 request_data 且已脱敏。"""
secret = "S3cret@2026!"
target = make_user()
resp = client.put(
f"{_USERS}/{target['id']}/password",
json={"new_password": secret},
headers=admin["headers"],
)
assert resp.status_code == 200
resp = client.get(
_OPERATION_LOGS,
params={"username": admin["username"], "action": "重置密码"},
headers=admin["headers"],
)
row = resp.json()["data"]["list"][0]
assert "request_data" not in row
detail = client.get(f"{_OPERATION_LOGS}/{row['id']}", headers=admin["headers"])
assert detail.status_code == 200
request_data = detail.json()["data"]["request_data"] or ""
assert secret not in request_data
assert "***" in request_data
def test_详情端点不存在返回404(self, client: TestClient, admin: dict) -> None:
resp = client.get(f"{_OPERATION_LOGS}/999999999", headers=admin["headers"])
assert resp.status_code == 404
def test_详情端点普通用户被拒(self, client: TestClient, normal: dict) -> None:
resp = client.get(f"{_OPERATION_LOGS}/1", headers=normal["headers"])
assert resp.status_code == 403
def test_普通用户被拒(self, client: TestClient, normal: dict) -> None:
assert client.get(_OPERATION_LOGS, headers=normal["headers"]).status_code == 403
class TestLoginLogQuery:
"""登录日志查询。"""
@pytest.fixture
def seeded_logs(self):
"""直插两条登录日志(一成功一失败),用后清理。"""
username = f"t_login_{uuid.uuid4().hex[:6]}"
factory = get_session_factory()
with factory() as session:
session.add(
LoginLog(
username=username,
ip="10.1.2.3",
user_agent="pytest",
success=0,
fail_reason="用户名或密码错误",
)
)
session.add(LoginLog(username=username, ip="10.1.2.4", user_agent="pytest", success=1))
session.commit()
yield username
with factory() as session:
session.execute(delete(LoginLog).where(LoginLog.username == username))
session.commit()
def test_按用户名查询并返回两端结果(
self, client: TestClient, admin: dict, seeded_logs: str
) -> None:
resp = client.get(_LOGIN_LOGS, params={"username": seeded_logs}, headers=admin["headers"])
data = resp.json()["data"]
assert data["total"] == 2
assert {row["success"] for row in data["list"]} == {0, 1}
def test_按IP精确筛选(self, client: TestClient, admin: dict, seeded_logs: str) -> None:
resp = client.get(_LOGIN_LOGS, params={"ip": "10.1.2.3"}, headers=admin["headers"])
data = resp.json()["data"]
assert data["total"] == 1
assert data["list"][0]["fail_reason"] == "用户名或密码错误"
def test_按结果筛选(self, client: TestClient, admin: dict, seeded_logs: str) -> None:
resp = client.get(
_LOGIN_LOGS,
params={"username": seeded_logs, "success": 0},
headers=admin["headers"],
)
data = resp.json()["data"]
assert data["total"] == 1
assert data["list"][0]["success"] == 0
def test_普通用户被拒(self, client: TestClient, normal: dict) -> None:
assert client.get(_LOGIN_LOGS, headers=normal["headers"]).status_code == 403
@@ -0,0 +1,209 @@
"""角色管理接口集成测试(需要 MySQL 与 Redis)。
守的是角色管理的**保护规则** —— 它们不是界面体验,而是系统自举与授权
安全的最低防线:
- `admin` 角色禁删禁停(cli 建管理员与 require_admin 判定都依赖该编码)
- 有用户绑定的角色禁删(user_roles 外键是 CASCADE,直接删会静默剥离权限)
- `role_code` 创建后不可改(授权判定的锚点)
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
"""
import uuid
import pytest
from fastapi.testclient import TestClient
from app.core.constants import ROLE_USER
pytestmark = pytest.mark.integration
_ROLES = "/api/v1/system/roles"
def _new_code() -> str:
return f"t_role_{uuid.uuid4().hex[:6]}"
def _admin_role_id(client: TestClient, admin: dict) -> int:
"""从管理列表取内置 admin 角色的 id。"""
resp = client.get(_ROLES, params={"keyword": "admin"}, headers=admin["headers"])
rows = resp.json()["data"]["list"]
matches = [row for row in rows if row["role_code"] == "admin"]
assert matches, "内置 admin 角色缺失"
return matches[0]["id"]
class TestCrudFlow:
"""正向链路:建 → 查(列表 + 下拉)→ 改 → 停用 → 删。"""
def test_新增角色并联动列表与下拉(self, client: TestClient, admin: dict) -> None:
code = _new_code()
resp = client.post(
_ROLES,
json={"role_code": code, "role_name": "对外协作岗", "sort_order": 50},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
role_id = resp.json()["data"]["id"]
assert resp.json()["data"]["user_count"] == 0
# 管理列表可查(keyword 同时匹配编码与名称)
resp = client.get(_ROLES, params={"keyword": code}, headers=admin["headers"])
assert resp.json()["data"]["total"] == 1
# 用户表单下拉包含(启用状态)
resp = client.get("/api/v1/system/users/meta/roles", headers=admin["headers"])
assert code in [row["role_code"] for row in resp.json()["data"]]
assert client.delete(f"{_ROLES}/{role_id}", headers=admin["headers"]).status_code == 200
def test_重复编码被拒(self, client: TestClient, admin: dict, make_role) -> None:
role = make_role()
resp = client.post(
_ROLES,
json={"role_code": role["role_code"], "role_name": "重复"},
headers=admin["headers"],
)
assert resp.status_code == 400
assert "已存在" in resp.json()["message"]
def test_编码格式非法被拒(self, client: TestClient, admin: dict) -> None:
resp = client.post(
_ROLES, json={"role_code": "BadCode", "role_name": "大写"}, headers=admin["headers"]
)
assert resp.status_code == 400
def test_编辑角色名称与排序(self, client: TestClient, admin: dict, make_role) -> None:
role = make_role()
resp = client.put(
f"{_ROLES}/{role['id']}",
json={"role_name": "改名后", "description": "新的说明", "sort_order": 7},
headers=admin["headers"],
)
assert resp.status_code == 200
data = resp.json()["data"]
assert data["role_name"] == "改名后"
assert data["description"] == "新的说明"
assert data["sort_order"] == 7
def test_编码不可被修改(self, client: TestClient, admin: dict, make_role) -> None:
"""请求体携带 role_code 会被忽略(编辑模型不接收该字段),编码保持不变。"""
role = make_role()
resp = client.put(
f"{_ROLES}/{role['id']}",
json={"role_name": "改名", "role_code": "hacked"},
headers=admin["headers"],
)
assert resp.status_code == 200
assert resp.json()["data"]["role_code"] == role["role_code"]
def test_停用后不在下拉但仍在管理列表(self, client: TestClient, admin: dict, make_role) -> None:
role = make_role()
resp = client.put(
f"{_ROLES}/{role['id']}/status", json={"status": 0}, headers=admin["headers"]
)
assert resp.status_code == 200
assert resp.json()["data"]["status"] == 0
resp = client.get("/api/v1/system/users/meta/roles", headers=admin["headers"])
assert role["role_code"] not in [row["role_code"] for row in resp.json()["data"]]
resp = client.get(_ROLES, params={"keyword": role["role_code"]}, headers=admin["headers"])
assert resp.json()["data"]["total"] == 1
class TestProtection:
"""保护规则:内置角色与有绑定的角色。"""
def test_admin角色不可删除(self, client: TestClient, admin: dict) -> None:
role_id = _admin_role_id(client, admin)
resp = client.delete(f"{_ROLES}/{role_id}", headers=admin["headers"])
assert resp.status_code == 403
assert "不可删除" in resp.json()["message"]
def test_admin角色不可停用(self, client: TestClient, admin: dict) -> None:
role_id = _admin_role_id(client, admin)
resp = client.put(
f"{_ROLES}/{role_id}/status", json={"status": 0}, headers=admin["headers"]
)
assert resp.status_code == 403
assert "不可停用" in resp.json()["message"]
def test_有绑定的角色不可删除且解绑后可删除(
self, client: TestClient, admin: dict, make_role, make_user
) -> None:
"""`user_roles` 外键是 CASCADE —— 后端 400 拦截是防止静默剥离权限的
唯一防线,前端按钮禁用只是体验。"""
role = make_role()
bound = make_user([role["role_code"]])
resp = client.delete(f"{_ROLES}/{role['id']}", headers=admin["headers"])
assert resp.status_code == 400
assert "1 个用户" in resp.json()["message"]
# 解绑(换回内置 user 角色)后可删除
resp = client.put(
f"/api/v1/system/users/{bound['id']}",
json={"roles": [ROLE_USER]},
headers=admin["headers"],
)
assert resp.status_code == 200
assert client.delete(f"{_ROLES}/{role['id']}", headers=admin["headers"]).status_code == 200
def test_列表返回绑定用户数(
self, client: TestClient, admin: dict, make_role, make_user
) -> None:
role = make_role()
make_user([role["role_code"]])
resp = client.get(_ROLES, params={"keyword": role["role_code"]}, headers=admin["headers"])
assert resp.json()["data"]["list"][0]["user_count"] == 1
def test_编辑不存在的角色404(self, client: TestClient, admin: dict) -> None:
resp = client.put(
f"{_ROLES}/999999999", json={"role_name": "不存在"}, headers=admin["headers"]
)
assert resp.status_code == 404
def test_停用角色不可再分配给用户(
self, client: TestClient, admin: dict, make_role, make_user
) -> None:
"""回归:停用只挡住了表单下拉,接口层此前仍可绑定(或使用停用前
加载的旧选项提交)—— 与「停用后不可再分配」的语义矛盾。"""
role = make_role()
resp = client.put(
f"{_ROLES}/{role['id']}/status", json={"status": 0}, headers=admin["headers"]
)
assert resp.status_code == 200
username = f"t_{uuid.uuid4().hex[:8]}"
resp = client.post(
"/api/v1/system/users",
json={"username": username, "password": "TestPwd@2026", "roles": [role["role_code"]]},
headers=admin["headers"],
)
assert resp.status_code == 400
assert "停用" in resp.json()["message"]
# 已绑定该角色的用户(历史数据)改回启用角色后可正常保存
bound = make_user([role["role_code"]])
resp = client.put(
f"/api/v1/system/users/{bound['id']}",
json={"roles": [ROLE_USER]},
headers=admin["headers"],
)
assert resp.status_code == 200
class TestAuthorization:
"""权限:角色管理为管理员专属。"""
def test_普通用户访问被拒(self, client: TestClient, normal: dict) -> None:
assert client.get(_ROLES, headers=normal["headers"]).status_code == 403
resp = client.post(
_ROLES,
json={"role_code": _new_code(), "role_name": "越权"},
headers=normal["headers"],
)
assert resp.status_code == 403
+54 -9
View File
@@ -1,13 +1,16 @@
"""DDL 漂移检测。
结构定义同时存在于两处:
- `db/migration/V1__*.sql` —— 事实来源,生产迁移用
- `db/migration/V*.sql` —— 事实来源,生产迁移用
- `db/init/init-dev.sql` —— 开发环境重置脚本,Docker 首次启动唯一会自动执行的位置
两份 DDL 手工同步迟早会漂移,且后果隐蔽:开发库的表结构与迁移脚本不一致,
新人克隆下来建出的库和生产不一样,等到上线才发现。
本测试把「靠自觉」变成「可自动拦截」,成本极低。
**必须比对全部 V*.sql 的合集**:早期版本只解析 V1,新增 V2 之后,
两份 DDL 同时缺表仍会被判为「一致」,漂移守卫形同虚设。
注:本文件不需要容器,放在 integration 下是因为它校验的是交付产物而非应用代码。
"""
@@ -17,8 +20,9 @@ from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[3]
MIGRATION_FILE = PROJECT_ROOT / "db" / "migration" / "V1__init_rbac_and_login_logs.sql"
MIGRATION_DIR = PROJECT_ROOT / "db" / "migration"
INIT_DEV_FILE = PROJECT_ROOT / "db" / "init" / "init-dev.sql"
ALEMBIC_VERSIONS_DIR = PROJECT_ROOT / "backend" / "alembic" / "versions"
_CREATE_TABLE = re.compile(r"CREATE TABLE\s+`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL)
_COLUMN_LINE = re.compile(r"^\s{4}`?(\w+)`?\s", re.MULTILINE)
@@ -38,9 +42,38 @@ def _parse_tables(sql: str) -> dict[str, set[str]]:
return tables
def _alembic_head() -> str:
"""从迁移脚本推断当前 head 版本号。
纯文本解析而非调用 alembic 运行时 —— 后者需要连数据库,会让本测试
从「秒级、无需容器」变成「必须起库」。
head 的定义:没有被任何其他迁移的 down_revision 引用过的那个 revision。
"""
revisions: set[str] = set()
referenced: set[str] = set()
for path in ALEMBIC_VERSIONS_DIR.glob("*.py"):
text = path.read_text(encoding="utf-8")
if found := re.search(r"^revision: str = ['\"](\w+)['\"]", text, re.MULTILINE):
revisions.add(found.group(1))
if found := re.search(r"^down_revision:.*?= ['\"](\w+)['\"]", text, re.MULTILINE):
referenced.add(found.group(1))
heads = revisions - referenced
assert len(heads) == 1, f"迁移链存在分叉或缺失,heads={sorted(heads)}"
return heads.pop()
@pytest.fixture(scope="module")
def migration_tables() -> dict[str, set[str]]:
return _parse_tables(MIGRATION_FILE.read_text(encoding="utf-8"))
"""合并解析 db/migration/ 下全部 V*.sql。"""
files = sorted(MIGRATION_DIR.glob("V*.sql"))
assert files, f"未找到迁移脚本:{MIGRATION_DIR}"
merged: dict[str, set[str]] = {}
for path in files:
for table, columns in _parse_tables(path.read_text(encoding="utf-8")).items():
merged.setdefault(table, set()).update(columns)
return merged
@pytest.fixture(scope="module")
@@ -52,7 +85,7 @@ class TestSchemaSync:
"""两份 DDL 的结构一致性。"""
def test_文件存在(self) -> None:
assert MIGRATION_FILE.is_file(), f"缺少迁移脚本:{MIGRATION_FILE}"
assert list(MIGRATION_DIR.glob("V*.sql")), f"缺少迁移脚本:{MIGRATION_DIR}"
assert INIT_DEV_FILE.is_file(), f"缺少开发初始化脚本:{INIT_DEV_FILE}"
def test_表集合一致(self, migration_tables: dict, init_dev_tables: dict) -> None:
@@ -74,16 +107,28 @@ class TestSchemaSync:
class TestSeedData:
"""字典数据在两处应保持同步。"""
"""字典数据与版本标记。"""
@pytest.mark.parametrize("role_code", ["admin", "user"])
def test_角色初始化数据在两处都存在(self, role_code: str) -> None:
migration_sql = MIGRATION_FILE.read_text(encoding="utf-8")
migration_sql = "\n".join(
path.read_text(encoding="utf-8") for path in sorted(MIGRATION_DIR.glob("V*.sql"))
)
init_sql = INIT_DEV_FILE.read_text(encoding="utf-8")
assert f"'{role_code}'" in migration_sql
assert f"'{role_code}'" in init_sql
def test_开发脚本标记了迁移版本(self) -> None:
"""不写 alembic_version 的话,在开发库上执行 upgrade head 会重复建表而失败。"""
def test_开发脚本标记的版本号等于当前head(self) -> None:
"""版本号必须等于 head,且不能只断言「存在」。
落后的后果:开发库重建后执行 `alembic upgrade head`,alembic 认为该迁移
尚未应用,重复建表并报 1050 Table already exists,只能手工补版本或 down -v。
"""
init_sql = INIT_DEV_FILE.read_text(encoding="utf-8")
assert "INSERT INTO alembic_version" in init_sql
found = re.search(
r"INSERT INTO alembic_version \(version_num\) VALUES \('(\w+)'\)", init_sql
)
assert found, "init-dev.sql 未写入 alembic_version"
assert found.group(1) == _alembic_head(), (
f"init-dev.sql 标记的版本号 {found.group(1)!r} 落后于当前 head {_alembic_head()!r}"
)
@@ -0,0 +1,364 @@
"""用户管理接口集成测试(需要 MySQL 与 Redis)。
守的是:用户 CRUD 的**串联正确性**与**审计留痕**。
审计是重点验收项:每个写操作(含失败的)都必须在 operation_logs 留下
可读记录 —— 模块、操作、摘要、操作人、请求元信息完整,且 request_data
中**绝无明文密码**;账号被删除后审计行通过「user_id 置空 + 冗余 username」
保留可追溯性。
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
"""
import uuid
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import OperationLog
pytestmark = pytest.mark.integration
_PASSWORD = "TestPwd@2026"
_USERS = "/api/v1/system/users"
def _new_username() -> str:
return f"t_{uuid.uuid4().hex[:8]}"
def _create_via_api(client: TestClient, admin: dict, username: str, **overrides: object) -> dict:
"""经接口创建用户并返回响应 data(账号归 autouse 清理兜底)。"""
payload: dict = {
"username": username,
"password": _PASSWORD,
"real_name": "接口新用户",
"roles": ["user"],
}
payload.update(overrides)
resp = client.post(_USERS, json=payload, headers=admin["headers"])
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _logs_of(username: str) -> list[OperationLog]:
"""取某操作人最近的审计行(按 id 倒序)。"""
factory = get_session_factory()
with factory() as session:
rows = list(
session.execute(
select(OperationLog)
.where(OperationLog.username == username)
.order_by(OperationLog.id.desc())
).scalars()
)
return rows
class TestCrudFlow:
"""正向链路:建 → 查 → 改 → 启停 → 重置密码 → 删。"""
def test_新增用户并可在列表查到(self, client: TestClient, admin: dict) -> None:
username = _new_username()
data = _create_via_api(client, admin, username)
assert data["username"] == username
assert data["roles"] == ["user"]
assert data["status"] == 1
resp = client.get(_USERS, params={"username": username}, headers=admin["headers"])
body = resp.json()["data"]
assert body["total"] == 1
assert body["list"][0]["id"] == data["id"]
def test_重名用户被拒(self, client: TestClient, admin: dict, make_user) -> None:
target = make_user()
resp = client.post(
_USERS,
json={"username": target["username"], "password": _PASSWORD, "roles": ["user"]},
headers=admin["headers"],
)
assert resp.status_code == 400
assert "已存在" in resp.json()["message"]
def test_分页参数以驼峰命名生效(self, client: TestClient, admin: dict) -> None:
"""pageNum/pageSize 是前端契约(PageQuery 的 alias)。
防回归:若被改成蛇形命名,FastAPI 会忽略这两个参数、恒返回
第 1 页 10 条 —— 表现为「翻页没反应」,且没有任何报错。
"""
resp = client.get(_USERS, params={"pageNum": 2, "pageSize": 5}, headers=admin["headers"])
data = resp.json()["data"]
assert {"list", "total", "pageNum", "pageSize"} <= set(data)
assert data["pageNum"] == 2
assert data["pageSize"] == 5
assert len(data["list"]) <= 5
def test_编辑用户信息与角色(self, client: TestClient, admin: dict, make_user) -> None:
target = make_user()
resp = client.put(
f"{_USERS}/{target['id']}",
json={"real_name": "改后姓名", "roles": ["admin"]},
headers=admin["headers"],
)
assert resp.status_code == 200
data = resp.json()["data"]
assert data["real_name"] == "改后姓名"
assert data["roles"] == ["admin"]
def test_编辑用户保留既有角色不触发唯一键冲突(
self, client: TestClient, admin: dict, make_user
) -> None:
"""回归:新旧角色集合有交集时(编辑时未改角色必然如此),
`replace_roles` 若在 clear 后不先落库删除,SQLAlchemy 同一 flush
内先 INSERT 后 DELETE 会撞 uk_user_roles_user_id_role_id(1062)。
该缺陷最初由前端 e2e「编辑用户」步骤暴露 —— 集成测试当时只覆盖
了「换掉全部角色」的场景。"""
target = make_user(["user"])
resp = client.put(
f"{_USERS}/{target['id']}",
json={"real_name": "保留角色", "roles": ["user", "admin"]},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
assert sorted(resp.json()["data"]["roles"]) == ["admin", "user"]
def test_禁用与重新启用(self, client: TestClient, admin: dict, make_user) -> None:
target = make_user()
resp = client.put(
f"{_USERS}/{target['id']}/status", json={"status": 0}, headers=admin["headers"]
)
assert resp.status_code == 200
assert resp.json()["data"]["status"] == 0
resp = client.put(
f"{_USERS}/{target['id']}/status", json={"status": 1}, headers=admin["headers"]
)
assert resp.json()["data"]["status"] == 1
def test_禁用后旧令牌立即失效(
self, client: TestClient, admin: dict, make_user, auth_headers
) -> None:
"""禁用会提 token_version —— 不依赖 Redis 的「离职即失效」。"""
target = make_user()
old_headers = auth_headers(target)
assert client.get("/api/v1/auth/me", headers=old_headers).status_code == 200
client.put(f"{_USERS}/{target['id']}/status", json={"status": 0}, headers=admin["headers"])
assert client.get("/api/v1/auth/me", headers=old_headers).status_code == 401
def test_重置密码后旧令牌立即失效(
self, client: TestClient, admin: dict, make_user, auth_headers
) -> None:
target = make_user()
old_headers = auth_headers(target)
assert client.get("/api/v1/auth/me", headers=old_headers).status_code == 200
resp = client.put(
f"{_USERS}/{target['id']}/password",
json={"new_password": "NewPwd@2026"},
headers=admin["headers"],
)
assert resp.status_code == 200
assert client.get("/api/v1/auth/me", headers=old_headers).status_code == 401
def test_删除用户(self, client: TestClient, admin: dict, make_user) -> None:
target = make_user()
resp = client.delete(f"{_USERS}/{target['id']}", headers=admin["headers"])
assert resp.status_code == 200
resp = client.get(f"{_USERS}/{target['id']}", headers=admin["headers"])
assert resp.status_code == 404
class TestSelfProtection:
"""自我保护:不允许对当前登录账号执行危险操作。"""
def test_不能删除自己(self, client: TestClient, admin: dict) -> None:
resp = client.delete(f"{_USERS}/{admin['id']}", headers=admin["headers"])
assert resp.status_code == 403
def test_不能禁用自己(self, client: TestClient, admin: dict) -> None:
resp = client.put(
f"{_USERS}/{admin['id']}/status", json={"status": 0}, headers=admin["headers"]
)
assert resp.status_code == 403
def test_不能移除自己的管理员角色(self, client: TestClient, admin: dict) -> None:
resp = client.put(
f"{_USERS}/{admin['id']}", json={"roles": ["user"]}, headers=admin["headers"]
)
assert resp.status_code == 403
class TestValidation:
"""参数与业务校验。"""
def test_无效角色编码被拒(self, client: TestClient, admin: dict) -> None:
resp = client.post(
_USERS,
json={"username": _new_username(), "password": _PASSWORD, "roles": ["nonexist"]},
headers=admin["headers"],
)
assert resp.status_code == 400
assert "角色编码无效" in resp.json()["message"]
def test_空角色列表被拒(self, client: TestClient, admin: dict) -> None:
resp = client.post(
_USERS,
json={"username": _new_username(), "password": _PASSWORD, "roles": []},
headers=admin["headers"],
)
assert resp.status_code == 400
def test_密码25个汉字被拒(self, client: TestClient, admin: dict) -> None:
"""75 字节超 bcrypt 上限;按字符数校验拦不住(25 字符很短)。"""
resp = client.post(
_USERS,
json={"username": _new_username(), "password": "中" * 25, "roles": ["user"]},
headers=admin["headers"],
)
assert resp.status_code == 400
def test_查询不存在的用户返回404(self, client: TestClient, admin: dict) -> None:
resp = client.get(f"{_USERS}/999999999", headers=admin["headers"])
assert resp.status_code == 404
def test_编辑未修改字段不污染空值语义(self, client: TestClient, admin: dict, make_user) -> None:
"""回归:编辑表单把未修改字段以空串提交,此前会把库中 NULL 写成 ''——
两类空值(未填 vs 空串)语义漂移且不可逆;'' 现归一为 NULL。"""
target = make_user() # 直建账号 phone/email/remark 均为 NULL
resp = client.put(
f"{_USERS}/{target['id']}",
json={"real_name": "改名", "phone": "", "email": "", "remark": ""},
headers=admin["headers"],
)
assert resp.status_code == 200
data = resp.json()["data"]
assert data["real_name"] == "改名"
assert data["phone"] is None
assert data["email"] is None
assert data["remark"] is None
class TestFiltering:
"""筛选条件的口径。"""
def test_含下划线的用户名不误配(self, client: TestClient, admin: dict, make_user) -> None:
"""回归:模糊筛选未转义 LIKE 通配符时 `_` 匹配任意单字符,
搜 t_xx 会命中 txXx 之类无关账号 —— 用户名普遍含下划线,非理论场景。"""
target = make_user()
decoy_name = target["username"].replace("_", "x", 1)
make_user(username=decoy_name)
resp = client.get(_USERS, params={"username": target["username"]}, headers=admin["headers"])
names = [row["username"] for row in resp.json()["data"]["list"]]
assert target["username"] in names
assert decoy_name not in names
class TestAuditTrail:
"""审计留痕 —— 本模块的核心验收点。"""
def test_每个写操作都留下审计记录(self, client: TestClient, admin: dict, make_user) -> None:
target = make_user()
client.put(
f"{_USERS}/{target['id']}",
json={"real_name": "审计留痕"},
headers=admin["headers"],
)
client.put(f"{_USERS}/{target['id']}/status", json={"status": 0}, headers=admin["headers"])
client.put(
f"{_USERS}/{target['id']}/password",
json={"new_password": "AuditPwd@2026"},
headers=admin["headers"],
)
client.delete(f"{_USERS}/{target['id']}", headers=admin["headers"])
logs = _logs_of(admin["username"])
actions = {(log.module, log.action) for log in logs}
assert ("用户管理", "修改") in actions
assert ("用户管理", "禁用") in actions
assert ("用户管理", "重置密码") in actions
assert ("用户管理", "删除") in actions
# 摘要含目标用户名(供人阅读的追溯锚点)
assert any(target["username"] in log.summary for log in logs)
# 请求元信息与结果完整
success_logs = [log for log in logs if log.success == 1]
assert success_logs
for log in success_logs:
assert log.method and log.path
assert log.duration_ms is not None and log.duration_ms >= 0
def test_失败操作记为失败且带中文原因(self, client: TestClient, admin: dict) -> None:
resp = client.delete(f"{_USERS}/999999999", headers=admin["headers"])
assert resp.status_code == 404
latest = _logs_of(admin["username"])[0]
assert latest.success == 0
assert latest.action == "删除"
assert latest.error_message == "用户不存在"
def test_审计请求参数绝不包含明文密码(self, client: TestClient, admin: dict, make_user) -> None:
"""密码是审计脱敏的红线:全表扫描该操作人的 request_data 不得命中。"""
secret = "S3cret@2026!"
_create_via_api(client, admin, _new_username(), password=secret)
target = make_user()
client.put(
f"{_USERS}/{target['id']}/password",
json={"new_password": secret},
headers=admin["headers"],
)
factory = get_session_factory()
with factory() as session:
rows = list(
session.execute(
select(OperationLog.request_data).where(
OperationLog.username == admin["username"]
)
).scalars()
)
joined = "\n".join(row for row in rows if row)
assert secret not in joined
assert "***" in joined # 键名保留、值被抹除
def test_账号删除后审计行保留用户名(
self, client: TestClient, admin: dict, make_user, auth_headers
) -> None:
"""user_id 外键 SET NULL + username 冗余 —— 删除账号不得让审计断链。"""
victim = make_user(["admin"])
victim_headers = auth_headers(victim)
# victim 作为操作人产生一条审计(失败操作最省事,不留业务数据)
client.delete(f"{_USERS}/999999999", headers=victim_headers)
assert (
client.delete(f"{_USERS}/{victim['id']}", headers=admin["headers"]).status_code == 200
)
factory = get_session_factory()
with factory() as session:
logs = list(
session.execute(
select(OperationLog).where(OperationLog.username == victim["username"])
).scalars()
)
assert logs, "审计行不应随账号删除而消失"
assert all(log.user_id is None for log in logs)
assert all(log.username == victim["username"] for log in logs)
class TestAuthorization:
"""权限:系统管理为管理员专属。"""
def test_普通用户访问用户管理被拒(self, client: TestClient, normal: dict) -> None:
assert client.get(_USERS, headers=normal["headers"]).status_code == 403
resp = client.post(
_USERS,
json={"username": _new_username(), "password": _PASSWORD, "roles": ["user"]},
headers=normal["headers"],
)
assert resp.status_code == 403
@@ -0,0 +1,128 @@
"""操作日志入库前处理单元测试。
守的是入库前的**截断与脱敏**:
- 截断:各列超长在 MySQL 严格模式下抛 1406,而「审计失败不影响业务」的约定
会把该异常吞掉 —— 整条日志静默消失;request_data 尤甚,中文一个字符
占 3 字节,必须按 **UTF-8 字节边界**切,否则会切出乱码残字
- 脱敏:密码是最容易从日志泄漏的凭据,request_data 入库前必经 scrub
"""
import json
from datetime import datetime
from app.models.operation_log import (
ERROR_MESSAGE_MAX_LEN,
REQUEST_DATA_MAX_BYTES,
SUMMARY_MAX_LEN,
)
from app.repositories.operation_log_repository import _clip, _serialize_request_data
class TestClip:
"""按字符数截断(长度常量与数据库列宽均按字符定义)。"""
def test_超长截断至上限(self) -> None:
result = _clip("x" * (SUMMARY_MAX_LEN + 50), SUMMARY_MAX_LEN)
assert result is not None
assert len(result) == SUMMARY_MAX_LEN
def test_未超长原样返回(self) -> None:
assert _clip("正常长度", SUMMARY_MAX_LEN) == "正常长度"
def test_空值返回None(self) -> None:
assert _clip(None, ERROR_MESSAGE_MAX_LEN) is None
assert _clip("", ERROR_MESSAGE_MAX_LEN) is None
def test_中文按字符数截断不切字(self) -> None:
"""str 切片天然按字符,不会切出半个汉字。"""
result = _clip("中" * 100, 10)
assert result == "中" * 10
class TestSerializeRequestData:
"""序列化:JSON 化 → 脱敏 → 字节边界截断。"""
def test_常规参数原样序列化(self) -> None:
text = _serialize_request_data({"status": 0, "roles": ["user"]})
assert text is not None
assert json.loads(text) == {"status": 0, "roles": ["user"]}
def test_空值返回None(self) -> None:
assert _serialize_request_data(None) is None
assert _serialize_request_data({}) is None
def test_密码值被抹除而键名保留(self) -> None:
"""键名保留便于审计阅读"提交过密码字段",值必须抹掉。"""
for payload in ({"password": "S3cret@2026"}, {"new_password": "S3cret@2026"}):
text = _serialize_request_data(payload) or ""
assert "S3cret@2026" not in text
assert "***" in text
def test_手机号被脱敏(self) -> None:
text = _serialize_request_data({"phone": "13812345678"}) or ""
assert "13812345678" not in text
assert "138****5678" in text
def test_超长中文按字节边界截断且无残字(self) -> None:
"""75000 字节(25000 个汉字)必然超限:
若按字符截断则字节数三倍超标;若按字节硬切可能切出半个字符。
正确结果应是原始 JSON 串的**干净前缀**。"""
payload = {"remark": "中" * 25000}
text = _serialize_request_data(payload)
assert text is not None
assert len(text.encode("utf-8")) <= REQUEST_DATA_MAX_BYTES
assert "�" not in text # 无替换字符
raw = json.dumps(payload, ensure_ascii=False)
assert raw.startswith(text)
def test_未超长的中文完整保留(self) -> None:
payload = {"remark": "中" * 100}
text = _serialize_request_data(payload)
assert text is not None
assert json.loads(text) == payload
def test_不可JSON序列化的值降级为字符串(self) -> None:
text = _serialize_request_data({"at": datetime(2026, 10, 10, 12, 0, 0)})
assert text is not None
assert "2026-10-10" in text
class TestStructuredSanitization:
"""结构化抹除 —— 按键名整值替换,不依赖文本正则。
回归背景:此前用文本正则脱敏,有三种逃逸形态 —— 值以空白开头时
整体匹配失败(**完全不脱敏**);值含空格/逗号时只抹首段;值含引号时
既残留明文又破坏 JSON 结构。密码校验只查字节数、前后端都不 trim,
这些形态都是正常可用密码。
"""
def test_前导空白密码被完全抹除(self) -> None:
"""回归:此前 `" abc12345"` 原样明文入库。"""
text = _serialize_request_data({"new_password": " abc12345"}) or ""
assert "abc12345" not in text
assert json.loads(text) == {"new_password": "***"}
def test_含空格逗号引号均整体抹除且JSON完整(self) -> None:
for password in ("abc 12345", "abc,def", 'abc"def', "abc&def"):
text = _serialize_request_data({"password": password}) or ""
assert password not in text
assert json.loads(text) == {"password": "***"}
def test_嵌套结构与键名大小写(self) -> None:
text = (
_serialize_request_data({"user": {"New_Password": "s3"}, "list": [{"TOKEN": "t"}]})
or ""
)
assert "s3" not in text
assert json.loads(text) == {"user": {"New_Password": "***"}, "list": [{"TOKEN": "***"}]}
def test_非敏感键不受影响(self) -> None:
text = _serialize_request_data({"keyword": "a_b", "remark": "正常备注"}) or ""
assert json.loads(text) == {"keyword": "a_b", "remark": "正常备注"}
def test_孤立代理字符不再抛错(self) -> None:
"""回归:直接 encode('utf-8') 遇 \\ud800 抛 UnicodeEncodeError,
该请求的审计记录会被兜底逻辑吞掉、静默丢失。"""
text = _serialize_request_data({"username": "a\ud800b"})
assert text is not None
+268
View File
@@ -0,0 +1,268 @@
"""操作日志审计服务单元测试。
守的是 `audit()` 上下文管理器的语义:
- 正常退出记成功、抛异常记失败,**失败记录必须先生成再让异常继续传播**
- 失败路径必须先 `rollback` 再写日志 —— 业务异常若来自 IntegrityError 等
数据库错误,不回滚时 session 处于待回滚状态,写日志会抛 PendingRollbackError,
最该留下的失败记录反而被静默丢掉
- 操作人信息在进入上下文时**固化为标量** —— 失败路径 rollback 会过期 ORM
对象,再读属性会触发额外查询,账号并发被删时甚至 ObjectDeletedError
令失败审计静默丢失
- 审计自身的任何失败都不得外抛("审计可丢,业务不可断")
"""
import logging
from datetime import datetime
from types import SimpleNamespace
import pytest
from app.core.exceptions import BadRequestError
from app.models import OperationLog
from app.services.audit_service import AuditRecord, RequestMeta, audit
class _StubSession:
"""最小会话桩:记录调用顺序与写入的日志对象,可注入失败。"""
def __init__(self) -> None:
self.events: list[str] = []
self.logs: list[OperationLog] = []
self.fail_add = False
self.fail_commit = False
# audit 入口会巡检未提交变更(嵌套调用防护)
self.new: list = []
self.dirty: list = []
self.deleted: list = []
def add(self, obj: object) -> None:
if self.fail_add:
raise RuntimeError("模拟写入失败")
self.events.append("add")
self.logs.append(obj) # type: ignore[arg-type]
def flush(self) -> None:
self.events.append("flush")
def commit(self) -> None:
if self.fail_commit:
raise RuntimeError("模拟提交失败")
self.events.append("commit")
def rollback(self) -> None:
self.events.append("rollback")
class _FrozenOperator:
"""只允许读取固定轮数属性的操作人桩。
模拟「rollback 后 ORM 对象过期」:若失败路径仍读取 operator 属性,
真实环境会触发额外 SELECT(且账号并发被删时抛 ObjectDeletedError),
此处直接以异常暴露该访问。audit 入口读 id + username 恰为 2 次。
"""
def __init__(self, max_reads: int = 2) -> None:
self._max_reads = max_reads
self._reads = 0
def _touch(self) -> None:
self._reads += 1
if self._reads > self._max_reads:
raise RuntimeError("进入审计后不应再访问操作人 ORM 属性")
@property
def id(self) -> int:
self._touch()
return 7
@property
def username(self) -> str:
self._touch()
return "admin"
def _operator() -> SimpleNamespace:
"""模拟已认证用户(audit 只读取 id 与 username 两个属性)。"""
return SimpleNamespace(id=7, username="admin")
def _record(**overrides: object) -> AuditRecord:
base: dict = {"module": "用户管理", "action": "新增", "summary": "新增用户 zhangsan"}
base.update(overrides)
return AuditRecord(**base)
def _audit(
stub: _StubSession,
*,
request_data: dict | None = None,
meta: RequestMeta | None = None,
):
return audit(
stub, # type: ignore[arg-type]
operator=_operator(),
record=_record(request_data=request_data),
meta=meta,
)
class TestSuccessPath:
"""正常退出:业务完成后记成功并独立提交。"""
def test_成功路径写入一条成功日志(self) -> None:
stub = _StubSession()
with _audit(stub):
pass
assert len(stub.logs) == 1
log = stub.logs[0]
assert log.success == 1
assert log.error_message is None
assert log.module == "用户管理"
assert log.action == "新增"
assert log.username == "admin"
assert log.user_id == 7
def test_日志提交发生在业务逻辑之后(self) -> None:
"""审计是业务的"后置动作":业务先 commit,日志再独立 commit。"""
stub = _StubSession()
with _audit(stub):
stub.events.append("business")
assert stub.events == ["business", "add", "flush", "commit"]
def test_请求元信息与耗时被写入(self) -> None:
stub = _StubSession()
meta = RequestMeta(
ip="10.0.0.8", user_agent="pytest-agent", method="POST", path="/api/v1/system/users"
)
with _audit(stub, meta=meta):
pass
log = stub.logs[0]
assert log.method == "POST"
assert log.path == "/api/v1/system/users"
assert log.ip == "10.0.0.8"
assert log.user_agent == "pytest-agent"
assert log.duration_ms is not None
assert log.duration_ms >= 0
def test_请求参数经脱敏后写入(self) -> None:
stub = _StubSession()
with _audit(stub, request_data={"username": "zhangsan", "password": "Secret@2026"}):
pass
request_data = stub.logs[0].request_data or ""
assert "Secret@2026" not in request_data
assert "zhangsan" in request_data
class TestFailurePath:
"""业务异常:先回滚、记失败、异常原样重抛。"""
def test_失败时原异常原样重抛(self) -> None:
stub = _StubSession()
with pytest.raises(BadRequestError, match="角色编码无效"), _audit(stub):
raise BadRequestError("角色编码无效")
def test_失败记录在回滚之后写入(self) -> None:
"""顺序断言:rollback 必须早于 add。颠倒时,来自数据库异常的失败
记录会因 session 待回滚而抛 PendingRollbackError,整条丢失。"""
stub = _StubSession()
with pytest.raises(BadRequestError), _audit(stub):
stub.events.append("business")
raise BadRequestError("用户名重复")
assert stub.events == ["business", "rollback", "add", "flush", "commit"]
log = stub.logs[0]
assert log.success == 0
assert log.error_message == "用户名重复"
def test_未预期异常不泄漏内部信息(self) -> None:
"""未预期异常的堆栈由全局处理器记入应用日志;审计表只落通用文案。"""
stub = _StubSession()
with pytest.raises(ValueError, match="内部实现细节"), _audit(stub):
raise ValueError("内部实现细节")
assert stub.logs[0].error_message == "服务端异常,请稍后重试"
class TestOperatorFrozen:
"""操作人标量在进入上下文时固化,之后(尤其 rollback 后)不得再触碰 ORM 对象。"""
def test_成功路径只读取一轮操作人属性(self) -> None:
stub = _StubSession()
with audit(stub, operator=_FrozenOperator(), record=_record()): # type: ignore[arg-type]
pass
assert stub.logs[0].username == "admin"
def test_失败路径不再触碰操作人对象(self) -> None:
"""回归:此前失败路径在 rollback 之后读 operator.id/username ——
真实环境会触发额外 SELECT,账号被并发删除时 ObjectDeletedError
还会被兜底吞掉,最该留痕的失败审计静默丢失。"""
stub = _StubSession()
with (
pytest.raises(BadRequestError, match="用户不存在"),
audit(
stub, # type: ignore[arg-type]
operator=_FrozenOperator(),
record=_record(action="删除", summary="删除用户 x"),
),
):
raise BadRequestError("用户不存在")
assert stub.logs[0].user_id == 7
assert stub.logs[0].username == "admin"
class TestDirtySessionGuard:
"""进入审计时存在未提交变更 → 记 warning(防嵌套调用造成半提交)。"""
def test_脏会话触发警告(self, caplog: pytest.LogCaptureFixture) -> None:
stub = _StubSession()
stub.dirty.append(object())
with caplog.at_level(logging.WARNING), _audit(stub):
pass
assert any("未提交的变更" in message for message in caplog.messages)
def test_干净会话不告警(self, caplog: pytest.LogCaptureFixture) -> None:
stub = _StubSession()
with caplog.at_level(logging.WARNING), _audit(stub):
pass
assert not caplog.messages
class TestResilience:
"""审计失败不得影响业务。"""
def test_写入失败不外抛且业务继续(self) -> None:
stub = _StubSession()
stub.fail_add = True
reached = False
with _audit(stub):
reached = True
assert reached is True # 审计写入失败不打断业务流
def test_提交失败不外抛(self) -> None:
stub = _StubSession()
stub.fail_commit = True
with _audit(stub):
pass # 正常退出,不抛异常
def test_业务失败时审计失败不以审计异常替换原异常(self) -> None:
"""业务异常是用户看到的原因,不能因审计自身失败而被替换。"""
stub = _StubSession()
stub.fail_add = True
with pytest.raises(BadRequestError, match="业务错误"), _audit(stub):
raise BadRequestError("业务错误")
class TestSerializationEdge:
"""审计参数的序列化边界(细节覆盖见 test_audit_serialization)。"""
def test_请求参数可为None(self) -> None:
stub = _StubSession()
with _audit(stub, request_data=None):
pass
assert stub.logs[0].request_data is None
def test_不可JSON序列化的值被降级为字符串(self) -> None:
"""如 datetime 等对象经 default=str 降级,序列化不应抛异常。"""
stub = _StubSession()
with _audit(stub, request_data={"at": datetime(2026, 10, 10, 12, 0, 0)}):
pass
assert "2026-10-10" in (stub.logs[0].request_data or "")
+92
View File
@@ -0,0 +1,92 @@
"""日志查询模型单元测试。
守的是**时间范围校验**与**时区归一**:起始晚于结束时必须拒绝 —— 放过去
的话 SQL 条件互斥、查询恒为空,使用者会以为是"没有数据"而不是"条件写反了";
带时区的输入必须转换为本地 naive 时间 —— 不转换时 aware/naive 混用比较
抛 TypeError(穿透成 500),或偏移被驱动静默丢弃(查询窗口偏 8 小时)。
"""
from datetime import UTC, datetime
import pytest
from pydantic import ValidationError
from app.schemas.log import LoginLogQuery, OperationLogQuery
class TestTimeRange:
"""start_time ≤ end_time 校验(两类日志共用基类)。"""
def test_合法范围通过(self) -> None:
query = OperationLogQuery(
start_time=datetime(2026, 10, 1, 0, 0, 0), end_time=datetime(2026, 10, 10, 0, 0, 0)
)
assert query.start_time is not None
def test_起始晚于结束被拒(self) -> None:
with pytest.raises(ValidationError):
OperationLogQuery(start_time=datetime(2026, 10, 2), end_time=datetime(2026, 10, 1))
def test_登录日志同样校验(self) -> None:
with pytest.raises(ValidationError):
LoginLogQuery(start_time=datetime(2026, 10, 2), end_time=datetime(2026, 10, 1))
def test_边界相等通过(self) -> None:
moment = datetime(2026, 10, 1, 12, 0, 0)
assert OperationLogQuery(start_time=moment, end_time=moment).start_time == moment
def test_仅传一端不校验(self) -> None:
assert OperationLogQuery(start_time=datetime(2026, 10, 1)).end_time is None
assert OperationLogQuery(end_time=datetime(2026, 10, 1)).start_time is None
class TestQueryFields:
"""筛选字段边界。"""
def test_成功标记只能为0或1(self) -> None:
assert OperationLogQuery(success=0).success == 0
with pytest.raises(ValidationError):
OperationLogQuery(success=2)
def test_分页别名与偏移(self) -> None:
query = OperationLogQuery(pageNum=2, pageSize=50)
assert query.offset == 50
def test_操作日志筛选字段(self) -> None:
query = OperationLogQuery(module="用户管理", action="删除", username="adm")
assert (query.module, query.action, query.username) == ("用户管理", "删除", "adm")
def test_登录日志按IP精确筛选(self) -> None:
assert LoginLogQuery(ip="10.0.0.1").ip == "10.0.0.1"
class TestTimezoneNormalization:
"""时区归一。
回归背景:混合 aware/naive 比较曾抛 TypeError 穿透成 500;aware 直传
曾被驱动静默丢弃偏移(查询窗口整体偏 8 小时且无提示)。
"""
def test_utc后缀被转换为本地时间(self) -> None:
"""Z 后缀 = UTC 零点 = 东八区本地 08:00(与库列的本地时间口径对齐)。"""
query = OperationLogQuery(start_time="2026-10-01T00:00:00Z")
assert query.start_time == datetime(2026, 10, 1, 8, 0, 0)
def test_混合aware与naive不再抛比较错误(self) -> None:
query = OperationLogQuery(
start_time=datetime(2026, 10, 1, 0, 0, 0),
end_time=datetime(2026, 10, 2, 0, 0, 0, tzinfo=UTC),
)
assert query.end_time is not None
assert query.end_time.tzinfo is None # 已摘除
def test_归一后范围校验仍按本地口径生效(self) -> None:
with pytest.raises(ValidationError):
OperationLogQuery(
start_time="2026-10-02T00:00:00Z", # 本地 10-02 08:00
end_time="2026-10-01 12:00:00", # 本地 10-01 12:00,早于起始
)
def test_naive时间视为本地原样保留(self) -> None:
moment = datetime(2026, 10, 1, 12, 0, 0)
assert OperationLogQuery(start_time=moment).start_time == moment
+10 -2
View File
@@ -11,7 +11,15 @@ from sqlalchemy.dialects import mysql
from app.models import Base
EXPECTED_TABLES = {"users", "roles", "user_roles", "permissions", "role_permissions", "login_logs"}
EXPECTED_TABLES = {
"users",
"roles",
"user_roles",
"permissions",
"role_permissions",
"login_logs",
"operation_logs",
}
def _ddl(table_name: str) -> str:
@@ -101,7 +109,7 @@ class TestTimestampColumns:
def test_带时间戳的表含ON_UPDATE子句(self, table_name: str) -> None:
assert "ON UPDATE CURRENT_TIMESTAMP" in _ddl(table_name)
@pytest.mark.parametrize("table_name", ["login_logs"])
@pytest.mark.parametrize("table_name", ["login_logs", "operation_logs"])
def test_仅追加的日志表不含ON_UPDATE子句(self, table_name: str) -> None:
"""日志是不可变记录,没有"更新时间"语义。"""
assert "ON UPDATE CURRENT_TIMESTAMP" not in _ddl(table_name)
+98
View File
@@ -0,0 +1,98 @@
"""角色请求模型单元测试。
守的是**角色编码的格式红线**:编码会进入授权判断(`ROLE_ADMIN` 按编码
比对)与操作日志,必须限定为小写字母开头的小写/数字/下划线组合 ——
放开大写或中文会让对照与检索困难,超长则与列宽(32)不符。
"""
import pytest
from pydantic import ValidationError
from app.schemas.role import RoleCreateIn, RoleQuery, RoleStatusIn, RoleUpdateIn
def _create(**overrides: object) -> RoleCreateIn:
base: dict[str, object] = {"role_code": "finance", "role_name": "财务"}
base.update(overrides)
return RoleCreateIn(**base)
class TestRoleCodePattern:
"""编码规则:^[a-z][a-z0-9_]{1,31}$"""
def test_合法编码通过(self) -> None:
assert _create().role_code == "finance"
def test_下划线与数字通过(self) -> None:
assert _create(role_code="sales_01").role_code == "sales_01"
def test_大写字母被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="Finance")
def test_中文被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="财务")
def test_数字开头被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="1abc")
def test_下划线开头被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="_abc")
def test_单字符被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="a")
def test_超长被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="a" * 33)
def test_首尾空白被自动去除(self) -> None:
assert _create(role_code=" finance ").role_code == "finance"
class TestRoleUpdate:
"""编辑模型:不含 role_code。"""
def test_编辑不接受role_code(self) -> None:
"""编码是授权判定锚点,编辑接口有意不接收;多余字段被忽略而非报错
(前端表单可能整份回填提交)。"""
payload = RoleUpdateIn(role_name="财务二组", role_code="hacked")
assert not hasattr(payload, "role_code")
def test_名称为空被拒(self) -> None:
with pytest.raises(ValidationError):
RoleUpdateIn(role_name="")
def test_排序负数被拒(self) -> None:
with pytest.raises(ValidationError):
RoleUpdateIn(role_name="财务", sort_order=-1)
class TestRoleStatusAndQuery:
"""状态与查询模型。"""
def test_状态只能为0或1(self) -> None:
assert RoleStatusIn(status=0).status == 0
assert RoleStatusIn(status=1).status == 1
with pytest.raises(ValidationError):
RoleStatusIn(status=2)
def test_查询默认分页(self) -> None:
query = RoleQuery()
assert query.page_num == 1
assert query.page_size == 10
assert query.keyword is None
assert query.status is None
def test_分页别名照常工作(self) -> None:
"""pageNum/pageSize 是前端契约(PageQuery 的 alias)。"""
query = RoleQuery(pageNum=3, pageSize=5)
assert query.offset == 10
def test_关键词超长被拒(self) -> None:
with pytest.raises(ValidationError):
RoleQuery(keyword="a" * 65)
+38 -5
View File
@@ -5,16 +5,18 @@
-- 数据卷已存在时修改本文件不会生效,需要:
-- docker compose -f docker/docker-compose.dev.yml down -v && up -d
--
-- 内容 = V1 迁移的完整结构(额外带 DROP,开发环境允许重置)
-- 与 db/migration/V1__init_rbac_and_login_logs.sql 必须保持一致,
-- 内容 = db/migration/ 下**全部迁移**的最终结构(额外带 DROP,开发环境允许重置)
-- 必须与 db/migration/V*.sql 的合集保持一致,
-- 由 backend/tests/integration/test_schema_sync.py 自动校验,防止两份 DDL 漂移
--
-- 结尾写入 alembic_version 记录,使 `alembic upgrade head` 在本库上成为空操作
-- 结尾写入 alembic_version 记录(**须等于当前 head**,否则开发库重建后
-- 执行 alembic upgrade head 会重复建表并报 1050 Table already exists)
-- ============================================================================
SET NAMES utf8mb4;
-- 删除顺序遵循外键依赖:先删子表
DROP TABLE IF EXISTS `operation_logs`;
DROP TABLE IF EXISTS `role_permissions`;
DROP TABLE IF EXISTS `user_roles`;
DROP TABLE IF EXISTS `login_logs`;
@@ -134,6 +136,37 @@ CREATE TABLE user_roles (
CREATE INDEX idx_user_roles_role_id ON user_roles (role_id);
-- ---------------------------------------------------------------------------
-- 操作日志表(V2 迁移)
-- ---------------------------------------------------------------------------
CREATE TABLE operation_logs (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
user_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)',
username VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余存储便于追溯)',
module VARCHAR(64) NOT NULL COMMENT '所属模块,如 用户管理',
action VARCHAR(32) NOT NULL COMMENT '操作类型:新增/修改/删除/导出',
summary VARCHAR(255) NOT NULL COMMENT '操作摘要(中文,供人阅读)',
method VARCHAR(8) COMMENT 'HTTP 方法',
path VARCHAR(255) COMMENT '请求路径',
ip VARCHAR(45) COMMENT '客户端IP(45位兼容IPv6)',
user_agent VARCHAR(255) COMMENT '客户端User-Agent(截断存储)',
request_data TEXT COMMENT '请求参数(已脱敏)',
success TINYINT NOT NULL COMMENT '是否成功:1成功 0失败' DEFAULT '0',
error_message VARCHAR(500) COMMENT '失败原因(写入方须截断)',
duration_ms INTEGER COMMENT '处理耗时(毫秒)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_operation_logs PRIMARY KEY (id),
CONSTRAINT fk_operation_logs_user_id FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE SET NULL
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='操作日志表' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_operation_logs_create_time ON operation_logs (create_time);
CREATE INDEX idx_operation_logs_module ON operation_logs (module);
CREATE INDEX idx_operation_logs_user_id ON operation_logs (user_id);
CREATE INDEX idx_operation_logs_username ON operation_logs (username);
-- ---------------------------------------------------------------------------
-- 字典数据:角色(与 V1 迁移中的初始化数据一致)
-- ---------------------------------------------------------------------------
@@ -142,11 +175,11 @@ INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('admin
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('user', '普通用户', '业务操作', 2);
-- ---------------------------------------------------------------------------
-- 迁移版本记录:标记 V1 已应用,使 alembic upgrade head 成为空操作
-- 迁移版本记录:须等于当前 head,使 alembic upgrade head 成为空操作
-- ---------------------------------------------------------------------------
CREATE TABLE alembic_version (
version_num VARCHAR(32) NOT NULL,
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
INSERT INTO alembic_version (version_num) VALUES ('6165691326d4');
INSERT INTO alembic_version (version_num) VALUES ('a45a8087831b');
+32
View File
@@ -0,0 +1,32 @@
-- Running upgrade 6165691326d4 -> a45a8087831b
CREATE TABLE operation_logs (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
user_id BIGINT UNSIGNED COMMENT '操作人ID(账号删除后置空)',
username VARCHAR(64) NOT NULL COMMENT '操作人用户名(冗余存储便于追溯)',
module VARCHAR(64) NOT NULL COMMENT '所属模块,如 用户管理',
action VARCHAR(32) NOT NULL COMMENT '操作类型:新增/修改/删除/导出',
summary VARCHAR(255) NOT NULL COMMENT '操作摘要(中文,供人阅读)',
method VARCHAR(8) COMMENT 'HTTP 方法',
path VARCHAR(255) COMMENT '请求路径',
ip VARCHAR(45) COMMENT '客户端IP(45位兼容IPv6)',
user_agent VARCHAR(255) COMMENT '客户端User-Agent(截断存储)',
request_data TEXT COMMENT '请求参数(已脱敏)',
success TINYINT NOT NULL COMMENT '是否成功:1成功 0失败' DEFAULT '0',
error_message VARCHAR(500) COMMENT '失败原因(写入方须截断)',
duration_ms INTEGER COMMENT '处理耗时(毫秒)',
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_operation_logs PRIMARY KEY (id),
CONSTRAINT fk_operation_logs_user_id FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE SET NULL
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='操作日志表' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_operation_logs_create_time ON operation_logs (create_time);
CREATE INDEX idx_operation_logs_module ON operation_logs (module);
CREATE INDEX idx_operation_logs_user_id ON operation_logs (user_id);
CREATE INDEX idx_operation_logs_username ON operation_logs (username);
UPDATE alembic_version SET version_num='a45a8087831b' WHERE alembic_version.version_num = '6165691326d4';
Binary file not shown.
+25 -9
View File
@@ -1,6 +1,6 @@
# 前端
> AI摘要:衍水集团营销人员工资管理系统的前端。Vue 3 + TypeScript + Vite + Element Plus + Pinia。关键文件 `src/api/request.ts`(统一请求封装)、`src/router/index.ts`(路由守卫)、`src/store/user.ts`(会话状态)、`src/views/login/index.vue`(登录页)。当前完成登录链路,业务模块页面待接入。
> AI摘要:衍水集团营销人员工资管理系统的前端。Vue 3 + TypeScript + Vite + Element Plus + Pinia。关键文件 `src/api/request.ts`(统一请求封装)、`src/components/layout/BasicLayout.vue`(应用布局与导航)、`src/router/index.ts`(路由守卫)、`src/store/user.ts`(会话状态)。当前完成登录链路与系统管理页面(用户/角色/日志查询),业务模块页面待接入。
## 职责
@@ -26,18 +26,26 @@
src/
├── api/
│ ├── request.ts # axios 实例、拦截器、统一信封解包
│ └── auth.ts # 认证接口封装与类型
├── assets/styles/ # 全局样式
├── components/ # 通用组件(待补充)
├── composables/ # 组合式函数(待补充)
├── router/index.ts # 路由表与全局守卫
│ ├── auth.ts # 认证接口封装与类型
│ ├── users.ts / roles.ts / logs.ts # 系统管理接口封装
│ └── types.ts # 分页等公共类型
├── assets/styles/ # 全局样式与品牌 token(:root 的 --ys-*)
├── components/
│ ├── layout/ # BasicLayout(侧边栏/顶栏/面包屑)、AppMenu
│ └── table/ # StatusTag(0/1 状态标签)、AppPagination
├── composables/
│ ├── useAppMenu.ts # 菜单从路由表派生(meta.title/group/order/requiresAdmin)
│ ├── useConfirmAction.ts # 确认框→执行→提示→刷新 动作流(启停/删除共用)
│ └── usePagedList.ts # 分页列表状态(请求序号防竞态、删除后页码回退)
├── router/index.ts # 路由表与全局守卫(四层,含 requiresAdmin)
├── store/
│ ├── index.ts # Pinia 实例
│ └── user.ts # 会话状态:token、用户信息、登录/登出
├── utils/storage.ts # localStorage 读写
├── utils/ # storage/format/validate/constants/error(handledError)
└── views/
├── login/ # 登录页
└── dashboard/ # 首页(登录链路验证页)
├── dashboard/ # 首页(登录链路验证页)
└── system/ # 系统管理:users(用户)/ roles(角色)/ logs(日志查询)
```
## 启动
@@ -85,6 +93,8 @@ pnpm build # 类型检查 + 生产构建
**字体**:品牌名用**宋体**(衬线,拟正式、拟账册),正文用系统无衬线,数字与验证码用等宽。
**界面不引入网络字体**——内网环境字体加载不可靠,靠字重、字距与留白做层次更稳妥。新页面请沿用这套字号节奏。
**落地方式**:品牌色已提升为全局 CSS 变量(`src/assets/styles/index.css` 的 `:root`,`--ys-*` 前缀),登录页与布局、系统管理页面共用;全局还把 `--el-color-primary` 指向赭金,Element Plus 组件默认走品牌色。登录页 scoped 里的同名局部变量只是全局 token 的别名。
**签名元素**:品牌面板的水纹层理线,越往下越密(如潜向深处),载入时用 `stroke-dashoffset` 逐条画出——**一个统一的入场动作,而不是散落的特效**。
**构图**:品牌内容(名称 / 金线 / 系统名 / 主标语 / 环节行)**垂直居中聚成一块**,配一层柔和径向光晕作落点。
@@ -104,14 +114,20 @@ pnpm build # 类型检查 + 生产构建
## 端到端测试
`e2e/login_flow.py` 用 Playwright 驱动真实浏览器,覆盖 8 项:路由守卫、验证码渲染、失败后自动刷新、登录跳转、用户信息展示、刷新恢复会话、登出、登出后令牌失效。
`e2e/` 下两个脚本用 Playwright 驱动真实浏览器,公共工具抽在 `e2e/common.py`(登录动作、从 Redis 取验证码答案、提示等待、失败断言与控制台错误收集):
- `login_flow.py`(8 项):路由守卫、验证码渲染、失败后自动刷新、登录跳转、用户信息展示、刷新恢复会话、登出、登出后令牌失效
- `system_management.py`(13 项):菜单导航、用户增删改查(含重名负例)、重置密码、启用/禁用、角色保护与 CRUD、操作日志留痕与详情懒加载脱敏、登录日志、普通用户菜单隐藏与路由守卫
```bash
# 前置:MySQL/Redis 容器、后端、前端 dev server 均在运行
python e2e/login_flow.py
# 管理员密码经环境变量传入(不写死在源码里)
E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
```
需要 Python `playwright` 包与 chromium(`python -m playwright install chromium`)。
脚本自建的测试数据(`e2e_` 前缀)在正常流程内清理;中途失败会残留在开发库。
## 常见问题
+153
View File
@@ -0,0 +1,153 @@
"""端到端测试公共工具。
抽取自 `login_flow.py`,供各流程脚本复用:环境常量、Redis 读取(取验证码
答案,仅自动化测试可用)、完整登录动作、失败处理与控制台错误收集。
约定:断言失败抛 `E2EFailure`(消息格式 `步骤:原因`),由各脚本的 `main()`
统一截图并返回退出码 1。
"""
import os
import subprocess
import sys
import time
from urllib.parse import urlparse
from playwright.sync_api import Page
BASE_URL = "http://127.0.0.1:5173"
REDIS_CONTAINER = "ys-redis-dev"
# Windows 终端默认 cp936,✓/✗ 等符号会抛 UnicodeEncodeError 而非打印失败原因。
# 与后端 core/logging.py 的处理一致,显式重配标准输出编码。
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
class E2EFailure(Exception):
"""步骤断言失败。"""
def redis_password() -> str:
"""从 docker/.env 读取 Redis 密码。
测试脚本无法用应用配置(那需要后端 venv),故直接读环境文件。
"""
if value := os.environ.get("REDIS_PASSWORD"):
return value
env_file = os.path.join(os.path.dirname(__file__), "..", "..", "docker", ".env")
with open(env_file, encoding="utf-8") as handle:
for line in handle:
if line.startswith("REDIS_PASSWORD="):
return line.split("=", 1)[1].strip()
raise RuntimeError("未能从 docker/.env 读取 REDIS_PASSWORD")
def redis_get(key: str) -> str | None:
"""读取 Redis 键值。仅用于自动化测试 —— 生产当然不能这样取验证码答案。"""
result = subprocess.run(
["docker", "exec", REDIS_CONTAINER, "redis-cli", "-a", redis_password(),
"--no-auth-warning", "get", key],
capture_output=True, text=True, encoding="utf-8", check=False,
)
return result.stdout.strip() or None
def fail(step: str, detail: str) -> None:
"""断言失败:抛异常而非进程退出 —— 由 main() 统一截图后再退出。"""
raise E2EFailure(f"{step}:{detail}")
def current_path(page: Page) -> str:
"""当前 URL 的路径部分。
比较路径**不能**用 `**/dashboard` 这类 glob —— 它会把
`/login?redirect=/dashboard` 也匹配上,"登录失败"会被误判为成功。
"""
return urlparse(page.url).path
def collect_console_errors(page: Page, sink: list[str] | None = None) -> list[str]:
"""注册控制台错误收集(失败诊断时打印最近几条)。
:param sink: 可选的外部列表(如脚本的模块级变量,供 main() 的失败路径读取)
"""
errors = sink if sink is not None else []
page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None)
page.on("pageerror", lambda exc: errors.append(str(exc)))
return errors
def toast_text(page: Page) -> str:
"""当前显示的全局提示文本(el-message),无提示时为空串。"""
locator = page.locator(".el-message")
return locator.first.text_content().strip() if locator.count() else ""
def wait_toast(page: Page, expected: str, timeout_ms: int = 5000) -> str:
"""等待包含 expected 的全局提示出现,返回其文本(超时返回当前文本)。
直接读 `toast_text` 会在「上一条提示尚未消失、新提示尚未出现」的窗口里
读到旧文本,产生误判;轮询等待把这类时序问题挡在断言之外。
"""
deadline = time.monotonic() + timeout_ms / 1000
text = ""
while time.monotonic() < deadline:
text = toast_text(page)
if expected in text:
return text
page.wait_for_timeout(200)
return text
def expect_toast(page: Page, expected: str, step: str, timeout_ms: int = 5000) -> None:
"""断言出现了包含指定文本的全局提示。"""
text = wait_toast(page, expected, timeout_ms)
if expected not in text:
fail(step, f"未出现提示「{expected}」,当前提示:{text or '(无)'}")
def login(page: Page, username: str, password: str) -> None:
"""完整登录:打开登录页 → 从 Redis 取验证码答案 → 提交 → 等待进入首页。
验证码 id 经响应监听捕获(前端把它内联在响应体里,不是 URL 参数)。
"""
captcha_ids: list[str] = []
def on_response(response) -> None:
if "/api/v1/auth/captcha" not in response.url or response.status != 200:
return
try:
captcha_ids.append(response.json()["data"]["captcha_id"])
except Exception:
# 页面导航后响应体已不可读(会抛协议错误),属可忽略的噪声
pass
page.on("response", on_response)
try:
page.goto(f"{BASE_URL}/login", wait_until="networkidle")
for _ in range(50):
if captcha_ids:
break
page.wait_for_timeout(100)
if not captcha_ids:
fail("登录", "未捕获到验证码请求")
captcha_id = captcha_ids[-1]
answer = redis_get(f"ys:captcha:{captcha_id}")
if not answer:
fail("登录", f"Redis 中未找到验证码 ys:captcha:{captcha_id}")
page.fill('input[autocomplete="username"]', username)
page.fill('input[autocomplete="current-password"]', password)
page.fill('input[autocomplete="off"]', answer)
page.click("button:has-text('登 录')")
page.wait_for_timeout(2000)
if current_path(page) != "/dashboard":
detail = f"未跳转到首页,当前 {page.url}"
if message := toast_text(page):
detail += f",提示:{message}"
fail("登录", detail)
page.wait_for_load_state("networkidle")
finally:
page.remove_listener("response", on_response)
+33 -69
View File
@@ -1,4 +1,4 @@
"""浏览器端端到端验证:登录 + 图形验证码。
"""浏览器端到端验证:登录 + 图形验证码。
验证的是**只有真实浏览器才能暴露**的问题:
@@ -20,53 +20,28 @@
cd frontend && python e2e/login_flow.py
依赖:Python `playwright` 包与 chromium(`python -m playwright install chromium`)。
公共工具见 `e2e/common.py`。
"""
import os
import subprocess
import sys
from playwright.sync_api import Page, sync_playwright
BASE_URL = "http://127.0.0.1:5173"
from common import (
BASE_URL,
E2EFailure,
collect_console_errors,
current_path,
fail,
redis_get,
toast_text,
)
USERNAME = "admin"
PASSWORD = "Admin@2026"
REDIS_CONTAINER = "ys-redis-dev"
def _redis_password() -> str:
"""从 docker/.env 读取 Redis 密码。
测试脚本无法用应用配置(那需要后端 venv),故直接读环境文件。
"""
if value := os.environ.get("REDIS_PASSWORD"):
return value
env_file = os.path.join(os.path.dirname(__file__), "..", "..", "docker", ".env")
with open(env_file, encoding="utf-8") as handle:
for line in handle:
if line.startswith("REDIS_PASSWORD="):
return line.split("=", 1)[1].strip()
raise RuntimeError("未能从 docker/.env 读取 REDIS_PASSWORD")
def redis_get(key: str) -> str | None:
"""读取 Redis 键值。仅用于自动化测试 —— 生产当然不能这样取验证码答案。"""
result = subprocess.run(
["docker", "exec", REDIS_CONTAINER, "redis-cli", "-a", _redis_password(),
"--no-auth-warning", "get", key],
capture_output=True, text=True, encoding="utf-8", check=False,
)
return result.stdout.strip() or None
def _fail(step: str, detail: str) -> None:
print(f" ✗ {step}:{detail}")
sys.exit(1)
def run(page: Page) -> None:
captured_captcha_ids: list[str] = []
console_errors: list[str] = []
console_errors = collect_console_errors(page)
def on_response(response) -> None:
if "/api/v1/auth/captcha" not in response.url or response.status != 200:
@@ -79,27 +54,11 @@ def run(page: Page) -> None:
pass
page.on("response", on_response)
page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
page.on("pageerror", lambda exc: console_errors.append(str(exc)))
def at_dashboard() -> bool:
"""判断是否真的在首页。
不能用 `**/dashboard` 这类 glob —— 它会把
`/login?redirect=/dashboard` 也匹配上,导致"登录失败"被误判为成功。
"""
from urllib.parse import urlparse
return urlparse(page.url).path == "/dashboard"
def toast_text() -> str:
locator = page.locator(".el-message")
return locator.first.text_content().strip() if locator.count() else ""
# ---- 1. 路由守卫:未登录访问首页应被拦回登录页 ----
page.goto(BASE_URL, wait_until="networkidle")
if "/login" not in page.url:
_fail("路由守卫", f"未登录访问首页未被拦截,当前 URL={page.url}")
fail("路由守卫", f"未登录访问首页未被拦截,当前 URL={page.url}")
print(f"✓ 1. 路由守卫 未登录访问 / → 重定向到 {page.url}")
# ---- 2. 验证码渲染(证明 浏览器→Vite代理→uvicorn→Redis 全链路通)----
@@ -107,9 +66,9 @@ def run(page: Page) -> None:
image.wait_for(state="visible", timeout=10_000)
src = image.get_attribute("src") or ""
if not src.startswith("data:image/png;base64,"):
_fail("验证码渲染", f"图片 src 前缀异常:{src[:40]}")
fail("验证码渲染", f"图片 src 前缀异常:{src[:40]}")
if len(src) < 1000:
_fail("验证码渲染", f"图片数据过小({len(src)}),可能未真正渲染")
fail("验证码渲染", f"图片数据过小({len(src)}),可能未真正渲染")
print(f"✓ 2. 验证码渲染 PNG base64 长度 {len(src)}(代理链路已通)")
# ---- 3. 输错验证码 → 应提示且验证码自动刷新 ----
@@ -121,38 +80,38 @@ def run(page: Page) -> None:
page.wait_for_timeout(1500)
if "/login" not in page.url:
_fail("失败处理", f"登录失败后不应离开登录页,当前 URL={page.url}")
fail("失败处理", f"登录失败后不应离开登录页,当前 URL={page.url}")
second_id = captured_captcha_ids[-1]
if second_id == first_id:
_fail("验证码刷新", "登录失败后验证码未刷新 —— 第二次提交必然报「验证码错误」")
fail("验证码刷新", "登录失败后验证码未刷新 —— 第二次提交必然报「验证码错误」")
print("✓ 3. 失败自动刷新 验证码已换新(GETDEL 消费后的必要处理)")
# ---- 4. 正确登录 ----
answer = redis_get(f"ys:captcha:{second_id}")
if not answer:
_fail("取验证码", f"Redis 中未找到 ys:captcha:{second_id}")
fail("取验证码", f"Redis 中未找到 ys:captcha:{second_id}")
page.fill('input[autocomplete="off"]', answer)
page.click("button:has-text('登 录')")
page.wait_for_timeout(2000)
if not at_dashboard():
if current_path(page) != "/dashboard":
detail = f"仍停留在 {page.url}"
if message := toast_text():
if message := toast_text(page):
detail += f",提示:{message}"
if console_errors:
detail += f",控制台错误:{console_errors[:3]}"
_fail("登录", detail)
fail("登录", detail)
page.wait_for_load_state("networkidle")
print(f"✓ 4. 登录成功 跳转到 {page.url}")
if USERNAME not in page.content():
_fail("用户信息", "首页未显示当前用户名")
fail("用户信息", "首页未显示当前用户名")
print("✓ 5. 用户信息 首页已展示当前账号")
# ---- 6. 刷新页面 → 守卫应调用 /me 恢复会话 ----
page.reload(wait_until="networkidle")
if "/dashboard" not in page.url:
_fail("会话恢复", f"刷新后被踢回登录页,当前 URL={page.url}")
if current_path(page) != "/dashboard":
fail("会话恢复", f"刷新后被踢回登录页,当前 URL={page.url}")
print("✓ 6. 会话恢复 刷新页面后仍保持登录(/me 恢复成功)")
# ---- 7. 登出 → 令牌立即失效 ----
@@ -162,8 +121,8 @@ def run(page: Page) -> None:
page.goto(f"{BASE_URL}/dashboard", wait_until="networkidle")
page.wait_for_timeout(1000)
if at_dashboard():
_fail("登出失效", "登出后仍能访问受保护页面")
if current_path(page) == "/dashboard":
fail("登出失效", "登出后仍能访问受保护页面")
print("✓ 8. 令牌失效 登出后直接访问 /dashboard 被拦回登录页")
@@ -174,9 +133,14 @@ def main() -> int:
page.set_default_timeout(10_000)
try:
run(page)
except E2EFailure as exc:
page.screenshot(path="e2e/failure.png", full_page=True)
print(f"\n ✗ {exc}")
print(" 已保存截图 e2e/failure.png")
return 1
except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因
page.screenshot(path="e2e/failure.png", full_page=True)
print(f"\n✗ 执行中断:{type(exc).__name__}: {exc}")
print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}")
print(" 已保存截图 e2e/failure.png")
return 1
finally:
+401
View File
@@ -0,0 +1,401 @@
"""系统管理端到端验证:用户 / 角色 / 日志 / 权限。
覆盖的链路(只有真实浏览器才能验证的组合):
- 侧边菜单导航与「系统管理」分组
- 用户增删改查:新建(含重名负例)、编辑、重置密码、启用/禁用、删除
- 角色保护:内置 admin 行的禁用/删除按钮禁用;角色 CRUD 全流程
- 操作日志:写操作后可按条件查到记录,展开详情懒加载出脱敏后的请求参数
- 登录日志:能查到当前会话的成功登录
- 权限:普通用户看不到「系统管理」菜单,直连管理页面被守卫弹回首页
**运行前置**(缺一不可)::
docker compose -f docker/docker-compose.dev.yml up -d # MySQL + Redis
cd backend && uv run uvicorn app.main:app --port 8000 # 后端
cd frontend && pnpm dev # 前端
管理员密码经环境变量传入(不写死在源码里)::
cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
脚本自建的测试数据(`e2e_` 前缀)在流程内清理;中途失败会残留在开发库,
可用后端集成测试的前缀清理或手工删除。公共工具见 `e2e/common.py`。
"""
import os
import time
from playwright.sync_api import Page, sync_playwright
from common import (
BASE_URL,
E2EFailure,
collect_console_errors,
current_path,
expect_toast,
fail,
login,
toast_text,
wait_toast,
)
ADMIN = "admin"
E2E_PASSWORD = "E2ePwd@2026"
RESET_PASSWORD = "E2eReset@2026"
SUFFIX = str(int(time.time()))
NEW_USERNAME = f"e2e_user_{SUFFIX}"
VIEWER_USERNAME = f"e2e_viewer_{SUFFIX}"
NEW_ROLE_CODE = f"e2e_role_{SUFFIX}"
# 控制台错误收集(失败诊断):模块级列表,main() 的失败路径也要读取
console_errors: list[str] = []
# ==================== 页面操作辅助 ====================
def open_menu(page: Page, title: str) -> None:
"""点击侧边菜单项,必要时先展开「系统管理」分组。
分组已展开时再次点击会收拢,因此先判断菜单项是否已经可见。
"""
menu = page.locator(".app-menu")
item = menu.get_by_text(title, exact=True)
if not item.is_visible():
menu.get_by_text("系统管理", exact=True).click()
page.wait_for_timeout(400)
item.click()
page.wait_for_timeout(800)
def fill_filter(page: Page, label: str, value: str) -> None:
"""在**当前可见**的筛选表单中按标签填写输入框。
选择器一律带 `:visible` —— 日志页两个 tab 各有一个表单(非激活 tab
用 v-show 隐藏),不带可见性过滤会命中隐藏表单的元素。
"""
form_item = page.locator(".filter-form:visible .el-form-item", has_text=label).first
form_item.locator("input").first.fill(value)
def select_filter_option(page: Page, label: str, option: str) -> None:
"""展开可见筛选表单中指定标签的下拉并选择选项。
标签用 `:text-is` 精确匹配 —— 子串匹配会让「操作」命中「操作人」。
"""
item = page.locator(
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
)
item.locator(".el-select").click()
page.wait_for_timeout(300)
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=option).first.click()
page.wait_for_timeout(200)
def click_query(page: Page) -> None:
"""点击可见筛选表单中的查询按钮。"""
page.locator(".filter-form:visible button:has-text('查询')").first.click()
page.wait_for_timeout(800)
def table_rows(page: Page):
"""当前可见的表格行(日志页非激活 tab 的表格隐藏,需过滤)。"""
return page.locator(".el-table__body .el-table__row:visible")
def confirm_message_box(page: Page, button_text: str) -> None:
"""点击确认框(ElMessageBox)中的指定按钮。"""
page.locator(".el-message-box").locator(f"button:has-text('{button_text}')").click()
page.wait_for_timeout(800)
def visible_dialog(page: Page):
"""当前可见的对话框(同页存在多个 dialog 实例,按可见性取)。"""
return page.locator(".el-dialog:visible").last
def create_user_via_ui(
page: Page, username: str, real_name: str, expect_success: bool = True
) -> None:
"""经界面新增用户(角色固定选择「普通用户」)。"""
page.click("button:has-text('新增用户')")
page.wait_for_timeout(500)
dialog = visible_dialog(page)
dialog.locator('input[placeholder="登录用户名,创建后不可修改"]').fill(username)
dialog.locator('input[placeholder="8–72 字节(中文一个字符约 3 字节)"]').fill(E2E_PASSWORD)
dialog.locator('input[placeholder="真实姓名"]').fill(real_name)
# 角色多选:展开下拉后选择「普通用户」
dialog.locator(".el-select").click()
page.wait_for_timeout(300)
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text="普通用户").first.click()
page.wait_for_timeout(200)
dialog.locator(".el-dialog__header").click() # 收起下拉
page.wait_for_timeout(200)
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
if expect_success:
expect_toast(page, "用户已创建", "新增用户")
page.wait_for_timeout(600)
def find_user_row(page: Page, username: str):
"""在用户列表中按用户名筛选并返回目标行。"""
fill_filter(page, "用户名", username)
click_query(page)
return table_rows(page).filter(has_text=username).first
# ==================== 流程步骤 ====================
def step_user_create(page: Page, admin_password: str) -> None:
"""登录、菜单导航、新增用户与重名负例。"""
login(page, ADMIN, admin_password)
open_menu(page, "用户管理")
if current_path(page) != "/system/users":
fail("菜单导航", f"未进入用户管理页,当前 {page.url}")
print("✓ 1. 菜单导航 经侧边菜单进入用户管理")
create_user_via_ui(page, NEW_USERNAME, "E2E测试")
row = find_user_row(page, NEW_USERNAME)
if row.count() == 0:
fail("新增用户", f"列表中未找到新建的 {NEW_USERNAME}")
print(f"✓ 2. 新增用户 创建 {NEW_USERNAME} 并可在列表查到")
create_user_via_ui(page, NEW_USERNAME, "E2E重复", expect_success=False)
# 用 wait_toast 而非直读:上一条「用户已创建」提示尚在停留窗口内时,
# 直读会命中它造成偶发误判
if "已存在" not in wait_toast(page, "已存在"):
fail("重名负例", f"重复用户名未提示已存在,当前提示:{toast_text(page) or '(无)'}")
visible_dialog(page).locator(".el-dialog__footer button:has-text('取消')").click()
page.wait_for_timeout(500)
print("✓ 3. 重名负例 重复用户名被拒并给出中文提示")
def step_user_edit_and_status(page: Page) -> None:
"""编辑用户、重置密码、启用/禁用。"""
row = find_user_row(page, NEW_USERNAME)
row.locator("button:has-text('编辑')").click()
page.wait_for_timeout(500)
dialog = visible_dialog(page)
dialog.locator('input[placeholder="真实姓名"]').fill("E2E已改名")
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "已保存", "编辑用户")
row = find_user_row(page, NEW_USERNAME)
if "E2E已改名" not in row.inner_text():
fail("编辑用户", "列表未显示修改后的姓名")
print("✓ 4. 编辑用户 姓名修改生效")
row = find_user_row(page, NEW_USERNAME)
row.locator("button:has-text('重置密码')").click()
page.wait_for_timeout(500)
dialog = visible_dialog(page)
dialog.locator('input[placeholder="8–72 字节(中文一个字符约 3 字节)"]').fill(RESET_PASSWORD)
dialog.locator(".el-dialog__footer button:has-text('确认重置')").click()
expect_toast(page, "密码已重置", "重置密码")
page.wait_for_timeout(600)
print("✓ 5. 重置密码 提示该用户需重新登录")
row = find_user_row(page, NEW_USERNAME)
row.locator("button:has-text('禁用')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "禁用")
expect_toast(page, "已禁用", "禁用用户")
row = find_user_row(page, NEW_USERNAME)
if "禁用" not in row.inner_text():
fail("禁用用户", "列表中状态未更新为禁用")
row.locator("button:has-text('启用')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "启用")
expect_toast(page, "已启用", "启用用户")
print("✓ 6. 启用/禁用 状态切换与后端联动正常")
def step_role_management(page: Page) -> None:
"""角色保护与角色 CRUD。"""
open_menu(page, "角色管理")
if current_path(page) != "/system/roles":
fail("菜单导航", f"未进入角色管理页,当前 {page.url}")
admin_row = table_rows(page).filter(has_text="系统管理员").first
if not admin_row.locator("button:has-text('禁用')").is_disabled():
fail("角色保护", "内置 admin 角色的禁用按钮未禁用")
if not admin_row.locator("button:has-text('删除')").is_disabled():
fail("角色保护", "内置 admin 角色的删除按钮未禁用")
print("✓ 7. 角色保护 内置 admin 行的禁用/删除按钮均禁用")
page.click("button:has-text('新增角色')")
page.wait_for_timeout(500)
dialog = visible_dialog(page)
dialog.locator('input[placeholder="如 sales_manager,创建后不可修改"]').fill(NEW_ROLE_CODE)
dialog.locator('input[placeholder="如 销售主管"]').fill("E2E测试角色")
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "角色已创建", "新增角色")
fill_filter(page, "关键词", NEW_ROLE_CODE)
click_query(page)
role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first
if role_row.count() == 0:
fail("新增角色", f"列表中未找到 {NEW_ROLE_CODE}")
role_row.locator("button:has-text('编辑')").click()
page.wait_for_timeout(500)
dialog = visible_dialog(page)
dialog.locator('input[placeholder="如 销售主管"]').fill("E2E角色改名")
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "已保存", "编辑角色")
role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first
role_row.locator("button:has-text('禁用')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "禁用")
expect_toast(page, "已禁用", "禁用角色")
print("✓ 8. 角色管理 新增/编辑/禁用全流程正常")
def step_logs_query(page: Page) -> None:
"""操作日志(含详情懒加载与脱敏断言)与登录日志。"""
open_menu(page, "日志查询")
if current_path(page) != "/system/logs":
fail("菜单导航", f"未进入日志查询页,当前 {page.url}")
fill_filter(page, "操作人", ADMIN)
select_filter_option(page, "操作", "新增")
click_query(page)
log_row = table_rows(page).filter(has_text=NEW_USERNAME).first
if log_row.count() == 0:
fail("操作日志", f"未查到「新增用户 {NEW_USERNAME}」的审计记录")
log_row.locator(".el-table__expand-icon").click()
# 详情按 id 懒加载:等「加载中…」被替换为内容(含脱敏标记)
detail = ""
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
detail = page.locator(".log-detail__data:visible").first.text_content() or ""
if "***" in detail:
break
page.wait_for_timeout(200)
if "***" not in detail:
fail("日志脱敏", f"展开详情未加载出已脱敏参数,当前内容:{detail[:80]}")
if E2E_PASSWORD in detail:
fail("日志脱敏", "请求参数中出现明文密码 —— 脱敏未生效")
print("✓ 9. 操作日志 写操作留痕、详情懒加载且密码已脱敏")
page.locator(".el-tabs__item:has-text('登录日志')").click()
page.wait_for_timeout(800)
fill_filter(page, "用户名", ADMIN)
click_query(page)
if table_rows(page).filter(has_text="成功").count() == 0:
fail("登录日志", "未查到当前会话的成功登录记录")
print("✓ 10. 登录日志 当前会话登录记录可查")
def step_permission_check(page: Page, admin_password: str) -> None:
"""普通用户菜单隐藏与路由守卫。"""
open_menu(page, "用户管理")
create_user_via_ui(page, VIEWER_USERNAME, "E2E访客")
page.click("text=退出登录")
page.wait_for_url("**/login**", timeout=10_000)
login(page, VIEWER_USERNAME, E2E_PASSWORD)
if page.locator(".app-menu").get_by_text("系统管理", exact=True).count() != 0:
fail("菜单权限", "普通用户看到了「系统管理」菜单")
print("✓ 11. 菜单权限 普通用户看不到「系统管理」分组")
page.goto(f"{BASE_URL}/system/users", wait_until="networkidle")
page.wait_for_timeout(800)
if current_path(page) != "/dashboard":
fail("路由守卫", f"普通用户直连管理页未被弹回,当前 {page.url}")
if "需要管理员权限" not in toast_text(page):
fail("路由守卫", "直连管理页未给出权限提示")
print("✓ 12. 路由守卫 直连 /system/users 被弹回首页")
page.click("text=退出登录")
page.wait_for_url("**/login**", timeout=10_000)
login(page, ADMIN, admin_password)
def step_cleanup(page: Page) -> None:
"""删除测试用户与角色(用 expect_toast 校验,失败不假绿)。"""
open_menu(page, "用户管理")
for username in (NEW_USERNAME, VIEWER_USERNAME):
row = find_user_row(page, username)
if row.count() == 0:
continue
row.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "删除")
expect_toast(page, "已删除", "数据清理")
open_menu(page, "角色管理")
fill_filter(page, "关键词", NEW_ROLE_CODE)
click_query(page)
role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first
if role_row.count() > 0:
role_row.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "删除")
expect_toast(page, "已删除", "数据清理")
print("✓ 13. 数据清理 e2e 测试用户与角色已删除")
# ==================== 入口 ====================
def run(page: Page) -> None:
collect_console_errors(page, console_errors)
admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "")
if not admin_password:
fail(
"环境变量",
"未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码),"
"示例:E2E_ADMIN_PASSWORD=xxx python e2e/system_management.py",
)
step_user_create(page, admin_password)
step_user_edit_and_status(page)
step_role_management(page)
step_logs_query(page)
step_permission_check(page, admin_password)
step_cleanup(page)
if console_errors:
print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})")
def main() -> int:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page()
page.set_default_timeout(10_000)
try:
run(page)
except E2EFailure as exc:
page.screenshot(path="e2e/failure.png", full_page=True)
print(f"\n ✗ {exc}")
if console_errors:
print(f" 控制台错误:{console_errors[-5:]}")
print(" 已保存截图 e2e/failure.png")
return 1
except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因
page.screenshot(path="e2e/failure.png", full_page=True)
print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}")
if console_errors:
print(f" 控制台错误:{console_errors[-5:]}")
print(" 已保存截图 e2e/failure.png")
return 1
finally:
browser.close()
print("\n全部通过。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+78
View File
@@ -0,0 +1,78 @@
import { request } from './request'
import type { PageData } from './types'
/** 操作日志列表项(对应后端 `OperationLogOut`;不含 request_data 大字段)。 */
export interface OperationLogItem {
id: number
user_id: number | null
username: string
module: string
action: string
summary: string
method: string | null
path: string | null
ip: string | null
user_agent: string | null
/** 1成功 0失败 */
success: number
error_message: string | null
duration_ms: number | null
create_time: string | null
}
/** 操作日志详情(对应后端 `OperationLogDetailOut`;request_data 已脱敏) */
export interface OperationLogDetail extends OperationLogItem {
request_data: string | null
}
export interface OperationLogQueryParams {
pageNum?: number
pageSize?: number
username?: string
module?: string
action?: string
success?: number
/** 格式 YYYY-MM-DD HH:mm:ss(el-date-picker 需配 value-format) */
start_time?: string
end_time?: string
}
/** 登录日志项(对应后端 `LoginLogOut`)。 */
export interface LoginLogItem {
id: number
user_id: number | null
username: string
ip: string | null
user_agent: string | null
/** 1成功 0失败 */
success: number
fail_reason: string | null
create_time: string | null
}
export interface LoginLogQueryParams {
pageNum?: number
pageSize?: number
username?: string
ip?: string
success?: number
start_time?: string
end_time?: string
}
/** 分页查询操作日志(列表不含 request_data,详情走 getOperationLog) */
export function listOperationLogs(
params: OperationLogQueryParams,
): Promise<PageData<OperationLogItem>> {
return request({ url: '/v1/system/operation-logs', method: 'get', params })
}
/** 操作日志详情(含已脱敏的请求参数,展开行时按需加载) */
export function getOperationLog(logId: number): Promise<OperationLogDetail> {
return request({ url: `/v1/system/operation-logs/${logId}`, method: 'get' })
}
/** 分页查询登录日志 */
export function listLoginLogs(params: LoginLogQueryParams): Promise<PageData<LoginLogItem>> {
return request({ url: '/v1/system/login-logs', method: 'get', params })
}
+54
View File
@@ -0,0 +1,54 @@
import { request } from './request'
import type { PageData, RoleOption } from './types'
/** 角色管理列表项(对应后端 `RoleManageOut`,比下拉多绑定用户数)。 */
export interface RoleItem extends RoleOption {
/** 绑定用户数:>0 的角色不可删除,前端据此禁用按钮 */
user_count: number
}
export interface RoleQueryParams {
pageNum?: number
pageSize?: number
keyword?: string
status?: number
}
export interface RoleCreateParams {
role_code: string
role_name: string
description?: string
sort_order?: number
}
/** 编辑:不含编码(编码是授权判定锚点,创建后不可改) */
export interface RoleUpdateParams {
role_name: string
description?: string
sort_order?: number
}
/** 分页查询角色(含停用角色) */
export function listRoles(params: RoleQueryParams): Promise<PageData<RoleItem>> {
return request({ url: '/v1/system/roles', method: 'get', params })
}
/** 新增角色 */
export function createRole(data: RoleCreateParams): Promise<RoleItem> {
return request({ url: '/v1/system/roles', method: 'post', data })
}
/** 编辑角色 */
export function updateRole(roleId: number, data: RoleUpdateParams): Promise<RoleItem> {
return request({ url: `/v1/system/roles/${roleId}`, method: 'put', data })
}
/** 启用 / 禁用角色(内置 admin 不可停用) */
export function setRoleStatus(roleId: number, status: number): Promise<RoleItem> {
return request({ url: `/v1/system/roles/${roleId}/status`, method: 'put', data: { status } })
}
/** 删除角色(内置 admin 与仍有绑定用户的角色受后端保护) */
export function deleteRole(roleId: number): Promise<null> {
return request({ url: `/v1/system/roles/${roleId}`, method: 'delete' })
}
+25
View File
@@ -0,0 +1,25 @@
/** 请求/响应公共类型。 */
/**
* 分页响应 —— 与后端 `PageResult` 的序列化字段一致。
*
* 分页字段是驼峰(list/total/pageNum/pageSize,规范约定),而业务筛选
* 参数是蛇形(username、real_name…,与后端 Query 模型一致),
* 这是既有契约,不要"顺手统一"。
*/
export interface PageData<T> {
list: T[]
total: number
pageNum: number
pageSize: number
}
/** 已启用角色的下拉选项 */
export interface RoleOption {
id: number
role_code: string
role_name: string
description: string | null
sort_order: number
status: number
}
+86
View File
@@ -0,0 +1,86 @@
import { request } from './request'
import type { PageData, RoleOption } from './types'
/** 用户列表项(对应后端 `UserOut`)。 */
export interface UserItem {
id: number
username: string
real_name: string | null
phone: string | null
email: string | null
/** 1启用 0禁用 */
status: number
/** 角色编码列表 */
roles: string[]
last_login_time: string | null
remark: string | null
create_time: string | null
}
/** 列表查询:分页参数为驼峰,业务筛选为蛇形(与后端契约一致,勿"统一") */
export interface UserQueryParams {
pageNum?: number
pageSize?: number
username?: string
real_name?: string
status?: number
role_code?: string
}
export interface UserCreateParams {
username: string
password: string
real_name?: string
phone?: string
email?: string
roles: string[]
remark?: string
}
/** 编辑:不含用户名与密码(后者走专门的重置接口) */
export interface UserUpdateParams {
real_name?: string
phone?: string
email?: string
roles?: string[]
remark?: string
}
/** 分页查询用户 */
export function listUsers(params: UserQueryParams): Promise<PageData<UserItem>> {
return request({ url: '/v1/system/users', method: 'get', params })
}
/** 新增用户 */
export function createUser(data: UserCreateParams): Promise<UserItem> {
return request({ url: '/v1/system/users', method: 'post', data })
}
/** 编辑用户 */
export function updateUser(userId: number, data: UserUpdateParams): Promise<UserItem> {
return request({ url: `/v1/system/users/${userId}`, method: 'put', data })
}
/** 删除用户(物理删除,建议优先用禁用) */
export function deleteUser(userId: number): Promise<null> {
return request({ url: `/v1/system/users/${userId}`, method: 'delete' })
}
/** 重置密码(该用户已签发的令牌立即失效,需重新登录) */
export function resetPassword(userId: number, newPassword: string): Promise<null> {
return request({
url: `/v1/system/users/${userId}/password`,
method: 'put',
data: { new_password: newPassword },
})
}
/** 启用 / 禁用 */
export function setUserStatus(userId: number, status: number): Promise<UserItem> {
return request({ url: `/v1/system/users/${userId}/status`, method: 'put', data: { status } })
}
/** 角色下拉选项(仅启用角色) */
export function listRoleOptions(): Promise<RoleOption[]> {
return request({ url: '/v1/system/users/meta/roles', method: 'get' })
}
+40 -1
View File
@@ -1,4 +1,34 @@
/* 全局样式:重置 + 基础版式 */
/* 全局样式:重置 + 品牌 token + 基础版式 */
:root {
/*
* 品牌色:与登录页视觉体系同一套(原为登录页 scoped 局部变量,布局框架
* 与系统管理页面共用后提升为全局 token)。
* 金色分两档:亮金只用于深色底、深金只用于浅色底,保证两处对比度都达标。
*/
--ys-ink-900: #061a22;
--ys-ink-800: #0a2833;
--ys-ink-700: #0f3646;
--ys-ink-600: #1a4f63;
--ys-gold-bright: #c9a227;
--ys-gold-deep: #8f6520;
--ys-paper: #fafaf7;
--ys-text: #16262d;
--ys-muted: #6b7b84;
/*
* Element Plus 主色 = 赭金(浅色底配白字约 6.2:1)。
* 色阶为按 Element Plus 公式「主色与白色混合」预先算好的静态值 ——
* 不用 color-mix(),避免依赖较新的浏览器特性。
*/
--el-color-primary: #8f6520;
--el-color-primary-light-3: #b19363;
--el-color-primary-light-5: #c7b290;
--el-color-primary-light-7: #ddd1bc;
--el-color-primary-light-8: #e9e0d2;
--el-color-primary-light-9: #f4f0e9;
--el-color-primary-dark-2: #72511a;
}
* {
box-sizing: border-box;
@@ -20,3 +50,12 @@ body {
background-color: #f5f7fa;
-webkit-font-smoothing: antialiased;
}
/* 系统管理列表页共用的版式(原在三个页面 scoped 中各自重复) */
.filter-form {
margin-bottom: 4px;
}
.toolbar {
margin-bottom: 12px;
}
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useAppMenu } from '@/composables/useAppMenu'
/** 侧边导航菜单:桌面端 aside 与移动端抽屉复用同一份(路由派生)数据。 */
const route = useRoute()
const { topLevelMenus, groupedMenus } = useAppMenu()
</script>
<template>
<el-menu class="app-menu" :default-active="route.path" router>
<el-menu-item v-for="item in topLevelMenus" :key="item.path" :index="item.path">
{{ item.title }}
</el-menu-item>
<el-sub-menu v-for="group in groupedMenus" :key="group.name" :index="group.name">
<template #title>{{ group.name }}</template>
<el-menu-item v-for="item in group.items" :key="item.path" :index="item.path">
{{ item.title }}
</el-menu-item>
</el-sub-menu>
</el-menu>
</template>
<style scoped>
/*
* 深色底上的菜单:覆盖 Element Plus 的主题变量而非直接写颜色 ——
* 后者与组件库规则同权重,受样式注入顺序影响而不稳定。
* 激活色用亮金(深色底专用档,见 index.css 的金色两档约定)。
*/
.app-menu {
--el-menu-bg-color: transparent;
--el-menu-text-color: rgb(250 250 247 / 78%);
--el-menu-active-color: var(--ys-gold-bright);
--el-menu-hover-bg-color: rgb(255 255 255 / 6%);
--el-menu-item-height: 44px;
border-right: none;
}
.app-menu :deep(.el-menu-item.is-active) {
background-color: rgb(201 162 39 / 12%);
font-weight: 600;
}
.app-menu :deep(.el-sub-menu.is-active > .el-sub-menu__title) {
color: var(--ys-gold-bright);
}
</style>
@@ -0,0 +1,219 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import AppMenu from '@/components/layout/AppMenu.vue'
import { useUserStore } from '@/store/user'
/** 基础布局:左侧品牌导航(窄屏收为抽屉)+ 顶栏(面包屑/用户/退出)+ 内容区。 */
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const drawerVisible = ref(false)
/** 面包屑:首页 + 分组(如有)+ 当前页标题(首页自身不重复) */
const breadcrumbs = computed<string[]>(() => {
const items: string[] = []
if (route.meta.group) items.push(route.meta.group)
if (route.meta.title && route.path !== '/dashboard') items.push(route.meta.title)
return items
})
// 移动端在抽屉里点完菜单即收起(路由变化意味着导航已发生)
watch(
() => route.path,
() => {
drawerVisible.value = false
},
)
async function handleLogout(): Promise<void> {
await userStore.logout()
ElMessage.success('已退出登录')
await router.replace('/login')
}
</script>
<template>
<el-container class="app-layout">
<el-aside class="app-aside" width="220px">
<div class="app-brand">
<span class="app-brand__name">衍水集团</span>
<span class="app-brand__sub">营销人员工资管理系统</span>
</div>
<AppMenu class="app-aside__menu" />
</el-aside>
<el-container class="app-body">
<el-header class="app-header">
<div class="app-header__left">
<button
type="button"
class="app-header__menu-toggle"
aria-label="打开导航菜单"
@click="drawerVisible = true"
>
<svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true">
<path
d="M3 5h14M3 10h14M3 15h14"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
/>
</svg>
</button>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/dashboard' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item">
{{ item }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="app-header__user">
<el-tag v-for="role in userStore.roles" :key="role" size="small" type="info">
{{ role }}
</el-tag>
<span class="app-header__username">{{ userStore.displayName }}</span>
<el-button link type="primary" @click="handleLogout">退出登录</el-button>
</div>
</el-header>
<el-main class="app-main">
<router-view />
</el-main>
</el-container>
<el-drawer
v-model="drawerVisible"
class="app-drawer"
direction="ltr"
size="240px"
:with-header="false"
>
<div class="app-brand">
<span class="app-brand__name">衍水集团</span>
<span class="app-brand__sub">营销人员工资管理系统</span>
</div>
<AppMenu />
</el-drawer>
</el-container>
</template>
<style scoped>
.app-layout {
height: 100%;
}
.app-aside {
display: flex;
flex-direction: column;
background: linear-gradient(180deg, var(--ys-ink-900) 0%, var(--ys-ink-700) 100%);
}
.app-brand {
display: flex;
flex-direction: column;
gap: 2px;
padding: 18px 20px 14px;
}
.app-brand__name {
font-family: 'Songti SC', SimSun, serif;
font-size: 18px;
letter-spacing: 2px;
color: var(--ys-paper);
}
.app-brand__sub {
font-size: 12px;
letter-spacing: 1px;
color: rgb(250 250 247 / 55%);
}
.app-aside__menu {
flex: 1;
overflow-y: auto;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
background-color: #fff;
border-bottom: 1px solid #e5e7eb;
}
.app-header__left {
display: flex;
align-items: center;
gap: 12px;
}
/* 窄屏才出现的汉堡按钮;不引入图标库,用内联 SVG */
.app-header__menu-toggle {
display: none;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
color: var(--ys-text);
cursor: pointer;
background: none;
border: none;
border-radius: 6px;
}
.app-header__menu-toggle:hover {
background-color: #f2f3f5;
}
.app-header__menu-toggle:focus-visible {
outline: 2px solid var(--ys-gold-deep);
outline-offset: 2px;
}
.app-header__user {
display: flex;
align-items: center;
gap: 10px;
}
.app-header__username {
font-size: 14px;
color: var(--ys-text);
}
.app-main {
padding: 20px;
background-color: #f5f7fa;
}
@media (max-width: 768px) {
.app-aside {
display: none;
}
.app-header__menu-toggle {
display: inline-flex;
}
}
@media (prefers-reduced-motion: reduce) {
.app-header__menu-toggle {
transition: none;
}
}
</style>
<style>
/* 抽屉内容铺满深色底(非 scoped:目标元素由 el-drawer 渲染在组件根之外) */
.app-drawer .el-drawer__body {
padding: 0;
background: linear-gradient(180deg, var(--ys-ink-900) 0%, var(--ys-ink-700) 100%);
}
</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
/**
* 分页条:统一四个列表页的布局与页码尺寸选项。
* 与 `usePagedList` 的分页字段(pageNum/pageSize/total)配套使用。
*/
defineProps<{
total: number
pageNum: number
pageSize: number
}>()
defineEmits<{
(e: 'page-change', page: number): void
(e: 'size-change', size: number): void
}>()
</script>
<template>
<el-pagination
class="app-pager"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
:current-page="pageNum"
:page-size="pageSize"
:page-sizes="[10, 20, 50]"
@current-change="$emit('page-change', $event)"
@size-change="$emit('size-change', $event)"
/>
</template>
<style scoped>
.app-pager {
justify-content: flex-end;
margin-top: 16px;
}
</style>
@@ -0,0 +1,29 @@
<script setup lang="ts">
/**
* 状态标签:0/1 语义的通用展示。
*
* 用户状态、角色状态、操作结果、登录结果四处共用,故提取;
* 语义文字由调用方通过 props 定制(如同一组件展示「启用/禁用」与「成功/失败」)。
*/
withDefaults(
defineProps<{
value: number
activeText?: string
inactiveText?: string
activeType?: 'success' | 'warning' | 'danger' | 'info' | 'primary'
inactiveType?: 'success' | 'warning' | 'danger' | 'info' | 'primary'
}>(),
{
activeText: '启用',
inactiveText: '禁用',
activeType: 'success',
inactiveType: 'info',
},
)
</script>
<template>
<el-tag :type="value === 1 ? activeType : inactiveType" size="small" disable-transitions>
{{ value === 1 ? activeText : inactiveText }}
</el-tag>
</template>
+65
View File
@@ -0,0 +1,65 @@
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { ROLE_ADMIN } from '@/utils/constants'
/** 菜单节点:从路由表派生,避免菜单标题与页面标题两处维护漂移。 */
export interface MenuNode {
path: string
title: string
order: number
group?: string
}
export interface MenuGroup {
name: string
items: MenuNode[]
}
/**
* 侧边菜单数据。
*
* 规则:
* - 取路由表中带 `meta.title` 且未被 `meta.menu === false` 排除的记录
* - `meta.requiresAdmin` 的项仅管理员可见(与路由守卫双保险,隐藏入口在前)
* - `meta.group` 相同的项聚合为一个子菜单;无 group 的为顶层菜单项
* - 按 `meta.order` 排序
*/
export function useAppMenu() {
const router = useRouter()
const userStore = useUserStore()
const isAdmin = computed(() => userStore.roles.includes(ROLE_ADMIN))
const menuNodes = computed<MenuNode[]>(() =>
router
.getRoutes()
.filter((record) => Boolean(record.meta.title) && record.meta.menu !== false)
.filter((record) => !record.meta.requiresAdmin || isAdmin.value)
.map((record) => ({
path: record.path,
title: record.meta.title ?? '',
order: record.meta.order ?? 99,
group: record.meta.group,
}))
.sort((a, b) => a.order - b.order),
)
/** 无分组的顶层菜单项 */
const topLevelMenus = computed(() => menuNodes.value.filter((node) => !node.group))
/** 分组菜单(按 group 名聚合,组内保持 order 顺序) */
const groupedMenus = computed<MenuGroup[]>(() => {
const groups = new Map<string, MenuNode[]>()
for (const node of menuNodes.value) {
if (!node.group) continue
const items = groups.get(node.group) ?? []
items.push(node)
groups.set(node.group, items)
}
return [...groups.entries()].map(([name, items]) => ({ name, items }))
})
return { topLevelMenus, groupedMenus }
}
@@ -0,0 +1,52 @@
import { ElMessage, ElMessageBox } from 'element-plus'
import { handledError } from '@/utils/error'
/**
* 「确认框 → 执行 → 成功提示 → 收尾刷新」动作流。
*
* 用户/角色两页的启用禁用、删除共四处结构逐行相同,收敛到这里:
* 取消确认静默返回 false;接口失败由请求拦截器统一提示,此处仅兜底
* 阻止 Promise 继续传播(handledError 显式标注,不写空 catch)。
*/
export function useConfirmAction() {
/**
* 弹出确认框并执行;返回是否真正执行成功(用户取消或执行失败均为 false)。
*/
async function confirmAndRun(options: {
/** 确认框正文 */
confirmText: string
/** 确认框标题 */
title?: string
/** 确认按钮文案 */
confirmButtonText?: string
/** 执行成功后的全局提示 */
successText: string
/** 真正的业务动作 */
run: () => Promise<unknown>
/** 成功后的收尾(如刷新列表) */
after?: () => Promise<void> | void
}): Promise<boolean> {
try {
await ElMessageBox.confirm(options.confirmText, options.title ?? '操作确认', {
type: 'warning',
confirmButtonText: options.confirmButtonText ?? '确定',
cancelButtonText: '取消',
})
} catch {
return false // 用户取消,静默返回
}
try {
await options.run()
ElMessage.success(options.successText)
await options.after?.()
return true
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
return false
}
}
return { confirmAndRun }
}
+86
View File
@@ -0,0 +1,86 @@
import { ref, type Ref } from 'vue'
import type { PageData } from '@/api/types'
import { handledError } from '@/utils/error'
/**
* 分页列表状态管理。
*
* 四个列表页(用户/角色/操作日志/登录日志)共用同一套分页交互,要点:
*
* - 「删除当前页最后一条后页码回退」是高频踩坑点:不回退就会停在空页
* - **请求序号防竞态**:快速翻页/改条件时并发请求乱序返回,只有最新
* 请求的响应允许写入状态,否则旧数据会覆盖新数据、与分页器错位
* - 请求失败不外抛(提示由拦截器统一完成),loading 仅由最新请求复位
*/
export function usePagedList<T, F extends object>(
fetcher: (params: F & { pageNum: number; pageSize: number }) => Promise<PageData<T>>,
getFilters: () => F,
) {
const records = ref([]) as Ref<T[]>
const total = ref(0)
const pageNum = ref(1)
const pageSize = ref(10)
const loading = ref(false)
/** 自增请求序号:仅最新请求的响应可写入状态 */
let requestSeq = 0
/** 按当前页码与筛选条件加载 */
async function load(): Promise<void> {
const seq = ++requestSeq
loading.value = true
try {
const data = await fetcher({
...getFilters(),
pageNum: pageNum.value,
pageSize: pageSize.value,
})
if (seq !== requestSeq) return // 已有更新的请求发出,丢弃过期响应
records.value = data.list
total.value = data.total
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
} finally {
if (seq === requestSeq) loading.value = false
}
}
/** 条件查询:回到第一页再加载(停留旧页码会查到空页) */
function search(): void {
pageNum.value = 1
void load()
}
function onPageChange(page: number): void {
pageNum.value = page
void load()
}
function onSizeChange(size: number): void {
pageSize.value = size
pageNum.value = 1
void load()
}
/** 删除后刷新:当前页已被删空且不在第一页时,先回退页码再加载 */
async function reloadAfterDelete(removedCount = 1): Promise<void> {
if (records.value.length - removedCount <= 0 && pageNum.value > 1) {
pageNum.value -= 1
}
await load()
}
return {
records,
total,
pageNum,
pageSize,
loading,
load,
search,
onPageChange,
onSizeChange,
reloadAfterDelete,
}
}
+55 -4
View File
@@ -1,20 +1,64 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/user'
import { ROLE_ADMIN } from '@/utils/constants'
declare module 'vue-router' {
interface RouteMeta {
/** 免登录页面(登录页) */
public?: boolean
/** 页面标题:浏览器标题 / 面包屑 / 菜单共用,新页面必须写 */
title?: string
/** 仅管理员可访问 —— 菜单隐藏 + 守卫兜底(直接输 URL 会被弹回首页) */
requiresAdmin?: boolean
/** 是否出现在侧边菜单(默认 true);false 用于排除登录页等 */
menu?: boolean
/** 菜单分组名(同名聚合为一个子菜单);不设则为顶层菜单项 */
group?: string
/** 菜单排序,越小越靠前 */
order?: number
}
}
const routes: RouteRecordRaw[] = [
{ path: '/', redirect: '/dashboard' },
{
path: '/login',
name: 'login',
component: () => import('@/views/login/index.vue'),
meta: { public: true, title: '登录' },
meta: { public: true, title: '登录', menu: false },
},
{
// 应用主框架:菜单、顶栏、面包屑由布局提供,业务页面作为子路由渲染
path: '/',
component: () => import('@/components/layout/BasicLayout.vue'),
redirect: '/dashboard',
children: [
{
path: '/dashboard',
name: 'dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '首页' },
meta: { title: '首页', order: 1 },
},
{
path: '/system/users',
name: 'system-users',
component: () => import('@/views/system/users/index.vue'),
meta: { title: '用户管理', group: '系统管理', requiresAdmin: true, order: 2 },
},
{
path: '/system/roles',
name: 'system-roles',
component: () => import('@/views/system/roles/index.vue'),
meta: { title: '角色管理', group: '系统管理', requiresAdmin: true, order: 3 },
},
{
path: '/system/logs',
name: 'system-logs',
component: () => import('@/views/system/logs/index.vue'),
meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 4 },
},
],
},
// 未匹配路由回首页;若未登录会被守卫拦到登录页
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
@@ -28,11 +72,13 @@ const router = createRouter({
/**
* 全局前置守卫。
*
* 三层判断:
* 四层判断:
* 1. `meta.public` 标记的页面(登录页)直接放行
* 2. 无令牌 → 跳登录页,并把原目标记在 query 里供登录后回跳
* 3. 有令牌但内存中无用户信息(说明是刷新页面)→ 调 /me 恢复会话;
* 失败则视为令牌已失效,清状态并跳登录页
* 4. `meta.requiresAdmin` 的页面要求管理员角色 —— 此时用户信息已就绪,
* 这是"隐藏菜单"之外的第二道防线(防手输 URL)
*/
router.beforeEach(async (to) => {
const userStore = useUserStore()
@@ -54,6 +100,11 @@ router.beforeEach(async (to) => {
}
}
if (to.meta.requiresAdmin && !userStore.roles.includes(ROLE_ADMIN)) {
ElMessage.error('需要管理员权限')
return { path: '/dashboard' }
}
return true
})
+23
View File
@@ -0,0 +1,23 @@
/** 跨页面共享常量。 */
/** 系统管理员:可见「系统管理」菜单,全部管理接口可用(与后端 core/constants.py 一致) */
export const ROLE_ADMIN = 'admin'
/**
* 操作日志的模块 / 操作词表 —— 与后端 `core/constants.py` 的
* `MODULE_*` / `ACTION_*` 保持一致;新增模块时两处同步(后端是权威来源)。
*/
export const MODULE_OPTIONS = ['用户管理', '角色管理'] as const
export const ACTION_OPTIONS = ['新增', '修改', '删除', '重置密码', '启用', '禁用'] as const
/** 启用/禁用筛选选项(文案与 StatusTag 的展示一致) */
export const STATUS_OPTIONS = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
] as const
/** 成功/失败筛选选项 */
export const RESULT_OPTIONS = [
{ label: '成功', value: 1 },
{ label: '失败', value: 0 },
] as const
+13
View File
@@ -0,0 +1,13 @@
/** 异常处理工具。 */
/**
* 显式声明「异常已按约定处理」。
*
* 本项目请求失败的提示统一由 axios 拦截器完成,页面 catch 块只需阻止
* Promise 继续传播;但**空 catch 会掩盖非预期异常**(如本地 TypeError),
* 也违反「禁空 catch」规范,故以本函数显式标注意图并留痕。
* 用 debug 级别:既不污染 e2e 收集的 error 级控制台信息,也便于排查时开启。
*/
export function handledError(error: unknown): void {
console.debug('[handled]', error)
}
+12
View File
@@ -0,0 +1,12 @@
/** 显示格式化工具。 */
/**
* 时间展示:把后端的时间串规范为 `YYYY-MM-DD HH:mm:ss`。
*
* Pydantic 序列化 datetime 为 ISO 8601(含 `T`),直接展示不友好;
* 值缺失(空串/null/undefined)时返回占位符而非空白。
*/
export function formatDateTime(value?: string | null, placeholder = '—'): string {
if (!value) return placeholder
return value.replace('T', ' ').slice(0, 19)
}
+38
View File
@@ -0,0 +1,38 @@
/** 表单校验工具。 */
/**
* 密码校验:按 **UTF-8 字节数** 而非字符数。
*
* 后端 bcrypt 对超过 72 字节的密码直接拒绝;中文一个字符占 3 字节,
* 24 个汉字即到顶 —— 按字符数校验(如 maxlength=20)根本拦不住,
* 会出现"前端放行、后端 400"的错位体验。与后端 `PASSWORD_*_BYTES` 对齐。
*/
export const PASSWORD_MIN_BYTES = 8
export const PASSWORD_MAX_BYTES = 72
const BYTE_HINT = '(中文一个字符约 3 字节)'
/** 计算 UTF-8 字节数 */
export function passwordByteLength(value: string): number {
return new TextEncoder().encode(value).length
}
/** Element Plus 表单校验器(用于 el-form 的 rules.validator) */
export function passwordRule(
_rule: unknown,
value: string,
callback: (error?: Error) => void,
): void {
if (!value) {
callback(new Error('请输入密码'))
return
}
const length = passwordByteLength(value)
if (length < PASSWORD_MIN_BYTES) {
callback(new Error(`密码不足 ${PASSWORD_MIN_BYTES} 字节${BYTE_HINT}`))
} else if (length > PASSWORD_MAX_BYTES) {
callback(new Error(`密码超过 ${PASSWORD_MAX_BYTES} 字节上限${BYTE_HINT}`))
} else {
callback()
}
}
+11 -60
View File
@@ -1,33 +1,17 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { formatDateTime } from '@/utils/format'
const router = useRouter()
/**
* 首页:登录链路验证页。
*
* 顶部栏(用户信息、退出登录)已由 BasicLayout 提供,本页只保留内容区;
* 用户名等字段的展示是端到端脚本的断言对象,调整时需同步 e2e。
*/
const userStore = useUserStore()
async function handleLogout(): Promise<void> {
await userStore.logout()
ElMessage.success('已退出登录')
await router.replace('/login')
}
</script>
<template>
<el-container class="layout">
<el-header class="layout-header">
<span class="layout-title">衍水集团营销人员工资管理系统</span>
<div class="layout-user">
<el-tag v-for="role in userStore.roles" :key="role" size="small" type="info">
{{ role }}
</el-tag>
<span class="layout-username">{{ userStore.displayName }}</span>
<el-button link type="primary" @click="handleLogout">退出登录</el-button>
</div>
</el-header>
<el-main class="layout-main">
<el-card shadow="never">
<template #header>
<span>登录状态验证通过</span>
@@ -43,56 +27,23 @@ async function handleLogout(): Promise<void> {
{{ userStore.roles.join('、') || '—' }}
</el-descriptions-item>
<el-descriptions-item label="上次登录">
{{ userStore.userInfo?.last_login_time || '—' }}
{{ formatDateTime(userStore.userInfo?.last_login_time) }}
</el-descriptions-item>
</el-descriptions>
<el-alert
class="layout-hint"
class="dashboard-hint"
type="info"
:closable="false"
show-icon
title="这是登录链路的验证页"
description="刷新页面可验证会话恢复(守卫会调用 /me);点击右上角退出登录可验证令牌即时失效。业务模块页面将在后续阶段接入。"
description="刷新页面可验证会话恢复(守卫会调用 /me);「系统管理」菜单已接入用户管理、角色管理与日志查询。"
/>
</el-card>
</el-main>
</el-container>
</template>
<style scoped>
.layout {
height: 100%;
}
.layout-header {
display: flex;
align-items: center;
justify-content: space-between;
color: #fff;
background-color: #1f4e79;
}
.layout-title {
font-size: 16px;
font-weight: 600;
}
.layout-user {
display: flex;
align-items: center;
gap: 10px;
}
.layout-username {
font-size: 14px;
}
.layout-main {
padding: 20px;
}
.layout-hint {
.dashboard-hint {
margin-top: 18px;
}
</style>
+11 -11
View File
@@ -182,19 +182,19 @@ onMounted(refreshCaptcha)
<style scoped>
/*
* 配色说明:金色分两档 —— 亮金只用于深色底、深金只用于浅色底,
* 保证两处对比度都达标(深金配白字约 6.2:1)。
* 配色:品牌 token 已提升为全局(见 assets/styles/index.css,布局框架与
* 系统管理页面共用),此处只做局部别名,用法与取值均与原先一致。
*/
.login {
--ink-900: #061a22;
--ink-800: #0a2833;
--ink-700: #0f3646;
--ink-600: #1a4f63;
--gold-bright: #c9a227;
--gold-deep: #8f6520;
--paper: #fafaf7;
--text: #16262d;
--muted: #6b7b84;
--ink-900: var(--ys-ink-900);
--ink-800: var(--ys-ink-800);
--ink-700: var(--ys-ink-700);
--ink-600: var(--ys-ink-600);
--gold-bright: var(--ys-gold-bright);
--gold-deep: var(--ys-gold-deep);
--paper: var(--ys-paper);
--text: var(--ys-text);
--muted: var(--ys-muted);
display: flex;
min-height: 100vh;
+393
View File
@@ -0,0 +1,393 @@
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'
import * as logApi from '@/api/logs'
import type {
LoginLogItem,
LoginLogQueryParams,
OperationLogDetail,
OperationLogItem,
OperationLogQueryParams,
} from '@/api/logs'
import StatusTag from '@/components/table/StatusTag.vue'
import AppPagination from '@/components/table/AppPagination.vue'
import { usePagedList } from '@/composables/usePagedList'
import { ACTION_OPTIONS, MODULE_OPTIONS, RESULT_OPTIONS } from '@/utils/constants'
import { handledError } from '@/utils/error'
import { formatDateTime } from '@/utils/format'
/**
* 日志查询(需求 2.5.1「支持日志查询」)。
*
* 操作日志与登录日志分开两个 tab(后端也是两个端点:字段与筛选项不同)。
*
* 时间筛选的两个关键点:
* - 必须给 el-date-picker 指定 `value-format` —— 否则绑定值是 Date 对象,
* axios 会序列化为 UTC ISO 串,随本地时区产生 8 小时偏移
* - 必须配 `default-time` —— datetimerange 只选日期时时刻默认 00:00:00,
* 跨天查询会漏掉结束日整天(起点补 00:00:00、终点补 23:59:59)
*
* 操作日志列表**不含请求参数**(大字段,随列表返回会让翻页负担随日志表
* 增长);展开行时按 id 调详情端点懒加载并缓存。
*/
/** datetimerange 的起止时刻补全(与 value-format 配合) */
const RANGE_DEFAULT_TIME: [Date, Date] = [
new Date(2000, 0, 1, 0, 0, 0),
new Date(2000, 0, 1, 23, 59, 59),
]
const activeTab = ref('operation')
// ==================== 操作日志 ====================
const opFilters = reactive({
username: '',
module: undefined as string | undefined,
action: undefined as string | undefined,
success: undefined as number | undefined,
})
const opTimeRange = ref<[string, string] | null>(null)
const {
records: opRecords,
total: opTotal,
pageNum: opPageNum,
pageSize: opPageSize,
loading: opLoading,
load: loadOperationLogs,
search: searchOperationLogs,
onPageChange: onOpPageChange,
onSizeChange: onOpSizeChange,
} = usePagedList<OperationLogItem, OperationLogQueryParams>(logApi.listOperationLogs, () => ({
username: opFilters.username,
module: opFilters.module,
action: opFilters.action,
success: opFilters.success,
start_time: opTimeRange.value?.[0],
end_time: opTimeRange.value?.[1],
}))
function resetOpFilters(): void {
opFilters.username = ''
opFilters.module = undefined
opFilters.action = undefined
opFilters.success = undefined
opTimeRange.value = null
searchOperationLogs()
}
/** 详情缓存与加载中集合(列表无 request_data,展开时按需拉取) */
const detailMap = reactive(new Map<number, OperationLogDetail>())
const detailLoading = reactive(new Set<number>())
async function onExpandChange(row: OperationLogItem, expandedRows: OperationLogItem[]): Promise<void> {
const isExpanded = expandedRows.some((item) => item.id === row.id)
if (!isExpanded || detailMap.has(row.id)) return
detailLoading.add(row.id)
try {
detailMap.set(row.id, await logApi.getOperationLog(row.id))
} catch (error) {
handledError(error) // 拦截器已提示,展开处显示占位
} finally {
detailLoading.delete(row.id)
}
}
/** 展开行展示的请求参数:加载中 / 暂无 / 格式化后的 JSON(已脱敏) */
function detailText(logId: number): string {
if (detailLoading.has(logId)) return '加载中…'
const detail = detailMap.get(logId)
if (!detail) return '—'
return formatRequestData(detail.request_data)
}
function formatRequestData(raw: string | null): string {
if (!raw) return '—'
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}
// ==================== 登录日志 ====================
const loginFilters = reactive({
username: '',
ip: '',
success: undefined as number | undefined,
})
const loginTimeRange = ref<[string, string] | null>(null)
const {
records: loginRecords,
total: loginTotal,
pageNum: loginPageNum,
pageSize: loginPageSize,
loading: loginLoading,
load: loadLoginLogs,
search: searchLoginLogs,
onPageChange: onLoginPageChange,
onSizeChange: onLoginSizeChange,
} = usePagedList<LoginLogItem, LoginLogQueryParams>(logApi.listLoginLogs, () => ({
username: loginFilters.username,
ip: loginFilters.ip,
success: loginFilters.success,
start_time: loginTimeRange.value?.[0],
end_time: loginTimeRange.value?.[1],
}))
function resetLoginFilters(): void {
loginFilters.username = ''
loginFilters.ip = ''
loginFilters.success = undefined
loginTimeRange.value = null
searchLoginLogs()
}
// ==================== 初始化 ====================
onMounted(loadOperationLogs)
// 登录日志 tab 每次激活都刷新(首屏不打多余请求;此前"只加载一次"的
// 标志在首次失败后不会重置,切回时停在空表且无从自查)
watch(activeTab, (tab) => {
if (tab === 'login') {
void loadLoginLogs()
}
})
</script>
<template>
<el-card shadow="never">
<el-tabs v-model="activeTab">
<!-- 操作日志 -->
<el-tab-pane label="操作日志" name="operation" lazy>
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="操作人">
<el-input
v-model="opFilters.username"
placeholder="模糊匹配"
clearable
style="width: 140px"
@keyup.enter="searchOperationLogs"
/>
</el-form-item>
<el-form-item label="模块">
<el-select v-model="opFilters.module" placeholder="全部" clearable style="width: 130px">
<el-option v-for="item in MODULE_OPTIONS" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="操作">
<el-select v-model="opFilters.action" placeholder="全部" clearable style="width: 120px">
<el-option v-for="item in ACTION_OPTIONS" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="结果">
<el-select v-model="opFilters.success" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in RESULT_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="opTimeRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
:default-time="RANGE_DEFAULT_TIME"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="searchOperationLogs">查询</el-button>
<el-button @click="resetOpFilters">重置</el-button>
</el-form-item>
</el-form>
<el-table
v-loading="opLoading"
:data="opRecords"
stripe
@expand-change="onExpandChange"
>
<el-table-column type="expand">
<template #default="{ row }">
<div class="log-detail">
<p>
<span class="log-detail__label">请求:</span>
{{ row.method || '—' }} {{ row.path || '—' }}
</p>
<p>
<span class="log-detail__label">User-Agent:</span>
{{ row.user_agent || '—' }}
</p>
<p class="log-detail__label">请求参数(已脱敏):</p>
<pre class="log-detail__data">{{ detailText(row.id) }}</pre>
</div>
</template>
</el-table-column>
<el-table-column label="时间" min-width="165">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column prop="username" label="操作人" min-width="110" />
<el-table-column prop="module" label="模块" width="100" />
<el-table-column prop="action" label="操作" width="90" />
<el-table-column prop="summary" label="摘要" min-width="200" show-overflow-tooltip />
<el-table-column label="结果" width="80" align="center">
<template #default="{ row }">
<el-tooltip
:content="row.error_message ?? ''"
:disabled="row.success !== 0 || !row.error_message"
placement="top"
>
<StatusTag
:value="row.success"
active-text="成功"
inactive-text="失败"
inactive-type="danger"
/>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="耗时(ms)" width="90" align="right">
<template #default="{ row }">{{ row.duration_ms ?? '—' }}</template>
</el-table-column>
<el-table-column prop="ip" label="IP" min-width="120">
<template #default="{ row }">{{ row.ip || '—' }}</template>
</el-table-column>
</el-table>
<AppPagination
:total="opTotal"
:page-num="opPageNum"
:page-size="opPageSize"
@page-change="onOpPageChange"
@size-change="onOpSizeChange"
/>
</el-tab-pane>
<!-- 登录日志 -->
<el-tab-pane label="登录日志" name="login" lazy>
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="用户名">
<el-input
v-model="loginFilters.username"
placeholder="模糊匹配"
clearable
style="width: 160px"
@keyup.enter="searchLoginLogs"
/>
</el-form-item>
<el-form-item label="IP">
<el-input
v-model="loginFilters.ip"
placeholder="精确匹配"
clearable
style="width: 150px"
@keyup.enter="searchLoginLogs"
/>
</el-form-item>
<el-form-item label="结果">
<el-select v-model="loginFilters.success" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in RESULT_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="loginTimeRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
:default-time="RANGE_DEFAULT_TIME"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="searchLoginLogs">查询</el-button>
<el-button @click="resetLoginFilters">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loginLoading" :data="loginRecords" stripe>
<el-table-column label="时间" min-width="165">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column prop="username" label="用户名" min-width="130" />
<el-table-column label="结果" width="80" align="center">
<template #default="{ row }">
<StatusTag
:value="row.success"
active-text="成功"
inactive-text="失败"
inactive-type="danger"
/>
</template>
</el-table-column>
<el-table-column prop="fail_reason" label="失败原因" min-width="150">
<template #default="{ row }">{{ row.fail_reason || '—' }}</template>
</el-table-column>
<el-table-column prop="ip" label="IP" min-width="120">
<template #default="{ row }">{{ row.ip || '—' }}</template>
</el-table-column>
<el-table-column label="User-Agent" min-width="200">
<template #default="{ row }">
<span class="ua-cell" :title="row.user_agent || ''">{{ row.user_agent || '—' }}</span>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="loginTotal"
:page-num="loginPageNum"
:page-size="loginPageSize"
@page-change="onLoginPageChange"
@size-change="onLoginSizeChange"
/>
</el-tab-pane>
</el-tabs>
</el-card>
</template>
<style scoped>
.log-detail {
padding: 4px 16px;
color: var(--ys-text);
}
.log-detail__label {
font-weight: 600;
}
.log-detail__data {
padding: 8px 12px;
margin: 6px 0 12px;
overflow-x: auto;
font-family: Consolas, 'Courier New', monospace;
font-size: 12px;
background-color: var(--ys-paper);
border: 1px solid #e5e7eb;
border-radius: 4px;
}
.ua-cell {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
</style>
+305
View File
@@ -0,0 +1,305 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import * as roleApi from '@/api/roles'
import type { RoleItem, RoleQueryParams } from '@/api/roles'
import AppPagination from '@/components/table/AppPagination.vue'
import StatusTag from '@/components/table/StatusTag.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList'
import { ROLE_ADMIN, STATUS_OPTIONS } from '@/utils/constants'
import { handledError } from '@/utils/error'
/**
* 角色管理(轻量版:不含权限点分配,权限清单待业务方给出后启用)。
*
* 与后端的保护规则对齐(后端是唯一防线,此处禁用按钮只是体验):
* - 内置 admin 角色不可删除、不可禁用
* - 仍有绑定用户的角色不可删除(`user_roles` 外键是 CASCADE,
* 直接删会静默剥离这些用户的权限)
* - 编码创建后不可修改(授权判定的锚点)
* - 禁用角色不再出现在用户表单下拉中,且**不可再分配给用户**;
* 已绑定用户的既有授权不受影响
*/
const { confirmAndRun } = useConfirmAction()
const filters = reactive({
keyword: '',
status: undefined as number | undefined,
})
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
usePagedList<RoleItem, RoleQueryParams>(roleApi.listRoles, () => ({
keyword: filters.keyword,
status: filters.status,
}))
function resetFilters(): void {
filters.keyword = ''
filters.status = undefined
search()
}
/** 内置管理员角色 */
function isBuiltin(row: RoleItem): boolean {
return row.role_code === ROLE_ADMIN
}
/** 删除保护的悬停说明(禁用按钮自身不触发悬浮事件,由外层 span 提供 title) */
function deleteHint(row: RoleItem): string {
if (isBuiltin(row)) return '内置管理员角色不可删除'
if (row.user_count > 0) return `仍有 ${row.user_count} 个用户绑定该角色,请先解除绑定`
return ''
}
// ==================== 新增 / 编辑 ====================
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
const isEdit = computed(() => editingId.value !== null)
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive({
role_code: '',
role_name: '',
description: '',
sort_order: 0,
})
const rules = computed<FormRules>(() => ({
role_code: [
{ required: true, message: '请输入角色编码', trigger: 'blur' },
{
pattern: /^[a-z][a-z0-9_]{1,31}$/,
message: '以小写字母开头,仅可含小写字母、数字与下划线(2–32 位)',
trigger: 'blur',
},
],
role_name: [
{ required: true, message: '请输入角色名称', trigger: 'blur' },
{ max: 64, message: '最长 64 个字符', trigger: 'blur' },
],
description: [{ max: 255, message: '最长 255 个字符', trigger: 'blur' }],
}))
function openCreate(): void {
editingId.value = null
Object.assign(form, { role_code: '', role_name: '', description: '', sort_order: 0 })
dialogVisible.value = true
formRef.value?.clearValidate()
}
function openEdit(row: RoleItem): void {
editingId.value = row.id
Object.assign(form, {
role_code: row.role_code,
role_name: row.role_name,
description: row.description ?? '',
sort_order: row.sort_order,
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
async function handleSubmit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (editingId.value !== null) {
await roleApi.updateRole(editingId.value, {
role_name: form.role_name,
description: form.description,
// el-input-number 清空后为 undefined,被 JSON 省略时后端会取默认 0,
// 排序被静默重置 —— 提交前兜底为 0 并明确表达「未填即 0」的意图
sort_order: form.sort_order ?? 0,
})
ElMessage.success('已保存')
} else {
await roleApi.createRole({
role_code: form.role_code,
role_name: form.role_name,
description: form.description || undefined,
sort_order: form.sort_order ?? 0,
})
ElMessage.success('角色已创建')
}
dialogVisible.value = false
await load()
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
} finally {
submitting.value = false
}
}
// ==================== 启停 / 删除 ====================
async function handleToggleStatus(row: RoleItem): Promise<void> {
const next = row.status === 1 ? 0 : 1
const action = next === 1 ? '启用' : '禁用'
await confirmAndRun({
confirmText:
next === 0
? `确定禁用角色「${row.role_name}」?禁用后该角色不再出现在用户表单的下拉中且不可再分配,但已绑定用户的权限不受影响。`
: `确定启用角色「${row.role_name}」?`,
title: `${action}确认`,
confirmButtonText: action,
successText: `已${action}`,
run: () => roleApi.setRoleStatus(row.id, next),
after: load,
})
}
async function handleDelete(row: RoleItem): Promise<void> {
await confirmAndRun({
confirmText: `确定删除角色「${row.role_name}」(${row.role_code})?删除后不可恢复。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '角色已删除',
run: () => roleApi.deleteRole(row.id),
after: reloadAfterDelete,
})
}
onMounted(load)
</script>
<template>
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="关键词">
<el-input
v-model="filters.keyword"
placeholder="编码或名称,模糊匹配"
clearable
style="width: 200px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增角色</el-button>
</div>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column label="编码" min-width="150">
<template #default="{ row }">
<span class="role-code">{{ row.role_code }}</span>
<el-tag v-if="isBuiltin(row)" class="builtin-tag" size="small" type="warning" disable-transitions>
内置
</el-tag>
</template>
</el-table-column>
<el-table-column prop="role_name" label="名称" min-width="120" />
<el-table-column prop="description" label="说明" min-width="180">
<template #default="{ row }">{{ row.description || '—' }}</template>
</el-table-column>
<el-table-column prop="sort_order" label="排序" width="80" align="center" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" />
</template>
</el-table-column>
<el-table-column label="用户数" width="90" align="center">
<template #default="{ row }">{{ row.user_count }}</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span :title="isBuiltin(row) ? '内置管理员角色不可禁用' : ''">
<el-button
link
type="warning"
size="small"
:disabled="isBuiltin(row)"
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
</span>
<span :title="deleteHint(row)">
<el-button
link
type="danger"
size="small"
:disabled="isBuiltin(row) || row.user_count > 0"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑角色' : '新增角色'"
width="480px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="角色编码" prop="role_code">
<el-input
v-model="form.role_code"
:disabled="isEdit"
placeholder="如 sales_manager,创建后不可修改"
/>
</el-form-item>
<el-form-item label="角色名称" prop="role_name">
<el-input v-model="form.role_name" placeholder="如 销售主管" />
</el-form-item>
<el-form-item label="说明" prop="description">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="选填" />
</el-form-item>
<el-form-item label="排序" prop="sort_order">
<el-input-number v-model="form.sort_order" :min="0" :max="9999" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.role-code {
font-family: Consolas, 'Courier New', monospace;
}
.builtin-tag {
margin-left: 6px;
}
</style>
+467
View File
@@ -0,0 +1,467 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import type { RoleOption } from '@/api/types'
import * as userApi from '@/api/users'
import type { UserItem, UserQueryParams } from '@/api/users'
import AppPagination from '@/components/table/AppPagination.vue'
import StatusTag from '@/components/table/StatusTag.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList'
import { useUserStore } from '@/store/user'
import { STATUS_OPTIONS } from '@/utils/constants'
import { handledError } from '@/utils/error'
import { formatDateTime } from '@/utils/format'
import { passwordRule } from '@/utils/validate'
/**
* 用户管理(需求 2.5.1)。
*
* 与后端约定对齐:
* - 不能删除/禁用自己、不能移除自己的管理员角色 —— 前端对当前登录行
* 禁用相应按钮(后端仍会拦截,界面禁用只是体验)
* - 密码按字节校验(8–72),中文一个字符占 3 字节
* - 重置密码会使用户已登录的会话立即失效
* - 编辑表单把未修改字段以空串提交,后端将 '' 归一为 NULL(清空即写回
* NULL),前端无需特殊处理
*/
const userStore = useUserStore()
const { confirmAndRun } = useConfirmAction()
// ==================== 列表 ====================
const filters = reactive({
username: '',
real_name: '',
status: undefined as number | undefined,
role_code: undefined as string | undefined,
})
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
usePagedList<UserItem, UserQueryParams>(userApi.listUsers, () => ({
username: filters.username,
real_name: filters.real_name,
status: filters.status,
role_code: filters.role_code,
}))
const roleOptions = ref<RoleOption[]>([])
async function loadRoleOptions(): Promise<void> {
try {
roleOptions.value = await userApi.listRoleOptions()
} catch (error) {
handledError(error) // 拦截器已提示;下拉为空不阻断列表使用
}
}
function resetFilters(): void {
filters.username = ''
filters.real_name = ''
filters.status = undefined
filters.role_code = undefined
search()
}
/** 是否当前登录用户本人(其危险操作按钮需禁用) */
function isSelf(row: UserItem): boolean {
return row.id === userStore.userInfo?.id
}
/** 自我保护提示:按钮外层 span 的 title(disabled 按钮自身不触发悬浮事件) */
function selfHint(row: UserItem): string {
return isSelf(row) ? '不能对自己执行此操作' : ''
}
// ==================== 新增 / 编辑 ====================
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
const isEdit = computed(() => editingId.value !== null)
const formRef = ref<FormInstance>()
const submitting = ref(false)
const form = reactive({
username: '',
password: '',
real_name: '',
phone: '',
email: '',
roles: [] as string[],
remark: '',
})
/** 编辑时不校验密码(密码走专门的重置入口) */
const rules = computed<FormRules>(() => ({
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 64, message: '用户名长度 2–64 个字符', trigger: 'blur' },
],
...(isEdit.value ? {} : { password: [{ required: true, validator: passwordRule, trigger: 'blur' }] }),
roles: [
{ required: true, type: 'array', min: 1, message: '至少选择一个角色', trigger: 'change' },
],
phone: [{ max: 20, message: '手机号最长 20 个字符', trigger: 'blur' }],
email: [{ max: 128, message: '邮箱最长 128 个字符', trigger: 'blur' }],
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
}))
function openCreate(): void {
editingId.value = null
Object.assign(form, {
username: '',
password: '',
real_name: '',
phone: '',
email: '',
roles: [],
remark: '',
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
function openEdit(row: UserItem): void {
editingId.value = row.id
Object.assign(form, {
username: row.username,
password: '',
real_name: row.real_name ?? '',
phone: row.phone ?? '',
email: row.email ?? '',
roles: [...row.roles],
remark: row.remark ?? '',
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
async function handleSubmit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (editingId.value !== null) {
await userApi.updateUser(editingId.value, {
real_name: form.real_name,
phone: form.phone,
email: form.email,
roles: form.roles,
remark: form.remark,
})
ElMessage.success('已保存')
} else {
await userApi.createUser({
username: form.username,
password: form.password,
real_name: form.real_name || undefined,
phone: form.phone || undefined,
email: form.email || undefined,
roles: form.roles,
remark: form.remark || undefined,
})
ElMessage.success('用户已创建')
}
dialogVisible.value = false
await load()
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
} finally {
submitting.value = false
}
}
// ==================== 重置密码 ====================
const resetVisible = ref(false)
const resetFormRef = ref<FormInstance>()
const resetSubmitting = ref(false)
const resetTarget = reactive({ id: 0, username: '', isSelf: false })
const resetForm = reactive({ newPassword: '' })
const resetRules: FormRules = {
newPassword: [{ required: true, validator: passwordRule, trigger: 'blur' }],
}
function openReset(row: UserItem): void {
resetTarget.id = row.id
resetTarget.username = row.username
resetTarget.isSelf = isSelf(row)
resetForm.newPassword = ''
resetVisible.value = true
resetFormRef.value?.clearValidate()
}
async function handleResetSubmit(): Promise<void> {
const valid = await resetFormRef.value?.validate().catch(() => false)
if (!valid) return
resetSubmitting.value = true
try {
await userApi.resetPassword(resetTarget.id, resetForm.newPassword)
ElMessage.success('密码已重置,该用户需重新登录')
resetVisible.value = false
} catch (error) {
handledError(error) // 拦截器已提示
} finally {
resetSubmitting.value = false
}
}
// ==================== 启停 / 删除 ====================
async function handleToggleStatus(row: UserItem): Promise<void> {
const next = row.status === 1 ? 0 : 1
const action = next === 1 ? '启用' : '禁用'
await confirmAndRun({
confirmText:
next === 0
? `确定禁用用户「${row.username}」?其已登录的会话将立即失效。`
: `确定启用用户「${row.username}」?`,
title: `${action}确认`,
confirmButtonText: action,
successText: `已${action}`,
run: () => userApi.setUserStatus(row.id, next),
after: load,
})
}
async function handleDelete(row: UserItem): Promise<void> {
await confirmAndRun({
confirmText: `确定删除用户「${row.username}」?删除为物理删除、不可恢复;如仅为停用账号,建议使用「禁用」以保留审计线索。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '用户已删除',
run: () => userApi.deleteUser(row.id),
after: reloadAfterDelete,
})
}
// ==================== 初始化 ====================
onMounted(() => {
// 两个请求各自容错(load 内部已兜底),互不拖累
void load()
void loadRoleOptions()
})
</script>
<template>
<div>
<el-card shadow="never">
<el-form class="filter-form" :inline="true" @submit.prevent>
<el-form-item label="用户名">
<el-input
v-model="filters.username"
placeholder="模糊匹配"
clearable
style="width: 160px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="姓名">
<el-input
v-model="filters.real_name"
placeholder="模糊匹配"
clearable
style="width: 140px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="角色">
<el-select v-model="filters.role_code" placeholder="全部" clearable style="width: 150px">
<el-option
v-for="role in roleOptions"
:key="role.role_code"
:label="role.role_name"
:value="role.role_code"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增用户</el-button>
</div>
<el-table v-loading="loading" :data="records" stripe>
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="real_name" label="姓名" min-width="100">
<template #default="{ row }">{{ row.real_name || '—' }}</template>
</el-table-column>
<el-table-column prop="phone" label="手机号" min-width="120">
<template #default="{ row }">{{ row.phone || '—' }}</template>
</el-table-column>
<el-table-column label="角色" min-width="140">
<template #default="{ row }">
<el-tag
v-for="role in row.roles"
:key="role"
class="role-tag"
size="small"
type="info"
disable-transitions
>
{{ role }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" />
</template>
</el-table-column>
<el-table-column label="最后登录" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.last_login_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openReset(row)">
重置密码
</el-button>
<span :title="selfHint(row)">
<el-button
link
type="warning"
size="small"
:disabled="isSelf(row)"
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
</span>
<span :title="selfHint(row)">
<el-button
link
type="danger"
size="small"
:disabled="isSelf(row)"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
</template>
</el-table-column>
</el-table>
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</el-card>
<!-- 新增 / 编辑 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑用户' : '新增用户'"
width="520px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="用户名" prop="username">
<el-input
v-model="form.username"
:disabled="isEdit"
placeholder="登录用户名,创建后不可修改"
/>
</el-form-item>
<el-form-item v-if="!isEdit" label="初始密码" prop="password">
<el-input
v-model="form.password"
type="password"
show-password
placeholder="8–72 字节(中文一个字符约 3 字节)"
/>
</el-form-item>
<el-form-item label="姓名" prop="real_name">
<el-input v-model="form.real_name" placeholder="真实姓名" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="选填" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="选填" />
</el-form-item>
<el-form-item label="角色" prop="roles">
<el-select v-model="form.roles" multiple placeholder="至少选择一个角色" style="width: 100%">
<el-option
v-for="role in roleOptions"
:key="role.role_code"
:label="`${role.role_name}(${role.role_code})`"
:value="role.role_code"
/>
</el-select>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
<!-- 重置密码 -->
<el-dialog v-model="resetVisible" title="重置密码" width="440px" destroy-on-close>
<p class="reset-tip">
将重置用户「{{ resetTarget.username }}」的密码。
重置后该用户<strong>已登录的所有会话立即失效</strong>,需用新密码重新登录。
<template v-if="resetTarget.isSelf">
<br />
注意:<strong>你正在重置自己的密码</strong>,保存后当前会话也会失效。
</template>
</p>
<el-form ref="resetFormRef" :model="resetForm" :rules="resetRules" label-width="80px">
<el-form-item label="新密码" prop="newPassword">
<el-input
v-model="resetForm.newPassword"
type="password"
show-password
placeholder="8–72 字节(中文一个字符约 3 字节)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="resetVisible = false">取消</el-button>
<el-button type="primary" :loading="resetSubmitting" @click="handleResetSubmit">
确认重置
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.role-tag {
margin-right: 4px;
}
.reset-tip {
margin: 0 0 16px;
color: var(--ys-text);
line-height: 1.7;
}
</style>