diff --git a/.gitignore b/.gitignore index 6f27c66..2ffbab2 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,4 @@ frontend/e2e/*.png # 备份 backup/ +.claude diff --git a/backend/README.md b/backend/README.md index 4300b80..5a0c45d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 是安全问题,必须启动即失败。 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 3dba999..e867ba0 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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: diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 67b1529..5a89cf7 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 62150c4..07aced3 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -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="登录成功") diff --git a/backend/app/api/v1/system/__init__.py b/backend/app/api/v1/system/__init__.py index 6e2813b..add9495 100644 --- a/backend/app/api/v1/system/__init__.py +++ b/backend/app/api/v1/system/__init__.py @@ -1,6 +1,6 @@ """系统管理路由(需求 2.5.1)。 -含用户管理、角色查询、操作日志查询三部分,全部要求管理员权限。 +含用户管理、角色管理、日志查询(操作日志 + 登录日志)四部分,全部要求管理员权限。 与 `api/v1/auth.py` 的分工:`auth` 是登录链路(未认证即可访问的验证码与登录, 以及任何登录用户都能访问的 `/me` 与登出);本包是**管理功能**。 diff --git a/backend/app/api/v1/system/logs.py b/backend/app/api/v1/system/logs.py new file mode 100644 index 0000000..74b673c --- /dev/null +++ b/backend/app/api/v1/system/logs.py @@ -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)) diff --git a/backend/app/api/v1/system/roles.py b/backend/app/api/v1/system/roles.py new file mode 100644 index 0000000..cfbbbc1 --- /dev/null +++ b/backend/app/api/v1/system/roles.py @@ -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="角色已删除") diff --git a/backend/app/api/v1/system/users.py b/backend/app/api/v1/system/users.py index c9e6432..ab71157 100644 --- a/backend/app/api/v1/system/users.py +++ b/backend/app/api/v1/system/users.py @@ -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 "已禁用") diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py index 5074c0d..fc44fd9 100644 --- a/backend/app/core/constants.py +++ b/backend/app/core/constants.py @@ -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 = "禁用" diff --git a/backend/app/models/operation_log.py b/backend/app/models/operation_log.py index fbce11b..23a15ff 100644 --- a/backend/app/models/operation_log.py +++ b/backend/app/models/operation_log.py @@ -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( diff --git a/backend/app/repositories/login_log_repository.py b/backend/app/repositories/login_log_repository.py index 5c230bc..122e9e6 100644 --- a/backend/app/repositories/login_log_repository.py +++ b/backend/app/repositories/login_log_repository.py @@ -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 diff --git a/backend/app/repositories/operation_log_repository.py b/backend/app/repositories/operation_log_repository.py new file mode 100644 index 0000000..977feb4 --- /dev/null +++ b/backend/app/repositories/operation_log_repository.py @@ -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") diff --git a/backend/app/repositories/query_utils.py b/backend/app/repositories/query_utils.py new file mode 100644 index 0000000..bba28b7 --- /dev/null +++ b/backend/app/repositories/query_utils.py @@ -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 diff --git a/backend/app/repositories/role_repository.py b/backend/app/repositories/role_repository.py new file mode 100644 index 0000000..cdd2c13 --- /dev/null +++ b/backend/app/repositories/role_repository.py @@ -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() diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py index c99c3bc..6b18d5b 100644 --- a/backend/app/repositories/user_repository.py +++ b/backend/app/repositories/user_repository.py @@ -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()) diff --git a/backend/app/schemas/log.py b/backend/app/schemas/log.py new file mode 100644 index 0000000..79ce87e --- /dev/null +++ b/backend/app/schemas/log.py @@ -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 diff --git a/backend/app/schemas/role.py b/backend/app/schemas/role.py new file mode 100644 index 0000000..e31ad33 --- /dev/null +++ b/backend/app/schemas/role.py @@ -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="状态筛选") diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index be21775..151f900 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -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 diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py new file mode 100644 index 0000000..dcb80c1 --- /dev/null +++ b/backend/app/services/audit_service.py @@ -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)) diff --git a/backend/app/services/log_service.py b/backend/app/services/log_service.py new file mode 100644 index 0000000..3ff945f --- /dev/null +++ b/backend/app/services/log_service.py @@ -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, + ) diff --git a/backend/app/services/role_service.py b/backend/app/services/role_service.py new file mode 100644 index 0000000..dd73912 --- /dev/null +++ b/backend/app/services/role_service.py @@ -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) diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 8811a49..f679e00 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -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) diff --git a/backend/tests/README.md b/backend/tests/README.md index cc75c6a..28151c8 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -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 完成并补齐用例,见上表。 diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py new file mode 100644 index 0000000..be9fe07 --- /dev/null +++ b/backend/tests/integration/conftest.py @@ -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 diff --git a/backend/tests/integration/test_log_query.py b/backend/tests/integration/test_log_query.py new file mode 100644 index 0000000..29206bd --- /dev/null +++ b/backend/tests/integration/test_log_query.py @@ -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 diff --git a/backend/tests/integration/test_role_management.py b/backend/tests/integration/test_role_management.py new file mode 100644 index 0000000..64b9a21 --- /dev/null +++ b/backend/tests/integration/test_role_management.py @@ -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 diff --git a/backend/tests/integration/test_user_management.py b/backend/tests/integration/test_user_management.py new file mode 100644 index 0000000..a48a61f --- /dev/null +++ b/backend/tests/integration/test_user_management.py @@ -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 diff --git a/backend/tests/unit/test_audit_serialization.py b/backend/tests/unit/test_audit_serialization.py new file mode 100644 index 0000000..9f10873 --- /dev/null +++ b/backend/tests/unit/test_audit_serialization.py @@ -0,0 +1,128 @@ +"""操作日志入库前处理单元测试。 + +守的是入库前的**截断与脱敏**: + +- 截断:各列超长在 MySQL 严格模式下抛 1406,而「审计失败不影响业务」的约定 + 会把该异常吞掉 —— 整条日志静默消失;request_data 尤甚,中文一个字符 + 占 3 字节,必须按 **UTF-8 字节边界**切,否则会切出乱码残字 +- 脱敏:密码是最容易从日志泄漏的凭据,request_data 入库前必经 scrub +""" + +import json +from datetime import datetime + +from app.models.operation_log import ( + ERROR_MESSAGE_MAX_LEN, + REQUEST_DATA_MAX_BYTES, + SUMMARY_MAX_LEN, +) +from app.repositories.operation_log_repository import _clip, _serialize_request_data + + +class TestClip: + """按字符数截断(长度常量与数据库列宽均按字符定义)。""" + + def test_超长截断至上限(self) -> None: + result = _clip("x" * (SUMMARY_MAX_LEN + 50), SUMMARY_MAX_LEN) + assert result is not None + assert len(result) == SUMMARY_MAX_LEN + + def test_未超长原样返回(self) -> None: + assert _clip("正常长度", SUMMARY_MAX_LEN) == "正常长度" + + def test_空值返回None(self) -> None: + assert _clip(None, ERROR_MESSAGE_MAX_LEN) is None + assert _clip("", ERROR_MESSAGE_MAX_LEN) is None + + def test_中文按字符数截断不切字(self) -> None: + """str 切片天然按字符,不会切出半个汉字。""" + result = _clip("中" * 100, 10) + assert result == "中" * 10 + + +class TestSerializeRequestData: + """序列化:JSON 化 → 脱敏 → 字节边界截断。""" + + def test_常规参数原样序列化(self) -> None: + text = _serialize_request_data({"status": 0, "roles": ["user"]}) + assert text is not None + assert json.loads(text) == {"status": 0, "roles": ["user"]} + + def test_空值返回None(self) -> None: + assert _serialize_request_data(None) is None + assert _serialize_request_data({}) is None + + def test_密码值被抹除而键名保留(self) -> None: + """键名保留便于审计阅读"提交过密码字段",值必须抹掉。""" + for payload in ({"password": "S3cret@2026"}, {"new_password": "S3cret@2026"}): + text = _serialize_request_data(payload) or "" + assert "S3cret@2026" not in text + assert "***" in text + + def test_手机号被脱敏(self) -> None: + text = _serialize_request_data({"phone": "13812345678"}) or "" + assert "13812345678" not in text + assert "138****5678" in text + + def test_超长中文按字节边界截断且无残字(self) -> None: + """75000 字节(25000 个汉字)必然超限: + 若按字符截断则字节数三倍超标;若按字节硬切可能切出半个字符。 + 正确结果应是原始 JSON 串的**干净前缀**。""" + payload = {"remark": "中" * 25000} + text = _serialize_request_data(payload) + assert text is not None + assert len(text.encode("utf-8")) <= REQUEST_DATA_MAX_BYTES + assert "�" not in text # 无替换字符 + raw = json.dumps(payload, ensure_ascii=False) + assert raw.startswith(text) + + def test_未超长的中文完整保留(self) -> None: + payload = {"remark": "中" * 100} + text = _serialize_request_data(payload) + assert text is not None + assert json.loads(text) == payload + + def test_不可JSON序列化的值降级为字符串(self) -> None: + text = _serialize_request_data({"at": datetime(2026, 10, 10, 12, 0, 0)}) + assert text is not None + assert "2026-10-10" in text + + +class TestStructuredSanitization: + """结构化抹除 —— 按键名整值替换,不依赖文本正则。 + + 回归背景:此前用文本正则脱敏,有三种逃逸形态 —— 值以空白开头时 + 整体匹配失败(**完全不脱敏**);值含空格/逗号时只抹首段;值含引号时 + 既残留明文又破坏 JSON 结构。密码校验只查字节数、前后端都不 trim, + 这些形态都是正常可用密码。 + """ + + def test_前导空白密码被完全抹除(self) -> None: + """回归:此前 `" abc12345"` 原样明文入库。""" + text = _serialize_request_data({"new_password": " abc12345"}) or "" + assert "abc12345" not in text + assert json.loads(text) == {"new_password": "***"} + + def test_含空格逗号引号均整体抹除且JSON完整(self) -> None: + for password in ("abc 12345", "abc,def", 'abc"def', "abc&def"): + text = _serialize_request_data({"password": password}) or "" + assert password not in text + assert json.loads(text) == {"password": "***"} + + def test_嵌套结构与键名大小写(self) -> None: + text = ( + _serialize_request_data({"user": {"New_Password": "s3"}, "list": [{"TOKEN": "t"}]}) + or "" + ) + assert "s3" not in text + assert json.loads(text) == {"user": {"New_Password": "***"}, "list": [{"TOKEN": "***"}]} + + def test_非敏感键不受影响(self) -> None: + text = _serialize_request_data({"keyword": "a_b", "remark": "正常备注"}) or "" + assert json.loads(text) == {"keyword": "a_b", "remark": "正常备注"} + + def test_孤立代理字符不再抛错(self) -> None: + """回归:直接 encode('utf-8') 遇 \\ud800 抛 UnicodeEncodeError, + 该请求的审计记录会被兜底逻辑吞掉、静默丢失。""" + text = _serialize_request_data({"username": "a\ud800b"}) + assert text is not None diff --git a/backend/tests/unit/test_audit_service.py b/backend/tests/unit/test_audit_service.py new file mode 100644 index 0000000..8765df3 --- /dev/null +++ b/backend/tests/unit/test_audit_service.py @@ -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 "") diff --git a/backend/tests/unit/test_log_schemas.py b/backend/tests/unit/test_log_schemas.py new file mode 100644 index 0000000..89ade25 --- /dev/null +++ b/backend/tests/unit/test_log_schemas.py @@ -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 diff --git a/backend/tests/unit/test_role_schemas.py b/backend/tests/unit/test_role_schemas.py new file mode 100644 index 0000000..0345558 --- /dev/null +++ b/backend/tests/unit/test_role_schemas.py @@ -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)