PayrollManagementSystem/backend/README.md
liuwenxin 6219698a39 test(system): M1 参数体系集成用例、e2e 脚本与文档更新
- 集成用例 26 项:岗位角色(CRUD/内置保护/权限)+ 参数管理(目录回溯/变更生命周期/值校验/预览金标准)
- 361 天分界断言按 Fraction 数值比较(4/1000 自动约分为 1/250,值相等)
- e2e param_management.py:7 步全通过(含预览金标准 27 天/9.7‰/145.5 元)
- 结果卡补「计提比例」精确分数行(对账透明)
- 三份 README 更新:接口职责/AI 摘要/M1 用例清单与运行记录/e2e 记录

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 01:05:07 +08:00

166 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 后端
> AI摘要:衍水集团营销人员工资管理系统的后端服务。FastAPI + SQLAlchemy(同步)+ MySQL + Redis,Python 3.12 由 uv 锁定。六层结构 `api / services / repositories / models / schemas / core`。已实现登录鉴权、系统管理(用户/角色/操作日志/日志查询)与 M1 参数体系(岗位角色管理、计提参数版本化/留痕/对比/预览/导出,2026-10-13);业务模块(基础/业务数据、核算、报表)按路线图推进。
## 职责
- 提供 REST 接口(统一前缀 `/api`)
- 登录鉴权:图形验证码、JWT、登录限流、登出黑名单、登录日志
- 系统管理(需求 2.5.1):用户管理、角色管理、岗位角色(#28 可扩展+公式模板绑定)、操作日志(自动审计)、操作与登录日志查询
- 计提参数体系(需求 #28):16 项参数目录、按生效日期版本化(回款到账日取值)、变更批次留痕/对比/导出、计算预览
- 后续承担:基础数据、业务数据、绩效核算、报表统计、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`。