Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7212cd93f | ||
|
|
8355e0cc7d | ||
|
|
c83287819a | ||
|
|
e22c4bcdc9 | ||
|
|
ad5fe4a411 |
No files matched your search
+6
-1
@@ -26,7 +26,10 @@ out/
|
|||||||
|
|
||||||
# 日志
|
# 日志
|
||||||
*.log
|
*.log
|
||||||
logs/
|
# 后端运行日志目录(core/logging.py 创建)。
|
||||||
|
# 不要写成裸 `logs/` —— 那会匹配任意层级的同名目录,曾把
|
||||||
|
# frontend/src/views/system/logs 整个吞掉(文件进不了版本库)
|
||||||
|
/backend/logs/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
@@ -54,3 +57,5 @@ frontend/e2e/*.png
|
|||||||
|
|
||||||
# 备份
|
# 备份
|
||||||
backup/
|
backup/
|
||||||
|
.claude
|
||||||
|
CLAUDE.md
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
营销人员绩效工资核算系统。后端 Python/FastAPI,前端 Vue 3,数据存 MySQL,会话与缓存在 Redis。
|
营销人员绩效工资核算系统。后端 Python/FastAPI,前端 Vue 3,数据存 MySQL,会话与缓存在 Redis。
|
||||||
|
|
||||||
> 当前进度:登录鉴权链路已跑通(验证码 / JWT / 限流 / 登出黑名单 / 登录日志)。业务模块(基础数据、业务数据、绩效核算、报表)待需求澄清后开发。
|
> 当前进度:登录鉴权与系统管理(用户/角色/操作日志/日志查询)已完成,前端具备应用布局框架。业务模块(基础数据、业务数据、绩效核算、报表)待需求澄清后开发。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -81,7 +81,8 @@ pnpm type-check # 类型检查
|
|||||||
pnpm build # 类型检查 + 生产构建
|
pnpm build # 类型检查 + 生产构建
|
||||||
|
|
||||||
# 浏览器端到端(需容器、后端、前端都在运行)
|
# 浏览器端到端(需容器、后端、前端都在运行)
|
||||||
python e2e/login_flow.py
|
python e2e/login_flow.py # 登录链路 8 项
|
||||||
|
python e2e/system_management.py # 系统管理 13 项
|
||||||
```
|
```
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|||||||
+36
-1
@@ -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`)
|
- 提供 REST 接口(统一前缀 `/api`)
|
||||||
- 登录鉴权:图形验证码、JWT、登录限流、登出黑名单、登录日志
|
- 登录鉴权:图形验证码、JWT、登录限流、登出黑名单、登录日志
|
||||||
|
- 系统管理(需求 2.5.1):用户管理、角色管理、操作日志(自动审计)、操作与登录日志查询
|
||||||
- 后续承担:基础数据、业务数据、绩效核算、报表统计、T+ 数据同步
|
- 后续承担:基础数据、业务数据、绩效核算、报表统计、T+ 数据同步
|
||||||
|
|
||||||
## 技术栈与版本
|
## 技术栈与版本
|
||||||
@@ -53,6 +54,24 @@ app/
|
|||||||
| POST | `/api/v1/auth/logout` | 是 | 令牌入黑名单,立即失效 |
|
| POST | `/api/v1/auth/logout` | 是 | 令牌入黑名单,立即失效 |
|
||||||
| GET | `/api/v1/auth/me` | 是 | 当前用户与角色 |
|
| 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`。
|
统一响应 `{code, message, data}`,分页为 `list/total/pageNum/pageSize`。
|
||||||
状态码:400 参数错误 / 401 未登录 / 403 无权限 / 404 不存在 / 500 服务端异常。
|
状态码:400 参数错误 / 401 未登录 / 403 无权限 / 404 不存在 / 500 服务端异常。
|
||||||
|
|
||||||
@@ -118,6 +137,22 @@ Redis 黑名单只负责「用户主动登出」。
|
|||||||
bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是静默截断)。
|
bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是静默截断)。
|
||||||
中文一个字符占 3 字节,24 个汉字即到顶——`max_length=20` 这类字符数校验拦不住。
|
中文一个字符占 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 是安全问题,必须启动即失败。
|
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Revises: ${down_revision | comma,n}
|
|||||||
Create Date: ${create_date}
|
Create Date: ${create_date}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from typing import Sequence, Union
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -13,16 +13,22 @@ ${imports if imports else ""}
|
|||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = ${repr(up_revision)}
|
revision: str = ${repr(up_revision)}
|
||||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
down_revision: str | Sequence[str] | None = ${repr(down_revision)}
|
||||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
"""Upgrade schema."""
|
"""应用迁移。"""
|
||||||
${upgrades if upgrades else "pass"}
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
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"}
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -337,17 +337,19 @@ def upgrade() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def downgrade() -> 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_table("user_roles")
|
||||||
op.drop_index("idx_role_permissions_permission_id", table_name="role_permissions")
|
|
||||||
op.drop_table("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_table("login_logs")
|
||||||
op.drop_index(op.f("idx_users_phone"), table_name="users")
|
|
||||||
op.drop_table("users")
|
op.drop_table("users")
|
||||||
op.drop_table("roles")
|
op.drop_table("roles")
|
||||||
op.drop_table("permissions")
|
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
@@ -2,16 +2,18 @@
|
|||||||
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import ROLE_ADMIN
|
||||||
from app.core.db import get_db
|
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.core.security import decode_token
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.repositories import user_repository
|
from app.repositories import user_repository
|
||||||
from app.services import token_blacklist
|
from app.services import token_blacklist
|
||||||
|
from app.services.audit_service import RequestMeta
|
||||||
|
|
||||||
# auto_error=False:令牌缺失时由我们自己抛 AuthError,
|
# auto_error=False:令牌缺失时由我们自己抛 AuthError,
|
||||||
# 才能返回统一响应信封,而不是 FastAPI 默认的 {"detail": "Not authenticated"}
|
# 才能返回统一响应信封,而不是 FastAPI 默认的 {"detail": "Not authenticated"}
|
||||||
@@ -21,6 +23,29 @@ BearerCredentials = Annotated[HTTPAuthorizationCredentials | None, Depends(_bear
|
|||||||
DbSession = Annotated[Session, Depends(get_db)]
|
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:
|
def get_raw_token(credentials: BearerCredentials) -> str:
|
||||||
"""取原始令牌字符串。登出时需要它来解析 jti。"""
|
"""取原始令牌字符串。登出时需要它来解析 jti。"""
|
||||||
if credentials is None or not credentials.credentials:
|
if credentials is None or not credentials.credentials:
|
||||||
@@ -71,3 +96,20 @@ def _is_digits(value: object) -> bool:
|
|||||||
令牌内容不可信,直接 int() 会因非法输入抛 ValueError 而不是 401。
|
令牌内容不可信,直接 int() 会因非法输入抛 ValueError 而不是 401。
|
||||||
"""
|
"""
|
||||||
return isinstance(value, str) and value.isdigit()
|
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)]
|
||||||
@@ -8,6 +8,9 @@ from fastapi import APIRouter
|
|||||||
|
|
||||||
from app.api import health
|
from app.api import health
|
||||||
from app.api.v1 import auth
|
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()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@@ -17,4 +20,13 @@ api_router.include_router(health.router)
|
|||||||
# 业务接口:/api/v1/*
|
# 业务接口:/api/v1/*
|
||||||
v1_router = APIRouter(prefix="/v1")
|
v1_router = APIRouter(prefix="/v1")
|
||||||
v1_router.include_router(auth.router)
|
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)
|
api_router.include_router(v1_router)
|
||||||
@@ -4,7 +4,7 @@ from typing import Annotated
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
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.core.response import ApiResponse, ok
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.repositories.user_repository import get_role_codes
|
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=["认证"])
|
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="获取图形验证码")
|
@router.get("/captcha", response_model=ApiResponse[CaptchaOut], summary="获取图形验证码")
|
||||||
def get_captcha() -> ApiResponse[CaptchaOut]:
|
def get_captcha() -> ApiResponse[CaptchaOut]:
|
||||||
"""签发图形验证码。无需认证。
|
"""签发图形验证码。无需认证。
|
||||||
@@ -52,7 +42,7 @@ def login(payload: LoginIn, request: Request, session: DbSession) -> ApiResponse
|
|||||||
password=payload.password,
|
password=payload.password,
|
||||||
captcha_id=payload.captcha_id,
|
captcha_id=payload.captcha_id,
|
||||||
captcha_code=payload.captcha_code,
|
captcha_code=payload.captcha_code,
|
||||||
ip=_client_ip(request),
|
ip=client_ip(request),
|
||||||
user_agent=request.headers.get("user-agent"),
|
user_agent=request.headers.get("user-agent"),
|
||||||
)
|
)
|
||||||
return ok(result, message="登录成功")
|
return ok(result, message="登录成功")
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""系统管理路由(需求 2.5.1)。
|
||||||
|
|
||||||
|
含用户管理、角色管理、日志查询(操作日志 + 登录日志)四部分,全部要求管理员权限。
|
||||||
|
|
||||||
|
与 `api/v1/auth.py` 的分工:`auth` 是登录链路(未认证即可访问的验证码与登录,
|
||||||
|
以及任何登录用户都能访问的 `/me` 与登出);本包是**管理功能**。
|
||||||
|
"""
|
||||||
@@ -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))
|
||||||
@@ -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="角色已删除")
|
||||||
@@ -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))
|
||||||
@@ -36,3 +36,15 @@ PASSWORD_MAX_BYTES = 72
|
|||||||
# 剔除易混淆字符 0/O/1/I/l,降低人工识别错误率
|
# 剔除易混淆字符 0/O/1/I/l,降低人工识别错误率
|
||||||
CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||||
CAPTCHA_LENGTH = 4
|
CAPTCHA_LENGTH = 4
|
||||||
|
|
||||||
|
# ===== 操作日志词表(operation_logs 的 module / action 取值)=====
|
||||||
|
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致
|
||||||
|
MODULE_USER = "用户管理"
|
||||||
|
MODULE_ROLE = "角色管理"
|
||||||
|
|
||||||
|
ACTION_CREATE = "新增"
|
||||||
|
ACTION_UPDATE = "修改"
|
||||||
|
ACTION_DELETE = "删除"
|
||||||
|
ACTION_RESET_PASSWORD = "重置密码"
|
||||||
|
ACTION_ENABLE = "启用"
|
||||||
|
ACTION_DISABLE = "禁用"
|
||||||
@@ -11,12 +11,14 @@
|
|||||||
|
|
||||||
from app.models.base import Base, TimestampMixin
|
from app.models.base import Base, TimestampMixin
|
||||||
from app.models.login_log import LoginLog
|
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.role import Permission, Role, RolePermission
|
||||||
from app.models.user import User, UserRole
|
from app.models.user import User, UserRole
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
"LoginLog",
|
"LoginLog",
|
||||||
|
"OperationLog",
|
||||||
"Permission",
|
"Permission",
|
||||||
"Role",
|
"Role",
|
||||||
"RolePermission",
|
"RolePermission",
|
||||||
|
|||||||
@@ -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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import LoginLog
|
from app.models import LoginLog
|
||||||
|
from app.repositories.query_utils import LogFilters, apply_log_filters
|
||||||
|
|
||||||
# user_agent 列宽,超长需截断否则插入报错
|
# user_agent 列宽,超长需截断否则插入报错
|
||||||
_USER_AGENT_MAX_LEN = 255
|
_USER_AGENT_MAX_LEN = 255
|
||||||
@@ -36,3 +38,40 @@ def add(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def list_logs(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
filters: LogFilters,
|
||||||
|
ip: str | None = None,
|
||||||
|
) -> list[LoginLog]:
|
||||||
|
"""分页查询登录日志。
|
||||||
|
|
||||||
|
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
||||||
|
不确定,翻页会重复或遗漏(与用户列表同一理由)。
|
||||||
|
"""
|
||||||
|
stmt = _apply_filters(select(LoginLog), filters=filters, ip=ip)
|
||||||
|
stmt = stmt.order_by(LoginLog.id.desc()).offset(offset).limit(limit)
|
||||||
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
|
def count_logs(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
filters: LogFilters,
|
||||||
|
ip: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""统计符合条件的登录日志总数,供分页使用。"""
|
||||||
|
stmt = _apply_filters(select(func.count(LoginLog.id)), filters=filters, ip=ip)
|
||||||
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_filters(stmt: Select, *, filters: LogFilters, ip: str | None) -> Select:
|
||||||
|
"""应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。"""
|
||||||
|
stmt = apply_log_filters(stmt, LoginLog, filters)
|
||||||
|
if ip:
|
||||||
|
stmt = stmt.where(LoginLog.ip == ip)
|
||||||
|
return stmt
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""操作日志数据访问。
|
||||||
|
|
||||||
|
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||||||
|
|
||||||
|
写入前处理(截断 / 脱敏 / 序列化)集中在本层,与 `login_log_repository`
|
||||||
|
的做法一致:模型定义的长度常量在这里落实,调用方只需传业务值。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import Select, func, select
|
||||||
|
from sqlalchemy.orm import Session, defer
|
||||||
|
|
||||||
|
from app.core.logging import scrub
|
||||||
|
from app.models import OperationLog
|
||||||
|
from app.models.operation_log import (
|
||||||
|
ACTION_MAX_LEN,
|
||||||
|
ERROR_MESSAGE_MAX_LEN,
|
||||||
|
IP_MAX_LEN,
|
||||||
|
METHOD_MAX_LEN,
|
||||||
|
MODULE_MAX_LEN,
|
||||||
|
PATH_MAX_LEN,
|
||||||
|
REQUEST_DATA_MAX_BYTES,
|
||||||
|
SUMMARY_MAX_LEN,
|
||||||
|
USER_AGENT_MAX_LEN,
|
||||||
|
USERNAME_MAX_LEN,
|
||||||
|
)
|
||||||
|
from app.repositories.query_utils import LogFilters, apply_log_filters
|
||||||
|
|
||||||
|
# 键名(不区分大小写)含以下子串的字段,其值一律整体抹除。
|
||||||
|
# 口径与 core.logging.scrub 的键名正则一致,但改为结构化处理 ——
|
||||||
|
# 文本正则有三种逃逸形态:值以空白开头时整体匹配失败(完全不脱敏)、
|
||||||
|
# 值含空格/逗号时只抹首段、值含引号时既残留又破坏 JSON 结构。
|
||||||
|
_SENSITIVE_KEY_PARTS = ("password", "passwd", "pwd", "secret", "token", "app_key", "access_key")
|
||||||
|
|
||||||
|
_MASK = "***"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OperationLogEntry:
|
||||||
|
"""一条待写入的操作日志(全字段 DTO)。
|
||||||
|
|
||||||
|
替代 14 个平铺参数:字段若在 audit() / _safe_write() / add() 三处
|
||||||
|
重复展开,增删字段时任何一侧漏改都只表现为审计数据不完整、不报错
|
||||||
|
(success 默认 0 的语义使漏传尤其隐蔽)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
user_id: int | None
|
||||||
|
username: str
|
||||||
|
module: str
|
||||||
|
action: str
|
||||||
|
summary: str
|
||||||
|
success: bool
|
||||||
|
method: str | None = None
|
||||||
|
path: str | None = None
|
||||||
|
ip: str | None = None
|
||||||
|
user_agent: str | None = None
|
||||||
|
request_data: dict | None = None
|
||||||
|
error_message: str | None = None
|
||||||
|
duration_ms: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def add(session: Session, entry: OperationLogEntry) -> None:
|
||||||
|
"""写入一条操作日志。只 flush 不 commit。
|
||||||
|
|
||||||
|
所有文本列按模型定义的长度常量截断 —— 超长在 MySQL 严格模式下抛 1406,
|
||||||
|
而「审计失败不影响业务」的约定会把该异常吞掉,结果是整条日志**静默丢失**,
|
||||||
|
事后极难排查。截断是唯一防线,必须在此层无条件执行。
|
||||||
|
"""
|
||||||
|
session.add(
|
||||||
|
OperationLog(
|
||||||
|
user_id=entry.user_id,
|
||||||
|
username=entry.username[:USERNAME_MAX_LEN],
|
||||||
|
module=entry.module[:MODULE_MAX_LEN],
|
||||||
|
action=entry.action[:ACTION_MAX_LEN],
|
||||||
|
summary=entry.summary[:SUMMARY_MAX_LEN],
|
||||||
|
method=_clip(entry.method, METHOD_MAX_LEN),
|
||||||
|
path=_clip(entry.path, PATH_MAX_LEN),
|
||||||
|
ip=_clip(entry.ip, IP_MAX_LEN),
|
||||||
|
user_agent=_clip(entry.user_agent, USER_AGENT_MAX_LEN),
|
||||||
|
request_data=_serialize_request_data(entry.request_data),
|
||||||
|
success=1 if entry.success else 0,
|
||||||
|
error_message=_clip(entry.error_message, ERROR_MESSAGE_MAX_LEN),
|
||||||
|
duration_ms=entry.duration_ms,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_id(session: Session, log_id: int) -> OperationLog | None:
|
||||||
|
"""按主键查询单条日志(详情端点用,含 request_data)。不存在返回 None。"""
|
||||||
|
return session.get(OperationLog, log_id)
|
||||||
|
|
||||||
|
|
||||||
|
def list_logs(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
filters: LogFilters,
|
||||||
|
module: str | None = None,
|
||||||
|
action: str | None = None,
|
||||||
|
) -> list[OperationLog]:
|
||||||
|
"""分页查询操作日志。
|
||||||
|
|
||||||
|
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
|
||||||
|
不确定,翻页会重复或遗漏(与用户列表同一理由)。
|
||||||
|
|
||||||
|
**排除 request_data 列**(TEXT,单行最大 60000 字节):列表页默认不
|
||||||
|
展示它,全列加载会让翻页负担随日志表增长线性放大;详情走 `get_by_id`
|
||||||
|
按需读取。
|
||||||
|
"""
|
||||||
|
stmt = _apply_filters(
|
||||||
|
select(OperationLog).options(defer(OperationLog.request_data)),
|
||||||
|
filters=filters,
|
||||||
|
module=module,
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
stmt = stmt.order_by(OperationLog.id.desc()).offset(offset).limit(limit)
|
||||||
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
|
def count_logs(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
filters: LogFilters,
|
||||||
|
module: str | None = None,
|
||||||
|
action: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""统计符合条件的日志总数,供分页使用。"""
|
||||||
|
stmt = _apply_filters(
|
||||||
|
select(func.count(OperationLog.id)), filters=filters, module=module, action=action
|
||||||
|
)
|
||||||
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_filters(
|
||||||
|
stmt: Select,
|
||||||
|
*,
|
||||||
|
filters: LogFilters,
|
||||||
|
module: str | None,
|
||||||
|
action: str | None,
|
||||||
|
) -> Select:
|
||||||
|
"""应用筛选条件(公共部分复用 query_utils,与登录日志同口径)。"""
|
||||||
|
stmt = apply_log_filters(stmt, OperationLog, filters)
|
||||||
|
if module:
|
||||||
|
stmt = stmt.where(OperationLog.module == module)
|
||||||
|
if action:
|
||||||
|
stmt = stmt.where(OperationLog.action == action)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def _clip(text: str | None, limit: int) -> str | None:
|
||||||
|
"""按字符数截断(长度常量与数据库列宽均按字符定义)。"""
|
||||||
|
return text[:limit] if text else None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_sensitive_key(key: object) -> bool:
|
||||||
|
name = str(key).lower()
|
||||||
|
return any(part in name for part in _SENSITIVE_KEY_PARTS)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_sensitive(value: object) -> object:
|
||||||
|
"""递归抹除请求参数中的敏感字段值(dict 按键名整体替换、list 逐项递归)。"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
key: _MASK if _is_sensitive_key(key) else _sanitize_sensitive(item)
|
||||||
|
for key, item in value.items()
|
||||||
|
}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_sanitize_sensitive(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_request_data(data: dict | None) -> str | None:
|
||||||
|
"""请求参数 → JSON 文本:两段脱敏 → 字节边界截断。
|
||||||
|
|
||||||
|
脱敏分两层,各司其职:
|
||||||
|
|
||||||
|
- **结构化抹除**(`_sanitize_sensitive`):按键名整体置 ``***``,
|
||||||
|
不依赖文本模式匹配 —— 密码含空格、引号、前导空白等形态都不受影响
|
||||||
|
- `core.logging.scrub` 作文本兜底:覆盖手机号、Bearer 等无法按键名
|
||||||
|
判断的值
|
||||||
|
|
||||||
|
截断必须在 **UTF-8 字节边界** 进行:request_data 列是 TEXT(65535 字节
|
||||||
|
上限),而 request_data 上限 60000 字节也是按字节定的;若按字符切片,
|
||||||
|
中文场景下字符数与字节数相差 3 倍同样可能超限;先编码再按字节切片
|
||||||
|
则可能把多字节字符切成两半产生非法序列。故顺序为:编码 → 字节切片 →
|
||||||
|
宽容解码丢弃残片。encode 用 ``errors="replace"`` 兜底孤立代理字符 ——
|
||||||
|
请求 JSON 里的 ``\\ud800`` 转义会解析为孤立代理,直接 encode 会抛
|
||||||
|
UnicodeEncodeError,被审计兜底逻辑吞掉后该请求的记录**静默丢失**。
|
||||||
|
"""
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
text = scrub(json.dumps(_sanitize_sensitive(data), ensure_ascii=False, default=str))
|
||||||
|
encoded = text.encode("utf-8", errors="replace")
|
||||||
|
if len(encoded) <= REQUEST_DATA_MAX_BYTES:
|
||||||
|
return text
|
||||||
|
return encoded[:REQUEST_DATA_MAX_BYTES].decode("utf-8", errors="ignore")
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""repositories 层公共查询工具。
|
||||||
|
|
||||||
|
集中两类易错点,避免各 repository 各写一份、口径漂移:
|
||||||
|
|
||||||
|
- **模糊匹配的 LIKE 通配符转义**:直接 ``f"%{value}%"`` 拼接时,输入中的
|
||||||
|
下划线会作为单字符通配执行(本系统用户名普遍含下划线,误配是常态而非
|
||||||
|
理论),百分号则命中全表并触发全表扫描
|
||||||
|
- **两类日志共有的筛选条件**:用户名/结果/时间范围在操作日志与登录日志
|
||||||
|
之间逐字重复,抽为同一实现后「列表与计数」「操作与登录」不可能对不上
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import Select
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LogFilters:
|
||||||
|
"""两类日志(操作/登录)共有的筛选条件。"""
|
||||||
|
|
||||||
|
username: str | None = None
|
||||||
|
success: int | None = None
|
||||||
|
start_time: datetime | None = None
|
||||||
|
end_time: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def fuzzy(stmt: Select, column: Any, value: str) -> Select:
|
||||||
|
"""模糊匹配(含通配符自动转义)。
|
||||||
|
|
||||||
|
``contains(autoescape=True)`` 会转义输入中的 ``%``/``_`` 并附加 ``ESCAPE``
|
||||||
|
子句,把用户输入当字面量匹配。
|
||||||
|
"""
|
||||||
|
return stmt.where(column.contains(value, autoescape=True))
|
||||||
|
|
||||||
|
|
||||||
|
def apply_log_filters(stmt: Select, model: Any, filters: LogFilters) -> Select:
|
||||||
|
"""应用两类日志共有的筛选条件。
|
||||||
|
|
||||||
|
``model`` 传 ``OperationLog`` / ``LoginLog`` 的模型类(两者字段同名同型)。
|
||||||
|
|
||||||
|
列表与计数共用本函数,避免「列表按条件过滤而总数不按条件过滤」——
|
||||||
|
漏改一侧时表现为翻到最后一页是空页或页码虚增,且没有任何报错。
|
||||||
|
"""
|
||||||
|
if filters.username:
|
||||||
|
stmt = fuzzy(stmt, model.username, filters.username)
|
||||||
|
if filters.success is not None:
|
||||||
|
stmt = stmt.where(model.success == filters.success)
|
||||||
|
if filters.start_time:
|
||||||
|
stmt = stmt.where(model.create_time >= filters.start_time)
|
||||||
|
if filters.end_time:
|
||||||
|
stmt = stmt.where(model.create_time <= filters.end_time)
|
||||||
|
return stmt
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""角色数据访问。
|
||||||
|
|
||||||
|
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||||||
|
|
||||||
|
`get_all_roles` 与 `get_roles_by_codes` 原位于 `user_repository`,
|
||||||
|
因角色管理成为独立模块而迁入本文件。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Select, func, or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import Role, UserRole
|
||||||
|
|
||||||
|
# ==================== 读取 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_id(session: Session, role_id: int, *, for_update: bool = False) -> Role | None:
|
||||||
|
"""按主键查询角色。不存在返回 None。
|
||||||
|
|
||||||
|
``for_update=True`` 走 ``SELECT ... FOR UPDATE`` 锁定角色行 —— 删除前的
|
||||||
|
「有用户绑定禁删」校验必须用它:校验与 DELETE 之间若发生并发绑定,
|
||||||
|
`user_roles` 的 CASCADE 会把新绑定一并静默剥离。锁行后,并发绑定的
|
||||||
|
外键检查会等待,删除得以基于最新状态做出(对方失败而非数据被吞)。
|
||||||
|
"""
|
||||||
|
if for_update:
|
||||||
|
stmt = select(Role).where(Role.id == role_id).with_for_update()
|
||||||
|
return session.execute(stmt).scalar_one_or_none()
|
||||||
|
return session.get(Role, role_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_code(session: Session, role_code: str) -> Role | None:
|
||||||
|
"""按编码查询角色。不存在返回 None。"""
|
||||||
|
return session.execute(select(Role).where(Role.role_code == role_code)).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def get_roles_by_codes(session: Session, role_codes: list[str]) -> list[Role]:
|
||||||
|
"""按编码批量取角色。用于校验前端传来的角色是否存在。"""
|
||||||
|
if not role_codes:
|
||||||
|
return []
|
||||||
|
stmt = select(Role).where(Role.role_code.in_(role_codes))
|
||||||
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_roles(session: Session) -> list[Role]:
|
||||||
|
"""取全部启用角色,供前端下拉选择。"""
|
||||||
|
stmt = select(Role).where(Role.status == 1).order_by(Role.sort_order, Role.id)
|
||||||
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
|
def list_roles(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
keyword: str | None = None,
|
||||||
|
status: int | None = None,
|
||||||
|
) -> list[Role]:
|
||||||
|
"""分页查询角色(管理列表:含停用角色)。
|
||||||
|
|
||||||
|
排序与下拉口径一致(sort_order, id),保持同一套相对次序。
|
||||||
|
"""
|
||||||
|
stmt = _apply_filters(select(Role), keyword=keyword, status=status)
|
||||||
|
stmt = stmt.order_by(Role.sort_order, Role.id).offset(offset).limit(limit)
|
||||||
|
return list(session.execute(stmt).scalars())
|
||||||
|
|
||||||
|
|
||||||
|
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
|
||||||
|
"""统计符合条件的角色总数,供分页使用。"""
|
||||||
|
stmt = _apply_filters(select(func.count(Role.id)), keyword=keyword, status=status)
|
||||||
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
|
||||||
|
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
|
||||||
|
|
||||||
|
关键词匹配走 `contains(autoescape=True)`(转义 LIKE 通配符)——
|
||||||
|
角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。
|
||||||
|
"""
|
||||||
|
if keyword:
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Role.role_code.contains(keyword, autoescape=True),
|
||||||
|
Role.role_name.contains(keyword, autoescape=True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if status is not None:
|
||||||
|
stmt = stmt.where(Role.status == status)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def count_users_by_role(session: Session, role_ids: list[int]) -> dict[int, int]:
|
||||||
|
"""批量统计各角色关联的用户数。
|
||||||
|
|
||||||
|
一次 GROUP BY 取回全部计数,而非在列表循环里逐行查询(N+1)。
|
||||||
|
"""
|
||||||
|
if not role_ids:
|
||||||
|
return {}
|
||||||
|
stmt = (
|
||||||
|
select(UserRole.role_id, func.count())
|
||||||
|
.where(UserRole.role_id.in_(role_ids))
|
||||||
|
.group_by(UserRole.role_id)
|
||||||
|
)
|
||||||
|
return {int(role_id): int(count) for role_id, count in session.execute(stmt)}
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 写入 ====================
|
||||||
|
|
||||||
|
|
||||||
|
def create(session: Session, role: Role) -> Role:
|
||||||
|
"""新增角色。只 flush 以取得自增 id。"""
|
||||||
|
session.add(role)
|
||||||
|
session.flush()
|
||||||
|
return role
|
||||||
|
|
||||||
|
|
||||||
|
def delete(session: Session, role: Role) -> None:
|
||||||
|
"""删除角色。
|
||||||
|
|
||||||
|
调用方(service)必须先校验该角色无用户绑定 —— `user_roles` 外键是
|
||||||
|
CASCADE,直接删会静默剥离绑定用户的权限。
|
||||||
|
"""
|
||||||
|
session.delete(role)
|
||||||
|
session.flush()
|
||||||
@@ -1,14 +1,23 @@
|
|||||||
"""用户与角色数据访问。
|
"""用户数据访问(角色相关的读取与维护见 `role_repository`)。
|
||||||
|
|
||||||
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import Select, func, select
|
||||||
from sqlalchemy.orm import Session
|
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:
|
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 决定。"""
|
"""更新最后登录时间。只 flush,提交由 service 决定。"""
|
||||||
user.last_login_time = moment
|
user.last_login_time = moment
|
||||||
session.flush()
|
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()
|
||||||
@@ -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
|
||||||
@@ -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="状态筛选")
|
||||||
@@ -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="角色编码筛选")
|
||||||
@@ -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))
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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 |
|
| `unit/test_auth_schemas.py` | 密码**按字节**校验 | 9 | 24 个汉字 = 72 字节正好、25 个被拒 —— 按字符数校验(如 `max_length=20`)根本拦不住,会让登录接口 500 |
|
||||||
| `integration/test_auth_flow.py` | 整条链路**串联正确性** | 16 | 验证码→登录→持令牌访问→登出失效;验证码重放被拒;失败原因如实落库且**日志表无任何密码字段**;账号不存在与密码错误文案完全相同(防枚举);禁用账号与 `token_version` 提升均使旧令牌立即失效 |
|
| `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 | 全部 | 120 | ✅ 通过(4.99s) | 阶段 1 骨架 + 阶段 2 数据库完成后首次全量运行 |
|
||||||
| 2026-10-09 | 全部 | 119 | ✅ 通过(5.01s) | ORM 由异步改回同步后回归;净减 1 例(`sync_database_url` 独立配置已合并) |
|
| 2026-10-09 | 全部 | 119 | ✅ 通过(5.01s) | ORM 由异步改回同步后回归;净减 1 例(`sync_database_url` 独立配置已合并) |
|
||||||
| 2026-10-09 | 全部 | 184 | ✅ 通过(25.46s) | **登录鉴权模块完成**后统一编写并执行该模块测试套件(新增 65 例)。集成用例需容器在跑 |
|
| 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 | 验证码沿用 `captcha` 库默认渲染 | 随机彩色字符 + 重噪点,与界面视觉体系冲突;该库最后更新于 2025-03,内部还引用了 Pillow 的旧别名 | 改为自绘(`core/captcha_image.py`):配色与界面统一,难度由逐字旋转、抖动、干扰曲线与噪点保证;仅沿用其内置字体文件 |
|
||||||
| 2026-10-09 | 噪点用 `ellipse` 绘制 | 3×3 包围盒画出的椭圆恰好填充中心与四个边中点,形状就是「+」,形成规则的十字纹理——既显眼又容易被整体滤波抹掉,起不到干扰作用 | 改用单像素点与 2×2 方块 |
|
| 2026-10-09 | 噪点用 `ellipse` 绘制 | 3×3 包围盒画出的椭圆恰好填充中心与四个边中点,形状就是「+」,形成规则的十字纹理——既显眼又容易被整体滤波抹掉,起不到干扰作用 | 改用单像素点与 2×2 方块 |
|
||||||
| 2026-10-09 | `tests/unit/test_jwt.py` 两处赋值了未使用的 `settings`(ruff F841) | 变量未被引用,属无效代码 | 删除赋值;该文件的 `get_settings` 导入仍被其他用例使用 |
|
| 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+ 对接方式确认 |
|
| 基础数据管理 | 未开始 | 依赖 T+ 对接方式确认 |
|
||||||
| 业务数据管理 | 未开始 | 依赖需求澄清(回款天数口径等 10 项) |
|
| 业务数据管理 | 未开始 | 依赖需求澄清(回款天数口径等 10 项) |
|
||||||
| 绩效工资核算 | 未开始 | 依赖需求澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率) |
|
| 绩效工资核算 | 未开始 | 依赖需求澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率) |
|
||||||
| 报表统计 | 未开始 | 依赖报表口径确认 |
|
| 报表统计 | 未开始 | 依赖报表口径确认 |
|
||||||
|
|
||||||
|
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
"""DDL 漂移检测。
|
"""DDL 漂移检测。
|
||||||
|
|
||||||
结构定义同时存在于两处:
|
结构定义同时存在于两处:
|
||||||
- `db/migration/V1__*.sql` —— 事实来源,生产迁移用
|
- `db/migration/V*.sql` —— 事实来源,生产迁移用
|
||||||
- `db/init/init-dev.sql` —— 开发环境重置脚本,Docker 首次启动唯一会自动执行的位置
|
- `db/init/init-dev.sql` —— 开发环境重置脚本,Docker 首次启动唯一会自动执行的位置
|
||||||
|
|
||||||
两份 DDL 手工同步迟早会漂移,且后果隐蔽:开发库的表结构与迁移脚本不一致,
|
两份 DDL 手工同步迟早会漂移,且后果隐蔽:开发库的表结构与迁移脚本不一致,
|
||||||
新人克隆下来建出的库和生产不一样,等到上线才发现。
|
新人克隆下来建出的库和生产不一样,等到上线才发现。
|
||||||
本测试把「靠自觉」变成「可自动拦截」,成本极低。
|
本测试把「靠自觉」变成「可自动拦截」,成本极低。
|
||||||
|
|
||||||
|
**必须比对全部 V*.sql 的合集**:早期版本只解析 V1,新增 V2 之后,
|
||||||
|
两份 DDL 同时缺表仍会被判为「一致」,漂移守卫形同虚设。
|
||||||
|
|
||||||
注:本文件不需要容器,放在 integration 下是因为它校验的是交付产物而非应用代码。
|
注:本文件不需要容器,放在 integration 下是因为它校验的是交付产物而非应用代码。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -17,8 +20,9 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
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"
|
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)
|
_CREATE_TABLE = re.compile(r"CREATE TABLE\s+`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL)
|
||||||
_COLUMN_LINE = re.compile(r"^\s{4}`?(\w+)`?\s", re.MULTILINE)
|
_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
|
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")
|
@pytest.fixture(scope="module")
|
||||||
def migration_tables() -> dict[str, set[str]]:
|
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")
|
@pytest.fixture(scope="module")
|
||||||
@@ -52,7 +85,7 @@ class TestSchemaSync:
|
|||||||
"""两份 DDL 的结构一致性。"""
|
"""两份 DDL 的结构一致性。"""
|
||||||
|
|
||||||
def test_文件存在(self) -> None:
|
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}"
|
assert INIT_DEV_FILE.is_file(), f"缺少开发初始化脚本:{INIT_DEV_FILE}"
|
||||||
|
|
||||||
def test_表集合一致(self, migration_tables: dict, init_dev_tables: dict) -> None:
|
def test_表集合一致(self, migration_tables: dict, init_dev_tables: dict) -> None:
|
||||||
@@ -74,16 +107,28 @@ class TestSchemaSync:
|
|||||||
|
|
||||||
|
|
||||||
class TestSeedData:
|
class TestSeedData:
|
||||||
"""字典数据在两处应保持同步。"""
|
"""字典数据与版本标记。"""
|
||||||
|
|
||||||
@pytest.mark.parametrize("role_code", ["admin", "user"])
|
@pytest.mark.parametrize("role_code", ["admin", "user"])
|
||||||
def test_角色初始化数据在两处都存在(self, role_code: str) -> None:
|
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")
|
init_sql = INIT_DEV_FILE.read_text(encoding="utf-8")
|
||||||
assert f"'{role_code}'" in migration_sql
|
assert f"'{role_code}'" in migration_sql
|
||||||
assert f"'{role_code}'" in init_sql
|
assert f"'{role_code}'" in init_sql
|
||||||
|
|
||||||
def test_开发脚本标记了迁移版本(self) -> None:
|
def test_开发脚本标记的版本号等于当前head(self) -> None:
|
||||||
"""不写 alembic_version 的话,在开发库上执行 upgrade head 会重复建表而失败。"""
|
"""版本号必须等于 head,且不能只断言「存在」。
|
||||||
|
|
||||||
|
落后的后果:开发库重建后执行 `alembic upgrade head`,alembic 认为该迁移
|
||||||
|
尚未应用,重复建表并报 1050 Table already exists,只能手工补版本或 down -v。
|
||||||
|
"""
|
||||||
init_sql = INIT_DEV_FILE.read_text(encoding="utf-8")
|
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
|
||||||
@@ -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 "")
|
||||||
@@ -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
|
||||||
@@ -11,7 +11,15 @@ from sqlalchemy.dialects import mysql
|
|||||||
|
|
||||||
from app.models import Base
|
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:
|
def _ddl(table_name: str) -> str:
|
||||||
@@ -101,7 +109,7 @@ class TestTimestampColumns:
|
|||||||
def test_带时间戳的表含ON_UPDATE子句(self, table_name: str) -> None:
|
def test_带时间戳的表含ON_UPDATE子句(self, table_name: str) -> None:
|
||||||
assert "ON UPDATE CURRENT_TIMESTAMP" in _ddl(table_name)
|
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:
|
def test_仅追加的日志表不含ON_UPDATE子句(self, table_name: str) -> None:
|
||||||
"""日志是不可变记录,没有"更新时间"语义。"""
|
"""日志是不可变记录,没有"更新时间"语义。"""
|
||||||
assert "ON UPDATE CURRENT_TIMESTAMP" not in _ddl(table_name)
|
assert "ON UPDATE CURRENT_TIMESTAMP" not in _ddl(table_name)
|
||||||
|
|||||||
@@ -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
@@ -5,16 +5,18 @@
|
|||||||
-- 数据卷已存在时修改本文件不会生效,需要:
|
-- 数据卷已存在时修改本文件不会生效,需要:
|
||||||
-- docker compose -f docker/docker-compose.dev.yml down -v && up -d
|
-- docker compose -f docker/docker-compose.dev.yml down -v && up -d
|
||||||
--
|
--
|
||||||
-- 内容 = V1 迁移的完整结构(额外带 DROP,开发环境允许重置)
|
-- 内容 = db/migration/ 下**全部迁移**的最终结构(额外带 DROP,开发环境允许重置)
|
||||||
-- 与 db/migration/V1__init_rbac_and_login_logs.sql 必须保持一致,
|
-- 必须与 db/migration/V*.sql 的合集保持一致,
|
||||||
-- 由 backend/tests/integration/test_schema_sync.py 自动校验,防止两份 DDL 漂移
|
-- 由 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;
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
-- 删除顺序遵循外键依赖:先删子表
|
-- 删除顺序遵循外键依赖:先删子表
|
||||||
|
DROP TABLE IF EXISTS `operation_logs`;
|
||||||
DROP TABLE IF EXISTS `role_permissions`;
|
DROP TABLE IF EXISTS `role_permissions`;
|
||||||
DROP TABLE IF EXISTS `user_roles`;
|
DROP TABLE IF EXISTS `user_roles`;
|
||||||
DROP TABLE IF EXISTS `login_logs`;
|
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);
|
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 迁移中的初始化数据一致)
|
-- 字典数据:角色(与 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);
|
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 (
|
CREATE TABLE alembic_version (
|
||||||
version_num VARCHAR(32) NOT NULL,
|
version_num VARCHAR(32) NOT NULL,
|
||||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||||
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
INSERT INTO alembic_version (version_num) VALUES ('6165691326d4');
|
INSERT INTO alembic_version (version_num) VALUES ('a45a8087831b');
|
||||||
@@ -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
@@ -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/
|
src/
|
||||||
├── api/
|
├── api/
|
||||||
│ ├── request.ts # axios 实例、拦截器、统一信封解包
|
│ ├── request.ts # axios 实例、拦截器、统一信封解包
|
||||||
│ └── auth.ts # 认证接口封装与类型
|
│ ├── auth.ts # 认证接口封装与类型
|
||||||
├── assets/styles/ # 全局样式
|
│ ├── users.ts / roles.ts / logs.ts # 系统管理接口封装
|
||||||
├── components/ # 通用组件(待补充)
|
│ └── types.ts # 分页等公共类型
|
||||||
├── composables/ # 组合式函数(待补充)
|
├── assets/styles/ # 全局样式与品牌 token(:root 的 --ys-*)
|
||||||
├── router/index.ts # 路由表与全局守卫
|
├── 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/
|
├── store/
|
||||||
│ ├── index.ts # Pinia 实例
|
│ ├── index.ts # Pinia 实例
|
||||||
│ └── user.ts # 会话状态:token、用户信息、登录/登出
|
│ └── user.ts # 会话状态:token、用户信息、登录/登出
|
||||||
├── utils/storage.ts # localStorage 读写
|
├── utils/ # storage/format/validate/constants/error(handledError)
|
||||||
└── views/
|
└── views/
|
||||||
├── login/ # 登录页
|
├── 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` 逐条画出——**一个统一的入场动作,而不是散落的特效**。
|
**签名元素**:品牌面板的水纹层理线,越往下越密(如潜向深处),载入时用 `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
|
```bash
|
||||||
# 前置:MySQL/Redis 容器、后端、前端 dev server 均在运行
|
# 前置:MySQL/Redis 容器、后端、前端 dev server 均在运行
|
||||||
python e2e/login_flow.py
|
python e2e/login_flow.py
|
||||||
|
# 管理员密码经环境变量传入(不写死在源码里)
|
||||||
|
E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
|
||||||
```
|
```
|
||||||
|
|
||||||
需要 Python `playwright` 包与 chromium(`python -m playwright install chromium`)。
|
需要 Python `playwright` 包与 chromium(`python -m playwright install chromium`)。
|
||||||
|
脚本自建的测试数据(`e2e_` 前缀)在正常流程内清理;中途失败会残留在开发库。
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,4 +1,4 @@
|
|||||||
"""浏览器端端到端验证:登录 + 图形验证码。
|
"""浏览器端到端验证:登录 + 图形验证码。
|
||||||
|
|
||||||
验证的是**只有真实浏览器才能暴露**的问题:
|
验证的是**只有真实浏览器才能暴露**的问题:
|
||||||
|
|
||||||
@@ -20,53 +20,28 @@
|
|||||||
cd frontend && python e2e/login_flow.py
|
cd frontend && python e2e/login_flow.py
|
||||||
|
|
||||||
依赖:Python `playwright` 包与 chromium(`python -m playwright install chromium`)。
|
依赖: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
|
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"
|
USERNAME = "admin"
|
||||||
PASSWORD = "Admin@2026"
|
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:
|
def run(page: Page) -> None:
|
||||||
captured_captcha_ids: list[str] = []
|
captured_captcha_ids: list[str] = []
|
||||||
console_errors: list[str] = []
|
console_errors = collect_console_errors(page)
|
||||||
|
|
||||||
def on_response(response) -> None:
|
def on_response(response) -> None:
|
||||||
if "/api/v1/auth/captcha" not in response.url or response.status != 200:
|
if "/api/v1/auth/captcha" not in response.url or response.status != 200:
|
||||||
@@ -79,27 +54,11 @@ def run(page: Page) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
page.on("response", on_response)
|
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. 路由守卫:未登录访问首页应被拦回登录页 ----
|
# ---- 1. 路由守卫:未登录访问首页应被拦回登录页 ----
|
||||||
page.goto(BASE_URL, wait_until="networkidle")
|
page.goto(BASE_URL, wait_until="networkidle")
|
||||||
if "/login" not in page.url:
|
if "/login" not in page.url:
|
||||||
_fail("路由守卫", f"未登录访问首页未被拦截,当前 URL={page.url}")
|
fail("路由守卫", f"未登录访问首页未被拦截,当前 URL={page.url}")
|
||||||
print(f"✓ 1. 路由守卫 未登录访问 / → 重定向到 {page.url}")
|
print(f"✓ 1. 路由守卫 未登录访问 / → 重定向到 {page.url}")
|
||||||
|
|
||||||
# ---- 2. 验证码渲染(证明 浏览器→Vite代理→uvicorn→Redis 全链路通)----
|
# ---- 2. 验证码渲染(证明 浏览器→Vite代理→uvicorn→Redis 全链路通)----
|
||||||
@@ -107,9 +66,9 @@ def run(page: Page) -> None:
|
|||||||
image.wait_for(state="visible", timeout=10_000)
|
image.wait_for(state="visible", timeout=10_000)
|
||||||
src = image.get_attribute("src") or ""
|
src = image.get_attribute("src") or ""
|
||||||
if not src.startswith("data:image/png;base64,"):
|
if not src.startswith("data:image/png;base64,"):
|
||||||
_fail("验证码渲染", f"图片 src 前缀异常:{src[:40]}")
|
fail("验证码渲染", f"图片 src 前缀异常:{src[:40]}")
|
||||||
if len(src) < 1000:
|
if len(src) < 1000:
|
||||||
_fail("验证码渲染", f"图片数据过小({len(src)}),可能未真正渲染")
|
fail("验证码渲染", f"图片数据过小({len(src)}),可能未真正渲染")
|
||||||
print(f"✓ 2. 验证码渲染 PNG base64 长度 {len(src)}(代理链路已通)")
|
print(f"✓ 2. 验证码渲染 PNG base64 长度 {len(src)}(代理链路已通)")
|
||||||
|
|
||||||
# ---- 3. 输错验证码 → 应提示且验证码自动刷新 ----
|
# ---- 3. 输错验证码 → 应提示且验证码自动刷新 ----
|
||||||
@@ -121,38 +80,38 @@ def run(page: Page) -> None:
|
|||||||
page.wait_for_timeout(1500)
|
page.wait_for_timeout(1500)
|
||||||
|
|
||||||
if "/login" not in page.url:
|
if "/login" not in page.url:
|
||||||
_fail("失败处理", f"登录失败后不应离开登录页,当前 URL={page.url}")
|
fail("失败处理", f"登录失败后不应离开登录页,当前 URL={page.url}")
|
||||||
second_id = captured_captcha_ids[-1]
|
second_id = captured_captcha_ids[-1]
|
||||||
if second_id == first_id:
|
if second_id == first_id:
|
||||||
_fail("验证码刷新", "登录失败后验证码未刷新 —— 第二次提交必然报「验证码错误」")
|
fail("验证码刷新", "登录失败后验证码未刷新 —— 第二次提交必然报「验证码错误」")
|
||||||
print("✓ 3. 失败自动刷新 验证码已换新(GETDEL 消费后的必要处理)")
|
print("✓ 3. 失败自动刷新 验证码已换新(GETDEL 消费后的必要处理)")
|
||||||
|
|
||||||
# ---- 4. 正确登录 ----
|
# ---- 4. 正确登录 ----
|
||||||
answer = redis_get(f"ys:captcha:{second_id}")
|
answer = redis_get(f"ys:captcha:{second_id}")
|
||||||
if not answer:
|
if not answer:
|
||||||
_fail("取验证码", f"Redis 中未找到 ys:captcha:{second_id}")
|
fail("取验证码", f"Redis 中未找到 ys:captcha:{second_id}")
|
||||||
page.fill('input[autocomplete="off"]', answer)
|
page.fill('input[autocomplete="off"]', answer)
|
||||||
page.click("button:has-text('登 录')")
|
page.click("button:has-text('登 录')")
|
||||||
page.wait_for_timeout(2000)
|
page.wait_for_timeout(2000)
|
||||||
|
|
||||||
if not at_dashboard():
|
if current_path(page) != "/dashboard":
|
||||||
detail = f"仍停留在 {page.url}"
|
detail = f"仍停留在 {page.url}"
|
||||||
if message := toast_text():
|
if message := toast_text(page):
|
||||||
detail += f",提示:{message}"
|
detail += f",提示:{message}"
|
||||||
if console_errors:
|
if console_errors:
|
||||||
detail += f",控制台错误:{console_errors[:3]}"
|
detail += f",控制台错误:{console_errors[:3]}"
|
||||||
_fail("登录", detail)
|
fail("登录", detail)
|
||||||
page.wait_for_load_state("networkidle")
|
page.wait_for_load_state("networkidle")
|
||||||
print(f"✓ 4. 登录成功 跳转到 {page.url}")
|
print(f"✓ 4. 登录成功 跳转到 {page.url}")
|
||||||
|
|
||||||
if USERNAME not in page.content():
|
if USERNAME not in page.content():
|
||||||
_fail("用户信息", "首页未显示当前用户名")
|
fail("用户信息", "首页未显示当前用户名")
|
||||||
print("✓ 5. 用户信息 首页已展示当前账号")
|
print("✓ 5. 用户信息 首页已展示当前账号")
|
||||||
|
|
||||||
# ---- 6. 刷新页面 → 守卫应调用 /me 恢复会话 ----
|
# ---- 6. 刷新页面 → 守卫应调用 /me 恢复会话 ----
|
||||||
page.reload(wait_until="networkidle")
|
page.reload(wait_until="networkidle")
|
||||||
if "/dashboard" not in page.url:
|
if current_path(page) != "/dashboard":
|
||||||
_fail("会话恢复", f"刷新后被踢回登录页,当前 URL={page.url}")
|
fail("会话恢复", f"刷新后被踢回登录页,当前 URL={page.url}")
|
||||||
print("✓ 6. 会话恢复 刷新页面后仍保持登录(/me 恢复成功)")
|
print("✓ 6. 会话恢复 刷新页面后仍保持登录(/me 恢复成功)")
|
||||||
|
|
||||||
# ---- 7. 登出 → 令牌立即失效 ----
|
# ---- 7. 登出 → 令牌立即失效 ----
|
||||||
@@ -162,8 +121,8 @@ def run(page: Page) -> None:
|
|||||||
|
|
||||||
page.goto(f"{BASE_URL}/dashboard", wait_until="networkidle")
|
page.goto(f"{BASE_URL}/dashboard", wait_until="networkidle")
|
||||||
page.wait_for_timeout(1000)
|
page.wait_for_timeout(1000)
|
||||||
if at_dashboard():
|
if current_path(page) == "/dashboard":
|
||||||
_fail("登出失效", "登出后仍能访问受保护页面")
|
fail("登出失效", "登出后仍能访问受保护页面")
|
||||||
print("✓ 8. 令牌失效 登出后直接访问 /dashboard 被拦回登录页")
|
print("✓ 8. 令牌失效 登出后直接访问 /dashboard 被拦回登录页")
|
||||||
|
|
||||||
|
|
||||||
@@ -174,9 +133,14 @@ def main() -> int:
|
|||||||
page.set_default_timeout(10_000)
|
page.set_default_timeout(10_000)
|
||||||
try:
|
try:
|
||||||
run(page)
|
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 — 测试脚本需要打印完整失败原因
|
except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因
|
||||||
page.screenshot(path="e2e/failure.png", full_page=True)
|
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")
|
print(" 已保存截图 e2e/failure.png")
|
||||||
return 1
|
return 1
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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' })
|
||||||
|
}
|
||||||
@@ -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;
|
box-sizing: border-box;
|
||||||
@@ -20,3 +50,12 @@ body {
|
|||||||
background-color: #f5f7fa;
|
background-color: #f5f7fa;
|
||||||
-webkit-font-smoothing: antialiased;
|
-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>
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,64 @@
|
|||||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
import { useUserStore } from '@/store/user'
|
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[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{ path: '/', redirect: '/dashboard' },
|
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'login',
|
name: 'login',
|
||||||
component: () => import('@/views/login/index.vue'),
|
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',
|
path: '/dashboard',
|
||||||
name: 'dashboard',
|
name: 'dashboard',
|
||||||
component: () => import('@/views/dashboard/index.vue'),
|
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' },
|
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
|
||||||
@@ -28,11 +72,13 @@ const router = createRouter({
|
|||||||
/**
|
/**
|
||||||
* 全局前置守卫。
|
* 全局前置守卫。
|
||||||
*
|
*
|
||||||
* 三层判断:
|
* 四层判断:
|
||||||
* 1. `meta.public` 标记的页面(登录页)直接放行
|
* 1. `meta.public` 标记的页面(登录页)直接放行
|
||||||
* 2. 无令牌 → 跳登录页,并把原目标记在 query 里供登录后回跳
|
* 2. 无令牌 → 跳登录页,并把原目标记在 query 里供登录后回跳
|
||||||
* 3. 有令牌但内存中无用户信息(说明是刷新页面)→ 调 /me 恢复会话;
|
* 3. 有令牌但内存中无用户信息(说明是刷新页面)→ 调 /me 恢复会话;
|
||||||
* 失败则视为令牌已失效,清状态并跳登录页
|
* 失败则视为令牌已失效,清状态并跳登录页
|
||||||
|
* 4. `meta.requiresAdmin` 的页面要求管理员角色 —— 此时用户信息已就绪,
|
||||||
|
* 这是"隐藏菜单"之外的第二道防线(防手输 URL)
|
||||||
*/
|
*/
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
const userStore = useUserStore()
|
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
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/** 异常处理工具。 */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显式声明「异常已按约定处理」。
|
||||||
|
*
|
||||||
|
* 本项目请求失败的提示统一由 axios 拦截器完成,页面 catch 块只需阻止
|
||||||
|
* Promise 继续传播;但**空 catch 会掩盖非预期异常**(如本地 TypeError),
|
||||||
|
* 也违反「禁空 catch」规范,故以本函数显式标注意图并留痕。
|
||||||
|
* 用 debug 级别:既不污染 e2e 收集的 error 级控制台信息,也便于排查时开启。
|
||||||
|
*/
|
||||||
|
export function handledError(error: unknown): void {
|
||||||
|
console.debug('[handled]', error)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
|
import { formatDateTime } from '@/utils/format'
|
||||||
|
|
||||||
const router = useRouter()
|
/**
|
||||||
|
* 首页:登录链路验证页。
|
||||||
|
*
|
||||||
|
* 顶部栏(用户信息、退出登录)已由 BasicLayout 提供,本页只保留内容区;
|
||||||
|
* 用户名等字段的展示是端到端脚本的断言对象,调整时需同步 e2e。
|
||||||
|
*/
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
async function handleLogout(): Promise<void> {
|
|
||||||
await userStore.logout()
|
|
||||||
ElMessage.success('已退出登录')
|
|
||||||
await router.replace('/login')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<el-card shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<span>登录状态验证通过</span>
|
<span>登录状态验证通过</span>
|
||||||
@@ -43,56 +27,23 @@ async function handleLogout(): Promise<void> {
|
|||||||
{{ userStore.roles.join('、') || '—' }}
|
{{ userStore.roles.join('、') || '—' }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="上次登录">
|
<el-descriptions-item label="上次登录">
|
||||||
{{ userStore.userInfo?.last_login_time || '—' }}
|
{{ formatDateTime(userStore.userInfo?.last_login_time) }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
<el-alert
|
<el-alert
|
||||||
class="layout-hint"
|
class="dashboard-hint"
|
||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
title="这是登录链路的验证页"
|
title="这是登录链路的验证页"
|
||||||
description="刷新页面可验证会话恢复(守卫会调用 /me);点击右上角退出登录可验证令牌即时失效。业务模块页面将在后续阶段接入。"
|
description="刷新页面可验证会话恢复(守卫会调用 /me);「系统管理」菜单已接入用户管理、角色管理与日志查询。"
|
||||||
/>
|
/>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-main>
|
|
||||||
</el-container>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.layout {
|
.dashboard-hint {
|
||||||
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 {
|
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -182,19 +182,19 @@ onMounted(refreshCaptcha)
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/*
|
/*
|
||||||
* 配色说明:金色分两档 —— 亮金只用于深色底、深金只用于浅色底,
|
* 配色:品牌 token 已提升为全局(见 assets/styles/index.css,布局框架与
|
||||||
* 保证两处对比度都达标(深金配白字约 6.2:1)。
|
* 系统管理页面共用),此处只做局部别名,用法与取值均与原先一致。
|
||||||
*/
|
*/
|
||||||
.login {
|
.login {
|
||||||
--ink-900: #061a22;
|
--ink-900: var(--ys-ink-900);
|
||||||
--ink-800: #0a2833;
|
--ink-800: var(--ys-ink-800);
|
||||||
--ink-700: #0f3646;
|
--ink-700: var(--ys-ink-700);
|
||||||
--ink-600: #1a4f63;
|
--ink-600: var(--ys-ink-600);
|
||||||
--gold-bright: #c9a227;
|
--gold-bright: var(--ys-gold-bright);
|
||||||
--gold-deep: #8f6520;
|
--gold-deep: var(--ys-gold-deep);
|
||||||
--paper: #fafaf7;
|
--paper: var(--ys-paper);
|
||||||
--text: #16262d;
|
--text: var(--ys-text);
|
||||||
--muted: #6b7b84;
|
--muted: var(--ys-muted);
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in new issue
Block a user