- 调整单金额恒正:schema 正则拦格式(非负两位小数、≤12 位整数防 NUMERIC(14,2) 溢出)+ 服务层 Decimal>0 语义校验——负数会反转补发/ 扣减语义,'abc' 原样入库会以 500 暴露 - 分界天数参数解析:int(parse_fraction(...)) 替代 int(参数原文)—— '360.0' 是 INT 参数的合法入库值(Fraction 分母为 1),直接 int(str) 抛 ValueError 会让整月核算 500 - logo 旧文件清理容错:OSError 只记 warning——裸抛会把成功的 logo 更新 记成失败审计并 500(库中 logo_path 却已是新值,审计与事实相反) - health_check.sh:Redis 探活改容器内环境变量展开(密码不进宿主进程 命令行,模式同 backup_full.sh);compose redis 补 REDIS_PASSWORD - restore_drill.sh:行数核对扩到全部 32 张业务表(原 16 张漏 V7 四表、 二次分配等核心表,备份漏表演练全绿);查询失败回显 '?' 一律判 ✗ (原 '?'=='?' 判一致,表缺失时误报「备份可恢复」) - 并入 88 会话的 #12 实现:manager.personal.included 开关消费 (_build_manager_aggregates 参数化,测试见 828b573) - 测试:调整单金额恒正 5 稿、INT 参数小数原文核算 200 回归;单测 469 Co-Authored-By: Claude Code <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| alembic | ||
| app | ||
| tests | ||
| .env.example | ||
| .python-version | ||
| alembic.ini | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
后端
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) |
分层结构与红线
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/结果/时间范围) |
基础数据接口(需求 2.1,登录即可 —— 业务操作不要求管理员)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET / POST | /api/v1/base/personnel |
人员列表(姓名模糊/状态/岗位筛选,附岗位与经理名)/ 新增(岗位须启用;经理非自己且在职) |
| GET / PUT / DELETE | /api/v1/base/personnel/{id} |
详情(附区域与计划)/ 编辑 / 删除(被团队成员或客户归属引用 → 400 引导改离职) |
| PUT | /api/v1/base/personnel/{id}/status |
在职 / 离职切换 |
| PUT | /api/v1/base/personnel/{id}/regions |
辐射区域全量替换(空列表即清空) |
| PUT | /api/v1/base/personnel/{id}/plans |
市场拓展计划全量替换 |
| GET / POST | /api/v1/base/products |
产品列表(名称/规格模糊、类别/状态筛选)/ 新增(类别值域校验;名称+规格组合唯一) |
| GET / PUT / DELETE | /api/v1/base/products/{id} |
详情 / 编辑 / 删除(被业务明细引用保护在业务表就位后生效) |
| PUT | /api/v1/base/products/{id}/status |
启用 / 停用 |
| GET / POST | /api/v1/base/customers |
客户列表(公司/负责人模糊、分类/归属筛选)/ 新增(分类值域;公司名+负责人唯一) |
| GET / PUT / DELETE | /api/v1/base/customers/{id} |
详情 / 编辑(不触归属)/ 删除 |
| PUT | /api/v1/base/customers/{id}/ownership |
归属变更(同事务写留痕;只影响变更后核算,不回溯历史 #19) |
| GET | /api/v1/base/customers/{id}/ownership-logs |
归属变更留痕分页(join 人员出姓名) |
业务数据接口(需求 2.2,登录即可)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET / POST | /api/v1/business/businesses |
业务列表(编号/公司名模糊、状态/客户/归属/日期筛选)/ 创建(编号 YW{日期}-{序号} 自动生成;明细 ≥1 行;销售额服务层固化) |
| GET / PUT / DELETE | /api/v1/business/businesses/{id} |
详情(附明细)/ 编辑(明细全量替换 + 五项汇总重算;归属不在此端点)/ 删除(有发货或回款 → 400 引导改已终止) |
| PUT | /api/v1/business/businesses/{id}/status |
状态流转(1→2/3;终态不可再变更;终止必填原因;写状态留痕) |
| PUT | /api/v1/business/businesses/{id}/ownership |
负责/协助归属变更(同事务写留痕,owner_type=2) |
| GET | /api/v1/business/businesses/{id}/ownership-logs |
业务归属变更留痕分页 |
| GET | /api/v1/business/businesses/{id}/status-logs |
状态流转时间线(创建写首条 old=new=1) |
| GET | /api/v1/business/businesses/{id}/gross-profit |
毛利润数据视图(只读实时计算,不落库:行级率/折算额 + 类别汇总;不含运费 #2;阈值按 as_of 取参数快照;成本缺失行标注无法计算) |
| GET / POST | /api/v1/business/shipments |
发货单列表 / 创建(编号 SH…;业务须执行中;行金额服务层固化;业务级发货/运费汇总回写) |
| GET / PUT / DELETE | /api/v1/business/shipments/{id} |
详情 / 编辑(终态业务禁改)/ 删除(被回款批次指定 → 400) |
| GET / POST | /api/v1/business/receipts |
回款列表(含 payment_days 派生:发货日首日=第 1 天 #1)/ 新增(编号 HK…;shipment_id 同业务强校验;业务须执行中) |
| GET / PUT / DELETE | /api/v1/business/receipts/{id} |
详情 / 编辑 / 删除(已核算回款禁改禁删——M3 防重锚点) |
| GET / POST | /api/v1/business/rating-grades |
评级等级全量列表(sort_order 升序)/ 新增(区间值域 + 与既有等级重叠 → 400) |
| PUT / DELETE | /api/v1/business/rating-grades/{id} |
编辑(重叠判定)/ 删除(无下游引用,允许清空) |
全部写操作自动写入操作日志(成功与失败都记,机制见下)。
统一响应 {code, message, data},分页为 list/total/pageNum/pageSize。
状态码:400 参数错误 / 401 未登录 / 403 无权限 / 404 不存在 / 500 服务端异常。
登录失败一律 400 或 403,绝不使用 401 —— 前端把 401 一律当作「清 token + 跳登录页」, 若登录接口自身返回 401,用户输错一次密码就会触发「清空 + 跳转 + 报错」的风暴。
常用命令
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,停用只影响新分配、不影响既有授权)。
M2 业务数据的四个关键约定(2026-10-13 定稿,改动前先读)
- 单据编号(
services/no_generator.py):{前缀}{YYYYMMDD}-{当日4位序号},业务 YW / 发货 SH / 回款 HK(core/constants.py的NO_PREFIX_*)。SELECT MAX ... FOR UPDATE锁当日区间防并发重号;残余竞态以唯一键 1062 冲突重试一次兜底(无限重试会掩盖死锁)。序号超 4 位按实际位数输出(VARCHAR(32) 余量足够)。 - 五项汇总冗余(
businesses.total_*):明细/发货/回款的任何写路径统一收口到business_service._recalc_totals单点重算,禁止各处手写 SUM 回写;发货与回款服务写操作前必须get_by_id(for_update=True)锁业务行(防两模块并发写同一业务的丢失更新)。毛利润率/折算额不落库——它们是明细金额 + 时点化阈值的派生值,落库会在成本调整或参数改版后漂移;视图接口(/gross-profit)实时算,M3 核算台账才是法定固化点。 - 留痕表的固化设计:
ownership_change_logs与business_status_logs对客户/业务/人员 ID 均无外键(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。 - 金额契约:schema 入参出参一律
str,小数超两位直接拒绝(不静默舍入);服务层Decimal,比率Fraction;毛利润率出参为分数原样字符串("997/1000",同 M1 计算预览口径),前端原样展示禁止转 float。
常见问题
起服务报 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。