feat/m3-increments #1

Merged
liuwenxin merged 100 commits from feat/m3-increments into main 2026-10-19 04:59:57 -04:00
32 changed files with 3158 additions and 137 deletions
Showing only changes of commit c83287819a - Show all commits

1
.gitignore vendored
View File

@ -54,3 +54,4 @@ frontend/e2e/*.png
# 备份
backup/
.claude

View File

@ -1,11 +1,12 @@
# 后端
> AI摘要:衍水集团营销人员工资管理系统的后端服务。FastAPI + SQLAlchemy(同步)+ MySQL + Redis,Python 3.12 由 uv 锁定。六层结构 `api / services / repositories / models / schemas / core`。当前实现登录鉴权与健康检查,业务模块待需求澄清后开发。
> AI摘要:衍水集团营销人员工资管理系统的后端服务。FastAPI + SQLAlchemy(同步)+ MySQL + Redis,Python 3.12 由 uv 锁定。六层结构 `api / services / repositories / models / schemas / core`。当前实现登录鉴权与系统管理(用户/角色/操作日志/日志查询),业务模块待需求澄清后开发。
## 职责
- 提供 REST 接口(统一前缀 `/api`)
- 登录鉴权:图形验证码、JWT、登录限流、登出黑名单、登录日志
- 系统管理(需求 2.5.1):用户管理、角色管理、操作日志(自动审计)、操作与登录日志查询
- 后续承担:基础数据、业务数据、绩效核算、报表统计、T+ 数据同步
## 技术栈与版本
@ -53,6 +54,24 @@ app/
| POST | `/api/v1/auth/logout` | 是 | 令牌入黑名单,立即失效 |
| GET | `/api/v1/auth/me` | 是 | 当前用户与角色 |
### 系统管理接口(需求 2.5.1,全部要求管理员权限)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET / POST | `/api/v1/system/users` | 用户列表(分页/筛选)/ 新增用户 |
| GET / PUT / DELETE | `/api/v1/system/users/{id}` | 用户详情 / 编辑(含角色分配)/ 删除(物理删除) |
| PUT | `/api/v1/system/users/{id}/password` | 重置密码(提升令牌版本,旧令牌立即失效) |
| PUT | `/api/v1/system/users/{id}/status` | 启用 / 禁用(禁用同样提升令牌版本) |
| GET | `/api/v1/system/users/meta/roles` | 角色下拉选项(仅启用角色) |
| GET / POST | `/api/v1/system/roles` | 角色列表(含停用与绑定用户数)/ 新增角色 |
| PUT / DELETE | `/api/v1/system/roles/{id}` | 编辑(不含编码)/ 删除(内置与有绑定的角色受保护) |
| PUT | `/api/v1/system/roles/{id}/status` | 启用 / 禁用(内置 admin 禁停) |
| GET | `/api/v1/system/operation-logs` | 操作日志查询(操作人/模块/操作/结果/时间范围;**不含 request_data** 大字段) |
| GET | `/api/v1/system/operation-logs/{id}` | 操作日志详情(含已脱敏的请求参数,展开行懒加载) |
| GET | `/api/v1/system/login-logs` | 登录日志查询(用户名/IP/结果/时间范围) |
全部写操作自动写入操作日志(成功与失败都记,机制见下)。
统一响应 `{code, message, data}`,分页为 `list/total/pageNum/pageSize`。
状态码:400 参数错误 / 401 未登录 / 403 无权限 / 404 不存在 / 500 服务端异常。
@ -118,6 +137,22 @@ Redis 黑名单只负责「用户主动登出」。
bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是静默截断)。
中文一个字符占 3 字节,24 个汉字即到顶——`max_length=20` 这类字符数校验拦不住。
### 操作日志:审计上下文 + 独立提交
写操作统一经 `services/audit_service.py` 的 `audit()` 上下文管理器留痕(用法见 `api/v1/system/users.py`):
- 调用形态为 DTO:`audit(session, operator=..., record=AuditRecord(module, action, summary, request_data), meta=...)`;落库条目为 `OperationLogEntry`
- 正常退出记成功;抛异常**先 `session.rollback()` 再**记失败(success=0 + 中文错误原因),随后原异常继续传播
- 日志**独立 commit**,整体兜底不外抛 —— 方向是「审计可丢,业务不可断」;反向理解:业务失败回滚不会带走失败记录,而那正是审计最需要的
- **操作人标量在进入上下文时固化**:失败路径先 rollback 会使 ORM 对象过期,之后再读 id/username 会触发额外查询(账号并发被删时 ObjectDeletedError 还会被兜底吞掉)
- 请求元信息(ip / user-agent / method / path)经路由层的 `RequestMetaDep` 传入 service
- **commit 之后的任何查询/ORM 读取都要前置**(提到 commit 前或用已固化的局部变量):业务已提交却因后置查询抛错会被记成「失败」,产生与事实相反的审计
- 文本列按 `models/operation_log.py` 的长度常量截断;`request_data` 先经**结构化脱敏**(递归按键名整值抹除 password/token 等,不依赖文本模式匹配)再经 `core.logging.scrub` 文本兜底,最后按 **UTF-8 字节边界**截断(encode 带 `errors="replace"` 兜底孤立代理字符)—— 超长/编码异常都会在 MySQL 或 encode 处抛出,被兜底吞掉后表现为「日志莫名消失」
- **日志列表与详情两段式**:`operation-logs` 列表排除 `request_data`(TEXT 大字段,随列表返回会让翻页负担随表增长),展开详情走 `GET /operation-logs/{id}` 懒加载
- 模块/操作词表在 `core/constants.py`(`MODULE_*` / `ACTION_*`),前端 `utils/constants.ts` 与其对齐
**角色管理保护规则**(后端是唯一防线,前端按钮禁用只是体验):admin 角色禁删禁停;有用户绑定的角色禁删(`user_roles` 外键是 CASCADE;删除前走 `SELECT ... FOR UPDATE` 锁行,堵住并发绑定窗口);`role_code` 创建后不可改;**停用角色不可再分配**(`_resolve_roles` 校验 status,停用只影响新分配、不影响既有授权)。
## 常见问题
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。

View File

@ -2,7 +2,7 @@
from typing import Annotated
from fastapi import Depends
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
@ -13,6 +13,7 @@ from app.core.security import decode_token
from app.models import User
from app.repositories import user_repository
from app.services import token_blacklist
from app.services.audit_service import RequestMeta
# auto_error=False:令牌缺失时由我们自己抛 AuthError,
# 才能返回统一响应信封,而不是 FastAPI 默认的 {"detail": "Not authenticated"}
@ -22,6 +23,29 @@ BearerCredentials = Annotated[HTTPAuthorizationCredentials | None, Depends(_bear
DbSession = Annotated[Session, Depends(get_db)]
def client_ip(request: Request) -> str | None:
"""取客户端 IP。
当前取直连地址。生产环境经 Nginx 反代后直连地址会变成代理地址,
届时需改为读取 `X-Forwarded-For` 的**第一个**值 —— 且必须确保该头
由网关覆写,否则客户端可以伪造。此改动留到部署阶段一并处理。
"""
return request.client.host if request.client else None
def get_request_meta(request: Request) -> RequestMeta:
"""提取 HTTP 请求元信息,供操作日志审计使用。"""
return RequestMeta(
ip=client_ip(request),
user_agent=request.headers.get("user-agent"),
method=request.method,
path=request.url.path,
)
RequestMetaDep = Annotated[RequestMeta, Depends(get_request_meta)]
def get_raw_token(credentials: BearerCredentials) -> str:
"""取原始令牌字符串。登出时需要它来解析 jti。"""
if credentials is None or not credentials.credentials:

View File

@ -8,6 +8,8 @@ from fastapi import APIRouter
from app.api import health
from app.api.v1 import auth
from app.api.v1.system import logs as system_logs
from app.api.v1.system import roles as system_roles
from app.api.v1.system import users as system_users
api_router = APIRouter()
@ -22,6 +24,9 @@ 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)

View File

@ -4,7 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Request
from app.api.deps import DbSession, get_current_user, get_raw_token
from app.api.deps import DbSession, client_ip, get_current_user, get_raw_token
from app.core.response import ApiResponse, ok
from app.models import User
from app.repositories.user_repository import get_role_codes
@ -14,16 +14,6 @@ from app.services import auth_service, captcha_service
router = APIRouter(prefix="/auth", tags=["认证"])
def _client_ip(request: Request) -> str | None:
"""取客户端 IP。
当前取直连地址。生产环境经 Nginx 反代后直连地址会变成代理地址,
届时需改为读取 `X-Forwarded-For` 的**第一个**值 —— 且必须确保该头
由网关覆写,否则客户端可以伪造。此改动留到部署阶段一并处理。
"""
return request.client.host if request.client else None
@router.get("/captcha", response_model=ApiResponse[CaptchaOut], summary="获取图形验证码")
def get_captcha() -> ApiResponse[CaptchaOut]:
"""签发图形验证码。无需认证。
@ -52,7 +42,7 @@ def login(payload: LoginIn, request: Request, session: DbSession) -> ApiResponse
password=payload.password,
captcha_id=payload.captcha_id,
captcha_code=payload.captcha_code,
ip=_client_ip(request),
ip=client_ip(request),
user_agent=request.headers.get("user-agent"),
)
return ok(result, message="登录成功")

View File

@ -1,6 +1,6 @@
"""系统管理路由(需求 2.5.1)。
含用户管理、角色查询、操作日志查询三部分,全部要求管理员权限。
含用户管理、角色管理、日志查询(操作日志 + 登录日志)四部分,全部要求管理员权限。
与 `api/v1/auth.py` 的分工:`auth` 是登录链路(未认证即可访问的验证码与登录,
以及任何登录用户都能访问的 `/me` 与登出);本包是**管理功能**。

View File

@ -0,0 +1,67 @@
"""日志查询接口(需求 2.5.1:支持日志查询)。
操作日志与登录日志是两个独立端点:两表字段与筛选项不同(前者含模块/操作/
请求详情,后者含失败原因),合并会让 `action` 等参数产生二义;前端正好是
两个 tab。操作日志另有单条详情(列表为性能排除 `request_data`)。
均要求管理员权限;只读查询不写审计(读操作记日志只会淹没审计表)。
"""
from typing import Annotated
from fastapi import APIRouter, Path, Query
from app.api.deps import AdminUser, DbSession
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.log import (
LoginLogOut,
LoginLogQuery,
OperationLogDetailOut,
OperationLogOut,
OperationLogQuery,
)
from app.services import log_service
operation_router = APIRouter(prefix="/operation-logs", tags=["系统管理 · 日志"])
login_router = APIRouter(prefix="/login-logs", tags=["系统管理 · 日志"])
@operation_router.get(
"", response_model=ApiResponse[PageResult[OperationLogOut]], summary="操作日志"
)
def list_operation_logs(
session: DbSession,
_: AdminUser,
query: Annotated[OperationLogQuery, Query()],
) -> ApiResponse[PageResult[OperationLogOut]]:
"""分页查询操作日志,支持按操作人/模块/操作类型/结果/时间范围筛选。
按 id 倒序(时间倒序的稳定实现)。列表**不含** `request_data`(大字段),
展开详情请调 `GET /operation-logs/{log_id}`。
"""
return ok(log_service.list_operation_logs(session, query))
@operation_router.get(
"/{log_id}", response_model=ApiResponse[OperationLogDetailOut], summary="操作日志详情"
)
def get_operation_log(
session: DbSession,
_: AdminUser,
log_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[OperationLogDetailOut]:
"""查询单条操作日志详情(含已脱敏的请求参数)。
前端展开日志行时按 id 按需加载 —— 避免列表翻页携带大字段。
"""
return ok(log_service.get_operation_log(session, log_id))
@login_router.get("", response_model=ApiResponse[PageResult[LoginLogOut]], summary="登录日志")
def list_login_logs(
session: DbSession,
_: AdminUser,
query: Annotated[LoginLogQuery, Query()],
) -> ApiResponse[PageResult[LoginLogOut]]:
"""分页查询登录日志,支持按用户名/IP/结果/时间范围筛选。"""
return ok(log_service.list_login_logs(session, query))

View File

@ -0,0 +1,100 @@
"""角色管理接口(自主完善模块:需求 2.5.1 仅定义 admin / user 两个角色)。
保护规则(admin 禁删禁停、有绑定的角色禁删、role_code 不可改)见
`role_service` 模块 docstring。前端会禁用相应按钮,但**后端校验是唯一防线**。
全部要求管理员权限;写操作经 `RequestMetaDep` 带入请求元信息由 service
层写入操作日志。
"""
from typing import Annotated
from fastapi import APIRouter, Path, Query
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.role import RoleCreateIn, RoleManageOut, RoleQuery, RoleStatusIn, RoleUpdateIn
from app.services import role_service
router = APIRouter(prefix="/roles", tags=["系统管理 · 角色"])
@router.get("", response_model=ApiResponse[PageResult[RoleManageOut]], summary="角色列表")
def list_roles(
session: DbSession,
_: AdminUser,
query: Annotated[RoleQuery, Query()],
) -> ApiResponse[PageResult[RoleManageOut]]:
"""分页查询角色。
与用户表单的角色下拉口径不同:**本列表含停用角色**(下拉只给启用项)。
每行附带 `user_count`(绑定用户数),供前端提示删除保护。
"""
return ok(role_service.list_roles(session, query))
@router.post("", response_model=ApiResponse[RoleManageOut], summary="新增角色")
def create_role(
session: DbSession,
current_user: AdminUser,
payload: RoleCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[RoleManageOut]:
"""新增角色。编码全局唯一、创建后不可修改;新角色默认启用。"""
return ok(
role_service.create_role(session, payload, operator=current_user, meta=meta),
message="角色已创建",
)
@router.put("/{role_id}", response_model=ApiResponse[RoleManageOut], summary="编辑角色")
def update_role(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
payload: RoleUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[RoleManageOut]:
"""编辑角色名称/说明/排序。
**不含 role_code** —— 编码是授权判定(`ROLE_ADMIN` 常量)的锚点。
"""
return ok(
role_service.update_role(session, role_id, payload, operator=current_user, meta=meta),
message="已保存",
)
@router.put("/{role_id}/status", response_model=ApiResponse[RoleManageOut], summary="启用/禁用角色")
def set_status(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
payload: RoleStatusIn,
meta: RequestMetaDep,
) -> ApiResponse[RoleManageOut]:
"""启用或禁用角色。
停用**不解除**用户绑定、也不影响既有授权(授权判定只看编码、不看状态);
内置管理员角色不可停用。
"""
result = role_service.set_status(
session, role_id, payload.status, operator=current_user, meta=meta
)
return ok(result, message="已启用" if payload.status == 1 else "已禁用")
@router.delete("/{role_id}", response_model=ApiResponse[None], summary="删除角色")
def delete_role(
session: DbSession,
current_user: AdminUser,
role_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除角色。
内置管理员角色不可删除;仍有关联用户的角色不可删除(提示先解除绑定)——
`user_roles` 外键是 CASCADE,后端拦截是防止静默剥离权限的唯一防线。
"""
role_service.delete_role(session, role_id, operator=current_user, meta=meta)
return ok(message="角色已删除")

View File

@ -5,17 +5,20 @@
删除、禁用、改角色三处都有**自我保护**:不允许对当前登录账号执行,
否则管理员误操作会立刻失去全部管理权限且无法自行恢复。
全部写操作经 `RequestMetaDep` 带入请求元信息后由 service 层写入操作日志
(成功与失败都记),操作人为当前登录管理员。
"""
from typing import Annotated
from fastapi import APIRouter, Path, Query
from app.api.deps import AdminUser, DbSession
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,
RoleOut,
UserCreateIn,
UserOut,
UserQuery,
@ -50,11 +53,15 @@ def get_user(
@router.post("", response_model=ApiResponse[UserOut], summary="新增用户")
def create_user(
session: DbSession,
_: AdminUser,
current_user: AdminUser,
payload: UserCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[UserOut]:
"""新增用户。用户名须唯一,至少指定一个角色。"""
return ok(user_service.create_user(session, payload), message="用户已创建")
return ok(
user_service.create_user(session, payload, operator=current_user, meta=meta),
message="用户已创建",
)
@router.put("/{user_id}", response_model=ApiResponse[UserOut], summary="编辑用户")
@ -63,13 +70,14 @@ def update_user(
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_id=current_user.id),
user_service.update_user(session, user_id, payload, operator=current_user, meta=meta),
message="已保存",
)
@ -79,29 +87,33 @@ 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_id=current_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,
_: AdminUser,
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)
user_service.reset_password(
session, user_id, payload.new_password, operator=current_user, meta=meta
)
return ok(message="密码已重置,该用户需重新登录")
@ -111,13 +123,16 @@ def set_status(
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_id=current_user.id)
result = user_service.set_status(
session, user_id, payload.status, operator=current_user, meta=meta
)
return ok(result, message="已启用" if payload.status == 1 else "已禁用")

View File

@ -36,3 +36,15 @@ PASSWORD_MAX_BYTES = 72
# 剔除易混淆字符 0/O/1/I/l,降低人工识别错误率
CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
CAPTCHA_LENGTH = 4
# ===== 操作日志词表(operation_logs 的 module / action 取值)=====
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致
MODULE_USER = "用户管理"
MODULE_ROLE = "角色管理"
ACTION_CREATE = "新增"
ACTION_UPDATE = "修改"
ACTION_DELETE = "删除"
ACTION_RESET_PASSWORD = "重置密码"
ACTION_ENABLE = "启用"
ACTION_DISABLE = "禁用"

View File

@ -21,6 +21,7 @@ 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
@ -73,7 +74,7 @@ class OperationLog(Base):
String(SUMMARY_MAX_LEN), nullable=False, comment="操作摘要(中文,供人阅读)"
)
method: Mapped[str | None] = mapped_column(String(8), comment="HTTP 方法")
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(

View File

@ -1,8 +1,10 @@
"""登录日志数据访问。"""
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session
from app.models import LoginLog
from app.repositories.query_utils import LogFilters, apply_log_filters
# user_agent 列宽,超长需截断否则插入报错
_USER_AGENT_MAX_LEN = 255
@ -36,3 +38,40 @@ def add(
)
)
session.flush()
def list_logs(
session: Session,
*,
offset: int,
limit: int,
filters: LogFilters,
ip: str | None = None,
) -> list[LoginLog]:
"""分页查询登录日志。
**按 id 倒序而非 create_time**:后者是秒级精度,同一秒内的多条记录排序
不确定,翻页会重复或遗漏(与用户列表同一理由)。
"""
stmt = _apply_filters(select(LoginLog), filters=filters, ip=ip)
stmt = stmt.order_by(LoginLog.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).scalars())
def count_logs(
session: Session,
*,
filters: LogFilters,
ip: str | None = None,
) -> int:
"""统计符合条件的登录日志总数,供分页使用。"""
stmt = _apply_filters(select(func.count(LoginLog.id)), filters=filters, ip=ip)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, *, filters: LogFilters, ip: str | None) -> Select:
"""应用筛选条件(公共部分复用 query_utils,与操作日志同口径)。"""
stmt = apply_log_filters(stmt, LoginLog, filters)
if ip:
stmt = stmt.where(LoginLog.ip == ip)
return stmt

View File

@ -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")

View File

@ -0,0 +1,54 @@
"""repositories 层公共查询工具。
集中两类易错点,避免各 repository 各写一份、口径漂移:
- **模糊匹配的 LIKE 通配符转义**:直接 ``f"%{value}%"`` 拼接时,输入中的
下划线会作为单字符通配执行(本系统用户名普遍含下划线,误配是常态而非
理论),百分号则命中全表并触发全表扫描
- **两类日志共有的筛选条件**:用户名/结果/时间范围在操作日志与登录日志
之间逐字重复,抽为同一实现后「列表与计数」「操作与登录」不可能对不上
"""
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from sqlalchemy import Select
@dataclass(frozen=True, slots=True)
class LogFilters:
"""两类日志(操作/登录)共有的筛选条件。"""
username: str | None = None
success: int | None = None
start_time: datetime | None = None
end_time: datetime | None = None
def fuzzy(stmt: Select, column: Any, value: str) -> Select:
"""模糊匹配(含通配符自动转义)。
``contains(autoescape=True)`` 会转义输入中的 ``%``/``_`` 并附加 ``ESCAPE``
子句,把用户输入当字面量匹配。
"""
return stmt.where(column.contains(value, autoescape=True))
def apply_log_filters(stmt: Select, model: Any, filters: LogFilters) -> Select:
"""应用两类日志共有的筛选条件。
``model`` 传 ``OperationLog`` / ``LoginLog`` 的模型类(两者字段同名同型)。
列表与计数共用本函数,避免「列表按条件过滤而总数不按条件过滤」——
漏改一侧时表现为翻到最后一页是空页或页码虚增,且没有任何报错。
"""
if filters.username:
stmt = fuzzy(stmt, model.username, filters.username)
if filters.success is not None:
stmt = stmt.where(model.success == filters.success)
if filters.start_time:
stmt = stmt.where(model.create_time >= filters.start_time)
if filters.end_time:
stmt = stmt.where(model.create_time <= filters.end_time)
return stmt

View File

@ -0,0 +1,123 @@
"""角色数据访问。
本层只 flush 不 commit —— 事务边界由 services 层控制。
`get_all_roles` 与 `get_roles_by_codes` 原位于 `user_repository`,
因角色管理成为独立模块而迁入本文件。
"""
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session
from app.models import Role, UserRole
# ==================== 读取 ====================
def get_by_id(session: Session, role_id: int, *, for_update: bool = False) -> Role | None:
"""按主键查询角色。不存在返回 None。
``for_update=True`` 走 ``SELECT ... FOR UPDATE`` 锁定角色行 —— 删除前的
「有用户绑定禁删」校验必须用它:校验与 DELETE 之间若发生并发绑定,
`user_roles` 的 CASCADE 会把新绑定一并静默剥离。锁行后,并发绑定的
外键检查会等待,删除得以基于最新状态做出(对方失败而非数据被吞)。
"""
if for_update:
stmt = select(Role).where(Role.id == role_id).with_for_update()
return session.execute(stmt).scalar_one_or_none()
return session.get(Role, role_id)
def get_by_code(session: Session, role_code: str) -> Role | None:
"""按编码查询角色。不存在返回 None。"""
return session.execute(select(Role).where(Role.role_code == role_code)).scalar_one_or_none()
def get_roles_by_codes(session: Session, role_codes: list[str]) -> list[Role]:
"""按编码批量取角色。用于校验前端传来的角色是否存在。"""
if not role_codes:
return []
stmt = select(Role).where(Role.role_code.in_(role_codes))
return list(session.execute(stmt).scalars())
def get_all_roles(session: Session) -> list[Role]:
"""取全部启用角色,供前端下拉选择。"""
stmt = select(Role).where(Role.status == 1).order_by(Role.sort_order, Role.id)
return list(session.execute(stmt).scalars())
def list_roles(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
status: int | None = None,
) -> list[Role]:
"""分页查询角色(管理列表:含停用角色)。
排序与下拉口径一致(sort_order, id),保持同一套相对次序。
"""
stmt = _apply_filters(select(Role), keyword=keyword, status=status)
stmt = stmt.order_by(Role.sort_order, Role.id).offset(offset).limit(limit)
return list(session.execute(stmt).scalars())
def count_roles(session: Session, *, keyword: str | None = None, status: int | None = None) -> int:
"""统计符合条件的角色总数,供分页使用。"""
stmt = _apply_filters(select(func.count(Role.id)), keyword=keyword, status=status)
return int(session.execute(stmt).scalar_one())
def _apply_filters(stmt: Select, *, keyword: str | None, status: int | None) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
关键词匹配走 `contains(autoescape=True)`(转义 LIKE 通配符)——
角色编码普遍含下划线,直接拼接会让 `_` 当单字符通配执行。
"""
if keyword:
stmt = stmt.where(
or_(
Role.role_code.contains(keyword, autoescape=True),
Role.role_name.contains(keyword, autoescape=True),
)
)
if status is not None:
stmt = stmt.where(Role.status == status)
return stmt
def count_users_by_role(session: Session, role_ids: list[int]) -> dict[int, int]:
"""批量统计各角色关联的用户数。
一次 GROUP BY 取回全部计数,而非在列表循环里逐行查询(N+1)。
"""
if not role_ids:
return {}
stmt = (
select(UserRole.role_id, func.count())
.where(UserRole.role_id.in_(role_ids))
.group_by(UserRole.role_id)
)
return {int(role_id): int(count) for role_id, count in session.execute(stmt)}
# ==================== 写入 ====================
def create(session: Session, role: Role) -> Role:
"""新增角色。只 flush 以取得自增 id。"""
session.add(role)
session.flush()
return role
def delete(session: Session, role: Role) -> None:
"""删除角色。
调用方(service)必须先校验该角色无用户绑定 —— `user_roles` 外键是
CASCADE,直接删会静默剥离绑定用户的权限。
"""
session.delete(role)
session.flush()

View File

@ -1,4 +1,4 @@
"""用户与角色数据访问。
"""用户数据访问(角色相关的读取与维护见 `role_repository`)。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
@ -9,6 +9,7 @@ from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session, selectinload
from app.models import Role, User, UserRole
from app.repositories.query_utils import fuzzy
def _base_query() -> Select[tuple[User]]:
@ -95,11 +96,15 @@ def _apply_filters(
real_name: str | None,
status: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。
模糊匹配统一走 `query_utils.fuzzy`(转义 LIKE 通配符)—— 用户名普遍
含下划线,直接拼接会让 `_` 当单字符通配执行、误配无关账号。
"""
if username:
stmt = stmt.where(User.username.like(f"%{username}%"))
stmt = fuzzy(stmt, User.username, username)
if real_name:
stmt = stmt.where(User.real_name.like(f"%{real_name}%"))
stmt = fuzzy(stmt, User.real_name, real_name)
if status is not None:
stmt = stmt.where(User.status == status)
return stmt
@ -127,15 +132,7 @@ def delete(session: Session, user: User) -> None:
session.flush()
# ==================== 角色 ====================
def get_roles_by_codes(session: Session, role_codes: list[str]) -> list[Role]:
"""按编码批量取角色。用于校验前端传来的角色是否存在。"""
if not role_codes:
return []
stmt = select(Role).where(Role.role_code.in_(role_codes))
return list(session.execute(stmt).scalars())
# ==================== 角色关联 ====================
def replace_roles(session: Session, user: User, roles: list[Role]) -> None:
@ -144,18 +141,18 @@ def replace_roles(session: Session, user: User, roles: list[Role]) -> None:
采用「先清后建」而非逐条比对:角色数量极少(本期仅两个),
差异计算的复杂度换不来收益,且整体替换的语义更直白。
**clear 之后必须立刻 flush 一次**:SQLAlchemy 在同一事务的 flush 中
**先处理 INSERT 再处理 DELETE**,若中间不落库,重插与旧集合有交集的
(user_id, role_id)(编辑用户时保留既有角色就会发生)会撞
`uk_user_roles_user_id_role_id` 唯一键,报 1062。
**必须经 `user.user_roles` 集合追加,不能用 `session.add(UserRole(...))`**:
后者只把对象放进 session,不会同步到关系的已加载集合,于是同一事务内
再读 `user.user_roles` 仍是空的 —— 表现为「保存成功,但接口返回的角色
列表是 []」。经集合操作时 `user_id` 由关系自动填充,集合也立即反映新状态。
"""
user.user_roles.clear()
session.flush()
for role in roles:
user.user_roles.append(UserRole(role_id=role.id))
session.flush()
def get_all_roles(session: Session) -> list[Role]:
"""取全部启用角色,供前端下拉选择。"""
stmt = select(Role).where(Role.status == 1).order_by(Role.sort_order, Role.id)
return list(session.execute(stmt).scalars())

105
backend/app/schemas/log.py Normal file
View File

@ -0,0 +1,105 @@
"""日志查询的请求/响应模型。
操作日志与登录日志字段不同(前者含模块/操作/请求详情,后者含失败原因),
故各自定义 Query 与 Out,仅共用「用户名 + 结果 + 时间范围」的筛选基类。
"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.core.response import PageQuery
class LogQueryBase(PageQuery):
"""日志公共查询条件:模糊用户名 + 结果 + 时间范围。"""
username: str | None = Field(default=None, max_length=64, description="用户名,模糊匹配")
success: int | None = Field(default=None, ge=0, le=1, description="1成功 0失败")
start_time: datetime | None = Field(default=None, description="起始时间(含)")
end_time: datetime | None = Field(default=None, description="结束时间(含)")
@field_validator("start_time", "end_time")
@classmethod
def _to_local_naive(cls, value: datetime | None) -> datetime | None:
"""统一为本地时区的 naive 时间。
库列存本地时间(容器 TZ=Asia/Shanghai),而 API 接受 ISO 8601
(可带 ``Z`` 等时区后缀)。不归一时有两种故障:aware 与 naive
混用比较抛 TypeError(穿透成 500);aware 直接透传给驱动会
**静默丢弃偏移**(查询窗口整体偏 8 小时且无任何提示)。
aware 先转系统本地时区、再摘除 tzinfo 是唯一正确姿势;
naive 视为本地时间原样保留。
"""
if value is not None and value.tzinfo is not None:
return value.astimezone().replace(tzinfo=None)
return value
@model_validator(mode="after")
def _check_time_range(self) -> "LogQueryBase":
if self.start_time and self.end_time and self.start_time > self.end_time:
raise ValueError("起始时间不能晚于结束时间")
return self
class OperationLogQuery(LogQueryBase):
"""操作日志查询条件。"""
module: str | None = Field(default=None, max_length=64, description="所属模块,精确匹配")
action: str | None = Field(default=None, max_length=32, description="操作类型,精确匹配")
class OperationLogOut(BaseModel):
"""操作日志列表项。
**不含 request_data**(TEXT,单行最大 60000 字节):列表页默认不展示它,
随列表返回会让翻页负担随日志表增长线性放大;展开详情走
`GET /operation-logs/{id}`(`OperationLogDetailOut`),前端按需加载。
"""
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int | None = None
username: str
module: str
action: str
summary: str
method: str | None = None
path: str | None = None
ip: str | None = None
user_agent: str | None = None
success: int = 0
error_message: str | None = None
duration_ms: int | None = None
create_time: datetime | None = None
class OperationLogDetailOut(OperationLogOut):
"""操作日志详情:列表字段 + 请求参数。
`request_data` 入库时已经结构化 + 文本两层脱敏,可直接展示。
"""
request_data: str | None = None
class LoginLogQuery(LogQueryBase):
"""登录日志查询条件。"""
ip: str | None = Field(default=None, max_length=45, description="客户端IP,精确匹配")
class LoginLogOut(BaseModel):
"""登录日志响应。"""
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int | None = None
username: str
ip: str | None = None
user_agent: str | None = None
success: int = 0
fail_reason: str | None = None
create_time: datetime | None = None

View File

@ -0,0 +1,80 @@
"""角色管理的请求/响应模型。
`RoleOut` 原位于 `schemas/user.py`,因角色管理成为独立模块而迁入本文件 ——
用户表单的角色下拉与角色管理列表共用同一响应模型。
"""
import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.response import PageQuery
# 角色编码规则:小写字母开头,后接小写字母/数字/下划线,长度 2–32(与列宽一致)。
# 不允许大写与中文 —— 编码会出现在授权判断(`ROLE_ADMIN` 常量按编码比对)与
# 操作日志里,保持单一风格避免大小写/全角混用造成对照困难
ROLE_CODE_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,31}$")
class RoleOut(BaseModel):
"""角色基础响应(用户表单下拉与角色列表共用)。"""
model_config = ConfigDict(from_attributes=True)
id: int
role_code: str
role_name: str
description: str | None = None
sort_order: int = 0
status: int = 1
class RoleManageOut(RoleOut):
"""角色管理列表项:额外携带绑定用户数,供前端提示删除保护的原因。"""
user_count: int = 0
class RoleCreateIn(BaseModel):
"""新增角色。"""
model_config = ConfigDict(str_strip_whitespace=True)
role_code: str = Field(min_length=2, max_length=32, description="角色编码,创建后不可修改")
role_name: str = Field(min_length=1, max_length=64, description="角色名称")
description: str | None = Field(default=None, max_length=255)
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
@field_validator("role_code")
@classmethod
def _check_code(cls, value: str) -> str:
if not ROLE_CODE_PATTERN.match(value):
raise ValueError("角色编码须以小写字母开头,仅可含小写字母、数字与下划线")
return value
class RoleUpdateIn(BaseModel):
"""编辑角色。
**不含 role_code** —— 编码是授权判定的锚点,修改会让既有授权语义静默改变。
前端表单全量提交,故为全量替换语义。
"""
model_config = ConfigDict(str_strip_whitespace=True)
role_name: str = Field(min_length=1, max_length=64, description="角色名称")
description: str | None = Field(default=None, max_length=255)
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
class RoleStatusIn(BaseModel):
"""启用/禁用角色。"""
status: int = Field(ge=0, le=1, description="1启用 0禁用")
class RoleQuery(PageQuery):
"""角色列表查询条件。"""
keyword: str | None = Field(default=None, max_length=64, description="编码或名称,模糊匹配")
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")

View File

@ -1,4 +1,4 @@
"""用户与角色的请求/响应模型。
"""用户管理的请求/响应模型(角色相关模型见 `role.py`)。
与 `auth.py` 的分工:`auth.py` 管登录链路(验证码、登录、当前用户),
本模块管**用户管理**(增删改查、重置密码、启停、角色分配)。
@ -111,16 +111,3 @@ class UserQuery(PageQuery):
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="角色编码筛选")
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

View File

@ -0,0 +1,173 @@
"""操作日志审计服务。
需求 2.5.1 要求记录用户的操作行为。与登录行为的分工:登录在 `login_logs`
单独记录(失败也要留痕且不依赖认证上下文),本模块记录**已认证用户的管理类
操作**。
设计要点:
- **成功与失败都留痕** —— 失败的操作(如试图删除不存在的用户)同样是审计
对象,模型里专门有 `success` / `error_message` 两列承载它
- **审计失败不影响业务** —— 落库过程整体兜底,失败只记应用日志、绝不外抛
(与 `auth_service._record` 同一原则:"审计可丢,业务不可断")
- **独立提交** —— 日志单独 commit,不与业务同事务。同事务时业务失败回滚会
连带丢掉最该留痕的失败记录
用法(上下文管理器)::
target = user_repository.get_by_id(session, user_id) # 只读预取,供摘要
with audit(session, operator=current_user,
record=AuditRecord(MODULE_USER, ACTION_DELETE,
f"删除用户 {target.username}"),
meta=meta):
...业务操作与 session.commit()...
return out
约定:
- 业务代码在上下文内自行 commit(事务边界仍在 service 层)。**commit 之后
的任何数据库查询、ORM 属性读取都必须移出上下文**(提到 commit 之前或
用已固化的局部变量)—— 业务已提交却因后置查询抛错被记成「失败」,会
产生与事实相反的审计记录
- 上下文内不得吞掉异常后正常退出——吞掉后会被记为成功
- 摘要需要"目标实体名"时,应在进入上下文**之前**只读预取,
这样目标不存在(NotFound 失败)时也能留下可追溯的记录
- 不支持嵌套(在 audit 内再调用会自行 commit 的服务)——进入侦测到
未提交变更会记 warning
"""
import logging
import time
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.core.exceptions import AppException
from app.models import User
from app.repositories import operation_log_repository
from app.repositories.operation_log_repository import OperationLogEntry
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class RequestMeta:
"""HTTP 请求元信息。由路由层依赖 `RequestMetaDep` 提取后传入 service。"""
ip: str | None = None
user_agent: str | None = None
method: str | None = None
path: str | None = None
@dataclass(frozen=True, slots=True)
class AuditRecord:
"""审计的业务侧内容(模块 / 操作 / 摘要 / 请求参数)。"""
module: str
action: str
summary: str
request_data: dict | None = None
@contextmanager
def audit(
session: Session,
*,
operator: User,
record: AuditRecord,
meta: RequestMeta | None = None,
) -> Iterator[None]:
"""审计上下文:正常退出记成功;抛异常记失败并**原样重抛**。"""
started = time.perf_counter()
meta = meta or RequestMeta()
# 固化操作人为普通标量,之后不再触碰 ORM 对象 —— 失败路径先 rollback
# 会使其全部过期,再读 id/username 将触发额外 SELECT;若账号恰被并发
# 删除,ObjectDeletedError 还会被下面的兜底吞掉、失败审计静默丢失
base: dict = {
"user_id": operator.id,
"username": operator.username,
"module": record.module,
"action": record.action,
"summary": record.summary,
"method": meta.method,
"path": meta.path,
"ip": meta.ip,
"user_agent": meta.user_agent,
"request_data": record.request_data,
}
# 进入审计时应无未提交变更(业务写操作在上下文内才发生)。出现变更
# 意味着调用方可能嵌套了会自行 commit 的服务:失败路径的回滚将无法
# 撤销已提交的部分,审计与数据状态会不一致
if session.new or session.dirty or session.deleted:
logger.warning(
"进入审计上下文时存在未提交的变更 | module=%s | action=%s",
record.module,
record.action,
)
try:
yield
except Exception as exc:
# 先回滚再写日志:业务异常可能源于 IntegrityError 等数据库错误,
# 不回滚的话 session 处于「必须回滚」状态,写日志会抛
# PendingRollbackError —— 最该留下的失败记录反而被丢掉
_rollback_quietly(session)
_safe_write(
session,
entry=OperationLogEntry(
**base,
success=False,
error_message=_error_text(exc),
duration_ms=_elapsed_ms(started),
),
)
raise
else:
_safe_write(
session,
entry=OperationLogEntry(**base, success=True, duration_ms=_elapsed_ms(started)),
)
def _safe_write(session: Session, *, entry: OperationLogEntry) -> None:
"""落库并独立提交。整体兜底:任何失败只记应用日志,绝不外抛。"""
try:
operation_log_repository.add(session, entry)
session.commit()
except Exception:
logger.warning(
"操作日志写入失败(不影响业务) | module=%s | action=%s | summary=%s",
entry.module,
entry.action,
entry.summary,
exc_info=True,
)
_rollback_quietly(session)
def _rollback_quietly(session: Session) -> None:
try:
session.rollback()
except Exception:
logger.warning("会话回滚失败", exc_info=True)
def _error_text(exc: Exception) -> str:
"""失败原因文案。
业务异常取其面向用户的消息;未预期异常返回通用文案 —— 完整堆栈由全局
异常处理器记入应用日志,审计表不落内部细节。
"""
if isinstance(exc, AppException):
return exc.message
return "服务端异常,请稍后重试"
def _elapsed_ms(started: float) -> int:
"""度量业务处理耗时(不含响应序列化)。"""
return max(0, int((time.perf_counter() - started) * 1000))

View File

@ -0,0 +1,82 @@
"""日志查询编排。
操作日志与登录日志分别提供分页查询(两表字段与筛选项不同),操作日志另有
单条详情(列表为性能排除的 `request_data` 走详情按需加载)。
纯只读操作,不写审计 —— 读操作记日志只会淹没审计表。
"""
from sqlalchemy.orm import Session
from app.core.exceptions import NotFoundError
from app.core.response import PageResult
from app.repositories import login_log_repository, operation_log_repository
from app.repositories.query_utils import LogFilters
from app.schemas.log import (
LoginLogOut,
LoginLogQuery,
OperationLogDetailOut,
OperationLogOut,
OperationLogQuery,
)
def list_operation_logs(session: Session, query: OperationLogQuery) -> PageResult[OperationLogOut]:
"""分页查询操作日志(列表不含 request_data,详情走 `get_operation_log`)。"""
filters = _filters_from(query)
logs = operation_log_repository.list_logs(
session,
offset=query.offset,
limit=query.page_size,
filters=filters,
module=query.module,
action=query.action,
)
total = operation_log_repository.count_logs(
session, filters=filters, module=query.module, action=query.action
)
return PageResult[OperationLogOut](
records=[OperationLogOut.model_validate(log) for log in logs],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_operation_log(session: Session, log_id: int) -> OperationLogDetailOut:
"""查询单条操作日志详情(含已脱敏的 request_data)。
:raises NotFoundError: 日志不存在
"""
log = operation_log_repository.get_by_id(session, log_id)
if log is None:
raise NotFoundError("日志不存在")
return OperationLogDetailOut.model_validate(log)
def list_login_logs(session: Session, query: LoginLogQuery) -> PageResult[LoginLogOut]:
"""分页查询登录日志。"""
filters = _filters_from(query)
logs = login_log_repository.list_logs(
session,
offset=query.offset,
limit=query.page_size,
filters=filters,
ip=query.ip,
)
total = login_log_repository.count_logs(session, filters=filters, ip=query.ip)
return PageResult[LoginLogOut](
records=[LoginLogOut.model_validate(log) for log in logs],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def _filters_from(query: OperationLogQuery | LoginLogQuery) -> LogFilters:
"""两类 Query 的公共筛选字段 → LogFilters(口径与 repository 共用一个实现)。"""
return LogFilters(
username=query.username,
success=query.success,
start_time=query.start_time,
end_time=query.end_time,
)

View File

@ -0,0 +1,253 @@
"""角色管理业务编排。
需求 2.5.1 只定义 admin / user 两个角色,本模块属用户确认的**自主完善**
(轻量版:不含权限点分配 —— `permissions` / `role_permissions` 表继续闲置,
待业务方给出权限清单后再启用)。
保护规则(都是系统自举与授权安全的最低防线,不只是界面体验):
- `admin` 角色**禁删禁停** —— `cli` 建管理员与 `require_admin` 判定都依赖
该编码存在,删除会使系统无法自举
- **有用户绑定的角色禁删** —— `user_roles` 外键是 CASCADE,直接删除会静默
剥离绑定用户的权限,属安全与审计事故;删除前的校验走 `FOR UPDATE`
锁行,堵住「校验与删除之间并发绑定」的窗口
- `role_code` 创建后不可改 —— 它是授权判定(`ROLE_ADMIN` 常量按编码比对)的锚点
- 停用角色**不解除绑定、不影响既有授权**(授权判定只看编码、不看状态),
但**不可再分配给用户**(`user_service._resolve_roles` 拦截)
"""
import logging
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_CREATE,
ACTION_DELETE,
ACTION_DISABLE,
ACTION_ENABLE,
ACTION_UPDATE,
MODULE_ROLE,
ROLE_ADMIN,
)
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.models import Role, User
from app.repositories import role_repository
from app.schemas.role import RoleCreateIn, RoleManageOut, RoleQuery, RoleUpdateIn
from app.services import audit_service
from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__)
def _to_out(role: Role, user_count: int) -> RoleManageOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
return RoleManageOut(
id=role.id,
role_code=role.role_code,
role_name=role.role_name,
description=role.description,
sort_order=role.sort_order,
status=role.status,
user_count=user_count,
)
def _user_count(session: Session, role_id: int) -> int:
"""单个角色的绑定用户数(复用批量统计,保持口径一致)。"""
return role_repository.count_users_by_role(session, [role_id]).get(role_id, 0)
def list_roles(session: Session, query: RoleQuery) -> PageResult[RoleManageOut]:
"""分页查询角色。
与用户表单下拉(`/users/meta/roles`)口径不同:**管理列表含停用角色**,
下拉只给启用项。
"""
roles = role_repository.list_roles(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
status=query.status,
)
total = role_repository.count_roles(session, keyword=query.keyword, status=query.status)
counts = role_repository.count_users_by_role(session, [role.id for role in roles])
return PageResult[RoleManageOut](
records=[_to_out(role, counts.get(role.id, 0)) for role in roles],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def create_role(
session: Session,
payload: RoleCreateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> RoleManageOut:
"""新增角色。编码唯一、创建后不可修改,新角色固定启用。
:raises BadRequestError: 角色编码已存在(含并发提交的兜底)
"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_ROLE,
action=ACTION_CREATE,
summary=f"新增角色 {payload.role_name}({payload.role_code})",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if role_repository.get_by_code(session, payload.role_code) is not None:
raise BadRequestError(f"角色编码 {payload.role_code!r} 已存在")
role = Role(
role_code=payload.role_code,
role_name=payload.role_name,
description=payload.description,
sort_order=payload.sort_order,
status=1,
)
try:
role_repository.create(session, role)
out = _to_out(role, 0)
session.commit()
except IntegrityError as exc:
# 先查后写不是原子操作:并发提交同一编码会撞 uk_roles_role_code。
# 回滚并把唯一键冲突翻译为可读的 400,而非 500
session.rollback()
raise BadRequestError(f"角色编码 {payload.role_code!r} 已存在") from exc
logger.info("新增角色 | role_code=%s", payload.role_code)
return out
def update_role(
session: Session,
role_id: int,
payload: RoleUpdateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> RoleManageOut:
"""编辑角色名称/说明/排序(全量替换,不含 role_code)。
:raises NotFoundError: 角色不存在
"""
role = role_repository.get_by_id(session, role_id)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_ROLE,
action=ACTION_UPDATE,
summary=f"修改角色 {role.role_name if role else f'#{role_id}'}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if role is None:
raise NotFoundError("角色不存在")
role.role_name = payload.role_name
role.description = payload.description
role.sort_order = payload.sort_order
# 响应与后置动作全部在 commit 之前完成(见 user_service 顶部说明)
out = _to_out(role, _user_count(session, role_id))
session.commit()
logger.info("修改角色 | role_id=%s", role_id)
return out
def set_status(
session: Session,
role_id: int,
status: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> RoleManageOut:
"""启用或禁用角色。
停用不解除用户绑定、不影响既有授权(授权判定只看编码不看状态),
但该角色不可再分配给用户(`user_service._resolve_roles` 拦截);
内置管理员角色不可停用。
:raises NotFoundError: 角色不存在
:raises ForbiddenError: 试图停用内置管理员角色
"""
role = role_repository.get_by_id(session, role_id)
verb = "启用" if status == 1 else "禁用"
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_ROLE,
action=ACTION_ENABLE if status == 1 else ACTION_DISABLE,
summary=f"{verb}角色 {role.role_name if role else f'#{role_id}'}",
request_data={"status": status},
),
meta=meta,
):
if role is None:
raise NotFoundError("角色不存在")
if role.role_code == ROLE_ADMIN and status == 0:
raise ForbiddenError("内置管理员角色不可停用")
role.status = status
out = _to_out(role, _user_count(session, role_id))
session.commit()
logger.info("变更角色状态 | role_id=%s | status=%s", role_id, status)
return out
def delete_role(
session: Session,
role_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""删除角色。
内置管理员角色不可删除;仍有关联用户的角色不可删除(提示先解除绑定)。
查询走 `FOR UPDATE` 锁行:校验与删除之间若发生并发绑定,CASCADE 会把
新绑定一并静默剥离 —— 锁行使并发绑定失败(可见)而非数据被吞(不可见)。
:raises NotFoundError: 角色不存在
:raises ForbiddenError: 试图删除内置管理员角色
:raises BadRequestError: 角色下仍有绑定的用户
"""
role = role_repository.get_by_id(session, role_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_ROLE,
action=ACTION_DELETE,
summary=f"删除角色 {role.role_name if role else f'#{role_id}'}",
),
meta=meta,
):
if role is None:
raise NotFoundError("角色不存在")
if role.role_code == ROLE_ADMIN:
raise ForbiddenError("内置管理员角色不可删除")
# user_roles 外键是 CASCADE:不拦截的话,删除会静默剥离绑定用户的权限
user_count = _user_count(session, role_id)
if user_count > 0:
raise BadRequestError(f"该角色下仍有 {user_count} 个用户,请先解除绑定")
role_code = role.role_code # 固化:对象随删除失效
role_repository.delete(session, role)
session.commit()
logger.info("删除角色 | role_code=%s", role_code)

View File

@ -4,25 +4,45 @@
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 ROLE_ADMIN
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 user_repository
from app.schemas.user import RoleOut, UserCreateIn, UserOut, UserQuery, UserUpdateIn
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__)
@ -83,73 +103,126 @@ def get_user(session: Session, user_id: int) -> UserOut:
return _to_out(user)
def create_user(session: Session, payload: UserCreateIn) -> UserOut:
def create_user(
session: Session,
payload: UserCreateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> UserOut:
"""新增用户。
:raises BadRequestError: 用户名已存在,或角色编码无效
:raises BadRequestError: 用户名已存在,或角色编码无效/已停用
"""
if user_repository.get_by_username(session, payload.username) is not None:
raise BadRequestError(f"用户名 {payload.username!r} 已存在")
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)
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,
)
user_repository.create(session, user)
user_repository.replace_roles(session, user, roles)
session.commit()
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", user.username, payload.roles)
return _to_out(user)
logger.info("新增用户 | username=%s | roles=%s", payload.username, payload.roles)
return out
def update_user(
session: Session, user_id: int, payload: UserUpdateIn, *, operator_id: int
session: Session,
user_id: int,
payload: UserUpdateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> UserOut:
"""编辑用户的基本信息与角色。
**不含用户名与密码**:用户名是审计追溯的锚点;密码走专门的重置接口,
以免"改资料"顺手改掉密码这类越权操作。
空串字段(`''`)归一为 None:编辑表单会把未修改的字段以空串提交,
若原样写入会把库中的 NULL 污染成空串(两类空值语义漂移且不可逆);
归一后「清空输入框」= 写回 NULL,语义正确。
:raises NotFoundError: 用户不存在
:raises BadRequestError: 角色编码无效
:raises BadRequestError: 角色编码无效或已停用
"""
user = user_repository.get_by_id(session, user_id)
if user is None:
raise NotFoundError("用户不存在")
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
if payload.phone is not None:
user.phone = payload.phone
if payload.email is not None:
user.email = payload.email
if payload.remark is not None:
user.remark = payload.remark
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("不能移除自己的管理员角色")
# 不允许把自己降级掉管理角色,否则改完立刻失去管理权限
if payload.roles is not None and user_id == operator.id and ROLE_ADMIN not in payload.roles:
raise ForbiddenError("不能移除自己的管理员角色")
session.flush()
session.flush()
if payload.roles is not None:
roles = _resolve_roles(session, payload.roles)
user_repository.replace_roles(session, user, roles)
if payload.roles is not None:
roles = _resolve_roles(session, payload.roles)
user_repository.replace_roles(session, user, roles)
session.commit()
logger.info("编辑用户 | user_id=%s | roles=%s", user_id, payload.roles)
return _to_out(user)
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) -> None:
def reset_password(
session: Session,
user_id: int,
new_password: str,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""重置用户密码。
同时把 `token_version` 加一,使该用户**已签发的令牌立即失效** ——
@ -158,16 +231,35 @@ def reset_password(session: Session, user_id: int, new_password: str) -> None:
:raises NotFoundError: 用户不存在
"""
user = user_repository.get_by_id(session, user_id)
if user is None:
raise NotFoundError("用户不存在")
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)
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_id: int) -> UserOut:
def set_status(
session: Session,
user_id: int,
status: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> UserOut:
"""启用或禁用用户。
禁用时同样提升 `token_version`,使已签发令牌立即失效 ——
@ -177,21 +269,40 @@ def set_status(session: Session, user_id: int, status: int, *, operator_id: int)
:raises ForbiddenError: 试图禁用自己
"""
user = user_repository.get_by_id(session, user_id)
if user is None:
raise NotFoundError("用户不存在")
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("不能禁用自己")
if status == 0 and user_id == operator.id:
raise ForbiddenError("不能禁用自己")
user.status = status
if status == 0:
user.token_version += 1
session.commit()
logger.info("变更用户状态 | user_id=%s | status=%s", user_id, status)
return _to_out(user)
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_id: int) -> None:
def delete_user(
session: Session,
user_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""删除用户。
物理删除。历史操作日志通过 `user_id` 置空 + 冗余 `username` 保留可追溯性。
@ -200,33 +311,50 @@ def delete_user(session: Session, user_id: int, *, operator_id: int) -> None:
:raises ForbiddenError: 试图删除自己
"""
user = user_repository.get_by_id(session, user_id)
if user is None:
raise NotFoundError("用户不存在")
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("不能删除自己")
if user_id == operator.id:
raise ForbiddenError("不能删除自己")
username = user.username
user_repository.delete(session, user)
session.commit()
logger.info("删除用户 | username=%s", username)
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 user_repository.get_all_roles(session)]
"""取全部启用角色,供用户表单的角色选择使用。"""
return [RoleOut.model_validate(role) for role in role_repository.get_all_roles(session)]
def _resolve_roles(session: Session, role_codes: list[str]) -> list:
"""把角色编码解析为角色对象,并校验全部存在。
"""把角色编码解析为角色对象,校验全部存在且**未停用**。
:raises BadRequestError: 存在无效编码
停用角色不出现在表单下拉中;接口层若不拦截,直接调 API(或使用停用前
加载的旧选项)仍可绑定,与「停用后不可再分配」的语义矛盾。已绑定停用
角色的用户需先转为启用角色再保存。
:raises BadRequestError: 存在无效编码,或角色已停用
"""
if not role_codes:
raise BadRequestError("至少需要指定一个角色")
roles = user_repository.get_roles_by_codes(session, role_codes)
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)

View File

@ -1,6 +1,6 @@
# 后端测试
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 184 个用例全绿(单元 155 / 集成 29)。
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 301 个用例全绿(单元 223 / 集成 78)。
## 一、测试约定
@ -57,6 +57,23 @@ uv run pytest tests/integration -q # 仅集成
| `unit/test_auth_schemas.py` | 密码**按字节**校验 | 9 | 24 个汉字 = 72 字节正好、25 个被拒 —— 按字符数校验(如 `max_length=20`)根本拦不住,会让登录接口 500 |
| `integration/test_auth_flow.py` | 整条链路**串联正确性** | 16 | 验证码→登录→持令牌访问→登出失效;验证码重放被拒;失败原因如实落库且**日志表无任何密码字段**;账号不存在与密码错误文案完全相同(防枚举);禁用账号与 `token_version` 提升均使旧令牌立即失效 |
### 系统管理 · 用户与角色管理
| 测试文件 | 守什么 | 用例数 | 关键覆盖点 |
| --- | --- | :---: | --- |
| `unit/test_audit_service.py` | 审计上下文的语义 | 16 | 正常退出记成功、抛异常记失败;**失败时先 rollback 再写日志**且原异常原样重抛(顺序颠倒时数据库异常会让失败记录丢成 PendingRollbackError);**操作人标量在进入时固化**(失败路径不再触碰会过期的 ORM 对象);进入时脏会话告警(防嵌套半提交);审计自身失败不外抛、不替换业务异常 |
| `unit/test_audit_serialization.py` | 入库前截断与脱敏不丢数据、不泄密 | 16 | 各列按模型常量截断;60KB 请求参数按 **UTF-8 字节边界**截断后是原串的干净前缀(中文不切半);**结构化抹除**(按敏感键名整值替换):前导空白/空格/逗号/引号密码全被抹除且 JSON 结构完好、嵌套 dict/list 与键名大小写、非敏感键不误伤;手机号仍由 scrub 文本兜底;孤立代理字符不再抛错丢审计 |
| `unit/test_role_schemas.py` | 角色编码格式红线 | 16 | `^[a-z][a-z0-9_]{1,31}$` 全边界(大写/中文/数字或下划线开头/单字符/超长被拒);编辑模型**不接受 role_code**(多余字段忽略);状态只能 0/1 |
| `integration/test_user_management.py` | 用户 CRUD 串联正确性 + **审计留痕** | 23 | 分页以 `pageNum/pageSize` 生效(钉死别名契约,改蛇形会静默失效);自删/自禁/移除自己 admin 角色 403;禁用与重置密码使旧令牌立即失效;**每个写操作(含失败的)在 operation_logs 有对应记录**、request_data 全文扫不到明文密码;账号删除后审计行 `user_id` 置空而 username 保留;**编辑时保留既有角色不撞唯一键**(1062 回归);**编辑未修改字段不把 NULL 污染成空串**;**含下划线用户名不误配**(LIKE 转义回归) |
| `integration/test_role_management.py` | 角色保护规则(系统自举与授权安全的最低防线) | 13 | admin 角色禁删禁停 403;有绑定用户的角色禁删 400 且消息含用户数、解绑后可删;编码创建后不可改;**停用角色不可再分配**(接口层拦截);停用后不在 `/users/meta/roles` 但仍在管理列表;`user_count` 正确;普通用户 403 |
### 系统管理 · 日志查询
| 测试文件 | 守什么 | 用例数 | 关键覆盖点 |
| --- | --- | :---: | --- |
| `unit/test_log_schemas.py` | 时间范围校验与时区归一 | 13 | 起始晚于结束拒绝(两类日志共用基类,放过去则 SQL 条件互斥、查询恒空且无提示);**带时区输入转本地 naive**(Z 后缀 → 东八区 08:00;混用 aware/naive 不再抛 TypeError 穿透 500);边界相等通过;仅传一端不校验 |
| `integration/test_log_query.py` | 写入与查询的**口径一致** | 13 | 写操作产生的审计可按 username/module/action/success/时间范围检索到,摘要含目标用户名;失败记录带中文 `error_message`;**列表不含 request_data、详情端点返回已脱敏参数**(两段式,含 404/403);起始晚于结束 400;登录日志直插造数后可按 ip 精确筛选;两类日志普通用户 403 |
### 应用骨架与交付产物
| 测试文件 | 守什么 | 用例数 | 关键覆盖点 |
@ -71,6 +88,8 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-09 | 全部 | 120 | ✅ 通过(4.99s) | 阶段 1 骨架 + 阶段 2 数据库完成后首次全量运行 |
| 2026-10-09 | 全部 | 119 | ✅ 通过(5.01s) | ORM 由异步改回同步后回归;净减 1 例(`sync_database_url` 独立配置已合并) |
| 2026-10-09 | 全部 | 184 | ✅ 通过(25.46s) | **登录鉴权模块完成**后统一编写并执行该模块测试套件(新增 65 例)。集成用例需容器在跑 |
| 2026-10-10 | 全部 | 282 | ✅ 通过(103.81s) | **系统管理模块(用户/角色/日志)完成**后统一编写该模块套件(新增 91 例:单元 48 / 集成 43)。含操作日志审计机制专项;**e2e 发现并修复 `replace_roles` 唯一键缺陷**(见四、缺陷表);集成用例需容器在跑 |
| 2026-10-10 | 全部 | 301 | ✅ 通过(87.40s) | **code-review 缺陷修复后全量回归**(新增/改造 19 例)。密码结构化脱敏、时区归一、日志详情两段式各有专项;集成套件因密码哈希模块级预计算提速(63s→42s) |
## 四、测试发现并修复的缺陷
@ -85,14 +104,19 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-09 | 验证码沿用 `captcha` 库默认渲染 | 随机彩色字符 + 重噪点,与界面视觉体系冲突;该库最后更新于 2025-03,内部还引用了 Pillow 的旧别名 | 改为自绘(`core/captcha_image.py`):配色与界面统一,难度由逐字旋转、抖动、干扰曲线与噪点保证;仅沿用其内置字体文件 |
| 2026-10-09 | 噪点用 `ellipse` 绘制 | 3×3 包围盒画出的椭圆恰好填充中心与四个边中点,形状就是「+」,形成规则的十字纹理——既显眼又容易被整体滤波抹掉,起不到干扰作用 | 改用单像素点与 2×2 方块 |
| 2026-10-09 | `tests/unit/test_jwt.py` 两处赋值了未使用的 `settings`(ruff F841) | 变量未被引用,属无效代码 | 删除赋值;该文件的 `get_settings` 导入仍被其他用例使用 |
| 2026-10-10 | `replace_roles` 先清后建但中间未落库 | SQLAlchemy 在同一 flush 中**先处理 INSERT 再处理 DELETE**,编辑用户保留既有角色时重插同 `(user_id, role_id)` 撞唯一键(1062)——编辑用户接口 500。**由前端 e2e「编辑用户」步骤暴露**:集成测试当时只覆盖了「换掉全部角色」(无交集)的路径 | `clear()` 后立即 `session.flush()` 把删除落库,再追加新集合;补集成回归用例(编辑时保留既有角色) |
| 2026-10-10 | 审计脱敏依赖文本正则 | 三种逃逸形态:密码**以空白开头时完全不脱敏**、含空格/逗号只抹首段、含引号既残留明文又破坏 JSON——明文/残段写入 `operation_logs.request_data` 且日志页可直接展开查看(**由 code-review 发现并逐条实证复现**;既有测试所用密码均不含空格,恰好掩盖该路径) | 改为按键名的**结构化抹除**(递归 dict/list 整值替换 `***`),scrub 降级为文本兜底;补前导空白/空格/引号/嵌套/大小写五类边界用例 |
| 2026-10-10 | `.gitignore` 的 `logs/` 规则吞掉日志查询页目录 | 规则无前导斜杠、匹配任意层级:`frontend/src/views/system/logs/` 整体被忽略,文件**进不了版本库**——干净克隆构建失败(本地因文件已在磁盘上而一切"正常",隐蔽性极强) | 收窄为 `/backend/logs/`(该规则本意即后端运行日志);以 `git check-ignore` 实证不再命中 |
| 2026-10-10 | 时间筛选未做时区归一 | 混用 aware/naive 抛 TypeError 穿透成 **500**;带时区输入被驱动静默丢弃偏移(查询窗口偏 8 小时且无提示) | `LogQueryBase` 加 field_validator:aware → `astimezone()` 本地 naive、naive 原样;补 4 例专项 |
| 2026-10-10 | 审计时序两处缺陷 | ① 失败路径 rollback 后读 operator 触发额外查询,账号并发被删时 ObjectDeletedError 被兜底吞掉、失败审计静默丢失;② 成功/失败两分支 8 个实参重复展开,且 role/user service 在 commit 之后仍做查询与响应构造——后置查询一旦抛错,「已提交成功」会被记成失败 | operator 标量进入时固化;审计链 DTO 化(`audit` 4 参 / `_safe_write` 2 参 / `add` 2 参);service 的 commit 后查询与响应构造全部前置 |
## 五、待补用例
| 模块 | 状态 | 说明 |
| --- | --- | --- |
| 系统管理 · 用户与角色管理 | 待编写 | 需求 2.5.1 的用户增删改查、密码重置、启用/禁用、角色分配 — 该模块功能尚未实现 |
| 系统管理 · 操作日志 | 待编写 | 除登录日志外的操作审计(当前仅登录日志落库) |
| 基础数据管理 | 未开始 | 依赖 T+ 对接方式确认 |
| 业务数据管理 | 未开始 | 依赖需求澄清(回款天数口径等 10 项) |
| 绩效工资核算 | 未开始 | 依赖需求澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率) |
| 报表统计 | 未开始 | 依赖报表口径确认 |
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。

View File

@ -0,0 +1,169 @@
"""系统管理集成测试共用夹具(需要 MySQL 与 Redis)。
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
数据卫生约定:
- 测试账号/角色统一 ``t_`` 前缀;工厂夹具清理自己创建的对象,另有
autouse 兜底按前缀全量清理(用例经**接口**创建的数据不在工厂名下,
失败中断时会残留并影响后续用例的 total 断言)
- 操作日志的清理同时按 username 与 user_id —— 账号被删除后其 ``user_id``
会被 SET NULL,只能靠冗余的 username 追溯,清理逻辑必须覆盖两种状态
"""
import uuid
import pytest
from sqlalchemy import delete, select
from app.core.constants import ROLE_ADMIN, ROLE_USER
from app.core.db import get_session_factory
from app.core.security import create_access_token, hash_password
from app.models import OperationLog, Role, User, UserRole
TEST_PASSWORD = "TestPwd@2026"
# 模块级预计算一次:bcrypt(rounds=12)单次约 0.2–0.4 秒,逐用例重复哈希
# 会让整个集成套件多花 20 秒以上;这些测试账号全部走直签 JWT,
# 哈希值不被任何断言消费,复用一个哈希与每次重算等价
_TEST_PASSWORD_HASH = hash_password(TEST_PASSWORD)
def _purge_test_data() -> None:
"""按 `t_` 前缀清理测试残留(先日志后账号;角色先断关联再删)。
``t\\_%`` 中的下划线做了转义 —— 不转义时 `_` 是 LIKE 的单字符通配,
会把 tn... 这类无关账号一并卷入。
"""
factory = get_session_factory()
with factory() as session:
users = list(
session.execute(select(User.id, User.username).where(User.username.like("t\\_%"))).all()
)
ids = [row[0] for row in users]
names = [row[1] for row in users]
if ids:
session.execute(delete(OperationLog).where(OperationLog.user_id.in_(ids)))
if names:
session.execute(delete(OperationLog).where(OperationLog.username.in_(names)))
session.execute(delete(User).where(User.id.in_(ids)))
role_ids = list(
session.execute(select(Role.id).where(Role.role_code.like("t\\_%"))).scalars()
)
if role_ids:
session.execute(delete(UserRole).where(UserRole.role_id.in_(role_ids)))
session.execute(delete(Role).where(Role.id.in_(role_ids)))
session.commit()
@pytest.fixture(autouse=True)
def _purge_after_each_test():
"""每个集成用例结束后兜底清理,避免残留影响后续用例的计数断言。"""
yield
_purge_test_data()
@pytest.fixture
def make_user():
"""独立测试账号工厂:``make_user(["admin"])`` → dict(id/username/password)。
不复用真实 admin —— 测试会改密码、提 token_version、删除账号,
污染真实账号会让后续手工验证产生困惑。
"""
def _factory(role_codes: list[str] | None = None, username: str | None = None) -> dict:
codes = role_codes if role_codes is not None else [ROLE_USER]
name = username or f"t_{uuid.uuid4().hex[:8]}"
factory = get_session_factory()
with factory() as session:
roles = list(session.execute(select(Role).where(Role.role_code.in_(codes))).scalars())
assert len(roles) == len(codes), f"角色不存在:{codes}"
user = User(
username=name,
password=_TEST_PASSWORD_HASH,
real_name="测试账号",
status=1,
)
user.user_roles = [UserRole(role_id=role.id) for role in roles]
session.add(user)
session.commit()
return {"id": user.id, "username": name, "password": TEST_PASSWORD}
return _factory
@pytest.fixture
def auth_headers():
"""令牌直签工厂:``auth_headers(user)`` → Authorization 请求头。
绕开验证码与登录接口 —— 令牌只承载身份,授权判定以数据库中的角色为准
(`require_admin` 每次查库),直签不影响测试有效性,省去清理 Redis
限流键与黑名单。令牌版本变化(如重置密码、禁用)后旧令牌会失效,
再次调用本工厂可换新令牌。
"""
def _factory(user: dict) -> dict[str, str]:
factory = get_session_factory()
with factory() as session:
db_user = session.get(User, user["id"])
assert db_user is not None, "账号已被删除,无法签发令牌"
roles = [link.role.role_code for link in db_user.user_roles if link.role]
token, _, _ = create_access_token(
user_id=db_user.id,
token_version=db_user.token_version,
roles=roles,
)
return {"Authorization": f"Bearer {token}"}
return _factory
@pytest.fixture
def make_role():
"""独立测试角色工厂(ORM 直建,用后删除)。
清理时先删除 user_roles 关联(用例失败时可能残留绑定),再删角色。
"""
created: list[int] = []
def _factory(**overrides: object) -> dict:
code = str(overrides.pop("role_code", f"t_role_{uuid.uuid4().hex[:6]}"))
name = str(overrides.pop("role_name", "测试角色"))
factory = get_session_factory()
with factory() as session:
role = Role(
role_code=code,
role_name=name,
description=overrides.pop("description", None),
sort_order=overrides.pop("sort_order", 99),
status=overrides.pop("status", 1),
**overrides,
)
session.add(role)
session.commit()
created.append(role.id)
return {"id": role.id, "role_code": code, "role_name": name}
yield _factory
factory = get_session_factory()
with factory() as session:
session.execute(delete(UserRole).where(UserRole.role_id.in_(created)))
session.execute(delete(Role).where(Role.id.in_(created)))
session.commit()
@pytest.fixture
def admin(make_user, auth_headers) -> dict:
"""管理员账号(含请求头)。"""
user = make_user([ROLE_ADMIN])
user["headers"] = auth_headers(user)
return user
@pytest.fixture
def normal(make_user, auth_headers) -> dict:
"""普通用户账号(含请求头)。"""
user = make_user([ROLE_USER])
user["headers"] = auth_headers(user)
return user

View File

@ -0,0 +1,198 @@
"""日志查询接口集成测试(需要 MySQL 与 Redis)。
守的是:两类日志的筛选、分页、权限,以及**写入与查询的口径一致** ——
写操作产生的审计记录必须能被查询接口按同样口径检索到(否则"有写入
没查询"或"查询条件对不上",日志功能形同虚设)。
登录日志造数直接 ORM 插入,不经过登录接口:绕开验证码依赖,且能精确
控制 success/fail_reason/ip 组合。
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
"""
import uuid
from datetime import datetime, timedelta
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete
from app.core.db import get_session_factory
from app.models import LoginLog
pytestmark = pytest.mark.integration
_OPERATION_LOGS = "/api/v1/system/operation-logs"
_LOGIN_LOGS = "/api/v1/system/login-logs"
_USERS = "/api/v1/system/users"
def _make_audit(client: TestClient, admin: dict, action: str = "删除") -> None:
"""触发一条审计记录(失败操作最省事、不留业务数据)。"""
resp = client.delete(f"{_USERS}/999999999", headers=admin["headers"])
assert resp.status_code == 404
assert action == "删除"
class TestOperationLogQuery:
"""操作日志查询。"""
def test_写操作后可按模块与操作筛选(self, client: TestClient, admin: dict, make_user) -> None:
target = make_user()
resp = client.delete(f"{_USERS}/{target['id']}", headers=admin["headers"])
assert resp.status_code == 200
resp = client.get(
_OPERATION_LOGS,
params={"username": admin["username"], "module": "用户管理", "action": "删除"},
headers=admin["headers"],
)
data = resp.json()["data"]
assert data["total"] >= 1
row = data["list"][0]
assert row["success"] == 1
assert target["username"] in row["summary"]
assert row["module"] == "用户管理"
def test_按结果筛选出失败记录(self, client: TestClient, admin: dict) -> None:
_make_audit(client, admin)
resp = client.get(
_OPERATION_LOGS,
params={"username": admin["username"], "success": 0},
headers=admin["headers"],
)
data = resp.json()["data"]
assert data["total"] >= 1
assert data["list"][0]["error_message"]
def test_时间范围筛选(self, client: TestClient, admin: dict) -> None:
_make_audit(client, admin)
now = datetime.now()
fmt = "%Y-%m-%d %H:%M:%S"
resp = client.get(
_OPERATION_LOGS,
params={
"username": admin["username"],
"start_time": (now - timedelta(minutes=5)).strftime(fmt),
"end_time": (now + timedelta(minutes=5)).strftime(fmt),
},
headers=admin["headers"],
)
assert resp.json()["data"]["total"] >= 1
# 已过去的时间窗(排除测试刚产生的记录)
resp = client.get(
_OPERATION_LOGS,
params={
"username": admin["username"],
"start_time": "2020-01-01 00:00:00",
"end_time": "2020-01-02 00:00:00",
},
headers=admin["headers"],
)
assert resp.json()["data"]["total"] == 0
def test_起始晚于结束返回400(self, client: TestClient, admin: dict) -> None:
resp = client.get(
_OPERATION_LOGS,
params={"start_time": "2026-10-02 00:00:00", "end_time": "2026-10-01 00:00:00"},
headers=admin["headers"],
)
assert resp.status_code == 400
def test_列表响应不含请求参数字段(self, client: TestClient, admin: dict) -> None:
"""列表为性能排除 request_data(TEXT 大字段),防止翻页负担随日志表增长。"""
resp = client.get(_OPERATION_LOGS, headers=admin["headers"])
rows = resp.json()["data"]["list"]
if rows:
assert "request_data" not in rows[0]
def test_详情端点返回已脱敏的请求参数(self, client: TestClient, admin: dict, make_user) -> None:
"""列表 → 详情 的两段式:详情含 request_data 且已脱敏。"""
secret = "S3cret@2026!"
target = make_user()
resp = client.put(
f"{_USERS}/{target['id']}/password",
json={"new_password": secret},
headers=admin["headers"],
)
assert resp.status_code == 200
resp = client.get(
_OPERATION_LOGS,
params={"username": admin["username"], "action": "重置密码"},
headers=admin["headers"],
)
row = resp.json()["data"]["list"][0]
assert "request_data" not in row
detail = client.get(f"{_OPERATION_LOGS}/{row['id']}", headers=admin["headers"])
assert detail.status_code == 200
request_data = detail.json()["data"]["request_data"] or ""
assert secret not in request_data
assert "***" in request_data
def test_详情端点不存在返回404(self, client: TestClient, admin: dict) -> None:
resp = client.get(f"{_OPERATION_LOGS}/999999999", headers=admin["headers"])
assert resp.status_code == 404
def test_详情端点普通用户被拒(self, client: TestClient, normal: dict) -> None:
resp = client.get(f"{_OPERATION_LOGS}/1", headers=normal["headers"])
assert resp.status_code == 403
def test_普通用户被拒(self, client: TestClient, normal: dict) -> None:
assert client.get(_OPERATION_LOGS, headers=normal["headers"]).status_code == 403
class TestLoginLogQuery:
"""登录日志查询。"""
@pytest.fixture
def seeded_logs(self):
"""直插两条登录日志(一成功一失败),用后清理。"""
username = f"t_login_{uuid.uuid4().hex[:6]}"
factory = get_session_factory()
with factory() as session:
session.add(
LoginLog(
username=username,
ip="10.1.2.3",
user_agent="pytest",
success=0,
fail_reason="用户名或密码错误",
)
)
session.add(LoginLog(username=username, ip="10.1.2.4", user_agent="pytest", success=1))
session.commit()
yield username
with factory() as session:
session.execute(delete(LoginLog).where(LoginLog.username == username))
session.commit()
def test_按用户名查询并返回两端结果(
self, client: TestClient, admin: dict, seeded_logs: str
) -> None:
resp = client.get(_LOGIN_LOGS, params={"username": seeded_logs}, headers=admin["headers"])
data = resp.json()["data"]
assert data["total"] == 2
assert {row["success"] for row in data["list"]} == {0, 1}
def test_按IP精确筛选(self, client: TestClient, admin: dict, seeded_logs: str) -> None:
resp = client.get(_LOGIN_LOGS, params={"ip": "10.1.2.3"}, headers=admin["headers"])
data = resp.json()["data"]
assert data["total"] == 1
assert data["list"][0]["fail_reason"] == "用户名或密码错误"
def test_按结果筛选(self, client: TestClient, admin: dict, seeded_logs: str) -> None:
resp = client.get(
_LOGIN_LOGS,
params={"username": seeded_logs, "success": 0},
headers=admin["headers"],
)
data = resp.json()["data"]
assert data["total"] == 1
assert data["list"][0]["success"] == 0
def test_普通用户被拒(self, client: TestClient, normal: dict) -> None:
assert client.get(_LOGIN_LOGS, headers=normal["headers"]).status_code == 403

View File

@ -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

View File

@ -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

View File

@ -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 "<EFBFBD>" 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

View File

@ -0,0 +1,268 @@
"""操作日志审计服务单元测试。
守的是 `audit()` 上下文管理器的语义:
- 正常退出记成功、抛异常记失败,**失败记录必须先生成再让异常继续传播**
- 失败路径必须先 `rollback` 再写日志 —— 业务异常若来自 IntegrityError 等
数据库错误,不回滚时 session 处于待回滚状态,写日志会抛 PendingRollbackError,
最该留下的失败记录反而被静默丢掉
- 操作人信息在进入上下文时**固化为标量** —— 失败路径 rollback 会过期 ORM
对象,再读属性会触发额外查询,账号并发被删时甚至 ObjectDeletedError
令失败审计静默丢失
- 审计自身的任何失败都不得外抛("审计可丢,业务不可断")
"""
import logging
from datetime import datetime
from types import SimpleNamespace
import pytest
from app.core.exceptions import BadRequestError
from app.models import OperationLog
from app.services.audit_service import AuditRecord, RequestMeta, audit
class _StubSession:
"""最小会话桩:记录调用顺序与写入的日志对象,可注入失败。"""
def __init__(self) -> None:
self.events: list[str] = []
self.logs: list[OperationLog] = []
self.fail_add = False
self.fail_commit = False
# audit 入口会巡检未提交变更(嵌套调用防护)
self.new: list = []
self.dirty: list = []
self.deleted: list = []
def add(self, obj: object) -> None:
if self.fail_add:
raise RuntimeError("模拟写入失败")
self.events.append("add")
self.logs.append(obj) # type: ignore[arg-type]
def flush(self) -> None:
self.events.append("flush")
def commit(self) -> None:
if self.fail_commit:
raise RuntimeError("模拟提交失败")
self.events.append("commit")
def rollback(self) -> None:
self.events.append("rollback")
class _FrozenOperator:
"""只允许读取固定轮数属性的操作人桩。
模拟「rollback 后 ORM 对象过期」:若失败路径仍读取 operator 属性,
真实环境会触发额外 SELECT(且账号并发被删时抛 ObjectDeletedError),
此处直接以异常暴露该访问。audit 入口读 id + username 恰为 2 次。
"""
def __init__(self, max_reads: int = 2) -> None:
self._max_reads = max_reads
self._reads = 0
def _touch(self) -> None:
self._reads += 1
if self._reads > self._max_reads:
raise RuntimeError("进入审计后不应再访问操作人 ORM 属性")
@property
def id(self) -> int:
self._touch()
return 7
@property
def username(self) -> str:
self._touch()
return "admin"
def _operator() -> SimpleNamespace:
"""模拟已认证用户(audit 只读取 id 与 username 两个属性)。"""
return SimpleNamespace(id=7, username="admin")
def _record(**overrides: object) -> AuditRecord:
base: dict = {"module": "用户管理", "action": "新增", "summary": "新增用户 zhangsan"}
base.update(overrides)
return AuditRecord(**base)
def _audit(
stub: _StubSession,
*,
request_data: dict | None = None,
meta: RequestMeta | None = None,
):
return audit(
stub, # type: ignore[arg-type]
operator=_operator(),
record=_record(request_data=request_data),
meta=meta,
)
class TestSuccessPath:
"""正常退出:业务完成后记成功并独立提交。"""
def test_成功路径写入一条成功日志(self) -> None:
stub = _StubSession()
with _audit(stub):
pass
assert len(stub.logs) == 1
log = stub.logs[0]
assert log.success == 1
assert log.error_message is None
assert log.module == "用户管理"
assert log.action == "新增"
assert log.username == "admin"
assert log.user_id == 7
def test_日志提交发生在业务逻辑之后(self) -> None:
"""审计是业务的"后置动作":业务先 commit,日志再独立 commit。"""
stub = _StubSession()
with _audit(stub):
stub.events.append("business")
assert stub.events == ["business", "add", "flush", "commit"]
def test_请求元信息与耗时被写入(self) -> None:
stub = _StubSession()
meta = RequestMeta(
ip="10.0.0.8", user_agent="pytest-agent", method="POST", path="/api/v1/system/users"
)
with _audit(stub, meta=meta):
pass
log = stub.logs[0]
assert log.method == "POST"
assert log.path == "/api/v1/system/users"
assert log.ip == "10.0.0.8"
assert log.user_agent == "pytest-agent"
assert log.duration_ms is not None
assert log.duration_ms >= 0
def test_请求参数经脱敏后写入(self) -> None:
stub = _StubSession()
with _audit(stub, request_data={"username": "zhangsan", "password": "Secret@2026"}):
pass
request_data = stub.logs[0].request_data or ""
assert "Secret@2026" not in request_data
assert "zhangsan" in request_data
class TestFailurePath:
"""业务异常:先回滚、记失败、异常原样重抛。"""
def test_失败时原异常原样重抛(self) -> None:
stub = _StubSession()
with pytest.raises(BadRequestError, match="角色编码无效"), _audit(stub):
raise BadRequestError("角色编码无效")
def test_失败记录在回滚之后写入(self) -> None:
"""顺序断言:rollback 必须早于 add。颠倒时,来自数据库异常的失败
记录会因 session 待回滚而抛 PendingRollbackError,整条丢失。"""
stub = _StubSession()
with pytest.raises(BadRequestError), _audit(stub):
stub.events.append("business")
raise BadRequestError("用户名重复")
assert stub.events == ["business", "rollback", "add", "flush", "commit"]
log = stub.logs[0]
assert log.success == 0
assert log.error_message == "用户名重复"
def test_未预期异常不泄漏内部信息(self) -> None:
"""未预期异常的堆栈由全局处理器记入应用日志;审计表只落通用文案。"""
stub = _StubSession()
with pytest.raises(ValueError, match="内部实现细节"), _audit(stub):
raise ValueError("内部实现细节")
assert stub.logs[0].error_message == "服务端异常,请稍后重试"
class TestOperatorFrozen:
"""操作人标量在进入上下文时固化,之后(尤其 rollback 后)不得再触碰 ORM 对象。"""
def test_成功路径只读取一轮操作人属性(self) -> None:
stub = _StubSession()
with audit(stub, operator=_FrozenOperator(), record=_record()): # type: ignore[arg-type]
pass
assert stub.logs[0].username == "admin"
def test_失败路径不再触碰操作人对象(self) -> None:
"""回归:此前失败路径在 rollback 之后读 operator.id/username ——
真实环境会触发额外 SELECT,账号被并发删除时 ObjectDeletedError
还会被兜底吞掉,最该留痕的失败审计静默丢失。"""
stub = _StubSession()
with (
pytest.raises(BadRequestError, match="用户不存在"),
audit(
stub, # type: ignore[arg-type]
operator=_FrozenOperator(),
record=_record(action="删除", summary="删除用户 x"),
),
):
raise BadRequestError("用户不存在")
assert stub.logs[0].user_id == 7
assert stub.logs[0].username == "admin"
class TestDirtySessionGuard:
"""进入审计时存在未提交变更 → 记 warning(防嵌套调用造成半提交)。"""
def test_脏会话触发警告(self, caplog: pytest.LogCaptureFixture) -> None:
stub = _StubSession()
stub.dirty.append(object())
with caplog.at_level(logging.WARNING), _audit(stub):
pass
assert any("未提交的变更" in message for message in caplog.messages)
def test_干净会话不告警(self, caplog: pytest.LogCaptureFixture) -> None:
stub = _StubSession()
with caplog.at_level(logging.WARNING), _audit(stub):
pass
assert not caplog.messages
class TestResilience:
"""审计失败不得影响业务。"""
def test_写入失败不外抛且业务继续(self) -> None:
stub = _StubSession()
stub.fail_add = True
reached = False
with _audit(stub):
reached = True
assert reached is True # 审计写入失败不打断业务流
def test_提交失败不外抛(self) -> None:
stub = _StubSession()
stub.fail_commit = True
with _audit(stub):
pass # 正常退出,不抛异常
def test_业务失败时审计失败不以审计异常替换原异常(self) -> None:
"""业务异常是用户看到的原因,不能因审计自身失败而被替换。"""
stub = _StubSession()
stub.fail_add = True
with pytest.raises(BadRequestError, match="业务错误"), _audit(stub):
raise BadRequestError("业务错误")
class TestSerializationEdge:
"""审计参数的序列化边界(细节覆盖见 test_audit_serialization)。"""
def test_请求参数可为None(self) -> None:
stub = _StubSession()
with _audit(stub, request_data=None):
pass
assert stub.logs[0].request_data is None
def test_不可JSON序列化的值被降级为字符串(self) -> None:
"""如 datetime 等对象经 default=str 降级,序列化不应抛异常。"""
stub = _StubSession()
with _audit(stub, request_data={"at": datetime(2026, 10, 10, 12, 0, 0)}):
pass
assert "2026-10-10" in (stub.logs[0].request_data or "")

View File

@ -0,0 +1,92 @@
"""日志查询模型单元测试。
守的是**时间范围校验**与**时区归一**:起始晚于结束时必须拒绝 —— 放过去
的话 SQL 条件互斥、查询恒为空,使用者会以为是"没有数据"而不是"条件写反了";
带时区的输入必须转换为本地 naive 时间 —— 不转换时 aware/naive 混用比较
抛 TypeError(穿透成 500),或偏移被驱动静默丢弃(查询窗口偏 8 小时)。
"""
from datetime import UTC, datetime
import pytest
from pydantic import ValidationError
from app.schemas.log import LoginLogQuery, OperationLogQuery
class TestTimeRange:
"""start_time ≤ end_time 校验(两类日志共用基类)。"""
def test_合法范围通过(self) -> None:
query = OperationLogQuery(
start_time=datetime(2026, 10, 1, 0, 0, 0), end_time=datetime(2026, 10, 10, 0, 0, 0)
)
assert query.start_time is not None
def test_起始晚于结束被拒(self) -> None:
with pytest.raises(ValidationError):
OperationLogQuery(start_time=datetime(2026, 10, 2), end_time=datetime(2026, 10, 1))
def test_登录日志同样校验(self) -> None:
with pytest.raises(ValidationError):
LoginLogQuery(start_time=datetime(2026, 10, 2), end_time=datetime(2026, 10, 1))
def test_边界相等通过(self) -> None:
moment = datetime(2026, 10, 1, 12, 0, 0)
assert OperationLogQuery(start_time=moment, end_time=moment).start_time == moment
def test_仅传一端不校验(self) -> None:
assert OperationLogQuery(start_time=datetime(2026, 10, 1)).end_time is None
assert OperationLogQuery(end_time=datetime(2026, 10, 1)).start_time is None
class TestQueryFields:
"""筛选字段边界。"""
def test_成功标记只能为0或1(self) -> None:
assert OperationLogQuery(success=0).success == 0
with pytest.raises(ValidationError):
OperationLogQuery(success=2)
def test_分页别名与偏移(self) -> None:
query = OperationLogQuery(pageNum=2, pageSize=50)
assert query.offset == 50
def test_操作日志筛选字段(self) -> None:
query = OperationLogQuery(module="用户管理", action="删除", username="adm")
assert (query.module, query.action, query.username) == ("用户管理", "删除", "adm")
def test_登录日志按IP精确筛选(self) -> None:
assert LoginLogQuery(ip="10.0.0.1").ip == "10.0.0.1"
class TestTimezoneNormalization:
"""时区归一。
回归背景:混合 aware/naive 比较曾抛 TypeError 穿透成 500;aware 直传
曾被驱动静默丢弃偏移(查询窗口整体偏 8 小时且无提示)。
"""
def test_utc后缀被转换为本地时间(self) -> None:
"""Z 后缀 = UTC 零点 = 东八区本地 08:00(与库列的本地时间口径对齐)。"""
query = OperationLogQuery(start_time="2026-10-01T00:00:00Z")
assert query.start_time == datetime(2026, 10, 1, 8, 0, 0)
def test_混合aware与naive不再抛比较错误(self) -> None:
query = OperationLogQuery(
start_time=datetime(2026, 10, 1, 0, 0, 0),
end_time=datetime(2026, 10, 2, 0, 0, 0, tzinfo=UTC),
)
assert query.end_time is not None
assert query.end_time.tzinfo is None # 已摘除
def test_归一后范围校验仍按本地口径生效(self) -> None:
with pytest.raises(ValidationError):
OperationLogQuery(
start_time="2026-10-02T00:00:00Z", # 本地 10-02 08:00
end_time="2026-10-01 12:00:00", # 本地 10-01 12:00,早于起始
)
def test_naive时间视为本地原样保留(self) -> None:
moment = datetime(2026, 10, 1, 12, 0, 0)
assert OperationLogQuery(start_time=moment).start_time == moment

View File

@ -0,0 +1,98 @@
"""角色请求模型单元测试。
守的是**角色编码的格式红线**:编码会进入授权判断(`ROLE_ADMIN` 按编码
比对)与操作日志,必须限定为小写字母开头的小写/数字/下划线组合 ——
放开大写或中文会让对照与检索困难,超长则与列宽(32)不符。
"""
import pytest
from pydantic import ValidationError
from app.schemas.role import RoleCreateIn, RoleQuery, RoleStatusIn, RoleUpdateIn
def _create(**overrides: object) -> RoleCreateIn:
base: dict[str, object] = {"role_code": "finance", "role_name": "财务"}
base.update(overrides)
return RoleCreateIn(**base)
class TestRoleCodePattern:
"""编码规则:^[a-z][a-z0-9_]{1,31}$"""
def test_合法编码通过(self) -> None:
assert _create().role_code == "finance"
def test_下划线与数字通过(self) -> None:
assert _create(role_code="sales_01").role_code == "sales_01"
def test_大写字母被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="Finance")
def test_中文被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="财务")
def test_数字开头被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="1abc")
def test_下划线开头被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="_abc")
def test_单字符被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="a")
def test_超长被拒(self) -> None:
with pytest.raises(ValidationError):
_create(role_code="a" * 33)
def test_首尾空白被自动去除(self) -> None:
assert _create(role_code=" finance ").role_code == "finance"
class TestRoleUpdate:
"""编辑模型:不含 role_code。"""
def test_编辑不接受role_code(self) -> None:
"""编码是授权判定锚点,编辑接口有意不接收;多余字段被忽略而非报错
(前端表单可能整份回填提交)。"""
payload = RoleUpdateIn(role_name="财务二组", role_code="hacked")
assert not hasattr(payload, "role_code")
def test_名称为空被拒(self) -> None:
with pytest.raises(ValidationError):
RoleUpdateIn(role_name="")
def test_排序负数被拒(self) -> None:
with pytest.raises(ValidationError):
RoleUpdateIn(role_name="财务", sort_order=-1)
class TestRoleStatusAndQuery:
"""状态与查询模型。"""
def test_状态只能为0或1(self) -> None:
assert RoleStatusIn(status=0).status == 0
assert RoleStatusIn(status=1).status == 1
with pytest.raises(ValidationError):
RoleStatusIn(status=2)
def test_查询默认分页(self) -> None:
query = RoleQuery()
assert query.page_num == 1
assert query.page_size == 10
assert query.keyword is None
assert query.status is None
def test_分页别名照常工作(self) -> None:
"""pageNum/pageSize 是前端契约(PageQuery 的 alias)。"""
query = RoleQuery(pageNum=3, pageSize=5)
assert query.offset == 10
def test_关键词超长被拒(self) -> None:
with pytest.raises(ValidationError):
RoleQuery(keyword="a" * 65)