- 操作日志审计机制:audit 上下文(成功与失败都记、独立提交、操作人标量 固化、commit 后查询前置约定),用户/角色全部写操作接入;request_data 按键名的结构化脱敏 + UTF-8 字节边界截断 - 角色管理:CRUD 与启停;admin 禁删禁停、有绑定禁删(FOR UPDATE 锁行)、 编码创建后不可改、停用角色不可再分配 - 日志查询:操作/登录两端点,列表与详情两段式(详情含已脱敏请求参数); 时间参数统一转本地 naive(修混用时区 500 与偏移丢弃) - 用户管理:补齐 reset_password 操作人、编辑空串归一 NULL、并发唯一键兜底 400 - 测试 301 例全绿(单元 223 / 集成 78) Co-Authored-By: Claude Code <noreply@anthropic.com>
165 lines
11 KiB
Markdown
165 lines
11 KiB
Markdown
# 后端
|
||
|
||
> AI摘要:衍水集团营销人员工资管理系统的后端服务。FastAPI + SQLAlchemy(同步)+ MySQL + Redis,Python 3.12 由 uv 锁定。六层结构 `api / services / repositories / models / schemas / core`。当前实现登录鉴权与系统管理(用户/角色/操作日志/日志查询),业务模块待需求澄清后开发。
|
||
|
||
## 职责
|
||
|
||
- 提供 REST 接口(统一前缀 `/api`)
|
||
- 登录鉴权:图形验证码、JWT、登录限流、登出黑名单、登录日志
|
||
- 系统管理(需求 2.5.1):用户管理、角色管理、操作日志(自动审计)、操作与登录日志查询
|
||
- 后续承担:基础数据、业务数据、绩效核算、报表统计、T+ 数据同步
|
||
|
||
## 技术栈与版本
|
||
|
||
| 组件 | 版本 | 说明 |
|
||
| --- | --- | --- |
|
||
| Python | 3.12 | uv 锁定。系统默认是 3.14,**不要用**——第三方库轮子覆盖不如 3.12 完备 |
|
||
| FastAPI / Starlette | 0.143 / 1.7 | `starlette` 显式限定 `<2`:FastAPI 对它的约束无上界,升级时可能漂移 |
|
||
| SQLAlchemy | 2.1 | **同步**引擎,路由写 `def` 由 FastAPI 放入线程池 |
|
||
| PyMySQL | 1.2 | **必须带 `[rsa]`**:MySQL 8.0 默认 `caching_sha2_password`,缺 `cryptography` 时首次连接抛 `RuntimeError`,且报错在建连阶段、极易误读为「密码错误」 |
|
||
| alembic | 1.20 | 迁移;运行期与迁移共用同一份 `mysql+pymysql` 连接串 |
|
||
| redis | 8.1 | **同步**客户端 |
|
||
| pwdlib | 0.3 | 密码哈希。**不用 passlib**(已停更且与 bcrypt ≥4.1 不兼容)。算法**显式指定** BcryptHasher,不用 `recommended()` |
|
||
| captcha | 0.7 | **仅用其内置字体文件**,渲染已自绘(见 `core/captcha_image.py`) |
|
||
|
||
## 分层结构与红线
|
||
|
||
```text
|
||
app/
|
||
├── api/ HTTP 层:路由、依赖注入、参数校验
|
||
│ ├── deps.py 公共依赖:get_db、get_current_user、get_raw_token
|
||
│ └── v1/ 版本化业务路由
|
||
├── services/ 业务编排 + **唯一事务边界**(commit/rollback 只在此层)
|
||
├── repositories/ 数据访问,**只 flush 不 commit**
|
||
├── models/ ORM 映射,不含业务逻辑
|
||
├── schemas/ 请求/响应模型,禁止把 ORM 对象直接当 response_model
|
||
└── core/ 横切基础设施:配置、安全、连接、异常、响应、日志
|
||
```
|
||
|
||
| 层 | 可以做 | **不可做** |
|
||
| --- | --- | --- |
|
||
| `api/` | 参数绑定、调 service、返回统一响应 | 写业务判断、直接拼 SQL |
|
||
| `services/` | 编排多个 repository、提交事务 | — |
|
||
| `repositories/` | 读写数据、flush | `commit()`、抛业务异常 |
|
||
| `models/` | 表映射 | 业务逻辑 |
|
||
| `core/` | 基础设施 | 业务规则 |
|
||
|
||
## 核心接口
|
||
|
||
| 方法 | 路径 | 认证 | 说明 |
|
||
| --- | --- | :---: | --- |
|
||
| GET | `/api/health` | 否 | 探活,上报 app/db/redis 三项状态 |
|
||
| GET | `/api/v1/auth/captcha` | 否 | 签发图形验证码,返回 id + 图片 base64 |
|
||
| POST | `/api/v1/auth/login` | 否 | 账号 + 密码 + 验证码 → JWT |
|
||
| 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 服务端异常。
|
||
|
||
**登录失败一律 400 或 403,绝不使用 401** —— 前端把 401 一律当作「清 token + 跳登录页」,
|
||
若登录接口自身返回 401,用户输错一次密码就会触发「清空 + 跳转 + 报错」的风暴。
|
||
|
||
## 常用命令
|
||
|
||
```bash
|
||
uv sync # 安装依赖
|
||
uv run uvicorn app.main:app --reload --port 8000 # 起服务(开发务必加 --reload)
|
||
uv run alembic upgrade head # 应用迁移
|
||
uv run alembic revision --autogenerate -m "说明" # 生成迁移
|
||
uv run python -m app.cli create-admin # 建管理员(幂等,已存在不覆盖)
|
||
uv run pytest tests/unit -q # 纯单元测试,无需容器
|
||
uv run pytest -q # 全部测试
|
||
uv run ruff check . # 代码检查
|
||
uv run ruff check --fix . # 代码检查并自动修复可修项
|
||
uv run ruff format . # 格式化代码
|
||
```
|
||
|
||
Python 版本由 `.python-version` 与 `pyproject.toml` 的 `requires-python` 双重锁定。
|
||
|
||
### 代码检查与格式化
|
||
|
||
配置在 `pyproject.toml` 的 `[tool.ruff]`。三处**刻意关闭**的规则,不要误以为是漏配:
|
||
|
||
| 规则 | 关闭原因 |
|
||
| --- | --- |
|
||
| `E501` 行宽 | 已由 formatter 控制,lint 重复报无意义;长中文注释极易触发 |
|
||
| `B008` 参数默认值调用 | FastAPI 依赖注入要求在参数默认值处写 `Depends()`,是框架约定而非缺陷 |
|
||
| `RUF001/002/003` 易混淆 Unicode | 该规则默认针对英文文本,会把中文的全角逗号、句号、括号全部误报(本项目实测 800+ 条)。中文里全角标点正是规范写法 |
|
||
|
||
另有 `known-first-party = ["app", "tests"]`:本项目 `[tool.uv] package = false`(不作为包安装),不显式声明的话 ruff 无法识别 `app` 是第一方包,会把第三方依赖排到它后面。
|
||
|
||
## 关键实现约定
|
||
|
||
### JWT 时间一律用 UTC
|
||
|
||
`exp` 是 UTC 时间戳,而库列存本地时间(容器 `TZ=Asia/Shanghai`)。混算会产生 8 小时偏差,
|
||
表现为「刚登录就过期」或「过期了还能用」。库列用 `datetime.now()`,JWT 用 `datetime.now(timezone.utc)`。
|
||
|
||
### 「离职即失效」用 `users.token_version`,不依赖 Redis
|
||
|
||
禁用 / 改密 / 强制下线只需 `UPDATE users SET token_version = token_version + 1`。
|
||
鉴权时比对 JWT 的 `ver` 声明与库中值,Redis 故障时依然生效。
|
||
Redis 黑名单只负责「用户主动登出」。
|
||
|
||
### Redis 键 TTL 由业务语义决定,不要套用固定值
|
||
|
||
| 键 | TTL | 依据 |
|
||
| --- | --- | --- |
|
||
| `ys:captcha:{id}` | 300s | 规范要求 |
|
||
| `ys:login:fail:{username}` | 900s | = 锁定时长,写死会导致锁定提前失效 |
|
||
| `ys:token:blacklist:{jti}` | 令牌剩余有效期 | 写死会导致「登出 N 分钟后令牌又可用」 |
|
||
|
||
### 验证码用 `GETDEL` 原子取出即删
|
||
|
||
写成「先 GET 再 DEL」存在竞态窗口,并发请求可用同一验证码重复提交。
|
||
|
||
### 密码按字节数校验,不按字符数
|
||
|
||
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 是安全问题,必须启动即失败。
|
||
|
||
**连数据库抛 `RuntimeError: 'cryptography' package is required`** —— 依赖被改回不带 `[rsa]` 的 `pymysql`。装回 `pymysql[rsa]`。
|
||
|
||
**`alembic upgrade head` 报表已存在** —— 开发库是 `init-dev.sql` 建的、`alembic_version` 未写入。手工补版本记录,或 `down -v` 重建。
|
||
|
||
**改了代码不生效** —— 启动时没加 `--reload`。
|